mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-15 12:50:40 +00:00
Compare commits
11 Commits
feat/distr
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bae4e74eb | ||
|
|
3ca219d02f | ||
|
|
60c0d73143 | ||
|
|
90f24a0fe9 | ||
|
|
908e44c1e8 | ||
|
|
da94bb975f | ||
|
|
2a3a5fe4ef | ||
|
|
d59bc57394 | ||
|
|
af995d970b | ||
|
|
9d94886c9b | ||
|
|
9fef8eea69 |
@@ -4517,99 +4517,6 @@ components:
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesDistributionAxes:
|
||||
properties:
|
||||
x:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionXAxis'
|
||||
description: The bucket axis.
|
||||
"y":
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionYAxis'
|
||||
description: The count axis.
|
||||
type: object
|
||||
DashboardtypesDistributionChartAppearance:
|
||||
properties:
|
||||
percentiles:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionPercentiles'
|
||||
description: Percentile markers drawn across the bucket axis.
|
||||
stack:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesStackMode'
|
||||
description: 'How groups combine in a bucket: normal (default) stacks them,
|
||||
none draws them side by side, percent stacks them as shares of the bucket.'
|
||||
required:
|
||||
- stack
|
||||
type: object
|
||||
DashboardtypesDistributionPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionAxes'
|
||||
description: The X (bucket) axis range and Y (count) axis scale.
|
||||
chartAppearance:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionChartAppearance'
|
||||
description: Stacking and percentile markers.
|
||||
formatting:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
description: Unit and decimal precision of the bucket bounds; counts take
|
||||
no unit.
|
||||
legend:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesLegend'
|
||||
description: Legend placement and series colors.
|
||||
visualization:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
description: The time range the bucket counts are summed over.
|
||||
type: object
|
||||
DashboardtypesDistributionPercentiles:
|
||||
properties:
|
||||
enabled:
|
||||
description: Whether percentile markers are drawn. Defaults to true.
|
||||
type: boolean
|
||||
values:
|
||||
description: The percentiles to mark, each in (0, 100), at most 5, no duplicates.
|
||||
Defaults to [50, 90, 99].
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- enabled
|
||||
- values
|
||||
type: object
|
||||
DashboardtypesDistributionXAxis:
|
||||
properties:
|
||||
max:
|
||||
description: Buckets wholly above this value are hidden. Null shows them
|
||||
all.
|
||||
nullable: true
|
||||
type: number
|
||||
min:
|
||||
description: Buckets wholly below this value are hidden. Null shows them
|
||||
all.
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesDistributionYAxis:
|
||||
properties:
|
||||
scale:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionYScale'
|
||||
description: 'How counts are spaced: linear (default) or log.'
|
||||
required:
|
||||
- scale
|
||||
type: object
|
||||
DashboardtypesDistributionYScale:
|
||||
enum:
|
||||
- linear
|
||||
- log
|
||||
type: string
|
||||
DashboardtypesDynamicVariableSignal:
|
||||
enum:
|
||||
- traces
|
||||
@@ -5380,7 +5287,6 @@ components:
|
||||
mapping:
|
||||
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/DistributionPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
|
||||
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
@@ -5403,7 +5309,6 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -5418,7 +5323,6 @@ components:
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
- signoz/DistributionPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -5450,21 +5354,6 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
description: The panel kind.
|
||||
enum:
|
||||
- signoz/DistributionPanel
|
||||
type: string
|
||||
spec:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionPanelSpec'
|
||||
description: The options for this panel kind. {} accepts every default.
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -5618,9 +5507,9 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPlugin'
|
||||
description: 'The visualization: kind is one of signoz/TimeSeriesPanel,
|
||||
signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel,
|
||||
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel,
|
||||
signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind''s
|
||||
options ({} accepts every default).'
|
||||
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel
|
||||
or signoz/HeatmapPanel, and spec holds that kind''s options ({} accepts
|
||||
every default).'
|
||||
queries:
|
||||
description: The queries the panel runs. Use exactly one entry for a data
|
||||
panel and [] for signoz/TextPanel. To run several named queries or formulas,
|
||||
@@ -5756,8 +5645,8 @@ components:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5RequestType'
|
||||
description: 'The result shape the panel requests: time_series for time
|
||||
series, bar, area and histogram panels, scalar for number, table and pie
|
||||
panels, raw for list panels, heatmap for heatmap panels, distribution
|
||||
for distribution panels, trace for trace panels.'
|
||||
panels, raw for list panels, heatmap for heatmap panels, trace for trace
|
||||
panels.'
|
||||
spec:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesQuerySpec'
|
||||
@@ -5914,9 +5803,9 @@ components:
|
||||
(builder query over AI observability traces), signoz/CompositeQuery (several
|
||||
named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL
|
||||
or signoz/TraceOperator, and spec holds that kind''s query. Pie and table
|
||||
panels reject signoz/PromQLQuery; heatmap and distribution panels reject
|
||||
signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only
|
||||
signoz/BuilderQuery and signoz/AIBuilderQuery.'
|
||||
panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery
|
||||
and signoz/TraceOperator; list panels accept only signoz/BuilderQuery
|
||||
and signoz/AIBuilderQuery.'
|
||||
required:
|
||||
- plugin
|
||||
type: object
|
||||
|
||||
@@ -11,7 +11,7 @@ const config: Config.InitialOptions = {
|
||||
coverageReporters: ['text', 'cobertura', 'html', 'json-summary'],
|
||||
collectCoverageFrom: ['src/**/*.{ts,tsx}'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
|
||||
modulePathIgnorePatterns: ['<rootDir>/dist/'],
|
||||
modulePathIgnorePatterns: ['dist'],
|
||||
moduleNameMapper: {
|
||||
'\\.(png|jpg|jpeg|gif|svg|webp|avif|ico|bmp|tiff)$':
|
||||
'<rootDir>/__mocks__/fileMock.ts',
|
||||
|
||||
@@ -6943,107 +6943,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesScatterPlotPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel kind.
|
||||
*/
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind {
|
||||
'signoz/DistributionPanel' = 'signoz/DistributionPanel',
|
||||
}
|
||||
export interface DashboardtypesDistributionXAxisDTO {
|
||||
/**
|
||||
* @type number,null
|
||||
* @description Buckets wholly above this value are hidden. Null shows them all.
|
||||
*/
|
||||
max?: number | null;
|
||||
/**
|
||||
* @type number,null
|
||||
* @description Buckets wholly below this value are hidden. Null shows them all.
|
||||
*/
|
||||
min?: number | null;
|
||||
}
|
||||
|
||||
export enum DashboardtypesDistributionYScaleDTO {
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
}
|
||||
export interface DashboardtypesDistributionYAxisDTO {
|
||||
/**
|
||||
* @description How counts are spaced: linear (default) or log.
|
||||
*/
|
||||
scale: DashboardtypesDistributionYScaleDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionAxesDTO {
|
||||
/**
|
||||
* @description The bucket axis.
|
||||
*/
|
||||
x?: DashboardtypesDistributionXAxisDTO;
|
||||
/**
|
||||
* @description The count axis.
|
||||
*/
|
||||
y?: DashboardtypesDistributionYAxisDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionPercentilesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Whether percentile markers are drawn. Defaults to true.
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description The percentiles to mark, each in (0, 100), at most 5, no duplicates. Defaults to [50, 90, 99].
|
||||
*/
|
||||
values: number[] | null;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionChartAppearanceDTO {
|
||||
/**
|
||||
* @description Percentile markers drawn across the bucket axis.
|
||||
*/
|
||||
percentiles?: DashboardtypesDistributionPercentilesDTO;
|
||||
/**
|
||||
* @description How groups combine in a bucket: normal (default) stacks them, none draws them side by side, percent stacks them as shares of the bucket.
|
||||
*/
|
||||
stack: DashboardtypesStackModeDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionPanelSpecDTO {
|
||||
/**
|
||||
* @description The X (bucket) axis range and Y (count) axis scale.
|
||||
*/
|
||||
axes?: DashboardtypesDistributionAxesDTO;
|
||||
/**
|
||||
* @description Stacking and percentile markers.
|
||||
*/
|
||||
chartAppearance?: DashboardtypesDistributionChartAppearanceDTO;
|
||||
/**
|
||||
* @description Unit and decimal precision of the bucket bounds; counts take no unit.
|
||||
*/
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
/**
|
||||
* @description Legend placement and series colors.
|
||||
*/
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @description The time range the bucket counts are summed over.
|
||||
*/
|
||||
visualization?: DashboardtypesBasicVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/DistributionPanel
|
||||
* @type string
|
||||
* @description The panel kind.
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind;
|
||||
/**
|
||||
* @description The options for this panel kind. {} accepts every default.
|
||||
*/
|
||||
spec: DashboardtypesDistributionPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -7055,8 +6954,7 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -7545,14 +7443,14 @@ export interface DashboardtypesQuerySpecDTO {
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @description The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap and distribution panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery.
|
||||
* @description The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery.
|
||||
*/
|
||||
plugin: DashboardtypesQueryPluginDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesQueryDTO {
|
||||
/**
|
||||
* @description The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, distribution for distribution panels, trace for trace panels.
|
||||
* @description The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, trace for trace panels.
|
||||
*/
|
||||
kind: Querybuildertypesv5RequestTypeDTO;
|
||||
/**
|
||||
@@ -7572,7 +7470,7 @@ export interface DashboardtypesPanelSpecDTO {
|
||||
*/
|
||||
links?: DashboardtypesLinkDTO[] | null;
|
||||
/**
|
||||
* @description The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel, signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind's options ({} accepts every default).
|
||||
* @description The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel or signoz/HeatmapPanel, and spec holds that kind's options ({} accepts every default).
|
||||
*/
|
||||
plugin: DashboardtypesPanelPluginDTO;
|
||||
/**
|
||||
@@ -8343,7 +8241,6 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
|
||||
'signoz/DistributionPanel' = 'signoz/DistributionPanel',
|
||||
}
|
||||
/**
|
||||
* The RFC 6902 operations, applied in order against the dashboard's postable shape.
|
||||
|
||||
@@ -3,24 +3,4 @@
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-0) var(--spacing-0);
|
||||
//TODO: remove this once we have a proper dashboard page.
|
||||
// The embedded V2 DashboardContainer renders its own page header (dashboard
|
||||
// title + share/feedback chrome) removed it for now
|
||||
:global([class*='dashboardPageHeader']),
|
||||
:global([class*='dashboardInfoWithActions']) {
|
||||
display: none;
|
||||
}
|
||||
// Embedded read-only: the add-variable and panel-action triggers are dead here.
|
||||
:global([class*='addSlot']),
|
||||
:global([data-testid^='panel-actions-']) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.errorState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import Spinner from 'components/Spinner';
|
||||
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
|
||||
import SystemDashboard from 'container/SystemDashboard/SystemDashboard';
|
||||
import { createSystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
|
||||
import { CHARTS_ONLY } from 'pages/DashboardPage/DashboardContainer/controls/presets';
|
||||
|
||||
import { useSystemDashboard } from './hooks/useSystemDashboard';
|
||||
import styles from './Overview.module.scss';
|
||||
|
||||
function Overview(): JSX.Element {
|
||||
const { dashboard, isLoading, isError, error, refetch } = useSystemDashboard();
|
||||
const AI_OVERVIEW = createSystemDashboardHost(
|
||||
'ai-o11y-overview',
|
||||
'llm-observability-overview',
|
||||
);
|
||||
|
||||
function Overview(): JSX.Element {
|
||||
useEffect(() => {
|
||||
void logEvent('AI Observability Overview: Page visited', {});
|
||||
}, []);
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
if (isError || !dashboard) {
|
||||
return (
|
||||
<div className={styles.errorState}>
|
||||
<Typography.Title>Failed to load dashboard</Typography.Title>
|
||||
<Typography.Text>
|
||||
{error?.response?.data?.error?.message ?? error?.message}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardContainer
|
||||
dashboard={dashboard}
|
||||
refetch={refetch}
|
||||
canEditDashboardOverride={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overview} data-testid="llm-observability-overview">
|
||||
{renderContent()}
|
||||
<SystemDashboard host={AI_OVERVIEW} controls={CHARTS_ONLY} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
useGetSystemDashboard,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { GetSystemDashboardQueryError } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const SYSTEM_DASHBOARD_NAME = 'ai-o11y-overview';
|
||||
|
||||
const DASHBOARD_ID = 'llm-observability-overview';
|
||||
|
||||
export interface UseSystemDashboardResult {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: GetSystemDashboardQueryError | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useSystemDashboard(): UseSystemDashboardResult {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, isError, error, refetch } = useGetSystemDashboard(
|
||||
{ name: SYSTEM_DASHBOARD_NAME },
|
||||
{ query: { staleTime: Infinity, refetchOnMount: false } },
|
||||
);
|
||||
|
||||
const dashboard = useMemo(
|
||||
() =>
|
||||
data
|
||||
? ({
|
||||
...data.data,
|
||||
id: DASHBOARD_ID,
|
||||
} as DashboardtypesGettableDashboardV2DTO)
|
||||
: undefined,
|
||||
[data],
|
||||
);
|
||||
|
||||
if (dashboard) {
|
||||
queryClient.setQueryData<GetDashboardV2200>(
|
||||
getGetDashboardV2QueryKey({ id: DASHBOARD_ID }),
|
||||
{ data: dashboard, status: 'success' },
|
||||
);
|
||||
}
|
||||
|
||||
const refetchDashboard = useCallback((): void => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return { dashboard, isLoading, isError, error, refetch: refetchDashboard };
|
||||
}
|
||||
@@ -31,6 +31,8 @@ jest.mock('@floating-ui/dom', () => ({
|
||||
Promise.resolve({ x: 0, y: 0, placement, strategy, middlewareData: {} }),
|
||||
}));
|
||||
|
||||
jest.setTimeout(15_000);
|
||||
|
||||
const VIEW_1_ORDER_BY = JSON.stringify({ columnName: 'name', order: 'asc' });
|
||||
const VIEW_1_QUERY = "severity = 'critical'";
|
||||
|
||||
|
||||
@@ -2,77 +2,14 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.meter-explorer-date-time {
|
||||
display: flex;
|
||||
min-height: 30px;
|
||||
justify-content: end;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.meter-explorer-graphs {
|
||||
.meter-explorer-notices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
gap: 36px;
|
||||
gap: 12px;
|
||||
padding: 16px 20px 0;
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meter-column-graph {
|
||||
.row-card {
|
||||
background-color: var(--l2-background);
|
||||
padding-left: 10px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.section-title {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.graph-description {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meter-page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
.meter-graph {
|
||||
height: 400px;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.total {
|
||||
.meter-column-graph {
|
||||
.meter-page-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
.meter-graph {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,121 +1,30 @@
|
||||
import { useCallback } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Alert } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import GridCard from 'container/WidgetCard/Card';
|
||||
import { Card, CardContainer } from 'container/WidgetCard/styles';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import SystemDashboard from 'container/SystemDashboard/SystemDashboard';
|
||||
import { createSystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
getLogCountWidgetData,
|
||||
getLogSizeWidgetData,
|
||||
getMetricCountWidgetData,
|
||||
getSpanCountWidgetData,
|
||||
getSpanSizeWidgetData,
|
||||
getTotalLogSizeWidgetData,
|
||||
getTotalMetricDatapointCountWidgetData,
|
||||
getTotalTraceSizeWidgetData,
|
||||
} from './graphs';
|
||||
CHARTS_ONLY,
|
||||
withControls,
|
||||
} from 'pages/DashboardPage/DashboardContainer/controls/presets';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import './BreakDown.styles.scss';
|
||||
|
||||
type MetricSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
graphs: Widgets[];
|
||||
};
|
||||
const COST_METER = createSystemDashboardHost(
|
||||
'cost-meter-breakdown',
|
||||
'cost-meter-breakdown',
|
||||
);
|
||||
|
||||
const sections: MetricSection[] = [
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Total',
|
||||
graphs: [
|
||||
getTotalLogSizeWidgetData(),
|
||||
getTotalTraceSizeWidgetData(),
|
||||
getTotalMetricDatapointCountWidgetData(),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Logs',
|
||||
graphs: [getLogCountWidgetData(), getLogSizeWidgetData()],
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Traces',
|
||||
graphs: [getSpanCountWidgetData(), getSpanSizeWidgetData()],
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Metrics',
|
||||
graphs: [getMetricCountWidgetData()],
|
||||
},
|
||||
];
|
||||
|
||||
function Section(section: MetricSection): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { title, graphs } = section;
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number) => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="meter-column-graph"
|
||||
data-testid={`meter-breakdown-section-${title.toLowerCase()}`}
|
||||
>
|
||||
<CardContainer className="row-card" isDarkMode={isDarkMode}>
|
||||
<Typography.Text className="section-title">{title}</Typography.Text>
|
||||
</CardContainer>
|
||||
<div className="meter-page-grid">
|
||||
{graphs.map((widget) => (
|
||||
<Card
|
||||
key={widget?.id}
|
||||
isDarkMode={isDarkMode}
|
||||
$panelType={PANEL_TYPES.BAR}
|
||||
className="meter-graph"
|
||||
>
|
||||
<GridCard widget={widget} onDragSelect={onDragSelect} version="v5" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const CONTROLS = withControls(CHARTS_ONLY, {
|
||||
toolbar: { autoRefresh: false },
|
||||
});
|
||||
|
||||
function BreakDown(): JSX.Element {
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
@@ -133,10 +42,7 @@ 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
|
||||
type="info"
|
||||
@@ -179,28 +85,8 @@ function BreakDown(): JSX.Element {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<section className="total">
|
||||
<Section
|
||||
id={sections[0].id}
|
||||
title={sections[0].title}
|
||||
graphs={sections[0].graphs}
|
||||
/>
|
||||
</section>
|
||||
{sections.map((section, idx) => {
|
||||
if (idx === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
key={section.id}
|
||||
id={section.id}
|
||||
title={section.title}
|
||||
graphs={section.graphs}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
<SystemDashboard host={COST_METER} controls={CONTROLS} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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.getByText(/Billing is calculated in UTC/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
.errorState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
49
frontend/src/container/SystemDashboard/SystemDashboard.tsx
Normal file
49
frontend/src/container/SystemDashboard/SystemDashboard.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import Spinner from 'components/Spinner';
|
||||
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 <Spinner tip="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
if (isError || !dashboard) {
|
||||
return (
|
||||
<div className={styles.errorState} data-testid="system-dashboard-error">
|
||||
<Typography.Title>Failed to load dashboard</Typography.Title>
|
||||
<Typography.Text>
|
||||
{error?.response?.data?.error?.message ?? error?.message}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardContainer
|
||||
dashboard={dashboard}
|
||||
refetch={refetch}
|
||||
source={host.source}
|
||||
readOnly
|
||||
controls={controls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SystemDashboard;
|
||||
@@ -0,0 +1,84 @@
|
||||
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('shows why the dashboard failed to load', async () => {
|
||||
server.use(
|
||||
rest.get(ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(404),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'not_found', message: 'not provisioned' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SystemDashboard host={HOST} />);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('system-dashboard-error'),
|
||||
).resolves.toHaveTextContent('not provisioned');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getGetSystemDashboardQueryKey } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
GetSystemDashboard200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardQuerySource } from 'pages/DashboardPage/DashboardContainer/store/slices/editContextSlice';
|
||||
|
||||
export interface SystemDashboardHost {
|
||||
/** Definition name without the `signoz---` prefix. */
|
||||
name: string;
|
||||
select: (
|
||||
response: GetSystemDashboard200,
|
||||
) => DashboardtypesGettableDashboardV2DTO;
|
||||
source: DashboardQuerySource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call at module level: `select` and `source` must stay referentially stable.
|
||||
* System dashboards carry no `id`; `id` keys the store's per-dashboard state
|
||||
* (saved variable values), so keep it stable across releases.
|
||||
*/
|
||||
export function createSystemDashboardHost(
|
||||
name: string,
|
||||
id: string,
|
||||
): SystemDashboardHost {
|
||||
const select = (
|
||||
response: GetSystemDashboard200,
|
||||
): DashboardtypesGettableDashboardV2DTO =>
|
||||
({ ...response.data, id }) as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
return {
|
||||
name,
|
||||
select,
|
||||
source: { queryKey: getGetSystemDashboardQueryKey({ name }), select },
|
||||
};
|
||||
}
|
||||
41
frontend/src/container/SystemDashboard/useSystemDashboard.ts
Normal file
41
frontend/src/container/SystemDashboard/useSystemDashboard.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useGetSystemDashboard } from 'api/generated/services/dashboard';
|
||||
import type { GetSystemDashboardQueryError } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { SystemDashboardHost } from './systemDashboardHost';
|
||||
|
||||
export interface UseSystemDashboardResult {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: GetSystemDashboardQueryError | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useSystemDashboard(
|
||||
host: SystemDashboardHost,
|
||||
): UseSystemDashboardResult {
|
||||
const {
|
||||
data: dashboard,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetSystemDashboard(
|
||||
{ name: host.name },
|
||||
{
|
||||
query: {
|
||||
select: host.select,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const refetchDashboard = useCallback((): void => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return { dashboard, isLoading, isError, error, refetch: refetchDashboard };
|
||||
}
|
||||
@@ -175,6 +175,7 @@ function AutoRefresh({
|
||||
}
|
||||
>
|
||||
<ButtonContainer
|
||||
data-testid="auto-refresh-trigger"
|
||||
title="Set auto refresh"
|
||||
type={showAutoRefreshBtnPrimary ? 'primary' : 'default'}
|
||||
>
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { createVariableWidthBarsPathBuilder } from '../variableWidthBars';
|
||||
|
||||
class FakePath2D {
|
||||
rects: number[][] = [];
|
||||
|
||||
rect(x: number, y: number, width: number, height: number): void {
|
||||
this.rects.push([x, y, width, height]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 10 px per unit on both axes; y grows downward from 100. */
|
||||
const fakePlot: Partial<uPlot> = {
|
||||
valToPos: (value: number, scaleKey: string): number =>
|
||||
scaleKey === 'x' ? value * 10 : 100 - value * 10,
|
||||
ctx: {} as CanvasRenderingContext2D,
|
||||
};
|
||||
const plot = fakePlot as uPlot;
|
||||
|
||||
type Fills = Map<CanvasRenderingContext2D['fillStyle'], Path2D>;
|
||||
|
||||
function fillsOf(paths: ReturnType<uPlot.Series.PathBuilder>): Fills {
|
||||
const fill = paths?.fill;
|
||||
if (!(fill instanceof Map)) {
|
||||
throw new Error('expected a fill per style');
|
||||
}
|
||||
return fill;
|
||||
}
|
||||
|
||||
function rectsOf(path: Path2D | undefined): number[][] {
|
||||
if (!(path instanceof FakePath2D)) {
|
||||
throw new Error('expected a FakePath2D');
|
||||
}
|
||||
return path.rects;
|
||||
}
|
||||
|
||||
describe('createVariableWidthBarsPathBuilder', () => {
|
||||
const originalPath2D = globalThis.Path2D;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'Path2D', {
|
||||
value: FakePath2D,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(globalThis, 'Path2D', {
|
||||
value: originalPath2D,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
function build(
|
||||
pattern: CanvasPattern | null = null,
|
||||
): ReturnType<uPlot.Series.PathBuilder> {
|
||||
return createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [
|
||||
{ x0: 0, x1: 1, y0: 0, y1: 2, hatched: false },
|
||||
{ x0: 1, x1: 4, y0: 0, y1: 5, hatched: false },
|
||||
{ x0: 4, x1: 6, y0: 1, y1: 3, hatched: true },
|
||||
],
|
||||
fill: '#ff0000',
|
||||
hatchedFill: '#ff000066',
|
||||
getHatchPattern: () => pattern,
|
||||
gapPx: 2,
|
||||
})(plot, 1, 0, 0);
|
||||
}
|
||||
|
||||
it('draws each bar at its own width, inset by half the gap', () => {
|
||||
const fills = fillsOf(build());
|
||||
|
||||
expect(rectsOf(fills.get('#ff0000'))).toStrictEqual([
|
||||
[1, 80, 8, 20],
|
||||
[11, 50, 28, 50],
|
||||
]);
|
||||
});
|
||||
|
||||
it('draws hatched bars in their own fill, with the pattern on top', () => {
|
||||
const pattern = {} as CanvasPattern;
|
||||
const fills = fillsOf(build(pattern));
|
||||
|
||||
expect([...fills.keys()]).toStrictEqual(['#ff0000', '#ff000066', pattern]);
|
||||
expect(rectsOf(fills.get(pattern))).toStrictEqual([[41, 70, 18, 20]]);
|
||||
});
|
||||
it('reads a fill callback on every draw', () => {
|
||||
let fill = '#ff0000';
|
||||
const builder = createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false }],
|
||||
fill: () => fill,
|
||||
hatchedFill: '#00ff00',
|
||||
getHatchPattern: () => null,
|
||||
gapPx: 0,
|
||||
});
|
||||
|
||||
const first = fillsOf(builder(plot, 1, 0, 0));
|
||||
fill = '#0000ff';
|
||||
const second = fillsOf(builder(plot, 1, 0, 0));
|
||||
|
||||
expect([...first.keys()]).toStrictEqual(['#ff0000']);
|
||||
expect([...second.keys()]).toStrictEqual(['#0000ff']);
|
||||
});
|
||||
|
||||
it('keeps solid bars when the hatched fill is the same colour', () => {
|
||||
const fills = fillsOf(
|
||||
createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [
|
||||
{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false },
|
||||
{ x0: 1, x1: 2, y0: 0, y1: 1, hatched: true },
|
||||
],
|
||||
fill: 'red',
|
||||
hatchedFill: 'red',
|
||||
getHatchPattern: () => null,
|
||||
gapPx: 0,
|
||||
})(plot, 1, 0, 0),
|
||||
);
|
||||
|
||||
expect(rectsOf(fills.get('red'))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('hatches only overflow bars when both fills are the same colour', () => {
|
||||
const pattern = {} as CanvasPattern;
|
||||
const fills = fillsOf(
|
||||
createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [
|
||||
{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false },
|
||||
{ x0: 1, x1: 2, y0: 0, y1: 1, hatched: true },
|
||||
],
|
||||
fill: 'red',
|
||||
hatchedFill: 'red',
|
||||
getHatchPattern: () => pattern,
|
||||
gapPx: 0,
|
||||
})(plot, 1, 0, 0),
|
||||
);
|
||||
|
||||
expect(rectsOf(fills.get(pattern))).toStrictEqual([[10, 90, 10, 10]]);
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
/** A bar in scale space. Unlike uPlot's own bars, each one carries its own width. */
|
||||
export interface VariableWidthBar {
|
||||
x0: number;
|
||||
x1: number;
|
||||
y0: number;
|
||||
y1: number;
|
||||
/** Drawn in `hatchedFill` with the hatch pattern on top. */
|
||||
hatched: boolean;
|
||||
}
|
||||
|
||||
/** A fill, or one read on every draw (e.g. to dim a series on hover). */
|
||||
type BarFill = string | (() => string);
|
||||
|
||||
/** Bars narrower than this, in CSS pixels, keep no gap between them. */
|
||||
const MIN_WIDTH_FOR_GAP_PX = 3;
|
||||
|
||||
/**
|
||||
* Draws `[x0, x1] × [y0, y1]` rects on the `x` and `y` scales. uPlot's bar builder
|
||||
* assumes one width for every bar, which a bucket axis with uneven bounds breaks.
|
||||
*
|
||||
* `getBars` runs on every redraw, so a caller can derive the bars from the plot's
|
||||
* current state (e.g. which series are shown).
|
||||
*/
|
||||
export function createVariableWidthBarsPathBuilder({
|
||||
getBars,
|
||||
fill,
|
||||
hatchedFill,
|
||||
getHatchPattern,
|
||||
gapPx,
|
||||
}: {
|
||||
getBars: (u: uPlot, seriesIdx: number) => VariableWidthBar[];
|
||||
fill: BarFill;
|
||||
hatchedFill: BarFill;
|
||||
getHatchPattern: (ctx: CanvasRenderingContext2D) => CanvasPattern | null;
|
||||
/** Space between neighbouring bars, in CSS pixels. */
|
||||
gapPx: number;
|
||||
}): uPlot.Series.PathBuilder {
|
||||
return (u, seriesIdx): uPlot.Series.Paths => {
|
||||
const solidFill = resolveFill(fill);
|
||||
const overflowFill = resolveFill(hatchedFill);
|
||||
const solidPath = new Path2D();
|
||||
// The hatch overlays only overflow bars, so they keep a path of their own even
|
||||
// when one colour fill covers both (a Map keyed by fill holds one path a fill).
|
||||
const hatchedPath = new Path2D();
|
||||
const isSharedFill = overflowFill === solidFill;
|
||||
const inset = (gapPx * uPlot.pxRatio) / 2;
|
||||
const minWidthForGap = MIN_WIDTH_FOR_GAP_PX * uPlot.pxRatio;
|
||||
let hasHatched = false;
|
||||
|
||||
getBars(u, seriesIdx).forEach((bar) => {
|
||||
const left = u.valToPos(bar.x0, 'x', true);
|
||||
const right = u.valToPos(bar.x1, 'x', true);
|
||||
const top = u.valToPos(bar.y1, 'y', true);
|
||||
const bottom = u.valToPos(bar.y0, 'y', true);
|
||||
const width = right - left;
|
||||
const gap = width > minWidthForGap ? inset : 0;
|
||||
const rect = [left + gap, top, width - 2 * gap, bottom - top] as const;
|
||||
if (!bar.hatched || isSharedFill) {
|
||||
solidPath.rect(...rect);
|
||||
}
|
||||
if (bar.hatched) {
|
||||
hatchedPath.rect(...rect);
|
||||
hasHatched = true;
|
||||
}
|
||||
});
|
||||
|
||||
const fills = new Map<CanvasRenderingContext2D['fillStyle'], Path2D>([
|
||||
[solidFill, solidPath],
|
||||
]);
|
||||
if (hasHatched) {
|
||||
if (!isSharedFill) {
|
||||
fills.set(overflowFill, hatchedPath);
|
||||
}
|
||||
const pattern = getHatchPattern(u.ctx);
|
||||
if (pattern) {
|
||||
fills.set(pattern, hatchedPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { stroke: null, fill: fills, clip: null, band: null, flags: 0 };
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFill(fill: BarFill): string {
|
||||
return typeof fill === 'function' ? fill() : fill;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.notice {
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import {
|
||||
LegendPosition,
|
||||
type TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import { resolveBoundaryPrecision } from 'lib/visualization/charts/Heatmap/utils';
|
||||
import { DistributionChartProps } from 'lib/visualization/charts/types';
|
||||
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
|
||||
import type { LegendLayout } from 'lib/visualization/layout/ChartLayout/ChartLayout';
|
||||
|
||||
import { resolveOtherColor, resolveSeriesColor } from './colors';
|
||||
import DistributionTooltip from './components/DistributionTooltip/DistributionTooltip';
|
||||
import { buildDistributionConfig } from './config';
|
||||
import {
|
||||
clampDistributionXAxis,
|
||||
cropDistributionXAxis,
|
||||
resolveDistributionBars,
|
||||
resolveDistributionXAxis,
|
||||
sumVisibleCounts,
|
||||
} from './layout';
|
||||
import { computeDistributionPercentiles } from './percentiles';
|
||||
import { groupIntoOther, resolveMaxDrawnGroups } from './topGroups';
|
||||
import { DistributionYScale } from './types';
|
||||
import { useDistributionBarClick } from './useDistributionBarClick';
|
||||
|
||||
import styles from './Distribution.module.scss';
|
||||
import { createBoundFormatter, prepareDistributionChartData } from './utils';
|
||||
|
||||
/** Rough share of the panel width the plot gets, for fitting side-by-side bars. */
|
||||
const PLOT_SHARE = 0.9;
|
||||
const PLOT_SHARE_WITH_RIGHT_LEGEND = 0.65;
|
||||
/** Vertical space the Other notice takes out of the container. */
|
||||
const NOTICE_HEIGHT = 20;
|
||||
const NO_PERCENTILES: readonly number[] = Object.freeze([]);
|
||||
|
||||
export default function Distribution({
|
||||
id,
|
||||
buckets,
|
||||
series,
|
||||
width,
|
||||
height,
|
||||
isDarkMode,
|
||||
xMin,
|
||||
xMax,
|
||||
yScale = DistributionYScale.Linear,
|
||||
stack = StackMode.Normal,
|
||||
xAxisUnit,
|
||||
decimalPrecision,
|
||||
customColors,
|
||||
percentiles = NO_PERCENTILES,
|
||||
onBarClick,
|
||||
showLegend = true,
|
||||
legendPosition = LegendPosition.BOTTOM,
|
||||
showTooltip = true,
|
||||
canPinTooltip = false,
|
||||
renderTooltipFooter,
|
||||
tooltipPortalRoot,
|
||||
layoutChildren,
|
||||
'data-testid': testId,
|
||||
}: DistributionChartProps): JSX.Element {
|
||||
const labels = useMemo(() => series.map((entry) => entry.label), [series]);
|
||||
const counts = useMemo(() => series.map((entry) => entry.counts), [series]);
|
||||
|
||||
const { visibleKeys, focusedSeriesIndex, onLegendAction } =
|
||||
useLegendVisibility({ keys: labels, indexOffset: 1, id });
|
||||
|
||||
const visible = useMemo(
|
||||
() => labels.map((label) => visibleKeys.includes(label)),
|
||||
[labels, visibleKeys],
|
||||
);
|
||||
|
||||
const colors = useMemo(
|
||||
() =>
|
||||
labels.map((label) => resolveSeriesColor(label, isDarkMode, customColors)),
|
||||
[labels, isDarkMode, customColors],
|
||||
);
|
||||
|
||||
const { xAxis, totals } = useMemo(() => {
|
||||
const full = resolveDistributionXAxis(buckets);
|
||||
const visibleTotals = sumVisibleCounts(counts, visible, full.rows.length);
|
||||
// An explicit max may reach past the last occupied bucket; only auto-crop without one.
|
||||
const fitted =
|
||||
xMax === undefined ? cropDistributionXAxis(full, visibleTotals) : full;
|
||||
return {
|
||||
xAxis: clampDistributionXAxis(fitted, xMin, xMax),
|
||||
totals: visibleTotals,
|
||||
};
|
||||
}, [buckets, xMin, xMax, counts, visible]);
|
||||
|
||||
// Over the visible groups combined, so a legend toggle moves them without a refetch.
|
||||
const markers = useMemo(
|
||||
() => computeDistributionPercentiles({ xAxis, totals, percentiles }),
|
||||
[xAxis, totals, percentiles],
|
||||
);
|
||||
|
||||
const isLogY =
|
||||
yScale === DistributionYScale.Log && stack !== StackMode.Percent;
|
||||
|
||||
// The legend lists every group; the plot draws the largest and sums the rest into
|
||||
// Other, so a toggle re-ranks without a refetch.
|
||||
const drawn = useMemo(
|
||||
() =>
|
||||
groupIntoOther({
|
||||
labels,
|
||||
counts,
|
||||
colors,
|
||||
visible,
|
||||
maxGroups: resolveMaxDrawnGroups({
|
||||
stack,
|
||||
xAxis,
|
||||
plotWidth:
|
||||
width *
|
||||
(legendPosition === LegendPosition.RIGHT
|
||||
? PLOT_SHARE_WITH_RIGHT_LEGEND
|
||||
: PLOT_SHARE),
|
||||
}),
|
||||
otherColor: resolveOtherColor(isDarkMode),
|
||||
}),
|
||||
[
|
||||
labels,
|
||||
counts,
|
||||
colors,
|
||||
visible,
|
||||
stack,
|
||||
xAxis,
|
||||
width,
|
||||
legendPosition,
|
||||
isDarkMode,
|
||||
],
|
||||
);
|
||||
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: drawn.counts,
|
||||
stack,
|
||||
isLogY,
|
||||
}),
|
||||
[xAxis, drawn, stack, isLogY],
|
||||
);
|
||||
|
||||
const boundaryPrecision = useMemo(
|
||||
() =>
|
||||
resolveBoundaryPrecision({
|
||||
yAxis: xAxis,
|
||||
yAxisUnit: xAxisUnit,
|
||||
decimalPrecision,
|
||||
}),
|
||||
[xAxis, xAxisUnit, decimalPrecision],
|
||||
);
|
||||
|
||||
const formatBound = useMemo(
|
||||
() => createBoundFormatter(xAxisUnit, boundaryPrecision),
|
||||
[xAxisUnit, boundaryPrecision],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildDistributionConfig({
|
||||
id,
|
||||
xAxis,
|
||||
layout,
|
||||
labels: drawn.labels,
|
||||
colors: drawn.colors,
|
||||
stack,
|
||||
isLogY,
|
||||
isDarkMode,
|
||||
markers,
|
||||
xAxisUnit,
|
||||
decimalPrecision: boundaryPrecision,
|
||||
}),
|
||||
[
|
||||
id,
|
||||
xAxis,
|
||||
layout,
|
||||
drawn,
|
||||
stack,
|
||||
isLogY,
|
||||
isDarkMode,
|
||||
markers,
|
||||
xAxisUnit,
|
||||
boundaryPrecision,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => (
|
||||
<DistributionTooltip
|
||||
uPlotInstance={args.uPlotInstance}
|
||||
isPinned={args.isPinned}
|
||||
dismiss={args.dismiss}
|
||||
xAxis={xAxis}
|
||||
layout={layout}
|
||||
labels={drawn.labels}
|
||||
colors={drawn.colors}
|
||||
counts={drawn.counts}
|
||||
formatBound={formatBound}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
/>
|
||||
),
|
||||
[xAxis, layout, drawn, formatBound, renderTooltipFooter],
|
||||
);
|
||||
|
||||
const handleClick = useDistributionBarClick({
|
||||
xAxis,
|
||||
layout,
|
||||
drawn,
|
||||
series,
|
||||
onBarClick,
|
||||
});
|
||||
|
||||
const data = useMemo(
|
||||
() => prepareDistributionChartData(xAxis, drawn.counts),
|
||||
[xAxis, drawn],
|
||||
);
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
labels.map((label, index) => ({
|
||||
seriesIndex: index + 1,
|
||||
label,
|
||||
color: colors[index],
|
||||
show: visible[index],
|
||||
})),
|
||||
[labels, colors, visible],
|
||||
);
|
||||
|
||||
const groupLegend = useCallback(
|
||||
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => (
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={legendPosition}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showSearch}
|
||||
itemLabel="groups"
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
),
|
||||
[legendItems, legendPosition, focusedSeriesIndex, onLegendAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
config={config}
|
||||
data={data}
|
||||
width={width}
|
||||
height={
|
||||
drawn.otherGroupCount > 0 ? Math.max(0, height - NOTICE_HEIGHT) : height
|
||||
}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
showLegend={showLegend}
|
||||
customLegend={groupLegend}
|
||||
legendLabels={labels}
|
||||
showTooltip={showTooltip}
|
||||
canPinTooltip={canPinTooltip}
|
||||
decimalPrecision={decimalPrecision}
|
||||
customTooltip={renderTooltip}
|
||||
onClick={handleClick}
|
||||
contentFooter={
|
||||
drawn.otherGroupCount > 0 && (
|
||||
<div className={styles.notice} data-testid="distribution-other-notice">
|
||||
Showing top {drawn.labels.length - 1} of {drawn.visibleGroupCount} groups;
|
||||
the rest are summed into Other.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
tooltipPortalRoot={tooltipPortalRoot}
|
||||
layoutChildren={layoutChildren}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import type React from 'react';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import Distribution from '../Distribution';
|
||||
import type { DistributionSeries } from '../types';
|
||||
|
||||
// The shared Legend virtualises its items; render them all so they are queryable.
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<div>
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex}>{itemContent(index, item)}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const BUCKETS = [5, 10, 100, 1000];
|
||||
|
||||
const SERIES: DistributionSeries[] = [
|
||||
{ label: 'service.name=cart', counts: [3, 40, 12, 1, 0] },
|
||||
{ label: 'service.name=checkout', counts: [1, 10, 30, 6, 2] },
|
||||
];
|
||||
|
||||
function renderDistribution(
|
||||
props: Partial<React.ComponentProps<typeof Distribution>> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<Distribution
|
||||
id="panel-1"
|
||||
buckets={BUCKETS}
|
||||
series={SERIES}
|
||||
width={800}
|
||||
height={400}
|
||||
isDarkMode
|
||||
data-testid="distribution"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Distribution', () => {
|
||||
// Legend items count from 1, as uPlot's data series do.
|
||||
const CART = 1;
|
||||
const CHECKOUT = 2;
|
||||
|
||||
// The selection persists under the panel id, which every case here shares.
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
function legendItem(seriesIndex: number): HTMLElement {
|
||||
return screen.getByTestId(`legend-item-${seriesIndex}`);
|
||||
}
|
||||
|
||||
it('renders the plot container', () => {
|
||||
renderDistribution();
|
||||
|
||||
expect(screen.getByTestId('distribution')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists every group in the legend, all shown', () => {
|
||||
renderDistribution();
|
||||
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('isolates a group when its row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderDistribution();
|
||||
|
||||
await user.click(legendItem(CART));
|
||||
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('hides only that group when its marker is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderDistribution();
|
||||
|
||||
await user.click(screen.getByTestId(`legend-marker-${CHECKOUT}`));
|
||||
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
it('sums groups past the stacked cap into Other, with a notice', () => {
|
||||
renderDistribution({
|
||||
series: Array.from({ length: 12 }, (_, index) => ({
|
||||
label: `group-${index}`,
|
||||
counts: [index, 1, 1, 0, 0],
|
||||
})),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('distribution-other-notice')).toHaveTextContent(
|
||||
'Showing top 9 of 12 groups; the rest are summed into Other.',
|
||||
);
|
||||
// The legend still lists every group, so any can be isolated.
|
||||
expect(legendItem(1)).toHaveAttribute('aria-label', 'group-0');
|
||||
});
|
||||
|
||||
it('shows no notice when every group fits', () => {
|
||||
renderDistribution();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('distribution-other-notice'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { resolveDistributionBars, resolveDistributionXAxis } from '../layout';
|
||||
import { buildDistributionConfig } from '../config';
|
||||
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4]. Only (10, 20] is occupied.
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40]);
|
||||
const counts = [
|
||||
[0, 4, 0, 0],
|
||||
[0, 6, 0, 0],
|
||||
];
|
||||
|
||||
function setCursorHook(drawnCounts = counts): (u: uPlot) => void {
|
||||
const config = buildDistributionConfig({
|
||||
id: 'panel-1',
|
||||
xAxis,
|
||||
layout: resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: drawnCounts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
}),
|
||||
labels: drawnCounts.map((_, index) => `group-${index}`),
|
||||
colors: drawnCounts.map(() => '#ff0000'),
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
isDarkMode: true,
|
||||
markers: [],
|
||||
}).getConfig();
|
||||
const [hook] = config.hooks?.setCursor ?? [];
|
||||
return hook as (u: uPlot) => void;
|
||||
}
|
||||
|
||||
/** Pixels are values, so the cursor sits at `(left, top)` in scale space. */
|
||||
function plotAt(
|
||||
left: number,
|
||||
top: number,
|
||||
): { u: uPlot; setSeries: jest.Mock; redraw: jest.Mock } {
|
||||
const setSeries = jest.fn();
|
||||
const redraw = jest.fn();
|
||||
const plot: Partial<uPlot> = {
|
||||
cursor: { left, top },
|
||||
posToVal: (position: number) => position,
|
||||
setSeries,
|
||||
redraw,
|
||||
};
|
||||
return { u: plot as uPlot, setSeries, redraw };
|
||||
}
|
||||
|
||||
describe('buildDistributionConfig cursor', () => {
|
||||
it('turns off drag-to-zoom', () => {
|
||||
const config = buildDistributionConfig({
|
||||
id: 'panel-1',
|
||||
xAxis,
|
||||
layout: resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
}),
|
||||
labels: ['group-0', 'group-1'],
|
||||
colors: ['#ff0000', '#ff0000'],
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
isDarkMode: true,
|
||||
markers: [],
|
||||
}).getConfig();
|
||||
|
||||
expect(config.cursor?.drag).toMatchObject({ x: false, y: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDistributionConfig setCursor', () => {
|
||||
// TooltipPlugin only shows a tooltip for a focused series.
|
||||
it('focuses the group under the cursor, 1-based as uPlot counts series', () => {
|
||||
const { u, setSeries, redraw } = plotAt(1.5, 8);
|
||||
|
||||
setCursorHook()(u);
|
||||
|
||||
expect(setSeries).toHaveBeenCalledWith(2, { focus: true });
|
||||
expect(redraw).toHaveBeenCalledWith(true, false);
|
||||
});
|
||||
|
||||
it('focuses the nearest group above the stack, dimming the rest', () => {
|
||||
const { u, setSeries, redraw } = plotAt(1.5, 50);
|
||||
|
||||
setCursorHook()(u);
|
||||
|
||||
expect(setSeries).toHaveBeenCalledWith(2, { focus: true });
|
||||
expect(redraw).toHaveBeenCalledWith(true, false);
|
||||
});
|
||||
|
||||
it('keeps a tooltip over an empty bucket without dimming any group', () => {
|
||||
const hook = setCursorHook();
|
||||
const { u, setSeries, redraw } = plotAt(1.5, 8);
|
||||
hook(u);
|
||||
redraw.mockClear();
|
||||
|
||||
u.cursor.left = 2.5;
|
||||
u.cursor.top = 1;
|
||||
hook(u);
|
||||
|
||||
expect(setSeries).toHaveBeenLastCalledWith(1, { focus: true });
|
||||
expect(redraw).toHaveBeenCalledWith(true, false);
|
||||
});
|
||||
|
||||
it('focuses nothing off the plot', () => {
|
||||
const hook = setCursorHook();
|
||||
const { u, setSeries } = plotAt(1.5, 8);
|
||||
hook(u);
|
||||
|
||||
u.cursor.left = -1;
|
||||
hook(u);
|
||||
|
||||
expect(setSeries).toHaveBeenLastCalledWith(null, { focus: true });
|
||||
});
|
||||
|
||||
it('focuses nothing over an empty bucket when every group is hidden', () => {
|
||||
const { u, setSeries } = plotAt(2.5, 1);
|
||||
|
||||
setCursorHook([])(u);
|
||||
|
||||
expect(setSeries).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import {
|
||||
layoutMarkerLabels,
|
||||
resolveHoveredMarker,
|
||||
} from '../drawPercentileMarkers';
|
||||
|
||||
describe('resolveHoveredMarker', () => {
|
||||
const areas = [
|
||||
{
|
||||
x: 100,
|
||||
badges: [
|
||||
{ left: 92, right: 108, top: 0, bottom: 16 },
|
||||
{ left: 92, right: 108, top: 184, bottom: 200 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 120,
|
||||
badges: [{ left: 112, right: 128, top: 18, bottom: 34 }],
|
||||
},
|
||||
];
|
||||
|
||||
it('picks the line nearest x within the tolerance', () => {
|
||||
expect(resolveHoveredMarker(areas, 103, 100, 4)).toBe(0);
|
||||
expect(resolveHoveredMarker(areas, 117, 100, 4)).toBe(1);
|
||||
expect(resolveHoveredMarker(areas, 110, 100, 4)).toBeNull();
|
||||
});
|
||||
|
||||
it('picks the marker whose badge is under the cursor', () => {
|
||||
expect(resolveHoveredMarker(areas, 107, 190, 4)).toBe(0);
|
||||
expect(resolveHoveredMarker(areas, 126, 20, 4)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layoutMarkerLabels', () => {
|
||||
it('keeps labels that fit on the first row', () => {
|
||||
expect(
|
||||
layoutMarkerLabels(
|
||||
[
|
||||
{ x: 0, width: 20 },
|
||||
{ x: 40, width: 20 },
|
||||
],
|
||||
4,
|
||||
),
|
||||
).toStrictEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('drops a label that would overlap to the next free row', () => {
|
||||
expect(
|
||||
layoutMarkerLabels(
|
||||
[
|
||||
{ x: 30, width: 20 },
|
||||
{ x: 0, width: 20 },
|
||||
{ x: 10, width: 20 },
|
||||
],
|
||||
4,
|
||||
),
|
||||
).toStrictEqual([0, 0, 1]);
|
||||
});
|
||||
});
|
||||
@@ -1,286 +0,0 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import {
|
||||
clampDistributionXAxis,
|
||||
cropDistributionXAxis,
|
||||
LOG_Y_BASE,
|
||||
resolveDistributionBars,
|
||||
resolveDistributionXAxis,
|
||||
resolveNearestSeries,
|
||||
sumVisibleCounts,
|
||||
thinLabelledSplits,
|
||||
} from '../layout';
|
||||
|
||||
function widths(edges: number[]): number[] {
|
||||
return edges.slice(1).map((edge, index) => edge - edges[index]);
|
||||
}
|
||||
|
||||
describe('resolveDistributionXAxis', () => {
|
||||
it('lays N bounds out as N + 1 buckets, overflow last', () => {
|
||||
const axis = resolveDistributionXAxis([10, 20, 30]);
|
||||
|
||||
expect(axis.rows).toHaveLength(4);
|
||||
expect(axis.rows[0].isUnderflow).toBe(true);
|
||||
expect(axis.rows[3]).toMatchObject({ lower: 30, isOverflow: true });
|
||||
expect(axis.edges).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('gives every bucket the same width, ticks on the bounds', () => {
|
||||
const axis = resolveDistributionXAxis([0, 5, 10, 25, 1000]);
|
||||
|
||||
expect(widths(axis.edges)).toStrictEqual([1, 1, 1, 1, 1, 1]);
|
||||
expect(axis.splits).toStrictEqual([1, 2, 3, 4, 5]);
|
||||
expect(axis.overflowSplit).toBe(6);
|
||||
expect(axis.toBucketValue(3.5)).toBe(17.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cropDistributionXAxis', () => {
|
||||
it('stops one bucket past the last occupied one', () => {
|
||||
const axis = resolveDistributionXAxis([1, 2, 3, 4]);
|
||||
|
||||
const cropped = cropDistributionXAxis(axis, [1, 5, 0, 0, 0]);
|
||||
|
||||
expect(cropped.max).toBe(3);
|
||||
expect(cropped.overflowSplit).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampDistributionXAxis', () => {
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], (40, 80], > 80 in slots [0, 1] … [4, 5].
|
||||
const axis = resolveDistributionXAxis([10, 20, 40, 80]);
|
||||
|
||||
it('keeps every bucket without a range', () => {
|
||||
expect(clampDistributionXAxis(axis)).toStrictEqual(axis);
|
||||
});
|
||||
|
||||
it('hides buckets wholly outside the range, keeping straddling ones whole', () => {
|
||||
const clamped = clampDistributionXAxis(axis, 20, 50);
|
||||
|
||||
expect([clamped.min, clamped.max]).toStrictEqual([2, 4]);
|
||||
expect(clamped.overflowSplit).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the overflow tick while the overflow bucket shows', () => {
|
||||
const clamped = clampDistributionXAxis(axis, 15);
|
||||
|
||||
expect([clamped.min, clamped.max]).toStrictEqual([1, 5]);
|
||||
expect(clamped.overflowSplit).toBe(5);
|
||||
});
|
||||
|
||||
it('keeps one bucket when the range misses them all', () => {
|
||||
const clamped = clampDistributionXAxis(axis, 1000);
|
||||
|
||||
expect([clamped.min, clamped.max]).toStrictEqual([4, 5]);
|
||||
});
|
||||
|
||||
it('narrows an already cropped axis, never widening it', () => {
|
||||
const cropped = cropDistributionXAxis(axis, [1, 5, 0, 0, 0]);
|
||||
|
||||
expect(clampDistributionXAxis(cropped, 15).max).toBe(cropped.max);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sumVisibleCounts', () => {
|
||||
it('adds only the visible series', () => {
|
||||
expect(
|
||||
sumVisibleCounts(
|
||||
[
|
||||
[1, 2],
|
||||
[10, 20],
|
||||
[100, 200],
|
||||
],
|
||||
[true, false, true],
|
||||
2,
|
||||
),
|
||||
).toStrictEqual([101, 202]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDistributionBars', () => {
|
||||
const xAxis = resolveDistributionXAxis([1, 2]);
|
||||
const counts = [
|
||||
[4, 6, 0],
|
||||
[1, 3, 2],
|
||||
];
|
||||
|
||||
it('sizes the count axis by the buckets left in the window', () => {
|
||||
// Buckets ≤ 1, (1, 2], > 2; a max of 1 keeps only the first.
|
||||
const { yMax } = resolveDistributionBars({
|
||||
xAxis: clampDistributionXAxis(xAxis, undefined, 1),
|
||||
counts: [[4, 1000, 0]],
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(yMax).toBe(4);
|
||||
});
|
||||
|
||||
it('stacks series in each bucket under normal', () => {
|
||||
const { rects, yMax } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[0].map(({ y0, y1 }) => [y0, y1])).toStrictEqual([
|
||||
[0, 4],
|
||||
[0, 6],
|
||||
]);
|
||||
expect(rects[1].map(({ y0, y1 }) => [y0, y1])).toStrictEqual([
|
||||
[4, 5],
|
||||
[6, 9],
|
||||
[0, 2],
|
||||
]);
|
||||
expect(yMax).toBe(9);
|
||||
});
|
||||
|
||||
it('marks the overflow bucket hatched', () => {
|
||||
const { rects } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[1].map(({ hatched }) => hatched)).toStrictEqual([
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('stacks shares of each bucket under percent', () => {
|
||||
const { rects, yMax } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Percent,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[0][0]).toMatchObject({ y0: 0, y1: 80 });
|
||||
expect(rects[1][0]).toMatchObject({ y0: 80, y1: 100 });
|
||||
expect(yMax).toBe(100);
|
||||
});
|
||||
|
||||
it('splits the bucket width between series under none', () => {
|
||||
const { rects, yMax } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.None,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[0][0]).toMatchObject({ x0: 0, x1: 0.5, y0: 0, y1: 4 });
|
||||
expect(rects[1][0]).toMatchObject({ x0: 0.5, x1: 1, y0: 0, y1: 1 });
|
||||
expect(yMax).toBe(6);
|
||||
});
|
||||
|
||||
it('starts bars at the log base and skips zero counts', () => {
|
||||
const { rects } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: true,
|
||||
});
|
||||
|
||||
expect(rects[0].map(({ y0 }) => y0)).toStrictEqual([LOG_Y_BASE, LOG_Y_BASE]);
|
||||
expect(rects[0].map(({ bucketIndex }) => bucketIndex)).toStrictEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('skips bars whose top stays under the log base, never inverting one', () => {
|
||||
const fractional = [
|
||||
[0.5, 3, 0],
|
||||
[0.25, 0.5, 2],
|
||||
];
|
||||
const stacked = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: fractional,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: true,
|
||||
});
|
||||
const sideBySide = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: fractional,
|
||||
stack: StackMode.None,
|
||||
isLogY: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
stacked.rects.map((rects) => rects.map(({ y0, y1 }) => [y0, y1])),
|
||||
).toStrictEqual([
|
||||
[[LOG_Y_BASE, 3]],
|
||||
[
|
||||
[3, 3.5],
|
||||
[LOG_Y_BASE, 2],
|
||||
],
|
||||
]);
|
||||
expect(
|
||||
sideBySide.rects.map((rects) => rects.map(({ bucketIndex }) => bucketIndex)),
|
||||
).toStrictEqual([[1], [2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('thinLabelledSplits', () => {
|
||||
it('keeps labels that clear each other by the gap', () => {
|
||||
expect(
|
||||
thinLabelledSplits({
|
||||
positions: [0, 50, 100],
|
||||
widths: [20, 20, 20],
|
||||
gap: 10,
|
||||
}),
|
||||
).toStrictEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('drops a wide label that would touch its neighbour, keeping the rightmost', () => {
|
||||
// 16.67 mins-wide labels 60 px apart: each needs 70 px of centre distance.
|
||||
expect(
|
||||
thinLabelledSplits({
|
||||
positions: [0, 60, 120, 180],
|
||||
widths: [60, 60, 60, 60],
|
||||
gap: 10,
|
||||
}),
|
||||
).toStrictEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveNearestSeries', () => {
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40]);
|
||||
const counts = [
|
||||
[0, 4, 0, 0],
|
||||
[0, 6, 0, 0],
|
||||
];
|
||||
const stacked = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
it('names the group whose bar is under the point', () => {
|
||||
expect(resolveNearestSeries(stacked, 1, 1.5, 2)).toBe(0);
|
||||
expect(resolveNearestSeries(stacked, 1, 1.5, 8)).toBe(1);
|
||||
});
|
||||
|
||||
it('takes the vertically nearest group off the bars, as a bar chart does', () => {
|
||||
expect(resolveNearestSeries(stacked, 1, 1.5, 50)).toBe(1);
|
||||
});
|
||||
|
||||
it('is null over an empty bucket', () => {
|
||||
expect(resolveNearestSeries(stacked, 2, 2.5, 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('picks the sub-bar under the cursor side by side', () => {
|
||||
const sideBySide = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.None,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(resolveNearestSeries(sideBySide, 1, 1.25, 50)).toBe(0);
|
||||
expect(resolveNearestSeries(sideBySide, 1, 1.75, 1)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import { resolveDistributionXAxis } from '../layout';
|
||||
import {
|
||||
computeDistributionPercentiles,
|
||||
formatPercentileBadge,
|
||||
formatPercentileName,
|
||||
formatPercentileValue,
|
||||
groupPercentileMarkers,
|
||||
} from '../percentiles';
|
||||
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40]);
|
||||
|
||||
describe('computeDistributionPercentiles', () => {
|
||||
it('interpolates inside the bucket holding the rank, as histogram_quantile does', () => {
|
||||
const markers = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [10, 20, 10, 0],
|
||||
percentiles: [50, 90],
|
||||
});
|
||||
|
||||
expect(markers.map(({ value }) => value)).toStrictEqual([15, 32]);
|
||||
expect(markers.every(({ isOverflow }) => !isOverflow)).toBe(true);
|
||||
});
|
||||
|
||||
it('places the marker the same fraction across the bucket on the axis', () => {
|
||||
const [p50] = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [10, 20, 10, 0],
|
||||
percentiles: [50],
|
||||
});
|
||||
|
||||
expect(p50.axisValue).toBe(1.5);
|
||||
});
|
||||
|
||||
it('starts the lowest bucket at zero', () => {
|
||||
const [p10] = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [10, 20, 10, 0],
|
||||
percentiles: [10],
|
||||
});
|
||||
|
||||
expect(p10.value).toBe(4);
|
||||
});
|
||||
|
||||
it('reports a percentile in overflow as above the last bound, at its left edge', () => {
|
||||
const [p99] = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [0, 0, 10, 10],
|
||||
percentiles: [99],
|
||||
});
|
||||
|
||||
expect(p99).toMatchObject({ value: 40, axisValue: 3, isOverflow: true });
|
||||
});
|
||||
|
||||
it('marks nothing when every count is zero', () => {
|
||||
expect(
|
||||
computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [0, 0, 0, 0],
|
||||
percentiles: [50],
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPercentileName', () => {
|
||||
it('keeps fractional percentile names short', () => {
|
||||
expect(formatPercentileName(99.9)).toBe('p99.9');
|
||||
expect(formatPercentileName(50)).toBe('p50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('percentile marker groups', () => {
|
||||
const formatBound = (value: number): string => `${value} ms`;
|
||||
const p50 = { percentile: 50, value: 15, axisValue: 1.5, isOverflow: false };
|
||||
const p99 = { percentile: 99, value: 40, axisValue: 3, isOverflow: true };
|
||||
const p90 = { percentile: 90, value: 40, axisValue: 3, isOverflow: true };
|
||||
|
||||
it('groups markers on the same spot, lowest percentile first', () => {
|
||||
expect(groupPercentileMarkers([p50, p99, p90])).toStrictEqual([
|
||||
[p50],
|
||||
[p90, p99],
|
||||
]);
|
||||
});
|
||||
|
||||
it('names a group by its lowest percentile', () => {
|
||||
expect(formatPercentileBadge([p50])).toBe('p50');
|
||||
expect(formatPercentileBadge([p90, p99])).toBe('p90+');
|
||||
});
|
||||
|
||||
it('shows the estimate, or the overflow bound once for the group', () => {
|
||||
expect(formatPercentileValue([p50], formatBound)).toBe('p50 ≈ 15 ms');
|
||||
expect(formatPercentileValue([p90, p99], formatBound)).toBe(
|
||||
'p90, p99 > 40 ms',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { resolveDistributionXAxis } from '../layout';
|
||||
import {
|
||||
groupIntoOther,
|
||||
resolveClickedGroup,
|
||||
MAX_STACKED_GROUPS,
|
||||
OTHER_GROUP_LABEL,
|
||||
resolveMaxDrawnGroups,
|
||||
} from '../topGroups';
|
||||
|
||||
describe('resolveMaxDrawnGroups', () => {
|
||||
// Four equal buckets plus underflow and overflow: six slots of one unit each.
|
||||
const xAxis = resolveDistributionXAxis([1, 2, 3, 4, 5]);
|
||||
|
||||
it('caps stacked and percent at the fixed limit', () => {
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.Normal, xAxis, plotWidth: 60 }),
|
||||
).toBe(MAX_STACKED_GROUPS);
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.Percent, xAxis, plotWidth: 60 }),
|
||||
).toBe(MAX_STACKED_GROUPS);
|
||||
});
|
||||
|
||||
it('fits side by side so the narrowest bar stays 3 px wide', () => {
|
||||
// 120 px over six slots: 20 px a bucket, six 3 px bars.
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.None, xAxis, plotWidth: 120 }),
|
||||
).toBe(6);
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.None, xAxis, plotWidth: 6 }),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupIntoOther', () => {
|
||||
const args = {
|
||||
labels: ['a', 'b', 'c', 'd'],
|
||||
counts: [
|
||||
[1, 1],
|
||||
[5, 5],
|
||||
[2, 0],
|
||||
[3, 3],
|
||||
],
|
||||
colors: ['#a', '#b', '#c', '#d'],
|
||||
visible: [true, true, true, true],
|
||||
otherColor: '#999',
|
||||
};
|
||||
|
||||
it('draws every visible group when they fit', () => {
|
||||
const drawn = groupIntoOther({ ...args, maxGroups: 4 });
|
||||
|
||||
expect(drawn.labels).toStrictEqual(['a', 'b', 'c', 'd']);
|
||||
expect(drawn.otherGroupCount).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the largest, in legend order, and sums the rest exactly', () => {
|
||||
const drawn = groupIntoOther({ ...args, maxGroups: 3 });
|
||||
|
||||
expect(drawn.labels).toStrictEqual(['b', 'd', OTHER_GROUP_LABEL]);
|
||||
expect(drawn.sourceIndexes).toStrictEqual([1, 3, null]);
|
||||
expect(drawn.counts[2]).toStrictEqual([3, 1]);
|
||||
expect(drawn.colors[2]).toBe('#999');
|
||||
expect(drawn).toMatchObject({ otherGroupCount: 2, visibleGroupCount: 4 });
|
||||
});
|
||||
|
||||
it('re-ranks without hidden groups, which never reach Other', () => {
|
||||
const drawn = groupIntoOther({
|
||||
...args,
|
||||
visible: [true, false, true, true],
|
||||
maxGroups: 2,
|
||||
});
|
||||
|
||||
expect(drawn.labels).toStrictEqual(['d', OTHER_GROUP_LABEL]);
|
||||
expect(drawn.sourceIndexes).toStrictEqual([3, null]);
|
||||
expect(drawn.counts[1]).toStrictEqual([3, 1]);
|
||||
expect(drawn.visibleGroupCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClickedGroup', () => {
|
||||
const series = ['a', 'b', 'c'].map((label) => ({
|
||||
label,
|
||||
counts: [label === 'a' ? 5 : 1],
|
||||
}));
|
||||
const drawn = groupIntoOther({
|
||||
labels: ['a', 'b', 'c'],
|
||||
counts: series.map(({ counts }) => counts),
|
||||
colors: ['#a', '#b', '#c'],
|
||||
visible: [true, true, true],
|
||||
maxGroups: 2,
|
||||
otherColor: '#999',
|
||||
});
|
||||
|
||||
it('maps a drawn bar back to its source group and colour', () => {
|
||||
expect(resolveClickedGroup(drawn, 0, series)).toStrictEqual({
|
||||
series: series[0],
|
||||
color: '#a',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses Other and empty space', () => {
|
||||
expect(resolveClickedGroup(drawn, 1, series)).toBeNull();
|
||||
expect(resolveClickedGroup(drawn, null, series)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps by source index, not label, when groups share a legend', () => {
|
||||
const twins = [
|
||||
{ label: 'Other', groupLabels: { pod: 'a' }, counts: [1] },
|
||||
{ label: 'Other', groupLabels: { pod: 'b' }, counts: [2] },
|
||||
];
|
||||
const drawnTwins = groupIntoOther({
|
||||
labels: twins.map(({ label }) => label),
|
||||
counts: twins.map(({ counts }) => counts),
|
||||
colors: ['#a', '#b'],
|
||||
visible: [true, true],
|
||||
maxGroups: 2,
|
||||
otherColor: '#999',
|
||||
});
|
||||
|
||||
expect(resolveClickedGroup(drawnTwins, 1, twins)).toStrictEqual({
|
||||
series: twins[1],
|
||||
color: '#b',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { toAlphaHex } from 'lib/uPlotV2/utils/fillOpacity';
|
||||
|
||||
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
export function withOpacity(color: string, opacity: number): string {
|
||||
return HEX_COLOR.test(color) ? `${color}${toAlphaHex(opacity)}` : color;
|
||||
}
|
||||
|
||||
/** Neutral, so the summed remainder never reads as one more group. */
|
||||
export function resolveOtherColor(isDarkMode: boolean): string {
|
||||
return isDarkMode ? Color.BG_VANILLA_400 : Color.BG_SLATE_50;
|
||||
}
|
||||
|
||||
export function resolveSeriesColor(
|
||||
label: string,
|
||||
isDarkMode: boolean,
|
||||
customColors?: Record<string, string>,
|
||||
): string {
|
||||
return (
|
||||
customColors?.[label] ??
|
||||
generateColor(
|
||||
label,
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
// Same surface as the shared and heatmap tooltips.
|
||||
.container {
|
||||
--gutter: 14px;
|
||||
--row-inset: 6px;
|
||||
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--l2-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 240px;
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
|
||||
// Same as the heatmap tooltip's bucket row.
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-6) var(--gutter);
|
||||
}
|
||||
|
||||
.titleBucket,
|
||||
.titleCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--popover-foreground);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.titleSeparator {
|
||||
flex: 1;
|
||||
border-width: 0.5px;
|
||||
border-style: dashed;
|
||||
border-color: currentColor;
|
||||
min-width: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
// One grid for the heading and every row, so their columns line up.
|
||||
.rows {
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(0, 1fr) repeat(3, auto);
|
||||
column-gap: var(--spacing-6);
|
||||
row-gap: var(--spacing-1);
|
||||
align-content: start;
|
||||
padding: var(--spacing-4) var(--spacing-4) var(--spacing-6);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.columnHeader,
|
||||
.rows .row {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
// The shared item's own `gap` would otherwise shift the subgrid off the parent.
|
||||
column-gap: var(--spacing-6);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.columnHeader {
|
||||
padding: 0 6px var(--spacing-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
|
||||
> span:nth-child(n + 2) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.columnLabel {
|
||||
grid-column: 1 / 3;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rows .row {
|
||||
border-radius: var(--radius);
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&[data-is-active='true'] {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
> :nth-child(n + 3) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
formatRowLabel,
|
||||
resolveRowIndex,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import TooltipPinnedBadge from 'lib/uPlotV2/components/Tooltip/components/TooltipPinnedBadge/TooltipPinnedBadge';
|
||||
import ItemStyles from 'lib/uPlotV2/components/Tooltip/components/TooltipItem/TooltipItem.module.scss';
|
||||
|
||||
import { resolveNearestSeries } from '../../layout';
|
||||
import {
|
||||
buildDistributionTooltipRows,
|
||||
formatPercent,
|
||||
resolveGroupColumn,
|
||||
} from './distributionTooltipContent';
|
||||
import { DistributionTooltipProps } from './types';
|
||||
|
||||
import Styles from './DistributionTooltip.module.scss';
|
||||
|
||||
/**
|
||||
* The bucket comes from the live cursor, not uPlot's `cursor.idx`: that snaps to the
|
||||
* nearest bucket centre, which on uneven widths is often the neighbour.
|
||||
*/
|
||||
export default function DistributionTooltip({
|
||||
uPlotInstance,
|
||||
xAxis,
|
||||
layout,
|
||||
labels,
|
||||
colors,
|
||||
counts,
|
||||
formatBound,
|
||||
isPinned,
|
||||
dismiss,
|
||||
renderTooltipFooter,
|
||||
}: DistributionTooltipProps): JSX.Element | null {
|
||||
// Read outside the memo: uPlot mutates the same instance on every move.
|
||||
const { left = -10, top = -10 } = uPlotInstance.cursor;
|
||||
|
||||
const hover = useMemo(() => {
|
||||
if (left < 0 || top < 0) {
|
||||
return null;
|
||||
}
|
||||
const x = uPlotInstance.posToVal(left, 'x');
|
||||
const bucket = resolveRowIndex(xAxis.edges, x);
|
||||
if (bucket === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bucket,
|
||||
series: resolveNearestSeries(
|
||||
layout,
|
||||
bucket,
|
||||
x,
|
||||
uPlotInstance.posToVal(top, 'y'),
|
||||
),
|
||||
};
|
||||
}, [left, top, uPlotInstance, xAxis, layout]);
|
||||
|
||||
const groupColumn = useMemo(() => resolveGroupColumn(labels), [labels]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
hover
|
||||
? buildDistributionTooltipRows({
|
||||
labels,
|
||||
colors,
|
||||
counts,
|
||||
bucket: hover.bucket,
|
||||
hoveredSeries: hover.series,
|
||||
formatLabel: groupColumn.format,
|
||||
})
|
||||
: [],
|
||||
[hover, labels, colors, counts, groupColumn],
|
||||
);
|
||||
|
||||
if (!hover) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bucket = xAxis.rows[hover.bucket];
|
||||
const bucketTotal = rows.reduce((sum, row) => sum + row.count, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
|
||||
data-pinned={isPinned}
|
||||
data-testid="distribution-tooltip"
|
||||
>
|
||||
<div className={Styles.title}>
|
||||
<span
|
||||
className={Styles.titleBucket}
|
||||
data-testid="distribution-tooltip-range"
|
||||
>
|
||||
{formatRowLabel(bucket, formatBound)}
|
||||
</span>
|
||||
<span className={Styles.titleSeparator} />
|
||||
<span
|
||||
className={Styles.titleCount}
|
||||
data-testid="distribution-tooltip-total"
|
||||
>
|
||||
{bucketTotal.toLocaleString()}
|
||||
</span>
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} />
|
||||
|
||||
<div className={Styles.rows}>
|
||||
<div className={Styles.columnHeader}>
|
||||
<span
|
||||
className={Styles.columnLabel}
|
||||
title={groupColumn.heading}
|
||||
data-testid="distribution-tooltip-group-heading"
|
||||
>
|
||||
{groupColumn.heading}
|
||||
</span>
|
||||
<span>Count</span>
|
||||
<span>% Total</span>
|
||||
<span data-testid="distribution-tooltip-cumulative-heading">
|
||||
{bucket.isOverflow ? 'All' : `≤ ${formatBound(bucket.upper)}`}
|
||||
</span>
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.seriesIndex}
|
||||
className={cx(ItemStyles.uplotTooltipItem, Styles.row)}
|
||||
data-is-active={row.isHovered}
|
||||
data-testid="distribution-tooltip-row"
|
||||
data-hovered={row.isHovered}
|
||||
>
|
||||
<span
|
||||
className={ItemStyles.uplotTooltipItemMarker}
|
||||
style={{ borderColor: row.color, backgroundColor: row.color }}
|
||||
/>
|
||||
<span className={ItemStyles.uplotTooltipItemLabel} title={row.label}>
|
||||
{row.label}
|
||||
</span>
|
||||
<span className={ItemStyles.uplotTooltipItemValue}>
|
||||
{row.count.toLocaleString()}
|
||||
</span>
|
||||
<span className={ItemStyles.uplotTooltipItemValue}>
|
||||
{formatPercent(row.share)}
|
||||
</span>
|
||||
<span className={ItemStyles.uplotTooltipItemValue}>
|
||||
{formatPercent(row.cumulativeShare)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
resolveDistributionBars,
|
||||
resolveDistributionXAxis,
|
||||
} from 'lib/visualization/charts/Distribution/layout';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import DistributionTooltip from '../DistributionTooltip';
|
||||
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40]);
|
||||
const counts = [
|
||||
[1, 4, 2, 0],
|
||||
[0, 6, 1, 1],
|
||||
];
|
||||
const layout = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
/** Pixels are values: the cursor at (1.5, 2) is over the first group in (10, 20]. */
|
||||
function plotAt(left: number, top: number): uPlot {
|
||||
const plot: Partial<uPlot> = {
|
||||
cursor: { left, top },
|
||||
posToVal: (position: number) => position,
|
||||
};
|
||||
return plot as uPlot;
|
||||
}
|
||||
|
||||
function renderTooltip(uPlotInstance: uPlot): void {
|
||||
render(
|
||||
<DistributionTooltip
|
||||
uPlotInstance={uPlotInstance}
|
||||
isPinned={false}
|
||||
dismiss={jest.fn()}
|
||||
xAxis={xAxis}
|
||||
layout={layout}
|
||||
labels={['{service.name="cart"}', '{service.name="checkout"}']}
|
||||
colors={['#f00', '#0f0']}
|
||||
counts={counts}
|
||||
formatBound={(value): string => `${value} ms`}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('DistributionTooltip', () => {
|
||||
it('names the bucket under the cursor and its total', () => {
|
||||
renderTooltip(plotAt(1.5, 2));
|
||||
|
||||
expect(screen.getByTestId('distribution-tooltip-range')).toHaveTextContent(
|
||||
'10 ms – 20 ms',
|
||||
);
|
||||
expect(screen.getByTestId('distribution-tooltip-total')).toHaveTextContent(
|
||||
'10',
|
||||
);
|
||||
});
|
||||
|
||||
it('heads the columns with the group-by key and the bucket bound', () => {
|
||||
renderTooltip(plotAt(1.5, 2));
|
||||
|
||||
expect(
|
||||
screen.getByTestId('distribution-tooltip-group-heading'),
|
||||
).toHaveTextContent('service.name');
|
||||
expect(
|
||||
screen.getByTestId('distribution-tooltip-cumulative-heading'),
|
||||
).toHaveTextContent('≤ 20 ms');
|
||||
});
|
||||
|
||||
it('lists every group in legend order, the hovered one active', () => {
|
||||
renderTooltip(plotAt(1.5, 2));
|
||||
|
||||
const rows = screen.getAllByTestId('distribution-tooltip-row');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toHaveTextContent('cart457%71%');
|
||||
expect(rows[0]).toHaveAttribute('data-is-active', 'true');
|
||||
expect(rows[1]).toHaveTextContent('checkout675%75%');
|
||||
expect(rows[1]).toHaveAttribute('data-is-active', 'false');
|
||||
});
|
||||
|
||||
it('renders nothing off the plot', () => {
|
||||
renderTooltip(plotAt(-1, -1));
|
||||
|
||||
expect(screen.queryByTestId('distribution-tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
buildDistributionTooltipRows,
|
||||
formatPercent,
|
||||
resolveGroupColumn,
|
||||
} from '../distributionTooltipContent';
|
||||
|
||||
describe('formatPercent', () => {
|
||||
it('rounds, and never shows a real share as 0%', () => {
|
||||
expect(formatPercent(0.256)).toBe('26%');
|
||||
expect(formatPercent(0.004)).toBe('<1%');
|
||||
expect(formatPercent(0)).toBe('0%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGroupColumn', () => {
|
||||
it('names the column after a shared single key and shows its values', () => {
|
||||
const column = resolveGroupColumn([
|
||||
'{service.name="cart"}',
|
||||
'{service.name="checkout"}',
|
||||
]);
|
||||
|
||||
expect(column.heading).toBe('service.name');
|
||||
expect(column.format('{service.name="cart"}')).toBe('cart');
|
||||
});
|
||||
|
||||
it('keeps full labels for several keys or mixed labels', () => {
|
||||
const several = resolveGroupColumn(['{env="prod", service.name="cart"}']);
|
||||
const mixed = resolveGroupColumn(['{service.name="cart"}', 'Other']);
|
||||
|
||||
expect(several.heading).toBe('Group');
|
||||
expect(several.format('{env="prod", service.name="cart"}')).toBe(
|
||||
'{env="prod", service.name="cart"}',
|
||||
);
|
||||
expect(mixed.heading).toBe('Group');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDistributionTooltipRows', () => {
|
||||
it('gives each drawn group, in legend order, its count, share and cumulative share', () => {
|
||||
const rows = buildDistributionTooltipRows({
|
||||
labels: ['cart', 'checkout'],
|
||||
colors: ['#f00', '#0f0'],
|
||||
counts: [
|
||||
[2, 6, 2],
|
||||
[5, 0, 5],
|
||||
],
|
||||
bucket: 1,
|
||||
hoveredSeries: 0,
|
||||
formatLabel: (label) => label.toUpperCase(),
|
||||
});
|
||||
|
||||
expect(rows).toStrictEqual([
|
||||
{
|
||||
seriesIndex: 0,
|
||||
label: 'CART',
|
||||
color: '#f00',
|
||||
count: 6,
|
||||
share: 0.6,
|
||||
cumulativeShare: 0.8,
|
||||
isHovered: true,
|
||||
},
|
||||
{
|
||||
seriesIndex: 1,
|
||||
label: 'CHECKOUT',
|
||||
color: '#0f0',
|
||||
count: 0,
|
||||
share: 0,
|
||||
cumulativeShare: 0.5,
|
||||
isHovered: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { DistributionGroupColumn, DistributionTooltipRow } from './types';
|
||||
|
||||
const SINGLE_LABEL = /^\{([^=,]+)="([^"]*)"\}$/;
|
||||
|
||||
/** `<1%` rather than `0%`, so a share that exists never reads as none. */
|
||||
export function formatPercent(share: number): string {
|
||||
if (share > 0 && share < 0.01) {
|
||||
return '<1%';
|
||||
}
|
||||
return `${Math.round(share * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the group column after the one group-by key every label shares, showing just
|
||||
* its values; anything else (several keys, Other) keeps the full labels.
|
||||
*/
|
||||
export function resolveGroupColumn(labels: string[]): DistributionGroupColumn {
|
||||
const keys = new Set<string>();
|
||||
labels.forEach((label) => {
|
||||
keys.add(SINGLE_LABEL.exec(label)?.[1] ?? '');
|
||||
});
|
||||
const [key] = [...keys];
|
||||
if (keys.size !== 1 || !key) {
|
||||
return { heading: 'Group', format: (label) => label };
|
||||
}
|
||||
return {
|
||||
heading: key,
|
||||
format: (label) => SINGLE_LABEL.exec(label)?.[2] ?? label,
|
||||
};
|
||||
}
|
||||
|
||||
/** One row per drawn group, in legend order, with its count in `bucket`. */
|
||||
export function buildDistributionTooltipRows({
|
||||
labels,
|
||||
colors,
|
||||
counts,
|
||||
bucket,
|
||||
hoveredSeries,
|
||||
formatLabel,
|
||||
}: {
|
||||
labels: string[];
|
||||
colors: string[];
|
||||
/** `counts[series][bucket]`. */
|
||||
counts: number[][];
|
||||
bucket: number;
|
||||
hoveredSeries: number | null;
|
||||
formatLabel: (label: string) => string;
|
||||
}): DistributionTooltipRow[] {
|
||||
return labels.map((label, series) => {
|
||||
const seriesCounts = counts[series] ?? [];
|
||||
let total = 0;
|
||||
let atOrBelow = 0;
|
||||
seriesCounts.forEach((count, index) => {
|
||||
total += count;
|
||||
if (index <= bucket) {
|
||||
atOrBelow += count;
|
||||
}
|
||||
});
|
||||
const count = seriesCounts[bucket] ?? 0;
|
||||
return {
|
||||
seriesIndex: series,
|
||||
label: formatLabel(label),
|
||||
color: colors[series],
|
||||
count,
|
||||
share: total > 0 ? count / total : 0,
|
||||
cumulativeShare: total > 0 ? atOrBelow / total : 0,
|
||||
isHovered: series === hoveredSeries,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import type {
|
||||
IRenderTooltipFooterArgs,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
|
||||
import type { DistributionBarsLayout } from '../../layout';
|
||||
import type { DistributionXAxis } from '../../types';
|
||||
|
||||
export interface DistributionTooltipRow {
|
||||
/** Index among the drawn series. */
|
||||
seriesIndex: number;
|
||||
label: string;
|
||||
color: string;
|
||||
count: number;
|
||||
/** Of the group's own total over the range. */
|
||||
share: number;
|
||||
/** Of the group's total, at or below this bucket. */
|
||||
cumulativeShare: number;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
export interface DistributionGroupColumn {
|
||||
heading: string;
|
||||
format: (label: string) => string;
|
||||
}
|
||||
|
||||
export interface DistributionTooltipProps extends Pick<
|
||||
TooltipRenderArgs,
|
||||
'uPlotInstance' | 'isPinned' | 'dismiss'
|
||||
> {
|
||||
xAxis: DistributionXAxis;
|
||||
layout: DistributionBarsLayout;
|
||||
labels: string[];
|
||||
colors: string[];
|
||||
/** `counts[series][bucket]`. */
|
||||
counts: number[][];
|
||||
formatBound: (value: number) => string;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
DistributionType,
|
||||
DrawStyle,
|
||||
StackMode,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { createVariableWidthBarsPathBuilder } from 'lib/uPlotV2/paths/variableWidthBars';
|
||||
import { resolveRowIndex } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { createHatchPattern } from 'lib/uPlotV2/plugins/HeatmapPlugin/paint';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { withOpacity } from './colors';
|
||||
import {
|
||||
drawPercentileMarkers,
|
||||
MARKER_HIT_PX,
|
||||
PercentileMarkerHitArea,
|
||||
resolveHoveredMarker,
|
||||
} from './drawPercentileMarkers';
|
||||
import {
|
||||
DistributionBarsLayout,
|
||||
LOG_Y_BASE,
|
||||
resolveNearestSeries,
|
||||
thinLabelledSplits,
|
||||
} from './layout';
|
||||
import { DistributionPercentile, groupPercentileMarkers } from './percentiles';
|
||||
import { DistributionXAxis } from './types';
|
||||
import { createBoundFormatter } from './utils';
|
||||
|
||||
/** Minimum space between two x tick labels, in CSS pixels. */
|
||||
const X_LABEL_GAP_PX = 12;
|
||||
const BAR_GAP_PX = 1;
|
||||
const BAR_OPACITY = 0.85;
|
||||
const OVERFLOW_BAR_OPACITY = 0.4;
|
||||
/** Every group but the hovered one fades to this. */
|
||||
const DIMMED_BAR_OPACITY = 0.25;
|
||||
const DIMMED_OVERFLOW_BAR_OPACITY = 0.12;
|
||||
const OVERFLOW_AXIS_LABEL = '∞';
|
||||
/** Headroom above the tallest bar: a fraction on linear, a factor on log. Marker
|
||||
* labels sit at the top of the plot, so they take more. */
|
||||
const LINEAR_HEADROOM = 1.1;
|
||||
const LINEAR_HEADROOM_WITH_MARKERS = 1.3;
|
||||
const LOG_HEADROOM = 2;
|
||||
const LOG_HEADROOM_WITH_MARKERS = 6;
|
||||
|
||||
function resolveYRange({
|
||||
yMax,
|
||||
isLogY,
|
||||
stack,
|
||||
hasMarkers,
|
||||
}: {
|
||||
yMax: number;
|
||||
isLogY: boolean;
|
||||
stack: StackMode;
|
||||
hasMarkers: boolean;
|
||||
}): [number, number] {
|
||||
if (stack === StackMode.Percent) {
|
||||
return [0, hasMarkers ? 100 * LINEAR_HEADROOM_WITH_MARKERS : 100];
|
||||
}
|
||||
if (isLogY) {
|
||||
const headroom = hasMarkers ? LOG_HEADROOM_WITH_MARKERS : LOG_HEADROOM;
|
||||
return [LOG_Y_BASE, Math.max(yMax, LOG_Y_BASE) * headroom];
|
||||
}
|
||||
const headroom = hasMarkers ? LINEAR_HEADROOM_WITH_MARKERS : LINEAR_HEADROOM;
|
||||
return [0, yMax > 0 ? yMax * headroom : 1];
|
||||
}
|
||||
|
||||
export interface BuildDistributionConfigArgs {
|
||||
id: string;
|
||||
xAxis: DistributionXAxis;
|
||||
layout: DistributionBarsLayout;
|
||||
labels: string[];
|
||||
colors: string[];
|
||||
stack: StackMode;
|
||||
isLogY: boolean;
|
||||
isDarkMode: boolean;
|
||||
/** Drawn across the plot, labelled at the top. */
|
||||
markers: DistributionPercentile[];
|
||||
/** Unit of the bucket bounds; counts take none. */
|
||||
xAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}
|
||||
|
||||
export function buildDistributionConfig({
|
||||
id,
|
||||
xAxis,
|
||||
layout,
|
||||
labels,
|
||||
colors,
|
||||
stack,
|
||||
isLogY,
|
||||
isDarkMode,
|
||||
markers,
|
||||
xAxisUnit,
|
||||
decimalPrecision,
|
||||
}: BuildDistributionConfigArgs): UPlotConfigBuilder {
|
||||
const builder = new UPlotConfigBuilder({ id });
|
||||
// uPlot's focus picks the series nearest in value, meaningless for stacked bars; the
|
||||
// setCursor hook focuses the one under the cursor itself. Alpha 1 leaves the
|
||||
// dimming to the bar fills.
|
||||
builder.setFocus({ alpha: 1 });
|
||||
// The x range is pinned to the buckets, so a drag-zoom would only flash a box.
|
||||
builder.setCursor({
|
||||
focus: { prox: -1 },
|
||||
points: { show: false },
|
||||
drag: { x: false, y: false },
|
||||
});
|
||||
|
||||
const formatBound = createBoundFormatter(xAxisUnit, decimalPrecision);
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: false,
|
||||
auto: false,
|
||||
range: (): [number, number] => [xAxis.min, xAxis.max],
|
||||
});
|
||||
const yRange = resolveYRange({
|
||||
yMax: layout.yMax,
|
||||
isLogY,
|
||||
stack,
|
||||
hasMarkers: markers.length > 0,
|
||||
});
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
auto: false,
|
||||
distribution: isLogY ? DistributionType.Logarithmic : DistributionType.Linear,
|
||||
range: (): [number, number] => yRange,
|
||||
});
|
||||
|
||||
const axisSplits =
|
||||
xAxis.overflowSplit === null
|
||||
? xAxis.splits
|
||||
: [...xAxis.splits, xAxis.overflowSplit];
|
||||
const splitLabels = new Map<number, string>();
|
||||
xAxis.splits.forEach((split, index) => {
|
||||
splitLabels.set(split, formatBound(xAxis.rows[index].upper));
|
||||
});
|
||||
if (xAxis.overflowSplit !== null) {
|
||||
splitLabels.set(xAxis.overflowSplit, OVERFLOW_AXIS_LABEL);
|
||||
}
|
||||
|
||||
const labelOf = (split: number): string =>
|
||||
splitLabels.get(split) ?? formatBound(xAxis.toBucketValue(split));
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
// Thinned by each label's own width: bound labels run from `5 s` to
|
||||
// `16.67 mins`, so no fixed spacing keeps them apart.
|
||||
splits: (self, axisIdx): number[] => {
|
||||
const visible = axisSplits.filter(
|
||||
(split) => split >= xAxis.min && split <= xAxis.max,
|
||||
);
|
||||
const font = self.axes[axisIdx]?.font?.[0];
|
||||
if (font) {
|
||||
self.ctx.font = font;
|
||||
}
|
||||
const kept = thinLabelledSplits({
|
||||
positions: visible.map((split) => self.valToPos(split, 'x')),
|
||||
widths: visible.map(
|
||||
(split) => self.ctx.measureText(labelOf(split)).width / uPlot.pxRatio,
|
||||
),
|
||||
gap: X_LABEL_GAP_PX,
|
||||
});
|
||||
return kept.map((index) => visible[index]);
|
||||
},
|
||||
values: (_, splits): string[] => splits.map(labelOf),
|
||||
});
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit: stack === StackMode.Percent ? 'percent' : 'short',
|
||||
// The marker headroom runs a share axis past 100%, which labels nothing real.
|
||||
...(stack === StackMode.Percent && {
|
||||
filter: (_self: uPlot, splits: number[]): Array<number | null> =>
|
||||
splits.map((split) => (split <= 100 ? split : null)),
|
||||
}),
|
||||
});
|
||||
|
||||
let hatchPattern: CanvasPattern | null | undefined;
|
||||
const getHatchPattern = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
): CanvasPattern | null => {
|
||||
hatchPattern ??= createHatchPattern(ctx, isDarkMode);
|
||||
return hatchPattern;
|
||||
};
|
||||
|
||||
// Read by every series' fill on each draw; a change redraws the paths.
|
||||
const hover: {
|
||||
series: number | null;
|
||||
focused: number | null;
|
||||
marker: number | null;
|
||||
} = { series: null, focused: null, marker: null };
|
||||
const plotMarkers = groupPercentileMarkers(
|
||||
markers.filter(
|
||||
({ axisValue }) => axisValue >= xAxis.min && axisValue <= xAxis.max,
|
||||
),
|
||||
);
|
||||
let markerHitAreas: PercentileMarkerHitArea[] = [];
|
||||
const isDimmed = (seriesIndex: number): boolean =>
|
||||
hover.series !== null && hover.series !== seriesIndex;
|
||||
|
||||
labels.forEach((label, seriesIndex) => {
|
||||
const color = colors[seriesIndex];
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
label,
|
||||
drawStyle: DrawStyle.Bar,
|
||||
pathBuilder: createVariableWidthBarsPathBuilder({
|
||||
getBars: () => layout.rects[seriesIndex] ?? [],
|
||||
fill: () =>
|
||||
withOpacity(
|
||||
color,
|
||||
isDimmed(seriesIndex) ? DIMMED_BAR_OPACITY : BAR_OPACITY,
|
||||
),
|
||||
hatchedFill: () =>
|
||||
withOpacity(
|
||||
color,
|
||||
isDimmed(seriesIndex)
|
||||
? DIMMED_OVERFLOW_BAR_OPACITY
|
||||
: OVERFLOW_BAR_OPACITY,
|
||||
),
|
||||
getHatchPattern,
|
||||
gapPx: BAR_GAP_PX,
|
||||
}),
|
||||
lineColor: color,
|
||||
lineWidth: 0,
|
||||
colorMapping: {},
|
||||
isDarkMode,
|
||||
showPoints: false,
|
||||
spanGaps: false,
|
||||
});
|
||||
});
|
||||
|
||||
builder.addHook('draw', (u: uPlot): void => {
|
||||
markerHitAreas = drawPercentileMarkers(
|
||||
u,
|
||||
plotMarkers,
|
||||
formatBound,
|
||||
isDarkMode,
|
||||
hover.marker,
|
||||
);
|
||||
});
|
||||
|
||||
// Registered as a hook, not a plugin, so it runs before TooltipPlugin's: the tooltip
|
||||
// only shows for a focused series, and must find it focused when it positions.
|
||||
builder.addHook('setCursor', (u: uPlot): void => {
|
||||
const { left = -1, top = -1 } = u.cursor;
|
||||
const isOnPlot = left >= 0 && top >= 0;
|
||||
const x = isOnPlot ? u.posToVal(left, 'x') : Number.NaN;
|
||||
const bucket = isOnPlot ? resolveRowIndex(xAxis.edges, x) : null;
|
||||
const marker = isOnPlot
|
||||
? resolveHoveredMarker(markerHitAreas, left, top, MARKER_HIT_PX)
|
||||
: null;
|
||||
// Over a marker, the bars neither dim nor show their tooltip.
|
||||
const hovered =
|
||||
bucket === null || marker !== null
|
||||
? null
|
||||
: resolveNearestSeries(layout, bucket, x, u.posToVal(top, 'y'));
|
||||
// An empty bucket still gets a tooltip, as a bar chart's zero column does; with no
|
||||
// nearest group to name, the first stands in.
|
||||
const isEmptyBucket = bucket !== null && marker === null && hovered === null;
|
||||
const focused = isEmptyBucket && labels.length > 0 ? 0 : hovered;
|
||||
|
||||
if (focused !== hover.focused) {
|
||||
hover.focused = focused;
|
||||
u.setSeries(focused === null ? null : focused + 1, { focus: true });
|
||||
}
|
||||
if (marker !== hover.marker) {
|
||||
// The crosshair would cut through the marker's badges.
|
||||
u.root
|
||||
.querySelectorAll<HTMLElement>('.u-cursor-x, .u-cursor-y')
|
||||
.forEach((line) => {
|
||||
line.style.visibility = marker === null ? '' : 'hidden';
|
||||
});
|
||||
}
|
||||
if (hovered !== hover.series || marker !== hover.marker) {
|
||||
hover.series = hovered;
|
||||
hover.marker = marker;
|
||||
u.redraw(true, false);
|
||||
}
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
DistributionPercentile,
|
||||
formatPercentileBadge,
|
||||
formatPercentileValue,
|
||||
} from './percentiles';
|
||||
|
||||
const MARKER_DASH: [number, number] = [3, 3];
|
||||
const MARKER_FONT_SIZE_PX = 9;
|
||||
/** Space between a badge's name and its diamond's edges. */
|
||||
const MARKER_BADGE_PADDING_PX = 2;
|
||||
const MARKER_BADGE_GAP_PX = 2;
|
||||
const MARKER_VALUE_FONT_SIZE_PX = 11;
|
||||
/** How far from a marker line the cursor still hovers it, in CSS pixels. */
|
||||
export const MARKER_HIT_PX = 4;
|
||||
const MARKER_PILL_PADDING_X_PX = 6;
|
||||
const MARKER_PILL_PADDING_Y_PX = 3;
|
||||
const MARKER_PILL_RADIUS_PX = 4;
|
||||
/** Height of the pill's notch pointing up at the line; also its gap from the axis. */
|
||||
const MARKER_PILL_POINTER_PX = 4;
|
||||
|
||||
interface HitRect {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/** A drawn marker's hover targets, in CSS pixels relative to the plot. */
|
||||
export interface PercentileMarkerHitArea {
|
||||
x: number;
|
||||
badges: HitRect[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker under `(x, y)`: one whose badge it is over, else the line nearest
|
||||
* `x` within `tolerance`. `null` when none is.
|
||||
*/
|
||||
export function resolveHoveredMarker(
|
||||
areas: PercentileMarkerHitArea[],
|
||||
x: number,
|
||||
y: number,
|
||||
tolerance: number,
|
||||
): number | null {
|
||||
const overBadge = areas.findIndex(({ badges }) =>
|
||||
badges.some(
|
||||
(badge) =>
|
||||
x >= badge.left && x <= badge.right && y >= badge.top && y <= badge.bottom,
|
||||
),
|
||||
);
|
||||
if (overBadge !== -1) {
|
||||
return overBadge;
|
||||
}
|
||||
let nearest: number | null = null;
|
||||
let nearestDistance = tolerance;
|
||||
areas.forEach((area, index) => {
|
||||
const distance = Math.abs(area.x - x);
|
||||
if (distance <= nearestDistance) {
|
||||
nearest = index;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
});
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacks marker badges into rows so none overlap: each goes in the first row whose
|
||||
* last badge ends before it starts. Input and output are in the same pixel space.
|
||||
*/
|
||||
export function layoutMarkerLabels(
|
||||
labels: Array<{ x: number; width: number }>,
|
||||
gap: number,
|
||||
): number[] {
|
||||
const rowEnds: number[] = [];
|
||||
const order = labels
|
||||
.map((label, index) => ({ ...label, index }))
|
||||
.sort((a, b) => a.x - b.x);
|
||||
const rows = new Array<number>(labels.length).fill(0);
|
||||
|
||||
order.forEach(({ x, width, index }) => {
|
||||
let row = rowEnds.findIndex((end) => x >= end + gap);
|
||||
if (row === -1) {
|
||||
row = rowEnds.length;
|
||||
rowEnds.push(0);
|
||||
}
|
||||
rowEnds[row] = x + width;
|
||||
rows[index] = row;
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** A square diamond centred on `(x, y)` with half-diagonal `r`, filled then outlined. */
|
||||
function drawDiamond(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
r: number,
|
||||
fill: string,
|
||||
stroke: string,
|
||||
): void {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y - r);
|
||||
ctx.lineTo(x + r, y);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.lineTo(x - r, y);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = fill;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashed lines across the plot with a diamond badge at each end naming the
|
||||
* percentile, one per group on the same spot; badges stack into rows so none overlap. The hovered marker is
|
||||
* emphasised and shows its value on the x axis. Returns each marker's hover targets.
|
||||
*/
|
||||
export function drawPercentileMarkers(
|
||||
u: uPlot,
|
||||
markers: DistributionPercentile[][],
|
||||
formatBound: (value: number) => string,
|
||||
isDarkMode: boolean,
|
||||
hovered: number | null,
|
||||
): PercentileMarkerHitArea[] {
|
||||
if (markers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const { ctx, bbox } = u;
|
||||
const { pxRatio } = uPlot;
|
||||
const color = isDarkMode ? themeColors.white : themeColors.black;
|
||||
const background = isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100;
|
||||
const bottom = bbox.top + bbox.height;
|
||||
|
||||
ctx.save();
|
||||
ctx.font = `600 ${MARKER_FONT_SIZE_PX * pxRatio}px Inter, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const named = markers.map((group) => {
|
||||
const text = formatPercentileBadge(group);
|
||||
return { text, width: ctx.measureText(text).width };
|
||||
});
|
||||
// One square diamond for all, fitting the widest name's box: |dx| + |dy| ≤ r at
|
||||
// its corners. Cap height stands in for the text's height.
|
||||
const r =
|
||||
(Math.max(...named.map(({ width }) => width)) +
|
||||
MARKER_FONT_SIZE_PX * pxRatio * 0.7) /
|
||||
2 +
|
||||
MARKER_BADGE_PADDING_PX * pxRatio;
|
||||
const badges = markers.map((group, index) => ({
|
||||
text: named[index].text,
|
||||
x: u.valToPos(group[0].axisValue, 'x', true),
|
||||
}));
|
||||
const rows = layoutMarkerLabels(
|
||||
badges.map(({ x }) => ({ x: x - r, width: 2 * r })),
|
||||
MARKER_BADGE_GAP_PX * pxRatio,
|
||||
);
|
||||
const rowStep = 2 * r + MARKER_BADGE_GAP_PX * pxRatio;
|
||||
const badgeCentres = (index: number): [number, number] => {
|
||||
const offset = r + rows[index] * rowStep;
|
||||
return [bbox.top + offset, bottom - offset];
|
||||
};
|
||||
|
||||
const drawMarker = (index: number): void => {
|
||||
const { text, x } = badges[index];
|
||||
const isHovered = index === hovered;
|
||||
|
||||
ctx.globalAlpha = isHovered ? 1 : 0.55;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = pxRatio;
|
||||
ctx.setLineDash(MARKER_DASH.map((dash) => dash * pxRatio));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, bbox.top);
|
||||
ctx.lineTo(x, bottom);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.setLineDash([]);
|
||||
badgeCentres(index).forEach((y) => {
|
||||
drawDiamond(ctx, x, y, r, isHovered ? color : background, color);
|
||||
ctx.fillStyle = isHovered ? background : color;
|
||||
ctx.fillText(text, x, y);
|
||||
});
|
||||
|
||||
if (!isHovered) {
|
||||
return;
|
||||
}
|
||||
// The value sits on the x axis under the line, over the tick labels, in a pill
|
||||
// whose notch points up at the line.
|
||||
ctx.font = `500 ${MARKER_VALUE_FONT_SIZE_PX * pxRatio}px Inter, sans-serif`;
|
||||
const value = formatPercentileValue(markers[index], formatBound);
|
||||
const padX = MARKER_PILL_PADDING_X_PX * pxRatio;
|
||||
const padY = MARKER_PILL_PADDING_Y_PX * pxRatio;
|
||||
const pointer = MARKER_PILL_POINTER_PX * pxRatio;
|
||||
const width = ctx.measureText(value).width + 2 * padX;
|
||||
const height = MARKER_VALUE_FONT_SIZE_PX * pxRatio + 2 * padY;
|
||||
const left = Math.min(Math.max(x - width / 2, 0), ctx.canvas.width - width);
|
||||
const top = bottom + 2 * pointer;
|
||||
const notchX = Math.min(
|
||||
Math.max(x, left + pointer + MARKER_PILL_RADIUS_PX * pxRatio),
|
||||
left + width - pointer - MARKER_PILL_RADIUS_PX * pxRatio,
|
||||
);
|
||||
// A halo in the chart's background hides the tick labels it overlaps.
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(left - padX, top, width + 2 * padX, height + 2 * padY);
|
||||
ctx.fillStyle = isDarkMode ? Color.BG_SLATE_400 : Color.BG_INK_400;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(left, top, width, height, MARKER_PILL_RADIUS_PX * pxRatio);
|
||||
ctx.moveTo(notchX - pointer, top);
|
||||
ctx.lineTo(notchX, top - pointer);
|
||||
ctx.lineTo(notchX + pointer, top);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.fillStyle = Color.BG_VANILLA_100;
|
||||
ctx.fillText(value, left + width / 2, top + height / 2);
|
||||
};
|
||||
|
||||
// The hovered one last, so it draws over its neighbours.
|
||||
badges.forEach((_, index) => index !== hovered && drawMarker(index));
|
||||
if (hovered !== null && hovered < badges.length) {
|
||||
drawMarker(hovered);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
return badges.map(({ x }, index) => {
|
||||
const toPlot = (value: number, origin: number): number =>
|
||||
(value - origin) / pxRatio;
|
||||
return {
|
||||
x: toPlot(x, bbox.left),
|
||||
badges: badgeCentres(index).map((y) => ({
|
||||
left: toPlot(x - r, bbox.left),
|
||||
right: toPlot(x + r, bbox.left),
|
||||
top: toPlot(y - r, bbox.top),
|
||||
bottom: toPlot(y + r, bbox.top),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
cropHeatmapYAxis,
|
||||
resolveHeatmapYAxis,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { HeatmapAxisScale } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
import { DistributionBarRect, DistributionXAxis } from './types';
|
||||
|
||||
/** Bottom of a log count axis: a bar has to start somewhere above zero. */
|
||||
export const LOG_Y_BASE = 1;
|
||||
|
||||
/**
|
||||
* N bounds give N + 1 buckets: `≤ first`, the finite ones, and `> last`. Every bucket
|
||||
* is equally wide: bucket `i` spans `[i, i + 1]`, and ticks still name the bounds.
|
||||
*/
|
||||
export function resolveDistributionXAxis(buckets: number[]): DistributionXAxis {
|
||||
const { rows, overflowSplit } = resolveHeatmapYAxis(
|
||||
buckets,
|
||||
HeatmapAxisScale.Linear,
|
||||
);
|
||||
const edges = rows.map((_, index) => index).concat(rows.length);
|
||||
return {
|
||||
rows,
|
||||
edges,
|
||||
splits: rows.slice(0, -1).map((_, index) => index + 1),
|
||||
overflowSplit: overflowSplit === null ? null : rows.length,
|
||||
toBucketValue: (axisValue: number): number => {
|
||||
const index = Math.min(rows.length - 1, Math.max(0, Math.floor(axisValue)));
|
||||
const row = rows[index];
|
||||
return row.lower + (axisValue - index) * (row.upper - row.lower);
|
||||
},
|
||||
min: 0,
|
||||
max: rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sum of each bucket across the visible series. */
|
||||
export function sumVisibleCounts(
|
||||
counts: number[][],
|
||||
visible: boolean[],
|
||||
bucketCount: number,
|
||||
): number[] {
|
||||
const totals = new Array<number>(bucketCount).fill(0);
|
||||
counts.forEach((seriesCounts, seriesIndex) => {
|
||||
if (!visible[seriesIndex]) {
|
||||
return;
|
||||
}
|
||||
for (let bucket = 0; bucket < bucketCount; bucket += 1) {
|
||||
totals[bucket] += seriesCounts[bucket] ?? 0;
|
||||
}
|
||||
});
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** Drops empty buckets past the last occupied one, as the heatmap does. */
|
||||
export function cropDistributionXAxis(
|
||||
axis: DistributionXAxis,
|
||||
totals: number[],
|
||||
): DistributionXAxis {
|
||||
return cropHeatmapYAxis(
|
||||
axis,
|
||||
totals.map((total) => [total]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows the axis window to the buckets overlapping `[min, max]`, never below one.
|
||||
* A bucket straddling a bound stays whole: cutting it would misstate its count.
|
||||
*/
|
||||
export function clampDistributionXAxis(
|
||||
axis: DistributionXAxis,
|
||||
min?: number,
|
||||
max?: number,
|
||||
): DistributionXAxis {
|
||||
const { rows, edges } = axis;
|
||||
let first = edges.indexOf(axis.min);
|
||||
let last = edges.indexOf(axis.max) - 1;
|
||||
while (min !== undefined && first < last && rows[first].upper <= min) {
|
||||
first += 1;
|
||||
}
|
||||
while (max !== undefined && last > first && rows[last].lower >= max) {
|
||||
last -= 1;
|
||||
}
|
||||
return {
|
||||
...axis,
|
||||
overflowSplit: rows[last].isOverflow ? axis.overflowSplit : null,
|
||||
min: edges[first],
|
||||
max: edges[last + 1],
|
||||
};
|
||||
}
|
||||
|
||||
export interface DistributionBarsLayout {
|
||||
/** One list per series, in series order. */
|
||||
rects: DistributionBarRect[][];
|
||||
/** Top of the tallest bar inside the axis window, for the count axis range. */
|
||||
yMax: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every bar, in scale space. `normal` stacks the series in each bucket, `percent`
|
||||
* stacks their shares of it, `none` splits the bucket width between them.
|
||||
*/
|
||||
export function resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack,
|
||||
isLogY,
|
||||
}: {
|
||||
xAxis: DistributionXAxis;
|
||||
/** `counts[series][bucket]`. */
|
||||
counts: number[][];
|
||||
stack: StackMode;
|
||||
isLogY: boolean;
|
||||
}): DistributionBarsLayout {
|
||||
const { rows, edges } = xAxis;
|
||||
const rects: DistributionBarRect[][] = counts.map(() => []);
|
||||
const base = isLogY ? LOG_Y_BASE : 0;
|
||||
let yMax = 0;
|
||||
|
||||
rows.forEach((row, bucketIndex) => {
|
||||
const x0 = edges[bucketIndex];
|
||||
const x1 = edges[bucketIndex + 1];
|
||||
// Buckets outside the window are off the plot and mustn't set its height.
|
||||
const isInWindow = x0 >= xAxis.min && x1 <= xAxis.max;
|
||||
const total = counts.reduce(
|
||||
(sum, seriesCounts) => sum + (seriesCounts[bucketIndex] ?? 0),
|
||||
0,
|
||||
);
|
||||
let cumulative = 0;
|
||||
|
||||
counts.forEach((seriesCounts, seriesIndex) => {
|
||||
const count = seriesCounts[bucketIndex] ?? 0;
|
||||
if (count <= 0) {
|
||||
return;
|
||||
}
|
||||
const bar = { bucketIndex, hatched: row.isOverflow };
|
||||
|
||||
if (stack === StackMode.None) {
|
||||
// Below the log base a bar would draw downwards from the axis.
|
||||
if (count <= base) {
|
||||
return;
|
||||
}
|
||||
const width = (x1 - x0) / counts.length;
|
||||
rects[seriesIndex].push({
|
||||
...bar,
|
||||
x0: x0 + seriesIndex * width,
|
||||
x1: x0 + (seriesIndex + 1) * width,
|
||||
y0: base,
|
||||
y1: count,
|
||||
});
|
||||
if (isInWindow) {
|
||||
yMax = Math.max(yMax, count);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = stack === StackMode.Percent ? 100 / total : 1;
|
||||
const y0 = cumulative * scale;
|
||||
cumulative += count;
|
||||
const y1 = cumulative * scale;
|
||||
if (y1 <= base) {
|
||||
return;
|
||||
}
|
||||
rects[seriesIndex].push({
|
||||
...bar,
|
||||
x0,
|
||||
x1,
|
||||
y0: Math.max(y0, base),
|
||||
y1,
|
||||
});
|
||||
if (isInWindow) {
|
||||
yMax = Math.max(yMax, y1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { rects, yMax };
|
||||
}
|
||||
|
||||
/**
|
||||
* The group nearest `(x, y)` vertically in `bucket`, as uPlot focuses bar series;
|
||||
* side by side, only the sub-bar under `x` competes. `null` over an empty bucket.
|
||||
*/
|
||||
export function resolveNearestSeries(
|
||||
layout: DistributionBarsLayout,
|
||||
bucket: number,
|
||||
x: number,
|
||||
y: number,
|
||||
): number | null {
|
||||
const candidates = layout.rects.flatMap((rects, series) =>
|
||||
rects
|
||||
.filter((rect) => rect.bucketIndex === bucket)
|
||||
.map((rect) => ({ rect, series })),
|
||||
);
|
||||
const underX = candidates.filter(({ rect }) => x >= rect.x0 && x < rect.x1);
|
||||
let nearest: number | null = null;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
(underX.length > 0 ? underX : candidates).forEach(({ rect, series }) => {
|
||||
const distance = Math.max(rect.y0 - y, y - rect.y1, 0);
|
||||
if (distance < nearestDistance) {
|
||||
nearest = series;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
});
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexes of the ticks whose centred labels fit without touching, walking from the
|
||||
* right so the `∞` edge survives. Positions and widths share one pixel space.
|
||||
*/
|
||||
export function thinLabelledSplits({
|
||||
positions,
|
||||
widths,
|
||||
gap,
|
||||
}: {
|
||||
positions: number[];
|
||||
widths: number[];
|
||||
gap: number;
|
||||
}): number[] {
|
||||
const kept: number[] = [];
|
||||
let leftEdge = Number.POSITIVE_INFINITY;
|
||||
for (let index = positions.length - 1; index >= 0; index -= 1) {
|
||||
const half = widths[index] / 2;
|
||||
if (positions[index] + half + gap <= leftEdge) {
|
||||
kept.push(index);
|
||||
leftEdge = positions[index] - half;
|
||||
}
|
||||
}
|
||||
return kept.reverse();
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { DistributionXAxis } from './types';
|
||||
|
||||
export interface DistributionPercentile {
|
||||
/** 0–100, as configured. */
|
||||
percentile: number;
|
||||
/** The estimate; in overflow, the last finite bound it is known to exceed. */
|
||||
value: number;
|
||||
/** Where the marker is drawn, in x-scale space. */
|
||||
axisValue: number;
|
||||
isOverflow: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Percentiles of the summed counts, by `histogram_quantile`'s method: find the bucket
|
||||
* holding the target rank and interpolate linearly inside it, with the lowest bucket
|
||||
* starting at zero. So the values match a time-series panel's percentiles.
|
||||
*/
|
||||
export function computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals,
|
||||
percentiles,
|
||||
}: {
|
||||
xAxis: DistributionXAxis;
|
||||
/** Summed visible counts per bucket. */
|
||||
totals: number[];
|
||||
percentiles: readonly number[];
|
||||
}): DistributionPercentile[] {
|
||||
const { rows, edges } = xAxis;
|
||||
const total = totals.reduce((sum, count) => sum + count, 0);
|
||||
if (total <= 0 || rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return percentiles.flatMap<DistributionPercentile>((percentile) => {
|
||||
const rank = (percentile / 100) * total;
|
||||
let below = 0;
|
||||
for (let index = 0; index < rows.length; index += 1) {
|
||||
const count = totals[index] ?? 0;
|
||||
if (count > 0 && below + count >= rank) {
|
||||
const row = rows[index];
|
||||
if (row.isOverflow) {
|
||||
return [
|
||||
{
|
||||
percentile,
|
||||
value: row.lower,
|
||||
axisValue: edges[index],
|
||||
isOverflow: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
const fraction = (rank - below) / count;
|
||||
const lower = row.isUnderflow ? Math.min(0, row.upper) : row.lower;
|
||||
return [
|
||||
{
|
||||
percentile,
|
||||
value: lower + fraction * (row.upper - lower),
|
||||
axisValue: edges[index] + fraction * (edges[index + 1] - edges[index]),
|
||||
isOverflow: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
below += count;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function formatPercentileName(percentile: number): string {
|
||||
return `p${Number(percentile.toFixed(3))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markers that fall on the same spot, sharing one badge: the percentiles landing
|
||||
* in overflow all sit on its edge. Each group is in ascending percentile order.
|
||||
*/
|
||||
export function groupPercentileMarkers(
|
||||
markers: DistributionPercentile[],
|
||||
): DistributionPercentile[][] {
|
||||
const groups = new Map<number, DistributionPercentile[]>();
|
||||
markers.forEach((marker) => {
|
||||
groups.set(marker.axisValue, [
|
||||
...(groups.get(marker.axisValue) ?? []),
|
||||
marker,
|
||||
]);
|
||||
});
|
||||
return [...groups.values()].map((group) =>
|
||||
[...group].sort((a, b) => a.percentile - b.percentile),
|
||||
);
|
||||
}
|
||||
|
||||
/** `p90` for one marker; `p90+` for a group, starting at its lowest percentile. */
|
||||
export function formatPercentileBadge(group: DistributionPercentile[]): string {
|
||||
const name = formatPercentileName(group[0].percentile);
|
||||
return group.length > 1 ? `${name}+` : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* `p99 ≈ 412 ms` for an estimate; `p90, p99 > 5 s` where only the overflow bound is
|
||||
* known. A group shares one spot, so one value and overflow flag.
|
||||
*/
|
||||
export function formatPercentileValue(
|
||||
group: DistributionPercentile[],
|
||||
formatBound: (value: number) => string,
|
||||
): string {
|
||||
const names = group
|
||||
.map(({ percentile }) => formatPercentileName(percentile))
|
||||
.join(', ');
|
||||
const [{ value, isOverflow }] = group;
|
||||
return `${names} ${isOverflow ? '>' : '≈'} ${formatBound(value)}`;
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { DistributionSeries, DistributionXAxis } from './types';
|
||||
|
||||
/** Colours stop being told apart past this many stacked groups. */
|
||||
export const MAX_STACKED_GROUPS = 10;
|
||||
/** Narrowest a side-by-side bar may get, in CSS pixels. */
|
||||
const MIN_SIDE_BY_SIDE_BAR_PX = 3;
|
||||
export const OTHER_GROUP_LABEL = 'Other';
|
||||
|
||||
/**
|
||||
* How many series fit: a fixed cap when stacked, and side by side as many as keep
|
||||
* every bar in the narrowest bucket at least `MIN_SIDE_BY_SIDE_BAR_PX` wide.
|
||||
*/
|
||||
export function resolveMaxDrawnGroups({
|
||||
stack,
|
||||
xAxis,
|
||||
plotWidth,
|
||||
}: {
|
||||
stack: StackMode;
|
||||
xAxis: DistributionXAxis;
|
||||
/** Approximate width of the plotting area, in CSS pixels. */
|
||||
plotWidth: number;
|
||||
}): number {
|
||||
if (stack !== StackMode.None) {
|
||||
return MAX_STACKED_GROUPS;
|
||||
}
|
||||
const span = xAxis.max - xAxis.min;
|
||||
let narrowest = Number.POSITIVE_INFINITY;
|
||||
for (let index = 0; index + 1 < xAxis.edges.length; index += 1) {
|
||||
if (xAxis.edges[index] < xAxis.max) {
|
||||
narrowest = Math.min(narrowest, xAxis.edges[index + 1] - xAxis.edges[index]);
|
||||
}
|
||||
}
|
||||
if (!(span > 0) || !Number.isFinite(narrowest)) {
|
||||
return MAX_STACKED_GROUPS;
|
||||
}
|
||||
const narrowestPx = (plotWidth * narrowest) / span;
|
||||
return Math.min(
|
||||
MAX_STACKED_GROUPS,
|
||||
Math.max(1, Math.floor(narrowestPx / MIN_SIDE_BY_SIDE_BAR_PX)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface DrawnGroups {
|
||||
labels: string[];
|
||||
counts: number[][];
|
||||
colors: string[];
|
||||
/** Each drawn group's index among the source series; `null` for Other. */
|
||||
sourceIndexes: (number | null)[];
|
||||
/** Visible groups summed into the Other series; 0 when none were. */
|
||||
otherGroupCount: number;
|
||||
/** Visible groups in all, drawn or folded. */
|
||||
visibleGroupCount: number;
|
||||
}
|
||||
|
||||
function sum(counts: number[]): number {
|
||||
return counts.reduce((total, count) => total + count, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The visible groups to draw: all of them when they fit, else the largest by total
|
||||
* and one Other series summing the rest. Exact, because bucket counts add. Drawn
|
||||
* groups keep legend order.
|
||||
*/
|
||||
export function groupIntoOther({
|
||||
labels,
|
||||
counts,
|
||||
colors,
|
||||
visible,
|
||||
maxGroups,
|
||||
otherColor,
|
||||
}: {
|
||||
labels: string[];
|
||||
counts: number[][];
|
||||
colors: string[];
|
||||
visible: boolean[];
|
||||
maxGroups: number;
|
||||
otherColor: string;
|
||||
}): DrawnGroups {
|
||||
const visibleIndexes = labels
|
||||
.map((_, index) => index)
|
||||
.filter((index) => visible[index]);
|
||||
|
||||
if (visibleIndexes.length <= maxGroups) {
|
||||
return {
|
||||
labels: visibleIndexes.map((index) => labels[index]),
|
||||
counts: visibleIndexes.map((index) => counts[index]),
|
||||
colors: visibleIndexes.map((index) => colors[index]),
|
||||
sourceIndexes: visibleIndexes,
|
||||
otherGroupCount: 0,
|
||||
visibleGroupCount: visibleIndexes.length,
|
||||
};
|
||||
}
|
||||
|
||||
const keep = new Set(
|
||||
[...visibleIndexes]
|
||||
.sort((a, b) => sum(counts[b]) - sum(counts[a]) || a - b)
|
||||
.slice(0, Math.max(0, maxGroups - 1)),
|
||||
);
|
||||
const kept = visibleIndexes.filter((index) => keep.has(index));
|
||||
const folded = visibleIndexes.filter((index) => !keep.has(index));
|
||||
const bucketCount = Math.max(...folded.map((index) => counts[index].length));
|
||||
const other = Array.from({ length: bucketCount }, (_, bucket) =>
|
||||
folded.reduce((total, index) => total + (counts[index][bucket] ?? 0), 0),
|
||||
);
|
||||
|
||||
return {
|
||||
labels: [...kept.map((index) => labels[index]), OTHER_GROUP_LABEL],
|
||||
counts: [...kept.map((index) => counts[index]), other],
|
||||
colors: [...kept.map((index) => colors[index]), otherColor],
|
||||
sourceIndexes: [...kept, null],
|
||||
otherGroupCount: folded.length,
|
||||
visibleGroupCount: visibleIndexes.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawn group at `drawnIndex` as a source series and its colour; `null` for Other,
|
||||
* which sums several groups no single filter can name.
|
||||
*/
|
||||
export function resolveClickedGroup(
|
||||
drawn: DrawnGroups,
|
||||
drawnIndex: number | null,
|
||||
series: DistributionSeries[],
|
||||
): { series: DistributionSeries; color: string } | null {
|
||||
const source =
|
||||
drawnIndex === null ? null : (drawn.sourceIndexes[drawnIndex] ?? null);
|
||||
if (drawnIndex === null || source === null) {
|
||||
return null;
|
||||
}
|
||||
return { series: series[source], color: drawn.colors[drawnIndex] };
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { VariableWidthBar } from 'lib/uPlotV2/paths/variableWidthBars';
|
||||
import type { HeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
export interface DistributionSeries {
|
||||
label: string;
|
||||
/** Group-by key → value, for drilling into this group. Empty when ungrouped. */
|
||||
groupLabels?: Record<string, string>;
|
||||
/** One count per bucket bound plus the trailing overflow, summed over the range. */
|
||||
counts: number[];
|
||||
}
|
||||
|
||||
/** A click on a group's bar; never on Other, which no filter can name. */
|
||||
export interface DistributionBarClick {
|
||||
series: DistributionSeries;
|
||||
color: string;
|
||||
/** Viewport coordinates of the click. */
|
||||
coordinates: { x: number; y: number };
|
||||
}
|
||||
|
||||
export enum DistributionYScale {
|
||||
Linear = 'linear',
|
||||
Log = 'log',
|
||||
}
|
||||
|
||||
/** The bucket axis in x-scale space. The heatmap's row axis, laid sideways. */
|
||||
export type DistributionXAxis = HeatmapYAxis;
|
||||
|
||||
/** `x` in bucket-axis units, `y` in counts (or percent); the overflow bucket is hatched. */
|
||||
export interface DistributionBarRect extends VariableWidthBar {
|
||||
bucketIndex: number;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { resolveRowIndex } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { type DistributionBarsLayout, resolveNearestSeries } from './layout';
|
||||
import { type DrawnGroups, resolveClickedGroup } from './topGroups';
|
||||
import type {
|
||||
DistributionBarClick,
|
||||
DistributionSeries,
|
||||
DistributionXAxis,
|
||||
} from './types';
|
||||
|
||||
/** Resolves a plot click to the group under it; Other and empty buckets report none. */
|
||||
export function useDistributionBarClick({
|
||||
xAxis,
|
||||
layout,
|
||||
drawn,
|
||||
series,
|
||||
onBarClick,
|
||||
}: {
|
||||
xAxis: DistributionXAxis;
|
||||
layout: DistributionBarsLayout;
|
||||
drawn: DrawnGroups;
|
||||
series: DistributionSeries[];
|
||||
onBarClick?: (click: DistributionBarClick) => void;
|
||||
}): ((data: ChartClickData) => void) | undefined {
|
||||
const onBarClickRef = useRef(onBarClick);
|
||||
onBarClickRef.current = onBarClick;
|
||||
|
||||
const handleClick = useCallback(
|
||||
({
|
||||
xValue,
|
||||
yValue,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
}: ChartClickData): void => {
|
||||
const bucket = resolveRowIndex(xAxis.edges, xValue);
|
||||
const clicked = resolveClickedGroup(
|
||||
drawn,
|
||||
bucket === null
|
||||
? null
|
||||
: resolveNearestSeries(layout, bucket, xValue, yValue),
|
||||
series,
|
||||
);
|
||||
if (clicked) {
|
||||
onBarClickRef.current?.({
|
||||
...clicked,
|
||||
coordinates: { x: absoluteMouseX, y: absoluteMouseY },
|
||||
});
|
||||
}
|
||||
},
|
||||
[xAxis, layout, drawn, series],
|
||||
);
|
||||
|
||||
return onBarClick ? handleClick : undefined;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { DistributionXAxis } from './types';
|
||||
|
||||
export function createBoundFormatter(
|
||||
unit?: string,
|
||||
decimalPrecision?: PrecisionOption,
|
||||
): (value: number) => string {
|
||||
return (value: number): string =>
|
||||
getToolTipValue(String(value), unit, decimalPrecision);
|
||||
}
|
||||
|
||||
/**
|
||||
* `[bucket centres, ...counts per series]`. The series draw from the layout, so the
|
||||
* data only feeds uPlot's cursor and tooltip.
|
||||
*/
|
||||
export function prepareDistributionChartData(
|
||||
xAxis: DistributionXAxis,
|
||||
counts: number[][],
|
||||
): uPlot.AlignedData {
|
||||
const centres = xAxis.rows.map(
|
||||
(_, index) => (xAxis.edges[index] + xAxis.edges[index + 1]) / 2,
|
||||
);
|
||||
return [
|
||||
centres,
|
||||
...counts.map((seriesCounts) =>
|
||||
centres.map((_, index) => seriesCounts[index] ?? 0),
|
||||
),
|
||||
] as unknown as uPlot.AlignedData;
|
||||
}
|
||||
@@ -26,11 +26,6 @@ import {
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import type {
|
||||
DistributionBarClick,
|
||||
DistributionSeries,
|
||||
DistributionYScale,
|
||||
} from 'lib/visualization/charts/Distribution/types';
|
||||
|
||||
interface BaseChartProps {
|
||||
width: number;
|
||||
@@ -172,48 +167,6 @@ export interface HeatmapChartProps {
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-range bucket counts, one series per group, drawn as bars whose widths follow
|
||||
* the bucket bounds. Builds its own config, since `buckets` fully determines the x
|
||||
* axis. `buckets` and `series` must be referentially stable: a new identity rebuilds
|
||||
* the plot.
|
||||
*/
|
||||
export interface DistributionChartProps {
|
||||
/** Widget id; the group selection persists under it. */
|
||||
id: string;
|
||||
/** Ascending. N boundaries describe N+1 buckets, the last one overflow. */
|
||||
buckets: number[];
|
||||
series: DistributionSeries[];
|
||||
width: number;
|
||||
height: number;
|
||||
isDarkMode: boolean;
|
||||
/** Buckets wholly below it are hidden. */
|
||||
xMin?: number;
|
||||
/** Buckets wholly above it are hidden; unset crops trailing empty buckets. */
|
||||
xMax?: number;
|
||||
/** Default linear. Ignored under `percent`, whose axis is a share. */
|
||||
yScale?: DistributionYScale;
|
||||
/** Default normal. `none` draws the groups side by side. */
|
||||
stack?: StackMode;
|
||||
/** Unit of the bucket bounds; counts are always plain numbers. */
|
||||
xAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
/** Hex colours keyed by group label, overriding the automatic palette. */
|
||||
customColors?: Record<string, string>;
|
||||
/** 0–100. Marked across the plot, over the visible groups combined. */
|
||||
percentiles?: readonly number[];
|
||||
/** Fires for a click on a group's bar; not on Other or empty space. */
|
||||
onBarClick?: (click: DistributionBarClick) => void;
|
||||
showLegend?: boolean;
|
||||
legendPosition?: LegendPosition;
|
||||
showTooltip?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
tooltipPortalRoot?: HTMLElement | null;
|
||||
layoutChildren?: React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One resolved pie/donut slice: a display label, its (already parsed) positive
|
||||
* numeric value, and the colour used for the arc + legend swatch.
|
||||
|
||||
@@ -26,7 +26,10 @@ 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';
|
||||
@@ -39,6 +42,7 @@ 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,6 +70,7 @@ function DashboardActions({
|
||||
deleteChecks,
|
||||
deleteDisabledTooltip,
|
||||
} = useDashboardEditContext();
|
||||
const show = useDashboardControlGroup('dashboardActions');
|
||||
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
|
||||
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
@@ -73,6 +78,7 @@ function DashboardActions({
|
||||
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.
|
||||
@@ -162,61 +168,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,66 +240,96 @@ 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">
|
||||
<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}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
{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>
|
||||
)}
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
@@ -298,30 +341,34 @@ function DashboardActions({
|
||||
>
|
||||
<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}>
|
||||
{show.json && (
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={!isEditable}
|
||||
color="secondary"
|
||||
prefix={<Braces size="md" />}
|
||||
testId="edit-json"
|
||||
onClick={handleOpenJsonEditor}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
JSON
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
{show.newPanel && (
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={!isEditable}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
<JsonEditorDrawer
|
||||
dashboard={dashboard}
|
||||
isOpen={isJsonEditorOpen}
|
||||
|
||||
@@ -24,9 +24,11 @@ import { openInNewTab } from 'utils/navigation';
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
|
||||
import TagsOverflowTooltip from './TagsOverflowTooltip';
|
||||
import { getLockTooltip } from './utils';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
|
||||
|
||||
// The tag cluster keeps a fixed footprint so a long title ellipsizes around it
|
||||
// instead of collapsing the tags: show up to two tags, then a `+N` overflow badge.
|
||||
@@ -75,27 +77,19 @@ function DashboardInfo({
|
||||
}: DashboardInfoProps): JSX.Element {
|
||||
const { isEditable: canEdit } = useDashboardEditContext();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const show = useDashboardControlGroup('title');
|
||||
|
||||
const hasTags = tags.length > 0;
|
||||
const hasDescription = !isEmpty(description);
|
||||
const hasTags = show.tags && tags.length > 0;
|
||||
const hasDescription = show.description && !isEmpty(description);
|
||||
|
||||
const visibleTags = tags.slice(0, MAX_VISIBLE_TAGS);
|
||||
const remainingTags = tags.slice(MAX_VISIBLE_TAGS);
|
||||
|
||||
// Without a toggle, say why it can't be toggled rather than only restating the
|
||||
// lock state.
|
||||
let lockTooltip: string;
|
||||
if (onToggleLock) {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'Locked — click to unlock'
|
||||
: 'Unlocked — click to lock';
|
||||
} else if (lockDisabledTooltip) {
|
||||
lockTooltip = lockDisabledTooltip;
|
||||
} else {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'This dashboard is locked'
|
||||
: 'This dashboard is unlocked';
|
||||
}
|
||||
const lockTooltip = getLockTooltip({
|
||||
isLocked: isDashboardLocked,
|
||||
canToggle: !!onToggleLock,
|
||||
disabledReason: lockDisabledTooltip,
|
||||
});
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
@@ -128,66 +122,69 @@ function DashboardInfo({
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardInfo}>
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
|
||||
{isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Save title"
|
||||
testId="dashboard-title-save"
|
||||
onClick={onCommit}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Cancel title edit"
|
||||
testId="dashboard-title-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip title={title}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
})}
|
||||
data-testid="dashboard-title"
|
||||
onClick={canEdit ? onStartEdit : undefined}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<CopyButton
|
||||
value={dashboardId}
|
||||
ariaLabel="Copy dashboard ID"
|
||||
testId="dashboard-copy-id"
|
||||
onCopy={handleCopyId}
|
||||
/>
|
||||
</>
|
||||
{show.name && (
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
)}
|
||||
|
||||
{show.name &&
|
||||
(isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Save title"
|
||||
testId="dashboard-title-save"
|
||||
onClick={onCommit}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Cancel title edit"
|
||||
testId="dashboard-title-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip title={title}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
})}
|
||||
data-testid="dashboard-title"
|
||||
onClick={canEdit ? onStartEdit : undefined}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<CopyButton
|
||||
value={dashboardId}
|
||||
ariaLabel="Copy dashboard ID"
|
||||
testId="dashboard-copy-id"
|
||||
onCopy={handleCopyId}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
|
||||
{hasDescription && (
|
||||
<Tooltip
|
||||
side="bottom"
|
||||
@@ -205,7 +202,7 @@ function DashboardInfo({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isPublicDashboard && (
|
||||
{show.publicBadge && isPublicDashboard && (
|
||||
<Tooltip title="This dashboard is publicly accessible. Click to open the public page.">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -222,7 +219,7 @@ function DashboardInfo({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{showLockToggle && (
|
||||
{show.lock && showLockToggle && (
|
||||
<Tooltip title={onToggleLock ? lockTooltip : undefined}>
|
||||
<Button
|
||||
disabledTooltip={lockTooltip}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Without a toggle, say why it can't be toggled rather than only restating the
|
||||
// lock state.
|
||||
export function getLockTooltip({
|
||||
isLocked,
|
||||
canToggle,
|
||||
disabledReason,
|
||||
}: {
|
||||
isLocked: boolean;
|
||||
canToggle: boolean;
|
||||
disabledReason?: string;
|
||||
}): string {
|
||||
if (canToggle) {
|
||||
return isLocked ? 'Locked — click to unlock' : 'Unlocked — click to lock';
|
||||
}
|
||||
if (disabledReason) {
|
||||
return disabledReason;
|
||||
}
|
||||
return isLocked ? 'This dashboard is locked' : 'This dashboard is unlocked';
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { FullScreenHandle } from 'react-full-screen';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useDashboardLockPermission } from 'hooks/dashboards/useDashboardLockPermission';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import type { DashboardControlsInput } from '../../controls/types';
|
||||
|
||||
import { usePublicDashboardMeta } from '../../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import DashboardPageToolbar from '../index';
|
||||
|
||||
const dashboard = {
|
||||
id: 'dash-1',
|
||||
locked: true,
|
||||
source: 'user',
|
||||
spec: {
|
||||
display: { name: 'Checkout' },
|
||||
panels: {},
|
||||
layouts: [],
|
||||
variables: [],
|
||||
},
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
const handle = { enter: jest.fn() } as unknown as FullScreenHandle;
|
||||
|
||||
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: 'dash-1', 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('dash-1', {
|
||||
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('dash-1', {
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the time range and keeps the variables bar mounted but hidden', () => {
|
||||
renderToolbar({
|
||||
controls: { toolbar: { timeRange: false, variables: false } },
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('time-selection')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('variables-bar')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { useCreatePanel } from '../hooks/useCreatePanel';
|
||||
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
|
||||
import { useDashboardControlGroup } from '../controls/useDashboardControlGroup';
|
||||
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
|
||||
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
@@ -56,6 +57,11 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
[dashboard.tags],
|
||||
);
|
||||
|
||||
const showTitle = useDashboardControlGroup('title');
|
||||
const showActions = useDashboardControlGroup('dashboardActions');
|
||||
const showToolbar = useDashboardControlGroup('toolbar');
|
||||
const hasTitleRow = Object.values(showTitle).some(Boolean);
|
||||
const hasActions = Object.values(showActions).some(Boolean);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const {
|
||||
@@ -71,10 +77,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
useDashboardLockPermission({
|
||||
dashboardId: id,
|
||||
source: dashboard.source,
|
||||
enabled: showTitle.lock || showActions.lock,
|
||||
});
|
||||
|
||||
// Public-sharing meta (deduped react-query read); drives the header globe.
|
||||
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
|
||||
const { isPublic, publicMeta } = usePublicDashboardMeta(id, {
|
||||
enabled: showTitle.publicBadge,
|
||||
});
|
||||
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
|
||||
|
||||
// Shared with the list's row menu — it owns the API call, the toast and the
|
||||
@@ -155,48 +164,66 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<section className={styles.dashboardPageToolbarContainer}>
|
||||
<div className={styles.dashboardInfoWithActions}>
|
||||
<DashboardInfo
|
||||
title={title}
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={isPublic}
|
||||
publicUrl={publicUrl}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
showLockToggle={showLockToggle}
|
||||
onToggleLock={
|
||||
canToggleLock
|
||||
? (): void => handleLockDashboardToggle('header')
|
||||
: undefined
|
||||
}
|
||||
lockDisabledTooltip={lockDisabledTooltip}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onStartEdit={startEdit}
|
||||
onCommit={commit}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
<DashboardActions
|
||||
title={title}
|
||||
dashboard={dashboard}
|
||||
handle={handle}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
onAddPanel={onAddPanel}
|
||||
onLockToggle={(): void => handleLockDashboardToggle('menu')}
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
</div>
|
||||
{(hasTitleRow || hasActions) && (
|
||||
<div className={styles.dashboardInfoWithActions}>
|
||||
{hasTitleRow && (
|
||||
<DashboardInfo
|
||||
title={title}
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={isPublic}
|
||||
publicUrl={publicUrl}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
showLockToggle={showLockToggle}
|
||||
onToggleLock={
|
||||
canToggleLock
|
||||
? (): void => handleLockDashboardToggle('header')
|
||||
: undefined
|
||||
}
|
||||
lockDisabledTooltip={lockDisabledTooltip}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onStartEdit={startEdit}
|
||||
onCommit={commit}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
)}
|
||||
{hasActions && (
|
||||
<DashboardActions
|
||||
title={title}
|
||||
dashboard={dashboard}
|
||||
handle={handle}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
onAddPanel={onAddPanel}
|
||||
onLockToggle={(): void => handleLockDashboardToggle('menu')}
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 2: the time selector floats top-right (declared first so the
|
||||
variables bar's content wraps around it); the variables bar
|
||||
collapses to one line and, when expanded, wraps full-width under it. */}
|
||||
<div className={styles.toolbarRow}>
|
||||
<div className={styles.timeCluster}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
{showToolbar.timeRange && (
|
||||
<div className={styles.timeCluster}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={showToolbar.autoRefresh}
|
||||
hideShareModal
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showToolbar.variables ? (
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
) : (
|
||||
// Still mounted: the selectors seed and auto-select the values panels query with.
|
||||
<div hidden>
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PanelTypeSelectionModal
|
||||
open={isPickerOpen}
|
||||
|
||||
@@ -26,9 +26,13 @@ const PUBLIC_META_STALE_TIME = 5 * 60 * 1000;
|
||||
*/
|
||||
export function usePublicDashboardMeta(
|
||||
dashboardId: string,
|
||||
options?: { enabled?: boolean },
|
||||
): UsePublicDashboardMetaReturn {
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
const enabled = !!dashboardId && (isCloudUser || isEnterpriseSelfHostedUser);
|
||||
const enabled =
|
||||
(options?.enabled ?? true) &&
|
||||
!!dashboardId &&
|
||||
(isCloudUser || isEnterpriseSelfHostedUser);
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch } = useGetPublicDashboard(
|
||||
{ id: dashboardId },
|
||||
|
||||
@@ -2,33 +2,17 @@ import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useContextLinkVariables } from '../useContextLinkVariables';
|
||||
|
||||
const mockUseGetDashboardV2 = jest.fn();
|
||||
const mockUseDashboardVariableNames = jest.fn();
|
||||
const mockUseQueryBuilder = jest.fn();
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { dashboardId: string }) => unknown,
|
||||
): unknown => selector({ dashboardId: 'dash-1' }),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => mockUseQueryBuilder(),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
|
||||
}));
|
||||
|
||||
// dtoToFormModel is exercised by its own suite; here we only need it to surface a name.
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters',
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
|
||||
() => ({
|
||||
dtoToFormModel: (dto: { name: string }): { name: string } => ({
|
||||
name: dto.name,
|
||||
}),
|
||||
useDashboardVariableNames: (): unknown => mockUseDashboardVariableNames(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -45,11 +29,7 @@ describe('useContextLinkVariables', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
mockUseGetDashboardV2.mockReturnValue({
|
||||
data: {
|
||||
data: { spec: { variables: [{ name: 'region' }, { name: 'tier' }] } },
|
||||
},
|
||||
});
|
||||
mockUseDashboardVariableNames.mockReturnValue(['region', 'tier']);
|
||||
});
|
||||
|
||||
it('orders globals, then _prefixed query fields (deduped), then dashboard vars', () => {
|
||||
@@ -69,9 +49,7 @@ describe('useContextLinkVariables', () => {
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { builder: { queryData: [] } },
|
||||
});
|
||||
// Loaded dashboard with no variables — useDashboardFetchRequired guarantees the
|
||||
// dashboard is present, so the empty case is an empty spec, not `undefined`.
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
|
||||
mockUseDashboardVariableNames.mockReturnValue([]);
|
||||
|
||||
const { result } = renderHook(() => useContextLinkVariables());
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { usePanelActionItems } from '../usePanelActionItems';
|
||||
|
||||
/** Keys of the disabled items, in order. */
|
||||
@@ -494,4 +495,43 @@ describe('usePanelActionItems', () => {
|
||||
click(actionRow(result.current.items, 'create-alert'));
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
|
||||
});
|
||||
|
||||
describe('host controls', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('drops every writing action on a read-only mount', () => {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch: jest.fn(),
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'separator',
|
||||
'download',
|
||||
'create-alert',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the actions the host hides', () => {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch: jest.fn(),
|
||||
controls: { panelActions: { createAlert: false, delete: false } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
|
||||
expect(itemKeys(result.current)).not.toContain('create-alert');
|
||||
expect(itemKeys(result.current)).not.toContain('delete-panel');
|
||||
expect(itemKeys(result.current)).toContain('edit-panel');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/t
|
||||
import { blockedBy } from 'lib/authz/components/AuthZDropdown/utils';
|
||||
import { getBlockedPanelActions } from '../utils/getBlockedPanelActions';
|
||||
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControlGroup } from '../../../controls/useDashboardControlGroup';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
// hooks' deps (a fresh [] each render would re-create their callbacks).
|
||||
@@ -66,6 +67,7 @@ export function usePanelActionItems({
|
||||
const blocked = useMemo(() => getBlockedPanelActions(panel), [panel]);
|
||||
const { isEditable, editChecks, editDisabledTooltip } =
|
||||
useDashboardEditContext();
|
||||
const show = useDashboardControlGroup('panelActions');
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { openView } = useViewPanel();
|
||||
@@ -103,7 +105,7 @@ export function usePanelActionItems({
|
||||
const editGate = { checks: editChecks, ...blockedBy(editDisabledTooltip) };
|
||||
|
||||
const panelGroup: AuthZDropdownItemType[] = [];
|
||||
if (panelCapabilities.view) {
|
||||
if (show.view && panelCapabilities.view) {
|
||||
panelGroup.push({
|
||||
type: 'item',
|
||||
value: 'view-panel',
|
||||
@@ -112,7 +114,7 @@ export function usePanelActionItems({
|
||||
onClick: (): void => openView(panelId, panel),
|
||||
});
|
||||
}
|
||||
if (panelCapabilities.edit) {
|
||||
if (show.edit && panelCapabilities.edit) {
|
||||
panelGroup.push({
|
||||
type: 'item',
|
||||
value: 'edit-panel',
|
||||
@@ -122,7 +124,7 @@ export function usePanelActionItems({
|
||||
onClick: (): void => openPanelEditor(panelId, { panel }),
|
||||
});
|
||||
}
|
||||
if (panelCapabilities.clone) {
|
||||
if (show.clone && panelCapabilities.clone) {
|
||||
// Needs section context to place the copy; disabled without it.
|
||||
panelGroup.push({
|
||||
type: 'item',
|
||||
@@ -142,13 +144,13 @@ export function usePanelActionItems({
|
||||
}
|
||||
|
||||
const dataGroup: AuthZDropdownItemType[] = [];
|
||||
if (downloadItem) {
|
||||
if (show.download && downloadItem) {
|
||||
dataGroup.push(downloadItem);
|
||||
}
|
||||
|
||||
// Create Alerts opens a new tab and never mutates the dashboard, so —
|
||||
// unlike edit/clone — it isn't gated on editability (V1 parity).
|
||||
if (panelCapabilities.createAlert) {
|
||||
if (show.createAlert && panelCapabilities.createAlert) {
|
||||
dataGroup.push({
|
||||
type: 'item',
|
||||
value: 'create-alert',
|
||||
@@ -159,8 +161,9 @@ export function usePanelActionItems({
|
||||
});
|
||||
}
|
||||
|
||||
const moveGroup: AuthZDropdownItemType[] =
|
||||
isEditable && panelActions
|
||||
const moveGroup: AuthZDropdownItemType[] = !show.move
|
||||
? []
|
||||
: isEditable && panelActions
|
||||
? buildMoveItems({
|
||||
sections,
|
||||
currentLayoutIndex: panelActions.currentLayoutIndex,
|
||||
@@ -177,17 +180,19 @@ export function usePanelActionItems({
|
||||
},
|
||||
];
|
||||
|
||||
const deleteGroup: AuthZDropdownItemType[] = [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'delete-panel',
|
||||
label: 'Delete panel',
|
||||
prefix: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
...editGate,
|
||||
onClick: (): void => requestDelete(),
|
||||
},
|
||||
];
|
||||
const deleteGroup: AuthZDropdownItemType[] = !show.delete
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'delete-panel',
|
||||
label: 'Delete panel',
|
||||
prefix: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
...editGate,
|
||||
onClick: (): void => requestDelete(),
|
||||
},
|
||||
];
|
||||
|
||||
return [panelGroup, dataGroup, moveGroup, deleteGroup]
|
||||
.filter((group) => group.length > 0)
|
||||
@@ -197,6 +202,7 @@ export function usePanelActionItems({
|
||||
: [{ type: 'separator', value: `separator-${index}` }, ...group],
|
||||
);
|
||||
}, [
|
||||
show,
|
||||
isEditable,
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControl } from '../../../controls/useDashboardControl';
|
||||
|
||||
interface ViewPanelModalHeaderBaseProps {
|
||||
onSwitchToEdit: () => void;
|
||||
@@ -70,6 +71,7 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
} = useDashboardEditContext();
|
||||
const showSwitchToEdit = useDashboardControl('viewModal.switchToEdit');
|
||||
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
|
||||
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
|
||||
const panelTypeItems = usePanelTypeSelectItems({
|
||||
@@ -87,20 +89,22 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
</div>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
disabled={!canSwitchToEdit}
|
||||
onClick={onSwitchToEdit}
|
||||
testId="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
{showSwitchToEdit && (
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
disabled={!canSwitchToEdit}
|
||||
onClick={onSwitchToEdit}
|
||||
testId="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
{query && (
|
||||
<Button
|
||||
size="md"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import ViewPanelModalHeader from '../ViewPanelModalHeader';
|
||||
import type { DashboardControlsInput } from '../../../../controls/types';
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
|
||||
() => ({
|
||||
useDashboardEditContext: (): unknown => ({
|
||||
isEditable: true,
|
||||
editChecks: [],
|
||||
editDisabledTooltip: '',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems',
|
||||
() => ({ usePanelTypeSelectItems: (): unknown[] => [] }),
|
||||
);
|
||||
|
||||
function renderHeader(
|
||||
readOnly: boolean,
|
||||
controls?: DashboardControlsInput,
|
||||
): void {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch: jest.fn(),
|
||||
readOnly,
|
||||
controls,
|
||||
});
|
||||
render(
|
||||
<ViewPanelModalHeader
|
||||
mode="static"
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChangePanelKind={jest.fn()}
|
||||
onSwitchToEdit={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ViewPanelModalHeader - controls', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('offers Switch to Edit Mode on an editable mount', () => {
|
||||
renderHeader(false);
|
||||
|
||||
expect(screen.getByTestId('view-panel-switch-to-edit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Switch to Edit Mode on a read-only mount', () => {
|
||||
renderHeader(true);
|
||||
|
||||
expect(screen.getByTestId('view-panel-type-selector')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('view-panel-switch-to-edit'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Switch to Edit Mode when the host hides it', () => {
|
||||
renderHeader(false, { viewModal: false });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('view-panel-switch-to-edit'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -24,11 +24,16 @@ 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/useOptimisticPatch',
|
||||
() => ({
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import { AllTheProviders, renderHook } from 'tests/test-utils';
|
||||
|
||||
import { CHARTS_ONLY } from '../../../../controls/presets';
|
||||
import type { DashboardControlsInput } from '../../../../controls/types';
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { usePanelInteractions } from '../usePanelInteractions';
|
||||
|
||||
function dragSelect(controls?: DashboardControlsInput): void {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: 'd1', refetch: jest.fn(), controls });
|
||||
const { result } = renderHook(() => usePanelInteractions(), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
result.current.onDragSelect(1_000, 2_000);
|
||||
}
|
||||
|
||||
describe('usePanelInteractions drag-to-zoom', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('writes the dragged range to the URL', () => {
|
||||
dragSelect();
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('startTime=1000&endTime=2000'),
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when the host turns zoom off', () => {
|
||||
dragSelect({ panelActions: { zoom: false } });
|
||||
|
||||
expect(safeNavigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still zooms when the host hides the toolbar but leaves zoom on', () => {
|
||||
dragSelect({ toolbar: false });
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still zooms on a charts-only mount', () => {
|
||||
dragSelect(CHARTS_ONLY);
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import { useDrilldownCoordinates } from './useDrilldownCoordinates';
|
||||
import { useDrilldownDashboardVariables } from './useDrilldownDashboardVariables';
|
||||
import { useDrilldownFilter } from './useDrilldownFilter';
|
||||
import { useResolvedDrilldownQuery } from './useResolvedDrilldownQuery';
|
||||
import { useDashboardControl } from 'pages/DashboardPage/DashboardContainer/controls/useDashboardControl';
|
||||
import { useViewPanel } from './useViewPanel';
|
||||
|
||||
/** Which menu the popover shows; extend as submenus are added (e.g. dashboard variables). */
|
||||
@@ -84,12 +85,14 @@ export function useDrilldown(
|
||||
|
||||
// Drilldown only for builder-authored panels with a query to drill into (PromQL /
|
||||
// ClickHouse have no builder context to refine).
|
||||
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],
|
||||
[showDrilldown, kind, panel, queries],
|
||||
);
|
||||
|
||||
const v1Query = useMemo(
|
||||
|
||||
@@ -13,6 +13,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { useDashboardControl } from 'pages/DashboardPage/DashboardContainer/controls/useDashboardControl';
|
||||
|
||||
export interface PanelInteractions {
|
||||
/** Drag-select a chart range → write it to the URL + global time so every panel re-fetches the same range. */
|
||||
@@ -43,8 +44,12 @@ export function usePanelInteractions(): PanelInteractions {
|
||||
[syncMode, syncFilterMode, dashboardId],
|
||||
);
|
||||
|
||||
const canZoom = useDashboardControl('panelActions.zoom');
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
if (!canZoom) {
|
||||
return;
|
||||
}
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
@@ -57,7 +62,7 @@ export function usePanelInteractions(): PanelInteractions {
|
||||
void logEvent(DashboardDetailEvents.PanelZoomed, { context: 'panel' });
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, safeNavigate, urlQuery],
|
||||
[canZoom, dispatch, pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
return { onDragSelect, dashboardPreference };
|
||||
|
||||
@@ -8,12 +8,12 @@ import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { useCreatePanel } from '../../../hooks/useCreatePanel';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import { useCloneSection } from '../hooks/useCloneSection';
|
||||
import { useDeleteSection } from '../hooks/useDeleteSection';
|
||||
import { usePanelPickerHighlight } from '../hooks/usePanelPickerHighlight';
|
||||
import { usePanelPickerReveal } from '../hooks/usePanelPickerReveal';
|
||||
import { useRenameSection } from '../hooks/useRenameSection';
|
||||
import { useScrollIntoView } from '../hooks/useScrollIntoView';
|
||||
import { useSectionHeaderActions } from '../hooks/useSectionHeaderActions';
|
||||
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
|
||||
import SectionTitleModal from '../SectionTitleModal';
|
||||
import SectionGrid from '../SectionGrid/SectionGrid';
|
||||
@@ -66,7 +66,14 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
setIsDeleteOpen(false);
|
||||
}, [deleteSection]);
|
||||
|
||||
const cloneSection = useCloneSection();
|
||||
const openRename = useCallback((): void => setIsRenaming(true), []);
|
||||
const openDelete = useCallback((): void => setIsDeleteOpen(true), []);
|
||||
const actions = useSectionHeaderActions({
|
||||
section,
|
||||
openPicker,
|
||||
openRename,
|
||||
openDelete,
|
||||
});
|
||||
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
useScrollIntoView(section.id, sectionRef);
|
||||
@@ -116,35 +123,30 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
dragHandle={dragHandle}
|
||||
disabledChecks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
actions={{
|
||||
onRename: (): void => setIsRenaming(true),
|
||||
onAddPanel: (): void => openPicker(section.layoutIndex),
|
||||
onCloneSection: (): void => void cloneSection(section),
|
||||
onDeleteSection: (): void => setIsDeleteOpen(true),
|
||||
}}
|
||||
actions={actions}
|
||||
/>
|
||||
{open &&
|
||||
(section.items.length > 0 || pickerTarget ? (
|
||||
grid
|
||||
) : (
|
||||
<div className={styles.emptySection}>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
size="md"
|
||||
type="button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={
|
||||
isEditable ? (): void => openPicker(section.layoutIndex) : undefined
|
||||
}
|
||||
testId={`section-add-panel-${section.id}`}
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
))}
|
||||
(section.items.length > 0 || pickerTarget
|
||||
? grid
|
||||
: actions.onAddPanel && (
|
||||
<div className={styles.emptySection}>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
size="md"
|
||||
type="button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={
|
||||
isEditable ? (): void => openPicker(section.layoutIndex) : undefined
|
||||
}
|
||||
testId={`section-add-panel-${section.id}`}
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
))}
|
||||
<SectionTitleModal
|
||||
open={isRenaming}
|
||||
heading="Rename section"
|
||||
|
||||
@@ -25,7 +25,7 @@ function SectionActionsMenu({
|
||||
onRename,
|
||||
onCloneSection,
|
||||
onDeleteSection,
|
||||
}: SectionActionsMenuProps): JSX.Element {
|
||||
}: SectionActionsMenuProps): JSX.Element | null {
|
||||
const items = useMemo<AuthZDropdownItemType[]>(() => {
|
||||
const gate = { checks: disabledChecks, ...blockedBy(disabledTooltip) };
|
||||
const result: AuthZDropdownItemType[] = [];
|
||||
@@ -83,6 +83,10 @@ function SectionActionsMenu({
|
||||
onDeleteSection,
|
||||
]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthZDropdown
|
||||
items={items}
|
||||
|
||||
@@ -16,12 +16,12 @@ export interface SectionDragHandle {
|
||||
setActivatorNodeRef: (element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
/** Editable-mode section actions — present together or not at all. */
|
||||
/** Section actions; an absent one isn't offered. */
|
||||
export interface SectionHeaderActions {
|
||||
onRename: () => void;
|
||||
onAddPanel: () => void;
|
||||
onCloneSection: () => void;
|
||||
onDeleteSection: () => void;
|
||||
onRename?: () => void;
|
||||
onAddPanel?: () => void;
|
||||
onCloneSection?: () => void;
|
||||
onDeleteSection?: () => void;
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useDashboardControlGroup } from '../../../controls/useDashboardControlGroup';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { SectionHeaderActions } from '../SectionHeader/SectionHeader';
|
||||
import { useCloneSection } from './useCloneSection';
|
||||
|
||||
/** The section menu's actions, minus the ones the host hides (`sectionActions`). */
|
||||
export function useSectionHeaderActions({
|
||||
section,
|
||||
openPicker,
|
||||
openRename,
|
||||
openDelete,
|
||||
}: {
|
||||
section: DashboardSection;
|
||||
openPicker: (layoutIndex: number) => void;
|
||||
openRename: () => void;
|
||||
openDelete: () => void;
|
||||
}): SectionHeaderActions {
|
||||
const show = useDashboardControlGroup('sectionActions');
|
||||
const cloneSection = useCloneSection();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
onRename: show.rename ? openRename : undefined,
|
||||
onAddPanel: show.addPanel
|
||||
? (): void => openPicker(section.layoutIndex)
|
||||
: undefined,
|
||||
onCloneSection: show.clone
|
||||
? (): void => void cloneSection(section)
|
||||
: undefined,
|
||||
onDeleteSection: show.delete ? openDelete : undefined,
|
||||
}),
|
||||
[show, openRename, openPicker, section, cloneSection, openDelete],
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { resolveDefaultSelection } from './utils/resolveVariableSelection';
|
||||
import VariableSelector from './components/VariableSelector/VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
|
||||
import { useDashboardControl } from '../controls/useDashboardControl';
|
||||
|
||||
interface VariablesBarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
@@ -39,6 +40,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
// Persisted per dashboard so the full/collapsed view survives reloads.
|
||||
const expanded = useDashboardStore(selectVariablesExpanded(dashboardId));
|
||||
const setVariablesExpanded = useDashboardStore((s) => s.setVariablesExpanded);
|
||||
const showAddVariable = useDashboardControl('toolbar.addVariable');
|
||||
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
|
||||
itemCount: variables.length,
|
||||
gap: 8,
|
||||
@@ -49,7 +51,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
|
||||
// Editors can add a variable even before any exist; viewers with no variables
|
||||
// have nothing to show.
|
||||
if (variables.length === 0 && !isEditable) {
|
||||
if (variables.length === 0 && !(isEditable && showAddVariable)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -131,13 +133,15 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
{/* After the more/less trigger, in every state. Kept inline (not block)
|
||||
so the row still flows under the floated time selector, and always
|
||||
mounted so measuring never toggles it. */}
|
||||
<span className={styles.addSlot}>
|
||||
<AddVariableIcon
|
||||
checks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
</span>
|
||||
{showAddVariable && (
|
||||
<span className={styles.addSlot}>
|
||||
<AddVariableIcon
|
||||
checks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import VariablesBar from '../VariablesBar';
|
||||
import type { DashboardControlsInput } from '../../controls/types';
|
||||
|
||||
const dashboard = {
|
||||
id: 'dash-1',
|
||||
spec: { display: { name: 'D' }, panels: {}, layouts: [], variables: [] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
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: 'dash-1',
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
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(): JSX.Element | null {
|
||||
const { data } = useQuery(QUERY_KEY, () =>
|
||||
dashboardResponse({
|
||||
panels: 3,
|
||||
sectioned: true,
|
||||
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) ?? ''}`
|
||||
);
|
||||
}
|
||||
|
||||
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 />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId(/^panel-/).length).toBeGreaterThan(0),
|
||||
);
|
||||
|
||||
const leaked = visibleInteractive()
|
||||
.map(describeElement)
|
||||
.filter(
|
||||
(id) => !ALLOWED_TEST_ID_PREFIXES.some((prefix) => id.startsWith(prefix)),
|
||||
);
|
||||
|
||||
expect(leaked).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
|
||||
import DashboardPageBreadcrumbs from './DashboardPageBreadcrumbs';
|
||||
import { useShareVariablesOption } from './useShareVariablesOption';
|
||||
|
||||
@@ -16,13 +17,16 @@ function DashboardPageHeader({
|
||||
image,
|
||||
}: DashboardPageHeaderProps): JSX.Element {
|
||||
const shareVariablesOption = useShareVariablesOption();
|
||||
const show = useDashboardControlGroup('header');
|
||||
return (
|
||||
<div className={styles.dashboardPageHeader}>
|
||||
<DashboardPageBreadcrumbs title={title} image={image} />
|
||||
{show.breadcrumbs && (
|
||||
<DashboardPageBreadcrumbs title={title} image={image} />
|
||||
)}
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
enableShare={show.share}
|
||||
enableFeedback={show.feedback}
|
||||
shareModalExtraOption={shareVariablesOption}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
CONTROL_WRITES,
|
||||
DEFAULT_CONTROLS,
|
||||
isControlVisible,
|
||||
resolveControls,
|
||||
} from '../dashboardControls';
|
||||
import type { DashboardControlId } from '../types';
|
||||
|
||||
const ALL_IDS = Object.keys(CONTROL_WRITES) as DashboardControlId[];
|
||||
|
||||
describe('resolveControls', () => {
|
||||
it('shows every control when the host passes nothing', () => {
|
||||
expect(resolveControls(undefined, false)).toStrictEqual(DEFAULT_CONTROLS);
|
||||
});
|
||||
|
||||
it('hides a whole group from one boolean', () => {
|
||||
const controls = resolveControls({ header: false }, false);
|
||||
|
||||
expect(controls.header).toStrictEqual({
|
||||
breadcrumbs: false,
|
||||
share: false,
|
||||
feedback: false,
|
||||
});
|
||||
expect(controls.title.name).toBe(true);
|
||||
});
|
||||
|
||||
it('hides single items and keeps the rest of the group', () => {
|
||||
const controls = resolveControls(
|
||||
{ panelActions: { createAlert: false } },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(controls.panelActions.createAlert).toBe(false);
|
||||
expect(controls.panelActions.download).toBe(true);
|
||||
});
|
||||
|
||||
it('hides every writing control on a read-only mount, whatever the host asked', () => {
|
||||
const controls = resolveControls(
|
||||
{ dashboardActions: true, panelActions: { edit: true } },
|
||||
true,
|
||||
);
|
||||
|
||||
ALL_IDS.forEach((id) => {
|
||||
expect([id, isControlVisible(controls, id)]).toStrictEqual([
|
||||
id,
|
||||
!CONTROL_WRITES[id],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('still lets the host hide non-writing controls on a read-only mount', () => {
|
||||
const controls = resolveControls({ panelActions: { download: false } }, true);
|
||||
|
||||
expect(controls.panelActions.download).toBe(false);
|
||||
expect(controls.panelActions.view).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { resolveControls } from '../dashboardControls';
|
||||
import { CHARTS_ONLY, EMBEDDED, withControls } from '../presets';
|
||||
|
||||
describe('withControls', () => {
|
||||
it('adds an override group the preset leaves alone', () => {
|
||||
expect(
|
||||
withControls(EMBEDDED, { toolbar: { autoRefresh: false } }),
|
||||
).toStrictEqual({
|
||||
header: false,
|
||||
title: false,
|
||||
dashboardActions: false,
|
||||
toolbar: { autoRefresh: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('turns single items back on inside a group the preset hides', () => {
|
||||
const merged = withControls(CHARTS_ONLY, { panelActions: { view: true } });
|
||||
const controls = resolveControls(merged, false);
|
||||
|
||||
expect(controls.panelActions.view).toBe(true);
|
||||
expect(controls.panelActions.download).toBe(false);
|
||||
expect(controls.panelActions.drilldown).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a later whole-group boolean replace earlier items', () => {
|
||||
expect(
|
||||
withControls(
|
||||
{ panelActions: { createAlert: false } },
|
||||
{ panelActions: true },
|
||||
),
|
||||
).toStrictEqual({ panelActions: true });
|
||||
});
|
||||
|
||||
it('lets later items win over earlier ones', () => {
|
||||
expect(
|
||||
withControls(
|
||||
{ toolbar: { timeRange: false, autoRefresh: false } },
|
||||
{ toolbar: { timeRange: true } },
|
||||
),
|
||||
).toStrictEqual({ toolbar: { timeRange: true, autoRefresh: false } });
|
||||
});
|
||||
|
||||
it('cannot turn a writing control back on for a read-only mount', () => {
|
||||
const merged = withControls(EMBEDDED, { dashboardActions: { delete: true } });
|
||||
|
||||
expect(resolveControls(merged, true).dashboardActions.delete).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type {
|
||||
DashboardControlGroup,
|
||||
DashboardControlId,
|
||||
DashboardControls,
|
||||
DashboardControlsInput,
|
||||
} from './types';
|
||||
|
||||
export const DEFAULT_CONTROLS: DashboardControls = {
|
||||
header: { breadcrumbs: true, share: true, feedback: true },
|
||||
title: {
|
||||
name: true,
|
||||
description: true,
|
||||
tags: true,
|
||||
lock: true,
|
||||
publicBadge: true,
|
||||
},
|
||||
toolbar: {
|
||||
timeRange: true,
|
||||
autoRefresh: true,
|
||||
variables: true,
|
||||
addVariable: true,
|
||||
},
|
||||
dashboardActions: {
|
||||
fullscreen: true,
|
||||
rename: true,
|
||||
clone: true,
|
||||
lock: true,
|
||||
newSection: true,
|
||||
delete: true,
|
||||
configure: true,
|
||||
json: true,
|
||||
newPanel: true,
|
||||
},
|
||||
sectionActions: { addPanel: true, rename: true, clone: true, delete: true },
|
||||
panelActions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: true,
|
||||
createAlert: true,
|
||||
move: true,
|
||||
delete: true,
|
||||
drilldown: true,
|
||||
zoom: true,
|
||||
},
|
||||
viewModal: { switchToEdit: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether each control writes to the dashboard. A read-only mount hides every
|
||||
* `true` here regardless of what the host asked for. Exhaustive, so a new
|
||||
* control can't ship without deciding.
|
||||
*/
|
||||
export const CONTROL_WRITES: Record<DashboardControlId, boolean> = {
|
||||
'header.breadcrumbs': false,
|
||||
'header.share': false,
|
||||
'header.feedback': false,
|
||||
'title.name': false,
|
||||
'title.description': false,
|
||||
'title.tags': false,
|
||||
'title.lock': true,
|
||||
'title.publicBadge': false,
|
||||
'toolbar.timeRange': false,
|
||||
'toolbar.autoRefresh': false,
|
||||
'toolbar.variables': false,
|
||||
'toolbar.addVariable': true,
|
||||
'dashboardActions.fullscreen': false,
|
||||
'dashboardActions.rename': true,
|
||||
'dashboardActions.clone': true,
|
||||
'dashboardActions.lock': true,
|
||||
'dashboardActions.newSection': true,
|
||||
'dashboardActions.delete': true,
|
||||
'dashboardActions.configure': true,
|
||||
// Opens read-only on a non-editable mount.
|
||||
'dashboardActions.json': false,
|
||||
'dashboardActions.newPanel': true,
|
||||
'sectionActions.addPanel': true,
|
||||
'sectionActions.rename': true,
|
||||
'sectionActions.clone': true,
|
||||
'sectionActions.delete': true,
|
||||
'panelActions.view': false,
|
||||
'panelActions.edit': true,
|
||||
'panelActions.clone': true,
|
||||
'panelActions.download': false,
|
||||
'panelActions.createAlert': false,
|
||||
'panelActions.move': true,
|
||||
'panelActions.delete': true,
|
||||
'panelActions.drilldown': false,
|
||||
'panelActions.zoom': false,
|
||||
'viewModal.switchToEdit': true,
|
||||
};
|
||||
|
||||
function resolveGroup<G extends DashboardControlGroup>(
|
||||
group: G,
|
||||
input: DashboardControlsInput | undefined,
|
||||
readOnly: boolean,
|
||||
): DashboardControls[G] {
|
||||
const defaults: Record<string, boolean> = DEFAULT_CONTROLS[group];
|
||||
const override = input?.[group] as
|
||||
| boolean
|
||||
| Partial<Record<string, boolean>>
|
||||
| undefined;
|
||||
const items = Object.keys(defaults).map((item): [string, boolean] => {
|
||||
const requested =
|
||||
typeof override === 'boolean'
|
||||
? override
|
||||
: (override?.[item] ?? defaults[item]);
|
||||
const writes = CONTROL_WRITES[`${group}.${item}` as DashboardControlId];
|
||||
return [item, requested && !(readOnly && writes)];
|
||||
});
|
||||
return Object.fromEntries(items) as DashboardControls[G];
|
||||
}
|
||||
|
||||
/** Host input over the defaults, then every writing control off when read-only. */
|
||||
export function resolveControls(
|
||||
input: DashboardControlsInput | undefined,
|
||||
readOnly: boolean,
|
||||
): DashboardControls {
|
||||
return {
|
||||
header: resolveGroup('header', input, readOnly),
|
||||
title: resolveGroup('title', input, readOnly),
|
||||
toolbar: resolveGroup('toolbar', input, readOnly),
|
||||
dashboardActions: resolveGroup('dashboardActions', input, readOnly),
|
||||
sectionActions: resolveGroup('sectionActions', input, readOnly),
|
||||
panelActions: resolveGroup('panelActions', input, readOnly),
|
||||
viewModal: resolveGroup('viewModal', input, readOnly),
|
||||
};
|
||||
}
|
||||
|
||||
export function isControlVisible(
|
||||
controls: DashboardControls,
|
||||
id: DashboardControlId,
|
||||
): boolean {
|
||||
const [group, item] = id.split('.') as [DashboardControlGroup, string];
|
||||
return (controls[group] as Record<string, boolean>)[item];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { DEFAULT_CONTROLS } from './dashboardControls';
|
||||
import type {
|
||||
DashboardControlGroup,
|
||||
DashboardControls,
|
||||
DashboardControlsInput,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* The host page owns the header, the title row and the dashboard-level actions.
|
||||
* Layout presets only: `readOnly` stays a separate switch, so no preset can turn
|
||||
* a writing control back on.
|
||||
*/
|
||||
export const EMBEDDED: DashboardControlsInput = {
|
||||
header: false,
|
||||
title: false,
|
||||
dashboardActions: false,
|
||||
};
|
||||
|
||||
/** Embedded, and panels are just charts: no panel menu, no drilldown. Charts still zoom. */
|
||||
export const CHARTS_ONLY: DashboardControlsInput = {
|
||||
...EMBEDDED,
|
||||
panelActions: {
|
||||
view: false,
|
||||
edit: false,
|
||||
clone: false,
|
||||
download: false,
|
||||
createAlert: false,
|
||||
move: false,
|
||||
delete: false,
|
||||
drilldown: false,
|
||||
},
|
||||
};
|
||||
|
||||
type GroupInput<G extends DashboardControlGroup> =
|
||||
| boolean
|
||||
| Partial<DashboardControls[G]>;
|
||||
|
||||
function mergeGroup<G extends DashboardControlGroup>(
|
||||
group: G,
|
||||
base: GroupInput<G> | undefined,
|
||||
next: GroupInput<G> | undefined,
|
||||
): GroupInput<G> | undefined {
|
||||
if (next === undefined) {
|
||||
return base;
|
||||
}
|
||||
if (typeof next === 'boolean' || base === undefined) {
|
||||
return next;
|
||||
}
|
||||
// A whole-group boolean underneath expands to its items before the override.
|
||||
const expanded: Partial<DashboardControls[G]> =
|
||||
typeof base === 'boolean'
|
||||
? (Object.fromEntries(
|
||||
Object.keys(DEFAULT_CONTROLS[group]).map((item) => [item, base]),
|
||||
) as Partial<DashboardControls[G]>)
|
||||
: base;
|
||||
return { ...expanded, ...next };
|
||||
}
|
||||
|
||||
/** Merges presets and overrides; later inputs win, item by item. */
|
||||
export function withControls(
|
||||
...inputs: DashboardControlsInput[]
|
||||
): DashboardControlsInput {
|
||||
const groups = Object.keys(DEFAULT_CONTROLS) as DashboardControlGroup[];
|
||||
return inputs.reduce<DashboardControlsInput>((merged, input) => {
|
||||
const result: DashboardControlsInput = { ...merged };
|
||||
groups.forEach((group) => {
|
||||
const value = mergeGroup(group, merged[group], input[group]);
|
||||
if (value !== undefined) {
|
||||
(result as Record<string, unknown>)[group] = value;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { PanelActionId } from '../PanelsAndSectionsLayout/Panel/PanelActionsMenu/panelActionMeta';
|
||||
|
||||
/** Every control a host can show or hide, grouped by where it renders. */
|
||||
export interface DashboardControls {
|
||||
header: { breadcrumbs: boolean; share: boolean; feedback: boolean };
|
||||
title: {
|
||||
name: boolean;
|
||||
description: boolean;
|
||||
tags: boolean;
|
||||
lock: boolean;
|
||||
publicBadge: boolean;
|
||||
};
|
||||
toolbar: {
|
||||
timeRange: boolean;
|
||||
autoRefresh: boolean;
|
||||
variables: boolean;
|
||||
addVariable: boolean;
|
||||
};
|
||||
dashboardActions: {
|
||||
fullscreen: boolean;
|
||||
rename: boolean;
|
||||
clone: boolean;
|
||||
lock: boolean;
|
||||
newSection: boolean;
|
||||
delete: boolean;
|
||||
configure: boolean;
|
||||
json: boolean;
|
||||
newPanel: boolean;
|
||||
};
|
||||
sectionActions: {
|
||||
addPanel: boolean;
|
||||
rename: boolean;
|
||||
clone: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
// Keyed by the panel menu's own ids, so a new panel action needs a control.
|
||||
// `drilldown` (click) and `zoom` (drag-select a time range) act on the chart itself.
|
||||
panelActions: Record<PanelActionId | 'drilldown' | 'zoom', boolean>;
|
||||
viewModal: { switchToEdit: boolean };
|
||||
}
|
||||
|
||||
export type DashboardControlGroup = keyof DashboardControls;
|
||||
|
||||
export type DashboardControlId = {
|
||||
[
|
||||
G in DashboardControlGroup
|
||||
]: `${G}.${Extract<keyof DashboardControls[G], string>}`;
|
||||
}[DashboardControlGroup];
|
||||
|
||||
/** A whole group as one boolean, or single items; omitted means shown. */
|
||||
export type DashboardControlsInput = {
|
||||
[G in DashboardControlGroup]?: boolean | Partial<DashboardControls[G]>;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { isControlVisible } from './dashboardControls';
|
||||
import type { DashboardControlId } from './types';
|
||||
|
||||
/**
|
||||
* Whether a control renders on this mount: the host's `controls`, with every
|
||||
* writing control off when read-only. Lock and permission state stay with
|
||||
* `useDashboardEditContext`, which decides disabled + reason.
|
||||
*/
|
||||
export function useDashboardControl(id: DashboardControlId): boolean {
|
||||
return useDashboardStore((s) => isControlVisible(s.controls, id));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import type { DashboardControlGroup, DashboardControls } from './types';
|
||||
|
||||
/** Every control in a group, resolved for this mount (see `useDashboardControl`). */
|
||||
export function useDashboardControlGroup<G extends DashboardControlGroup>(
|
||||
group: G,
|
||||
): DashboardControls[G] {
|
||||
return useDashboardStore((s) => s.controls[group]);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
buildDashboardUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useDashboardEditContext } from '../useDashboardEditContext';
|
||||
|
||||
const DASHBOARD_ID = 'dash-1';
|
||||
@@ -44,6 +45,23 @@ describe('useDashboardEditContext - AuthZ', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
useDashboardStore.setState({ readOnly: false });
|
||||
});
|
||||
|
||||
// A host-forced view-only mount outranks every grant.
|
||||
it('is read-only on a read-only mount despite full permissions', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
useDashboardStore.setState({ readOnly: true });
|
||||
|
||||
const { result } = renderGuard();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.editDisabledTooltip).toBe('dashboard_read_only_here'),
|
||||
);
|
||||
expect(result.current.isEditable).toBe(false);
|
||||
expect(result.current.canEditDashboard).toBe(false);
|
||||
expect(result.current.canDeleteDashboard).toBe(false);
|
||||
expect(result.current.deleteDisabledTooltip).toBe('dashboard_read_only_here');
|
||||
});
|
||||
|
||||
describe('permission granted', () => {
|
||||
|
||||
@@ -1,57 +1,115 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { renderHook } from 'tests/test-utils';
|
||||
import {
|
||||
type DashboardtypesGettableDashboardV2DTO,
|
||||
type DashboardtypesVariableDTO,
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind as TextVariableKind,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardQuerySource } from '../../store/slices/editContextSlice';
|
||||
import { useDashboardFetchRequired } from '../useDashboardFetchRequired';
|
||||
|
||||
const mockUseGetDashboardV2 = jest.fn();
|
||||
const QUERY_KEY = ['/api/v2/dashboards/dash-1'];
|
||||
const mockRefetch = jest.fn();
|
||||
let mockSource: DashboardQuerySource | null;
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { dashboardId: string }) => unknown,
|
||||
): unknown => selector({ dashboardId: 'dash-1' }),
|
||||
selector: (s: {
|
||||
dashboardId: string;
|
||||
dashboardQuerySource: DashboardQuerySource | null;
|
||||
refetch: () => void;
|
||||
}) => unknown,
|
||||
): unknown =>
|
||||
selector({
|
||||
dashboardId: 'dash-1',
|
||||
dashboardQuerySource: mockSource,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
|
||||
}));
|
||||
function textVariable(name: string): DashboardtypesVariableDTO {
|
||||
return {
|
||||
kind: TextVariableKind.TextVariable,
|
||||
spec: { name, display: { name }, value: '' },
|
||||
};
|
||||
}
|
||||
|
||||
function dashboardWith(
|
||||
spec: Partial<DashboardtypesGettableDashboardV2DTO['spec']>,
|
||||
): DashboardtypesGettableDashboardV2DTO {
|
||||
return { id: 'dash-1', spec } as DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function renderWithCache(
|
||||
cached?: unknown,
|
||||
): ReturnType<
|
||||
typeof renderHook<ReturnType<typeof useDashboardFetchRequired>, void>
|
||||
> {
|
||||
const queryClient = new QueryClient();
|
||||
if (cached !== undefined) {
|
||||
queryClient.setQueryData(mockSource?.queryKey ?? QUERY_KEY, cached);
|
||||
}
|
||||
return renderHook(() => useDashboardFetchRequired(), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe('useDashboardFetchRequired', () => {
|
||||
it('returns the loaded dashboard and its variables', () => {
|
||||
const dashboard = {
|
||||
spec: { variables: [{ name: 'region' }, { name: 'tier' }] },
|
||||
};
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: { data: dashboard } });
|
||||
beforeEach(() => {
|
||||
mockSource = null;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDashboardFetchRequired());
|
||||
it('reads the dashboards API entry when the host sets no source', () => {
|
||||
const variables = [textVariable('region'), textVariable('tier')];
|
||||
const dashboard = dashboardWith({ variables });
|
||||
|
||||
const { result } = renderWithCache({ data: dashboard, status: 'success' });
|
||||
|
||||
expect(result.current.dashboard).toBe(dashboard);
|
||||
expect(result.current.variables).toStrictEqual([
|
||||
{ name: 'region' },
|
||||
{ name: 'tier' },
|
||||
]);
|
||||
expect(result.current.variables).toBe(variables);
|
||||
expect(result.current.refetch).toBe(mockRefetch);
|
||||
});
|
||||
|
||||
it("reads a host's own cache entry through its select", () => {
|
||||
const systemDashboard = { name: 'ai-o11y-overview', spec: {} };
|
||||
mockSource = {
|
||||
queryKey: ['/api/v2/dashboards/system/ai-o11y-overview'],
|
||||
select: (response: { data: typeof systemDashboard }) =>
|
||||
({
|
||||
...response.data,
|
||||
id: 'system-id',
|
||||
}) as DashboardtypesGettableDashboardV2DTO,
|
||||
};
|
||||
|
||||
const { result } = renderWithCache({ data: systemDashboard });
|
||||
|
||||
expect(result.current.dashboard).toStrictEqual({
|
||||
name: 'ai-o11y-overview',
|
||||
spec: {},
|
||||
id: 'system-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes variables to an empty array when the spec has none', () => {
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
|
||||
|
||||
const { result } = renderHook(() => useDashboardFetchRequired());
|
||||
const { result } = renderWithCache({ data: dashboardWith({}) });
|
||||
|
||||
expect(result.current.variables).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('throws when used outside a loaded dashboard subtree', () => {
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: undefined });
|
||||
|
||||
it('throws when the source entry is empty', () => {
|
||||
// Suppress React's expected render-error logging for this case.
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
expect(() => renderHook(() => useDashboardFetchRequired())).toThrow(
|
||||
/outside a loaded dashboard subtree/,
|
||||
);
|
||||
expect(() => renderWithCache()).toThrow(/outside a loaded dashboard subtree/);
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
@@ -111,3 +111,32 @@ describe('useGetQueryRangeV5 enabled gating', () => {
|
||||
expect(mockQueryRangeV5).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGetQueryRangeV5 host fetch', () => {
|
||||
it('fetches through the host fetch instead of query_range', async () => {
|
||||
mockQueryRangeV5.mockReset();
|
||||
const fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 'success', data: {} } as QueryRangeV5200);
|
||||
const wrapper = ({ children }: PropsWithChildren): JSX.Element => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useGetQueryRangeV5({
|
||||
requestPayload: REQUEST,
|
||||
queryKey: KEY_A,
|
||||
enabled: true,
|
||||
fetch,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||
expect(fetch.mock.calls[0][0]).toBe(REQUEST);
|
||||
expect(mockQueryRangeV5).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,10 +19,12 @@ jest.mock('api/generated/services/dashboard', () => ({
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
|
||||
}));
|
||||
|
||||
let mockReadOnly = false;
|
||||
|
||||
jest.mock('../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: jest.fn(
|
||||
(selector: (s: { dashboardId: string; isEditable: boolean }) => unknown) =>
|
||||
selector({ dashboardId: 'dash-1', isEditable: true }),
|
||||
(selector: (s: { dashboardId: string; readOnly: boolean }) => unknown) =>
|
||||
selector({ dashboardId: 'dash-1', readOnly: mockReadOnly }),
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -35,6 +37,7 @@ const queryClient = {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let captured: { fn: (ops: any) => unknown; options: any };
|
||||
let mockMutateAsync: jest.Mock;
|
||||
|
||||
function dashboardEnvelope(name: string): GetDashboardV2200 {
|
||||
return {
|
||||
@@ -50,10 +53,12 @@ const replaceNameOp = {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockReadOnly = false;
|
||||
mockMutateAsync = jest.fn().mockResolvedValue(undefined);
|
||||
(useQueryClient as jest.Mock).mockReturnValue(queryClient);
|
||||
(useMutation as jest.Mock).mockImplementation((fn, options) => {
|
||||
captured = { fn, options };
|
||||
return { mutateAsync: jest.fn(), isLoading: false };
|
||||
return { mutateAsync: mockMutateAsync, isLoading: false };
|
||||
});
|
||||
renderHook(() => useOptimisticPatch());
|
||||
});
|
||||
@@ -119,4 +124,22 @@ describe('useOptimisticPatch', () => {
|
||||
// No follow-up GET (that could read stale data and flicker the UI back).
|
||||
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('patchAsync rejects without sending on a read-only mount', async () => {
|
||||
mockReadOnly = true;
|
||||
const { result } = renderHook(() => useOptimisticPatch());
|
||||
|
||||
await expect(result.current.patchAsync([replaceNameOp])).rejects.toThrow(
|
||||
'dashboard_read_only_here',
|
||||
);
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('patchAsync sends the ops when the mount is editable', async () => {
|
||||
const { result } = renderHook(() => useOptimisticPatch());
|
||||
|
||||
await result.current.patchAsync([replaceNameOp]);
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith([replaceNameOp]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
} from 'constants/queryCacheTime';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { usePanelQuery } from '../usePanelQuery';
|
||||
import { useGetQueryRangeV5 } from '../useGetQueryRangeV5';
|
||||
|
||||
@@ -598,4 +599,58 @@ describe('usePanelQuery', () => {
|
||||
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a host query runner', () => {
|
||||
const runner = {
|
||||
key: 'public:abc',
|
||||
run: jest.fn().mockResolvedValue({ status: 'success', data: {} }),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: 'd1',
|
||||
refetch: jest.fn(),
|
||||
queryRunner: runner,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('keys the cache by the runner and runs the query through it with the panel id', async () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPABILITIES,
|
||||
}),
|
||||
);
|
||||
const [{ queryKey, fetch, requestPayload }] =
|
||||
mockUseGetQueryRangeV5.mock.calls[0];
|
||||
|
||||
expect(queryKey).toContain('public:abc');
|
||||
await fetch(requestPayload);
|
||||
expect(runner.run).toHaveBeenCalledWith({
|
||||
panelId: 'p1',
|
||||
requestPayload,
|
||||
signal: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('runs on query_range when no host runner is set', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPABILITIES,
|
||||
}),
|
||||
);
|
||||
const [{ fetch }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
|
||||
expect(fetch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useDashboardFetchRequired } from './useDashboardFetchRequired';
|
||||
*/
|
||||
export function useDashboardEditContext(): DashboardEditContext {
|
||||
const { dashboard } = useDashboardFetchRequired();
|
||||
const readOnly = useDashboardStore((s) => s.readOnly);
|
||||
const {
|
||||
canRead,
|
||||
canEdit,
|
||||
@@ -24,12 +25,8 @@ export function useDashboardEditContext(): DashboardEditContext {
|
||||
editChecks,
|
||||
deletePermission,
|
||||
areOtherPermissionsLoading,
|
||||
} = useDashboardPermissions(dashboard.id);
|
||||
} = useDashboardPermissions(dashboard.id, { enabled: !readOnly });
|
||||
const { t } = useTranslation('dashboard');
|
||||
const readOnlyOverride = useDashboardStore(
|
||||
(s) => s.canEditDashboardOverride === false,
|
||||
);
|
||||
|
||||
const deleteChecks = useMemo(() => [deletePermission], [deletePermission]);
|
||||
|
||||
return deriveEditContext({
|
||||
@@ -44,6 +41,6 @@ export function useDashboardEditContext(): DashboardEditContext {
|
||||
locked: t('dashboard_locked'),
|
||||
readOnly: t('dashboard_read_only_here'),
|
||||
},
|
||||
readOnlyOverride,
|
||||
readOnlyOverride: readOnly,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,34 +1,52 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { getGetDashboardV2QueryKey } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesVariableDTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
|
||||
|
||||
import { useDashboardFetch } from './useDashboardFetch';
|
||||
import type { UseDashboardFetchResult } from './useDashboardFetch';
|
||||
|
||||
export interface UseDashboardFetchRequiredResult {
|
||||
/** Guaranteed present — the hook throws otherwise. */
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
/** Always an array, so callers skip `?? []` guards. */
|
||||
variables: DashboardtypesVariableDTO[];
|
||||
refetch: UseDashboardFetchResult['refetch'];
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
const selectDashboardV2 = (
|
||||
response: GetDashboardV2200,
|
||||
): DashboardtypesGettableDashboardV2DTO => response.data;
|
||||
|
||||
/**
|
||||
* Dashboard accessor for components rendered inside a loaded dashboard subtree. The
|
||||
* root pages own the fetch and gate on a resolved dashboard before mounting their
|
||||
* subtree, so this reuses the shared react-query cache entry and throws if the
|
||||
* dashboard is missing — turning a silent `undefined` into a loud programmer error. It
|
||||
* also derives spec collections (`variables`) since the dashboard is guaranteed here.
|
||||
* root owns the fetch and publishes where it cached the dashboard
|
||||
* (`dashboardQuerySource`); this observes that entry without fetching, and throws if
|
||||
* it's empty — turning a silent `undefined` into a loud programmer error. It also
|
||||
* derives spec collections (`variables`) since the dashboard is guaranteed here.
|
||||
*
|
||||
* Prefer this over `useDashboardFetch` outside the two root pages; the
|
||||
* `signoz/no-dashboard-fetch-outside-root` lint rule enforces the split.
|
||||
*/
|
||||
export function useDashboardFetchRequired(): UseDashboardFetchRequiredResult {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const { dashboard, refetch } = useDashboardFetch(dashboardId);
|
||||
const source = useDashboardStore((s) => s.dashboardQuerySource);
|
||||
const queryKey =
|
||||
source?.queryKey ?? getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
const select = source?.select ?? selectDashboardV2;
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
// Observe only: the root owns the request, so this never fetches.
|
||||
const { data: dashboard } = useQuery<
|
||||
unknown,
|
||||
unknown,
|
||||
DashboardtypesGettableDashboardV2DTO | undefined
|
||||
>({
|
||||
queryKey,
|
||||
select: select as (response: unknown) => DashboardtypesGettableDashboardV2DTO,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const variables = useMemo(
|
||||
() => dashboard?.spec?.variables ?? [],
|
||||
@@ -39,7 +57,7 @@ export function useDashboardFetchRequired(): UseDashboardFetchRequiredResult {
|
||||
throw new Error(
|
||||
'useDashboardFetchRequired was used outside a loaded dashboard subtree — the ' +
|
||||
'dashboard is undefined. Only call it below a root page that gates rendering ' +
|
||||
'on useDashboardFetch (DashboardPage / PanelEditorPage).',
|
||||
'on its fetch and seeds the edit context (DashboardContainer / PanelEditorPage).',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ export interface UseGetQueryRangeV5Args {
|
||||
keepPreviousData?: boolean;
|
||||
/** Unused-entry TTL; callers drop to 0 under auto-refresh to bound cache growth (V1 parity). */
|
||||
cacheTime?: number;
|
||||
/** Replaces the `query_range` call; the caller keys the cache for it. */
|
||||
fetch?: (
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<QueryRangeV5200>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +54,7 @@ export function useGetQueryRangeV5({
|
||||
enabled,
|
||||
keepPreviousData,
|
||||
cacheTime,
|
||||
fetch,
|
||||
}: UseGetQueryRangeV5Args): UseQueryResult<QueryRangeV5200, Error> {
|
||||
const queryClient = useQueryClient();
|
||||
// An errored key has no data, so it is always stale; keep it enabled so
|
||||
@@ -57,7 +63,7 @@ export function useGetQueryRangeV5({
|
||||
|
||||
return useQuery<QueryRangeV5200, Error>({
|
||||
queryKey,
|
||||
queryFn: ({ signal }) => queryRangeV5(requestPayload, signal),
|
||||
queryFn: ({ signal }) => (fetch ?? queryRangeV5)(requestPayload, signal),
|
||||
enabled: enabled || hasErrored,
|
||||
retry: retryUnlessClientError,
|
||||
keepPreviousData,
|
||||
|
||||
@@ -39,6 +39,7 @@ export function useOptimisticPatch(
|
||||
const storeDashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const dashboardId = dashboardIdOverride ?? storeDashboardId;
|
||||
const { t } = useTranslation('dashboard');
|
||||
const readOnly = useDashboardStore((s) => s.readOnly);
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
|
||||
@@ -78,6 +79,10 @@ export function useOptimisticPatch(
|
||||
const { mutateAsync } = mutation;
|
||||
const patchAsync = useCallback(
|
||||
(ops: DashboardtypesJSONPatchOperationDTO[]): Promise<unknown> => {
|
||||
// The UI renders no edit affordance on a read-only mount, so reaching here is a bug.
|
||||
if (readOnly) {
|
||||
return Promise.reject(new Error(t('dashboard_read_only_here')));
|
||||
}
|
||||
// Defense-in-depth against a lock, read straight from the cache this hook
|
||||
// already owns. Permission is enforced by the UI and, definitively, by the
|
||||
// backend — checking it here would drag authz into every mutation.
|
||||
@@ -87,7 +92,7 @@ export function useOptimisticPatch(
|
||||
}
|
||||
return mutateAsync(ops);
|
||||
},
|
||||
[queryClient, queryKey, mutateAsync],
|
||||
[readOnly, queryClient, queryKey, mutateAsync, t],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
@@ -126,6 +130,7 @@ export function usePanelQuery({
|
||||
// use it (V1 parity). Names come from the fetch context (all variables, even
|
||||
// unresolved ones); null before the variable bar initializes it.
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const queryRunner = useDashboardStore((s) => s.queryRunner);
|
||||
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
|
||||
const fetchContext = useDashboardStore((s) => s.variableFetchContext);
|
||||
|
||||
@@ -233,6 +238,7 @@ export function usePanelQuery({
|
||||
// Only the variables this panel references re-key the cache, so an unrelated
|
||||
// variable change doesn't refetch it.
|
||||
scopedVariables,
|
||||
queryRunner?.key ?? null,
|
||||
],
|
||||
[
|
||||
panelId,
|
||||
@@ -249,10 +255,24 @@ export function usePanelQuery({
|
||||
offset,
|
||||
pageSize,
|
||||
scopedVariables,
|
||||
queryRunner?.key,
|
||||
],
|
||||
);
|
||||
|
||||
const fetch = useMemo(
|
||||
() =>
|
||||
queryRunner
|
||||
? (
|
||||
payload: Querybuildertypesv5QueryRangeRequestDTO,
|
||||
signal?: AbortSignal,
|
||||
): Promise<QueryRangeV5200> =>
|
||||
queryRunner.run({ panelId, requestPayload: payload, signal })
|
||||
: undefined,
|
||||
[queryRunner, panelId],
|
||||
);
|
||||
|
||||
const response = useGetQueryRangeV5({
|
||||
fetch,
|
||||
requestPayload,
|
||||
queryKey,
|
||||
enabled: enabled && runnable && !isWaitingOnVariable,
|
||||
|
||||
@@ -9,6 +9,12 @@ import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
|
||||
import { useResolvedVariables } from './hooks/useResolvedVariables';
|
||||
import { useSyncVariablesForSuggestions } from './hooks/useSyncVariablesForSuggestions';
|
||||
import { useDashboardStore } from './store/useDashboardStore';
|
||||
import type {
|
||||
DashboardQuerySource,
|
||||
QueryRunner,
|
||||
} from './store/slices/editContextSlice';
|
||||
import type { DashboardControlsInput } from './controls/types';
|
||||
import { useDashboardControlGroup } from './controls/useDashboardControlGroup';
|
||||
import styles from './DashboardContainer.module.scss';
|
||||
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
|
||||
import LockedIndicator from './components/LockedIndicator/LockedIndicator';
|
||||
@@ -19,20 +25,23 @@ import { resolveDashboardImage } from 'pages/DashboardPage/DashboardContainer/da
|
||||
interface DashboardContainerProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
refetch: () => void;
|
||||
/**
|
||||
* @deprecated
|
||||
* `canEditDashboardOverride` is a temporary solution to allow the dashboard to be view only.
|
||||
* This is only used for LLM Observability.
|
||||
* It will be removed in the future.
|
||||
* TODO: @Ashwin / @Abhi — remove when the final solution is implemented.
|
||||
*/
|
||||
canEditDashboardOverride?: boolean;
|
||||
/** Where the host cached `dashboard`; defaults to the dashboards API entry. */
|
||||
source?: DashboardQuerySource;
|
||||
/** View-only mount: no edit affordance, no write, no server freshness probe. */
|
||||
readOnly?: boolean;
|
||||
/** What renders; omitted controls are shown. Writing controls are off when read-only. */
|
||||
controls?: DashboardControlsInput;
|
||||
/** Runs panel queries; omitted runs them on `query_range`. */
|
||||
queryRunner?: QueryRunner;
|
||||
}
|
||||
|
||||
function DashboardContainer({
|
||||
dashboard,
|
||||
refetch,
|
||||
canEditDashboardOverride,
|
||||
source,
|
||||
readOnly = false,
|
||||
controls,
|
||||
queryRunner,
|
||||
}: DashboardContainerProps): JSX.Element {
|
||||
const spec = dashboard.spec;
|
||||
const image = resolveDashboardImage(dashboard.image);
|
||||
@@ -55,7 +64,10 @@ function DashboardContainer({
|
||||
setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
refetch,
|
||||
canEditDashboardOverride,
|
||||
source,
|
||||
readOnly,
|
||||
controls,
|
||||
queryRunner,
|
||||
});
|
||||
|
||||
// Resolve the variable selection into the V5 query payload and publish it to
|
||||
@@ -66,7 +78,10 @@ function DashboardContainer({
|
||||
// suggests them ($variable) in the panel editor and dashboards-page builder.
|
||||
useSyncVariablesForSuggestions(dashboard);
|
||||
|
||||
const staleCheck = useDashboardStaleCheck(dashboard, refetch);
|
||||
const staleCheck = useDashboardStaleCheck(dashboard, refetch, !readOnly);
|
||||
const showHeader = Object.values(useDashboardControlGroup('header')).some(
|
||||
Boolean,
|
||||
);
|
||||
|
||||
// In full screen show only the sections and panels — the header/toolbar chrome
|
||||
// is hidden for a clean presentation view (exit with Esc).
|
||||
@@ -78,12 +93,12 @@ function DashboardContainer({
|
||||
<AutoRefreshTicker />
|
||||
) : (
|
||||
<>
|
||||
<DashboardPageHeader title={name} image={image} />
|
||||
{showHeader && <DashboardPageHeader title={name} image={image} />}
|
||||
<DashboardPageToolbar dashboard={dashboard} handle={fullScreenHandle} />
|
||||
</>
|
||||
)}
|
||||
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
|
||||
{!!dashboard.locked && <LockedIndicator />}
|
||||
{!!dashboard.locked && !readOnly && <LockedIndicator />}
|
||||
<DashboardChangedDialog
|
||||
open={staleCheck.showPrompt}
|
||||
onReload={staleCheck.reload}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useDashboardStore } from '../../useDashboardStore';
|
||||
import type { DashboardQuerySource } from '../editContextSlice';
|
||||
|
||||
const refetch = (): void => undefined;
|
||||
|
||||
const systemSource: DashboardQuerySource = {
|
||||
queryKey: ['/api/v2/dashboards/system/ai-o11y-overview'],
|
||||
select: (response: { data: DashboardtypesGettableDashboardV2DTO }) =>
|
||||
response.data,
|
||||
};
|
||||
|
||||
function store(): ReturnType<typeof useDashboardStore.getState> {
|
||||
return useDashboardStore.getState();
|
||||
}
|
||||
|
||||
describe('editContextSlice.setEditContext', () => {
|
||||
beforeEach(() => {
|
||||
store().setEditContext({ dashboardId: '', refetch });
|
||||
});
|
||||
|
||||
it('defaults to the dashboards API entry and an editable mount', () => {
|
||||
store().setEditContext({ dashboardId: 'dash-1', refetch });
|
||||
|
||||
expect(store().dashboardQuerySource).toBeNull();
|
||||
expect(store().readOnly).toBe(false);
|
||||
});
|
||||
|
||||
it("stores a host's source and read-only flag", () => {
|
||||
store().setEditContext({
|
||||
dashboardId: 'system',
|
||||
refetch,
|
||||
source: systemSource,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
expect(store().dashboardQuerySource).toBe(systemSource);
|
||||
expect(store().readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('clears read-only when the next mount does not ask for it', () => {
|
||||
store().setEditContext({
|
||||
dashboardId: 'system',
|
||||
refetch,
|
||||
source: systemSource,
|
||||
readOnly: true,
|
||||
});
|
||||
store().setEditContext({ dashboardId: 'dash-1', refetch });
|
||||
|
||||
expect(store().readOnly).toBe(false);
|
||||
expect(store().dashboardQuerySource).toBeNull();
|
||||
});
|
||||
|
||||
// Called during render: a set on every call would re-render subscribers in a loop.
|
||||
it('is a no-op when called again with equal values', () => {
|
||||
store().setEditContext({ dashboardId: 'dash-1', refetch });
|
||||
const listener = jest.fn();
|
||||
const unsubscribe = useDashboardStore.subscribe(listener);
|
||||
|
||||
store().setEditContext({ dashboardId: 'dash-1', refetch });
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('is a no-op for an equal inline controls object', () => {
|
||||
store().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch,
|
||||
controls: { header: false },
|
||||
});
|
||||
const listener = jest.fn();
|
||||
const unsubscribe = useDashboardStore.subscribe(listener);
|
||||
|
||||
store().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch,
|
||||
controls: { header: false },
|
||||
});
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(store().controls.header.share).toBe(false);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('resolves controls against read-only', () => {
|
||||
store().setEditContext({ dashboardId: 'dash-1', refetch, readOnly: true });
|
||||
|
||||
expect(store().controls.panelActions.edit).toBe(false);
|
||||
expect(store().controls.panelActions.view).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a runner with the same key as unchanged, a new key as a change', () => {
|
||||
const run = jest.fn();
|
||||
store().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch,
|
||||
queryRunner: { key: 'public:a', run },
|
||||
});
|
||||
const listener = jest.fn();
|
||||
const unsubscribe = useDashboardStore.subscribe(listener);
|
||||
|
||||
store().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch,
|
||||
queryRunner: { key: 'public:a', run: jest.fn() },
|
||||
});
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
store().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch,
|
||||
queryRunner: { key: 'public:b', run },
|
||||
});
|
||||
expect(store().queryRunner?.key).toBe('public:b');
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,65 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
import type { QueryKey } from 'react-query';
|
||||
import type { StateCreator } from 'zustand';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { resolveControls } from '../../controls/dashboardControls';
|
||||
import type {
|
||||
DashboardControls,
|
||||
DashboardControlsInput,
|
||||
} from '../../controls/types';
|
||||
import type { DashboardStore } from '../useDashboardStore';
|
||||
|
||||
/**
|
||||
* The one piece of page context the subtree can't derive for itself: which
|
||||
* dashboard is open, and how to refetch it. Editability is deliberately absent —
|
||||
* `useDashboardEditContext` derives it from the caches.
|
||||
* The react-query entry a host loaded the dashboard into, and how to read the
|
||||
* dashboard out of it. Must be referentially stable (module-level).
|
||||
*/
|
||||
export interface DashboardQuerySource {
|
||||
queryKey: QueryKey;
|
||||
// `never` accepts any host's response type; the one caller casts at the boundary.
|
||||
select: (response: never) => DashboardtypesGettableDashboardV2DTO | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a panel's query, when not via `POST /api/v5/query_range`. `key` joins
|
||||
* every panel cache key, so results from different runners never mix.
|
||||
*/
|
||||
export interface QueryRunner {
|
||||
key: string;
|
||||
run: (request: {
|
||||
panelId: string;
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO;
|
||||
signal?: AbortSignal;
|
||||
}) => Promise<QueryRangeV5200>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page context the subtree can't derive for itself: which dashboard is open,
|
||||
* where its cached copy lives, how to refetch it, and whether the host forces it
|
||||
* view-only. Editability is deliberately absent — `useDashboardEditContext`
|
||||
* derives it from the caches.
|
||||
*/
|
||||
export interface EditContextSlice {
|
||||
dashboardId: string;
|
||||
/** `null` reads the dashboards API entry for `dashboardId`. */
|
||||
dashboardQuerySource: DashboardQuerySource | null;
|
||||
refetch: () => void;
|
||||
/**
|
||||
* @deprecated Forces a view-only mount regardless of permissions. Used only by
|
||||
* LLM Observability; see SigNoz/pulse-pod#283.
|
||||
*/
|
||||
canEditDashboardOverride?: boolean;
|
||||
readOnly: boolean;
|
||||
/** Resolved: the host's `controls` over the defaults, writing ones off when read-only. */
|
||||
controls: DashboardControls;
|
||||
/** `null` runs panel queries on `query_range`. */
|
||||
queryRunner: QueryRunner | null;
|
||||
setEditContext: (ctx: {
|
||||
dashboardId: string;
|
||||
refetch: () => void;
|
||||
canEditDashboardOverride?: boolean;
|
||||
source?: DashboardQuerySource;
|
||||
readOnly?: boolean;
|
||||
controls?: DashboardControlsInput;
|
||||
queryRunner?: QueryRunner;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
@@ -29,22 +70,37 @@ export const createEditContextSlice: StateCreator<
|
||||
EditContextSlice
|
||||
> = (set, get) => ({
|
||||
dashboardId: '',
|
||||
dashboardQuerySource: null,
|
||||
refetch: (): void => undefined,
|
||||
canEditDashboardOverride: undefined,
|
||||
readOnly: false,
|
||||
controls: resolveControls(undefined, false),
|
||||
queryRunner: null,
|
||||
// Idempotent (no-op when unchanged) so it's safe to call during render.
|
||||
setEditContext: (ctx): void => {
|
||||
const prev = get();
|
||||
const source = ctx.source ?? null;
|
||||
const readOnly = ctx.readOnly ?? false;
|
||||
// Hosts pass `controls` inline, so compare by value, not reference.
|
||||
const controls = resolveControls(ctx.controls, readOnly);
|
||||
const queryRunner = ctx.queryRunner ?? null;
|
||||
if (
|
||||
prev.dashboardId === ctx.dashboardId &&
|
||||
prev.refetch === ctx.refetch &&
|
||||
prev.canEditDashboardOverride === ctx.canEditDashboardOverride
|
||||
prev.readOnly === readOnly &&
|
||||
prev.dashboardQuerySource === source &&
|
||||
isEqual(prev.controls, controls) &&
|
||||
// The key identifies a runner, so an inline one doesn't re-seed on every render.
|
||||
prev.queryRunner?.key === queryRunner?.key
|
||||
) {
|
||||
return;
|
||||
}
|
||||
set({
|
||||
dashboardId: ctx.dashboardId,
|
||||
dashboardQuerySource: source,
|
||||
refetch: ctx.refetch,
|
||||
canEditDashboardOverride: ctx.canEditDashboardOverride,
|
||||
readOnly,
|
||||
controls,
|
||||
queryRunner,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { rest } from 'msw';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
@@ -16,15 +15,12 @@ import {
|
||||
LLM_MODELS,
|
||||
LLM_SERVICES,
|
||||
llmPanelResponse,
|
||||
llmSystemDashboardResponse,
|
||||
} from './__story_mockdata__/llmOverview';
|
||||
|
||||
const PANELS = 'AI overview · panels';
|
||||
|
||||
/**
|
||||
* The dashboard itself is bundled with the app rather than fetched, so the only
|
||||
* thing left to answer is the panels, the variables and the share state its
|
||||
* header reads.
|
||||
*/
|
||||
/** The system dashboard definition, its panels and its variables. */
|
||||
export const llmOverviewMocks = defineStoryMocks({
|
||||
controls: {
|
||||
series: countControl('Breakdown series', {
|
||||
@@ -36,6 +32,10 @@ export const llmOverviewMocks = defineStoryMocks({
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v2/dashboards/system/ai-o11y-overview',
|
||||
response.json(() => llmSystemDashboardResponse()),
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) =>
|
||||
@@ -58,22 +58,6 @@ export const llmOverviewMocks = defineStoryMocks({
|
||||
};
|
||||
}),
|
||||
),
|
||||
// The header reads the share state on every load, so it answers even while
|
||||
// the panels are held in the loading or failed state.
|
||||
rest.get('http://localhost/api/v1/dashboards/:id/public', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(StatusCodes.NOT_FOUND),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'public_dashboard_not_found',
|
||||
message: "the AI observability overview isn't public",
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: () => ({ route: ROUTES.AI_OBSERVABILITY_OVERVIEW }),
|
||||
});
|
||||
|
||||
@@ -11,8 +11,8 @@ type LLMOverviewArgs = PageStoryArgs<typeof llmOverviewMocks>;
|
||||
const pageStory = storyMocks(llmOverviewMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The AI observability overview: cost, token and latency panels over
|
||||
* `query_range`.
|
||||
* The AI observability overview: the read-only system dashboard, with cost, token
|
||||
* and latency panels over `query_range`.
|
||||
*
|
||||
* Route: `/ai-observability/overview`.
|
||||
*/
|
||||
@@ -20,8 +20,7 @@ const meta = {
|
||||
title: 'Pages/AI Observability/Overview',
|
||||
component: LLMObservabilityPage,
|
||||
...pageStory,
|
||||
// The header's public-dashboard lookup is expected to 404, so every story opts out.
|
||||
parameters: { ...pageStory.parameters, allowConsoleErrors: true },
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<LLMOverviewArgs>;
|
||||
|
||||
export default meta;
|
||||
@@ -29,7 +28,7 @@ export default meta;
|
||||
type Story = StoryObj<LLMOverviewArgs>;
|
||||
|
||||
/**
|
||||
* The bundled overview dashboard: spend, tokens, latency and errors across
|
||||
* The system overview dashboard: spend, tokens, latency and errors across
|
||||
* every model the workspace calls, scoped by the model, environment and
|
||||
* service variables above it.
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
DashboardtypesSourceDTO,
|
||||
type DashboardtypesGettableSystemDashboardDTO,
|
||||
type GetSystemDashboard200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
// The backend's own definition, so the story renders what the server serves.
|
||||
import aiOverviewDefinition from '../../../../../../../pkg/modules/dashboard/impldashboard/fs/definitions/ai-o11y-overview.json';
|
||||
import type {
|
||||
MetricRangePayloadV5,
|
||||
QueryRangeRequestV5,
|
||||
@@ -161,3 +168,19 @@ export const llmPanelResponse = (
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
export function llmSystemDashboardResponse(): GetSystemDashboard200 {
|
||||
const { definition } = aiOverviewDefinition;
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
orgId: 'storybook-org',
|
||||
locked: true,
|
||||
source: DashboardtypesSourceDTO.system,
|
||||
name: definition.name,
|
||||
schemaVersion: definition.schemaVersion,
|
||||
tags: [],
|
||||
spec: definition.spec as DashboardtypesGettableSystemDashboardDTO['spec'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
METER_VOLUMES,
|
||||
meterAttributeKeys,
|
||||
meterExplorerQuery,
|
||||
meterSystemDashboardResponse,
|
||||
meterFieldKeys,
|
||||
meterFieldValues,
|
||||
meterMetricSignal,
|
||||
@@ -179,6 +180,10 @@ export const meterMocks = defineStoryMocks({
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v2/dashboards/system/cost-meter-breakdown',
|
||||
response.json(meterSystemDashboardResponse),
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => {
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type {
|
||||
GetQuickFilters200,
|
||||
ListMetrics200,
|
||||
ListSavedViews200,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
type GetQuickFilters200,
|
||||
type GetSystemDashboard200,
|
||||
type ListMetrics200,
|
||||
type ListSavedViews200,
|
||||
type TelemetrytypesTelemetryFieldKeyDTO,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal as MetricsSignal,
|
||||
@@ -22,16 +21,15 @@ import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
DashboardtypesSourceDTO,
|
||||
type DashboardtypesGettableSystemDashboardDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
// The backend's own definition, so the story renders what the server serves.
|
||||
import costMeterDefinition from '../../../../../../pkg/modules/dashboard/impldashboard/fs/definitions/cost-meter-breakdown.json';
|
||||
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import {
|
||||
getLogCountWidgetData,
|
||||
getLogSizeWidgetData,
|
||||
getMetricCountWidgetData,
|
||||
getSpanCountWidgetData,
|
||||
getSpanSizeWidgetData,
|
||||
} from 'container/MeterExplorer/Breakdown/graphs';
|
||||
import type { Widgets } from 'types/api/widgets/widget';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import type { TimeSeries } from 'types/api/v5/queryRange';
|
||||
|
||||
@@ -248,20 +246,66 @@ export const meterTimeWindow = (range: MeterTimeRange): MeterTimeWindow => {
|
||||
return relativeWindow(24 * HOUR_IN_MS, '1d');
|
||||
};
|
||||
|
||||
const EXPLORER_WIDGETS: Record<MeterVolume, () => Widgets> = {
|
||||
'log-count': getLogCountWidgetData,
|
||||
'log-size': getLogSizeWidgetData,
|
||||
'span-count': getSpanCountWidgetData,
|
||||
'span-size': getSpanSizeWidgetData,
|
||||
'metric-datapoints': getMetricCountWidgetData,
|
||||
/** The query the explorer starts on: the metric's hourly increase, summed. */
|
||||
export const meterExplorerQuery = (volume: MeterVolume): Query => {
|
||||
const metricName = VOLUME_METRICS[volume];
|
||||
|
||||
return {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
id: `meter-explorer-${volume}`,
|
||||
builder: {
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: metricName,
|
||||
id: `${metricName}--float64--Sum--true`,
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: METRICS[metricName].unit === 'bytes' ? 'size' : 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.SUM,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The query the explorer starts on, taken from the breakdown widget for the
|
||||
* same metric so the story asks for what the page itself would build.
|
||||
*/
|
||||
export const meterExplorerQuery = (volume: MeterVolume): Query =>
|
||||
EXPLORER_WIDGETS[volume]().query;
|
||||
/** The breakdown tab's system dashboard, as `GET /api/v2/dashboards/system/:name` serves it. */
|
||||
export const meterSystemDashboardResponse = (): GetSystemDashboard200 => {
|
||||
const { definition } = costMeterDefinition;
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
orgId: 'storybook-org',
|
||||
locked: true,
|
||||
source: DashboardtypesSourceDTO.system,
|
||||
name: definition.name,
|
||||
schemaVersion: definition.schemaVersion,
|
||||
tags: [],
|
||||
spec: definition.spec as DashboardtypesGettableSystemDashboardDTO['spec'],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** The metric picker in the query builder lists what the meter reports. */
|
||||
export const meterMetricsListResponse = (
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Mirrors `.refresh-actions` from DateTimeSelectionV2: one bordered, rounded container
|
||||
// holding borderless buttons split by a divider.
|
||||
.refreshActions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
|
||||
.refreshButton {
|
||||
display: flex;
|
||||
border-right: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
:global(.ant-btn) {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
align-items: center;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Check, ChevronDown, RefreshCw } from '@signozhq/icons';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Button, Popover } from 'antd';
|
||||
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import styles from './PublicAutoRefresh.module.scss';
|
||||
|
||||
// Reuse the app-wide auto-refresh popover styling.
|
||||
import 'container/TopNav/AutoRefreshV2/AutoRefreshV2.styles.scss';
|
||||
|
||||
interface PublicAutoRefreshProps {
|
||||
enabled: boolean;
|
||||
/** Selected interval key, e.g. `30s`. */
|
||||
interval: string;
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: string;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onIntervalChange: (key: string) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
// Prop-driven mirror of the app's refresh + auto-refresh cluster (the public viewer owns its
|
||||
// own time window, not Redux global time).
|
||||
function PublicAutoRefresh({
|
||||
enabled,
|
||||
interval,
|
||||
disabled = false,
|
||||
disabledTooltip,
|
||||
onToggle,
|
||||
onIntervalChange,
|
||||
onRefresh,
|
||||
}: PublicAutoRefreshProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.refreshActions}>
|
||||
<div className={styles.refreshButton}>
|
||||
<Button
|
||||
icon={<RefreshCw size={16} />}
|
||||
onClick={onRefresh}
|
||||
title="Refresh"
|
||||
data-testid="public-dashboard-refresh"
|
||||
/>
|
||||
</div>
|
||||
<Popover
|
||||
getPopupContainer={popupContainer}
|
||||
placement="bottomRight"
|
||||
rootClassName="auto-refresh-root"
|
||||
trigger={['click']}
|
||||
content={
|
||||
<div className="auto-refresh-menu">
|
||||
<div className="auto-refresh-checkbox">
|
||||
<Checkbox
|
||||
color="primary"
|
||||
disabledTooltip={disabledTooltip}
|
||||
onChange={(value): void => onToggle(value === true)}
|
||||
value={enabled}
|
||||
disabled={disabled}
|
||||
>
|
||||
Auto Refresh
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Typography.Text disabled={disabled} className="refresh-interval-text">
|
||||
Refresh Interval
|
||||
</Typography.Text>
|
||||
{refreshIntervalOptions
|
||||
.filter((option) => option.label !== 'off')
|
||||
.map((option) => (
|
||||
<Button
|
||||
type="text"
|
||||
className="refresh-interval-btns"
|
||||
key={option.label + option.value}
|
||||
onClick={(): void => onIntervalChange(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
{option.key === interval && enabled && <Check size={14} />}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
title="Set auto refresh"
|
||||
data-testid="public-dashboard-auto-refresh"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicAutoRefresh;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import PublicAutoRefresh from '../PublicAutoRefresh';
|
||||
|
||||
const props = {
|
||||
enabled: false,
|
||||
interval: '30s',
|
||||
onToggle: jest.fn(),
|
||||
onIntervalChange: jest.fn(),
|
||||
onRefresh: jest.fn(),
|
||||
};
|
||||
|
||||
describe('PublicAutoRefresh', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders the refresh and auto-refresh controls', () => {
|
||||
render(<PublicAutoRefresh {...props} />);
|
||||
expect(screen.getByTestId('public-dashboard-refresh')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('public-dashboard-auto-refresh'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRefresh when the refresh button is clicked', async () => {
|
||||
render(<PublicAutoRefresh {...props} />);
|
||||
await userEvent.click(screen.getByTestId('public-dashboard-refresh'));
|
||||
expect(props.onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('changes the interval from the menu', async () => {
|
||||
render(<PublicAutoRefresh {...props} />);
|
||||
await userEvent.click(screen.getByTestId('public-dashboard-auto-refresh'));
|
||||
await userEvent.click(await screen.findByText('5 seconds'));
|
||||
expect(props.onIntervalChange).toHaveBeenCalledWith('5s');
|
||||
});
|
||||
|
||||
it('toggles auto-refresh from the menu', async () => {
|
||||
render(<PublicAutoRefresh {...props} />);
|
||||
await userEvent.click(screen.getByTestId('public-dashboard-auto-refresh'));
|
||||
await userEvent.click(await screen.findByRole('checkbox'));
|
||||
expect(props.onToggle).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -44,27 +44,7 @@
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
padding: 8px 4px 0;
|
||||
font-weight: 600;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import type { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import { layoutsToSections } from 'pages/DashboardPage/DashboardContainer/utils';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useInterval } from 'react-use';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import type { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PublicDashboardSchema } from 'hooks/dashboard/useGetResolvedPublicDashboard';
|
||||
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
|
||||
|
||||
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
|
||||
import PublicAutoRefresh from './PublicAutoRefresh/PublicAutoRefresh';
|
||||
import PublicSectionGrid from './PublicSectionGrid/PublicSectionGrid';
|
||||
import { getStartTimeAndEndTimeFromTimeRange } from './utils';
|
||||
import { usePublicDashboardTime } from '../hooks/usePublicDashboardTime';
|
||||
import { createPublicDashboardHost } from '../publicDashboardHost';
|
||||
import styles from './PublicDashboardView.module.scss';
|
||||
|
||||
interface PublicDashboardViewProps {
|
||||
@@ -25,74 +15,26 @@ interface PublicDashboardViewProps {
|
||||
data: DashboardtypesGettablePublicDashboardDataV2DTO;
|
||||
}
|
||||
|
||||
// Read-only viewer for a v2 (Perses-spec) public dashboard; reuses the V2 panel renderers.
|
||||
// Variables aren't rendered — the public endpoint doesn't substitute them.
|
||||
const noop = (): void => undefined;
|
||||
|
||||
/** A shared link: the SigNoz header, then the dashboard read-only on the public endpoint. */
|
||||
function PublicDashboardView({
|
||||
publicDashboardId,
|
||||
data,
|
||||
}: PublicDashboardViewProps): JSX.Element {
|
||||
const { dashboard, publicDashboard } = data;
|
||||
|
||||
const sections = useMemo(
|
||||
() => layoutsToSections(dashboard?.spec?.layouts, dashboard?.spec?.panels),
|
||||
[dashboard?.spec?.layouts, dashboard?.spec?.panels],
|
||||
const timeRangeEnabled = data.publicDashboard?.timeRangeEnabled ?? false;
|
||||
const host = useMemo(
|
||||
() => createPublicDashboardHost(publicDashboardId, { timeRangeEnabled }),
|
||||
[publicDashboardId, timeRangeEnabled],
|
||||
);
|
||||
|
||||
const [selectedTimeRangeLabel, setSelectedTimeRangeLabel] = useState<string>(
|
||||
publicDashboard?.defaultTimeRange || DEFAULT_TIME_RANGE,
|
||||
const dashboard = useMemo(
|
||||
() => host.select({ schema: PublicDashboardSchema.V2, data }),
|
||||
[host, data],
|
||||
);
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState(() =>
|
||||
getStartTimeAndEndTimeFromTimeRange(
|
||||
publicDashboard?.defaultTimeRange || DEFAULT_TIME_RANGE,
|
||||
),
|
||||
);
|
||||
|
||||
const isTimeRangeEnabled = publicDashboard?.timeRangeEnabled || false;
|
||||
|
||||
const handleTimeChange = (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
): void => {
|
||||
if (dateTimeRange) {
|
||||
setSelectedTimeRange({
|
||||
startTime: Math.floor(dateTimeRange[0] / 1000),
|
||||
endTime: Math.floor(dateTimeRange[1] / 1000),
|
||||
});
|
||||
} else if (interval !== 'custom') {
|
||||
const { maxTime, minTime } = GetMinMax(interval);
|
||||
setSelectedTimeRange({
|
||||
startTime: Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000),
|
||||
endTime: Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000),
|
||||
});
|
||||
}
|
||||
setSelectedTimeRangeLabel(interval as string);
|
||||
};
|
||||
|
||||
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState<boolean>(false);
|
||||
const [autoRefreshInterval, setAutoRefreshInterval] = useState<string>('30s');
|
||||
|
||||
// Auto-refresh applies only to a rolling relative range, not a fixed custom window.
|
||||
const isAutoRefreshPaused = selectedTimeRangeLabel === 'custom';
|
||||
const refreshIntervalMs = useMemo(
|
||||
() =>
|
||||
autoRefreshEnabled
|
||||
? refreshIntervalOptions.find(
|
||||
(option) => option.key === autoRefreshInterval,
|
||||
)?.value || 0
|
||||
: 0,
|
||||
[autoRefreshEnabled, autoRefreshInterval],
|
||||
);
|
||||
|
||||
useInterval(
|
||||
() => handleTimeChange(selectedTimeRangeLabel as Time),
|
||||
isAutoRefreshPaused || refreshIntervalMs === 0 ? null : refreshIntervalMs,
|
||||
);
|
||||
|
||||
const handleRefresh = (): void =>
|
||||
handleTimeChange(selectedTimeRangeLabel as Time);
|
||||
|
||||
const startMs = selectedTimeRange.startTime * 1000;
|
||||
const endMs = selectedTimeRange.endTime * 1000;
|
||||
const isTimeReady = usePublicDashboardTime({
|
||||
defaultTimeRange: data.publicDashboard?.defaultTimeRange,
|
||||
timeRangeEnabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@@ -106,53 +48,20 @@ function PublicDashboardView({
|
||||
{dashboard?.spec?.display?.name}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{isTimeRangeEnabled && (
|
||||
<div className={styles.headerRight}>
|
||||
<PublicAutoRefresh
|
||||
enabled={autoRefreshEnabled}
|
||||
interval={autoRefreshInterval}
|
||||
disabled={isAutoRefreshPaused}
|
||||
disabledTooltip="Auto refresh is off for a custom time range"
|
||||
onToggle={setAutoRefreshEnabled}
|
||||
onIntervalChange={setAutoRefreshInterval}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={false}
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime={publicDashboard?.defaultTimeRange as Time}
|
||||
isModalTimeSelection
|
||||
modalSelectedInterval={selectedTimeRangeLabel as Time}
|
||||
disableUrlSync
|
||||
showRecentlyUsed={false}
|
||||
modalInitialStartTime={startMs}
|
||||
modalInitialEndTime={endMs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{sections.map((section) => (
|
||||
<section key={section.id} className={styles.section}>
|
||||
{section.title && (
|
||||
<Typography.Text className={styles.sectionTitle}>
|
||||
{section.title}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<PublicSectionGrid
|
||||
items={section.items}
|
||||
publicDashboardId={publicDashboardId}
|
||||
startMs={startMs}
|
||||
endMs={endMs}
|
||||
isVisible
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{isTimeReady && dashboard && (
|
||||
<div className={styles.content}>
|
||||
<DashboardContainer
|
||||
dashboard={dashboard}
|
||||
// A public link is a fixed snapshot of the shared spec.
|
||||
refetch={noop}
|
||||
source={host.source}
|
||||
readOnly={host.readOnly}
|
||||
controls={host.controls}
|
||||
queryRunner={host.queryRunner}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--text-panel-surface, var(--l2-background));
|
||||
border: 1px solid var(--text-panel-border, var(--l2-border));
|
||||
border-radius: 4px;
|
||||
color: var(--text-panel-ink, inherit);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { noop } from 'lodash-es';
|
||||
import PanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
|
||||
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { useTextBackground } from 'pages/DashboardPage/DashboardContainer/Panels/hooks/useTextBackground';
|
||||
import { isPanelHeaderHidden } from 'pages/DashboardPage/DashboardContainer/Panels/utils/isPanelHeaderHidden';
|
||||
|
||||
import { usePublicPanelQuery } from '../hooks/usePublicPanelQuery';
|
||||
import styles from './PublicPanel.module.scss';
|
||||
|
||||
interface PublicPanelProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Panel key in `spec.panels` — addresses the panel on the public endpoint. */
|
||||
panelKey: string;
|
||||
publicDashboardId: string;
|
||||
/** Epoch milliseconds. */
|
||||
startMs: number;
|
||||
/** Epoch milliseconds. */
|
||||
endMs: number;
|
||||
/** True once the panel is on screen — gates the fetch. */
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const PUBLIC_DASHBOARD_PREFERENCE: DashboardPreference = {
|
||||
syncMode: DashboardCursorSync.None,
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only v2 public panel. Forks on the kind's mode before any query
|
||||
* machinery exists. A static body is authored content meant to be read, so it
|
||||
* is not redacted; public dashboards carry no variable runtime, so variable
|
||||
* tokens render literally.
|
||||
*/
|
||||
function PublicPanel(props: PublicPanelProps): JSX.Element {
|
||||
const { panel, panelKey } = props;
|
||||
const panelDefinition = getPanelDefinition(panel.spec.plugin.kind);
|
||||
const background = useTextBackground(panel.spec);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
style={background.style}
|
||||
data-panel-root={panelKey}
|
||||
>
|
||||
{panelDefinition.mode === 'static' ? (
|
||||
<>
|
||||
{!isPanelHeaderHidden(panel.spec) && (
|
||||
<PanelHeader mode="static" panelId={panelKey} panel={panel} hideActions />
|
||||
)}
|
||||
<StaticPanelBody
|
||||
Renderer={panelDefinition.Renderer}
|
||||
panel={panel}
|
||||
panelId={panelKey}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<QueryPublicPanelContent {...props} panelDefinition={panelDefinition} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface QueryPublicPanelContentProps extends PublicPanelProps {
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
}
|
||||
|
||||
// Reuses the V2 header/body renderers with interactions disabled.
|
||||
function QueryPublicPanelContent({
|
||||
panel,
|
||||
panelKey,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
endMs,
|
||||
isVisible,
|
||||
panelDefinition,
|
||||
}: QueryPublicPanelContentProps): JSX.Element {
|
||||
const { data, isFetching, isPreviousData, error, refetch } =
|
||||
usePublicPanelQuery({
|
||||
panel,
|
||||
queryCapabilities: panelDefinition.queryCapabilities,
|
||||
panelKey,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
endMs,
|
||||
enabled: isVisible !== false,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelHeader
|
||||
mode="query"
|
||||
panelId={panelKey}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
hideActions
|
||||
/>
|
||||
<PanelBody
|
||||
Renderer={panelDefinition.Renderer}
|
||||
panel={panel}
|
||||
panelId={panelKey}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={noop}
|
||||
dashboardPreference={PUBLIC_DASHBOARD_PREFERENCE}
|
||||
enableDrillDown={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicPanel;
|
||||
@@ -1,172 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
|
||||
import { usePublicPanelQuery } from '../../hooks/usePublicPanelQuery';
|
||||
import PublicPanel from '../PublicPanel';
|
||||
|
||||
jest.mock('../../hooks/usePublicPanelQuery', () => ({
|
||||
usePublicPanelQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
// Real registry by default; individual tests override to a static definition.
|
||||
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => {
|
||||
const actual = jest.requireActual(
|
||||
'pages/DashboardPage/DashboardContainer/Panels/registry',
|
||||
);
|
||||
return { ...actual, getPanelDefinition: jest.fn(actual.getPanelDefinition) };
|
||||
});
|
||||
|
||||
// Stub the reused V2 renderers so the test targets PublicPanel's own wiring, not uPlot/timezone.
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({ hideActions }: { hideActions?: boolean }): JSX.Element => (
|
||||
<div data-testid="panel-header" data-hide-actions={String(!!hideActions)} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
enableDrillDown,
|
||||
panelId,
|
||||
}: {
|
||||
enableDrillDown?: boolean;
|
||||
panelId: string;
|
||||
}): JSX.Element => (
|
||||
<div
|
||||
data-testid="panel-body"
|
||||
data-drilldown={String(!!enableDrillDown)}
|
||||
data-panel-id={panelId}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const mockQuery = usePublicPanelQuery as jest.Mock;
|
||||
|
||||
const queryResult = {
|
||||
data: { response: undefined, requestPayload: undefined, legendMap: {} },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isPreviousData: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
cancelQuery: jest.fn(),
|
||||
pagination: undefined,
|
||||
};
|
||||
|
||||
const timeseriesPanel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'panel-1' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
const commonProps = {
|
||||
panelKey: 'p1',
|
||||
publicDashboardId: 'pub-1',
|
||||
startMs: 1000,
|
||||
endMs: 2000,
|
||||
};
|
||||
|
||||
describe('PublicPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockQuery.mockReset();
|
||||
mockQuery.mockReturnValue(queryResult);
|
||||
});
|
||||
|
||||
it('renders the reused header/body read-only (hideActions, no drill-down)', () => {
|
||||
render(<PublicPanel panel={timeseriesPanel} {...commonProps} />);
|
||||
|
||||
expect(screen.getByTestId('panel-header')).toHaveAttribute(
|
||||
'data-hide-actions',
|
||||
'true',
|
||||
);
|
||||
const body = screen.getByTestId('panel-body');
|
||||
expect(body).toHaveAttribute('data-drilldown', 'false');
|
||||
expect(body).toHaveAttribute('data-panel-id', 'p1');
|
||||
});
|
||||
|
||||
it('fetches by panel key and time', () => {
|
||||
render(<PublicPanel panel={timeseriesPanel} {...commonProps} />);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panelKey: 'p1',
|
||||
publicDashboardId: 'pub-1',
|
||||
startMs: 1000,
|
||||
endMs: 2000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a static kind with no fetch at all', () => {
|
||||
const StaticRenderer = (): JSX.Element => (
|
||||
<div data-testid="fake-static-renderer" />
|
||||
);
|
||||
(getPanelDefinition as jest.Mock).mockReturnValueOnce({
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
displayName: 'Static',
|
||||
sections: [],
|
||||
actions: {},
|
||||
mode: 'static',
|
||||
Renderer: StaticRenderer,
|
||||
EditorPane: StaticRenderer,
|
||||
});
|
||||
|
||||
render(<PublicPanel panel={timeseriesPanel} {...commonProps} />);
|
||||
|
||||
expect(screen.getByTestId('fake-static-renderer')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-header')).toHaveAttribute(
|
||||
'data-hide-actions',
|
||||
'true',
|
||||
);
|
||||
expect(mockQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a static kind without its header when headerOptions hides it', () => {
|
||||
const StaticRenderer = (): JSX.Element => (
|
||||
<div data-testid="fake-static-renderer" />
|
||||
);
|
||||
(getPanelDefinition as jest.Mock).mockReturnValueOnce({
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
displayName: 'Static',
|
||||
sections: [],
|
||||
actions: {},
|
||||
mode: 'static',
|
||||
Renderer: StaticRenderer,
|
||||
EditorPane: StaticRenderer,
|
||||
});
|
||||
const hiddenHeaderPanel = {
|
||||
...timeseriesPanel,
|
||||
spec: {
|
||||
...timeseriesPanel.spec,
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { headerOptions: { hide: true } },
|
||||
},
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
render(<PublicPanel panel={hiddenHeaderPanel} {...commonProps} />);
|
||||
|
||||
expect(screen.getByTestId('fake-static-renderer')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('panel-header')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gates the fetch when off screen', () => {
|
||||
render(
|
||||
<PublicPanel panel={timeseriesPanel} {...commonProps} isVisible={false} />,
|
||||
);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user