Compare commits

...

25 Commits

Author SHA1 Message Date
Abhi Kumar
75b6be358a feat(services): read rate, errors and Apdex from span metrics
V1 parity: span metrics over the service's top-level operations, which the page seeds. Apdex uses
V5 histogram threshold counts; the page rescales the authored 0.5s threshold to the saved one.
Key Operations shows calls and error rate.
2026-11-13 18:39:08 +05:30
Abhi Kumar
16eb36aeed fix(dashboard): offer drilldown Create var only on editable dashboards 2026-11-13 18:39:05 +05:30
Abhi Kumar
4edc3d37ca fix(dashboard): keep variable values a link or host sets
A custom variable keeps link-seeded values its list doesn't offer, as typed values. An auto-fill
computed before the seed landed no longer overwrites it a frame later.
2026-11-13 18:39:01 +05:30
Abhi Kumar
f041a2e0a5 fix(dashboard): skip drilldown on panels that draw only formulas
Formula series have no query to drill into, so a panel whose builder queries are all disabled
no longer offers the click or its tooltip hint.
2026-11-13 17:30:26 +05:30
Abhi Kumar
9c31b3c90f feat(services): add breadcrumbs to service detail and align its edges 2026-11-13 17:10:12 +05:30
Abhi Kumar
d8b91c12ae feat(dashboard): line up untitled grids with the toolbar
A grid without section chrome drops its side padding, so panels share the toolbar's edges.
Titled sections keep their inner padding.
2026-11-13 17:10:08 +05:30
Abhi Kumar
a34be3f63f fix(services): list every operation in Key Operations
V1's table lists all of a service's operations by default; only the charts stay on entry points.
2026-11-13 17:10:07 +05:30
Abhi Kumar
97e06f658e test(e2e): drive service detail through its system dashboards
Key Operations is a table panel now, so the entrypoint toggle and row links give way to its
Calls/Errors columns and the dashboard drilldown into traces.
2026-11-13 16:11:57 +05:30
Abhi Kumar
4ab56c4f2a refactor(services): remove the V1 service detail code
Drops the old page, its tabs, V1 queries, Key Operations table and Apdex widgets, plus the
exports only they used. Helpers Celery, Messaging Queues and API Monitoring share stay.
2026-11-13 16:11:53 +05:30
Abhi Kumar
86856c6714 feat(services): rebuild service detail on system dashboards
Each tab mounts its system dashboard; the page seeds the service and its saved Apdex threshold
through ?variables= and follows the dashboard's service picker. Built on @signozhq/ui.
2026-11-13 16:11:45 +05:30
Abhi Kumar
dd880f695f refactor(dashboard): use the @signozhq/ui spinner for system dashboards 2026-11-13 16:11:34 +05:30
Abhi Kumar
5cb1f33525 feat(services): add service detail system dashboards
Overview reads spans entering the service (isRoot or isEntryPoint), as the services list does,
so it needs no top-level operations list. DB and External calls keep the V1 span-metric queries.
2026-11-13 16:11:34 +05:30
Abhi Kumar
3851339e6c test(cost-meter): find the UTC billing notice by test id 2026-11-13 14:16:53 +05:30
Abhi Kumar
a4dde27546 fix(dashboard): tell a viewer without dashboard read why a system dashboard is missing 2026-11-13 14:16:49 +05:30
Abhi Kumar
0402254209 Merge branch 'feat/dashboard-host-seams' into feat/system-dashboard-cost-meter 2026-11-13 14:13:56 +05:30
Abhi Kumar
4225452554 test(dashboard): build controls test fixtures from typed builders 2026-11-13 14:13:49 +05:30
Abhi Kumar
50dfec7247 fix(dashboard): keep settings and empty-state actions in step with host controls
The settings drawer mounts once in the container, so Add variable still opens it when a
host hides the dashboard actions. The empty state's Configure and New Panel read the same
controls as the toolbar.
2026-11-13 14:13:42 +05:30
Abhi Kumar
ce1117eaa0 feat(cost-meter): render the breakdown as a read-only system dashboard
The eight widgets move into a backend system dashboard definition, so the page is data rather than
hand-built V1 cards. The LLM overview and the breakdown share one SystemDashboard mount.

The page drops its own time picker for the dashboard's, and hides panel actions.
2026-11-13 14:00:48 +05:30
Abhi Kumar
2eb46f19fb refactor(dashboard): mount system dashboards through a shared host
LLM Overview moves onto container/SystemDashboard, so later pages reuse its fetch,
loading and error states.
2026-11-13 14:00:21 +05:30
Abhi Kumar
e37e94af53 feat(dashboard): let hosts choose what runs panel queries
A host's queryRunner replaces only the network call; usePanelQuery still owns the window, the
request and the loading states, so renderers are untouched. The runner's key joins every panel
cache key, so results from different runners never mix.
2026-11-13 14:00:03 +05:30
Abhi Kumar
f060e262d2 feat(dashboard): let hosts turn off drag-to-zoom
CHARTS_ONLY lists its panel actions so zoom stays on.
2026-11-13 13:59:55 +05:30
Abhi Kumar
27a11ada95 feat(dashboard): add layout presets for host controls
EMBEDDED and CHARTS_ONLY name the layouts hosts repeat; withControls merges them with overrides item
by item. Presets only decide what shows: readOnly stays separate, so none can bring back a write.
2026-11-13 13:59:50 +05:30
Abhi Kumar
a4d83ea2fd feat(dashboard): let hosts choose which dashboard controls render
controls is grouped by area and on by default. Visibility is kept apart from readOnly: every
control declares whether it writes, and a read-only mount hides the writing ones whatever the
host asks for.

