mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-13 20:01:05 +00:00
Compare commits
26 Commits
feat/input
...
feat/servi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75b6be358a | ||
|
|
16eb36aeed | ||
|
|
4edc3d37ca | ||
|
|
f041a2e0a5 | ||
|
|
9c31b3c90f | ||
|
|
d8b91c12ae | ||
|
|
a34be3f63f | ||
|
|
97e06f658e | ||
|
|
4ab56c4f2a | ||
|
|
86856c6714 | ||
|
|
dd880f695f | ||
|
|
5cb1f33525 | ||
|
|
3851339e6c | ||
|
|
a4dde27546 | ||
|
|
0402254209 | ||
|
|
4225452554 | ||
|
|
50dfec7247 | ||
|
|
ce1117eaa0 | ||
|
|
2eb46f19fb | ||
|
|
e37e94af53 | ||
|
|
f060e262d2 | ||
|
|
27a11ada95 | ||
|
|
a4d83ea2fd | ||
|
|
2e4d47a124 | ||
|
|
5a62bcd85c | ||
|
|
9fef8eea69 |
@@ -11,7 +11,7 @@ export const ServicesTablePage = Loadable(
|
||||
export const ServiceMetricsPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "ServiceMetricsPage" */ 'pages/MetricsApplication/MetricsApplication'
|
||||
/* webpackChunkName: "ServiceMetricsPage" */ 'pages/ServiceDetails/ServiceDetails'
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ApiV4Instance } from 'api';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { MetricMetaProps } from 'types/api/metrics/getApDex';
|
||||
|
||||
export const getMetricMeta = (
|
||||
metricName: string,
|
||||
servicename: string,
|
||||
): Promise<AxiosResponse<MetricMetaProps>> =>
|
||||
ApiV4Instance.get(
|
||||
`/metric/metric_metadata?metricName=${metricName}&serviceName=${servicename}`,
|
||||
);
|
||||
@@ -1,27 +0,0 @@
|
||||
import { ApiV2Instance } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/metrics/getTopOperations';
|
||||
|
||||
const getTopOperations = async (props: Props): Promise<PayloadProps> => {
|
||||
try {
|
||||
const endpoint = props.isEntryPoint
|
||||
? '/service/entry_point_operations'
|
||||
: '/service/top_operations';
|
||||
|
||||
const response = await ApiV2Instance.post(endpoint, {
|
||||
start: `${props.start}`,
|
||||
end: `${props.end}`,
|
||||
service: props.service,
|
||||
tags: props.selectedTags,
|
||||
limit: 5000,
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default getTopOperations;
|
||||
@@ -3,24 +3,4 @@
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-0) var(--spacing-0);
|
||||
//TODO: remove this once we have a proper dashboard page.
|
||||
// The embedded V2 DashboardContainer renders its own page header (dashboard
|
||||
// title + share/feedback chrome) removed it for now
|
||||
:global([class*='dashboardPageHeader']),
|
||||
:global([class*='dashboardInfoWithActions']) {
|
||||
display: none;
|
||||
}
|
||||
// Embedded read-only: the add-variable and panel-action triggers are dead here.
|
||||
:global([class*='addSlot']),
|
||||
:global([data-testid^='panel-actions-']) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.errorState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import Spinner from 'components/Spinner';
|
||||
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
|
||||
import SystemDashboard from 'container/SystemDashboard/SystemDashboard';
|
||||
import { createSystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
|
||||
import { CHARTS_ONLY } from 'pages/DashboardPage/DashboardContainer/controls/presets';
|
||||
|
||||
import { useSystemDashboard } from './hooks/useSystemDashboard';
|
||||
import styles from './Overview.module.scss';
|
||||
|
||||
function Overview(): JSX.Element {
|
||||
const { dashboard, isLoading, isError, error, refetch } = useSystemDashboard();
|
||||
const AI_OVERVIEW = createSystemDashboardHost(
|
||||
'ai-o11y-overview',
|
||||
'llm-observability-overview',
|
||||
);
|
||||
|
||||
function Overview(): JSX.Element {
|
||||
useEffect(() => {
|
||||
void logEvent('AI Observability Overview: Page visited', {});
|
||||
}, []);
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
if (isError || !dashboard) {
|
||||
return (
|
||||
<div className={styles.errorState}>
|
||||
<Typography.Title>Failed to load dashboard</Typography.Title>
|
||||
<Typography.Text>
|
||||
{error?.response?.data?.error?.message ?? error?.message}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardContainer
|
||||
dashboard={dashboard}
|
||||
refetch={refetch}
|
||||
canEditDashboardOverride={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overview} data-testid="llm-observability-overview">
|
||||
{renderContent()}
|
||||
<SystemDashboard host={AI_OVERVIEW} controls={CHARTS_ONLY} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
useGetSystemDashboard,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { GetSystemDashboardQueryError } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const SYSTEM_DASHBOARD_NAME = 'ai-o11y-overview';
|
||||
|
||||
const DASHBOARD_ID = 'llm-observability-overview';
|
||||
|
||||
export interface UseSystemDashboardResult {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: GetSystemDashboardQueryError | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useSystemDashboard(): UseSystemDashboardResult {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, isError, error, refetch } = useGetSystemDashboard(
|
||||
{ name: SYSTEM_DASHBOARD_NAME },
|
||||
{ query: { staleTime: Infinity, refetchOnMount: false } },
|
||||
);
|
||||
|
||||
const dashboard = useMemo(
|
||||
() =>
|
||||
data
|
||||
? ({
|
||||
...data.data,
|
||||
id: DASHBOARD_ID,
|
||||
} as DashboardtypesGettableDashboardV2DTO)
|
||||
: undefined,
|
||||
[data],
|
||||
);
|
||||
|
||||
if (dashboard) {
|
||||
queryClient.setQueryData<GetDashboardV2200>(
|
||||
getGetDashboardV2QueryKey({ id: DASHBOARD_ID }),
|
||||
{ data: dashboard, status: 'success' },
|
||||
);
|
||||
}
|
||||
|
||||
const refetchDashboard = useCallback((): void => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return { dashboard, isLoading, isError, error, refetch: refetchDashboard };
|
||||
}
|
||||
@@ -31,6 +31,8 @@ jest.mock('@floating-ui/dom', () => ({
|
||||
Promise.resolve({ x: 0, y: 0, placement, strategy, middlewareData: {} }),
|
||||
}));
|
||||
|
||||
jest.setTimeout(15_000);
|
||||
|
||||
const VIEW_1_ORDER_BY = JSON.stringify({ columnName: 'name', order: 'asc' });
|
||||
const VIEW_1_QUERY = "severity = 'critical'";
|
||||
|
||||
|
||||
@@ -2,77 +2,14 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.meter-explorer-date-time {
|
||||
display: flex;
|
||||
min-height: 30px;
|
||||
justify-content: end;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.meter-explorer-graphs {
|
||||
.meter-explorer-notices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
gap: 36px;
|
||||
gap: 12px;
|
||||
padding: 16px 20px 0;
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meter-column-graph {
|
||||
.row-card {
|
||||
background-color: var(--l2-background);
|
||||
padding-left: 10px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.section-title {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.graph-description {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meter-page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
.meter-graph {
|
||||
height: 400px;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.total {
|
||||
.meter-column-graph {
|
||||
.meter-page-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
.meter-graph {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,121 +1,30 @@
|
||||
import { useCallback } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Alert } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import GridCard from 'container/WidgetCard/Card';
|
||||
import { Card, CardContainer } from 'container/WidgetCard/styles';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import SystemDashboard from 'container/SystemDashboard/SystemDashboard';
|
||||
import { createSystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
getLogCountWidgetData,
|
||||
getLogSizeWidgetData,
|
||||
getMetricCountWidgetData,
|
||||
getSpanCountWidgetData,
|
||||
getSpanSizeWidgetData,
|
||||
getTotalLogSizeWidgetData,
|
||||
getTotalMetricDatapointCountWidgetData,
|
||||
getTotalTraceSizeWidgetData,
|
||||
} from './graphs';
|
||||
CHARTS_ONLY,
|
||||
withControls,
|
||||
} from 'pages/DashboardPage/DashboardContainer/controls/presets';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import './BreakDown.styles.scss';
|
||||
|
||||
type MetricSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
graphs: Widgets[];
|
||||
};
|
||||
const COST_METER = createSystemDashboardHost(
|
||||
'cost-meter-breakdown',
|
||||
'cost-meter-breakdown',
|
||||
);
|
||||
|
||||
const sections: MetricSection[] = [
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Total',
|
||||
graphs: [
|
||||
getTotalLogSizeWidgetData(),
|
||||
getTotalTraceSizeWidgetData(),
|
||||
getTotalMetricDatapointCountWidgetData(),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Logs',
|
||||
graphs: [getLogCountWidgetData(), getLogSizeWidgetData()],
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Traces',
|
||||
graphs: [getSpanCountWidgetData(), getSpanSizeWidgetData()],
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Metrics',
|
||||
graphs: [getMetricCountWidgetData()],
|
||||
},
|
||||
];
|
||||
|
||||
function Section(section: MetricSection): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { title, graphs } = section;
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number) => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="meter-column-graph"
|
||||
data-testid={`meter-breakdown-section-${title.toLowerCase()}`}
|
||||
>
|
||||
<CardContainer className="row-card" isDarkMode={isDarkMode}>
|
||||
<Typography.Text className="section-title">{title}</Typography.Text>
|
||||
</CardContainer>
|
||||
<div className="meter-page-grid">
|
||||
{graphs.map((widget) => (
|
||||
<Card
|
||||
key={widget?.id}
|
||||
isDarkMode={isDarkMode}
|
||||
$panelType={PANEL_TYPES.BAR}
|
||||
className="meter-graph"
|
||||
>
|
||||
<GridCard widget={widget} onDragSelect={onDragSelect} version="v5" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const CONTROLS = withControls(CHARTS_ONLY, {
|
||||
toolbar: { autoRefresh: false },
|
||||
});
|
||||
|
||||
function BreakDown(): JSX.Element {
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
@@ -133,12 +42,10 @@ function BreakDown(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="meter-explorer-breakdown">
|
||||
<section className="meter-explorer-date-time">
|
||||
<DateTimeSelectionV2 showAutoRefresh={false} />
|
||||
</section>
|
||||
<section className="meter-explorer-graphs">
|
||||
<section className="meter-explorer-notices">
|
||||
{showInfo && (
|
||||
<Alert
|
||||
data-testid="cost-meter-utc-notice"
|
||||
type="info"
|
||||
showIcon
|
||||
closable
|
||||
@@ -179,28 +86,8 @@ function BreakDown(): JSX.Element {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<section className="total">
|
||||
<Section
|
||||
id={sections[0].id}
|
||||
title={sections[0].title}
|
||||
graphs={sections[0].graphs}
|
||||
/>
|
||||
</section>
|
||||
{sections.map((section, idx) => {
|
||||
if (idx === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
key={section.id}
|
||||
id={section.id}
|
||||
title={section.title}
|
||||
graphs={section.graphs}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
<SystemDashboard host={COST_METER} controls={CONTROLS} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { DashboardControlsInput } from 'pages/DashboardPage/DashboardContainer/controls/types';
|
||||
import { resolveControls } from 'pages/DashboardPage/DashboardContainer/controls/dashboardControls';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import type { SystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
|
||||
|
||||
import BreakDown from '../BreakDown';
|
||||
|
||||
jest.mock('container/SystemDashboard/SystemDashboard', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
host,
|
||||
controls,
|
||||
}: {
|
||||
host: SystemDashboardHost;
|
||||
controls: DashboardControlsInput;
|
||||
}): JSX.Element => (
|
||||
<div
|
||||
data-testid="system-dashboard"
|
||||
data-name={host.name}
|
||||
data-controls={JSON.stringify(controls)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('Cost meter breakdown', () => {
|
||||
it('mounts the cost meter system dashboard as charts only, without auto-refresh', () => {
|
||||
render(<BreakDown />);
|
||||
|
||||
const dashboard = screen.getByTestId('system-dashboard');
|
||||
expect(dashboard).toHaveAttribute('data-name', 'cost-meter-breakdown');
|
||||
const controls = resolveControls(
|
||||
JSON.parse(dashboard.getAttribute('data-controls') ?? ''),
|
||||
true,
|
||||
);
|
||||
expect(controls.header.breadcrumbs).toBe(false);
|
||||
expect(controls.panelActions.createAlert).toBe(false);
|
||||
expect(controls.panelActions.drilldown).toBe(false);
|
||||
expect(controls.panelActions.zoom).toBe(true);
|
||||
expect(controls.toolbar.autoRefresh).toBe(false);
|
||||
expect(controls.toolbar.timeRange).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the UTC billing notice above the dashboard', () => {
|
||||
render(<BreakDown />);
|
||||
|
||||
expect(screen.getByTestId('cost-meter-utc-notice')).toHaveTextContent(
|
||||
'Billing is calculated in UTC',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,374 +0,0 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetWidgetQueryBuilderProps } from 'container/MetricsApplication/types';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
interface GetWidgetQueryProps {
|
||||
title: string;
|
||||
description: string;
|
||||
queryData: IBuilderQuery[];
|
||||
queryFormulas?: IBuilderFormula[];
|
||||
panelTypes?: PANEL_TYPES;
|
||||
yAxisUnit?: string;
|
||||
columnUnits?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface GetWidgetQueryPropsReturn extends GetWidgetQueryBuilderProps {
|
||||
description?: string;
|
||||
nullZeroValues: string;
|
||||
columnUnits?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const getWidgetQueryBuilder = ({
|
||||
query,
|
||||
title = '',
|
||||
panelTypes,
|
||||
yAxisUnit = '',
|
||||
fillSpans = false,
|
||||
id,
|
||||
nullZeroValues,
|
||||
description,
|
||||
}: GetWidgetQueryPropsReturn): Widgets => ({
|
||||
description: description || '',
|
||||
id: id || uuid(),
|
||||
nullZeroValues: nullZeroValues || '',
|
||||
opacity: '1',
|
||||
panelTypes,
|
||||
query,
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
title,
|
||||
yAxisUnit,
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
selectedLogFields: [],
|
||||
selectedTracesFields: [],
|
||||
fillSpans,
|
||||
});
|
||||
|
||||
export function getWidgetQuery(
|
||||
props: GetWidgetQueryProps,
|
||||
): GetWidgetQueryPropsReturn {
|
||||
const { title, description, panelTypes, yAxisUnit, columnUnits } = props;
|
||||
return {
|
||||
title,
|
||||
yAxisUnit: yAxisUnit || 'none',
|
||||
panelTypes: panelTypes || PANEL_TYPES.TIME_SERIES,
|
||||
fillSpans: false,
|
||||
description,
|
||||
nullZeroValues: 'zero',
|
||||
columnUnits,
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: {
|
||||
queryData: props.queryData,
|
||||
queryFormulas: (props.queryFormulas as IBuilderFormula[]) || [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
};
|
||||
}
|
||||
export const getTotalLogSizeWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.log.size',
|
||||
id: 'signoz.meter.log.size--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.SUM,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Total size of log records ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.VALUE,
|
||||
yAxisUnit: 'bytes',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getTotalTraceSizeWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.span.size',
|
||||
id: 'signoz.meter.span.size--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.SUM,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Total size of spans ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.VALUE,
|
||||
yAxisUnit: 'bytes',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getTotalMetricDatapointCountWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.metric.datapoint.count',
|
||||
id: 'signoz.meter.metric.datapoint.count--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.SUM,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Total metric datapoints ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.VALUE,
|
||||
yAxisUnit: 'short',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getLogCountWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.log.count',
|
||||
id: 'signoz.meter.log.count--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Count of log records ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'short',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getLogSizeWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.log.size',
|
||||
id: 'signoz.meter.log.size--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'size',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Size of log records ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'bytes',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getSpanCountWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.span.count',
|
||||
id: 'signoz.meter.span.count--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Count of spans ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'short',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getSpanSizeWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.span.size',
|
||||
id: 'signoz.meter.span.size--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'size',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Size of spans ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'bytes',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getMetricCountWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.metric.datapoint.count',
|
||||
id: 'signoz.meter.metric.datapoint.count--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Count of metric datapoints ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'short',
|
||||
}),
|
||||
);
|
||||
@@ -1,136 +0,0 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
DataSource,
|
||||
MetricAggregateOperator,
|
||||
QueryBuilderData,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
import { FORMULA, MetricsType, WidgetKeys } from '../constant';
|
||||
import { DatabaseCallProps, DatabaseCallsRPSProps } from '../types';
|
||||
import {
|
||||
getQueryBuilderQueries,
|
||||
getQueryBuilderQuerieswithFormula,
|
||||
} from './MetricsPageQueriesFactory';
|
||||
|
||||
export const databaseCallsRPS = ({
|
||||
servicename,
|
||||
legend,
|
||||
tagFilterItems,
|
||||
}: DatabaseCallsRPSProps): QueryBuilderData => {
|
||||
const autocompleteData: BaseAutocompleteData[] = [
|
||||
{
|
||||
key: WidgetKeys.SignozDBLatencyCount,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
},
|
||||
];
|
||||
const groupBy: BaseAutocompleteData[] = [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
key: WidgetKeys.DbSystem,
|
||||
type: 'tag',
|
||||
},
|
||||
];
|
||||
const filterItems: TagFilterItem[][] = [
|
||||
[
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [`${servicename}`],
|
||||
},
|
||||
...tagFilterItems,
|
||||
],
|
||||
];
|
||||
|
||||
const legends = [legend];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
const timeAggregateOperators = [MetricAggregateOperator.RATE];
|
||||
const spaceAggregateOperators = [MetricAggregateOperator.SUM];
|
||||
|
||||
return getQueryBuilderQueries({
|
||||
autocompleteData,
|
||||
groupBy,
|
||||
legends,
|
||||
filterItems,
|
||||
dataSource,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
});
|
||||
};
|
||||
|
||||
export const databaseCallsAvgDuration = ({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
}: DatabaseCallProps): QueryBuilderData => {
|
||||
const autocompleteDataA: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozDbLatencySum,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
const autocompleteDataB: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozDBLatencyCount,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const additionalItemsA: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [servicename],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const autocompleteData: BaseAutocompleteData[] = [
|
||||
autocompleteDataA,
|
||||
autocompleteDataB,
|
||||
];
|
||||
|
||||
const additionalItems: TagFilterItem[][] = [
|
||||
additionalItemsA,
|
||||
additionalItemsA,
|
||||
];
|
||||
|
||||
const legends = ['', ''];
|
||||
const disabled = [true, true];
|
||||
const legendFormulas = ['Average Duration'];
|
||||
const expressions = [FORMULA.DATABASE_CALLS_AVG_DURATION];
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
return getQueryBuilderQuerieswithFormula({
|
||||
autocompleteData,
|
||||
additionalItems,
|
||||
legends,
|
||||
disabled,
|
||||
expressions,
|
||||
legendFormulas,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
dataSource,
|
||||
});
|
||||
};
|
||||
@@ -1,276 +0,0 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
DataSource,
|
||||
MetricAggregateOperator,
|
||||
QueryBuilderData,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
import { FORMULA, MetricsType, WidgetKeys } from '../constant';
|
||||
import {
|
||||
ExternalCallDurationByAddressProps,
|
||||
ExternalCallProps,
|
||||
} from '../Tabs/types';
|
||||
import {
|
||||
getQueryBuilderQueries,
|
||||
getQueryBuilderQuerieswithFormula,
|
||||
} from './MetricsPageQueriesFactory';
|
||||
|
||||
const groupBy: BaseAutocompleteData[] = [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
key: WidgetKeys.Address,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
];
|
||||
|
||||
export const externalCallErrorPercent = ({
|
||||
servicename,
|
||||
legend,
|
||||
tagFilterItems,
|
||||
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
|
||||
const autocompleteDataA: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozExternalCallLatencyCount,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
const autocompleteDataB: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozExternalCallLatencyCount,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const additionalItemsA: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [servicename],
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.StatusCode,
|
||||
dataType: DataTypes.Int64,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: ['STATUS_CODE_ERROR'],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
const additionalItemsB: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [servicename],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
const legendFormulas = [legend];
|
||||
const expressions = [FORMULA.ERROR_PERCENTAGE];
|
||||
const disabled = [true, true];
|
||||
const autocompleteData = [autocompleteDataA, autocompleteDataB];
|
||||
|
||||
const additionalItems = [additionalItemsA, additionalItemsB];
|
||||
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
];
|
||||
const legends = [legend, legend];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
return getQueryBuilderQuerieswithFormula({
|
||||
autocompleteData,
|
||||
additionalItems,
|
||||
legends,
|
||||
groupBy,
|
||||
disabled,
|
||||
expressions,
|
||||
legendFormulas,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
dataSource,
|
||||
});
|
||||
};
|
||||
|
||||
export const externalCallDuration = ({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
}: ExternalCallProps): QueryBuilderData => {
|
||||
const autocompleteDataA: BaseAutocompleteData = {
|
||||
dataType: DataTypes.Float64,
|
||||
key: WidgetKeys.SignozExternalCallLatencySum,
|
||||
type: '',
|
||||
};
|
||||
const autocompleteDataB: BaseAutocompleteData = {
|
||||
dataType: DataTypes.Float64,
|
||||
key: WidgetKeys.SignozExternalCallLatencyCount,
|
||||
type: '',
|
||||
};
|
||||
const expressions = [FORMULA.DATABASE_CALLS_AVG_DURATION];
|
||||
const legendFormulas = ['Average Duration'];
|
||||
const legend = '';
|
||||
const disabled = [true, true];
|
||||
const additionalItemsA: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [servicename],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const autocompleteData = [autocompleteDataA, autocompleteDataB];
|
||||
|
||||
const additionalItems = [additionalItemsA, additionalItemsA];
|
||||
const legends = [legend, legend];
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
return getQueryBuilderQuerieswithFormula({
|
||||
autocompleteData,
|
||||
additionalItems,
|
||||
legends,
|
||||
disabled,
|
||||
expressions,
|
||||
legendFormulas,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
dataSource,
|
||||
});
|
||||
};
|
||||
|
||||
export const externalCallRpsByAddress = ({
|
||||
servicename,
|
||||
legend,
|
||||
tagFilterItems,
|
||||
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
|
||||
const autocompleteData: BaseAutocompleteData[] = [
|
||||
{
|
||||
dataType: DataTypes.Float64,
|
||||
key: WidgetKeys.SignozExternalCallLatencyCount,
|
||||
type: '',
|
||||
},
|
||||
];
|
||||
const filterItems: TagFilterItem[][] = [
|
||||
[
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [servicename],
|
||||
},
|
||||
...tagFilterItems,
|
||||
],
|
||||
];
|
||||
|
||||
const legends = [legend];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
const timeAggregateOperators = [MetricAggregateOperator.RATE];
|
||||
const spaceAggregateOperators = [MetricAggregateOperator.SUM];
|
||||
|
||||
return getQueryBuilderQueries({
|
||||
autocompleteData,
|
||||
groupBy,
|
||||
legends,
|
||||
filterItems,
|
||||
dataSource,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
});
|
||||
};
|
||||
|
||||
export const externalCallDurationByAddress = ({
|
||||
servicename,
|
||||
legend,
|
||||
tagFilterItems,
|
||||
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
|
||||
const autocompleteDataA: BaseAutocompleteData = {
|
||||
dataType: DataTypes.Float64,
|
||||
key: WidgetKeys.SignozExternalCallLatencySum,
|
||||
type: '',
|
||||
};
|
||||
const autocompleteDataB: BaseAutocompleteData = {
|
||||
dataType: DataTypes.Float64,
|
||||
key: WidgetKeys.SignozExternalCallLatencyCount,
|
||||
type: '',
|
||||
};
|
||||
const expressions = [FORMULA.DATABASE_CALLS_AVG_DURATION];
|
||||
const legendFormulas = [legend];
|
||||
const disabled = [true, true];
|
||||
const additionalItemsA: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [servicename],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const autocompleteData = [autocompleteDataA, autocompleteDataB];
|
||||
const additionalItems = [additionalItemsA, additionalItemsA];
|
||||
const legends = [legend, legend];
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
return getQueryBuilderQuerieswithFormula({
|
||||
autocompleteData,
|
||||
additionalItems,
|
||||
legends,
|
||||
groupBy,
|
||||
disabled,
|
||||
expressions,
|
||||
legendFormulas,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
dataSource,
|
||||
});
|
||||
};
|
||||
@@ -5,68 +5,13 @@ import {
|
||||
} from 'constants/queryBuilder';
|
||||
import getStep from 'lib/getStep';
|
||||
import store from 'store';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
MetricAggregateOperator,
|
||||
QueryBuilderData,
|
||||
ReduceOperators,
|
||||
Temporality,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
BuilderQueriesProps,
|
||||
BuilderQuerieswithFormulaProps,
|
||||
} from '../Tabs/types';
|
||||
|
||||
export const getQueryBuilderQueries = ({
|
||||
autocompleteData,
|
||||
groupBy = [],
|
||||
legends,
|
||||
filterItems,
|
||||
aggregateOperator,
|
||||
dataSource,
|
||||
queryNameAndExpression,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
}: BuilderQueriesProps): QueryBuilderData => ({
|
||||
queryFormulas: [],
|
||||
queryData: autocompleteData.map((item, index) => {
|
||||
const newQueryData: IBuilderQuery = {
|
||||
...initialQueryBuilderFormValuesMap.metrics,
|
||||
aggregateOperator: ((): string => {
|
||||
if (aggregateOperator) {
|
||||
return aggregateOperator[index];
|
||||
}
|
||||
return MetricAggregateOperator.SUM_RATE;
|
||||
})(),
|
||||
disabled: false,
|
||||
groupBy,
|
||||
aggregateAttribute: item,
|
||||
legend: legends[index],
|
||||
stepInterval: getStep({
|
||||
end: store.getState().globalTime.maxTime,
|
||||
inputFormat: 'ns',
|
||||
start: store.getState().globalTime.minTime,
|
||||
}),
|
||||
filters: {
|
||||
items: filterItems[index],
|
||||
op: 'AND',
|
||||
},
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: spaceAggregateOperators[index],
|
||||
timeAggregation: timeAggregateOperators[index],
|
||||
dataSource,
|
||||
};
|
||||
|
||||
if (queryNameAndExpression) {
|
||||
newQueryData.queryName = queryNameAndExpression[index];
|
||||
newQueryData.expression = queryNameAndExpression[index];
|
||||
}
|
||||
|
||||
return newQueryData;
|
||||
}),
|
||||
queryTraceOperator: [],
|
||||
});
|
||||
import { BuilderQuerieswithFormulaProps } from '../Tabs/types';
|
||||
|
||||
export const getQueryBuilderQuerieswithFormula = ({
|
||||
autocompleteData,
|
||||
|
||||
@@ -1,612 +0,0 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
DataSource,
|
||||
MetricAggregateOperator,
|
||||
QueryBuilderData,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
FORMULA,
|
||||
GraphTitle,
|
||||
LATENCY_AGGREGATEOPERATOR,
|
||||
LATENCY_AGGREGATEOPERATOR_SPAN_METRICS,
|
||||
MetricsType,
|
||||
OPERATION_LEGENDS,
|
||||
QUERYNAME_AND_EXPRESSION,
|
||||
WidgetKeys,
|
||||
} from '../constant';
|
||||
import {
|
||||
ApDexMetricsQueryBuilderQueriesProps,
|
||||
ApDexProps,
|
||||
LatencyProps,
|
||||
OperationPerSecProps,
|
||||
} from '../Tabs/types';
|
||||
import { convertMilSecToNanoSec, getNearestHighestBucketValue } from '../utils';
|
||||
import {
|
||||
getQueryBuilderQueries,
|
||||
getQueryBuilderQuerieswithFormula,
|
||||
} from './MetricsPageQueriesFactory';
|
||||
|
||||
export const latency = ({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
isSpanMetricEnable = false,
|
||||
topLevelOperationsRoute,
|
||||
}: LatencyProps): QueryBuilderData => {
|
||||
const signozLatencyBucketMetrics = WidgetKeys.SignozLatencyBucket;
|
||||
|
||||
const signozMetricsServiceName = WidgetKeys.OTelServiceName;
|
||||
const newAutoCompleteData: BaseAutocompleteData = {
|
||||
key: isSpanMetricEnable
|
||||
? signozLatencyBucketMetrics
|
||||
: WidgetKeys.DurationNano,
|
||||
dataType: DataTypes.Float64,
|
||||
type: isSpanMetricEnable ? '' : MetricsType.Tag,
|
||||
};
|
||||
|
||||
const autocompleteData = Array(3).fill(newAutoCompleteData);
|
||||
|
||||
const filterItem: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: isSpanMetricEnable ? signozMetricsServiceName : WidgetKeys.ServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: isSpanMetricEnable ? MetricsType.Resource : MetricsType.Tag,
|
||||
},
|
||||
op: isSpanMetricEnable ? OPERATORS.IN : OPERATORS['='],
|
||||
value: isSpanMetricEnable ? [servicename] : servicename,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: isSpanMetricEnable ? WidgetKeys.Operation : WidgetKeys.Name,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN.toLowerCase(), // TODO: need to remove toLowerCase() this once backend is changed
|
||||
value: [...topLevelOperationsRoute],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const filterItems = Array(3).fill([...filterItem]);
|
||||
const legends = LATENCY_AGGREGATEOPERATOR;
|
||||
const aggregateOperator = isSpanMetricEnable
|
||||
? LATENCY_AGGREGATEOPERATOR_SPAN_METRICS
|
||||
: LATENCY_AGGREGATEOPERATOR;
|
||||
const dataSource = isSpanMetricEnable ? DataSource.METRICS : DataSource.TRACES;
|
||||
const queryNameAndExpression = QUERYNAME_AND_EXPRESSION;
|
||||
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.EMPTY,
|
||||
MetricAggregateOperator.EMPTY,
|
||||
MetricAggregateOperator.EMPTY,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.P50,
|
||||
MetricAggregateOperator.P90,
|
||||
MetricAggregateOperator.P99,
|
||||
];
|
||||
|
||||
return getQueryBuilderQueries({
|
||||
autocompleteData,
|
||||
legends,
|
||||
filterItems,
|
||||
aggregateOperator,
|
||||
dataSource,
|
||||
queryNameAndExpression,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
});
|
||||
};
|
||||
|
||||
export const apDexTracesQueryBuilderQueries = ({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
topLevelOperationsRoute,
|
||||
threashold,
|
||||
}: ApDexProps): QueryBuilderData => {
|
||||
const autoCompleteDataA: BaseAutocompleteData = {
|
||||
dataType: DataTypes.Float64,
|
||||
key: '',
|
||||
type: '',
|
||||
};
|
||||
|
||||
const autoCompleteDataB: BaseAutocompleteData = {
|
||||
dataType: DataTypes.Float64,
|
||||
key: '',
|
||||
type: '',
|
||||
};
|
||||
|
||||
const autoCompleteDataC: BaseAutocompleteData = {
|
||||
dataType: DataTypes.Float64,
|
||||
key: '',
|
||||
type: '',
|
||||
};
|
||||
|
||||
const filterItemA: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.ServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: servicename,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Name,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [...topLevelOperationsRoute],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const filterItemB: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.HasError,
|
||||
dataType: DataTypes.bool,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.DurationNano,
|
||||
dataType: DataTypes.Float64,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['<='],
|
||||
value: convertMilSecToNanoSec(threashold),
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.ServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: servicename,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Name,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [...topLevelOperationsRoute],
|
||||
},
|
||||
];
|
||||
|
||||
const filterItemC: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.DurationNano,
|
||||
dataType: DataTypes.Float64,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['<='],
|
||||
value: convertMilSecToNanoSec(threashold * 4),
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.HasError,
|
||||
dataType: DataTypes.bool,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.ServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: servicename,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Name,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [...topLevelOperationsRoute],
|
||||
},
|
||||
];
|
||||
|
||||
const autocompleteData = [
|
||||
autoCompleteDataA,
|
||||
autoCompleteDataB,
|
||||
autoCompleteDataC,
|
||||
];
|
||||
const additionalItems = [filterItemA, filterItemB, filterItemC];
|
||||
const legends = [GraphTitle.APDEX];
|
||||
const disabled = Array(3).fill(true);
|
||||
const expressions = [FORMULA.APDEX_TRACES];
|
||||
const legendFormulas = [GraphTitle.APDEX];
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.COUNT,
|
||||
MetricAggregateOperator.COUNT,
|
||||
MetricAggregateOperator.COUNT,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.EMPTY,
|
||||
MetricAggregateOperator.EMPTY,
|
||||
MetricAggregateOperator.EMPTY,
|
||||
];
|
||||
const dataSource = DataSource.TRACES;
|
||||
|
||||
return getQueryBuilderQuerieswithFormula({
|
||||
autocompleteData,
|
||||
additionalItems,
|
||||
legends,
|
||||
disabled,
|
||||
expressions,
|
||||
legendFormulas,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
dataSource,
|
||||
});
|
||||
};
|
||||
|
||||
export const apDexMetricsQueryBuilderQueries = ({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
topLevelOperationsRoute,
|
||||
threashold,
|
||||
delta,
|
||||
metricsBuckets,
|
||||
}: ApDexMetricsQueryBuilderQueriesProps): QueryBuilderData => {
|
||||
const autoCompleteDataA: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozLatencyCount,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const autoCompleteDataB: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozLatencyBucket,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const autoCompleteDataC: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozLatencyBucket,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const filterItemA: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: servicename,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Operation,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [...topLevelOperationsRoute],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const filterItemB: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.StatusCode,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['!='],
|
||||
value: 'STATUS_CODE_ERROR',
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Le,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: getNearestHighestBucketValue(threashold * 1000, metricsBuckets),
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: servicename,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Operation,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [...topLevelOperationsRoute],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const filterItemC: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Le,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: getNearestHighestBucketValue(threashold * 1000 * 4, metricsBuckets),
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.StatusCode,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['!='],
|
||||
value: 'STATUS_CODE_ERROR',
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: servicename,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Operation,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [...topLevelOperationsRoute],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const autocompleteData = [
|
||||
autoCompleteDataA,
|
||||
autoCompleteDataB,
|
||||
autoCompleteDataC,
|
||||
];
|
||||
|
||||
const additionalItems = [filterItemA, filterItemB, filterItemC];
|
||||
const legends = [GraphTitle.APDEX];
|
||||
const disabled = Array(3).fill(true);
|
||||
const expressions = delta
|
||||
? [FORMULA.APDEX_DELTA_SPAN_METRICS]
|
||||
: [FORMULA.APDEX_CUMULATIVE_SPAN_METRICS];
|
||||
const legendFormulas = [GraphTitle.APDEX];
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
];
|
||||
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
return getQueryBuilderQuerieswithFormula({
|
||||
autocompleteData,
|
||||
additionalItems,
|
||||
legends,
|
||||
disabled,
|
||||
expressions,
|
||||
legendFormulas,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
dataSource,
|
||||
});
|
||||
};
|
||||
|
||||
export const operationPerSec = ({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
topLevelOperations,
|
||||
}: OperationPerSecProps): QueryBuilderData => {
|
||||
const autocompleteData: BaseAutocompleteData[] = [
|
||||
{
|
||||
key: WidgetKeys.SignozLatencyCount,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
},
|
||||
];
|
||||
|
||||
const filterItems: TagFilterItem[][] = [
|
||||
[
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [`${servicename}`],
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Operation,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: topLevelOperations,
|
||||
},
|
||||
...tagFilterItems,
|
||||
],
|
||||
];
|
||||
|
||||
const legends = OPERATION_LEGENDS;
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
const timeAggregateOperators = [MetricAggregateOperator.RATE];
|
||||
const spaceAggregateOperators = [MetricAggregateOperator.SUM];
|
||||
|
||||
return getQueryBuilderQueries({
|
||||
autocompleteData,
|
||||
legends,
|
||||
filterItems,
|
||||
dataSource,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
});
|
||||
};
|
||||
|
||||
export const errorPercentage = ({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
topLevelOperations,
|
||||
}: OperationPerSecProps): QueryBuilderData => {
|
||||
const autocompleteDataA: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozCallsTotal,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
const autocompleteDataB: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozCallsTotal,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const autocompleteData = [autocompleteDataA, autocompleteDataB];
|
||||
|
||||
const additionalItemsA: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [`${servicename}`],
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Operation,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: topLevelOperations,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.StatusCode,
|
||||
dataType: DataTypes.Int64,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: ['STATUS_CODE_ERROR'],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const additionalItemsB: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [`${servicename}`],
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Operation,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: topLevelOperations,
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const additionalItems = [additionalItemsA, additionalItemsB];
|
||||
const legends = [GraphTitle.ERROR_PERCENTAGE];
|
||||
const disabled = [true, true];
|
||||
const expressions = [FORMULA.ERROR_PERCENTAGE];
|
||||
const legendFormulas = [GraphTitle.ERROR_PERCENTAGE];
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
return getQueryBuilderQuerieswithFormula({
|
||||
autocompleteData,
|
||||
additionalItems,
|
||||
legends,
|
||||
disabled,
|
||||
expressions,
|
||||
legendFormulas,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
dataSource,
|
||||
});
|
||||
};
|
||||
@@ -1,146 +0,0 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
DataSource,
|
||||
MetricAggregateOperator,
|
||||
QueryBuilderData,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
GraphTitle,
|
||||
KeyOperationTableHeader,
|
||||
MetricsType,
|
||||
WidgetKeys,
|
||||
} from '../constant';
|
||||
import { TopOperationQueryFactoryProps } from '../Tabs/types';
|
||||
import { getQueryBuilderQuerieswithFormula } from './MetricsPageQueriesFactory';
|
||||
|
||||
export const topOperationQueries = ({
|
||||
servicename,
|
||||
}: TopOperationQueryFactoryProps): QueryBuilderData => {
|
||||
const latencyAutoCompleteData: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozLatencyBucket,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const errorRateAutoCompleteData: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozCallsTotal,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const numOfCallAutoCompleteData: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozLatencyCount,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const latencyAndNumberOfCallAdditionalItems: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
value: [servicename],
|
||||
op: OPERATORS.IN,
|
||||
},
|
||||
];
|
||||
|
||||
const errorRateAdditionalItemsA: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [servicename],
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
dataType: DataTypes.Int64,
|
||||
key: WidgetKeys.StatusCode,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: ['STATUS_CODE_ERROR'],
|
||||
},
|
||||
];
|
||||
|
||||
const errorRateAdditionalItemsB = latencyAndNumberOfCallAdditionalItems;
|
||||
|
||||
const groupBy: BaseAutocompleteData[] = [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
key: WidgetKeys.Operation,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
];
|
||||
|
||||
const autocompleteData = [
|
||||
latencyAutoCompleteData,
|
||||
latencyAutoCompleteData,
|
||||
latencyAutoCompleteData,
|
||||
errorRateAutoCompleteData,
|
||||
errorRateAutoCompleteData,
|
||||
numOfCallAutoCompleteData,
|
||||
];
|
||||
const additionalItems = [
|
||||
latencyAndNumberOfCallAdditionalItems,
|
||||
latencyAndNumberOfCallAdditionalItems,
|
||||
latencyAndNumberOfCallAdditionalItems,
|
||||
errorRateAdditionalItemsA,
|
||||
errorRateAdditionalItemsB,
|
||||
latencyAndNumberOfCallAdditionalItems,
|
||||
];
|
||||
const disabled = [false, false, false, true, true, false];
|
||||
const legends = [
|
||||
KeyOperationTableHeader.P50,
|
||||
KeyOperationTableHeader.P90,
|
||||
KeyOperationTableHeader.P99,
|
||||
KeyOperationTableHeader.ERROR_RATE,
|
||||
KeyOperationTableHeader.ERROR_RATE,
|
||||
KeyOperationTableHeader.NUM_OF_CALLS,
|
||||
];
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.EMPTY,
|
||||
MetricAggregateOperator.EMPTY,
|
||||
MetricAggregateOperator.EMPTY,
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.P50,
|
||||
MetricAggregateOperator.P90,
|
||||
MetricAggregateOperator.P99,
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
];
|
||||
const expressions = ['D*100/E'];
|
||||
const legendFormulas = [GraphTitle.ERROR_PERCENTAGE];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
return getQueryBuilderQuerieswithFormula({
|
||||
autocompleteData,
|
||||
additionalItems,
|
||||
disabled,
|
||||
legends,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
expressions,
|
||||
legendFormulas,
|
||||
dataSource,
|
||||
groupBy,
|
||||
});
|
||||
};
|
||||
@@ -1,253 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import { Col } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import Graph from 'container/WidgetCard/Card';
|
||||
import {
|
||||
databaseCallsAvgDuration,
|
||||
databaseCallsRPS,
|
||||
} from 'container/MetricsApplication/MetricsPageQueries/DBCallQueries';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import {
|
||||
convertRawQueriesToTraceSelectedTags,
|
||||
getResourceDeploymentKeys,
|
||||
resourceAttributesToTagFilterItems,
|
||||
} from 'hooks/useResourceAttribute/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import getStep from 'lib/getStep';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import store from 'store';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
GraphTitle,
|
||||
MENU_ITEMS,
|
||||
SERVICE_CHART_ID,
|
||||
SERVICE_DETAIL_DRILLDOWN_ENABLED,
|
||||
} from '../constant';
|
||||
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
|
||||
import { Card, GraphContainer, Row } from '../styles';
|
||||
import { Button } from './styles';
|
||||
import {
|
||||
dbSystemTags,
|
||||
handleNonInQueryRange,
|
||||
onViewTracePopupClick,
|
||||
useGetAPMToTracesQueries,
|
||||
useGraphClickHandler,
|
||||
} from './util';
|
||||
|
||||
function DBCall(): JSX.Element {
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
const [selectedTimeStamp, setSelectedTimeStamp] = useState<number>(0);
|
||||
const { queries } = useResourceAttribute();
|
||||
const urlQuery = useUrlQuery();
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number) => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
const tagFilterItems: TagFilterItem[] = useMemo(
|
||||
() =>
|
||||
handleNonInQueryRange(resourceAttributesToTagFilterItems(queries)) || [],
|
||||
[queries],
|
||||
);
|
||||
|
||||
const selectedTraceTags: string = useMemo(
|
||||
() =>
|
||||
JSON.stringify(
|
||||
convertRawQueriesToTraceSelectedTags(queries).concat(...dbSystemTags) || [],
|
||||
),
|
||||
[queries],
|
||||
);
|
||||
|
||||
const legend = '{{db.system}}';
|
||||
|
||||
const databaseCallsRPSWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: databaseCallsRPS({
|
||||
servicename,
|
||||
legend,
|
||||
tagFilterItems,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.DATABASE_CALLS_RPS,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: 'reqps',
|
||||
id: SERVICE_CHART_ID.dbCallsRPS,
|
||||
fillSpans: false,
|
||||
}),
|
||||
[servicename, tagFilterItems, legend],
|
||||
);
|
||||
const databaseCallsAverageDurationWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: databaseCallsAvgDuration({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.DATABASE_CALLS_AVG_DURATION,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: 'ms',
|
||||
id: GraphTitle.DATABASE_CALLS_AVG_DURATION,
|
||||
fillSpans: true,
|
||||
}),
|
||||
[servicename, tagFilterItems],
|
||||
);
|
||||
|
||||
const stepInterval = useMemo(
|
||||
() =>
|
||||
getStep({
|
||||
end: store.getState().globalTime.maxTime,
|
||||
inputFormat: 'ns',
|
||||
start: store.getState().globalTime.minTime,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current) {
|
||||
const selectedEnvironments = queries.find(
|
||||
(val) => val.tagKey === getResourceDeploymentKeys(),
|
||||
)?.tagValue;
|
||||
|
||||
logEvent('APM: Service detail page visited', {
|
||||
selectedEnvironments,
|
||||
resourceAttributeUsed: !!queries?.length,
|
||||
section: 'dbMetrics',
|
||||
});
|
||||
logEventCalledRef.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const apmToTraceQuery = useGetAPMToTracesQueries({
|
||||
servicename,
|
||||
isDBCall: true,
|
||||
});
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const onGraphClickHandler = useGraphClickHandler(setSelectedTimeStamp);
|
||||
|
||||
return (
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
id="database_call_rps_button"
|
||||
onClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
>
|
||||
View Traces
|
||||
</Button>
|
||||
<Card data-testid="database_call_rps">
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
widget={databaseCallsRPSWidget}
|
||||
onClickHandler={(xValue, yValue, mouseX, mouseY): void => {
|
||||
onGraphClickHandler(
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
'database_call_rps',
|
||||
);
|
||||
}}
|
||||
onDragSelect={onDragSelect}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
id="database_call_avg_duration_button"
|
||||
onClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
>
|
||||
View Traces
|
||||
</Button>
|
||||
|
||||
<Card data-testid="database_call_avg_duration">
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
widget={databaseCallsAverageDurationWidget}
|
||||
headerMenuList={MENU_ITEMS}
|
||||
onClickHandler={(xValue, yValue, mouseX, mouseY): void => {
|
||||
onGraphClickHandler(
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
'database_call_avg_duration',
|
||||
);
|
||||
}}
|
||||
onDragSelect={onDragSelect}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
export default DBCall;
|
||||
@@ -1,420 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import { Col } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import Graph from 'container/WidgetCard/Card';
|
||||
import {
|
||||
externalCallDuration,
|
||||
externalCallDurationByAddress,
|
||||
externalCallErrorPercent,
|
||||
externalCallRpsByAddress,
|
||||
} from 'container/MetricsApplication/MetricsPageQueries/ExternalQueries';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import {
|
||||
convertRawQueriesToTraceSelectedTags,
|
||||
getResourceDeploymentKeys,
|
||||
resourceAttributesToTagFilterItems,
|
||||
} from 'hooks/useResourceAttribute/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import getStep from 'lib/getStep';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import store from 'store';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
GraphTitle,
|
||||
legend,
|
||||
MENU_ITEMS,
|
||||
SERVICE_DETAIL_DRILLDOWN_ENABLED,
|
||||
} from '../constant';
|
||||
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
|
||||
import { Card, GraphContainer, Row } from '../styles';
|
||||
import GraphControlsPanel from './Overview/GraphControlsPanel/GraphControlsPanel';
|
||||
import {
|
||||
handleNonInQueryRange,
|
||||
onViewAPIMonitoringPopupClick,
|
||||
onViewTracePopupClick,
|
||||
useGetAPMToTracesQueries,
|
||||
useGraphClickHandler,
|
||||
} from './util';
|
||||
|
||||
function External(): JSX.Element {
|
||||
const [selectedTimeStamp, setSelectedTimeStamp] = useState<number>(0);
|
||||
const [selectedData, setSelectedData] = useState<any>(undefined);
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
const { queries } = useResourceAttribute();
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number) => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
const tagFilterItems = useMemo(
|
||||
() =>
|
||||
handleNonInQueryRange(resourceAttributesToTagFilterItems(queries)) || [],
|
||||
[queries],
|
||||
);
|
||||
|
||||
const externalCallErrorWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: externalCallErrorPercent({
|
||||
servicename,
|
||||
legend: legend.address,
|
||||
tagFilterItems,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.EXTERNAL_CALL_ERROR_PERCENTAGE,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: '%',
|
||||
id: GraphTitle.EXTERNAL_CALL_ERROR_PERCENTAGE,
|
||||
}),
|
||||
[servicename, tagFilterItems],
|
||||
);
|
||||
|
||||
const selectedTraceTags = useMemo(
|
||||
() => JSON.stringify(convertRawQueriesToTraceSelectedTags(queries) || []),
|
||||
[queries],
|
||||
);
|
||||
|
||||
const externalCallDurationWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: externalCallDuration({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.EXTERNAL_CALL_DURATION,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: 'ms',
|
||||
id: GraphTitle.EXTERNAL_CALL_DURATION,
|
||||
fillSpans: true,
|
||||
}),
|
||||
[servicename, tagFilterItems],
|
||||
);
|
||||
|
||||
const errorApmToTraceQuery = useGetAPMToTracesQueries({
|
||||
servicename,
|
||||
isExternalCall: true,
|
||||
filters: [
|
||||
{
|
||||
id: uuid().slice(0, 8),
|
||||
key: {
|
||||
key: 'hasError',
|
||||
dataType: DataTypes.bool,
|
||||
type: 'tag',
|
||||
id: 'hasError--bool--tag--true',
|
||||
},
|
||||
op: 'in',
|
||||
value: ['true'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const stepInterval = useMemo(
|
||||
() =>
|
||||
getStep({
|
||||
end: store.getState().globalTime.maxTime,
|
||||
inputFormat: 'ns',
|
||||
start: store.getState().globalTime.minTime,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const logEventCalledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current) {
|
||||
const selectedEnvironments = queries.find(
|
||||
(val) => val.tagKey === getResourceDeploymentKeys(),
|
||||
)?.tagValue;
|
||||
|
||||
logEvent('APM: Service detail page visited', {
|
||||
selectedEnvironments,
|
||||
resourceAttributeUsed: !!queries?.length,
|
||||
section: 'externalMetrics',
|
||||
});
|
||||
logEventCalledRef.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const externalCallRPSWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: externalCallRpsByAddress({
|
||||
servicename,
|
||||
legend: legend.address,
|
||||
tagFilterItems,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.EXTERNAL_CALL_RPS_BY_ADDRESS,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: 'reqps',
|
||||
id: GraphTitle.EXTERNAL_CALL_RPS_BY_ADDRESS,
|
||||
fillSpans: true,
|
||||
}),
|
||||
[servicename, tagFilterItems],
|
||||
);
|
||||
|
||||
const externalCallDurationAddressWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: externalCallDurationByAddress({
|
||||
servicename,
|
||||
legend: legend.address,
|
||||
tagFilterItems,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.EXTERNAL_CALL_DURATION_BY_ADDRESS,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: 'ms',
|
||||
id: GraphTitle.EXTERNAL_CALL_DURATION_BY_ADDRESS,
|
||||
fillSpans: true,
|
||||
}),
|
||||
[servicename, tagFilterItems],
|
||||
);
|
||||
|
||||
const apmToTraceQuery = useGetAPMToTracesQueries({
|
||||
servicename,
|
||||
isExternalCall: true,
|
||||
});
|
||||
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const onGraphClickHandler = useGraphClickHandler(
|
||||
setSelectedTimeStamp,
|
||||
setSelectedData,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<GraphControlsPanel
|
||||
id="external_call_error_percentage_button"
|
||||
onViewTracesClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery: errorApmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
onViewAPIMonitoringClick={onViewAPIMonitoringPopupClick({
|
||||
servicename,
|
||||
timestamp: selectedTimeStamp,
|
||||
domainName: selectedData?.address || '',
|
||||
isError: true,
|
||||
stepInterval: 300,
|
||||
safeNavigate,
|
||||
})}
|
||||
/>
|
||||
<Card data-testid="external_call_error_percentage">
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
headerMenuList={MENU_ITEMS}
|
||||
widget={externalCallErrorWidget}
|
||||
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
|
||||
onGraphClickHandler(
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
'external_call_error_percentage',
|
||||
data,
|
||||
);
|
||||
}}
|
||||
onDragSelect={onDragSelect}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<GraphControlsPanel
|
||||
id="external_call_duration_button"
|
||||
onViewTracesClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
onViewAPIMonitoringClick={onViewAPIMonitoringPopupClick({
|
||||
servicename,
|
||||
timestamp: selectedTimeStamp,
|
||||
domainName: selectedData?.address,
|
||||
isError: false,
|
||||
stepInterval: 300,
|
||||
safeNavigate,
|
||||
})}
|
||||
/>
|
||||
|
||||
<Card data-testid="external_call_duration">
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
headerMenuList={MENU_ITEMS}
|
||||
widget={externalCallDurationWidget}
|
||||
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
|
||||
onGraphClickHandler(
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
'external_call_duration',
|
||||
data,
|
||||
);
|
||||
}}
|
||||
onDragSelect={onDragSelect}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<GraphControlsPanel
|
||||
id="external_call_rps_by_address_button"
|
||||
onViewTracesClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
onViewAPIMonitoringClick={onViewAPIMonitoringPopupClick({
|
||||
servicename,
|
||||
timestamp: selectedTimeStamp,
|
||||
domainName: selectedData?.address,
|
||||
isError: false,
|
||||
stepInterval: 300,
|
||||
safeNavigate,
|
||||
})}
|
||||
/>
|
||||
<Card data-testid="external_call_rps_by_address">
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
widget={externalCallRPSWidget}
|
||||
headerMenuList={MENU_ITEMS}
|
||||
onClickHandler={(xValue, yValue, mouseX, mouseY, data): Promise<void> =>
|
||||
onGraphClickHandler(
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
'external_call_rps_by_address',
|
||||
data,
|
||||
)
|
||||
}
|
||||
onDragSelect={onDragSelect}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<GraphControlsPanel
|
||||
id="external_call_duration_by_address_button"
|
||||
onViewTracesClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
onViewAPIMonitoringClick={onViewAPIMonitoringPopupClick({
|
||||
servicename,
|
||||
timestamp: selectedTimeStamp,
|
||||
domainName: selectedData?.address,
|
||||
isError: false,
|
||||
stepInterval: 300,
|
||||
safeNavigate,
|
||||
})}
|
||||
/>
|
||||
|
||||
<Card data-testid="external_call_duration_by_address">
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
widget={externalCallDurationAddressWidget}
|
||||
headerMenuList={MENU_ITEMS}
|
||||
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
|
||||
onGraphClickHandler(
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
'external_call_duration_by_address',
|
||||
data,
|
||||
);
|
||||
}}
|
||||
onDragSelect={onDragSelect}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default External;
|
||||
@@ -1,412 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import getTopLevelOperations, {
|
||||
ServiceDataProps,
|
||||
} from 'api/metrics/getTopLevelOperations';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { routeConfig } from 'container/SideNav/config';
|
||||
import { getQueryString } from 'container/SideNav/helper';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import {
|
||||
convertRawQueriesToTraceSelectedTags,
|
||||
getResourceDeploymentKeys,
|
||||
resourceAttributesToTagFilterItems,
|
||||
} from 'hooks/useResourceAttribute/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import getStep from 'lib/getStep';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import { secondsToMilliseconds } from 'utils/timeUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { GraphTitle, SERVICE_CHART_ID } from '../constant';
|
||||
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
|
||||
import {
|
||||
errorPercentage,
|
||||
operationPerSec,
|
||||
} from '../MetricsPageQueries/OverviewQueries';
|
||||
import { Col, ColApDexContainer, ColErrorContainer, Row } from '../styles';
|
||||
import ApDex from './Overview/ApDex';
|
||||
import GraphControlsPanel from './Overview/GraphControlsPanel/GraphControlsPanel';
|
||||
import ServiceOverview from './Overview/ServiceOverview';
|
||||
import TopLevelOperation from './Overview/TopLevelOperations';
|
||||
import TopOperation from './Overview/TopOperation';
|
||||
import TopOperationMetrics from './Overview/TopOperationMetrics';
|
||||
import { Button, Card } from './styles';
|
||||
import {
|
||||
generateExplorerPath,
|
||||
handleNonInQueryRange,
|
||||
onViewTracePopupClick,
|
||||
useGetAPMToLogsQueries,
|
||||
useGetAPMToTracesQueries,
|
||||
useGraphClickHandler,
|
||||
} from './util';
|
||||
|
||||
function Application(): JSX.Element {
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const [selectedTimeStamp, setSelectedTimeStamp] = useState<number>(0);
|
||||
const { search, pathname } = useAppLocation();
|
||||
const { queries } = useResourceAttribute();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const { featureFlags } = useAppContext();
|
||||
const isSpanMetricEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_SPAN_METRICS)
|
||||
?.active || false;
|
||||
|
||||
const handleSetTimeStamp = useCallback((selectTime: number) => {
|
||||
setSelectedTimeStamp(selectTime);
|
||||
}, []);
|
||||
|
||||
const onGraphClickHandler = useGraphClickHandler(handleSetTimeStamp);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const handleGraphClick = useCallback(
|
||||
(type: string): OnClickPluginOpts['onClick'] =>
|
||||
(xValue, yValue, mouseX, mouseY): Promise<void> =>
|
||||
onGraphClickHandler(xValue, yValue, mouseX, mouseY, type),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[handleSetTimeStamp],
|
||||
);
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current) {
|
||||
const selectedEnvironments = queries.find(
|
||||
(val) => val.tagKey === getResourceDeploymentKeys(),
|
||||
)?.tagValue;
|
||||
|
||||
logEvent('APM: Service detail page visited', {
|
||||
selectedEnvironments,
|
||||
resourceAttributeUsed: !!queries?.length,
|
||||
section: 'overview',
|
||||
});
|
||||
logEventCalledRef.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: topLevelOperations,
|
||||
error: topLevelOperationsError,
|
||||
isLoading: topLevelOperationsIsLoading,
|
||||
isError: topLevelOperationsIsError,
|
||||
} = useQuery<ServiceDataProps>({
|
||||
queryKey: [servicename, minTime, maxTime],
|
||||
queryFn: (): Promise<ServiceDataProps> =>
|
||||
getTopLevelOperations({
|
||||
service: servicename || '',
|
||||
start: minTime,
|
||||
end: maxTime,
|
||||
}),
|
||||
// the time range is part of the key, so without this every window change blanks the
|
||||
// operations list and the widgets below are rebuilt with an empty `operation in []`
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const selectedTraceTags: string = JSON.stringify(
|
||||
convertRawQueriesToTraceSelectedTags(queries) || [],
|
||||
);
|
||||
|
||||
const apmToTraceQuery = useGetAPMToTracesQueries({ servicename });
|
||||
|
||||
const tagFilterItems = useMemo(
|
||||
() =>
|
||||
handleNonInQueryRange(resourceAttributesToTagFilterItems(queries)) || [],
|
||||
[queries],
|
||||
);
|
||||
|
||||
const topLevelOperationsRoute = useMemo(
|
||||
() =>
|
||||
topLevelOperations
|
||||
? defaultTo(topLevelOperations[servicename || ''], [])
|
||||
: [],
|
||||
[servicename, topLevelOperations],
|
||||
);
|
||||
|
||||
const operationPerSecWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: operationPerSec({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
topLevelOperations: topLevelOperationsRoute,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.RATE_PER_OPS,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: 'ops',
|
||||
id: SERVICE_CHART_ID.rps,
|
||||
}),
|
||||
[servicename, tagFilterItems, topLevelOperationsRoute],
|
||||
);
|
||||
|
||||
const errorPercentageWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: errorPercentage({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
topLevelOperations: topLevelOperationsRoute,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.ERROR_PERCENTAGE,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: '%',
|
||||
id: SERVICE_CHART_ID.errorPercentage,
|
||||
fillSpans: true,
|
||||
}),
|
||||
[servicename, tagFilterItems, topLevelOperationsRoute],
|
||||
);
|
||||
|
||||
const stepInterval = useMemo(
|
||||
() =>
|
||||
getStep({
|
||||
end: maxTime,
|
||||
inputFormat: 'ns',
|
||||
start: minTime,
|
||||
}),
|
||||
[maxTime, minTime],
|
||||
);
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number) => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param timestamp - The timestamp in seconds
|
||||
* @param apmToTraceQuery - query object
|
||||
* @param isViewLogsClicked - Whether this is for viewing logs vs traces
|
||||
* @returns A callback function that handles the navigation when executed
|
||||
*/
|
||||
const onErrorTrackHandler = useCallback(
|
||||
(
|
||||
timestamp: number,
|
||||
apmToTraceQuery: Query,
|
||||
isViewLogsClicked?: boolean,
|
||||
): ((e: React.MouseEvent) => void) =>
|
||||
(e: React.MouseEvent): void => {
|
||||
const endTime = secondsToMilliseconds(timestamp);
|
||||
const startTime = secondsToMilliseconds(timestamp - stepInterval);
|
||||
|
||||
const urlParams = new URLSearchParams(search);
|
||||
urlParams.set(QueryParams.startTime, startTime.toString());
|
||||
urlParams.set(QueryParams.endTime, endTime.toString());
|
||||
urlParams.delete(QueryParams.relativeTime);
|
||||
const avialableParams = routeConfig[ROUTES.TRACES_EXPLORER];
|
||||
const queryString = getQueryString(avialableParams, urlParams);
|
||||
|
||||
const JSONCompositeQuery = encodeURIComponent(
|
||||
JSON.stringify(apmToTraceQuery),
|
||||
);
|
||||
|
||||
const newPath = generateExplorerPath(
|
||||
isViewLogsClicked,
|
||||
urlParams,
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
JSONCompositeQuery,
|
||||
queryString,
|
||||
);
|
||||
|
||||
if (isModifierKeyPressed(e)) {
|
||||
openInNewTab(newPath);
|
||||
} else {
|
||||
navigate(newPath);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[stepInterval],
|
||||
);
|
||||
|
||||
const logErrorQuery = useGetAPMToLogsQueries({
|
||||
servicename,
|
||||
filters: [
|
||||
{
|
||||
id: uuid().slice(0, 8),
|
||||
key: {
|
||||
key: 'severity_text',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
id: 'severity_text--string----true',
|
||||
},
|
||||
op: 'in',
|
||||
value: ['ERROR', 'FATAL', 'error', 'fatal'],
|
||||
},
|
||||
],
|
||||
});
|
||||
const errorTrackQuery = useGetAPMToTracesQueries({
|
||||
servicename,
|
||||
filters: [
|
||||
{
|
||||
id: uuid().slice(0, 8),
|
||||
key: {
|
||||
key: 'hasError',
|
||||
dataType: DataTypes.bool,
|
||||
type: 'tag',
|
||||
id: 'hasError--bool--tag--true',
|
||||
},
|
||||
op: 'in',
|
||||
value: ['true'],
|
||||
},
|
||||
],
|
||||
});
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<ServiceOverview
|
||||
onDragSelect={onDragSelect}
|
||||
handleGraphClick={handleGraphClick}
|
||||
selectedTimeStamp={selectedTimeStamp}
|
||||
selectedTraceTags={selectedTraceTags}
|
||||
topLevelOperationsRoute={topLevelOperationsRoute}
|
||||
topLevelOperationsIsLoading={topLevelOperationsIsLoading}
|
||||
stepInterval={stepInterval}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
id="Rate_button"
|
||||
onClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
>
|
||||
View Traces
|
||||
</Button>
|
||||
<TopLevelOperation
|
||||
handleGraphClick={handleGraphClick}
|
||||
onDragSelect={onDragSelect}
|
||||
topLevelOperationsError={topLevelOperationsError}
|
||||
topLevelOperationsIsError={topLevelOperationsIsError}
|
||||
name="operations_per_sec"
|
||||
widget={operationPerSecWidget}
|
||||
opName="Rate"
|
||||
topLevelOperationsIsLoading={topLevelOperationsIsLoading}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<ColApDexContainer>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
id="ApDex_button"
|
||||
onClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
>
|
||||
View Traces
|
||||
</Button>
|
||||
<ApDex
|
||||
handleGraphClick={handleGraphClick}
|
||||
onDragSelect={onDragSelect}
|
||||
topLevelOperationsRoute={topLevelOperationsRoute}
|
||||
tagFilterItems={tagFilterItems}
|
||||
/>
|
||||
</ColApDexContainer>
|
||||
<ColErrorContainer>
|
||||
<GraphControlsPanel
|
||||
id="Error_button"
|
||||
onViewLogsClick={onErrorTrackHandler(
|
||||
selectedTimeStamp,
|
||||
logErrorQuery,
|
||||
true,
|
||||
)}
|
||||
onViewTracesClick={onErrorTrackHandler(
|
||||
selectedTimeStamp,
|
||||
errorTrackQuery,
|
||||
)}
|
||||
/>
|
||||
|
||||
<TopLevelOperation
|
||||
handleGraphClick={handleGraphClick}
|
||||
onDragSelect={onDragSelect}
|
||||
topLevelOperationsError={topLevelOperationsError}
|
||||
topLevelOperationsIsError={topLevelOperationsIsError}
|
||||
name="error_percentage_%"
|
||||
widget={errorPercentageWidget}
|
||||
opName="Error"
|
||||
topLevelOperationsIsLoading={topLevelOperationsIsLoading}
|
||||
/>
|
||||
</ColErrorContainer>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Card>
|
||||
{isSpanMetricEnabled ? <TopOperationMetrics /> : <TopOperation />}{' '}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export type ClickHandlerType = () => void;
|
||||
|
||||
export default Application;
|
||||
@@ -1,109 +0,0 @@
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import { Space } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import {
|
||||
apDexToolTipText,
|
||||
apDexToolTipUrl,
|
||||
apDexToolTipUrlText,
|
||||
} from 'constants/apDex';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import Graph from 'container/WidgetCard/Card';
|
||||
import DisplayThreshold from 'container/WidgetCard/Header/DisplayThreshold';
|
||||
import {
|
||||
GraphTitle,
|
||||
SERVICE_CHART_ID,
|
||||
SERVICE_DETAIL_DRILLDOWN_ENABLED,
|
||||
} from 'container/MetricsApplication/constant';
|
||||
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
|
||||
import { apDexMetricsQueryBuilderQueries } from 'container/MetricsApplication/MetricsPageQueries/OverviewQueries';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ApDexMetricsProps } from './types';
|
||||
|
||||
function ApDexMetrics({
|
||||
delta,
|
||||
metricsBuckets,
|
||||
thresholdValue,
|
||||
onDragSelect,
|
||||
tagFilterItems,
|
||||
topLevelOperationsRoute,
|
||||
handleGraphClick,
|
||||
}: ApDexMetricsProps): JSX.Element {
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
const apDexMetricsWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: apDexMetricsQueryBuilderQueries({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
topLevelOperationsRoute,
|
||||
threashold: thresholdValue || 0,
|
||||
delta: delta || false,
|
||||
metricsBuckets: metricsBuckets || [],
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: (
|
||||
<Space>
|
||||
<Typography>{GraphTitle.APDEX}</Typography>
|
||||
<TextToolTip
|
||||
text={apDexToolTipText}
|
||||
url={apDexToolTipUrl}
|
||||
useFilledIcon={false}
|
||||
urlText={apDexToolTipUrlText}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
id: SERVICE_CHART_ID.apdex,
|
||||
}),
|
||||
[
|
||||
delta,
|
||||
metricsBuckets,
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
thresholdValue,
|
||||
topLevelOperationsRoute,
|
||||
],
|
||||
);
|
||||
|
||||
const threshold: ReactNode = useMemo(() => {
|
||||
if (thresholdValue) {
|
||||
return <DisplayThreshold threshold={thresholdValue} />;
|
||||
}
|
||||
return null;
|
||||
}, [thresholdValue]);
|
||||
|
||||
const isQueryEnabled =
|
||||
topLevelOperationsRoute.length > 0 &&
|
||||
!!metricsBuckets &&
|
||||
metricsBuckets?.length > 0 &&
|
||||
delta !== undefined;
|
||||
|
||||
return (
|
||||
<Graph
|
||||
widget={apDexMetricsWidget}
|
||||
onDragSelect={onDragSelect}
|
||||
onClickHandler={handleGraphClick('ApDex')}
|
||||
threshold={threshold}
|
||||
isQueryEnabled={isQueryEnabled}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ApDexMetrics.defaultProps = {
|
||||
delta: undefined,
|
||||
le: undefined,
|
||||
};
|
||||
|
||||
export default ApDexMetrics;
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { useGetMetricMeta } from 'hooks/apDex/useGetMetricMeta';
|
||||
import useErrorNotification from 'hooks/useErrorNotification';
|
||||
|
||||
import { WidgetKeys } from '../../../constant';
|
||||
import ApDexMetrics from './ApDexMetrics';
|
||||
import { ApDexDataSwitcherProps } from './types';
|
||||
|
||||
function ApDexMetricsApplication({
|
||||
handleGraphClick,
|
||||
onDragSelect,
|
||||
tagFilterItems,
|
||||
thresholdValue,
|
||||
topLevelOperationsRoute,
|
||||
}: ApDexDataSwitcherProps): JSX.Element {
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
|
||||
const { data, isLoading, error } = useGetMetricMeta(
|
||||
WidgetKeys.SignozLatencyBucket,
|
||||
servicename,
|
||||
);
|
||||
useErrorNotification(error);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner height="40vh" tip="Loading..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ApDexMetrics
|
||||
topLevelOperationsRoute={topLevelOperationsRoute}
|
||||
handleGraphClick={handleGraphClick}
|
||||
delta={data?.data.delta}
|
||||
metricsBuckets={data?.data.le || []}
|
||||
onDragSelect={onDragSelect}
|
||||
tagFilterItems={tagFilterItems}
|
||||
thresholdValue={thresholdValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApDexMetricsApplication;
|
||||
@@ -1,55 +0,0 @@
|
||||
import { memo, useEffect } from 'react';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { Card, GraphContainer } from 'container/MetricsApplication/styles';
|
||||
import { useGetApDexSettings } from 'hooks/apDex/useGetApDexSettings';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import ApDexMetricsApplication from './ApDexMetricsApplication';
|
||||
import { ApDexApplicationProps } from './types';
|
||||
|
||||
function ApDexApplication({
|
||||
handleGraphClick,
|
||||
onDragSelect,
|
||||
topLevelOperationsRoute,
|
||||
tagFilterItems,
|
||||
}: ApDexApplicationProps): JSX.Element {
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { data, isLoading, error, isRefetching } =
|
||||
useGetApDexSettings(servicename);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
}
|
||||
}, [error, notifications]);
|
||||
|
||||
if (isLoading || isRefetching) {
|
||||
return (
|
||||
<Card>
|
||||
<Spinner height="40vh" tip="Loading..." />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card data-testid="apdex">
|
||||
<GraphContainer>
|
||||
<ApDexMetricsApplication
|
||||
handleGraphClick={handleGraphClick}
|
||||
onDragSelect={onDragSelect}
|
||||
topLevelOperationsRoute={topLevelOperationsRoute}
|
||||
tagFilterItems={tagFilterItems}
|
||||
thresholdValue={data?.data[0].threshold}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ApDexApplication);
|
||||
@@ -1,18 +0,0 @@
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface ApDexApplicationProps {
|
||||
handleGraphClick: (type: string) => OnClickPluginOpts['onClick'];
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
topLevelOperationsRoute: string[];
|
||||
tagFilterItems: TagFilterItem[];
|
||||
}
|
||||
|
||||
export interface ApDexDataSwitcherProps extends ApDexApplicationProps {
|
||||
thresholdValue?: number;
|
||||
}
|
||||
|
||||
export interface ApDexMetricsProps extends ApDexDataSwitcherProps {
|
||||
delta?: boolean;
|
||||
metricsBuckets?: number[];
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
.graph-controls-panel {
|
||||
position: absolute;
|
||||
z-index: 999;
|
||||
display: none;
|
||||
width: 150px;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
background: var(--l2-background);
|
||||
|
||||
.ant-btn-link {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-decoration: none;
|
||||
color: var(--l1-foreground);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ant-btn-link:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from 'antd';
|
||||
import { DraftingCompass, ScrollText, Binoculars } from '@signozhq/icons';
|
||||
|
||||
import './GraphControlsPanel.styles.scss';
|
||||
|
||||
interface GraphControlsPanelProps {
|
||||
id: string;
|
||||
onViewLogsClick?: (e: React.MouseEvent) => void;
|
||||
onViewTracesClick: (e: React.MouseEvent) => void;
|
||||
onViewAPIMonitoringClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function GraphControlsPanel({
|
||||
id,
|
||||
onViewLogsClick,
|
||||
onViewTracesClick,
|
||||
onViewAPIMonitoringClick,
|
||||
}: GraphControlsPanelProps): JSX.Element {
|
||||
return (
|
||||
<div id={id} className="graph-controls-panel">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<DraftingCompass size={14} />}
|
||||
size="small"
|
||||
onClick={onViewTracesClick}
|
||||
style={{ color: Color.BG_VANILLA_100 }}
|
||||
>
|
||||
View traces
|
||||
</Button>
|
||||
{onViewLogsClick && (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ScrollText size={14} />}
|
||||
size="small"
|
||||
onClick={onViewLogsClick}
|
||||
style={{ color: Color.BG_VANILLA_100 }}
|
||||
>
|
||||
View logs
|
||||
</Button>
|
||||
)}
|
||||
{onViewAPIMonitoringClick && (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<Binoculars size={14} />}
|
||||
size="small"
|
||||
onClick={onViewAPIMonitoringClick}
|
||||
style={{ color: Color.BG_VANILLA_100 }}
|
||||
>
|
||||
View External APIs
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
GraphControlsPanel.defaultProps = {
|
||||
onViewLogsClick: undefined,
|
||||
onViewAPIMonitoringClick: undefined,
|
||||
};
|
||||
|
||||
export default GraphControlsPanel;
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import Graph from 'container/WidgetCard/Card';
|
||||
import {
|
||||
GraphTitle,
|
||||
SERVICE_CHART_ID,
|
||||
SERVICE_DETAIL_DRILLDOWN_ENABLED,
|
||||
} from 'container/MetricsApplication/constant';
|
||||
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
|
||||
import { latency } from 'container/MetricsApplication/MetricsPageQueries/OverviewQueries';
|
||||
import { Card, GraphContainer } from 'container/MetricsApplication/styles';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { resourceAttributesToTagFilterItems } from 'hooks/useResourceAttribute/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
handleNonInQueryRange,
|
||||
onViewTracePopupClick,
|
||||
useGetAPMToLogsQueries,
|
||||
useGetAPMToTracesQueries,
|
||||
} from '../util';
|
||||
import GraphControlsPanel from './GraphControlsPanel/GraphControlsPanel';
|
||||
|
||||
function ServiceOverview({
|
||||
onDragSelect,
|
||||
handleGraphClick,
|
||||
selectedTraceTags,
|
||||
selectedTimeStamp,
|
||||
topLevelOperationsRoute,
|
||||
topLevelOperationsIsLoading,
|
||||
stepInterval,
|
||||
}: ServiceOverviewProps): JSX.Element {
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
|
||||
const { featureFlags } = useAppContext();
|
||||
const isSpanMetricEnable =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_SPAN_METRICS)
|
||||
?.active || false;
|
||||
|
||||
const { queries } = useResourceAttribute();
|
||||
|
||||
const tagFilterItems = useMemo(
|
||||
() =>
|
||||
handleNonInQueryRange(
|
||||
resourceAttributesToTagFilterItems(queries, !isSpanMetricEnable),
|
||||
) || [],
|
||||
[isSpanMetricEnable, queries],
|
||||
);
|
||||
|
||||
const latencyWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: latency({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
isSpanMetricEnable,
|
||||
topLevelOperationsRoute,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.LATENCY,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: 'ns',
|
||||
id: SERVICE_CHART_ID.latency,
|
||||
}),
|
||||
[isSpanMetricEnable, servicename, tagFilterItems, topLevelOperationsRoute],
|
||||
);
|
||||
|
||||
const isQueryEnabled =
|
||||
!topLevelOperationsIsLoading && topLevelOperationsRoute.length > 0;
|
||||
|
||||
const apmToTraceQuery = useGetAPMToTracesQueries({ servicename });
|
||||
|
||||
const apmToLogQuery = useGetAPMToLogsQueries({ servicename });
|
||||
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<GraphControlsPanel
|
||||
id="Service_button"
|
||||
onViewLogsClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery: apmToLogQuery,
|
||||
isViewLogsClicked: true,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
onViewTracesClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
/>
|
||||
<Card data-testid="service_latency">
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
onDragSelect={onDragSelect}
|
||||
widget={latencyWidget}
|
||||
onClickHandler={handleGraphClick('Service')}
|
||||
isQueryEnabled={isQueryEnabled}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
interface ServiceOverviewProps {
|
||||
stepInterval: number;
|
||||
selectedTimeStamp: number;
|
||||
selectedTraceTags: string;
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
handleGraphClick: (type: string) => OnClickPluginOpts['onClick'];
|
||||
topLevelOperationsRoute: string[];
|
||||
topLevelOperationsIsLoading: boolean;
|
||||
}
|
||||
|
||||
export default ServiceOverview;
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { navigateToTrace } from 'container/MetricsApplication/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { useGetAPMToTracesQueries } from '../../util';
|
||||
|
||||
function ColumnWithLink({
|
||||
servicename,
|
||||
minTime,
|
||||
maxTime,
|
||||
selectedTraceTags,
|
||||
record,
|
||||
}: LinkColumnProps): JSX.Element {
|
||||
const text = record.toString();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const apmToTraceQuery = useGetAPMToTracesQueries({
|
||||
servicename,
|
||||
filters: [
|
||||
{
|
||||
id: uuid().slice(0, 8),
|
||||
key: {
|
||||
key: 'name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'name--string--tag--true',
|
||||
},
|
||||
op: 'in',
|
||||
value: [text],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const handleOnClick = (operation: string, openInNewTab: boolean): void => {
|
||||
navigateToTrace({
|
||||
servicename,
|
||||
operation,
|
||||
minTime,
|
||||
maxTime,
|
||||
selectedTraceTags,
|
||||
apmToTraceQuery,
|
||||
safeNavigate,
|
||||
openInNewTab,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip placement="topLeft" title={text}>
|
||||
<Typography.Link
|
||||
onClick={(e): void => handleOnClick(text, e.metaKey || e.ctrlKey)}
|
||||
>
|
||||
{text}
|
||||
</Typography.Link>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface LinkColumnProps {
|
||||
servicename: string;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
selectedTraceTags: string;
|
||||
record: RowData;
|
||||
}
|
||||
|
||||
export default ColumnWithLink;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
|
||||
import { TableRendererProps } from '../../types';
|
||||
|
||||
export const getTableColumnRenderer = ({
|
||||
columnName,
|
||||
renderFunction,
|
||||
}: TableRendererProps): Record<string, (record: RowData) => ReactNode> => ({
|
||||
[columnName]: renderFunction,
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import axios from 'axios';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import Graph from 'container/WidgetCard/Card';
|
||||
import { SERVICE_DETAIL_DRILLDOWN_ENABLED } from 'container/MetricsApplication/constant';
|
||||
import { Card, GraphContainer } from 'container/MetricsApplication/styles';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
|
||||
function TopLevelOperation({
|
||||
name,
|
||||
opName,
|
||||
topLevelOperationsIsError,
|
||||
topLevelOperationsError,
|
||||
onDragSelect,
|
||||
handleGraphClick,
|
||||
widget,
|
||||
topLevelOperationsIsLoading,
|
||||
}: TopLevelOperationProps): JSX.Element {
|
||||
return (
|
||||
<Card data-testid={name}>
|
||||
{topLevelOperationsIsError ? (
|
||||
<Typography>
|
||||
{axios.isAxiosError(topLevelOperationsError)
|
||||
? topLevelOperationsError.response?.data
|
||||
: SOMETHING_WENT_WRONG}
|
||||
</Typography>
|
||||
) : (
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
widget={widget}
|
||||
onClickHandler={handleGraphClick(opName)}
|
||||
onDragSelect={onDragSelect}
|
||||
isQueryEnabled={!topLevelOperationsIsLoading}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
</GraphContainer>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface TopLevelOperationProps {
|
||||
name: string;
|
||||
opName: string;
|
||||
topLevelOperationsIsError: boolean;
|
||||
topLevelOperationsError: unknown;
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
handleGraphClick: (type: string) => OnClickPluginOpts['onClick'];
|
||||
widget: Widgets;
|
||||
topLevelOperationsIsLoading: boolean;
|
||||
}
|
||||
|
||||
export default TopLevelOperation;
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import getTopOperations from 'api/metrics/getTopOperations';
|
||||
import TopOperationsTable from 'container/MetricsApplication/TopOperationsTable';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { PayloadProps } from 'types/api/metrics/getTopOperations';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { Tags } from 'hooks/useResourceAttribute/types';
|
||||
|
||||
function TopOperation(): JSX.Element {
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const { servicename: encodedServiceName } = useAppParams<{
|
||||
servicename?: string;
|
||||
}>();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
|
||||
const [isEntryPoint, setIsEntryPoint] = useState<boolean>(false);
|
||||
|
||||
const { queries } = useResourceAttribute();
|
||||
const selectedTags = useMemo(
|
||||
() => (convertRawQueriesToTraceSelectedTags(queries) as Tags[]) || [],
|
||||
[queries],
|
||||
);
|
||||
|
||||
const { data, isLoading } = useQuery<PayloadProps>({
|
||||
queryKey: [minTime, maxTime, servicename, selectedTags, isEntryPoint],
|
||||
queryFn: (): Promise<PayloadProps> =>
|
||||
getTopOperations({
|
||||
service: servicename || '',
|
||||
start: minTime,
|
||||
end: maxTime,
|
||||
selectedTags,
|
||||
isEntryPoint,
|
||||
}),
|
||||
});
|
||||
|
||||
const topOperationData = data || [];
|
||||
|
||||
return (
|
||||
<TopOperationsTable
|
||||
data={topOperationData}
|
||||
isLoading={isLoading}
|
||||
isEntryPoint={isEntryPoint}
|
||||
onEntryPointToggle={setIsEntryPoint}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopOperation;
|
||||
@@ -1,123 +0,0 @@
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { topOperationMetricsDownloadOptions } from 'container/MetricsApplication/constant';
|
||||
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
|
||||
import { topOperationQueries } from 'container/MetricsApplication/MetricsPageQueries/TopOperationQueries';
|
||||
import { QueryTable } from 'container/QueryTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { title } from './config';
|
||||
import ColumnWithLink from './TableRenderer/ColumnWithLink';
|
||||
import { getTableColumnRenderer } from './TableRenderer/TableColumnRenderer';
|
||||
|
||||
function TopOperationMetrics(): JSX.Element {
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const {
|
||||
minTime,
|
||||
maxTime,
|
||||
selectedTime: globalSelectedInterval,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { queries } = useResourceAttribute();
|
||||
|
||||
const selectedTraceTags = JSON.stringify(
|
||||
convertRawQueriesToTraceSelectedTags(queries) || [],
|
||||
);
|
||||
|
||||
const keyOperationWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: topOperationQueries({
|
||||
servicename,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
panelTypes: PANEL_TYPES.TABLE,
|
||||
}),
|
||||
[servicename],
|
||||
);
|
||||
|
||||
const updatedQuery = updateStepInterval(keyOperationWidget.query);
|
||||
|
||||
const isEmptyWidget = keyOperationWidget.id === PANEL_TYPES.EMPTY_WIDGET;
|
||||
|
||||
const { data, isLoading } = useGetQueryRange(
|
||||
{
|
||||
selectedTime: keyOperationWidget?.timePreferance,
|
||||
graphType: keyOperationWidget?.panelTypes,
|
||||
query: updatedQuery,
|
||||
globalSelectedInterval,
|
||||
variables: {},
|
||||
},
|
||||
ENTITY_VERSION_V4,
|
||||
{
|
||||
queryKey: [
|
||||
`GetMetricsQueryRange-${keyOperationWidget?.timePreferance}-${globalSelectedInterval}-${keyOperationWidget?.id}`,
|
||||
keyOperationWidget,
|
||||
maxTime,
|
||||
minTime,
|
||||
globalSelectedInterval,
|
||||
],
|
||||
keepPreviousData: true,
|
||||
enabled: !isEmptyWidget,
|
||||
refetchOnMount: false,
|
||||
onError: (error) => {
|
||||
notifications.error({ message: error.message });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const queryTableData = data?.payload?.data?.newResult?.data?.result || [];
|
||||
|
||||
const renderColumnCell = useMemo(
|
||||
() =>
|
||||
getTableColumnRenderer({
|
||||
columnName: 'operation',
|
||||
renderFunction: (record: RowData): ReactNode => (
|
||||
<ColumnWithLink
|
||||
servicename={servicename}
|
||||
minTime={minTime}
|
||||
maxTime={maxTime}
|
||||
selectedTraceTags={selectedTraceTags}
|
||||
record={record}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[servicename, minTime, maxTime, selectedTraceTags],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryTable
|
||||
title={title}
|
||||
query={updatedQuery}
|
||||
queryTableData={queryTableData}
|
||||
loading={isLoading}
|
||||
renderColumnCell={renderColumnCell}
|
||||
downloadOption={topOperationMetricsDownloadOptions}
|
||||
sticky
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopOperationMetrics;
|
||||
@@ -1 +0,0 @@
|
||||
export const title = (): string => 'Key Operations';
|
||||
@@ -1,5 +1,3 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
@@ -8,19 +6,6 @@ export interface IServiceName {
|
||||
servicename: string;
|
||||
}
|
||||
|
||||
export interface TopOperationQueryFactoryProps {
|
||||
servicename: IServiceName['servicename'];
|
||||
}
|
||||
|
||||
export interface ExternalCallDurationByAddressProps extends ExternalCallProps {
|
||||
legend: string;
|
||||
}
|
||||
|
||||
export interface ExternalCallProps {
|
||||
servicename: IServiceName['servicename'];
|
||||
tagFilterItems: TagFilterItem[];
|
||||
}
|
||||
|
||||
export interface BuilderQueriesProps {
|
||||
autocompleteData: BaseAutocompleteData[];
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
@@ -45,33 +30,3 @@ export interface BuilderQuerieswithFormulaProps {
|
||||
spaceAggregateOperators: MetricAggregateOperator[];
|
||||
dataSource: DataSource;
|
||||
}
|
||||
|
||||
export interface OperationPerSecProps {
|
||||
servicename: IServiceName['servicename'];
|
||||
tagFilterItems: TagFilterItem[];
|
||||
topLevelOperations: string[];
|
||||
}
|
||||
|
||||
export interface LatencyProps {
|
||||
servicename: IServiceName['servicename'];
|
||||
tagFilterItems: TagFilterItem[];
|
||||
isSpanMetricEnable?: boolean;
|
||||
topLevelOperationsRoute: string[];
|
||||
}
|
||||
|
||||
export interface ApDexProps {
|
||||
servicename: IServiceName['servicename'];
|
||||
tagFilterItems: TagFilterItem[];
|
||||
topLevelOperationsRoute: string[];
|
||||
threashold: number;
|
||||
}
|
||||
|
||||
export interface TableRendererProps {
|
||||
columnName: string;
|
||||
renderFunction: (record: RowData) => ReactNode;
|
||||
}
|
||||
|
||||
export interface ApDexMetricsQueryBuilderQueriesProps extends ApDexProps {
|
||||
delta: boolean;
|
||||
metricsBuckets: number[];
|
||||
}
|
||||
|
||||
@@ -1,171 +1,9 @@
|
||||
import React, { Dispatch, SetStateAction, useMemo, useRef } from 'react';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ENDPOINT_STATS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
|
||||
import { routeConfig } from 'container/SideNav/config';
|
||||
import { getQueryString } from 'container/SideNav/helper';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Dispatch, SetStateAction, useRef } from 'react';
|
||||
import useClickOutside from 'hooks/useClickOutside';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { resourceAttributesToTracesFilterItems } from 'hooks/useResourceAttribute/utils';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { prepareQueryWithDefaultTimestamp } from 'pages/LogsExplorer/utils';
|
||||
import { traceFilterKeys } from 'constants/traceFilterKeys';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Tags } from 'hooks/useResourceAttribute/types';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { secondsToMilliseconds } from 'utils/timeUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export const dbSystemTags: Tags[] = [
|
||||
{
|
||||
Key: 'db.system.(string)',
|
||||
StringValues: [''],
|
||||
NumberValues: [],
|
||||
BoolValues: [],
|
||||
Operator: 'Exists',
|
||||
},
|
||||
];
|
||||
|
||||
interface OnViewTracePopupClickProps {
|
||||
servicename: string | undefined;
|
||||
selectedTraceTags: string;
|
||||
timestamp: number;
|
||||
apmToTraceQuery: Query;
|
||||
isViewLogsClicked?: boolean;
|
||||
stepInterval?: number;
|
||||
safeNavigate: (url: string, options?: { newTab?: boolean }) => void;
|
||||
}
|
||||
|
||||
interface OnViewAPIMonitoringPopupClickProps {
|
||||
servicename: string;
|
||||
timestamp: number;
|
||||
stepInterval?: number;
|
||||
domainName: string;
|
||||
isError: boolean;
|
||||
safeNavigate: (url: string, options?: { newTab?: boolean }) => void;
|
||||
}
|
||||
|
||||
export function generateExplorerPath(
|
||||
isViewLogsClicked: boolean | undefined,
|
||||
urlParams: URLSearchParams,
|
||||
servicename: string | undefined,
|
||||
selectedTraceTags: string,
|
||||
JSONCompositeQuery: string,
|
||||
queryString: string[],
|
||||
): string {
|
||||
const basePath = isViewLogsClicked
|
||||
? ROUTES.LOGS_EXPLORER
|
||||
: ROUTES.TRACES_EXPLORER;
|
||||
|
||||
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
|
||||
QueryParams.compositeQuery
|
||||
}=${JSONCompositeQuery}&${queryString.join('&')}`;
|
||||
}
|
||||
|
||||
// TODO(@rahul-signoz): update the name of this function once we have view logs button in every panel
|
||||
|
||||
/**
|
||||
* Handles click events for viewing trace/logs popup
|
||||
* @param selectedTraceTags - Selected trace tags
|
||||
* @param servicename - Name of the service
|
||||
* @param timestamp - Timestamp in seconds
|
||||
* @param apmToTraceQuery - Query object
|
||||
* @param isViewLogsClicked - Whether this is for viewing logs vs traces
|
||||
* @param stepInterval - Time interval in seconds
|
||||
* @param safeNavigate - Navigation function
|
||||
|
||||
*/
|
||||
export function onViewTracePopupClick({
|
||||
selectedTraceTags,
|
||||
servicename,
|
||||
timestamp,
|
||||
apmToTraceQuery,
|
||||
isViewLogsClicked,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
}: OnViewTracePopupClickProps): (e?: React.MouseEvent) => void {
|
||||
return (e?: React.MouseEvent): void => {
|
||||
const endTime = secondsToMilliseconds(timestamp);
|
||||
const startTime = secondsToMilliseconds(timestamp - (stepInterval || 60));
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
urlParams.set(QueryParams.startTime, startTime.toString());
|
||||
urlParams.set(QueryParams.endTime, endTime.toString());
|
||||
urlParams.delete(QueryParams.relativeTime);
|
||||
const avialableParams = routeConfig[ROUTES.TRACES_EXPLORER];
|
||||
const queryString = getQueryString(avialableParams, urlParams);
|
||||
|
||||
const JSONCompositeQuery = encodeURIComponent(
|
||||
JSON.stringify(apmToTraceQuery),
|
||||
);
|
||||
|
||||
const newPath = generateExplorerPath(
|
||||
isViewLogsClicked,
|
||||
urlParams,
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
JSONCompositeQuery,
|
||||
queryString,
|
||||
);
|
||||
|
||||
safeNavigate(newPath, { newTab: !!e && isModifierKeyPressed(e) });
|
||||
};
|
||||
}
|
||||
|
||||
const generateAPIMonitoringPath = (
|
||||
domainName: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
filterExpression: string,
|
||||
): string => {
|
||||
const basePath = ROUTES.API_MONITORING;
|
||||
return `${basePath}?${createQueryParams({
|
||||
apiMonitoringParams: JSON.stringify({
|
||||
selectedDomain: domainName,
|
||||
selectedView: 'endpoint_stats',
|
||||
modalTimeRange: {
|
||||
startTime,
|
||||
endTime,
|
||||
},
|
||||
selectedInterval: 'custom',
|
||||
}),
|
||||
[ENDPOINT_STATS_EXPRESSION_KEY]: filterExpression,
|
||||
})}`;
|
||||
};
|
||||
export function onViewAPIMonitoringPopupClick({
|
||||
servicename,
|
||||
timestamp,
|
||||
domainName,
|
||||
isError,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
}: OnViewAPIMonitoringPopupClickProps): (e?: React.MouseEvent) => void {
|
||||
return (e?: React.MouseEvent): void => {
|
||||
const endTime = timestamp + (stepInterval || 60);
|
||||
const startTime = timestamp - (stepInterval || 60);
|
||||
const filterExpression = [
|
||||
...(isError ? ["hasError IN ['true']"] : []),
|
||||
`service.name = ${formatValueForExpression(servicename)}`,
|
||||
].join(' AND ');
|
||||
const newPath = generateAPIMonitoringPath(
|
||||
domainName,
|
||||
startTime,
|
||||
endTime,
|
||||
filterExpression,
|
||||
);
|
||||
|
||||
safeNavigate(newPath, { newTab: !!e && isModifierKeyPressed(e) });
|
||||
};
|
||||
}
|
||||
|
||||
export function useGraphClickHandler(
|
||||
setSelectedTimeStamp: (n: number) => void | Dispatch<SetStateAction<number>>,
|
||||
setSelectedData?: (data: any) => void | Dispatch<SetStateAction<any>>,
|
||||
@@ -217,17 +55,6 @@ export function useGraphClickHandler(
|
||||
};
|
||||
}
|
||||
|
||||
export const handleNonInQueryRange = (tags: TagFilterItem[]): TagFilterItem[] =>
|
||||
tags.map((tag) => {
|
||||
if (tag.op === 'Not IN') {
|
||||
return {
|
||||
...tag,
|
||||
op: 'NIN',
|
||||
};
|
||||
}
|
||||
return tag;
|
||||
});
|
||||
|
||||
export function handleQueryChange(
|
||||
query: Query,
|
||||
attributeKeys: BaseAutocompleteData,
|
||||
@@ -258,114 +85,3 @@ export function handleQueryChange(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useGetAPMToLogsQueries({
|
||||
servicename,
|
||||
filters,
|
||||
}: {
|
||||
servicename: string;
|
||||
filters?: TagFilterItem[];
|
||||
}): Query {
|
||||
const finalFilters: TagFilterItem[] = [];
|
||||
const { updateAllQueriesOperators } = useQueryBuilder();
|
||||
let updatedQuery = updateAllQueriesOperators(
|
||||
initialQueriesMap.logs,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.LOGS,
|
||||
);
|
||||
const serviceName = {
|
||||
id: 'service.name--string--resource--true',
|
||||
dataType: DataTypes.String,
|
||||
key: 'service.name',
|
||||
type: 'resource',
|
||||
};
|
||||
|
||||
if (filters?.length) {
|
||||
finalFilters.push(...filters);
|
||||
}
|
||||
updatedQuery = prepareQueryWithDefaultTimestamp(updatedQuery);
|
||||
return handleQueryChange(
|
||||
updatedQuery,
|
||||
serviceName,
|
||||
servicename,
|
||||
finalFilters,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
export function useGetAPMToTracesQueries({
|
||||
servicename,
|
||||
isExternalCall,
|
||||
isDBCall,
|
||||
filters,
|
||||
}: {
|
||||
servicename: string;
|
||||
isExternalCall?: boolean;
|
||||
isDBCall?: boolean;
|
||||
filters?: TagFilterItem[];
|
||||
}): Query {
|
||||
const { updateAllQueriesOperators } = useQueryBuilder();
|
||||
const { queries } = useResourceAttribute();
|
||||
|
||||
const resourceAttributesFilters = useMemo(
|
||||
() => resourceAttributesToTracesFilterItems(queries),
|
||||
[queries],
|
||||
);
|
||||
const finalFilters: TagFilterItem[] = [];
|
||||
let spanKindFilter: TagFilterItem;
|
||||
let dbCallFilter: TagFilterItem;
|
||||
|
||||
if (isExternalCall) {
|
||||
spanKindFilter = {
|
||||
id: uuid().slice(0, 8),
|
||||
key: {
|
||||
key: 'spanKind',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'spanKind--string--tag--true',
|
||||
},
|
||||
op: '=',
|
||||
value: 'Client',
|
||||
};
|
||||
finalFilters.push(spanKindFilter);
|
||||
}
|
||||
|
||||
if (isDBCall) {
|
||||
dbCallFilter = {
|
||||
id: uuid().slice(0, 8),
|
||||
key: {
|
||||
key: 'dbSystem',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'dbSystem--string--tag--true',
|
||||
},
|
||||
op: 'exists',
|
||||
value: '',
|
||||
};
|
||||
finalFilters.push(dbCallFilter);
|
||||
}
|
||||
|
||||
if (filters?.length) {
|
||||
finalFilters.push(...filters);
|
||||
}
|
||||
|
||||
if (resourceAttributesFilters?.length) {
|
||||
finalFilters.push(...resourceAttributesFilters);
|
||||
}
|
||||
|
||||
return useMemo(() => {
|
||||
const updatedQuery = updateAllQueriesOperators(
|
||||
initialQueriesMap.traces,
|
||||
PANEL_TYPES.TRACE,
|
||||
DataSource.TRACES,
|
||||
);
|
||||
|
||||
return handleQueryChange(
|
||||
updatedQuery,
|
||||
traceFilterKeys.serviceName,
|
||||
servicename,
|
||||
finalFilters,
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [servicename, queries, updateAllQueriesOperators]);
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
.top-operation {
|
||||
position: relative;
|
||||
|
||||
&__controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 0px;
|
||||
z-index: 1;
|
||||
gap: 8px;
|
||||
}
|
||||
&__entry-point {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
&__download {
|
||||
.ant-btn-icon {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import { useRef } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import { Search } from '@signozhq/icons';
|
||||
import {
|
||||
InputRef,
|
||||
TableColumnsType as ColumnsType,
|
||||
TableColumnType as ColumnType,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import Download from 'container/Download/Download';
|
||||
import { filterDropdown } from 'container/ServiceApplication/Filter/FilterDropdown';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { useGetAPMToTracesQueries } from './Tabs/util';
|
||||
import {
|
||||
convertedTracesToDownloadData,
|
||||
getErrorRate,
|
||||
navigateToTrace,
|
||||
} from './utils';
|
||||
|
||||
import './TopOperationsTable.styles.scss';
|
||||
|
||||
function TopOperationsTable({
|
||||
data,
|
||||
isLoading,
|
||||
isEntryPoint,
|
||||
onEntryPointToggle,
|
||||
}: TopOperationsTableProps): JSX.Element {
|
||||
const searchInput = useRef<InputRef>(null);
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const { queries } = useResourceAttribute();
|
||||
|
||||
const selectedTraceTags: string = JSON.stringify(
|
||||
convertRawQueriesToTraceSelectedTags(queries) || [],
|
||||
);
|
||||
|
||||
const apmToTraceQuery = useGetAPMToTracesQueries({ servicename });
|
||||
|
||||
const params = useAppParams<'servicename'>();
|
||||
|
||||
const handleOnClick = (operation: string, openInNewTab: boolean): void => {
|
||||
const { servicename: encodedServiceName } = params;
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
|
||||
const opFilters: TagFilterItem[] = [
|
||||
{
|
||||
id: uuid().slice(0, 8),
|
||||
key: {
|
||||
key: 'name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'name--string--tag--true',
|
||||
},
|
||||
op: 'in',
|
||||
value: [operation],
|
||||
},
|
||||
];
|
||||
|
||||
const preparedQuery: Query = {
|
||||
...apmToTraceQuery,
|
||||
builder: {
|
||||
...apmToTraceQuery.builder,
|
||||
queryData: apmToTraceQuery.builder.queryData?.map((item) => ({
|
||||
...item,
|
||||
filters: {
|
||||
...item.filters,
|
||||
items: [...(item.filters?.items || []), ...opFilters],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
navigateToTrace({
|
||||
servicename,
|
||||
operation,
|
||||
minTime,
|
||||
maxTime,
|
||||
selectedTraceTags,
|
||||
apmToTraceQuery: preparedQuery,
|
||||
safeNavigate,
|
||||
openInNewTab,
|
||||
});
|
||||
};
|
||||
|
||||
const getSearchOption = (): ColumnType<TopOperationList> => ({
|
||||
filterDropdown,
|
||||
filterIcon: <Search size="md" />,
|
||||
onFilter: (value, record): boolean =>
|
||||
record.name
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.includes((value as string).toLowerCase()),
|
||||
onFilterDropdownOpenChange: (visible): void => {
|
||||
if (visible) {
|
||||
setTimeout(() => searchInput.current?.select(), 100);
|
||||
}
|
||||
},
|
||||
render: (text: string): JSX.Element => (
|
||||
<Tooltip placement="topLeft" title={text}>
|
||||
<Typography.Link
|
||||
testId="top-operation-link"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
handleOnClick(text, true); // open in new tab
|
||||
} else {
|
||||
handleOnClick(text, false); // open in current tab
|
||||
}
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography.Link>
|
||||
</Tooltip>
|
||||
),
|
||||
});
|
||||
|
||||
const columns: ColumnsType<TopOperationList> = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 100,
|
||||
...getSearchOption(),
|
||||
},
|
||||
{
|
||||
title: 'P50 (in ms)',
|
||||
dataIndex: 'p50',
|
||||
key: 'p50',
|
||||
width: 50,
|
||||
sorter: (a: TopOperationList, b: TopOperationList): number => a.p50 - b.p50,
|
||||
render: (value: number): string => (value / 1_000_000).toFixed(2),
|
||||
},
|
||||
{
|
||||
title: 'P95 (in ms)',
|
||||
dataIndex: 'p95',
|
||||
key: 'p95',
|
||||
width: 50,
|
||||
sorter: (a: TopOperationList, b: TopOperationList): number => a.p95 - b.p95,
|
||||
render: (value: number): string => (value / 1_000_000).toFixed(2),
|
||||
},
|
||||
{
|
||||
title: 'P99 (in ms)',
|
||||
dataIndex: 'p99',
|
||||
key: 'p99',
|
||||
width: 50,
|
||||
sorter: (a: TopOperationList, b: TopOperationList): number => a.p99 - b.p99,
|
||||
render: (value: number): string => (value / 1_000_000).toFixed(2),
|
||||
},
|
||||
{
|
||||
title: 'Number of Calls',
|
||||
dataIndex: 'numCalls',
|
||||
key: 'numCalls',
|
||||
width: 50,
|
||||
sorter: (a: TopOperationList, b: TopOperationList): number =>
|
||||
a.numCalls - b.numCalls,
|
||||
},
|
||||
{
|
||||
title: 'Error Rate',
|
||||
dataIndex: 'errorCount',
|
||||
key: 'errorCount',
|
||||
width: 50,
|
||||
sorter: (first: TopOperationList, second: TopOperationList): number =>
|
||||
getErrorRate(first) - getErrorRate(second),
|
||||
render: (_, record: TopOperationList): string =>
|
||||
`${getErrorRate(record).toFixed(2)} %`,
|
||||
},
|
||||
];
|
||||
|
||||
const downloadableData = convertedTracesToDownloadData(data);
|
||||
|
||||
const paginationConfig = {
|
||||
pageSize: 10,
|
||||
showSizeChanger: false,
|
||||
hideOnSinglePage: true,
|
||||
};
|
||||
|
||||
const entryPointSpanInfo = {
|
||||
text: 'Shows the spans where requests enter new services for the first time',
|
||||
url: 'https://signoz.io/docs/apm-and-distributed-tracing/application-details/',
|
||||
urlText: 'Learn more about Entrypoint Spans.',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="top-operation" data-testid="top-operations">
|
||||
<div className="top-operation__controls">
|
||||
<div className="top-operation__download">
|
||||
<Download
|
||||
data={downloadableData}
|
||||
isLoading={isLoading}
|
||||
fileName={`top-operations-${servicename}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="top-operation__entry-point">
|
||||
<Switch
|
||||
testId="top-operations-entrypoint-toggle"
|
||||
aria-label="Entrypoint Spans"
|
||||
color="primary"
|
||||
textPlacement="right"
|
||||
value={isEntryPoint}
|
||||
onChange={onEntryPointToggle}
|
||||
/>
|
||||
<span className="top-operation__entry-point-label">Entrypoint Spans</span>
|
||||
<TextToolTip
|
||||
text={entryPointSpanInfo.text}
|
||||
url={entryPointSpanInfo.url}
|
||||
useFilledIcon={false}
|
||||
urlText={entryPointSpanInfo.urlText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
showHeader
|
||||
title={(): string =>
|
||||
isEntryPoint ? 'Key Entrypoint Operations' : 'Key Operations'
|
||||
}
|
||||
tableLayout="fixed"
|
||||
dataSource={data}
|
||||
rowKey="name"
|
||||
pagination={paginationConfig}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TopOperationList {
|
||||
p50: number;
|
||||
p95: number;
|
||||
p99: number;
|
||||
numCalls: number;
|
||||
name: string;
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
interface TopOperationsTableProps {
|
||||
data: TopOperationList[];
|
||||
isLoading: boolean;
|
||||
isEntryPoint: boolean;
|
||||
onEntryPointToggle: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export default TopOperationsTable;
|
||||
@@ -1,78 +0,0 @@
|
||||
import { QueryClient } from 'react-query';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import { TopOperationList } from '../TopOperationsTable';
|
||||
|
||||
interface TopOperation {
|
||||
numCalls: number;
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
export const getTopOperationList = ({
|
||||
errorCount,
|
||||
numCalls,
|
||||
}: TopOperation): TopOperationList =>
|
||||
({
|
||||
p50: 0,
|
||||
errorCount,
|
||||
name: 'test',
|
||||
numCalls,
|
||||
p95: 0,
|
||||
p99: 0,
|
||||
}) as TopOperationList;
|
||||
|
||||
export const defaultApiCallExpectation = {
|
||||
service: 'test-service',
|
||||
start: 1640995200000,
|
||||
end: 1641081600000,
|
||||
selectedTags: [],
|
||||
isEntryPoint: false,
|
||||
};
|
||||
|
||||
export const mockStore = configureStore([]);
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const mockTopOperationsData: TopOperationList[] = [
|
||||
{
|
||||
name: 'GET /api/users',
|
||||
p50: 1000000,
|
||||
p95: 2000000,
|
||||
p99: 3000000,
|
||||
numCalls: 100,
|
||||
errorCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'POST /api/orders',
|
||||
p50: 1500000,
|
||||
p95: 2500000,
|
||||
p99: 3500000,
|
||||
numCalls: 80,
|
||||
errorCount: 2,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockEntryPointData: TopOperationList[] = [
|
||||
{
|
||||
name: 'GET /api/health',
|
||||
p50: 500000,
|
||||
p95: 1000000,
|
||||
p99: 1500000,
|
||||
numCalls: 200,
|
||||
errorCount: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export const createMockStore = (): any =>
|
||||
mockStore({
|
||||
globalTime: {
|
||||
minTime: 1640995200000,
|
||||
maxTime: 1641081600000,
|
||||
},
|
||||
});
|
||||
@@ -1,301 +0,0 @@
|
||||
import { QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { mockAppParams } from 'tests/router-mocks';
|
||||
|
||||
import {
|
||||
createMockStore,
|
||||
defaultApiCallExpectation,
|
||||
mockEntryPointData,
|
||||
mockTopOperationsData,
|
||||
queryClient,
|
||||
} from '../__mocks__/getTopOperation';
|
||||
import TopOperation from '../Tabs/Overview/TopOperation';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('hooks/useResourceAttribute');
|
||||
jest.mock('hooks/useResourceAttribute/utils', () => ({
|
||||
convertRawQueriesToTraceSelectedTags: (): any[] => [],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
mockAppParams({ servicename: encodeURIComponent('test-service') });
|
||||
|
||||
// Mock the util functions that TopOperationsTable uses
|
||||
jest.mock('../Tabs/util', () => ({
|
||||
useGetAPMToTracesQueries: (): any => ({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the resourceAttributesToTracesFilterItems function
|
||||
const mockedUseResourceAttribute = useResourceAttribute as jest.MockedFunction<
|
||||
typeof useResourceAttribute
|
||||
>;
|
||||
|
||||
// Constants
|
||||
const KEY_OPERATIONS_TEXT = 'Key Operations';
|
||||
const KEY_ENTRY_POINT_OPERATIONS_TEXT = 'Key Entrypoint Operations';
|
||||
const ENTRY_POINT_SPANS_TEXT = 'Entrypoint Spans';
|
||||
const TOP_OPERATIONS_ENDPOINT = 'top_operations';
|
||||
const ENTRY_POINT_OPERATIONS_ENDPOINT = 'entry_point_operations';
|
||||
|
||||
const renderComponent = (store = createMockStore()): any =>
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TopOperation />
|
||||
</QueryClientProvider>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Helper function to wait for initial render and verify basic functionality
|
||||
const waitForInitialRender = async (): Promise<void> => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to click toggle and wait for data to load
|
||||
const clickToggleAndWaitForDataLoad = async (): Promise<HTMLElement> => {
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(toggleSwitch);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
return toggleSwitch;
|
||||
};
|
||||
|
||||
describe('TopOperation API Integration', () => {
|
||||
let apiCalls: { endpoint: string; body: any }[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
queryClient.clear();
|
||||
apiCalls = [];
|
||||
|
||||
mockedUseResourceAttribute.mockReturnValue({
|
||||
queries: [],
|
||||
} as any);
|
||||
|
||||
server.use(
|
||||
rest.post(
|
||||
'http://localhost/api/v2/service/top_operations',
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
apiCalls.push({ endpoint: TOP_OPERATIONS_ENDPOINT, body });
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: mockTopOperationsData }),
|
||||
);
|
||||
},
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v2/service/entry_point_operations',
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
apiCalls.push({ endpoint: ENTRY_POINT_OPERATIONS_ENDPOINT, body });
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: mockEntryPointData }),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders with default key operations on initial load', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitForInitialRender();
|
||||
|
||||
// Verify the toggle is present and unchecked
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
expect(toggleSwitch).not.toBeChecked();
|
||||
expect(screen.getByText(ENTRY_POINT_SPANS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls top_operations API on initial render', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitForInitialRender();
|
||||
|
||||
// Wait a bit more for API calls to be captured
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Verify that only the top_operations endpoint was called
|
||||
expect(apiCalls).toHaveLength(1);
|
||||
expect(apiCalls[0].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
|
||||
expect(apiCalls[0].body).toStrictEqual({
|
||||
start: `${defaultApiCallExpectation.start}`,
|
||||
end: `${defaultApiCallExpectation.end}`,
|
||||
service: defaultApiCallExpectation.service,
|
||||
tags: defaultApiCallExpectation.selectedTags,
|
||||
limit: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls entry_point_operations API when toggle is switched to entry point', async () => {
|
||||
renderComponent();
|
||||
|
||||
// Wait for initial render
|
||||
await waitForInitialRender();
|
||||
|
||||
// Wait for initial API call
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Clear previous API calls
|
||||
apiCalls = [];
|
||||
|
||||
// Toggle to entry point
|
||||
await clickToggleAndWaitForDataLoad();
|
||||
|
||||
// Wait for the API call to be captured
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Verify that the entry_point_operations endpoint was called
|
||||
expect(apiCalls).toHaveLength(1);
|
||||
expect(apiCalls[0].endpoint).toBe(ENTRY_POINT_OPERATIONS_ENDPOINT);
|
||||
expect(apiCalls[0].body).toStrictEqual({
|
||||
start: `${defaultApiCallExpectation.start}`,
|
||||
end: `${defaultApiCallExpectation.end}`,
|
||||
service: defaultApiCallExpectation.service,
|
||||
tags: defaultApiCallExpectation.selectedTags,
|
||||
limit: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('switches to entry point operations when toggle is clicked', async () => {
|
||||
renderComponent();
|
||||
|
||||
// Wait for initial render
|
||||
await waitForInitialRender();
|
||||
|
||||
// Find and click the toggle switch
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
expect(toggleSwitch).not.toBeChecked();
|
||||
|
||||
await clickToggleAndWaitForDataLoad();
|
||||
|
||||
// Check that the switch is now checked and title updates
|
||||
expect(toggleSwitch).toBeChecked();
|
||||
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls correct APIs when toggling multiple times', async () => {
|
||||
renderComponent();
|
||||
|
||||
// Wait for initial render
|
||||
await waitForInitialRender();
|
||||
|
||||
// Wait for initial API call
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Should have called top_operations initially
|
||||
expect(apiCalls).toHaveLength(1);
|
||||
expect(apiCalls[0].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
|
||||
|
||||
// Toggle to entry point
|
||||
await clickToggleAndWaitForDataLoad();
|
||||
|
||||
// Wait for the second API call
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
// Should now have called entry_point_operations
|
||||
expect(apiCalls).toHaveLength(2);
|
||||
expect(apiCalls[1].endpoint).toBe(ENTRY_POINT_OPERATIONS_ENDPOINT);
|
||||
|
||||
// Toggle back to regular operations
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
act(() => {
|
||||
fireEvent.click(toggleSwitch);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for the third API call
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
// Should have called top_operations again
|
||||
expect(apiCalls).toHaveLength(3);
|
||||
expect(apiCalls[2].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
|
||||
|
||||
expect(toggleSwitch).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('displays entry point toggle with correct label', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(ENTRY_POINT_SPANS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
expect(toggleSwitch).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches back to key operations when toggle is clicked twice', async () => {
|
||||
renderComponent();
|
||||
|
||||
// Wait for initial render
|
||||
await waitForInitialRender();
|
||||
|
||||
// Toggle on (to entry point)
|
||||
await clickToggleAndWaitForDataLoad();
|
||||
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
|
||||
// Toggle off (back to key operations)
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
act(() => {
|
||||
fireEvent.click(toggleSwitch);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(toggleSwitch).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -1,69 +1,7 @@
|
||||
import { DownloadOptions } from 'container/Download/Download.types';
|
||||
import { MenuItemKeys } from 'container/WidgetCard/Header/contants';
|
||||
import {
|
||||
MetricAggregateOperator,
|
||||
TracesAggregatorOperator,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
export const legend = {
|
||||
address: '{{address}}',
|
||||
};
|
||||
|
||||
export const QUERYNAME_AND_EXPRESSION = ['A', 'B', 'C'];
|
||||
export const LATENCY_AGGREGATEOPERATOR = [
|
||||
TracesAggregatorOperator.P50,
|
||||
TracesAggregatorOperator.P90,
|
||||
TracesAggregatorOperator.P99,
|
||||
];
|
||||
export const LATENCY_AGGREGATEOPERATOR_SPAN_METRICS = [
|
||||
MetricAggregateOperator.P50,
|
||||
MetricAggregateOperator.P90,
|
||||
MetricAggregateOperator.P99,
|
||||
];
|
||||
|
||||
export const OPERATION_LEGENDS = ['Operations'];
|
||||
|
||||
export const MENU_ITEMS = [MenuItemKeys.View, MenuItemKeys.CreateAlerts];
|
||||
|
||||
export const SERVICE_DETAIL_DRILLDOWN_ENABLED = true;
|
||||
|
||||
export enum FORMULA {
|
||||
ERROR_PERCENTAGE = 'A*100/B',
|
||||
DATABASE_CALLS_AVG_DURATION = 'A/B',
|
||||
// The apdex formula is (satisfied_count + 0.5 * tolerating_count + 0 * frustating_count) / total_count
|
||||
// The satisfied_count is B, tolerating_count is C, total_count is A
|
||||
// But why do we have (B+C)/2 instead of B + C/2?
|
||||
// The way we issue the query is latency <= threshold, which means we over count i.e
|
||||
// query B => durationNano <= 500ms
|
||||
// query C => durationNano <= 2000ms
|
||||
// Since <= 2000ms includes <= 500ms, we over count, to correct we subtract B/2
|
||||
// so the full expression would be (B + C/2) - B/2 = (B+C)/2
|
||||
// However, if you add a filter on durationNano > 500ms, (filterItemC in overviewQueries) the query would be
|
||||
// B + C/2
|
||||
APDEX_TRACES = '((B + C)/2)/A',
|
||||
// The delta span metrics store delta compared to previous reporting interval
|
||||
// but not the counts for the current interval. The bucket counts are cumulative
|
||||
APDEX_DELTA_SPAN_METRICS = '((B + C)/2)/A',
|
||||
// Cumulative span metrics store the counts for all buckets
|
||||
// so we need to subtract B/2 to correct the over counting
|
||||
APDEX_CUMULATIVE_SPAN_METRICS = '((B + C)/2)/A',
|
||||
}
|
||||
|
||||
export const TOP_LEVEL_OPERATIONS = ['{{.top_level_operations}}'];
|
||||
|
||||
export enum GraphTitle {
|
||||
APDEX = 'Apdex',
|
||||
LATENCY = 'Latency',
|
||||
RATE_PER_OPS = 'Rate (ops/s)',
|
||||
ERROR_PERCENTAGE = 'Error Percentage',
|
||||
DATABASE_CALLS_RPS = 'Database Calls RPS',
|
||||
DATABASE_CALLS_AVG_DURATION = 'Database Calls Avg Duration',
|
||||
EXTERNAL_CALL_ERROR_PERCENTAGE = 'External Call Error Percentage',
|
||||
EXTERNAL_CALL_DURATION = 'External Call duration',
|
||||
EXTERNAL_CALL_RPS_BY_ADDRESS = 'External Call RPS(by Address)',
|
||||
EXTERNAL_CALL_DURATION_BY_ADDRESS = 'External Call duration(by Address)',
|
||||
}
|
||||
|
||||
export enum KeyOperationTableHeader {
|
||||
P50 = 'P50',
|
||||
P90 = 'P90',
|
||||
@@ -101,22 +39,3 @@ export enum WidgetKeys {
|
||||
SignozLatencyBucket = 'signoz_latency.bucket',
|
||||
DbSystem = 'db.system',
|
||||
}
|
||||
|
||||
export const topOperationMetricsDownloadOptions: DownloadOptions = {
|
||||
isDownloadEnabled: true,
|
||||
fileName: 'top-operation',
|
||||
} as const;
|
||||
|
||||
export const SERVICE_CHART_ID = {
|
||||
latency: 'SERVICE_OVERVIEW_LATENCY',
|
||||
error: 'SERVICE_OVERVIEW_ERROR',
|
||||
rps: 'SERVICE_OVERVIEW_RPS',
|
||||
apdex: 'SERVICE_OVERVIEW_APDEX',
|
||||
errorPercentage: 'SERVICE_OVERVIEW_ERROR_PERCENTAGE',
|
||||
dbCallsRPS: 'SERVICE_DATABASE_CALLS_RPS',
|
||||
dbCallsAvgDuration: 'SERVICE_DATABASE_CALLS_AVG_DURATION',
|
||||
externalCallDurationByAddress: 'SERVICE_EXTERNAL_CALLS_DURATION_BY_ADDRESS',
|
||||
externalCallErrorPercentage: 'SERVICE_EXTERNAL_CALLS_ERROR_PERCENTAGE',
|
||||
externalCallDuration: 'SERVICE_EXTERNAL_CALLS_DURATION',
|
||||
externalCallRPSByAddress: 'SERVICE_EXTERNAL_CALLS_RPS_BY_ADDRESS',
|
||||
};
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import {
|
||||
Card as CardComponent,
|
||||
Col as ColComponent,
|
||||
Row as RowComponent,
|
||||
} from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Card = styled(CardComponent)`
|
||||
&&& {
|
||||
height: 40vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Row = styled(RowComponent)`
|
||||
&&& {
|
||||
padding: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Col = styled(ColComponent)`
|
||||
display &&& {
|
||||
position: relative;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ColApDexContainer = styled(ColComponent)`
|
||||
padding: 0 !important;
|
||||
`;
|
||||
|
||||
export const ColErrorContainer = styled(ColComponent)`
|
||||
padding: 2rem 0 !important;
|
||||
`;
|
||||
|
||||
export const GraphContainer = styled.div`
|
||||
min-height: calc(40vh - 40px);
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export const GraphTitle = styled(Typography)`
|
||||
&&& {
|
||||
text-align: center;
|
||||
}
|
||||
`;
|
||||
@@ -1,8 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { IServiceName } from './Tabs/types';
|
||||
|
||||
export interface GetWidgetQueryBuilderProps {
|
||||
query: Widgets['query'];
|
||||
@@ -13,23 +10,3 @@ export interface GetWidgetQueryBuilderProps {
|
||||
fillSpans?: Widgets['fillSpans'];
|
||||
columnUnits?: Widgets['columnUnits'];
|
||||
}
|
||||
|
||||
export interface NavigateToTraceProps {
|
||||
servicename: string;
|
||||
operation: string;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
selectedTraceTags: string;
|
||||
apmToTraceQuery: Query;
|
||||
safeNavigate: (path: string) => void;
|
||||
openInNewTab: boolean;
|
||||
}
|
||||
|
||||
export interface DatabaseCallsRPSProps extends DatabaseCallProps {
|
||||
legend: string;
|
||||
}
|
||||
|
||||
export interface DatabaseCallProps {
|
||||
servicename: IServiceName['servicename'];
|
||||
tagFilterItems: TagFilterItem[];
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { getTopOperationList } from './__mocks__/getTopOperation';
|
||||
import { TopOperationList } from './TopOperationsTable';
|
||||
import {
|
||||
convertedTracesToDownloadData,
|
||||
getErrorRate,
|
||||
getNearestHighestBucketValue,
|
||||
} from './utils';
|
||||
|
||||
describe('Error Rate', () => {
|
||||
it('should return correct error rate', () => {
|
||||
const list: TopOperationList = getTopOperationList({
|
||||
errorCount: 10,
|
||||
numCalls: 100,
|
||||
});
|
||||
|
||||
expect(getErrorRate(list)).toBe(10);
|
||||
});
|
||||
|
||||
it('should handle no errors gracefully', () => {
|
||||
const list = getTopOperationList({ errorCount: 0, numCalls: 100 });
|
||||
expect(getErrorRate(list)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle zero calls', () => {
|
||||
const list = getTopOperationList({ errorCount: 0, numCalls: 0 });
|
||||
expect(getErrorRate(list)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNearestHighestBucketValue', () => {
|
||||
it('should return nearest higher bucket value', () => {
|
||||
expect(getNearestHighestBucketValue(50, [10, 20, 30, 40, 60, 70])).toBe('60');
|
||||
});
|
||||
|
||||
it('should return +Inf for value higher than any bucket', () => {
|
||||
expect(getNearestHighestBucketValue(80, [10, 20, 30, 40, 60, 70])).toBe(
|
||||
'+Inf',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the first bucket for value lower than all buckets', () => {
|
||||
expect(getNearestHighestBucketValue(5, [10, 20, 30, 40, 60, 70])).toBe('10');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertedTracesToDownloadData', () => {
|
||||
it('should convert trace data correctly', () => {
|
||||
const data = [
|
||||
{
|
||||
name: 'op1',
|
||||
p50: 50000000,
|
||||
p95: 95000000,
|
||||
p99: 99000000,
|
||||
numCalls: 100,
|
||||
errorCount: 10,
|
||||
},
|
||||
];
|
||||
|
||||
expect(convertedTracesToDownloadData(data)).toStrictEqual([
|
||||
{
|
||||
Name: 'op1',
|
||||
'P50 (in ms)': '50.00',
|
||||
'P95 (in ms)': '95.00',
|
||||
'P99 (in ms)': '99.00',
|
||||
'Number of calls': '100',
|
||||
'Error Rate (%)': '10.00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
import { TopOperationList } from './TopOperationsTable';
|
||||
import { NavigateToTraceProps } from './types';
|
||||
|
||||
export const getErrorRate = (list: TopOperationList): number => {
|
||||
if (list.errorCount === 0 && list.numCalls === 0) {
|
||||
return 0;
|
||||
}
|
||||
return (list.errorCount / list.numCalls) * 100;
|
||||
};
|
||||
|
||||
export const navigateToTrace = ({
|
||||
servicename,
|
||||
operation,
|
||||
minTime,
|
||||
maxTime,
|
||||
selectedTraceTags,
|
||||
apmToTraceQuery,
|
||||
safeNavigate,
|
||||
openInNewTab = false,
|
||||
}: NavigateToTraceProps): void => {
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set(
|
||||
QueryParams.startTime,
|
||||
Math.floor(minTime / 1_000_000).toString(),
|
||||
);
|
||||
urlParams.set(QueryParams.endTime, Math.floor(maxTime / 1_000_000).toString());
|
||||
|
||||
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(apmToTraceQuery));
|
||||
|
||||
const newTraceExplorerPath = `${
|
||||
ROUTES.TRACES_EXPLORER
|
||||
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
|
||||
QueryParams.compositeQuery
|
||||
}=${JSONCompositeQuery}`;
|
||||
|
||||
if (openInNewTab) {
|
||||
window.open(withBasePath(newTraceExplorerPath), '_blank');
|
||||
} else {
|
||||
safeNavigate(newTraceExplorerPath);
|
||||
}
|
||||
};
|
||||
|
||||
export const getNearestHighestBucketValue = (
|
||||
value: number,
|
||||
buckets: number[],
|
||||
): string => {
|
||||
// sort the buckets
|
||||
buckets.sort((a, b) => a - b);
|
||||
const nearestBucket = buckets.find((bucket) => bucket >= value);
|
||||
return nearestBucket?.toString() || '+Inf';
|
||||
};
|
||||
|
||||
export const convertMilSecToNanoSec = (value: number): number =>
|
||||
value * 1000000000;
|
||||
|
||||
export const convertedTracesToDownloadData = (
|
||||
originalData: TopOperationList[],
|
||||
): Record<string, string>[] =>
|
||||
originalData.map((item) => {
|
||||
const newObj: Record<string, string> = {
|
||||
Name: item.name,
|
||||
'P50 (in ms)': (item.p50 / 1000000).toFixed(2),
|
||||
'P95 (in ms)': (item.p95 / 1000000).toFixed(2),
|
||||
'P99 (in ms)': (item.p99 / 1000000).toFixed(2),
|
||||
'Number of calls': item.numCalls.toString(),
|
||||
'Error Rate (%)': getErrorRate(item).toFixed(2),
|
||||
};
|
||||
|
||||
return newObj;
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
.errorState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-16);
|
||||
}
|
||||
65
frontend/src/container/SystemDashboard/SystemDashboard.tsx
Normal file
65
frontend/src/container/SystemDashboard/SystemDashboard.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Spinner } from '@signozhq/ui/spinner';
|
||||
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import { buildDashboardReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
|
||||
import type { DashboardControlsInput } from 'pages/DashboardPage/DashboardContainer/controls/types';
|
||||
|
||||
import type { SystemDashboardHost } from './systemDashboardHost';
|
||||
import { useSystemDashboard } from './useSystemDashboard';
|
||||
import styles from './SystemDashboard.module.scss';
|
||||
|
||||
interface SystemDashboardProps {
|
||||
host: SystemDashboardHost;
|
||||
controls?: DashboardControlsInput;
|
||||
}
|
||||
|
||||
/** A shipped, read-only system dashboard, embedded in a feature page. */
|
||||
function SystemDashboard({
|
||||
host,
|
||||
controls,
|
||||
}: SystemDashboardProps): JSX.Element {
|
||||
const { dashboard, isLoading, isError, error, refetch } =
|
||||
useSystemDashboard(host);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
<Spinner size={24} aria-label="Loading dashboard" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error?.response?.status === 403) {
|
||||
return (
|
||||
<div className={styles.errorState} data-testid="system-dashboard-forbidden">
|
||||
<PermissionDeniedCallout
|
||||
deniedPermissions={[buildDashboardReadPermission('*')]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !dashboard) {
|
||||
return (
|
||||
<div className={styles.errorState} data-testid="system-dashboard-error">
|
||||
<Typography.Title>Failed to load dashboard</Typography.Title>
|
||||
<Typography.Text>
|
||||
{error?.response?.data?.error?.message ?? error?.message}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardContainer
|
||||
dashboard={dashboard}
|
||||
refetch={refetch}
|
||||
source={host.source}
|
||||
readOnly
|
||||
controls={controls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SystemDashboard;
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
DashboardtypesSourceDTO,
|
||||
type GetSystemDashboard200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import SystemDashboard from '../SystemDashboard';
|
||||
import { createSystemDashboardHost } from '../systemDashboardHost';
|
||||
|
||||
const HOST = createSystemDashboardHost('test-overview', 'test-overview-id');
|
||||
const ENDPOINT = '*/api/v2/dashboards/system/test-overview';
|
||||
|
||||
jest.mock('pages/DashboardPage/DashboardContainer', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
dashboard,
|
||||
readOnly,
|
||||
}: {
|
||||
dashboard: { id: string; spec: { display: { name: string } } };
|
||||
readOnly: boolean;
|
||||
}): JSX.Element => (
|
||||
<div
|
||||
data-testid="dashboard-container"
|
||||
data-dashboard-id={dashboard.id}
|
||||
data-read-only={String(readOnly)}
|
||||
>
|
||||
{dashboard.spec.display.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const response: GetSystemDashboard200 = {
|
||||
status: 'success',
|
||||
data: {
|
||||
orgId: 'org',
|
||||
locked: true,
|
||||
name: 'signoz---test-overview',
|
||||
schemaVersion: 'v6',
|
||||
source: DashboardtypesSourceDTO.system,
|
||||
tags: [],
|
||||
spec: {
|
||||
display: { name: 'Test overview' },
|
||||
variables: [],
|
||||
panels: {},
|
||||
layouts: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('SystemDashboard', () => {
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
it('mounts the named system dashboard read-only under the host id', async () => {
|
||||
server.use(rest.get(ENDPOINT, (_req, res, ctx) => res(ctx.json(response))));
|
||||
|
||||
render(<SystemDashboard host={HOST} />);
|
||||
|
||||
const container = await screen.findByTestId('dashboard-container');
|
||||
expect(container).toHaveTextContent('Test overview');
|
||||
expect(container).toHaveAttribute('data-dashboard-id', 'test-overview-id');
|
||||
expect(container).toHaveAttribute('data-read-only', 'true');
|
||||
});
|
||||
|
||||
it('tells a user without dashboard read access why it is missing', async () => {
|
||||
server.use(
|
||||
rest.get(ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(403),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'forbidden', message: 'forbidden' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SystemDashboard host={HOST} />);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('system-dashboard-forbidden'),
|
||||
).resolves.toHaveTextContent('is not authorized to perform read:dashboard:*');
|
||||
});
|
||||
|
||||
it('shows why the dashboard failed to load', async () => {
|
||||
server.use(
|
||||
rest.get(ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(404),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'not_found', message: 'not provisioned' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SystemDashboard host={HOST} />);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('system-dashboard-error'),
|
||||
).resolves.toHaveTextContent('not provisioned');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getGetSystemDashboardQueryKey } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
GetSystemDashboard200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardQuerySource } from 'pages/DashboardPage/DashboardContainer/store/slices/editContextSlice';
|
||||
|
||||
export interface SystemDashboardHost {
|
||||
/** Definition name without the `signoz---` prefix. */
|
||||
name: string;
|
||||
select: (
|
||||
response: GetSystemDashboard200,
|
||||
) => DashboardtypesGettableDashboardV2DTO;
|
||||
source: DashboardQuerySource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call at module level: `select` and `source` must stay referentially stable.
|
||||
* System dashboards carry no `id`; `id` keys the store's per-dashboard state
|
||||
* (saved variable values), so keep it stable across releases.
|
||||
*/
|
||||
export function createSystemDashboardHost(
|
||||
name: string,
|
||||
id: string,
|
||||
): SystemDashboardHost {
|
||||
const select = (
|
||||
response: GetSystemDashboard200,
|
||||
): DashboardtypesGettableDashboardV2DTO =>
|
||||
({ ...response.data, id }) as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
return {
|
||||
name,
|
||||
select,
|
||||
source: { queryKey: getGetSystemDashboardQueryKey({ name }), select },
|
||||
};
|
||||
}
|
||||
41
frontend/src/container/SystemDashboard/useSystemDashboard.ts
Normal file
41
frontend/src/container/SystemDashboard/useSystemDashboard.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useGetSystemDashboard } from 'api/generated/services/dashboard';
|
||||
import type { GetSystemDashboardQueryError } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { SystemDashboardHost } from './systemDashboardHost';
|
||||
|
||||
export interface UseSystemDashboardResult {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: GetSystemDashboardQueryError | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useSystemDashboard(
|
||||
host: SystemDashboardHost,
|
||||
): UseSystemDashboardResult {
|
||||
const {
|
||||
data: dashboard,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetSystemDashboard(
|
||||
{ name: host.name },
|
||||
{
|
||||
query: {
|
||||
select: host.select,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const refetchDashboard = useCallback((): void => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return { dashboard, isLoading, isError, error, refetch: refetchDashboard };
|
||||
}
|
||||
@@ -195,6 +195,7 @@ export const routesToSkip = [
|
||||
ROUTES.NOT_FOUND,
|
||||
ROUTES.METER_EXPLORER,
|
||||
ROUTES.METER,
|
||||
ROUTES.SERVICE_METRICS,
|
||||
ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
|
||||
ROUTES.SOMETHING_WENT_WRONG,
|
||||
ROUTES.AI_OBSERVABILITY_OVERVIEW,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { SolidInfoCircle } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
DisplayThresholdContainer,
|
||||
TypographHeading,
|
||||
Typography,
|
||||
} from 'container/WidgetCard/Header/styles';
|
||||
import { DisplayThresholdProps } from 'container/WidgetCard/Header/types';
|
||||
|
||||
function DisplayThreshold({ threshold }: DisplayThresholdProps): JSX.Element {
|
||||
return (
|
||||
<DisplayThresholdContainer>
|
||||
<TypographHeading>Threshold </TypographHeading>
|
||||
<Typography>{threshold || <SolidInfoCircle size="md" />}</Typography>
|
||||
</DisplayThresholdContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default DisplayThreshold;
|
||||
@@ -1,58 +0,0 @@
|
||||
import { grey } from '@ant-design/colors';
|
||||
import { Typography as TypographyComponent } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const HeaderContainer = styled.div<{ hover: boolean }>`
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.8rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
`;
|
||||
|
||||
export const HeaderContentContainer = styled.span`
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export const ThesholdContainer = styled.span`
|
||||
margin-top: -0.3rem;
|
||||
`;
|
||||
|
||||
export const DisplayThresholdContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
export const WidgetHeaderContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
`;
|
||||
|
||||
export const ArrowContainer = styled.span<{ hover: boolean }>`
|
||||
visibility: ${({ hover }): string => (hover ? 'visible' : 'hidden')};
|
||||
`;
|
||||
|
||||
export const Typography = styled(TypographyComponent)`
|
||||
&&& {
|
||||
width: auto;
|
||||
margin-left: 0.2rem;
|
||||
color: ${grey[2]};
|
||||
}
|
||||
`;
|
||||
|
||||
export const TypographHeading = styled(TypographyComponent)`
|
||||
&&& {
|
||||
color: ${grey[2]};
|
||||
}
|
||||
`;
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ReactNode } from 'react';
|
||||
import type { DropdownActionItemType } from '@signozhq/ui/dropdown';
|
||||
|
||||
import { MenuItemKeys } from 'container/WidgetCard/Header/contants';
|
||||
@@ -7,7 +6,3 @@ export type MenuItem = DropdownActionItemType & {
|
||||
value: MenuItemKeys;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
export interface DisplayThresholdProps {
|
||||
threshold: ReactNode;
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { getMetricMeta } from 'api/metrics/ApDex/getMetricMeta';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { MetricMetaProps } from 'types/api/metrics/getApDex';
|
||||
|
||||
export const useGetMetricMeta = (
|
||||
metricName: string,
|
||||
servicename: string,
|
||||
): UseQueryResult<AxiosResponse<MetricMetaProps>, AxiosError> =>
|
||||
useQuery<AxiosResponse<MetricMetaProps>, AxiosError>({
|
||||
queryKey: [{ metricName, servicename }],
|
||||
queryFn: async () => getMetricMeta(metricName, servicename),
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { buildRoutePath } from 'lib/router/buildRoutePath';
|
||||
import {
|
||||
@@ -26,19 +26,21 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import AuthZDropdown from 'lib/authz/components/AuthZDropdown/AuthZDropdown';
|
||||
import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/types';
|
||||
import type {
|
||||
AuthZDropdownItemType,
|
||||
AuthZGroupChildType,
|
||||
} from 'lib/authz/components/AuthZDropdown/types';
|
||||
import { blockedBy } from 'lib/authz/components/AuthZDropdown/utils';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { DashboardCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
import DashboardSettings from '../../DashboardSettings';
|
||||
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
|
||||
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
|
||||
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
|
||||
import SettingsDrawer from '../SettingsDrawer';
|
||||
import styles from './DashboardActions.module.scss';
|
||||
import { useDeleteDashboardAction } from './useDeleteDashboardAction';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
|
||||
|
||||
interface DashboardActionsProps {
|
||||
title: string;
|
||||
@@ -66,13 +68,14 @@ function DashboardActions({
|
||||
deleteChecks,
|
||||
deleteDisabledTooltip,
|
||||
} = useDashboardEditContext();
|
||||
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
|
||||
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
|
||||
const show = useDashboardControlGroup('dashboardActions');
|
||||
const requestSettings = useDashboardStore((s) => s.requestSettings);
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { disabledTooltip: lockDisabledTooltip } = useDashboardLockPermission({
|
||||
dashboardId: dashboard.id,
|
||||
source: dashboard.source,
|
||||
enabled: show.lock,
|
||||
});
|
||||
|
||||
// Cloning creates a new dashboard, so it needs `create` too, and no lock gate.
|
||||
@@ -81,8 +84,6 @@ function DashboardActions({
|
||||
[editChecks],
|
||||
);
|
||||
|
||||
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
|
||||
useState<boolean>(false);
|
||||
const [isJsonEditorOpen, setIsJsonEditorOpen] = useState<boolean>(false);
|
||||
const [isCloning, setIsCloning] = useState<boolean>(false);
|
||||
const [isNewSectionOpen, setIsNewSectionOpen] = useState<boolean>(false);
|
||||
@@ -94,14 +95,6 @@ function DashboardActions({
|
||||
panelCount: Object.keys(dashboard.spec.panels).length,
|
||||
});
|
||||
|
||||
// Open the settings drawer when something in the tree requests it (e.g. the
|
||||
// variables bar's "Add variable" button).
|
||||
useEffect(() => {
|
||||
if (settingsRequest) {
|
||||
setIsSettingsDrawerOpen(true);
|
||||
}
|
||||
}, [settingsRequest]);
|
||||
|
||||
const { addSection, isSaving: isAddingSection } = useAddSection({
|
||||
layouts: dashboard.spec.layouts,
|
||||
});
|
||||
@@ -143,8 +136,8 @@ function DashboardActions({
|
||||
void logEvent(DashboardDetailEvents.SettingsOpened, {
|
||||
dashboardId: dashboard.id,
|
||||
});
|
||||
setIsSettingsDrawerOpen(true);
|
||||
}, [dashboard.id]);
|
||||
requestSettings({ tab: 'Overview' });
|
||||
}, [dashboard.id, requestSettings]);
|
||||
|
||||
const handleOpenJsonEditor = useCallback((): void => {
|
||||
void logEvent(DashboardDetailEvents.JsonEditorOpened, {
|
||||
@@ -162,61 +155,68 @@ function DashboardActions({
|
||||
void handle.enter();
|
||||
}, [dashboard.id, handle]);
|
||||
|
||||
// Unavailable items stay in the menu, carrying the reason.
|
||||
const menuItems = useMemo<AuthZDropdownItemType[]>(
|
||||
() => [
|
||||
{
|
||||
type: 'group',
|
||||
value: 'group-dashboard',
|
||||
label: 'Dashboard',
|
||||
items: [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'rename',
|
||||
testId: 'dashboard-action-rename',
|
||||
label: 'Rename',
|
||||
prefix: <PenLine size={14} />,
|
||||
checks: editChecks,
|
||||
...blockedBy(editDisabledTooltip),
|
||||
onClick: onOpenRename,
|
||||
},
|
||||
// Clone creates a new dashboard, so it's not lock-gated.
|
||||
{
|
||||
type: 'item',
|
||||
value: 'clone',
|
||||
testId: 'dashboard-action-clone',
|
||||
label: 'Clone dashboard',
|
||||
prefix: <Copy size={14} />,
|
||||
checks: cloneChecks,
|
||||
loading: isCloning,
|
||||
loadingTooltip: 'Cloning dashboard',
|
||||
onClick: (): void => void handleClone(),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
value: 'lock',
|
||||
testId: 'dashboard-action-lock',
|
||||
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
|
||||
prefix: <LockKeyhole size={14} />,
|
||||
checks: editChecks,
|
||||
...blockedBy(lockDisabledTooltip),
|
||||
onClick: onLockToggle,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
value: 'fullscreen',
|
||||
testId: 'dashboard-action-fullscreen',
|
||||
label: 'Full screen',
|
||||
prefix: <Fullscreen size={14} />,
|
||||
onClick: handleEnterFullScreen,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
value: 'group-layout',
|
||||
label: 'Layout',
|
||||
items: [
|
||||
// Unavailable items stay in the menu, carrying the reason; hidden ones are dropped.
|
||||
const menuItems = useMemo<AuthZDropdownItemType[]>(() => {
|
||||
const dashboardItems: AuthZGroupChildType[] = [
|
||||
...(show.rename
|
||||
? [
|
||||
{
|
||||
type: 'item' as const,
|
||||
value: 'rename',
|
||||
testId: 'dashboard-action-rename',
|
||||
label: 'Rename',
|
||||
prefix: <PenLine size={14} />,
|
||||
checks: editChecks,
|
||||
...blockedBy(editDisabledTooltip),
|
||||
onClick: onOpenRename,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Clone creates a new dashboard, so it's not lock-gated.
|
||||
...(show.clone
|
||||
? [
|
||||
{
|
||||
type: 'item' as const,
|
||||
value: 'clone',
|
||||
testId: 'dashboard-action-clone',
|
||||
label: 'Clone dashboard',
|
||||
prefix: <Copy size={14} />,
|
||||
checks: cloneChecks,
|
||||
loading: isCloning,
|
||||
loadingTooltip: 'Cloning dashboard',
|
||||
onClick: (): void => void handleClone(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(show.lock
|
||||
? [
|
||||
{
|
||||
type: 'item' as const,
|
||||
value: 'lock',
|
||||
testId: 'dashboard-action-lock',
|
||||
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
|
||||
prefix: <LockKeyhole size={14} />,
|
||||
checks: editChecks,
|
||||
...blockedBy(lockDisabledTooltip),
|
||||
onClick: onLockToggle,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(show.fullscreen
|
||||
? [
|
||||
{
|
||||
type: 'item' as const,
|
||||
value: 'fullscreen',
|
||||
testId: 'dashboard-action-fullscreen',
|
||||
label: 'Full screen',
|
||||
prefix: <Fullscreen size={14} />,
|
||||
onClick: handleEnterFullScreen,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const layoutItems: AuthZGroupChildType[] = show.newSection
|
||||
? [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'new-section',
|
||||
@@ -227,101 +227,124 @@ function DashboardActions({
|
||||
...blockedBy(editDisabledTooltip),
|
||||
onClick: (): void => setIsNewSectionOpen(true),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator', value: 'divider-danger' },
|
||||
{
|
||||
type: 'item',
|
||||
value: 'delete',
|
||||
testId: 'dashboard-action-delete',
|
||||
label: 'Delete dashboard',
|
||||
prefix: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
checks: deleteChecks,
|
||||
// Independent of read/update, but a locked dashboard can't be removed.
|
||||
...blockedBy(deleteDisabledTooltip),
|
||||
onClick: confirmDeleteDashboard,
|
||||
},
|
||||
],
|
||||
[
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
deleteChecks,
|
||||
deleteDisabledTooltip,
|
||||
cloneChecks,
|
||||
isCloning,
|
||||
lockDisabledTooltip,
|
||||
isDashboardLocked,
|
||||
onOpenRename,
|
||||
handleClone,
|
||||
onLockToggle,
|
||||
handleEnterFullScreen,
|
||||
confirmDeleteDashboard,
|
||||
],
|
||||
);
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
...(dashboardItems.length
|
||||
? [
|
||||
{
|
||||
type: 'group' as const,
|
||||
value: 'group-dashboard',
|
||||
label: 'Dashboard',
|
||||
items: dashboardItems,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(layoutItems.length
|
||||
? [
|
||||
{
|
||||
type: 'group' as const,
|
||||
value: 'group-layout',
|
||||
label: 'Layout',
|
||||
items: layoutItems,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(show.delete
|
||||
? [
|
||||
{ type: 'separator' as const, value: 'divider-danger' },
|
||||
{
|
||||
type: 'item' as const,
|
||||
value: 'delete',
|
||||
testId: 'dashboard-action-delete',
|
||||
label: 'Delete dashboard',
|
||||
prefix: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
checks: deleteChecks,
|
||||
// Independent of read/update, but a locked dashboard can't be removed.
|
||||
...blockedBy(deleteDisabledTooltip),
|
||||
onClick: confirmDeleteDashboard,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}, [
|
||||
show,
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
deleteChecks,
|
||||
deleteDisabledTooltip,
|
||||
cloneChecks,
|
||||
isCloning,
|
||||
lockDisabledTooltip,
|
||||
isDashboardLocked,
|
||||
onOpenRename,
|
||||
handleClone,
|
||||
onLockToggle,
|
||||
handleEnterFullScreen,
|
||||
confirmDeleteDashboard,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardActionsContainer}>
|
||||
<AuthZDropdown items={menuItems} nativeButton align="end" side="bottom">
|
||||
{menuItems.length > 0 && (
|
||||
<AuthZDropdown items={menuItems} nativeButton align="end" side="bottom">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Grid3X3 size="md" />}
|
||||
testId="options"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</AuthZDropdown>
|
||||
)}
|
||||
{show.configure && (
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
disabled={!isEditable}
|
||||
onClick={handleOpenSettings}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
{show.json && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Grid3X3 size="md" />}
|
||||
testId="options"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</AuthZDropdown>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
disabled={!isEditable}
|
||||
onClick={handleOpenSettings}
|
||||
prefix={<Braces size="md" />}
|
||||
testId="edit-json"
|
||||
onClick={handleOpenJsonEditor}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
JSON
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
destroyOnClose
|
||||
onClose={(): void => {
|
||||
setIsSettingsDrawerOpen(false);
|
||||
clearSettingsRequest();
|
||||
}}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Braces size="md" />}
|
||||
testId="edit-json"
|
||||
onClick={handleOpenJsonEditor}
|
||||
size="md"
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={!isEditable}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
{show.newPanel && (
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={!isEditable}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
<JsonEditorDrawer
|
||||
dashboard={dashboard}
|
||||
isOpen={isJsonEditorOpen}
|
||||
|
||||
@@ -24,9 +24,11 @@ import { openInNewTab } from 'utils/navigation';
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
|
||||
import TagsOverflowTooltip from './TagsOverflowTooltip';
|
||||
import { getLockTooltip } from './utils';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
|
||||
|
||||
// The tag cluster keeps a fixed footprint so a long title ellipsizes around it
|
||||
// instead of collapsing the tags: show up to two tags, then a `+N` overflow badge.
|
||||
@@ -75,27 +77,19 @@ function DashboardInfo({
|
||||
}: DashboardInfoProps): JSX.Element {
|
||||
const { isEditable: canEdit } = useDashboardEditContext();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const show = useDashboardControlGroup('title');
|
||||
|
||||
const hasTags = tags.length > 0;
|
||||
const hasDescription = !isEmpty(description);
|
||||
const hasTags = show.tags && tags.length > 0;
|
||||
const hasDescription = show.description && !isEmpty(description);
|
||||
|
||||
const visibleTags = tags.slice(0, MAX_VISIBLE_TAGS);
|
||||
const remainingTags = tags.slice(MAX_VISIBLE_TAGS);
|
||||
|
||||
// Without a toggle, say why it can't be toggled rather than only restating the
|
||||
// lock state.
|
||||
let lockTooltip: string;
|
||||
if (onToggleLock) {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'Locked — click to unlock'
|
||||
: 'Unlocked — click to lock';
|
||||
} else if (lockDisabledTooltip) {
|
||||
lockTooltip = lockDisabledTooltip;
|
||||
} else {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'This dashboard is locked'
|
||||
: 'This dashboard is unlocked';
|
||||
}
|
||||
const lockTooltip = getLockTooltip({
|
||||
isLocked: isDashboardLocked,
|
||||
canToggle: !!onToggleLock,
|
||||
disabledReason: lockDisabledTooltip,
|
||||
});
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
@@ -128,66 +122,69 @@ function DashboardInfo({
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardInfo}>
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
|
||||
{isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Save title"
|
||||
testId="dashboard-title-save"
|
||||
onClick={onCommit}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Cancel title edit"
|
||||
testId="dashboard-title-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip title={title}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
})}
|
||||
data-testid="dashboard-title"
|
||||
onClick={canEdit ? onStartEdit : undefined}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<CopyButton
|
||||
value={dashboardId}
|
||||
ariaLabel="Copy dashboard ID"
|
||||
testId="dashboard-copy-id"
|
||||
onCopy={handleCopyId}
|
||||
/>
|
||||
</>
|
||||
{show.name && (
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
)}
|
||||
|
||||
{show.name &&
|
||||
(isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Save title"
|
||||
testId="dashboard-title-save"
|
||||
onClick={onCommit}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Cancel title edit"
|
||||
testId="dashboard-title-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip title={title}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
})}
|
||||
data-testid="dashboard-title"
|
||||
onClick={canEdit ? onStartEdit : undefined}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<CopyButton
|
||||
value={dashboardId}
|
||||
ariaLabel="Copy dashboard ID"
|
||||
testId="dashboard-copy-id"
|
||||
onCopy={handleCopyId}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
|
||||
{hasDescription && (
|
||||
<Tooltip
|
||||
side="bottom"
|
||||
@@ -205,7 +202,7 @@ function DashboardInfo({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isPublicDashboard && (
|
||||
{show.publicBadge && isPublicDashboard && (
|
||||
<Tooltip title="This dashboard is publicly accessible. Click to open the public page.">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -222,7 +219,7 @@ function DashboardInfo({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{showLockToggle && (
|
||||
{show.lock && showLockToggle && (
|
||||
<Tooltip title={onToggleLock ? lockTooltip : undefined}>
|
||||
<Button
|
||||
disabledTooltip={lockTooltip}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Without a toggle, say why it can't be toggled rather than only restating the
|
||||
// lock state.
|
||||
export function getLockTooltip({
|
||||
isLocked,
|
||||
canToggle,
|
||||
disabledReason,
|
||||
}: {
|
||||
isLocked: boolean;
|
||||
canToggle: boolean;
|
||||
disabledReason?: string;
|
||||
}): string {
|
||||
if (canToggle) {
|
||||
return isLocked ? 'Locked — click to unlock' : 'Unlocked — click to lock';
|
||||
}
|
||||
if (disabledReason) {
|
||||
return disabledReason;
|
||||
}
|
||||
return isLocked ? 'This dashboard is locked' : 'This dashboard is unlocked';
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { FullScreenHandle } from 'react-full-screen';
|
||||
import { useDashboardLockPermission } from 'hooks/dashboards/useDashboardLockPermission';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { dashboardResponse } from '../../../stories/__story_mockdata__/dashboard';
|
||||
import type { DashboardControlsInput } from '../../controls/types';
|
||||
|
||||
import { usePublicDashboardMeta } from '../../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import DashboardPageToolbar from '../index';
|
||||
|
||||
const { data: dashboard } = dashboardResponse({
|
||||
panels: 0,
|
||||
sectioned: false,
|
||||
variables: [],
|
||||
locked: true,
|
||||
});
|
||||
|
||||
const handle: FullScreenHandle = {
|
||||
active: false,
|
||||
enter: jest.fn(),
|
||||
exit: jest.fn(),
|
||||
node: { current: null },
|
||||
};
|
||||
|
||||
jest.mock('../DashboardActions/DashboardActions', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="dashboard-actions" />,
|
||||
}));
|
||||
jest.mock('../DashboardInfo/DashboardInfo', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="dashboard-info" />,
|
||||
}));
|
||||
jest.mock('../../VariablesBar/VariablesBar', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="variables-bar" />,
|
||||
}));
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="time-selection" />,
|
||||
}));
|
||||
jest.mock(
|
||||
'../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal',
|
||||
() => ({ __esModule: true, default: (): null => null }),
|
||||
);
|
||||
jest.mock('../../hooks/useCreatePanel', () => ({
|
||||
useCreatePanel: (): unknown => ({
|
||||
isPickerOpen: false,
|
||||
openPicker: jest.fn(),
|
||||
closePicker: jest.fn(),
|
||||
createPanel: jest.fn(),
|
||||
targetLayoutIndex: 0,
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../hooks/useOptimisticPatch', () => ({
|
||||
useOptimisticPatch: (): unknown => ({ patchAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('hooks/dashboards/useToggleDashboardLock', () => ({
|
||||
useToggleDashboardLock: (): unknown => ({ toggleLock: jest.fn() }),
|
||||
}));
|
||||
jest.mock('hooks/dashboards/useDashboardLockPermission', () => ({
|
||||
useDashboardLockPermission: jest.fn(() => ({
|
||||
canToggleLock: false,
|
||||
isLoading: false,
|
||||
disabledTooltip: '',
|
||||
})),
|
||||
}));
|
||||
jest.mock(
|
||||
'../../DashboardSettings/PublicDashboard/usePublicDashboardMeta',
|
||||
() => ({
|
||||
usePublicDashboardMeta: jest.fn(() => ({
|
||||
isPublic: false,
|
||||
publicMeta: undefined,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
function renderToolbar(
|
||||
ctx: { readOnly?: boolean; controls?: DashboardControlsInput } = {},
|
||||
): void {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: dashboard.id, refetch: jest.fn(), ...ctx });
|
||||
render(<DashboardPageToolbar dashboard={dashboard} handle={handle} />);
|
||||
}
|
||||
|
||||
describe('DashboardPageToolbar - controls', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders every row and checks lock + share by default', () => {
|
||||
renderToolbar();
|
||||
|
||||
expect(screen.getByTestId('dashboard-info')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-actions')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('time-selection')).toBeInTheDocument();
|
||||
expect(jest.mocked(useDashboardLockPermission)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: true }),
|
||||
);
|
||||
expect(jest.mocked(usePublicDashboardMeta)).toHaveBeenCalledWith(
|
||||
dashboard.id,
|
||||
{
|
||||
enabled: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the lock permission check on a read-only mount', () => {
|
||||
renderToolbar({ readOnly: true });
|
||||
|
||||
expect(jest.mocked(useDashboardLockPermission)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the title row and actions the host hides, and their requests', () => {
|
||||
renderToolbar({
|
||||
readOnly: true,
|
||||
controls: { title: false, dashboardActions: false },
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('dashboard-info')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('dashboard-actions')).not.toBeInTheDocument();
|
||||
expect(jest.mocked(usePublicDashboardMeta)).toHaveBeenCalledWith(
|
||||
dashboard.id,
|
||||
{
|
||||
enabled: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the time range and keeps the variables bar mounted but hidden', () => {
|
||||
renderToolbar({
|
||||
controls: { toolbar: { timeRange: false, variables: false } },
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('time-selection')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('variables-bar')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { useCreatePanel } from '../hooks/useCreatePanel';
|
||||
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
|
||||
import { useDashboardControlGroup } from '../controls/useDashboardControlGroup';
|
||||
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
|
||||
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
@@ -56,6 +57,11 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
[dashboard.tags],
|
||||
);
|
||||
|
||||
const showTitle = useDashboardControlGroup('title');
|
||||
const showActions = useDashboardControlGroup('dashboardActions');
|
||||
const showToolbar = useDashboardControlGroup('toolbar');
|
||||
const hasTitleRow = Object.values(showTitle).some(Boolean);
|
||||
const hasActions = Object.values(showActions).some(Boolean);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const {
|
||||
@@ -71,10 +77,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
useDashboardLockPermission({
|
||||
dashboardId: id,
|
||||
source: dashboard.source,
|
||||
enabled: showTitle.lock || showActions.lock,
|
||||
});
|
||||
|
||||
// Public-sharing meta (deduped react-query read); drives the header globe.
|
||||
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
|
||||
const { isPublic, publicMeta } = usePublicDashboardMeta(id, {
|
||||
enabled: showTitle.publicBadge,
|
||||
});
|
||||
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
|
||||
|
||||
// Shared with the list's row menu — it owns the API call, the toast and the
|
||||
@@ -155,48 +164,66 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<section className={styles.dashboardPageToolbarContainer}>
|
||||
<div className={styles.dashboardInfoWithActions}>
|
||||
<DashboardInfo
|
||||
title={title}
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={isPublic}
|
||||
publicUrl={publicUrl}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
showLockToggle={showLockToggle}
|
||||
onToggleLock={
|
||||
canToggleLock
|
||||
? (): void => handleLockDashboardToggle('header')
|
||||
: undefined
|
||||
}
|
||||
lockDisabledTooltip={lockDisabledTooltip}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onStartEdit={startEdit}
|
||||
onCommit={commit}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
<DashboardActions
|
||||
title={title}
|
||||
dashboard={dashboard}
|
||||
handle={handle}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
onAddPanel={onAddPanel}
|
||||
onLockToggle={(): void => handleLockDashboardToggle('menu')}
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
</div>
|
||||
{(hasTitleRow || hasActions) && (
|
||||
<div className={styles.dashboardInfoWithActions}>
|
||||
{hasTitleRow && (
|
||||
<DashboardInfo
|
||||
title={title}
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={isPublic}
|
||||
publicUrl={publicUrl}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
showLockToggle={showLockToggle}
|
||||
onToggleLock={
|
||||
canToggleLock
|
||||
? (): void => handleLockDashboardToggle('header')
|
||||
: undefined
|
||||
}
|
||||
lockDisabledTooltip={lockDisabledTooltip}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onStartEdit={startEdit}
|
||||
onCommit={commit}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
)}
|
||||
{hasActions && (
|
||||
<DashboardActions
|
||||
title={title}
|
||||
dashboard={dashboard}
|
||||
handle={handle}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
onAddPanel={onAddPanel}
|
||||
onLockToggle={(): void => handleLockDashboardToggle('menu')}
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 2: the time selector floats top-right (declared first so the
|
||||
variables bar's content wraps around it); the variables bar
|
||||
collapses to one line and, when expanded, wraps full-width under it. */}
|
||||
<div className={styles.toolbarRow}>
|
||||
<div className={styles.timeCluster}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
{showToolbar.timeRange && (
|
||||
<div className={styles.timeCluster}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={showToolbar.autoRefresh}
|
||||
hideShareModal
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showToolbar.variables ? (
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
) : (
|
||||
// Still mounted: the selectors seed and auto-select the values panels query with.
|
||||
<div hidden>
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PanelTypeSelectionModal
|
||||
open={isPickerOpen}
|
||||
|
||||
@@ -26,9 +26,13 @@ const PUBLIC_META_STALE_TIME = 5 * 60 * 1000;
|
||||
*/
|
||||
export function usePublicDashboardMeta(
|
||||
dashboardId: string,
|
||||
options?: { enabled?: boolean },
|
||||
): UsePublicDashboardMetaReturn {
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
const enabled = !!dashboardId && (isCloudUser || isEnterpriseSelfHostedUser);
|
||||
const enabled =
|
||||
(options?.enabled ?? true) &&
|
||||
!!dashboardId &&
|
||||
(isCloudUser || isEnterpriseSelfHostedUser);
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch } = useGetPublicDashboard(
|
||||
{ id: dashboardId },
|
||||
|
||||
@@ -2,33 +2,17 @@ import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useContextLinkVariables } from '../useContextLinkVariables';
|
||||
|
||||
const mockUseGetDashboardV2 = jest.fn();
|
||||
const mockUseDashboardVariableNames = jest.fn();
|
||||
const mockUseQueryBuilder = jest.fn();
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { dashboardId: string }) => unknown,
|
||||
): unknown => selector({ dashboardId: 'dash-1' }),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => mockUseQueryBuilder(),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
|
||||
}));
|
||||
|
||||
// dtoToFormModel is exercised by its own suite; here we only need it to surface a name.
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters',
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
|
||||
() => ({
|
||||
dtoToFormModel: (dto: { name: string }): { name: string } => ({
|
||||
name: dto.name,
|
||||
}),
|
||||
useDashboardVariableNames: (): unknown => mockUseDashboardVariableNames(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -45,11 +29,7 @@ describe('useContextLinkVariables', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
mockUseGetDashboardV2.mockReturnValue({
|
||||
data: {
|
||||
data: { spec: { variables: [{ name: 'region' }, { name: 'tier' }] } },
|
||||
},
|
||||
});
|
||||
mockUseDashboardVariableNames.mockReturnValue(['region', 'tier']);
|
||||
});
|
||||
|
||||
it('orders globals, then _prefixed query fields (deduped), then dashboard vars', () => {
|
||||
@@ -69,9 +49,7 @@ describe('useContextLinkVariables', () => {
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { builder: { queryData: [] } },
|
||||
});
|
||||
// Loaded dashboard with no variables — useDashboardFetchRequired guarantees the
|
||||
// dashboard is present, so the empty case is an empty spec, not `undefined`.
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
|
||||
mockUseDashboardVariableNames.mockReturnValue([]);
|
||||
|
||||
const { result } = renderHook(() => useContextLinkVariables());
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import PanelTypeSelectionModal from '../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import styles from './DashboardEmptyState.module.scss';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControl } from '../../controls/useDashboardControl';
|
||||
|
||||
interface DashboardEmptyStateProps {
|
||||
canAddPanel: boolean;
|
||||
@@ -24,9 +25,11 @@ function DashboardEmptyState({
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
const requestSettings = useDashboardStore((s) => s.requestSettings);
|
||||
const showConfigure = useDashboardControl('dashboardActions.configure');
|
||||
const showNewPanel = useDashboardControl('dashboardActions.newPanel');
|
||||
|
||||
return (
|
||||
<section className={styles.emptyState}>
|
||||
<section className={styles.emptyState} data-testid="dashboard-empty-state">
|
||||
<div className={styles.content}>
|
||||
<div className={styles.heading}>
|
||||
<img src={dashboardEmojiUrl} alt="" className={styles.emoji} />
|
||||
@@ -39,62 +42,66 @@ function DashboardEmptyState({
|
||||
</div>
|
||||
|
||||
<div className={styles.steps}>
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepText}>
|
||||
<Configure size={14} className={styles.stepIcon} />
|
||||
<div className={styles.stepCopy}>
|
||||
<Typography.Text className={styles.stepTitle}>
|
||||
Configure your new dashboard
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.stepInfo}>
|
||||
Give it a name, add description, tags and variables
|
||||
</Typography.Text>
|
||||
{showConfigure && (
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepText}>
|
||||
<Configure size={14} className={styles.stepIcon} />
|
||||
<div className={styles.stepCopy}>
|
||||
<Typography.Text className={styles.stepTitle}>
|
||||
Configure your new dashboard
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.stepInfo}>
|
||||
Give it a name, add description, tags and variables
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
width="140px"
|
||||
prefix={<Configure size="md" />}
|
||||
disabled={!isEditable}
|
||||
onClick={(): void => requestSettings({ tab: 'Overview' })}
|
||||
testId="empty-configure"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
width="140px"
|
||||
prefix={<Configure size="md" />}
|
||||
disabled={!isEditable}
|
||||
onClick={(): void => requestSettings({ tab: 'Overview' })}
|
||||
testId="empty-configure"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepText}>
|
||||
<img src={landscapeUrl} alt="" className={styles.stepIcon} />
|
||||
<div className={styles.stepCopy}>
|
||||
<Typography.Text className={styles.stepTitle}>
|
||||
Add panels
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.stepInfo}>
|
||||
Add panels to visualize your data
|
||||
</Typography.Text>
|
||||
{showNewPanel && (
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepText}>
|
||||
<img src={landscapeUrl} alt="" className={styles.stepIcon} />
|
||||
<div className={styles.stepCopy}>
|
||||
<Typography.Text className={styles.stepTitle}>
|
||||
Add panels
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.stepInfo}>
|
||||
Add panels to visualize your data
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size="md" />}
|
||||
disabled={!canAddPanel}
|
||||
onClick={(): void => openPicker()}
|
||||
testId="add-panel"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size="md" />}
|
||||
disabled={!canAddPanel}
|
||||
onClick={(): void => openPicker()}
|
||||
testId="add-panel"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PanelTypeSelectionModal
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { usePanelActionItems } from '../usePanelActionItems';
|
||||
|
||||
/** Keys of the disabled items, in order. */
|
||||
@@ -494,4 +495,43 @@ describe('usePanelActionItems', () => {
|
||||
click(actionRow(result.current.items, 'create-alert'));
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
|
||||
});
|
||||
|
||||
describe('host controls', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('drops every writing action on a read-only mount', () => {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch: jest.fn(),
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'separator',
|
||||
'download',
|
||||
'create-alert',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the actions the host hides', () => {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch: jest.fn(),
|
||||
controls: { panelActions: { createAlert: false, delete: false } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
|
||||
expect(itemKeys(result.current)).not.toContain('create-alert');
|
||||
expect(itemKeys(result.current)).not.toContain('delete-panel');
|
||||
expect(itemKeys(result.current)).toContain('edit-panel');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/t
|
||||
import { blockedBy } from 'lib/authz/components/AuthZDropdown/utils';
|
||||
import { getBlockedPanelActions } from '../utils/getBlockedPanelActions';
|
||||
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControlGroup } from '../../../controls/useDashboardControlGroup';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
// hooks' deps (a fresh [] each render would re-create their callbacks).
|
||||
@@ -66,6 +67,7 @@ export function usePanelActionItems({
|
||||
const blocked = useMemo(() => getBlockedPanelActions(panel), [panel]);
|
||||
const { isEditable, editChecks, editDisabledTooltip } =
|
||||
useDashboardEditContext();
|
||||
const show = useDashboardControlGroup('panelActions');
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { openView } = useViewPanel();
|
||||
@@ -103,7 +105,7 @@ export function usePanelActionItems({
|
||||
const editGate = { checks: editChecks, ...blockedBy(editDisabledTooltip) };
|
||||
|
||||
const panelGroup: AuthZDropdownItemType[] = [];
|
||||
if (panelCapabilities.view) {
|
||||
if (show.view && panelCapabilities.view) {
|
||||
panelGroup.push({
|
||||
type: 'item',
|
||||
value: 'view-panel',
|
||||
@@ -112,7 +114,7 @@ export function usePanelActionItems({
|
||||
onClick: (): void => openView(panelId, panel),
|
||||
});
|
||||
}
|
||||
if (panelCapabilities.edit) {
|
||||
if (show.edit && panelCapabilities.edit) {
|
||||
panelGroup.push({
|
||||
type: 'item',
|
||||
value: 'edit-panel',
|
||||
@@ -122,7 +124,7 @@ export function usePanelActionItems({
|
||||
onClick: (): void => openPanelEditor(panelId, { panel }),
|
||||
});
|
||||
}
|
||||
if (panelCapabilities.clone) {
|
||||
if (show.clone && panelCapabilities.clone) {
|
||||
// Needs section context to place the copy; disabled without it.
|
||||
panelGroup.push({
|
||||
type: 'item',
|
||||
@@ -142,13 +144,13 @@ export function usePanelActionItems({
|
||||
}
|
||||
|
||||
const dataGroup: AuthZDropdownItemType[] = [];
|
||||
if (downloadItem) {
|
||||
if (show.download && downloadItem) {
|
||||
dataGroup.push(downloadItem);
|
||||
}
|
||||
|
||||
// Create Alerts opens a new tab and never mutates the dashboard, so —
|
||||
// unlike edit/clone — it isn't gated on editability (V1 parity).
|
||||
if (panelCapabilities.createAlert) {
|
||||
if (show.createAlert && panelCapabilities.createAlert) {
|
||||
dataGroup.push({
|
||||
type: 'item',
|
||||
value: 'create-alert',
|
||||
@@ -159,8 +161,9 @@ export function usePanelActionItems({
|
||||
});
|
||||
}
|
||||
|
||||
const moveGroup: AuthZDropdownItemType[] =
|
||||
isEditable && panelActions
|
||||
const moveGroup: AuthZDropdownItemType[] = !show.move
|
||||
? []
|
||||
: isEditable && panelActions
|
||||
? buildMoveItems({
|
||||
sections,
|
||||
currentLayoutIndex: panelActions.currentLayoutIndex,
|
||||
@@ -177,17 +180,19 @@ export function usePanelActionItems({
|
||||
},
|
||||
];
|
||||
|
||||
const deleteGroup: AuthZDropdownItemType[] = [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'delete-panel',
|
||||
label: 'Delete panel',
|
||||
prefix: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
...editGate,
|
||||
onClick: (): void => requestDelete(),
|
||||
},
|
||||
];
|
||||
const deleteGroup: AuthZDropdownItemType[] = !show.delete
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'delete-panel',
|
||||
label: 'Delete panel',
|
||||
prefix: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
...editGate,
|
||||
onClick: (): void => requestDelete(),
|
||||
},
|
||||
];
|
||||
|
||||
return [panelGroup, dataGroup, moveGroup, deleteGroup]
|
||||
.filter((group) => group.length > 0)
|
||||
@@ -197,6 +202,7 @@ export function usePanelActionItems({
|
||||
: [{ type: 'separator', value: `separator-${index}` }, ...group],
|
||||
);
|
||||
}, [
|
||||
show,
|
||||
isEditable,
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControl } from '../../../controls/useDashboardControl';
|
||||
|
||||
interface ViewPanelModalHeaderBaseProps {
|
||||
onSwitchToEdit: () => void;
|
||||
@@ -70,6 +71,7 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
} = useDashboardEditContext();
|
||||
const showSwitchToEdit = useDashboardControl('viewModal.switchToEdit');
|
||||
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
|
||||
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
|
||||
const panelTypeItems = usePanelTypeSelectItems({
|
||||
@@ -87,20 +89,22 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
</div>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
disabled={!canSwitchToEdit}
|
||||
onClick={onSwitchToEdit}
|
||||
testId="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
{showSwitchToEdit && (
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
disabled={!canSwitchToEdit}
|
||||
onClick={onSwitchToEdit}
|
||||
testId="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
{query && (
|
||||
<Button
|
||||
size="md"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import ViewPanelModalHeader from '../ViewPanelModalHeader';
|
||||
import type { DashboardControlsInput } from '../../../../controls/types';
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
|
||||
() => ({
|
||||
useDashboardEditContext: (): unknown => ({
|
||||
isEditable: true,
|
||||
editChecks: [],
|
||||
editDisabledTooltip: '',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems',
|
||||
() => ({ usePanelTypeSelectItems: (): unknown[] => [] }),
|
||||
);
|
||||
|
||||
function renderHeader(
|
||||
readOnly: boolean,
|
||||
controls?: DashboardControlsInput,
|
||||
): void {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch: jest.fn(),
|
||||
readOnly,
|
||||
controls,
|
||||
});
|
||||
render(
|
||||
<ViewPanelModalHeader
|
||||
mode="static"
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChangePanelKind={jest.fn()}
|
||||
onSwitchToEdit={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ViewPanelModalHeader - controls', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('offers Switch to Edit Mode on an editable mount', () => {
|
||||
renderHeader(false);
|
||||
|
||||
expect(screen.getByTestId('view-panel-switch-to-edit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Switch to Edit Mode on a read-only mount', () => {
|
||||
renderHeader(true);
|
||||
|
||||
expect(screen.getByTestId('view-panel-type-selector')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('view-panel-switch-to-edit'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Switch to Edit Mode when the host hides it', () => {
|
||||
renderHeader(false, { viewModal: false });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('view-panel-switch-to-edit'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -163,6 +163,15 @@ describe('useDrilldown', () => {
|
||||
expect(result.current.enableDrillDown).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when every builder query is disabled, leaving only formulas to draw', () => {
|
||||
mockGetBuilderQueries.mockReturnValue([
|
||||
{ name: 'A', disabled: true },
|
||||
{ name: 'B', disabled: true },
|
||||
]);
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
expect(result.current.enableDrillDown).toBe(false);
|
||||
});
|
||||
|
||||
it.each([EQueryType.PROM, EQueryType.CLICKHOUSE, undefined])(
|
||||
'is false when the query type is %s (not the builder)',
|
||||
(queryType) => {
|
||||
|
||||
@@ -18,17 +18,29 @@ let mockVariables: Array<{
|
||||
let mockSelectionMap: Record<string, { value: unknown; allSelected: boolean }> =
|
||||
{};
|
||||
|
||||
let mockIsEditable = true;
|
||||
const mockSetVariableValue = jest.fn();
|
||||
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const DYNAMIC_KIND = 'signoz/DynamicVariable';
|
||||
const QUERY_KIND = 'signoz/QueryVariable';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useGetDashboardV2: (): unknown => ({
|
||||
data: { data: { spec: { variables: mockVariables } } },
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired',
|
||||
() => ({
|
||||
useDashboardFetchRequired: (): unknown => ({
|
||||
dashboard: { spec: { variables: mockVariables } },
|
||||
variables: mockVariables,
|
||||
refetch: jest.fn(),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
|
||||
() => ({
|
||||
useDashboardEditContext: (): unknown => ({ isEditable: mockIsEditable }),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useOptimisticPatch',
|
||||
() => ({
|
||||
@@ -97,6 +109,7 @@ function renderItems(): void {
|
||||
|
||||
describe('useDrilldownDashboardVariables', () => {
|
||||
beforeEach(() => {
|
||||
mockIsEditable = true;
|
||||
jest.clearAllMocks();
|
||||
mockVariables = [];
|
||||
mockSelectionMap = {};
|
||||
@@ -171,6 +184,17 @@ describe('useDrilldownDashboardVariables', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('offers nothing to create on a dashboard the viewer cannot edit', () => {
|
||||
mockVariables = [];
|
||||
mockIsEditable = false;
|
||||
const { result } = renderHook(() =>
|
||||
useDrilldownDashboardVariables({ filters, onClose: jest.fn() }),
|
||||
);
|
||||
|
||||
expect(result.current.actions).toStrictEqual([]);
|
||||
expect(result.current.hasFieldVariables).toBe(false);
|
||||
});
|
||||
|
||||
it('offers Create and persists a new dynamic variable when none matches', async () => {
|
||||
mockVariables = [];
|
||||
renderItems();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import { AllTheProviders, renderHook } from 'tests/test-utils';
|
||||
|
||||
import { CHARTS_ONLY } from '../../../../controls/presets';
|
||||
import type { DashboardControlsInput } from '../../../../controls/types';
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { usePanelInteractions } from '../usePanelInteractions';
|
||||
|
||||
function dragSelect(controls?: DashboardControlsInput): void {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: 'd1', refetch: jest.fn(), controls });
|
||||
const { result } = renderHook(() => usePanelInteractions(), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
result.current.onDragSelect(1_000, 2_000);
|
||||
}
|
||||
|
||||
describe('usePanelInteractions drag-to-zoom', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('writes the dragged range to the URL', () => {
|
||||
dragSelect();
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('startTime=1000&endTime=2000'),
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when the host turns zoom off', () => {
|
||||
dragSelect({ panelActions: { zoom: false } });
|
||||
|
||||
expect(safeNavigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still zooms when the host hides the toolbar but leaves zoom on', () => {
|
||||
dragSelect({ toolbar: false });
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still zooms on a charts-only mount', () => {
|
||||
dragSelect(CHARTS_ONLY);
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import { useDrilldownCoordinates } from './useDrilldownCoordinates';
|
||||
import { useDrilldownDashboardVariables } from './useDrilldownDashboardVariables';
|
||||
import { useDrilldownFilter } from './useDrilldownFilter';
|
||||
import { useResolvedDrilldownQuery } from './useResolvedDrilldownQuery';
|
||||
import { useDashboardControl } from 'pages/DashboardPage/DashboardContainer/controls/useDashboardControl';
|
||||
import { useViewPanel } from './useViewPanel';
|
||||
|
||||
/** Which menu the popover shows; extend as submenus are added (e.g. dashboard variables). */
|
||||
@@ -83,13 +84,16 @@ export function useDrilldown(
|
||||
const queries = panel.spec.queries;
|
||||
|
||||
// Drilldown only for builder-authored panels with a query to drill into (PromQL /
|
||||
// ClickHouse have no builder context to refine).
|
||||
// ClickHouse have no builder context to refine). A panel whose builder queries
|
||||
// are all disabled draws only formulas, which have no series to drill into.
|
||||
const showDrilldown = useDashboardControl('panelActions.drilldown');
|
||||
const enableDrillDown = useMemo(
|
||||
() =>
|
||||
showDrilldown &&
|
||||
getPanelDefinition(kind).actions.drilldown &&
|
||||
getPanelQueryType(panel) === EQueryType.QUERY_BUILDER &&
|
||||
getBuilderQueries(queries).length > 0,
|
||||
[kind, panel, queries],
|
||||
getBuilderQueries(queries).some((query) => !query.disabled),
|
||||
[showDrilldown, kind, panel, queries],
|
||||
);
|
||||
|
||||
const v1Query = useMemo(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type VariableFormModel,
|
||||
} from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableFormModel';
|
||||
import { buildVariablesPatch } from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/utils/variablePatchOps';
|
||||
import { useDashboardEditContext } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext';
|
||||
import { useDashboardFetchRequired } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired';
|
||||
import { useOptimisticPatch } from 'pages/DashboardPage/DashboardContainer/hooks/useOptimisticPatch';
|
||||
import { selectVariableValues } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
|
||||
@@ -44,7 +45,7 @@ export interface DrilldownVariableAction {
|
||||
}
|
||||
|
||||
export interface UseDrilldownDashboardVariablesApi {
|
||||
/** Whether the clicked point exposes any field to bind to a variable (gates the base-menu entry). */
|
||||
/** Whether the submenu has any entry to offer (gates the base-menu entry). */
|
||||
hasFieldVariables: boolean;
|
||||
/** Resolved menu entries, one per group-by field on the clicked point. */
|
||||
actions: DrilldownVariableAction[];
|
||||
@@ -74,6 +75,8 @@ export function useDrilldownDashboardVariables({
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
const setVariableValue = useDashboardStore((state) => state.setVariableValue);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
// Create writes the dashboard spec; Set/Unset only change this view's selection.
|
||||
const { isEditable } = useDashboardEditContext();
|
||||
|
||||
const fieldVariables = useMemo<[string, string | number][]>(
|
||||
() =>
|
||||
@@ -137,51 +140,57 @@ export function useDrilldownDashboardVariables({
|
||||
|
||||
const actions = useMemo<DrilldownVariableAction[]>(
|
||||
() =>
|
||||
fieldVariables.map(([fieldName, fieldValue]) => {
|
||||
const existing = dynamicVariables.find(
|
||||
(v) => v.dynamicAttribute === fieldName,
|
||||
);
|
||||
if (!existing) {
|
||||
fieldVariables
|
||||
.map(([fieldName, fieldValue]) => {
|
||||
const existing = dynamicVariables.find(
|
||||
(v) => v.dynamicAttribute === fieldName,
|
||||
);
|
||||
if (!existing) {
|
||||
if (!isEditable) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
fieldName,
|
||||
fieldValue,
|
||||
kind: DrilldownVariableActionKind.Create,
|
||||
onClick: (): void => {
|
||||
void handleCreate(fieldName, fieldValue);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const current = selection[existing.name]?.value;
|
||||
const isSame = Array.isArray(current)
|
||||
? current.length === 1 && current[0] === fieldValue
|
||||
: current === fieldValue;
|
||||
|
||||
// Multi-select values must be arrays (a scalar renders as empty in the selector).
|
||||
const cleared = existing.multiSelect ? [] : '';
|
||||
const assigned = existing.multiSelect ? [fieldValue] : fieldValue;
|
||||
|
||||
return {
|
||||
fieldName,
|
||||
fieldValue,
|
||||
kind: DrilldownVariableActionKind.Create,
|
||||
kind: isSame
|
||||
? DrilldownVariableActionKind.Unset
|
||||
: DrilldownVariableActionKind.Set,
|
||||
onClick: (): void => {
|
||||
void handleCreate(fieldName, fieldValue);
|
||||
void logEvent(DashboardDetailEvents.DrilldownAction, {
|
||||
action: 'setVariable',
|
||||
dashboardId,
|
||||
});
|
||||
setSelection(existing.name, {
|
||||
value: isSame ? cleared : assigned,
|
||||
allSelected: false,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const current = selection[existing.name]?.value;
|
||||
const isSame = Array.isArray(current)
|
||||
? current.length === 1 && current[0] === fieldValue
|
||||
: current === fieldValue;
|
||||
|
||||
// Multi-select values must be arrays (a scalar renders as empty in the selector).
|
||||
const cleared = existing.multiSelect ? [] : '';
|
||||
const assigned = existing.multiSelect ? [fieldValue] : fieldValue;
|
||||
|
||||
return {
|
||||
fieldName,
|
||||
fieldValue,
|
||||
kind: isSame
|
||||
? DrilldownVariableActionKind.Unset
|
||||
: DrilldownVariableActionKind.Set,
|
||||
onClick: (): void => {
|
||||
void logEvent(DashboardDetailEvents.DrilldownAction, {
|
||||
action: 'setVariable',
|
||||
dashboardId,
|
||||
});
|
||||
setSelection(existing.name, {
|
||||
value: isSame ? cleared : assigned,
|
||||
allSelected: false,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
}),
|
||||
})
|
||||
.filter((action): action is DrilldownVariableAction => action !== null),
|
||||
[
|
||||
fieldVariables,
|
||||
isEditable,
|
||||
dynamicVariables,
|
||||
selection,
|
||||
setSelection,
|
||||
@@ -191,5 +200,5 @@ export function useDrilldownDashboardVariables({
|
||||
],
|
||||
);
|
||||
|
||||
return { hasFieldVariables: fieldVariables.length > 0, actions };
|
||||
return { hasFieldVariables: actions.length > 0, actions };
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { useDashboardControl } from 'pages/DashboardPage/DashboardContainer/controls/useDashboardControl';
|
||||
|
||||
export interface PanelInteractions {
|
||||
/** Drag-select a chart range → write it to the URL + global time so every panel re-fetches the same range. */
|
||||
@@ -43,8 +44,12 @@ export function usePanelInteractions(): PanelInteractions {
|
||||
[syncMode, syncFilterMode, dashboardId],
|
||||
);
|
||||
|
||||
const canZoom = useDashboardControl('panelActions.zoom');
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
if (!canZoom) {
|
||||
return;
|
||||
}
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
@@ -57,7 +62,7 @@ export function usePanelInteractions(): PanelInteractions {
|
||||
void logEvent(DashboardDetailEvents.PanelZoomed, { context: 'panel' });
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, safeNavigate, urlQuery],
|
||||
[canZoom, dispatch, pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
return { onDragSelect, dashboardPreference };
|
||||
|
||||
@@ -8,12 +8,12 @@ import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { useCreatePanel } from '../../../hooks/useCreatePanel';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import { useCloneSection } from '../hooks/useCloneSection';
|
||||
import { useDeleteSection } from '../hooks/useDeleteSection';
|
||||
import { usePanelPickerHighlight } from '../hooks/usePanelPickerHighlight';
|
||||
import { usePanelPickerReveal } from '../hooks/usePanelPickerReveal';
|
||||
import { useRenameSection } from '../hooks/useRenameSection';
|
||||
import { useScrollIntoView } from '../hooks/useScrollIntoView';
|
||||
import { useSectionHeaderActions } from '../hooks/useSectionHeaderActions';
|
||||
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
|
||||
import SectionTitleModal from '../SectionTitleModal';
|
||||
import SectionGrid from '../SectionGrid/SectionGrid';
|
||||
@@ -66,7 +66,14 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
setIsDeleteOpen(false);
|
||||
}, [deleteSection]);
|
||||
|
||||
const cloneSection = useCloneSection();
|
||||
const openRename = useCallback((): void => setIsRenaming(true), []);
|
||||
const openDelete = useCallback((): void => setIsDeleteOpen(true), []);
|
||||
const actions = useSectionHeaderActions({
|
||||
section,
|
||||
openPicker,
|
||||
openRename,
|
||||
openDelete,
|
||||
});
|
||||
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
useScrollIntoView(section.id, sectionRef);
|
||||
@@ -81,6 +88,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
layoutIndex={section.layoutIndex}
|
||||
sections={sections}
|
||||
placeholderKind={pickerTarget?.panelKind}
|
||||
flush={!section.title}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -116,35 +124,30 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
dragHandle={dragHandle}
|
||||
disabledChecks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
actions={{
|
||||
onRename: (): void => setIsRenaming(true),
|
||||
onAddPanel: (): void => openPicker(section.layoutIndex),
|
||||
onCloneSection: (): void => void cloneSection(section),
|
||||
onDeleteSection: (): void => setIsDeleteOpen(true),
|
||||
}}
|
||||
actions={actions}
|
||||
/>
|
||||
{open &&
|
||||
(section.items.length > 0 || pickerTarget ? (
|
||||
grid
|
||||
) : (
|
||||
<div className={styles.emptySection}>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
size="md"
|
||||
type="button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={
|
||||
isEditable ? (): void => openPicker(section.layoutIndex) : undefined
|
||||
}
|
||||
testId={`section-add-panel-${section.id}`}
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
))}
|
||||
(section.items.length > 0 || pickerTarget
|
||||
? grid
|
||||
: actions.onAddPanel && (
|
||||
<div className={styles.emptySection}>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
size="md"
|
||||
type="button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={
|
||||
isEditable ? (): void => openPicker(section.layoutIndex) : undefined
|
||||
}
|
||||
testId={`section-add-panel-${section.id}`}
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
))}
|
||||
<SectionTitleModal
|
||||
open={isRenaming}
|
||||
heading="Rename section"
|
||||
|
||||
@@ -25,7 +25,7 @@ function SectionActionsMenu({
|
||||
onRename,
|
||||
onCloneSection,
|
||||
onDeleteSection,
|
||||
}: SectionActionsMenuProps): JSX.Element {
|
||||
}: SectionActionsMenuProps): JSX.Element | null {
|
||||
const items = useMemo<AuthZDropdownItemType[]>(() => {
|
||||
const gate = { checks: disabledChecks, ...blockedBy(disabledTooltip) };
|
||||
const result: AuthZDropdownItemType[] = [];
|
||||
@@ -83,6 +83,10 @@ function SectionActionsMenu({
|
||||
onDeleteSection,
|
||||
]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthZDropdown
|
||||
items={items}
|
||||
|
||||
@@ -23,6 +23,8 @@ interface SectionGridProps {
|
||||
sections?: DashboardSection[];
|
||||
/** Shows where a new panel of this kind will land. */
|
||||
placeholderKind?: PanelKind;
|
||||
/** No side padding: panels share the toolbar's edges. For a grid without section chrome. */
|
||||
flush?: boolean;
|
||||
}
|
||||
|
||||
function SectionGrid({
|
||||
@@ -30,6 +32,7 @@ function SectionGrid({
|
||||
layoutIndex,
|
||||
sections,
|
||||
placeholderKind,
|
||||
flush = false,
|
||||
}: SectionGridProps): JSX.Element {
|
||||
const { isEditable } = useDashboardEditContext();
|
||||
|
||||
@@ -65,6 +68,7 @@ function SectionGrid({
|
||||
onDragStop={handleLayoutChange}
|
||||
onResizeStop={handleLayoutChange}
|
||||
margin={[GRID_MARGIN, GRID_MARGIN]}
|
||||
containerPadding={flush ? [0, GRID_MARGIN] : [GRID_MARGIN, GRID_MARGIN]}
|
||||
>
|
||||
{items.map((item) => (
|
||||
// A layout item can reference a panel id that no longer exists in the
|
||||
|
||||
@@ -16,12 +16,12 @@ export interface SectionDragHandle {
|
||||
setActivatorNodeRef: (element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
/** Editable-mode section actions — present together or not at all. */
|
||||
/** Section actions; an absent one isn't offered. */
|
||||
export interface SectionHeaderActions {
|
||||
onRename: () => void;
|
||||
onAddPanel: () => void;
|
||||
onCloneSection: () => void;
|
||||
onDeleteSection: () => void;
|
||||
onRename?: () => void;
|
||||
onAddPanel?: () => void;
|
||||
onCloneSection?: () => void;
|
||||
onDeleteSection?: () => void;
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useDashboardControlGroup } from '../../../controls/useDashboardControlGroup';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { SectionHeaderActions } from '../SectionHeader/SectionHeader';
|
||||
import { useCloneSection } from './useCloneSection';
|
||||
|
||||
/** The section menu's actions, minus the ones the host hides (`sectionActions`). */
|
||||
export function useSectionHeaderActions({
|
||||
section,
|
||||
openPicker,
|
||||
openRename,
|
||||
openDelete,
|
||||
}: {
|
||||
section: DashboardSection;
|
||||
openPicker: (layoutIndex: number) => void;
|
||||
openRename: () => void;
|
||||
openDelete: () => void;
|
||||
}): SectionHeaderActions {
|
||||
const show = useDashboardControlGroup('sectionActions');
|
||||
const cloneSection = useCloneSection();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
onRename: show.rename ? openRename : undefined,
|
||||
onAddPanel: show.addPanel
|
||||
? (): void => openPicker(section.layoutIndex)
|
||||
: undefined,
|
||||
onCloneSection: show.clone
|
||||
? (): void => void cloneSection(section)
|
||||
: undefined,
|
||||
onDeleteSection: show.delete ? openDelete : undefined,
|
||||
}),
|
||||
[show, openRename, openPicker, section, cloneSection, openDelete],
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { resolveDefaultSelection } from './utils/resolveVariableSelection';
|
||||
import VariableSelector from './components/VariableSelector/VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
|
||||
import { useDashboardControl } from '../controls/useDashboardControl';
|
||||
|
||||
interface VariablesBarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
@@ -39,6 +40,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
// Persisted per dashboard so the full/collapsed view survives reloads.
|
||||
const expanded = useDashboardStore(selectVariablesExpanded(dashboardId));
|
||||
const setVariablesExpanded = useDashboardStore((s) => s.setVariablesExpanded);
|
||||
const showAddVariable = useDashboardControl('toolbar.addVariable');
|
||||
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
|
||||
itemCount: variables.length,
|
||||
gap: 8,
|
||||
@@ -49,7 +51,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
|
||||
// Editors can add a variable even before any exist; viewers with no variables
|
||||
// have nothing to show.
|
||||
if (variables.length === 0 && !isEditable) {
|
||||
if (variables.length === 0 && !(isEditable && showAddVariable)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -131,13 +133,15 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
{/* After the more/less trigger, in every state. Kept inline (not block)
|
||||
so the row still flows under the floated time selector, and always
|
||||
mounted so measuring never toggles it. */}
|
||||
<span className={styles.addSlot}>
|
||||
<AddVariableIcon
|
||||
checks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
</span>
|
||||
{showAddVariable && (
|
||||
<span className={styles.addSlot}>
|
||||
<AddVariableIcon
|
||||
checks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { dashboardResponse } from '../../../stories/__story_mockdata__/dashboard';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import VariablesBar from '../VariablesBar';
|
||||
import type { DashboardControlsInput } from '../../controls/types';
|
||||
|
||||
const { data: dashboard } = dashboardResponse({
|
||||
panels: 0,
|
||||
sectioned: false,
|
||||
variables: [],
|
||||
locked: false,
|
||||
});
|
||||
|
||||
jest.mock('../../hooks/useDashboardEditContext', () => ({
|
||||
useDashboardEditContext: (): unknown => ({
|
||||
isEditable: false,
|
||||
editChecks: [],
|
||||
editDisabledTooltip: '',
|
||||
}),
|
||||
}));
|
||||
jest.mock('../hooks/useVariableSelection', () => ({
|
||||
useVariableSelection: (): unknown => ({
|
||||
variables: [{ name: 'env' }],
|
||||
selection: {},
|
||||
setSelection: jest.fn(),
|
||||
autoSelect: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest.mock('../components/VariableSelector/VariableSelector', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="variable-selector" />,
|
||||
}));
|
||||
|
||||
function renderBar(readOnly: boolean, controls?: DashboardControlsInput): void {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
refetch: jest.fn(),
|
||||
readOnly,
|
||||
controls,
|
||||
});
|
||||
render(<VariablesBar dashboard={dashboard} />);
|
||||
}
|
||||
|
||||
describe('VariablesBar - controls', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('offers the add-variable trigger, disabled, to a viewer', () => {
|
||||
renderBar(false);
|
||||
|
||||
expect(screen.getByTestId('dashboard-variables-add')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the selectors but no add-variable trigger', () => {
|
||||
renderBar(true);
|
||||
|
||||
expect(screen.getByTestId('variable-selector')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('dashboard-variables-add'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no add-variable trigger when the host hides it', () => {
|
||||
renderBar(false, { toolbar: { addVariable: false } });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('dashboard-variables-add'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,26 @@ describe('useSeedVariableSelection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps URL values a custom list does not offer, as typed values', () => {
|
||||
mockUrlValues = { ops: ['overflow_operation', 'GET /cart'] };
|
||||
const dash = dashboard('d1', [
|
||||
model({
|
||||
name: 'ops',
|
||||
type: 'CUSTOM',
|
||||
multiSelect: true,
|
||||
customValue: 'overflow_operation',
|
||||
}),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'ops')).toStrictEqual({
|
||||
value: ['overflow_operation', 'GET /cart'],
|
||||
allSelected: false,
|
||||
customValues: ['GET /cart'],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the stored value when the URL has no entry', () => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
|
||||
@@ -71,6 +71,15 @@ function fromUrlValue(
|
||||
if (raw === ALL_SELECTED && model.multiSelect && model.showAllOption) {
|
||||
return { value: null, allSelected: true };
|
||||
}
|
||||
// Values a link carries that the custom list doesn't offer were typed by whoever
|
||||
// made it (or set by a host page); keep them as typed instead of dropping them.
|
||||
if (model.type === 'CUSTOM' && model.multiSelect && Array.isArray(raw)) {
|
||||
const options = knownVariableOptions(model);
|
||||
const customValues = raw.map(String).filter((v) => !options.includes(v));
|
||||
if (customValues.length > 0) {
|
||||
return { value: raw, allSelected: false, customValues };
|
||||
}
|
||||
}
|
||||
return { value: raw, allSelected: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -119,12 +119,29 @@ export function useVariableSelection(
|
||||
// store write + one `enqueueDescendantsBatch` means dependents re-fetch once
|
||||
// with the settled parent values, instead of once per fill.
|
||||
const pendingAutoFillRef = useRef<VariableSelectionMap>({});
|
||||
// The selection each pending fill was computed from.
|
||||
const pendingAutoFillBaseRef = useRef<
|
||||
Record<string, VariableSelection | undefined>
|
||||
>({});
|
||||
const autoFillFrameRef = useRef<number | null>(null);
|
||||
|
||||
const flushAutoFills = useCallback((): void => {
|
||||
autoFillFrameRef.current = null;
|
||||
const fills = pendingAutoFillRef.current;
|
||||
const bases = pendingAutoFillBaseRef.current;
|
||||
pendingAutoFillRef.current = {};
|
||||
pendingAutoFillBaseRef.current = {};
|
||||
// A fill reconciles the selection it saw. If that selection has moved since (the
|
||||
// seed committed a typed value, the user picked one), the fill answers a question
|
||||
// nobody is asking any more and would overwrite the newer value.
|
||||
Object.keys(fills).forEach((name) => {
|
||||
const base = bases[name];
|
||||
const now = selectionRef.current[name];
|
||||
const moved = base && now ? !areSelectionsEqual(base, now) : base !== now;
|
||||
if (moved) {
|
||||
delete fills[name];
|
||||
}
|
||||
});
|
||||
const names = Object.keys(fills);
|
||||
if (names.length === 0 || !dashboardId) {
|
||||
return;
|
||||
@@ -146,6 +163,9 @@ export function useVariableSelection(
|
||||
|
||||
const autoSelect = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
if (!(name in pendingAutoFillBaseRef.current)) {
|
||||
pendingAutoFillBaseRef.current[name] = selectionRef.current[name];
|
||||
}
|
||||
pendingAutoFillRef.current[name] = next;
|
||||
if (autoFillFrameRef.current == null) {
|
||||
autoFillFrameRef.current = requestAnimationFrame(flushAutoFills);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { dashboardResponse } from '../../stories/__story_mockdata__/dashboard';
|
||||
import { DEFAULT_CONTROLS } from '../controls/dashboardControls';
|
||||
import type { DashboardControlsInput } from '../controls/types';
|
||||
import DashboardContainer from '../index';
|
||||
import type { DashboardQuerySource } from '../store/slices/editContextSlice';
|
||||
|
||||
const QUERY_KEY = ['lockdown-dashboard'];
|
||||
|
||||
const ALL_CONTROLS_OFF = Object.fromEntries(
|
||||
Object.keys(DEFAULT_CONTROLS).map((group) => [group, false]),
|
||||
) as DashboardControlsInput;
|
||||
|
||||
const select = (
|
||||
response: GetDashboardV2200,
|
||||
): DashboardtypesGettableDashboardV2DTO => response.data;
|
||||
const SOURCE: DashboardQuerySource = { queryKey: QUERY_KEY, select };
|
||||
|
||||
/**
|
||||
* Interactive elements a host can't turn off: they read, never write, and the
|
||||
* page is unusable without them. Anything else here must be a control.
|
||||
*/
|
||||
const ALLOWED_TEST_ID_PREFIXES = [
|
||||
// Collapses a section in this browser only; it also carries the section title.
|
||||
'dashboard-section-toggle-',
|
||||
];
|
||||
|
||||
const INTERACTIVE =
|
||||
'button, a[href], input, select, textarea, [role="button"], [role="menuitem"], [role="combobox"], [role="switch"], [role="checkbox"]';
|
||||
|
||||
function LockdownHost({ panels }: { panels: number }): JSX.Element | null {
|
||||
const { data } = useQuery(QUERY_KEY, () =>
|
||||
dashboardResponse({
|
||||
panels,
|
||||
sectioned: panels > 0,
|
||||
variables: ['custom'],
|
||||
locked: false,
|
||||
}),
|
||||
);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DashboardContainer
|
||||
dashboard={data.data}
|
||||
refetch={jest.fn()}
|
||||
source={SOURCE}
|
||||
readOnly
|
||||
controls={ALL_CONTROLS_OFF}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function visibleInteractive(): Element[] {
|
||||
return Array.from(document.body.querySelectorAll(INTERACTIVE)).filter(
|
||||
(element) => !element.closest('[hidden]'),
|
||||
);
|
||||
}
|
||||
|
||||
function describeElement(element: Element): string {
|
||||
return (
|
||||
element.getAttribute('data-testid') ??
|
||||
`<${element.tagName.toLowerCase()} aria-label="${element.getAttribute('aria-label') ?? ''}">${element.textContent?.trim().slice(0, 40) ?? ''}`
|
||||
);
|
||||
}
|
||||
|
||||
function leakedInteractive(): string[] {
|
||||
return visibleInteractive()
|
||||
.map(describeElement)
|
||||
.filter(
|
||||
(id) => !ALLOWED_TEST_ID_PREFIXES.some((prefix) => id.startsWith(prefix)),
|
||||
);
|
||||
}
|
||||
|
||||
describe('DashboardContainer lockdown', () => {
|
||||
// A control a host can't switch off, or one read-only doesn't remove, fails here.
|
||||
it('renders nothing interactive with every control off on a read-only mount', async () => {
|
||||
render(<LockdownHost panels={3} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId(/^panel-/).length).toBeGreaterThan(0),
|
||||
);
|
||||
|
||||
expect(leakedInteractive()).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('renders nothing interactive on an empty dashboard', async () => {
|
||||
render(<LockdownHost panels={0} />);
|
||||
|
||||
await screen.findByTestId('dashboard-empty-state');
|
||||
|
||||
expect(leakedInteractive()).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { act, render, screen } from 'tests/test-utils';
|
||||
|
||||
import { dashboardResponse } from '../../stories/__story_mockdata__/dashboard';
|
||||
import DashboardContainer from '../index';
|
||||
import type { DashboardQuerySource } from '../store/slices/editContextSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
jest.mock('../DashboardSettings', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="dashboard-settings" />,
|
||||
}));
|
||||
|
||||
const QUERY_KEY = ['settings-drawer-dashboard'];
|
||||
const select = (
|
||||
response: GetDashboardV2200,
|
||||
): DashboardtypesGettableDashboardV2DTO => response.data;
|
||||
const SOURCE: DashboardQuerySource = { queryKey: QUERY_KEY, select };
|
||||
|
||||
function Host(): JSX.Element | null {
|
||||
const { data } = useQuery(QUERY_KEY, () =>
|
||||
dashboardResponse({
|
||||
panels: 0,
|
||||
sectioned: false,
|
||||
variables: [],
|
||||
locked: false,
|
||||
}),
|
||||
);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DashboardContainer
|
||||
dashboard={data.data}
|
||||
refetch={jest.fn()}
|
||||
source={SOURCE}
|
||||
controls={{ dashboardActions: false }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('DashboardContainer settings drawer', () => {
|
||||
it('opens on request when the host hides the dashboard actions', async () => {
|
||||
render(<Host />);
|
||||
await screen.findByTestId('dashboard-empty-state');
|
||||
|
||||
act(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.requestSettings({ tab: 'Variables', addVariable: true });
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('dashboard-settings'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { render, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { dashboardResponse } from '../../stories/__story_mockdata__/dashboard';
|
||||
import DashboardContainer from '../index';
|
||||
import type { DashboardQuerySource } from '../store/slices/editContextSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
const QUERY_KEY = ['variable-seed-dashboard'];
|
||||
const select = (
|
||||
response: GetDashboardV2200,
|
||||
): DashboardtypesGettableDashboardV2DTO => response.data;
|
||||
const SOURCE: DashboardQuerySource = { queryKey: QUERY_KEY, select };
|
||||
|
||||
function withOperationsVariable(): GetDashboardV2200 {
|
||||
const response = dashboardResponse({
|
||||
panels: 0,
|
||||
sectioned: false,
|
||||
variables: [],
|
||||
locked: false,
|
||||
});
|
||||
response.data.spec.variables = [
|
||||
{
|
||||
kind: 'ListVariable',
|
||||
spec: {
|
||||
name: 'ops',
|
||||
display: { name: 'ops' },
|
||||
allowMultiple: true,
|
||||
allowAllValue: false,
|
||||
sort: 'none',
|
||||
plugin: {
|
||||
kind: 'signoz/CustomVariable',
|
||||
spec: { customValue: 'overflow_operation' },
|
||||
},
|
||||
},
|
||||
},
|
||||
] as GetDashboardV2200['data']['spec']['variables'];
|
||||
return response;
|
||||
}
|
||||
|
||||
function Host(): JSX.Element | null {
|
||||
const { data } = useQuery(QUERY_KEY, withOperationsVariable);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DashboardContainer
|
||||
dashboard={data.data}
|
||||
refetch={jest.fn()}
|
||||
source={SOURCE}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('DashboardContainer variable seeding', () => {
|
||||
it('keeps custom-variable values a link seeds that the list does not offer', async () => {
|
||||
const seeded = ['overflow_operation', 'GET /cart', 'POST /checkout'];
|
||||
render(<Host />, undefined, {
|
||||
initialRoute: `/?variables=${encodeURIComponent(JSON.stringify({ ops: seeded }))}`,
|
||||
});
|
||||
|
||||
// The selector's first auto-fill is scheduled for the next frame; let it run.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
useDashboardStore.getState().variableValues[
|
||||
useDashboardStore.getState().dashboardId
|
||||
]?.ops?.value,
|
||||
).toStrictEqual(seeded),
|
||||
);
|
||||
await new Promise((resolve) => {
|
||||
requestAnimationFrame(resolve);
|
||||
});
|
||||
|
||||
expect(
|
||||
useDashboardStore.getState().variableValues[
|
||||
useDashboardStore.getState().dashboardId
|
||||
]?.ops?.value,
|
||||
).toStrictEqual(seeded);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
|
||||
import DashboardPageBreadcrumbs from './DashboardPageBreadcrumbs';
|
||||
import { useShareVariablesOption } from './useShareVariablesOption';
|
||||
|
||||
@@ -16,13 +17,16 @@ function DashboardPageHeader({
|
||||
image,
|
||||
}: DashboardPageHeaderProps): JSX.Element {
|
||||
const shareVariablesOption = useShareVariablesOption();
|
||||
const show = useDashboardControlGroup('header');
|
||||
return (
|
||||
<div className={styles.dashboardPageHeader}>
|
||||
<DashboardPageBreadcrumbs title={title} image={image} />
|
||||
{show.breadcrumbs && (
|
||||
<DashboardPageBreadcrumbs title={title} image={image} />
|
||||
)}
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
enableShare={show.share}
|
||||
enableFeedback={show.feedback}
|
||||
shareModalExtraOption={shareVariablesOption}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import DashboardSettings from '../../DashboardSettings';
|
||||
import SettingsDrawer from '../../DashboardPageToolbar/SettingsDrawer';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
interface DashboardSettingsDrawerProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens on any `requestSettings` in the tree. Mounted by the container, so it
|
||||
* works whichever controls a host hides.
|
||||
*/
|
||||
function DashboardSettingsDrawer({
|
||||
dashboard,
|
||||
}: DashboardSettingsDrawerProps): JSX.Element {
|
||||
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
|
||||
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
|
||||
|
||||
return (
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={!!settingsRequest}
|
||||
destroyOnClose
|
||||
onClose={clearSettingsRequest}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardSettingsDrawer;
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
CONTROL_WRITES,
|
||||
DEFAULT_CONTROLS,
|
||||
isControlVisible,
|
||||
resolveControls,
|
||||
} from '../dashboardControls';
|
||||
import type { DashboardControlId } from '../types';
|
||||
|
||||
const ALL_IDS = Object.keys(CONTROL_WRITES) as DashboardControlId[];
|
||||
|
||||
describe('resolveControls', () => {
|
||||
it('shows every control when the host passes nothing', () => {
|
||||
expect(resolveControls(undefined, false)).toStrictEqual(DEFAULT_CONTROLS);
|
||||
});
|
||||
|
||||
it('hides a whole group from one boolean', () => {
|
||||
const controls = resolveControls({ header: false }, false);
|
||||
|
||||
expect(controls.header).toStrictEqual({
|
||||
breadcrumbs: false,
|
||||
share: false,
|
||||
feedback: false,
|
||||
});
|
||||
expect(controls.title.name).toBe(true);
|
||||
});
|
||||
|
||||
it('hides single items and keeps the rest of the group', () => {
|
||||
const controls = resolveControls(
|
||||
{ panelActions: { createAlert: false } },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(controls.panelActions.createAlert).toBe(false);
|
||||
expect(controls.panelActions.download).toBe(true);
|
||||
});
|
||||
|
||||
it('hides every writing control on a read-only mount, whatever the host asked', () => {
|
||||
const controls = resolveControls(
|
||||
{ dashboardActions: true, panelActions: { edit: true } },
|
||||
true,
|
||||
);
|
||||
|
||||
ALL_IDS.forEach((id) => {
|
||||
expect([id, isControlVisible(controls, id)]).toStrictEqual([
|
||||
id,
|
||||
!CONTROL_WRITES[id],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('still lets the host hide non-writing controls on a read-only mount', () => {
|
||||
const controls = resolveControls({ panelActions: { download: false } }, true);
|
||||
|
||||
expect(controls.panelActions.download).toBe(false);
|
||||
expect(controls.panelActions.view).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { resolveControls } from '../dashboardControls';
|
||||
import { CHARTS_ONLY, EMBEDDED, withControls } from '../presets';
|
||||
|
||||
describe('withControls', () => {
|
||||
it('adds an override group the preset leaves alone', () => {
|
||||
expect(
|
||||
withControls(EMBEDDED, { toolbar: { autoRefresh: false } }),
|
||||
).toStrictEqual({
|
||||
header: false,
|
||||
title: false,
|
||||
dashboardActions: false,
|
||||
toolbar: { autoRefresh: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('turns single items back on inside a group the preset hides', () => {
|
||||
const merged = withControls(CHARTS_ONLY, { panelActions: { view: true } });
|
||||
const controls = resolveControls(merged, false);
|
||||
|
||||
expect(controls.panelActions.view).toBe(true);
|
||||
expect(controls.panelActions.download).toBe(false);
|
||||
expect(controls.panelActions.drilldown).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a later whole-group boolean replace earlier items', () => {
|
||||
expect(
|
||||
withControls(
|
||||
{ panelActions: { createAlert: false } },
|
||||
{ panelActions: true },
|
||||
),
|
||||
).toStrictEqual({ panelActions: true });
|
||||
});
|
||||
|
||||
it('lets later items win over earlier ones', () => {
|
||||
expect(
|
||||
withControls(
|
||||
{ toolbar: { timeRange: false, autoRefresh: false } },
|
||||
{ toolbar: { timeRange: true } },
|
||||
),
|
||||
).toStrictEqual({ toolbar: { timeRange: true, autoRefresh: false } });
|
||||
});
|
||||
|
||||
it('cannot turn a writing control back on for a read-only mount', () => {
|
||||
const merged = withControls(EMBEDDED, { dashboardActions: { delete: true } });
|
||||
|
||||
expect(resolveControls(merged, true).dashboardActions.delete).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type {
|
||||
DashboardControlGroup,
|
||||
DashboardControlId,
|
||||
DashboardControls,
|
||||
DashboardControlsInput,
|
||||
} from './types';
|
||||
|
||||
export const DEFAULT_CONTROLS: DashboardControls = {
|
||||
header: { breadcrumbs: true, share: true, feedback: true },
|
||||
title: {
|
||||
name: true,
|
||||
description: true,
|
||||
tags: true,
|
||||
lock: true,
|
||||
publicBadge: true,
|
||||
},
|
||||
toolbar: {
|
||||
timeRange: true,
|
||||
autoRefresh: true,
|
||||
variables: true,
|
||||
addVariable: true,
|
||||
},
|
||||
dashboardActions: {
|
||||
fullscreen: true,
|
||||
rename: true,
|
||||
clone: true,
|
||||
lock: true,
|
||||
newSection: true,
|
||||
delete: true,
|
||||
configure: true,
|
||||
json: true,
|
||||
newPanel: true,
|
||||
},
|
||||
sectionActions: { addPanel: true, rename: true, clone: true, delete: true },
|
||||
panelActions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: true,
|
||||
createAlert: true,
|
||||
move: true,
|
||||
delete: true,
|
||||
drilldown: true,
|
||||
zoom: true,
|
||||
},
|
||||
viewModal: { switchToEdit: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether each control writes to the dashboard. A read-only mount hides every
|
||||
* `true` here regardless of what the host asked for. Exhaustive, so a new
|
||||
* control can't ship without deciding.
|
||||
*/
|
||||
export const CONTROL_WRITES: Record<DashboardControlId, boolean> = {
|
||||
'header.breadcrumbs': false,
|
||||
'header.share': false,
|
||||
'header.feedback': false,
|
||||
'title.name': false,
|
||||
'title.description': false,
|
||||
'title.tags': false,
|
||||
'title.lock': true,
|
||||
'title.publicBadge': false,
|
||||
'toolbar.timeRange': false,
|
||||
'toolbar.autoRefresh': false,
|
||||
'toolbar.variables': false,
|
||||
'toolbar.addVariable': true,
|
||||
'dashboardActions.fullscreen': false,
|
||||
'dashboardActions.rename': true,
|
||||
'dashboardActions.clone': true,
|
||||
'dashboardActions.lock': true,
|
||||
'dashboardActions.newSection': true,
|
||||
'dashboardActions.delete': true,
|
||||
'dashboardActions.configure': true,
|
||||
// Opens read-only on a non-editable mount.
|
||||
'dashboardActions.json': false,
|
||||
'dashboardActions.newPanel': true,
|
||||
'sectionActions.addPanel': true,
|
||||
'sectionActions.rename': true,
|
||||
'sectionActions.clone': true,
|
||||
'sectionActions.delete': true,
|
||||
'panelActions.view': false,
|
||||
'panelActions.edit': true,
|
||||
'panelActions.clone': true,
|
||||
'panelActions.download': false,
|
||||
'panelActions.createAlert': false,
|
||||
'panelActions.move': true,
|
||||
'panelActions.delete': true,
|
||||
'panelActions.drilldown': false,
|
||||
'panelActions.zoom': false,
|
||||
'viewModal.switchToEdit': true,
|
||||
};
|
||||
|
||||
function resolveGroup<G extends DashboardControlGroup>(
|
||||
group: G,
|
||||
input: DashboardControlsInput | undefined,
|
||||
readOnly: boolean,
|
||||
): DashboardControls[G] {
|
||||
const defaults: Record<string, boolean> = DEFAULT_CONTROLS[group];
|
||||
const override = input?.[group] as
|
||||
| boolean
|
||||
| Partial<Record<string, boolean>>
|
||||
| undefined;
|
||||
const items = Object.keys(defaults).map((item): [string, boolean] => {
|
||||
const requested =
|
||||
typeof override === 'boolean'
|
||||
? override
|
||||
: (override?.[item] ?? defaults[item]);
|
||||
const writes = CONTROL_WRITES[`${group}.${item}` as DashboardControlId];
|
||||
return [item, requested && !(readOnly && writes)];
|
||||
});
|
||||
return Object.fromEntries(items) as DashboardControls[G];
|
||||
}
|
||||
|
||||
/** Host input over the defaults, then every writing control off when read-only. */
|
||||
export function resolveControls(
|
||||
input: DashboardControlsInput | undefined,
|
||||
readOnly: boolean,
|
||||
): DashboardControls {
|
||||
return {
|
||||
header: resolveGroup('header', input, readOnly),
|
||||
title: resolveGroup('title', input, readOnly),
|
||||
toolbar: resolveGroup('toolbar', input, readOnly),
|
||||
dashboardActions: resolveGroup('dashboardActions', input, readOnly),
|
||||
sectionActions: resolveGroup('sectionActions', input, readOnly),
|
||||
panelActions: resolveGroup('panelActions', input, readOnly),
|
||||
viewModal: resolveGroup('viewModal', input, readOnly),
|
||||
};
|
||||
}
|
||||
|
||||
export function isControlVisible(
|
||||
controls: DashboardControls,
|
||||
id: DashboardControlId,
|
||||
): boolean {
|
||||
const [group, item] = id.split('.') as [DashboardControlGroup, string];
|
||||
return (controls[group] as Record<string, boolean>)[item];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { DEFAULT_CONTROLS } from './dashboardControls';
|
||||
import type {
|
||||
DashboardControlGroup,
|
||||
DashboardControls,
|
||||
DashboardControlsInput,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* The host page owns the header, the title row and the dashboard-level actions.
|
||||
* Layout presets only: `readOnly` stays a separate switch, so no preset can turn
|
||||
* a writing control back on.
|
||||
*/
|
||||
export const EMBEDDED: DashboardControlsInput = {
|
||||
header: false,
|
||||
title: false,
|
||||
dashboardActions: false,
|
||||
};
|
||||
|
||||
/** Embedded, and panels are just charts: no panel menu, no drilldown. Charts still zoom. */
|
||||
export const CHARTS_ONLY: DashboardControlsInput = {
|
||||
...EMBEDDED,
|
||||
panelActions: {
|
||||
view: false,
|
||||
edit: false,
|
||||
clone: false,
|
||||
download: false,
|
||||
createAlert: false,
|
||||
move: false,
|
||||
delete: false,
|
||||
drilldown: false,
|
||||
},
|
||||
};
|
||||
|
||||
type GroupInput<G extends DashboardControlGroup> =
|
||||
| boolean
|
||||
| Partial<DashboardControls[G]>;
|
||||
|
||||
function mergeGroup<G extends DashboardControlGroup>(
|
||||
group: G,
|
||||
base: GroupInput<G> | undefined,
|
||||
next: GroupInput<G> | undefined,
|
||||
): GroupInput<G> | undefined {
|
||||
if (next === undefined) {
|
||||
return base;
|
||||
}
|
||||
if (typeof next === 'boolean' || base === undefined) {
|
||||
return next;
|
||||
}
|
||||
// A whole-group boolean underneath expands to its items before the override.
|
||||
const expanded: Partial<DashboardControls[G]> =
|
||||
typeof base === 'boolean'
|
||||
? (Object.fromEntries(
|
||||
Object.keys(DEFAULT_CONTROLS[group]).map((item) => [item, base]),
|
||||
) as Partial<DashboardControls[G]>)
|
||||
: base;
|
||||
return { ...expanded, ...next };
|
||||
}
|
||||
|
||||
/** Merges presets and overrides; later inputs win, item by item. */
|
||||
export function withControls(
|
||||
...inputs: DashboardControlsInput[]
|
||||
): DashboardControlsInput {
|
||||
const groups = Object.keys(DEFAULT_CONTROLS) as DashboardControlGroup[];
|
||||
return inputs.reduce<DashboardControlsInput>((merged, input) => {
|
||||
const result: DashboardControlsInput = { ...merged };
|
||||
groups.forEach((group) => {
|
||||
const value = mergeGroup(group, merged[group], input[group]);
|
||||
if (value !== undefined) {
|
||||
(result as Record<string, unknown>)[group] = value;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { PanelActionId } from '../PanelsAndSectionsLayout/Panel/PanelActionsMenu/panelActionMeta';
|
||||
|
||||
/** Every control a host can show or hide, grouped by where it renders. */
|
||||
export interface DashboardControls {
|
||||
header: { breadcrumbs: boolean; share: boolean; feedback: boolean };
|
||||
title: {
|
||||
name: boolean;
|
||||
description: boolean;
|
||||
tags: boolean;
|
||||
lock: boolean;
|
||||
publicBadge: boolean;
|
||||
};
|
||||
toolbar: {
|
||||
timeRange: boolean;
|
||||
autoRefresh: boolean;
|
||||
variables: boolean;
|
||||
addVariable: boolean;
|
||||
};
|
||||
dashboardActions: {
|
||||
fullscreen: boolean;
|
||||
rename: boolean;
|
||||
clone: boolean;
|
||||
lock: boolean;
|
||||
newSection: boolean;
|
||||
delete: boolean;
|
||||
configure: boolean;
|
||||
json: boolean;
|
||||
newPanel: boolean;
|
||||
};
|
||||
sectionActions: {
|
||||
addPanel: boolean;
|
||||
rename: boolean;
|
||||
clone: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
// Keyed by the panel menu's own ids, so a new panel action needs a control.
|
||||
// `drilldown` (click) and `zoom` (drag-select a time range) act on the chart itself.
|
||||
panelActions: Record<PanelActionId | 'drilldown' | 'zoom', boolean>;
|
||||
viewModal: { switchToEdit: boolean };
|
||||
}
|
||||
|
||||
export type DashboardControlGroup = keyof DashboardControls;
|
||||
|
||||
export type DashboardControlId = {
|
||||
[
|
||||
G in DashboardControlGroup
|
||||
]: `${G}.${Extract<keyof DashboardControls[G], string>}`;
|
||||
}[DashboardControlGroup];
|
||||
|
||||
/** A whole group as one boolean, or single items; omitted means shown. */
|
||||
export type DashboardControlsInput = {
|
||||
[G in DashboardControlGroup]?: boolean | Partial<DashboardControls[G]>;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { isControlVisible } from './dashboardControls';
|
||||
import type { DashboardControlId } from './types';
|
||||
|
||||
/**
|
||||
* Whether a control renders on this mount: the host's `controls`, with every
|
||||
* writing control off when read-only. Lock and permission state stay with
|
||||
* `useDashboardEditContext`, which decides disabled + reason.
|
||||
*/
|
||||
export function useDashboardControl(id: DashboardControlId): boolean {
|
||||
return useDashboardStore((s) => isControlVisible(s.controls, id));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import type { DashboardControlGroup, DashboardControls } from './types';
|
||||
|
||||
/** Every control in a group, resolved for this mount (see `useDashboardControl`). */
|
||||
export function useDashboardControlGroup<G extends DashboardControlGroup>(
|
||||
group: G,
|
||||
): DashboardControls[G] {
|
||||
return useDashboardStore((s) => s.controls[group]);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
buildDashboardUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useDashboardEditContext } from '../useDashboardEditContext';
|
||||
|
||||
const DASHBOARD_ID = 'dash-1';
|
||||
@@ -44,6 +45,23 @@ describe('useDashboardEditContext - AuthZ', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
useDashboardStore.setState({ readOnly: false });
|
||||
});
|
||||
|
||||
// A host-forced view-only mount outranks every grant.
|
||||
it('is read-only on a read-only mount despite full permissions', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
useDashboardStore.setState({ readOnly: true });
|
||||
|
||||
const { result } = renderGuard();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.editDisabledTooltip).toBe('dashboard_read_only_here'),
|
||||
);
|
||||
expect(result.current.isEditable).toBe(false);
|
||||
expect(result.current.canEditDashboard).toBe(false);
|
||||
expect(result.current.canDeleteDashboard).toBe(false);
|
||||
expect(result.current.deleteDisabledTooltip).toBe('dashboard_read_only_here');
|
||||
});
|
||||
|
||||
describe('permission granted', () => {
|
||||
|
||||
@@ -1,57 +1,115 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { renderHook } from 'tests/test-utils';
|
||||
import {
|
||||
type DashboardtypesGettableDashboardV2DTO,
|
||||
type DashboardtypesVariableDTO,
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind as TextVariableKind,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardQuerySource } from '../../store/slices/editContextSlice';
|
||||
import { useDashboardFetchRequired } from '../useDashboardFetchRequired';
|
||||
|
||||
const mockUseGetDashboardV2 = jest.fn();
|
||||
const QUERY_KEY = ['/api/v2/dashboards/dash-1'];
|
||||
const mockRefetch = jest.fn();
|
||||
let mockSource: DashboardQuerySource | null;
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { dashboardId: string }) => unknown,
|
||||
): unknown => selector({ dashboardId: 'dash-1' }),
|
||||
selector: (s: {
|
||||
dashboardId: string;
|
||||
dashboardQuerySource: DashboardQuerySource | null;
|
||||
refetch: () => void;
|
||||
}) => unknown,
|
||||
): unknown =>
|
||||
selector({
|
||||
dashboardId: 'dash-1',
|
||||
dashboardQuerySource: mockSource,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
|
||||
}));
|
||||
function textVariable(name: string): DashboardtypesVariableDTO {
|
||||
return {
|
||||
kind: TextVariableKind.TextVariable,
|
||||
spec: { name, display: { name }, value: '' },
|
||||
};
|
||||
}
|
||||
|
||||
function dashboardWith(
|
||||
spec: Partial<DashboardtypesGettableDashboardV2DTO['spec']>,
|
||||
): DashboardtypesGettableDashboardV2DTO {
|
||||
return { id: 'dash-1', spec } as DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function renderWithCache(
|
||||
cached?: unknown,
|
||||
): ReturnType<
|
||||
typeof renderHook<ReturnType<typeof useDashboardFetchRequired>, void>
|
||||
> {
|
||||
const queryClient = new QueryClient();
|
||||
if (cached !== undefined) {
|
||||
queryClient.setQueryData(mockSource?.queryKey ?? QUERY_KEY, cached);
|
||||
}
|
||||
return renderHook(() => useDashboardFetchRequired(), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe('useDashboardFetchRequired', () => {
|
||||
it('returns the loaded dashboard and its variables', () => {
|
||||
const dashboard = {
|
||||
spec: { variables: [{ name: 'region' }, { name: 'tier' }] },
|
||||
};
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: { data: dashboard } });
|
||||
beforeEach(() => {
|
||||
mockSource = null;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDashboardFetchRequired());
|
||||
it('reads the dashboards API entry when the host sets no source', () => {
|
||||
const variables = [textVariable('region'), textVariable('tier')];
|
||||
const dashboard = dashboardWith({ variables });
|
||||
|
||||
const { result } = renderWithCache({ data: dashboard, status: 'success' });
|
||||
|
||||
expect(result.current.dashboard).toBe(dashboard);
|
||||
expect(result.current.variables).toStrictEqual([
|
||||
{ name: 'region' },
|
||||
{ name: 'tier' },
|
||||
]);
|
||||
expect(result.current.variables).toBe(variables);
|
||||
expect(result.current.refetch).toBe(mockRefetch);
|
||||
});
|
||||
|
||||
it("reads a host's own cache entry through its select", () => {
|
||||
const systemDashboard = { name: 'ai-o11y-overview', spec: {} };
|
||||
mockSource = {
|
||||
queryKey: ['/api/v2/dashboards/system/ai-o11y-overview'],
|
||||
select: (response: { data: typeof systemDashboard }) =>
|
||||
({
|
||||
...response.data,
|
||||
id: 'system-id',
|
||||
}) as DashboardtypesGettableDashboardV2DTO,
|
||||
};
|
||||
|
||||
const { result } = renderWithCache({ data: systemDashboard });
|
||||
|
||||
expect(result.current.dashboard).toStrictEqual({
|
||||
name: 'ai-o11y-overview',
|
||||
spec: {},
|
||||
id: 'system-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes variables to an empty array when the spec has none', () => {
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
|
||||
|
||||
const { result } = renderHook(() => useDashboardFetchRequired());
|
||||
const { result } = renderWithCache({ data: dashboardWith({}) });
|
||||
|
||||
expect(result.current.variables).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('throws when used outside a loaded dashboard subtree', () => {
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: undefined });
|
||||
|
||||
it('throws when the source entry is empty', () => {
|
||||
// Suppress React's expected render-error logging for this case.
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
expect(() => renderHook(() => useDashboardFetchRequired())).toThrow(
|
||||
/outside a loaded dashboard subtree/,
|
||||
);
|
||||
expect(() => renderWithCache()).toThrow(/outside a loaded dashboard subtree/);
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
@@ -111,3 +111,32 @@ describe('useGetQueryRangeV5 enabled gating', () => {
|
||||
expect(mockQueryRangeV5).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGetQueryRangeV5 host fetch', () => {
|
||||
it('fetches through the host fetch instead of query_range', async () => {
|
||||
mockQueryRangeV5.mockReset();
|
||||
const fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 'success', data: {} } as QueryRangeV5200);
|
||||
const wrapper = ({ children }: PropsWithChildren): JSX.Element => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useGetQueryRangeV5({
|
||||
requestPayload: REQUEST,
|
||||
queryKey: KEY_A,
|
||||
enabled: true,
|
||||
fetch,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||
expect(fetch.mock.calls[0][0]).toBe(REQUEST);
|
||||
expect(mockQueryRangeV5).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,10 +19,12 @@ jest.mock('api/generated/services/dashboard', () => ({
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
|
||||
}));
|
||||
|
||||
let mockReadOnly = false;
|
||||
|
||||
jest.mock('../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: jest.fn(
|
||||
(selector: (s: { dashboardId: string; isEditable: boolean }) => unknown) =>
|
||||
selector({ dashboardId: 'dash-1', isEditable: true }),
|
||||
(selector: (s: { dashboardId: string; readOnly: boolean }) => unknown) =>
|
||||
selector({ dashboardId: 'dash-1', readOnly: mockReadOnly }),
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -35,6 +37,7 @@ const queryClient = {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let captured: { fn: (ops: any) => unknown; options: any };
|
||||
let mockMutateAsync: jest.Mock;
|
||||
|
||||
function dashboardEnvelope(name: string): GetDashboardV2200 {
|
||||
return {
|
||||
@@ -50,10 +53,12 @@ const replaceNameOp = {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockReadOnly = false;
|
||||
mockMutateAsync = jest.fn().mockResolvedValue(undefined);
|
||||
(useQueryClient as jest.Mock).mockReturnValue(queryClient);
|
||||
(useMutation as jest.Mock).mockImplementation((fn, options) => {
|
||||
captured = { fn, options };
|
||||
return { mutateAsync: jest.fn(), isLoading: false };
|
||||
return { mutateAsync: mockMutateAsync, isLoading: false };
|
||||
});
|
||||
renderHook(() => useOptimisticPatch());
|
||||
});
|
||||
@@ -119,4 +124,22 @@ describe('useOptimisticPatch', () => {
|
||||
// No follow-up GET (that could read stale data and flicker the UI back).
|
||||
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('patchAsync rejects without sending on a read-only mount', async () => {
|
||||
mockReadOnly = true;
|
||||
const { result } = renderHook(() => useOptimisticPatch());
|
||||
|
||||
await expect(result.current.patchAsync([replaceNameOp])).rejects.toThrow(
|
||||
'dashboard_read_only_here',
|
||||
);
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('patchAsync sends the ops when the mount is editable', async () => {
|
||||
const { result } = renderHook(() => useOptimisticPatch());
|
||||
|
||||
await result.current.patchAsync([replaceNameOp]);
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith([replaceNameOp]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
} from 'constants/queryCacheTime';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { usePanelQuery } from '../usePanelQuery';
|
||||
import { useGetQueryRangeV5 } from '../useGetQueryRangeV5';
|
||||
|
||||
@@ -598,4 +599,58 @@ describe('usePanelQuery', () => {
|
||||
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a host query runner', () => {
|
||||
const runner = {
|
||||
key: 'public:abc',
|
||||
run: jest.fn().mockResolvedValue({ status: 'success', data: {} }),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: 'd1',
|
||||
refetch: jest.fn(),
|
||||
queryRunner: runner,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('keys the cache by the runner and runs the query through it with the panel id', async () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPABILITIES,
|
||||
}),
|
||||
);
|
||||
const [{ queryKey, fetch, requestPayload }] =
|
||||
mockUseGetQueryRangeV5.mock.calls[0];
|
||||
|
||||
expect(queryKey).toContain('public:abc');
|
||||
await fetch(requestPayload);
|
||||
expect(runner.run).toHaveBeenCalledWith({
|
||||
panelId: 'p1',
|
||||
requestPayload,
|
||||
signal: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('runs on query_range when no host runner is set', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPABILITIES,
|
||||
}),
|
||||
);
|
||||
const [{ fetch }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
|
||||
expect(fetch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useDashboardFetchRequired } from './useDashboardFetchRequired';
|
||||
*/
|
||||
export function useDashboardEditContext(): DashboardEditContext {
|
||||
const { dashboard } = useDashboardFetchRequired();
|
||||
const readOnly = useDashboardStore((s) => s.readOnly);
|
||||
const {
|
||||
canRead,
|
||||
canEdit,
|
||||
@@ -24,12 +25,8 @@ export function useDashboardEditContext(): DashboardEditContext {
|
||||
editChecks,
|
||||
deletePermission,
|
||||
areOtherPermissionsLoading,
|
||||
} = useDashboardPermissions(dashboard.id);
|
||||
} = useDashboardPermissions(dashboard.id, { enabled: !readOnly });
|
||||
const { t } = useTranslation('dashboard');
|
||||
const readOnlyOverride = useDashboardStore(
|
||||
(s) => s.canEditDashboardOverride === false,
|
||||
);
|
||||
|
||||
const deleteChecks = useMemo(() => [deletePermission], [deletePermission]);
|
||||
|
||||
return deriveEditContext({
|
||||
@@ -44,6 +41,6 @@ export function useDashboardEditContext(): DashboardEditContext {
|
||||
locked: t('dashboard_locked'),
|
||||
readOnly: t('dashboard_read_only_here'),
|
||||
},
|
||||
readOnlyOverride,
|
||||
readOnlyOverride: readOnly,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,34 +1,52 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { getGetDashboardV2QueryKey } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesVariableDTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
|
||||
|
||||
import { useDashboardFetch } from './useDashboardFetch';
|
||||
import type { UseDashboardFetchResult } from './useDashboardFetch';
|
||||
|
||||
export interface UseDashboardFetchRequiredResult {
|
||||
/** Guaranteed present — the hook throws otherwise. */
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
/** Always an array, so callers skip `?? []` guards. */
|
||||
variables: DashboardtypesVariableDTO[];
|
||||
refetch: UseDashboardFetchResult['refetch'];
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
const selectDashboardV2 = (
|
||||
response: GetDashboardV2200,
|
||||
): DashboardtypesGettableDashboardV2DTO => response.data;
|
||||
|
||||
/**
|
||||
* Dashboard accessor for components rendered inside a loaded dashboard subtree. The
|
||||
* root pages own the fetch and gate on a resolved dashboard before mounting their
|
||||
* subtree, so this reuses the shared react-query cache entry and throws if the
|
||||
* dashboard is missing — turning a silent `undefined` into a loud programmer error. It
|
||||
* also derives spec collections (`variables`) since the dashboard is guaranteed here.
|
||||
* root owns the fetch and publishes where it cached the dashboard
|
||||
* (`dashboardQuerySource`); this observes that entry without fetching, and throws if
|
||||
* it's empty — turning a silent `undefined` into a loud programmer error. It also
|
||||
* derives spec collections (`variables`) since the dashboard is guaranteed here.
|
||||
*
|
||||
* Prefer this over `useDashboardFetch` outside the two root pages; the
|
||||
* `signoz/no-dashboard-fetch-outside-root` lint rule enforces the split.
|
||||
*/
|
||||
export function useDashboardFetchRequired(): UseDashboardFetchRequiredResult {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const { dashboard, refetch } = useDashboardFetch(dashboardId);
|
||||
const source = useDashboardStore((s) => s.dashboardQuerySource);
|
||||
const queryKey =
|
||||
source?.queryKey ?? getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
const select = source?.select ?? selectDashboardV2;
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
// Observe only: the root owns the request, so this never fetches.
|
||||
const { data: dashboard } = useQuery<
|
||||
unknown,
|
||||
unknown,
|
||||
DashboardtypesGettableDashboardV2DTO | undefined
|
||||
>({
|
||||
queryKey,
|
||||
select: select as (response: unknown) => DashboardtypesGettableDashboardV2DTO,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const variables = useMemo(
|
||||
() => dashboard?.spec?.variables ?? [],
|
||||
@@ -39,7 +57,7 @@ export function useDashboardFetchRequired(): UseDashboardFetchRequiredResult {
|
||||
throw new Error(
|
||||
'useDashboardFetchRequired was used outside a loaded dashboard subtree — the ' +
|
||||
'dashboard is undefined. Only call it below a root page that gates rendering ' +
|
||||
'on useDashboardFetch (DashboardPage / PanelEditorPage).',
|
||||
'on its fetch and seeds the edit context (DashboardContainer / PanelEditorPage).',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user