A lockdown test renders the container with every control off and fails on any interactive
element left, so a new control can't ship ungated.
2026-11-13 13:59:38 +05:30
Abhi Kumar
2e4d47a124 test(ai-observability): serve the system dashboard in the overview story
The mock builds the response from the backend's own definition JSON, so the story can't drift
from what the server serves.
2026-11-13 13:59:25 +05:30
Abhi Kumar
5a62bcd85c feat(dashboard): mount a dashboard read-only from any cache entry
The host tells the store where it cached the dashboard and the subtree observes that entry, so the
system dashboard no longer primes the dashboards API cache under a fake id.

readOnly replaces canEditDashboardOverride: it also rejects writes and skips edit-only requests.
2026-11-13 13:59:25 +05:30
166 changed files with 5997 additions and 6819 deletions

View File

@@ -11,7 +11,7 @@ export const ServicesTablePage = Loadable(
export const ServiceMetricsPage = Loadable(
() =>
import(
/* webpackChunkName: "ServiceMetricsPage" */ 'pages/MetricsApplication/MetricsApplication'
/* webpackChunkName: "ServiceMetricsPage" */ 'pages/ServiceDetails/ServiceDetails'
),
);

View File

@@ -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}`,
);

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -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 };
}

View File

@@ -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;
}
}
}

View File

@@ -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>
);
}

View File

@@ -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',
);
});
});

View File

@@ -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',
}),
);

View File

@@ -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,
});
};

View File

@@ -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,
});
};

View File

@@ -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,

View File

@@ -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,
});
};

View File

@@ -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,
});
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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[];
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -1 +0,0 @@
export const title = (): string => 'Key Operations';

View File

@@ -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[];
}

View File

@@ -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]);
}

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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,
},
});

View File

@@ -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();
});
});

View File

@@ -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',
};

View File

@@ -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;
}
`;

View File

@@ -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[];
}

View File

@@ -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',
},
]);
});
});

View File

@@ -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;
});

View File

@@ -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);
}

View 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;

View File

@@ -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');
});
});

View File

@@ -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 },
};
}

View 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 };
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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]};
}
`;

View File

@@ -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;
}

View File

@@ -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),
});

View File

@@ -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}

View File

@@ -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}

View File

@@ -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';
}

View File

@@ -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();
});
});

View File

@@ -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}

View File

@@ -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 },

View File

@@ -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());

View File

@@ -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

View File

@@ -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');
});
});
});

View File

@@ -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,

View File

@@ -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"

View File

@@ -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();
});
});

View File

@@ -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) => {

View File

@@ -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();

View File

@@ -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();
});
});

View File

@@ -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(

View File

@@ -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 };
}

View File

@@ -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 };

View File

@@ -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"

View File

@@ -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}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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],
);
}

View File

@@ -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>
);

View File

@@ -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();
});
});

View File

@@ -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()

View File

@@ -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 };
}

View File

@@ -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);

View File

@@ -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([]);
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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];
}

View File

@@ -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;
}, {});
}

View File

@@ -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]>;
};

View File

@@ -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));
}

View File

@@ -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]);
}

View File

@@ -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', () => {

View File

@@ -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();
});

View File

@@ -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();
});
});

View File

@@ -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]);
});
});

View File

@@ -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();
});
});

View File

@@ -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,
});
}

View File

@@ -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).',
);
}

View File

@@ -21,6 +21,7 @@ interface UseDashboardStaleCheck {
export function useDashboardStaleCheck(
dashboard: DashboardtypesGettableDashboardV2DTO,
refetch: () => void,
enabled = true,
): UseDashboardStaleCheck {
const {
id: dashboardId,
@@ -34,7 +35,7 @@ export function useDashboardStaleCheck(
const [dismissedAt, setDismissedAt] = useState<string | null>(null);
useEffect(() => {
if (!dashboardId) {
if (!dashboardId || !enabled) {
return undefined;
}
const probe = async (): Promise<void> => {
@@ -56,7 +57,7 @@ export function useDashboardStaleCheck(
document.removeEventListener('visibilitychange', probe);
window.removeEventListener('focus', probe);
};
}, [dashboardId]);
}, [dashboardId, enabled]);
const serverUpdatedAt = server?.updatedAt;

Some files were not shown because too many files have changed in this diff Show More