Compare commits

..

10 Commits

Author SHA1 Message Date
Abhi Kumar
6bae4e74eb feat(public-dashboard): render on the shared dashboard
createPublicDashboardHost mounts the shared tree read-only: variables dropped, charts only, panels
run on the public endpoint by key. The page opens on the shared default range and keeps the
viewer's own only when the link allows changing time; a fixed link can't zoom either, so zoom
becomes panelActions.zoom and CHARTS_ONLY names what it hides.
2026-11-13 02:27:15 +05:30
Abhi Kumar
3ca219d02f feat(dashboard): let hosts choose what runs panel queries
A host's queryRunner replaces only the network call; usePanelQuery still owns the window, the
request and the loading states, so renderers are untouched. The runner's key joins every panel
cache key, so results from different runners never mix.
2026-11-13 02:26:57 +05:30
Abhi Kumar
60c0d73143 feat(dashboard): add layout presets for host controls
EMBEDDED and CHARTS_ONLY name the layouts hosts repeat; withControls merges them with overrides item
by item. Presets only decide what shows: readOnly stays separate, so none can bring back a write.
2026-11-13 01:57:26 +05:30
Abhi Kumar
90f24a0fe9 Revert "feat(dashboard): let hosts set dashboard variable values"
This reverts commit 908e44c1e8.
2026-11-13 01:52:24 +05:30
Abhi Kumar
908e44c1e8 feat(dashboard): let hosts set dashboard variable values
A value the host passes wins over the URL, the saved selection and the default, and its variable is
hidden from the bar. The fetch engine treats such a variable as text, so a hidden query variable
can't leave panels waiting on a fetch nothing will run. Page values are never saved.
2026-11-13 00:29:23 +05:30
Abhi Kumar
da94bb975f feat(cost-meter): render the breakdown as a read-only system dashboard
The eight widgets move into a backend system dashboard definition, so the page is data rather than
hand-built V1 cards. The LLM overview and the breakdown share one SystemDashboard mount.

The page drops its own time picker for the dashboard's, and hides panel actions.
2026-11-13 00:14:24 +05:30
Abhi Kumar
2a3a5fe4ef refactor(dashboard): name control groups after whose actions they hide
panel: false read as hiding the panels. The groups become dashboardActions, sectionActions and
panelActions; header, title, toolbar and viewModal already name what they hide.
2026-11-13 00:12:29 +05:30
Abhi Kumar
d59bc57394 feat(dashboard): let hosts choose which dashboard controls render
controls is grouped by area and on by default. Visibility is kept apart from readOnly: every
control declares whether it writes, and a read-only mount hides the writing ones whatever the
host asks for.

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

readOnly replaces canEditDashboardOverride: it also rejects writes and skips edit-only requests.
2026-11-12 23:01:38 +05:30
87 changed files with 3066 additions and 2240 deletions

View File

@@ -11,8 +11,6 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
// generated API Instance
const generatedAPIAxiosInstance = axios.create({
baseURL: ENVIRONMENT.baseURL,
// The backend binds repeated keys (`states=a&states=b`); axios' default sends `states[]=`.
paramsSerializer: { indexes: null },
});
let generatedAPIQueryKeyHeaderContext: Record<string, unknown> | undefined;

View File

@@ -1,10 +1,12 @@
import { useMemo } from 'react';
import { useListRulesV3 } from 'api/generated/services/rules';
import {
type ListRulesV3200,
type ListRulesV3Params,
RuletypesAlertStateDTO,
type RuletypesListableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
const HOME_ALERT_RULES_LIMIT = 5;
@@ -21,7 +23,18 @@ interface HomeAlertRules {
export function useHomeAlertRules(): HomeAlertRules {
const firing = useListRulesV3(FIRING_RULES_PARAMS, {
query: { cacheTime: 0 },
query: {
cacheTime: 0,
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
queryFn: ({ signal }) =>
GeneratedAPIInstance<ListRulesV3200>({
url: '/api/v3/rules',
method: 'GET',
params: FIRING_RULES_PARAMS,
paramsSerializer: { indexes: null },
signal,
}),
},
});
const firingRules = firing.data?.data.rules;
const latest = useListRulesV3(

View File

@@ -3,24 +3,4 @@
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-0) var(--spacing-0);
//TODO: remove this once we have a proper dashboard page.
// The embedded V2 DashboardContainer renders its own page header (dashboard
// title + share/feedback chrome) removed it for now
:global([class*='dashboardPageHeader']),
:global([class*='dashboardInfoWithActions']) {
display: none;
}
// Embedded read-only: the add-variable and panel-action triggers are dead here.
:global([class*='addSlot']),
:global([data-testid^='panel-actions-']) {
display: none;
}
}
.errorState {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-8);
}

View File

@@ -1,47 +1,24 @@
import { useEffect } from 'react';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import Spinner from 'components/Spinner';
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
import SystemDashboard from 'container/SystemDashboard/SystemDashboard';
import { createSystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
import { CHARTS_ONLY } from 'pages/DashboardPage/DashboardContainer/controls/presets';
import { useSystemDashboard } from './hooks/useSystemDashboard';
import styles from './Overview.module.scss';
function Overview(): JSX.Element {
const { dashboard, isLoading, isError, error, refetch } = useSystemDashboard();
const AI_OVERVIEW = createSystemDashboardHost(
'ai-o11y-overview',
'llm-observability-overview',
);
function Overview(): JSX.Element {
useEffect(() => {
void logEvent('AI Observability Overview: Page visited', {});
}, []);
const renderContent = (): JSX.Element => {
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;
}
if (isError || !dashboard) {
return (
<div className={styles.errorState}>
<Typography.Title>Failed to load dashboard</Typography.Title>
<Typography.Text>
{error?.response?.data?.error?.message ?? error?.message}
</Typography.Text>
</div>
);
}
return (
<DashboardContainer
dashboard={dashboard}
refetch={refetch}
canEditDashboardOverride={false}
/>
);
};
return (
<div className={styles.overview} data-testid="llm-observability-overview">
{renderContent()}
<SystemDashboard host={AI_OVERVIEW} controls={CHARTS_ONLY} />
</div>
);
}

View File

@@ -1,56 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import {
getGetDashboardV2QueryKey,
useGetSystemDashboard,
} from 'api/generated/services/dashboard';
import type { GetSystemDashboardQueryError } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
const SYSTEM_DASHBOARD_NAME = 'ai-o11y-overview';
const DASHBOARD_ID = 'llm-observability-overview';
export interface UseSystemDashboardResult {
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
isLoading: boolean;
isError: boolean;
error: GetSystemDashboardQueryError | null;
refetch: () => void;
}
export function useSystemDashboard(): UseSystemDashboardResult {
const queryClient = useQueryClient();
const { data, isLoading, isError, error, refetch } = useGetSystemDashboard(
{ name: SYSTEM_DASHBOARD_NAME },
{ query: { staleTime: Infinity, refetchOnMount: false } },
);
const dashboard = useMemo(
() =>
data
? ({
...data.data,
id: DASHBOARD_ID,
} as DashboardtypesGettableDashboardV2DTO)
: undefined,
[data],
);
if (dashboard) {
queryClient.setQueryData<GetDashboardV2200>(
getGetDashboardV2QueryKey({ id: DASHBOARD_ID }),
{ data: dashboard, status: 'success' },
);
}
const refetchDashboard = useCallback((): void => {
void refetch();
}, [refetch]);
return { dashboard, isLoading, isError, error, refetch: refetchDashboard };
}

View File

@@ -3,11 +3,13 @@ import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRulesV3 } from 'api/generated/services/rules';
import type {
ListRulesV3200,
ListRulesV3Params,
RenderErrorResponseDTO,
RuletypesLabelPairDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
import { isUndefined } from 'lodash-es';
import type { AlertRule } from './types';
@@ -33,7 +35,18 @@ export function useAlertRulesData(
const hasLoggedEvent = useRef(false);
const rulesResponse = useListRulesV3(params, {
query: { keepPreviousData: true },
query: {
keepPreviousData: true,
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
queryFn: ({ signal }) =>
GeneratedAPIInstance<ListRulesV3200>({
url: '/api/v3/rules',
method: 'GET',
params,
paramsSerializer: { indexes: null },
signal,
}),
},
});
const rules = useMemo(

View File

@@ -2,77 +2,14 @@
display: flex;
flex-direction: column;
.meter-explorer-date-time {
display: flex;
min-height: 30px;
justify-content: end;
border-bottom: 1px solid var(--l1-border);
padding: 10px 16px;
}
.meter-explorer-graphs {
.meter-explorer-notices {
display: flex;
flex-direction: column;
padding: 20px;
gap: 36px;
gap: 12px;
padding: 16px 20px 0;
.info {
display: flex;
flex-direction: column;
gap: 12px;
}
.meter-column-graph {
.row-card {
background-color: var(--l2-background);
padding-left: 10px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
.section-title {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
}
.graph-description {
padding: 10px;
display: flex;
justify-content: center;
align-items: center;
}
.meter-page-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
align-items: flex-start;
gap: 10px;
.meter-graph {
height: 400px;
padding: 10px;
width: 100%;
box-sizing: border-box;
}
}
}
.total {
.meter-column-graph {
.meter-page-grid {
grid-template-columns: repeat(3, 1fr);
.meter-graph {
height: 200px;
}
}
}
&:empty {
display: none;
}
}
}

View File

@@ -1,121 +1,30 @@
import { useCallback } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useAppLocation } from 'lib/router/useAppLocation';
import { navigate } from 'lib/router/navigation';
import { useSelector } from 'react-redux';
import { Alert } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import GridCard from 'container/WidgetCard/Card';
import { Card, CardContainer } from 'container/WidgetCard/styles';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import SystemDashboard from 'container/SystemDashboard/SystemDashboard';
import { createSystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import useUrlQuery from 'hooks/useUrlQuery';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { Widgets } from 'types/api/widgets/widget';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuid } from 'uuid';
import {
getLogCountWidgetData,
getLogSizeWidgetData,
getMetricCountWidgetData,
getSpanCountWidgetData,
getSpanSizeWidgetData,
getTotalLogSizeWidgetData,
getTotalMetricDatapointCountWidgetData,
getTotalTraceSizeWidgetData,
} from './graphs';
CHARTS_ONLY,
withControls,
} from 'pages/DashboardPage/DashboardContainer/controls/presets';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import './BreakDown.styles.scss';
type MetricSection = {
id: string;
title: string;
graphs: Widgets[];
};
const COST_METER = createSystemDashboardHost(
'cost-meter-breakdown',
'cost-meter-breakdown',
);
const sections: MetricSection[] = [
{
id: uuid(),
title: 'Total',
graphs: [
getTotalLogSizeWidgetData(),
getTotalTraceSizeWidgetData(),
getTotalMetricDatapointCountWidgetData(),
],
},
{
id: uuid(),
title: 'Logs',
graphs: [getLogCountWidgetData(), getLogSizeWidgetData()],
},
{
id: uuid(),
title: 'Traces',
graphs: [getSpanCountWidgetData(), getSpanSizeWidgetData()],
},
{
id: uuid(),
title: 'Metrics',
graphs: [getMetricCountWidgetData()],
},
];
function Section(section: MetricSection): JSX.Element {
const isDarkMode = useIsDarkMode();
const { title, graphs } = section;
const { pathname } = useAppLocation();
const dispatch = useDispatch();
const urlQuery = useUrlQuery();
const onDragSelect = useCallback(
(start: number, end: number) => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
navigate(generatedUrl);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
},
[dispatch, pathname, urlQuery],
);
return (
<div
className="meter-column-graph"
data-testid={`meter-breakdown-section-${title.toLowerCase()}`}
>
<CardContainer className="row-card" isDarkMode={isDarkMode}>
<Typography.Text className="section-title">{title}</Typography.Text>
</CardContainer>
<div className="meter-page-grid">
{graphs.map((widget) => (
<Card
key={widget?.id}
isDarkMode={isDarkMode}
$panelType={PANEL_TYPES.BAR}
className="meter-graph"
>
<GridCard widget={widget} onDragSelect={onDragSelect} version="v5" />
</Card>
))}
</div>
</div>
);
}
const CONTROLS = withControls(CHARTS_ONLY, {
toolbar: { autoRefresh: false },
});
function BreakDown(): JSX.Element {
const { isCloudUser } = useGetTenantLicense();
@@ -133,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>
);
}

View File

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

View File

@@ -1,374 +0,0 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetWidgetQueryBuilderProps } from 'container/MetricsApplication/types';
import { Widgets } from 'types/api/widgets/widget';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderFormula,
IBuilderQuery,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
interface GetWidgetQueryProps {
title: string;
description: string;
queryData: IBuilderQuery[];
queryFormulas?: IBuilderFormula[];
panelTypes?: PANEL_TYPES;
yAxisUnit?: string;
columnUnits?: Record<string, string>;
}
interface GetWidgetQueryPropsReturn extends GetWidgetQueryBuilderProps {
description?: string;
nullZeroValues: string;
columnUnits?: Record<string, string>;
}
export const getWidgetQueryBuilder = ({
query,
title = '',
panelTypes,
yAxisUnit = '',
fillSpans = false,
id,
nullZeroValues,
description,
}: GetWidgetQueryPropsReturn): Widgets => ({
description: description || '',
id: id || uuid(),
nullZeroValues: nullZeroValues || '',
opacity: '1',
panelTypes,
query,
timePreferance: 'GLOBAL_TIME',
title,
yAxisUnit,
softMax: null,
softMin: null,
selectedLogFields: [],
selectedTracesFields: [],
fillSpans,
});
export function getWidgetQuery(
props: GetWidgetQueryProps,
): GetWidgetQueryPropsReturn {
const { title, description, panelTypes, yAxisUnit, columnUnits } = props;
return {
title,
yAxisUnit: yAxisUnit || 'none',
panelTypes: panelTypes || PANEL_TYPES.TIME_SERIES,
fillSpans: false,
description,
nullZeroValues: 'zero',
columnUnits,
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: {
queryData: props.queryData,
queryFormulas: (props.queryFormulas as IBuilderFormula[]) || [],
queryTraceOperator: [],
},
clickhouse_sql: [],
id: uuid(),
},
};
}
export const getTotalLogSizeWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.log.size',
id: 'signoz.meter.log.size--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.SUM,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Total size of log records ingested',
description: '',
panelTypes: PANEL_TYPES.VALUE,
yAxisUnit: 'bytes',
}),
);
export const getTotalTraceSizeWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.span.size',
id: 'signoz.meter.span.size--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.SUM,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Total size of spans ingested',
description: '',
panelTypes: PANEL_TYPES.VALUE,
yAxisUnit: 'bytes',
}),
);
export const getTotalMetricDatapointCountWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.metric.datapoint.count',
id: 'signoz.meter.metric.datapoint.count--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.SUM,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Total metric datapoints ingested',
description: '',
panelTypes: PANEL_TYPES.VALUE,
yAxisUnit: 'short',
}),
);
export const getLogCountWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.log.count',
id: 'signoz.meter.log.count--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Count of log records ingested',
description: '',
panelTypes: PANEL_TYPES.BAR,
yAxisUnit: 'short',
}),
);
export const getLogSizeWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.log.size',
id: 'signoz.meter.log.size--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'size',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Size of log records ingested',
description: '',
panelTypes: PANEL_TYPES.BAR,
yAxisUnit: 'bytes',
}),
);
export const getSpanCountWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.span.count',
id: 'signoz.meter.span.count--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Count of spans ingested',
description: '',
panelTypes: PANEL_TYPES.BAR,
yAxisUnit: 'short',
}),
);
export const getSpanSizeWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.span.size',
id: 'signoz.meter.span.size--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'size',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Size of spans ingested',
description: '',
panelTypes: PANEL_TYPES.BAR,
yAxisUnit: 'bytes',
}),
);
export const getMetricCountWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.metric.datapoint.count',
id: 'signoz.meter.metric.datapoint.count--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Count of metric datapoints ingested',
description: '',
panelTypes: PANEL_TYPES.BAR,
yAxisUnit: 'short',
}),
);

View File

@@ -0,0 +1,7 @@
.errorState {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-8);
}

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

View File

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

View File

@@ -0,0 +1,36 @@
import { getGetSystemDashboardQueryKey } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
GetSystemDashboard200,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardQuerySource } from 'pages/DashboardPage/DashboardContainer/store/slices/editContextSlice';
export interface SystemDashboardHost {
/** Definition name without the `signoz---` prefix. */
name: string;
select: (
response: GetSystemDashboard200,
) => DashboardtypesGettableDashboardV2DTO;
source: DashboardQuerySource;
}
/**
* Call at module level: `select` and `source` must stay referentially stable.
* System dashboards carry no `id`; `id` keys the store's per-dashboard state
* (saved variable values), so keep it stable across releases.
*/
export function createSystemDashboardHost(
name: string,
id: string,
): SystemDashboardHost {
const select = (
response: GetSystemDashboard200,
): DashboardtypesGettableDashboardV2DTO =>
({ ...response.data, id }) as DashboardtypesGettableDashboardV2DTO;
return {
name,
select,
source: { queryKey: getGetSystemDashboardQueryKey({ name }), select },
};
}

View File

@@ -0,0 +1,41 @@
import { useCallback } from 'react';
import { useGetSystemDashboard } from 'api/generated/services/dashboard';
import type { GetSystemDashboardQueryError } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import type { SystemDashboardHost } from './systemDashboardHost';
export interface UseSystemDashboardResult {
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
isLoading: boolean;
isError: boolean;
error: GetSystemDashboardQueryError | null;
refetch: () => void;
}
export function useSystemDashboard(
host: SystemDashboardHost,
): UseSystemDashboardResult {
const {
data: dashboard,
isLoading,
isError,
error,
refetch,
} = useGetSystemDashboard(
{ name: host.name },
{
query: {
select: host.select,
staleTime: Infinity,
refetchOnMount: false,
},
},
);
const refetchDashboard = useCallback((): void => {
void refetch();
}, [refetch]);
return { dashboard, isLoading, isError, error, refetch: refetchDashboard };
}

View File

@@ -175,6 +175,7 @@ function AutoRefresh({
}
>
<ButtonContainer
data-testid="auto-refresh-trigger"
title="Set auto refresh"
type={showAutoRefreshBtnPrimary ? 'primary' : 'default'}
>

View File

@@ -1,32 +0,0 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { back, getCurrentLocation } from 'lib/router/navigation';
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
// jest.config.ts maps `hooks/useSafeNavigate` to a mock; the `.ts` path loads the real hook.
const { useSafeNavigate } = jest.requireActual<
typeof import('hooks/useSafeNavigate')
>('hooks/useSafeNavigate.ts');
describe('useSafeNavigate', () => {
it('adds a history entry for replace: false even when the target adds params', async () => {
const initialSearch = `?${QueryParams.relativeTime}=15m`;
const { result } = renderHook(() => useSafeNavigate(), {
wrapper: ({ children }): JSX.Element => (
<AllTheProviders initialRoute={`${ROUTES.LOGS_EXPLORER}${initialSearch}`}>
{children}
</AllTheProviders>
),
});
act(() => {
result.current.safeNavigate(
`${ROUTES.LOGS_EXPLORER}?${QueryParams.startTime}=1000`,
{ replace: false },
);
});
act(() => back());
await waitFor(() => expect(getCurrentLocation().search).toBe(initialSearch));
});
});

View File

@@ -140,7 +140,7 @@ export const useSafeNavigate = (
const navigationOptions = {
...options,
replace: options?.replace ?? isDefaultParamsNavigation,
replace: isDefaultParamsNavigation || options?.replace,
};
if (typeof to === 'string') {

View File

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

View File

@@ -24,9 +24,11 @@ import { openInNewTab } from 'utils/navigation';
import styles from './DashboardInfo.module.scss';
import TagsOverflowTooltip from './TagsOverflowTooltip';
import { getLockTooltip } from './utils';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
// The tag cluster keeps a fixed footprint so a long title ellipsizes around it
// instead of collapsing the tags: show up to two tags, then a `+N` overflow badge.
@@ -75,27 +77,19 @@ function DashboardInfo({
}: DashboardInfoProps): JSX.Element {
const { isEditable: canEdit } = useDashboardEditContext();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const show = useDashboardControlGroup('title');
const hasTags = tags.length > 0;
const hasDescription = !isEmpty(description);
const hasTags = show.tags && tags.length > 0;
const hasDescription = show.description && !isEmpty(description);
const visibleTags = tags.slice(0, MAX_VISIBLE_TAGS);
const remainingTags = tags.slice(MAX_VISIBLE_TAGS);
// Without a toggle, say why it can't be toggled rather than only restating the
// lock state.
let lockTooltip: string;
if (onToggleLock) {
lockTooltip = isDashboardLocked
? 'Locked — click to unlock'
: 'Unlocked — click to lock';
} else if (lockDisabledTooltip) {
lockTooltip = lockDisabledTooltip;
} else {
lockTooltip = isDashboardLocked
? 'This dashboard is locked'
: 'This dashboard is unlocked';
}
const lockTooltip = getLockTooltip({
isLocked: isDashboardLocked,
canToggle: !!onToggleLock,
disabledReason: lockDisabledTooltip,
});
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
@@ -128,66 +122,69 @@ function DashboardInfo({
return (
<div className={styles.dashboardInfo}>
<img src={image} alt={title} className={styles.dashboardImage} />
{isEditing ? (
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={DASHBOARD_NAME_MAX_LENGTH}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="solid"
color="primary"
size="sm"
icon
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
size="sm"
icon
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<>
<Tooltip title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</Tooltip>
<CopyButton
value={dashboardId}
ariaLabel="Copy dashboard ID"
testId="dashboard-copy-id"
onCopy={handleCopyId}
/>
</>
{show.name && (
<img src={image} alt={title} className={styles.dashboardImage} />
)}
{show.name &&
(isEditing ? (
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={DASHBOARD_NAME_MAX_LENGTH}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="solid"
color="primary"
size="sm"
icon
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
size="sm"
icon
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<>
<Tooltip title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</Tooltip>
<CopyButton
value={dashboardId}
ariaLabel="Copy dashboard ID"
testId="dashboard-copy-id"
onCopy={handleCopyId}
/>
</>
))}
{hasDescription && (
<Tooltip
side="bottom"
@@ -205,7 +202,7 @@ function DashboardInfo({
</Tooltip>
)}
{isPublicDashboard && (
{show.publicBadge && isPublicDashboard && (
<Tooltip title="This dashboard is publicly accessible. Click to open the public page.">
<Button
type="button"
@@ -222,7 +219,7 @@ function DashboardInfo({
</Tooltip>
)}
{showLockToggle && (
{show.lock && showLockToggle && (
<Tooltip title={onToggleLock ? lockTooltip : undefined}>
<Button
disabledTooltip={lockTooltip}

View File

@@ -0,0 +1,19 @@
// Without a toggle, say why it can't be toggled rather than only restating the
// lock state.
export function getLockTooltip({
isLocked,
canToggle,
disabledReason,
}: {
isLocked: boolean;
canToggle: boolean;
disabledReason?: string;
}): string {
if (canToggle) {
return isLocked ? 'Locked — click to unlock' : 'Unlocked — click to lock';
}
if (disabledReason) {
return disabledReason;
}
return isLocked ? 'This dashboard is locked' : 'This dashboard is unlocked';
}

View File

@@ -0,0 +1,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();
});
});

View File

@@ -17,6 +17,7 @@ import { getAbsoluteUrl } from 'utils/basePath';
import { useCreatePanel } from '../hooks/useCreatePanel';
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
import { useDashboardControlGroup } from '../controls/useDashboardControlGroup';
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import DashboardActions from './DashboardActions/DashboardActions';
@@ -56,6 +57,11 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
[dashboard.tags],
);
const showTitle = useDashboardControlGroup('title');
const showActions = useDashboardControlGroup('dashboardActions');
const showToolbar = useDashboardControlGroup('toolbar');
const hasTitleRow = Object.values(showTitle).some(Boolean);
const hasActions = Object.values(showActions).some(Boolean);
const { showErrorModal } = useErrorModal();
const { patchAsync } = useOptimisticPatch();
const {
@@ -71,10 +77,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
useDashboardLockPermission({
dashboardId: id,
source: dashboard.source,
enabled: showTitle.lock || showActions.lock,
});
// Public-sharing meta (deduped react-query read); drives the header globe.
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
const { isPublic, publicMeta } = usePublicDashboardMeta(id, {
enabled: showTitle.publicBadge,
});
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
// Shared with the list's row menu — it owns the API call, the toast and the
@@ -155,48 +164,66 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
return (
<section className={styles.dashboardPageToolbarContainer}>
<div className={styles.dashboardInfoWithActions}>
<DashboardInfo
title={title}
image={image}
tags={tags}
description={description}
isPublicDashboard={isPublic}
publicUrl={publicUrl}
isDashboardLocked={isDashboardLocked}
showLockToggle={showLockToggle}
onToggleLock={
canToggleLock
? (): void => handleLockDashboardToggle('header')
: undefined
}
lockDisabledTooltip={lockDisabledTooltip}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}
onStartEdit={startEdit}
onCommit={commit}
onCancel={cancel}
/>
<DashboardActions
title={title}
dashboard={dashboard}
handle={handle}
isDashboardLocked={isDashboardLocked}
onAddPanel={onAddPanel}
onLockToggle={(): void => handleLockDashboardToggle('menu')}
onOpenRename={startEdit}
/>
</div>
{(hasTitleRow || hasActions) && (
<div className={styles.dashboardInfoWithActions}>
{hasTitleRow && (
<DashboardInfo
title={title}
image={image}
tags={tags}
description={description}
isPublicDashboard={isPublic}
publicUrl={publicUrl}
isDashboardLocked={isDashboardLocked}
showLockToggle={showLockToggle}
onToggleLock={
canToggleLock
? (): void => handleLockDashboardToggle('header')
: undefined
}
lockDisabledTooltip={lockDisabledTooltip}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}
onStartEdit={startEdit}
onCommit={commit}
onCancel={cancel}
/>
)}
{hasActions && (
<DashboardActions
title={title}
dashboard={dashboard}
handle={handle}
isDashboardLocked={isDashboardLocked}
onAddPanel={onAddPanel}
onLockToggle={(): void => handleLockDashboardToggle('menu')}
onOpenRename={startEdit}
/>
)}
</div>
)}
{/* Row 2: the time selector floats top-right (declared first so the
variables bar's content wraps around it); the variables bar
collapses to one line and, when expanded, wraps full-width under it. */}
<div className={styles.toolbarRow}>
<div className={styles.timeCluster}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
<VariablesBar dashboard={dashboard} />
{showToolbar.timeRange && (
<div className={styles.timeCluster}>
<DateTimeSelectionV2
showAutoRefresh={showToolbar.autoRefresh}
hideShareModal
/>
</div>
)}
{showToolbar.variables ? (
<VariablesBar dashboard={dashboard} />
) : (
// Still mounted: the selectors seed and auto-select the values panels query with.
<div hidden>
<VariablesBar dashboard={dashboard} />
</div>
)}
</div>
<PanelTypeSelectionModal
open={isPickerOpen}

View File

@@ -26,9 +26,13 @@ const PUBLIC_META_STALE_TIME = 5 * 60 * 1000;
*/
export function usePublicDashboardMeta(
dashboardId: string,
options?: { enabled?: boolean },
): UsePublicDashboardMetaReturn {
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const enabled = !!dashboardId && (isCloudUser || isEnterpriseSelfHostedUser);
const enabled =
(options?.enabled ?? true) &&
!!dashboardId &&
(isCloudUser || isEnterpriseSelfHostedUser);
const { data, isLoading, isFetching, error, refetch } = useGetPublicDashboard(
{ id: dashboardId },

View File

@@ -2,33 +2,17 @@ import { renderHook } from '@testing-library/react';
import { useContextLinkVariables } from '../useContextLinkVariables';
const mockUseGetDashboardV2 = jest.fn();
const mockUseDashboardVariableNames = jest.fn();
const mockUseQueryBuilder = jest.fn();
jest.mock(
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (
selector: (s: { dashboardId: string }) => unknown,
): unknown => selector({ dashboardId: 'dash-1' }),
}),
);
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => mockUseQueryBuilder(),
}));
jest.mock('api/generated/services/dashboard', () => ({
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
}));
// dtoToFormModel is exercised by its own suite; here we only need it to surface a name.
jest.mock(
'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters',
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
() => ({
dtoToFormModel: (dto: { name: string }): { name: string } => ({
name: dto.name,
}),
useDashboardVariableNames: (): unknown => mockUseDashboardVariableNames(),
}),
);
@@ -45,11 +29,7 @@ describe('useContextLinkVariables', () => {
},
},
});
mockUseGetDashboardV2.mockReturnValue({
data: {
data: { spec: { variables: [{ name: 'region' }, { name: 'tier' }] } },
},
});
mockUseDashboardVariableNames.mockReturnValue(['region', 'tier']);
});
it('orders globals, then _prefixed query fields (deduped), then dashboard vars', () => {
@@ -69,9 +49,7 @@ describe('useContextLinkVariables', () => {
mockUseQueryBuilder.mockReturnValue({
currentQuery: { builder: { queryData: [] } },
});
// Loaded dashboard with no variables — useDashboardFetchRequired guarantees the
// dashboard is present, so the empty case is an empty spec, not `undefined`.
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
mockUseDashboardVariableNames.mockReturnValue([]);
const { result } = renderHook(() => useContextLinkVariables());

View File

@@ -10,6 +10,7 @@ import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import type { DashboardSection } from '../../../../utils';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { usePanelActionItems } from '../usePanelActionItems';
/** Keys of the disabled items, in order. */
@@ -494,4 +495,43 @@ describe('usePanelActionItems', () => {
click(actionRow(result.current.items, 'create-alert'));
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
});
describe('host controls', () => {
afterEach(() => {
useDashboardStore
.getState()
.setEditContext({ dashboardId: '', refetch: jest.fn() });
});
it('drops every writing action on a read-only mount', () => {
useDashboardStore.getState().setEditContext({
dashboardId: 'dash-1',
refetch: jest.fn(),
readOnly: true,
});
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'separator',
'download',
'create-alert',
]);
});
it('drops the actions the host hides', () => {
useDashboardStore.getState().setEditContext({
dashboardId: 'dash-1',
refetch: jest.fn(),
controls: { panelActions: { createAlert: false, delete: false } },
});
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).not.toContain('create-alert');
expect(itemKeys(result.current)).not.toContain('delete-panel');
expect(itemKeys(result.current)).toContain('edit-panel');
});
});
});

View File

@@ -29,6 +29,7 @@ import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/t
import { blockedBy } from 'lib/authz/components/AuthZDropdown/utils';
import { getBlockedPanelActions } from '../utils/getBlockedPanelActions';
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
import { useDashboardControlGroup } from '../../../controls/useDashboardControlGroup';
// Stable fallback so renders without layout context don't churn the mutation
// hooks' deps (a fresh [] each render would re-create their callbacks).
@@ -66,6 +67,7 @@ export function usePanelActionItems({
const blocked = useMemo(() => getBlockedPanelActions(panel), [panel]);
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
const show = useDashboardControlGroup('panelActions');
const openPanelEditor = useOpenPanelEditor();
const createAlert = useCreateAlertFromPanel();
const { openView } = useViewPanel();
@@ -103,7 +105,7 @@ export function usePanelActionItems({
const editGate = { checks: editChecks, ...blockedBy(editDisabledTooltip) };
const panelGroup: AuthZDropdownItemType[] = [];
if (panelCapabilities.view) {
if (show.view && panelCapabilities.view) {
panelGroup.push({
type: 'item',
value: 'view-panel',
@@ -112,7 +114,7 @@ export function usePanelActionItems({
onClick: (): void => openView(panelId, panel),
});
}
if (panelCapabilities.edit) {
if (show.edit && panelCapabilities.edit) {
panelGroup.push({
type: 'item',
value: 'edit-panel',
@@ -122,7 +124,7 @@ export function usePanelActionItems({
onClick: (): void => openPanelEditor(panelId, { panel }),
});
}
if (panelCapabilities.clone) {
if (show.clone && panelCapabilities.clone) {
// Needs section context to place the copy; disabled without it.
panelGroup.push({
type: 'item',
@@ -142,13 +144,13 @@ export function usePanelActionItems({
}
const dataGroup: AuthZDropdownItemType[] = [];
if (downloadItem) {
if (show.download && downloadItem) {
dataGroup.push(downloadItem);
}
// Create Alerts opens a new tab and never mutates the dashboard, so —
// unlike edit/clone — it isn't gated on editability (V1 parity).
if (panelCapabilities.createAlert) {
if (show.createAlert && panelCapabilities.createAlert) {
dataGroup.push({
type: 'item',
value: 'create-alert',
@@ -159,8 +161,9 @@ export function usePanelActionItems({
});
}
const moveGroup: AuthZDropdownItemType[] =
isEditable && panelActions
const moveGroup: AuthZDropdownItemType[] = !show.move
? []
: isEditable && panelActions
? buildMoveItems({
sections,
currentLayoutIndex: panelActions.currentLayoutIndex,
@@ -177,17 +180,19 @@ export function usePanelActionItems({
},
];
const deleteGroup: AuthZDropdownItemType[] = [
{
type: 'item',
value: 'delete-panel',
label: 'Delete panel',
prefix: <Trash2 size={14} />,
danger: true,
...editGate,
onClick: (): void => requestDelete(),
},
];
const deleteGroup: AuthZDropdownItemType[] = !show.delete
? []
: [
{
type: 'item',
value: 'delete-panel',
label: 'Delete panel',
prefix: <Trash2 size={14} />,
danger: true,
...editGate,
onClick: (): void => requestDelete(),
},
];
return [panelGroup, dataGroup, moveGroup, deleteGroup]
.filter((group) => group.length > 0)
@@ -197,6 +202,7 @@ export function usePanelActionItems({
: [{ type: 'separator', value: `separator-${index}` }, ...group],
);
}, [
show,
isEditable,
editChecks,
editDisabledTooltip,

View File

@@ -14,6 +14,7 @@ import { EQueryType } from 'types/common/dashboard';
import styles from './ViewPanelModal.module.scss';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
import { useDashboardControl } from '../../../controls/useDashboardControl';
interface ViewPanelModalHeaderBaseProps {
onSwitchToEdit: () => void;
@@ -70,6 +71,7 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
editChecks,
editDisabledTooltip,
} = useDashboardEditContext();
const showSwitchToEdit = useDashboardControl('viewModal.switchToEdit');
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
const panelTypeItems = usePanelTypeSelectItems({
@@ -87,20 +89,22 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
onChange={onChangePanelKind}
/>
</div>
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
size="md"
variant="outlined"
color="secondary"
prefix={<PenLine />}
disabled={!canSwitchToEdit}
onClick={onSwitchToEdit}
testId="view-panel-switch-to-edit"
>
Switch to Edit Mode
</Button>
</AuthZTooltip>
{showSwitchToEdit && (
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
size="md"
variant="outlined"
color="secondary"
prefix={<PenLine />}
disabled={!canSwitchToEdit}
onClick={onSwitchToEdit}
testId="view-panel-switch-to-edit"
>
Switch to Edit Mode
</Button>
</AuthZTooltip>
)}
{query && (
<Button
size="md"

View File

@@ -0,0 +1,71 @@
import { render, screen } from 'tests/test-utils';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import ViewPanelModalHeader from '../ViewPanelModalHeader';
import type { DashboardControlsInput } from '../../../../controls/types';
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
() => ({
useDashboardEditContext: (): unknown => ({
isEditable: true,
editChecks: [],
editDisabledTooltip: '',
}),
}),
);
jest.mock(
'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems',
() => ({ usePanelTypeSelectItems: (): unknown[] => [] }),
);
function renderHeader(
readOnly: boolean,
controls?: DashboardControlsInput,
): void {
useDashboardStore.getState().setEditContext({
dashboardId: 'dash-1',
refetch: jest.fn(),
readOnly,
controls,
});
render(
<ViewPanelModalHeader
mode="static"
panelKind="signoz/TimeSeriesPanel"
onChangePanelKind={jest.fn()}
onSwitchToEdit={jest.fn()}
/>,
);
}
describe('ViewPanelModalHeader - controls', () => {
afterEach(() => {
useDashboardStore
.getState()
.setEditContext({ dashboardId: '', refetch: jest.fn() });
});
it('offers Switch to Edit Mode on an editable mount', () => {
renderHeader(false);
expect(screen.getByTestId('view-panel-switch-to-edit')).toBeInTheDocument();
});
it('hides Switch to Edit Mode on a read-only mount', () => {
renderHeader(true);
expect(screen.getByTestId('view-panel-type-selector')).toBeInTheDocument();
expect(
screen.queryByTestId('view-panel-switch-to-edit'),
).not.toBeInTheDocument();
});
it('hides Switch to Edit Mode when the host hides it', () => {
renderHeader(false, { viewModal: false });
expect(
screen.queryByTestId('view-panel-switch-to-edit'),
).not.toBeInTheDocument();
});
});

View File

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

View File

@@ -0,0 +1,51 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { AllTheProviders, renderHook } from 'tests/test-utils';
import { CHARTS_ONLY } from '../../../../controls/presets';
import type { DashboardControlsInput } from '../../../../controls/types';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { usePanelInteractions } from '../usePanelInteractions';
function dragSelect(controls?: DashboardControlsInput): void {
useDashboardStore
.getState()
.setEditContext({ dashboardId: 'd1', refetch: jest.fn(), controls });
const { result } = renderHook(() => usePanelInteractions(), {
wrapper: AllTheProviders,
});
result.current.onDragSelect(1_000, 2_000);
}
describe('usePanelInteractions drag-to-zoom', () => {
afterEach(() => {
useDashboardStore
.getState()
.setEditContext({ dashboardId: '', refetch: jest.fn() });
});
it('writes the dragged range to the URL', () => {
dragSelect();
expect(safeNavigateMock).toHaveBeenCalledWith(
expect.stringContaining('startTime=1000&endTime=2000'),
);
});
it('does nothing when the host turns zoom off', () => {
dragSelect({ panelActions: { zoom: false } });
expect(safeNavigateMock).not.toHaveBeenCalled();
});
it('still zooms when the host hides the toolbar but leaves zoom on', () => {
dragSelect({ toolbar: false });
expect(safeNavigateMock).toHaveBeenCalled();
});
it('still zooms on a charts-only mount', () => {
dragSelect(CHARTS_ONLY);
expect(safeNavigateMock).toHaveBeenCalled();
});
});

View File

@@ -33,6 +33,7 @@ import { useDrilldownCoordinates } from './useDrilldownCoordinates';
import { useDrilldownDashboardVariables } from './useDrilldownDashboardVariables';
import { useDrilldownFilter } from './useDrilldownFilter';
import { useResolvedDrilldownQuery } from './useResolvedDrilldownQuery';
import { useDashboardControl } from 'pages/DashboardPage/DashboardContainer/controls/useDashboardControl';
import { useViewPanel } from './useViewPanel';
/** Which menu the popover shows; extend as submenus are added (e.g. dashboard variables). */
@@ -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(

View File

@@ -13,6 +13,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import logEvent from 'api/common/logEvent';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { UpdateTimeInterval } from 'store/actions';
import { useDashboardControl } from 'pages/DashboardPage/DashboardContainer/controls/useDashboardControl';
export interface PanelInteractions {
/** Drag-select a chart range → write it to the URL + global time so every panel re-fetches the same range. */
@@ -43,8 +44,12 @@ export function usePanelInteractions(): PanelInteractions {
[syncMode, syncFilterMode, dashboardId],
);
const canZoom = useDashboardControl('panelActions.zoom');
const onDragSelect = useCallback(
(start: number, end: number): void => {
if (!canZoom) {
return;
}
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
@@ -57,7 +62,7 @@ export function usePanelInteractions(): PanelInteractions {
void logEvent(DashboardDetailEvents.PanelZoomed, { context: 'panel' });
}
},
[dispatch, pathname, safeNavigate, urlQuery],
[canZoom, dispatch, pathname, safeNavigate, urlQuery],
);
return { onDragSelect, dashboardPreference };

View File

@@ -8,12 +8,12 @@ import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { useCreatePanel } from '../../../hooks/useCreatePanel';
import type { DashboardSection } from '../../../utils';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import { useCloneSection } from '../hooks/useCloneSection';
import { useDeleteSection } from '../hooks/useDeleteSection';
import { usePanelPickerHighlight } from '../hooks/usePanelPickerHighlight';
import { usePanelPickerReveal } from '../hooks/usePanelPickerReveal';
import { useRenameSection } from '../hooks/useRenameSection';
import { useScrollIntoView } from '../hooks/useScrollIntoView';
import { useSectionHeaderActions } from '../hooks/useSectionHeaderActions';
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
import SectionTitleModal from '../SectionTitleModal';
import SectionGrid from '../SectionGrid/SectionGrid';
@@ -66,7 +66,14 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
setIsDeleteOpen(false);
}, [deleteSection]);
const cloneSection = useCloneSection();
const openRename = useCallback((): void => setIsRenaming(true), []);
const openDelete = useCallback((): void => setIsDeleteOpen(true), []);
const actions = useSectionHeaderActions({
section,
openPicker,
openRename,
openDelete,
});
const sectionRef = useRef<HTMLDivElement>(null);
useScrollIntoView(section.id, sectionRef);
@@ -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"

View File

@@ -25,7 +25,7 @@ function SectionActionsMenu({
onRename,
onCloneSection,
onDeleteSection,
}: SectionActionsMenuProps): JSX.Element {
}: SectionActionsMenuProps): JSX.Element | null {
const items = useMemo<AuthZDropdownItemType[]>(() => {
const gate = { checks: disabledChecks, ...blockedBy(disabledTooltip) };
const result: AuthZDropdownItemType[] = [];
@@ -83,6 +83,10 @@ function SectionActionsMenu({
onDeleteSection,
]);
if (items.length === 0) {
return null;
}
return (
<AuthZDropdown
items={items}

View File

@@ -16,12 +16,12 @@ export interface SectionDragHandle {
setActivatorNodeRef: (element: HTMLElement | null) => void;
}
/** Editable-mode section actions — present together or not at all. */
/** Section actions; an absent one isn't offered. */
export interface SectionHeaderActions {
onRename: () => void;
onAddPanel: () => void;
onCloneSection: () => void;
onDeleteSection: () => void;
onRename?: () => void;
onAddPanel?: () => void;
onCloneSection?: () => void;
onDeleteSection?: () => void;
}
interface SectionHeaderProps {

View File

@@ -0,0 +1,36 @@
import { useMemo } from 'react';
import { useDashboardControlGroup } from '../../../controls/useDashboardControlGroup';
import type { DashboardSection } from '../../../utils';
import type { SectionHeaderActions } from '../SectionHeader/SectionHeader';
import { useCloneSection } from './useCloneSection';
/** The section menu's actions, minus the ones the host hides (`sectionActions`). */
export function useSectionHeaderActions({
section,
openPicker,
openRename,
openDelete,
}: {
section: DashboardSection;
openPicker: (layoutIndex: number) => void;
openRename: () => void;
openDelete: () => void;
}): SectionHeaderActions {
const show = useDashboardControlGroup('sectionActions');
const cloneSection = useCloneSection();
return useMemo(
() => ({
onRename: show.rename ? openRename : undefined,
onAddPanel: show.addPanel
? (): void => openPicker(section.layoutIndex)
: undefined,
onCloneSection: show.clone
? (): void => void cloneSection(section)
: undefined,
onDeleteSection: show.delete ? openDelete : undefined,
}),
[show, openRename, openPicker, section, cloneSection, openDelete],
);
}

View File

@@ -16,6 +16,7 @@ import { resolveDefaultSelection } from './utils/resolveVariableSelection';
import VariableSelector from './components/VariableSelector/VariableSelector';
import styles from './VariablesBar.module.scss';
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
import { useDashboardControl } from '../controls/useDashboardControl';
interface VariablesBarProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
@@ -39,6 +40,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
// Persisted per dashboard so the full/collapsed view survives reloads.
const expanded = useDashboardStore(selectVariablesExpanded(dashboardId));
const setVariablesExpanded = useDashboardStore((s) => s.setVariablesExpanded);
const showAddVariable = useDashboardControl('toolbar.addVariable');
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
itemCount: variables.length,
gap: 8,
@@ -49,7 +51,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
// Editors can add a variable even before any exist; viewers with no variables
// have nothing to show.
if (variables.length === 0 && !isEditable) {
if (variables.length === 0 && !(isEditable && showAddVariable)) {
return null;
}
@@ -131,13 +133,15 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
{/* After the more/less trigger, in every state. Kept inline (not block)
so the row still flows under the floated time selector, and always
mounted so measuring never toggles it. */}
<span className={styles.addSlot}>
<AddVariableIcon
checks={editChecks}
disabledTooltip={editDisabledTooltip}
isEditable={isEditable}
/>
</span>
{showAddVariable && (
<span className={styles.addSlot}>
<AddVariableIcon
checks={editChecks}
disabledTooltip={editDisabledTooltip}
isEditable={isEditable}
/>
</span>
)}
</div>
</div>
);

View File

@@ -0,0 +1,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();
});
});

View File

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

View File

@@ -1,6 +1,7 @@
import { memo } from 'react';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
import DashboardPageBreadcrumbs from './DashboardPageBreadcrumbs';
import { useShareVariablesOption } from './useShareVariablesOption';
@@ -16,13 +17,16 @@ function DashboardPageHeader({
image,
}: DashboardPageHeaderProps): JSX.Element {
const shareVariablesOption = useShareVariablesOption();
const show = useDashboardControlGroup('header');
return (
<div className={styles.dashboardPageHeader}>
<DashboardPageBreadcrumbs title={title} image={image} />
{show.breadcrumbs && (
<DashboardPageBreadcrumbs title={title} image={image} />
)}
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
enableShare={show.share}
enableFeedback={show.feedback}
shareModalExtraOption={shareVariablesOption}
/>
</div>

View File

@@ -0,0 +1,57 @@
import {
CONTROL_WRITES,
DEFAULT_CONTROLS,
isControlVisible,
resolveControls,
} from '../dashboardControls';
import type { DashboardControlId } from '../types';
const ALL_IDS = Object.keys(CONTROL_WRITES) as DashboardControlId[];
describe('resolveControls', () => {
it('shows every control when the host passes nothing', () => {
expect(resolveControls(undefined, false)).toStrictEqual(DEFAULT_CONTROLS);
});
it('hides a whole group from one boolean', () => {
const controls = resolveControls({ header: false }, false);
expect(controls.header).toStrictEqual({
breadcrumbs: false,
share: false,
feedback: false,
});
expect(controls.title.name).toBe(true);
});
it('hides single items and keeps the rest of the group', () => {
const controls = resolveControls(
{ panelActions: { createAlert: false } },
false,
);
expect(controls.panelActions.createAlert).toBe(false);
expect(controls.panelActions.download).toBe(true);
});
it('hides every writing control on a read-only mount, whatever the host asked', () => {
const controls = resolveControls(
{ dashboardActions: true, panelActions: { edit: true } },
true,
);
ALL_IDS.forEach((id) => {
expect([id, isControlVisible(controls, id)]).toStrictEqual([
id,
!CONTROL_WRITES[id],
]);
});
});
it('still lets the host hide non-writing controls on a read-only mount', () => {
const controls = resolveControls({ panelActions: { download: false } }, true);
expect(controls.panelActions.download).toBe(false);
expect(controls.panelActions.view).toBe(true);
});
});

View File

@@ -0,0 +1,48 @@
import { resolveControls } from '../dashboardControls';
import { CHARTS_ONLY, EMBEDDED, withControls } from '../presets';
describe('withControls', () => {
it('adds an override group the preset leaves alone', () => {
expect(
withControls(EMBEDDED, { toolbar: { autoRefresh: false } }),
).toStrictEqual({
header: false,
title: false,
dashboardActions: false,
toolbar: { autoRefresh: false },
});
});
it('turns single items back on inside a group the preset hides', () => {
const merged = withControls(CHARTS_ONLY, { panelActions: { view: true } });
const controls = resolveControls(merged, false);
expect(controls.panelActions.view).toBe(true);
expect(controls.panelActions.download).toBe(false);
expect(controls.panelActions.drilldown).toBe(false);
});
it('lets a later whole-group boolean replace earlier items', () => {
expect(
withControls(
{ panelActions: { createAlert: false } },
{ panelActions: true },
),
).toStrictEqual({ panelActions: true });
});
it('lets later items win over earlier ones', () => {
expect(
withControls(
{ toolbar: { timeRange: false, autoRefresh: false } },
{ toolbar: { timeRange: true } },
),
).toStrictEqual({ toolbar: { timeRange: true, autoRefresh: false } });
});
it('cannot turn a writing control back on for a read-only mount', () => {
const merged = withControls(EMBEDDED, { dashboardActions: { delete: true } });
expect(resolveControls(merged, true).dashboardActions.delete).toBe(false);
});
});

View File

@@ -0,0 +1,136 @@
import type {
DashboardControlGroup,
DashboardControlId,
DashboardControls,
DashboardControlsInput,
} from './types';
export const DEFAULT_CONTROLS: DashboardControls = {
header: { breadcrumbs: true, share: true, feedback: true },
title: {
name: true,
description: true,
tags: true,
lock: true,
publicBadge: true,
},
toolbar: {
timeRange: true,
autoRefresh: true,
variables: true,
addVariable: true,
},
dashboardActions: {
fullscreen: true,
rename: true,
clone: true,
lock: true,
newSection: true,
delete: true,
configure: true,
json: true,
newPanel: true,
},
sectionActions: { addPanel: true, rename: true, clone: true, delete: true },
panelActions: {
view: true,
edit: true,
clone: true,
download: true,
createAlert: true,
move: true,
delete: true,
drilldown: true,
zoom: true,
},
viewModal: { switchToEdit: true },
};
/**
* Whether each control writes to the dashboard. A read-only mount hides every
* `true` here regardless of what the host asked for. Exhaustive, so a new
* control can't ship without deciding.
*/
export const CONTROL_WRITES: Record<DashboardControlId, boolean> = {
'header.breadcrumbs': false,
'header.share': false,
'header.feedback': false,
'title.name': false,
'title.description': false,
'title.tags': false,
'title.lock': true,
'title.publicBadge': false,
'toolbar.timeRange': false,
'toolbar.autoRefresh': false,
'toolbar.variables': false,
'toolbar.addVariable': true,
'dashboardActions.fullscreen': false,
'dashboardActions.rename': true,
'dashboardActions.clone': true,
'dashboardActions.lock': true,
'dashboardActions.newSection': true,
'dashboardActions.delete': true,
'dashboardActions.configure': true,
// Opens read-only on a non-editable mount.
'dashboardActions.json': false,
'dashboardActions.newPanel': true,
'sectionActions.addPanel': true,
'sectionActions.rename': true,
'sectionActions.clone': true,
'sectionActions.delete': true,
'panelActions.view': false,
'panelActions.edit': true,
'panelActions.clone': true,
'panelActions.download': false,
'panelActions.createAlert': false,
'panelActions.move': true,
'panelActions.delete': true,
'panelActions.drilldown': false,
'panelActions.zoom': false,
'viewModal.switchToEdit': true,
};
function resolveGroup<G extends DashboardControlGroup>(
group: G,
input: DashboardControlsInput | undefined,
readOnly: boolean,
): DashboardControls[G] {
const defaults: Record<string, boolean> = DEFAULT_CONTROLS[group];
const override = input?.[group] as
| boolean
| Partial<Record<string, boolean>>
| undefined;
const items = Object.keys(defaults).map((item): [string, boolean] => {
const requested =
typeof override === 'boolean'
? override
: (override?.[item] ?? defaults[item]);
const writes = CONTROL_WRITES[`${group}.${item}` as DashboardControlId];
return [item, requested && !(readOnly && writes)];
});
return Object.fromEntries(items) as DashboardControls[G];
}
/** Host input over the defaults, then every writing control off when read-only. */
export function resolveControls(
input: DashboardControlsInput | undefined,
readOnly: boolean,
): DashboardControls {
return {
header: resolveGroup('header', input, readOnly),
title: resolveGroup('title', input, readOnly),
toolbar: resolveGroup('toolbar', input, readOnly),
dashboardActions: resolveGroup('dashboardActions', input, readOnly),
sectionActions: resolveGroup('sectionActions', input, readOnly),
panelActions: resolveGroup('panelActions', input, readOnly),
viewModal: resolveGroup('viewModal', input, readOnly),
};
}
export function isControlVisible(
controls: DashboardControls,
id: DashboardControlId,
): boolean {
const [group, item] = id.split('.') as [DashboardControlGroup, string];
return (controls[group] as Record<string, boolean>)[item];
}

View File

@@ -0,0 +1,74 @@
import { DEFAULT_CONTROLS } from './dashboardControls';
import type {
DashboardControlGroup,
DashboardControls,
DashboardControlsInput,
} from './types';
/**
* The host page owns the header, the title row and the dashboard-level actions.
* Layout presets only: `readOnly` stays a separate switch, so no preset can turn
* a writing control back on.
*/
export const EMBEDDED: DashboardControlsInput = {
header: false,
title: false,
dashboardActions: false,
};
/** Embedded, and panels are just charts: no panel menu, no drilldown. Charts still zoom. */
export const CHARTS_ONLY: DashboardControlsInput = {
...EMBEDDED,
panelActions: {
view: false,
edit: false,
clone: false,
download: false,
createAlert: false,
move: false,
delete: false,
drilldown: false,
},
};
type GroupInput<G extends DashboardControlGroup> =
| boolean
| Partial<DashboardControls[G]>;
function mergeGroup<G extends DashboardControlGroup>(
group: G,
base: GroupInput<G> | undefined,
next: GroupInput<G> | undefined,
): GroupInput<G> | undefined {
if (next === undefined) {
return base;
}
if (typeof next === 'boolean' || base === undefined) {
return next;
}
// A whole-group boolean underneath expands to its items before the override.
const expanded: Partial<DashboardControls[G]> =
typeof base === 'boolean'
? (Object.fromEntries(
Object.keys(DEFAULT_CONTROLS[group]).map((item) => [item, base]),
) as Partial<DashboardControls[G]>)
: base;
return { ...expanded, ...next };
}
/** Merges presets and overrides; later inputs win, item by item. */
export function withControls(
...inputs: DashboardControlsInput[]
): DashboardControlsInput {
const groups = Object.keys(DEFAULT_CONTROLS) as DashboardControlGroup[];
return inputs.reduce<DashboardControlsInput>((merged, input) => {
const result: DashboardControlsInput = { ...merged };
groups.forEach((group) => {
const value = mergeGroup(group, merged[group], input[group]);
if (value !== undefined) {
(result as Record<string, unknown>)[group] = value;
}
});
return result;
}, {});
}

View File

@@ -0,0 +1,53 @@
import type { PanelActionId } from '../PanelsAndSectionsLayout/Panel/PanelActionsMenu/panelActionMeta';
/** Every control a host can show or hide, grouped by where it renders. */
export interface DashboardControls {
header: { breadcrumbs: boolean; share: boolean; feedback: boolean };
title: {
name: boolean;
description: boolean;
tags: boolean;
lock: boolean;
publicBadge: boolean;
};
toolbar: {
timeRange: boolean;
autoRefresh: boolean;
variables: boolean;
addVariable: boolean;
};
dashboardActions: {
fullscreen: boolean;
rename: boolean;
clone: boolean;
lock: boolean;
newSection: boolean;
delete: boolean;
configure: boolean;
json: boolean;
newPanel: boolean;
};
sectionActions: {
addPanel: boolean;
rename: boolean;
clone: boolean;
delete: boolean;
};
// Keyed by the panel menu's own ids, so a new panel action needs a control.
// `drilldown` (click) and `zoom` (drag-select a time range) act on the chart itself.
panelActions: Record<PanelActionId | 'drilldown' | 'zoom', boolean>;
viewModal: { switchToEdit: boolean };
}
export type DashboardControlGroup = keyof DashboardControls;
export type DashboardControlId = {
[
G in DashboardControlGroup
]: `${G}.${Extract<keyof DashboardControls[G], string>}`;
}[DashboardControlGroup];
/** A whole group as one boolean, or single items; omitted means shown. */
export type DashboardControlsInput = {
[G in DashboardControlGroup]?: boolean | Partial<DashboardControls[G]>;
};

View File

@@ -0,0 +1,12 @@
import { useDashboardStore } from '../store/useDashboardStore';
import { isControlVisible } from './dashboardControls';
import type { DashboardControlId } from './types';
/**
* Whether a control renders on this mount: the host's `controls`, with every
* writing control off when read-only. Lock and permission state stay with
* `useDashboardEditContext`, which decides disabled + reason.
*/
export function useDashboardControl(id: DashboardControlId): boolean {
return useDashboardStore((s) => isControlVisible(s.controls, id));
}

View File

@@ -0,0 +1,9 @@
import { useDashboardStore } from '../store/useDashboardStore';
import type { DashboardControlGroup, DashboardControls } from './types';
/** Every control in a group, resolved for this mount (see `useDashboardControl`). */
export function useDashboardControlGroup<G extends DashboardControlGroup>(
group: G,
): DashboardControls[G] {
return useDashboardStore((s) => s.controls[group]);
}

View File

@@ -11,6 +11,7 @@ import {
buildDashboardUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useDashboardEditContext } from '../useDashboardEditContext';
const DASHBOARD_ID = 'dash-1';
@@ -44,6 +45,23 @@ describe('useDashboardEditContext - AuthZ', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
useDashboardStore.setState({ readOnly: false });
});
// A host-forced view-only mount outranks every grant.
it('is read-only on a read-only mount despite full permissions', async () => {
server.use(setupAuthzAdmin());
useDashboardStore.setState({ readOnly: true });
const { result } = renderGuard();
await waitFor(() =>
expect(result.current.editDisabledTooltip).toBe('dashboard_read_only_here'),
);
expect(result.current.isEditable).toBe(false);
expect(result.current.canEditDashboard).toBe(false);
expect(result.current.canDeleteDashboard).toBe(false);
expect(result.current.deleteDisabledTooltip).toBe('dashboard_read_only_here');
});
describe('permission granted', () => {

View File

@@ -1,57 +1,115 @@
import { renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { renderHook } from 'tests/test-utils';
import {
type DashboardtypesGettableDashboardV2DTO,
type DashboardtypesVariableDTO,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind as TextVariableKind,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardQuerySource } from '../../store/slices/editContextSlice';
import { useDashboardFetchRequired } from '../useDashboardFetchRequired';
const mockUseGetDashboardV2 = jest.fn();
const QUERY_KEY = ['/api/v2/dashboards/dash-1'];
const mockRefetch = jest.fn();
let mockSource: DashboardQuerySource | null;
jest.mock(
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (
selector: (s: { dashboardId: string }) => unknown,
): unknown => selector({ dashboardId: 'dash-1' }),
selector: (s: {
dashboardId: string;
dashboardQuerySource: DashboardQuerySource | null;
refetch: () => void;
}) => unknown,
): unknown =>
selector({
dashboardId: 'dash-1',
dashboardQuerySource: mockSource,
refetch: mockRefetch,
}),
}),
);
jest.mock('api/generated/services/dashboard', () => ({
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
}));
function textVariable(name: string): DashboardtypesVariableDTO {
return {
kind: TextVariableKind.TextVariable,
spec: { name, display: { name }, value: '' },
};
}
function dashboardWith(
spec: Partial<DashboardtypesGettableDashboardV2DTO['spec']>,
): DashboardtypesGettableDashboardV2DTO {
return { id: 'dash-1', spec } as DashboardtypesGettableDashboardV2DTO;
}
function renderWithCache(
cached?: unknown,
): ReturnType<
typeof renderHook<ReturnType<typeof useDashboardFetchRequired>, void>
> {
const queryClient = new QueryClient();
if (cached !== undefined) {
queryClient.setQueryData(mockSource?.queryKey ?? QUERY_KEY, cached);
}
return renderHook(() => useDashboardFetchRequired(), {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
});
}
describe('useDashboardFetchRequired', () => {
it('returns the loaded dashboard and its variables', () => {
const dashboard = {
spec: { variables: [{ name: 'region' }, { name: 'tier' }] },
};
mockUseGetDashboardV2.mockReturnValue({ data: { data: dashboard } });
beforeEach(() => {
mockSource = null;
});
const { result } = renderHook(() => useDashboardFetchRequired());
it('reads the dashboards API entry when the host sets no source', () => {
const variables = [textVariable('region'), textVariable('tier')];
const dashboard = dashboardWith({ variables });
const { result } = renderWithCache({ data: dashboard, status: 'success' });
expect(result.current.dashboard).toBe(dashboard);
expect(result.current.variables).toStrictEqual([
{ name: 'region' },
{ name: 'tier' },
]);
expect(result.current.variables).toBe(variables);
expect(result.current.refetch).toBe(mockRefetch);
});
it("reads a host's own cache entry through its select", () => {
const systemDashboard = { name: 'ai-o11y-overview', spec: {} };
mockSource = {
queryKey: ['/api/v2/dashboards/system/ai-o11y-overview'],
select: (response: { data: typeof systemDashboard }) =>
({
...response.data,
id: 'system-id',
}) as DashboardtypesGettableDashboardV2DTO,
};
const { result } = renderWithCache({ data: systemDashboard });
expect(result.current.dashboard).toStrictEqual({
name: 'ai-o11y-overview',
spec: {},
id: 'system-id',
});
});
it('normalizes variables to an empty array when the spec has none', () => {
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
const { result } = renderHook(() => useDashboardFetchRequired());
const { result } = renderWithCache({ data: dashboardWith({}) });
expect(result.current.variables).toStrictEqual([]);
});
it('throws when used outside a loaded dashboard subtree', () => {
mockUseGetDashboardV2.mockReturnValue({ data: undefined });
it('throws when the source entry is empty', () => {
// Suppress React's expected render-error logging for this case.
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
expect(() => renderHook(() => useDashboardFetchRequired())).toThrow(
/outside a loaded dashboard subtree/,
);
expect(() => renderWithCache()).toThrow(/outside a loaded dashboard subtree/);
consoleError.mockRestore();
});

View File

@@ -111,3 +111,32 @@ describe('useGetQueryRangeV5 enabled gating', () => {
expect(mockQueryRangeV5).toHaveBeenCalledTimes(2);
});
});
describe('useGetQueryRangeV5 host fetch', () => {
it('fetches through the host fetch instead of query_range', async () => {
mockQueryRangeV5.mockReset();
const fetch = jest
.fn()
.mockResolvedValue({ status: 'success', data: {} } as QueryRangeV5200);
const wrapper = ({ children }: PropsWithChildren): JSX.Element => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
);
renderHook(
() =>
useGetQueryRangeV5({
requestPayload: REQUEST,
queryKey: KEY_A,
enabled: true,
fetch,
}),
{ wrapper },
);
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(fetch.mock.calls[0][0]).toBe(REQUEST);
expect(mockQueryRangeV5).not.toHaveBeenCalled();
});
});

View File

@@ -19,10 +19,12 @@ jest.mock('api/generated/services/dashboard', () => ({
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
}));
let mockReadOnly = false;
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: jest.fn(
(selector: (s: { dashboardId: string; isEditable: boolean }) => unknown) =>
selector({ dashboardId: 'dash-1', isEditable: true }),
(selector: (s: { dashboardId: string; readOnly: boolean }) => unknown) =>
selector({ dashboardId: 'dash-1', readOnly: mockReadOnly }),
),
}));
@@ -35,6 +37,7 @@ const queryClient = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let captured: { fn: (ops: any) => unknown; options: any };
let mockMutateAsync: jest.Mock;
function dashboardEnvelope(name: string): GetDashboardV2200 {
return {
@@ -50,10 +53,12 @@ const replaceNameOp = {
beforeEach(() => {
jest.clearAllMocks();
mockReadOnly = false;
mockMutateAsync = jest.fn().mockResolvedValue(undefined);
(useQueryClient as jest.Mock).mockReturnValue(queryClient);
(useMutation as jest.Mock).mockImplementation((fn, options) => {
captured = { fn, options };
return { mutateAsync: jest.fn(), isLoading: false };
return { mutateAsync: mockMutateAsync, isLoading: false };
});
renderHook(() => useOptimisticPatch());
});
@@ -119,4 +124,22 @@ describe('useOptimisticPatch', () => {
// No follow-up GET (that could read stale data and flicker the UI back).
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
});
it('patchAsync rejects without sending on a read-only mount', async () => {
mockReadOnly = true;
const { result } = renderHook(() => useOptimisticPatch());
await expect(result.current.patchAsync([replaceNameOp])).rejects.toThrow(
'dashboard_read_only_here',
);
expect(mockMutateAsync).not.toHaveBeenCalled();
});
it('patchAsync sends the ops when the mount is editable', async () => {
const { result } = renderHook(() => useOptimisticPatch());
await result.current.patchAsync([replaceNameOp]);
expect(mockMutateAsync).toHaveBeenCalledWith([replaceNameOp]);
});
});

View File

@@ -11,6 +11,7 @@ import {
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import { useDashboardStore } from '../../store/useDashboardStore';
import { usePanelQuery } from '../usePanelQuery';
import { useGetQueryRangeV5 } from '../useGetQueryRangeV5';
@@ -598,4 +599,58 @@ describe('usePanelQuery', () => {
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED);
});
});
describe('with a host query runner', () => {
const runner = {
key: 'public:abc',
run: jest.fn().mockResolvedValue({ status: 'success', data: {} }),
};
beforeEach(() => {
useDashboardStore.getState().setEditContext({
dashboardId: 'd1',
refetch: jest.fn(),
queryRunner: runner,
});
});
afterEach(() => {
useDashboardStore
.getState()
.setEditContext({ dashboardId: '', refetch: jest.fn() });
});
it('keys the cache by the runner and runs the query through it with the panel id', async () => {
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
const [{ queryKey, fetch, requestPayload }] =
mockUseGetQueryRangeV5.mock.calls[0];
expect(queryKey).toContain('public:abc');
await fetch(requestPayload);
expect(runner.run).toHaveBeenCalledWith({
panelId: 'p1',
requestPayload,
signal: undefined,
});
});
});
it('runs on query_range when no host runner is set', () => {
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
const [{ fetch }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(fetch).toBeUndefined();
});
});

View File

@@ -17,6 +17,7 @@ import { useDashboardFetchRequired } from './useDashboardFetchRequired';
*/
export function useDashboardEditContext(): DashboardEditContext {
const { dashboard } = useDashboardFetchRequired();
const readOnly = useDashboardStore((s) => s.readOnly);
const {
canRead,
canEdit,
@@ -24,12 +25,8 @@ export function useDashboardEditContext(): DashboardEditContext {
editChecks,
deletePermission,
areOtherPermissionsLoading,
} = useDashboardPermissions(dashboard.id);
} = useDashboardPermissions(dashboard.id, { enabled: !readOnly });
const { t } = useTranslation('dashboard');
const readOnlyOverride = useDashboardStore(
(s) => s.canEditDashboardOverride === false,
);
const deleteChecks = useMemo(() => [deletePermission], [deletePermission]);
return deriveEditContext({
@@ -44,6 +41,6 @@ export function useDashboardEditContext(): DashboardEditContext {
locked: t('dashboard_locked'),
readOnly: t('dashboard_read_only_here'),
},
readOnlyOverride,
readOnlyOverride: readOnly,
});
}

View File

@@ -1,34 +1,52 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
import { getGetDashboardV2QueryKey } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesVariableDTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import { useDashboardFetch } from './useDashboardFetch';
import type { UseDashboardFetchResult } from './useDashboardFetch';
export interface UseDashboardFetchRequiredResult {
/** Guaranteed present — the hook throws otherwise. */
dashboard: DashboardtypesGettableDashboardV2DTO;
/** Always an array, so callers skip `?? []` guards. */
variables: DashboardtypesVariableDTO[];
refetch: UseDashboardFetchResult['refetch'];
refetch: () => void;
}
const selectDashboardV2 = (
response: GetDashboardV2200,
): DashboardtypesGettableDashboardV2DTO => response.data;
/**
* Dashboard accessor for components rendered inside a loaded dashboard subtree. The
* root pages own the fetch and gate on a resolved dashboard before mounting their
* subtree, so this reuses the shared react-query cache entry and throws if the
* dashboard is missing — turning a silent `undefined` into a loud programmer error. It
* also derives spec collections (`variables`) since the dashboard is guaranteed here.
* root owns the fetch and publishes where it cached the dashboard
* (`dashboardQuerySource`); this observes that entry without fetching, and throws if
* it's empty — turning a silent `undefined` into a loud programmer error. It also
* derives spec collections (`variables`) since the dashboard is guaranteed here.
*
* Prefer this over `useDashboardFetch` outside the two root pages; the
* `signoz/no-dashboard-fetch-outside-root` lint rule enforces the split.
*/
export function useDashboardFetchRequired(): UseDashboardFetchRequiredResult {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { dashboard, refetch } = useDashboardFetch(dashboardId);
const source = useDashboardStore((s) => s.dashboardQuerySource);
const queryKey =
source?.queryKey ?? getGetDashboardV2QueryKey({ id: dashboardId });
const select = source?.select ?? selectDashboardV2;
const refetch = useDashboardStore((s) => s.refetch);
// Observe only: the root owns the request, so this never fetches.
const { data: dashboard } = useQuery<
unknown,
unknown,
DashboardtypesGettableDashboardV2DTO | undefined
>({
queryKey,
select: select as (response: unknown) => DashboardtypesGettableDashboardV2DTO,
enabled: false,
});
const variables = useMemo(
() => dashboard?.spec?.variables ?? [],
@@ -39,7 +57,7 @@ export function useDashboardFetchRequired(): UseDashboardFetchRequiredResult {
throw new Error(
'useDashboardFetchRequired was used outside a loaded dashboard subtree — the ' +
'dashboard is undefined. Only call it below a root page that gates rendering ' +
'on useDashboardFetch (DashboardPage / PanelEditorPage).',
'on its fetch and seeds the edit context (DashboardContainer / PanelEditorPage).',
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,66 +0,0 @@
import type { GridItem } from 'pages/DashboardPage/DashboardContainer/utils';
import GridLayout, { type Layout, WidthProvider } from 'react-grid-layout';
import PublicPanel from '../PublicPanel/PublicPanel';
const ResponsiveGridLayout = WidthProvider(GridLayout);
interface PublicSectionGridProps {
items: GridItem[];
publicDashboardId: string;
/** Epoch milliseconds. */
startMs: number;
/** Epoch milliseconds. */
endMs: number;
/** True once the section is on screen — forwarded to gate panel fetches. */
isVisible?: boolean;
}
// Fixed (non-editable) grid for one section of a v2 public dashboard.
function PublicSectionGrid({
items,
publicDashboardId,
startMs,
endMs,
isVisible,
}: PublicSectionGridProps): JSX.Element {
const layout: Layout[] = items.map((item) => ({
i: item.id,
x: item.x,
y: item.y,
w: item.width,
h: item.height,
static: true,
}));
return (
<ResponsiveGridLayout
cols={12}
rowHeight={45}
autoSize
useCSSTransforms
layout={layout}
isDraggable={false}
isResizable={false}
margin={[8, 8]}
>
{items.map((item) => (
// Empty cell for an orphan layout item (panel id missing from the map).
<div key={item.id}>
{item.panel && (
<PublicPanel
panel={item.panel}
panelKey={item.id}
publicDashboardId={publicDashboardId}
startMs={startMs}
endMs={endMs}
isVisible={isVisible}
/>
)}
</div>
))}
</ResponsiveGridLayout>
);
}
export default PublicSectionGrid;

View File

@@ -1,101 +1,111 @@
import { render, screen } from '@testing-library/react';
import type { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesSourceDTO,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind as TextVariableKind,
type DashboardtypesGettablePublicDashboardDataV2DTO,
} from 'api/generated/services/sigNoz.schemas';
import { getCurrentLocation } from 'lib/router/navigation';
import { render, screen } from 'tests/test-utils';
import PublicDashboardView from '../PublicDashboardView';
const mockGrid = jest.fn();
const mockContainer = jest.fn();
jest.mock('../PublicSectionGrid/PublicSectionGrid', () => ({
jest.mock('pages/DashboardPage/DashboardContainer', () => ({
__esModule: true,
default: (props: unknown): JSX.Element => {
mockGrid(props);
return <div data-testid="public-section-grid" />;
mockContainer(props);
return <div data-testid="dashboard-container" />;
},
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="datetime-selection" />,
}));
jest.mock('../PublicAutoRefresh/PublicAutoRefresh', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="auto-refresh" />,
}));
function buildData(
timeRangeEnabled: boolean,
): DashboardtypesGettablePublicDashboardDataV2DTO {
return {
dashboard: {
id: 'internal-id',
name: 'my-v2-dashboard',
orgId: 'org',
locked: false,
source: DashboardtypesSourceDTO.user,
tags: [],
schemaVersion: 'v6',
spec: {
display: { name: 'My V2 Dashboard' },
layouts: [
layouts: [],
panels: {},
variables: [
{
kind: 'Grid',
spec: {
display: { title: 'Section A' },
items: [
{
x: 0,
y: 0,
width: 6,
height: 6,
content: { $ref: '#/spec/panels/p1' },
},
],
},
kind: TextVariableKind.TextVariable,
spec: { name: 'env', display: { name: 'env' }, value: 'prod' },
},
],
panels: {
p1: {
kind: 'Panel',
spec: {
display: { name: 'p1' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
},
},
variables: [],
},
},
publicDashboard: { timeRangeEnabled, defaultTimeRange: '30m' },
} as unknown as DashboardtypesGettablePublicDashboardDataV2DTO;
publicDashboard: { timeRangeEnabled, defaultTimeRange: '6h' },
};
}
interface ContainerProps {
dashboard: { id: string; spec: { variables: unknown[] } };
readOnly: boolean;
controls: { toolbar?: unknown; panelActions?: { zoom?: boolean } };
queryRunner: { key: string };
}
function lastContainerProps(): ContainerProps {
return mockContainer.mock.calls.at(-1)?.[0] as ContainerProps;
}
describe('PublicDashboardView', () => {
beforeEach(() => mockGrid.mockReset());
beforeEach(() => mockContainer.mockReset());
it('renders the dashboard title, section title, and a grid per section', () => {
it('mounts the shared dashboard read-only on the public endpoint, without variables', () => {
render(
<PublicDashboardView publicDashboardId="pub-1" data={buildData(true)} />,
undefined,
{ initialRoute: '/public/dashboard/pub-1' },
);
expect(screen.getByText('My V2 Dashboard')).toBeInTheDocument();
expect(screen.getByText('Section A')).toBeInTheDocument();
expect(screen.getByTestId('public-section-grid')).toBeInTheDocument();
const gridProps = mockGrid.mock.calls[0][0];
expect(gridProps.publicDashboardId).toBe('pub-1');
expect(gridProps.items).toHaveLength(1);
expect(gridProps.items[0].id).toBe('p1');
expect(typeof gridProps.startMs).toBe('number');
expect(typeof gridProps.endMs).toBe('number');
// Times are handed to the endpoint in milliseconds.
expect(gridProps.endMs).toBeGreaterThan(gridProps.startMs);
const props = lastContainerProps();
expect(props.readOnly).toBe(true);
expect(props.queryRunner.key).toBe('public:pub-1');
expect(props.dashboard.id).toBe('public:pub-1');
expect(props.dashboard.spec.variables).toStrictEqual([]);
expect(props.controls.toolbar).toStrictEqual({ variables: false });
expect(props.controls.panelActions?.zoom).toBeUndefined();
});
it('shows the time controls only when the publisher enabled the time range', () => {
const { rerender } = render(
it('starts on the shared default range', () => {
render(
<PublicDashboardView publicDashboardId="pub-1" data={buildData(true)} />,
undefined,
{ initialRoute: '/public/dashboard/pub-1' },
);
expect(screen.getByTestId('datetime-selection')).toBeInTheDocument();
expect(screen.getByTestId('auto-refresh')).toBeInTheDocument();
rerender(
<PublicDashboardView publicDashboardId="pub-1" data={buildData(false)} />,
expect(getCurrentLocation().search).toContain('relativeTime=6h');
});
it("keeps the viewer's range when the link lets them change it", () => {
render(
<PublicDashboardView publicDashboardId="pub-1" data={buildData(true)} />,
undefined,
{ initialRoute: '/public/dashboard/pub-1?relativeTime=1h' },
);
expect(screen.queryByTestId('datetime-selection')).not.toBeInTheDocument();
expect(screen.queryByTestId('auto-refresh')).not.toBeInTheDocument();
expect(getCurrentLocation().search).toContain('relativeTime=1h');
});
it('pins the shared range and hides the toolbar when the link fixes the time', () => {
render(
<PublicDashboardView publicDashboardId="pub-1" data={buildData(false)} />,
undefined,
{ initialRoute: '/public/dashboard/pub-1?relativeTime=1h' },
);
expect(getCurrentLocation().search).toContain('relativeTime=6h');
expect(lastContainerProps().controls.toolbar).toBe(false);
expect(lastContainerProps().controls.panelActions?.zoom).toBe(false);
});
});

View File

@@ -1,118 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
import {
type DashboardtypesPanelDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { usePublicPanelQuery } from '../usePublicPanelQuery';
jest.mock('api/generated/services/dashboard', () => ({
getPublicDashboardPanelQueryRangeV2: jest.fn(),
}));
const mockFetch = getPublicDashboardPanelQueryRangeV2 as jest.Mock;
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
// A timeseries panel with a single runnable builder query (non-metrics signal → runnable).
const panel = {
kind: 'Panel',
spec: {
display: { name: 'panel-1' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: { visualization: {} } },
queries: [
{
kind: 'time_series',
spec: {
name: 'A',
plugin: {
kind: 'signoz/BuilderQuery',
spec: { name: 'A', signal: 'logs', legend: 'My legend' },
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
const args = {
panel,
// What TimeSeries declares; passed in rather than resolved from the registry, which
// would pull every panel renderer into this suite.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
panelKey: 'panel-1',
publicDashboardId: 'pub-1',
startMs: 1000,
endMs: 2000,
};
describe('usePublicPanelQuery', () => {
beforeEach(() => mockFetch.mockReset());
it('fetches by panel key + time and exposes the response as PanelQueryData', async () => {
mockFetch.mockResolvedValue({
status: 'success',
data: { type: 'time_series', data: { results: [] } },
});
const { result } = renderHook(() => usePublicPanelQuery(args), { wrapper });
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(mockFetch).toHaveBeenCalledWith(
{ id: 'pub-1', key: 'panel-1' },
{ startTime: '1000', endTime: '2000' },
expect.anything(),
);
expect(result.current.data.response?.status).toBe('success');
expect(result.current.data.legendMap).toStrictEqual({ A: 'My legend' });
expect(result.current.data.requestPayload?.start).toBe(1000);
expect(result.current.data.requestPayload?.end).toBe(2000);
// The public endpoint has no paging support.
expect(result.current.pagination).toBeUndefined();
});
it('does not fetch without a public dashboard id', () => {
renderHook(() => usePublicPanelQuery({ ...args, publicDashboardId: '' }), {
wrapper,
});
expect(mockFetch).not.toHaveBeenCalled();
});
it('does not fetch when the panel has no runnable queries', () => {
const emptyPanel = {
kind: 'Panel',
spec: {
display: { name: 'empty' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
renderHook(() => usePublicPanelQuery({ ...args, panel: emptyPanel }), {
wrapper,
});
expect(mockFetch).not.toHaveBeenCalled();
});
it('does not fetch when disabled', () => {
renderHook(() => usePublicPanelQuery({ ...args, enabled: false }), {
wrapper,
});
expect(mockFetch).not.toHaveBeenCalled();
});
});

View File

@@ -1,135 +0,0 @@
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesPanelDTO,
GetPublicDashboardPanelQueryRangeV2200,
} from 'api/generated/services/sigNoz.schemas';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { retryUnlessClientError } from 'pages/DashboardPage/DashboardContainer/hooks/useGetQueryRangeV5';
import type { PanelQueryCapabilities } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
import {
buildQueryRangeRequest,
extractLegendMap,
hasRunnableQueries,
} from 'pages/DashboardPage/DashboardContainer/queryV5/buildQueryRangeRequest';
import type {
PanelQueryData,
PanelPagination,
} from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { useCallback, useMemo } from 'react';
import { useQuery, useQueryClient } from 'react-query';
export interface UsePublicPanelQueryArgs {
panel: DashboardtypesPanelDTO;
/** The panel kind's declared query capabilities — `panelDefinition.queryCapabilities`. */
queryCapabilities: PanelQueryCapabilities;
/** Panel key in `spec.panels` — addresses the panel on the public endpoint. */
panelKey: string;
publicDashboardId: string;
/** Epoch milliseconds. */
startMs: number;
/** Epoch milliseconds. */
endMs: number;
/** Gate the fetch (default true). */
enabled?: boolean;
}
// Same shape as `usePanelQuery` so the V2 renderers consume it unchanged.
export interface UsePublicPanelQueryResult {
data: PanelQueryData;
isLoading: boolean;
isFetching: boolean;
isPreviousData: boolean;
error: Error | null;
refetch: () => void;
cancelQuery: () => void;
/** Always undefined — the public endpoint has no paging (#5557). */
pagination?: PanelPagination;
}
/**
* Fetches one v2 public panel by key + time. The public endpoint holds the query server-side
* (no body, variables, or paging); we still build the request DTO locally so the renderers get
* the `requestPayload`/`legendMap` they need — it is not sent.
*/
export function usePublicPanelQuery({
panel,
queryCapabilities,
panelKey,
publicDashboardId,
startMs,
endMs,
enabled = true,
}: UsePublicPanelQueryArgs): UsePublicPanelQueryResult {
const { queries } = panel.spec;
const pluginSpec = panel.spec.plugin.spec;
const visualization =
pluginSpec && 'visualization' in pluginSpec
? pluginSpec.visualization
: undefined;
const fillGaps = Boolean(
visualization && 'fillSpans' in visualization && visualization.fillSpans,
);
// For the renderers only — not sent to the server.
const requestPayload = useMemo(
() =>
buildQueryRangeRequest({
queries,
queryCapabilities,
startMs,
endMs,
fillGaps,
variables: {},
}),
[queries, queryCapabilities, startMs, endMs, fillGaps],
);
const legendMap = useMemo(() => extractLegendMap(queries), [queries]);
const runnable = useMemo(() => hasRunnableQueries(queries), [queries]);
// Redacted payloads are identical across panels — key on panel + time to avoid cache collisions.
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_PUBLIC_DASHBOARD_WIDGET_DATA,
publicDashboardId,
panelKey,
startMs,
endMs,
],
[publicDashboardId, panelKey, startMs, endMs],
);
const response = useQuery<GetPublicDashboardPanelQueryRangeV2200, Error>({
queryKey,
queryFn: ({ signal }) =>
getPublicDashboardPanelQueryRangeV2(
{ id: publicDashboardId, key: panelKey },
{ startTime: String(startMs), endTime: String(endMs) },
signal,
),
enabled: enabled && runnable && !!publicDashboardId && !!panelKey,
retry: retryUnlessClientError,
});
const data = useMemo<PanelQueryData>(
() => ({ response: response.data, requestPayload, legendMap }),
[response.data, requestPayload, legendMap],
);
const queryClient = useQueryClient();
const cancelQuery = useCallback((): void => {
void queryClient.cancelQueries(queryKey);
}, [queryClient, queryKey]);
return {
data,
isLoading: response.isLoading,
isFetching: response.isFetching,
isPreviousData: response.isPreviousData,
error: response.error ?? null,
refetch: response.refetch,
cancelQuery,
pagination: undefined,
};
}

View File

@@ -1,31 +0,0 @@
import dayjs from 'dayjs';
const CUSTOM_TIME_REGEX = /^(\d+)([mhdw])$/;
const UNIT_TO_DAYJS = {
m: 'minutes',
h: 'hours',
d: 'days',
w: 'weeks',
} as const;
// Relative range (`30m`/`6h`/`7d`/`1w`) → `{ startTime, endTime }` in unix seconds; default 30m.
export function getStartTimeAndEndTimeFromTimeRange(timeRange: string): {
startTime: number;
endTime: number;
} {
const match = timeRange.match(CUSTOM_TIME_REGEX);
if (match) {
const timeValue = parseInt(match[1] as string, 10);
const unit = UNIT_TO_DAYJS[match[2] as keyof typeof UNIT_TO_DAYJS];
return {
startTime: dayjs().subtract(timeValue, unit).unix(),
endTime: dayjs().unix(),
};
}
return {
startTime: dayjs().subtract(30, 'minutes').unix(),
endTime: dayjs().unix(),
};
}

View File

@@ -0,0 +1,33 @@
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
import type { Querybuildertypesv5QueryRangeRequestDTO } from 'api/generated/services/sigNoz.schemas';
import { PublicDashboardSchema } from 'hooks/dashboard/useGetResolvedPublicDashboard';
import { createPublicDashboardHost } from '../publicDashboardHost';
jest.mock('api/generated/services/dashboard', () => ({
getPublicDashboardPanelQueryRangeV2: jest.fn(),
}));
describe('createPublicDashboardHost', () => {
it('runs a panel query on the public endpoint by key, sending only the window', async () => {
const host = createPublicDashboardHost('pub-1', { timeRangeEnabled: true });
const requestPayload = {
start: 1000,
end: 2000,
} as Querybuildertypesv5QueryRangeRequestDTO;
await host.queryRunner.run({ panelId: 'p1', requestPayload });
expect(jest.mocked(getPublicDashboardPanelQueryRangeV2)).toHaveBeenCalledWith(
{ id: 'pub-1', key: 'p1' },
{ startTime: '1000', endTime: '2000' },
undefined,
);
});
it('mounts nothing for a dashboard that predates the v2 schema', () => {
const host = createPublicDashboardHost('pub-1', { timeRangeEnabled: true });
expect(host.select({ schema: PublicDashboardSchema.Legacy })).toBeUndefined();
});
});

View File

@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
// eslint-disable-next-line no-restricted-imports -- the dashboard reads redux global time
import { useDispatch } from 'react-redux';
import { QueryParams } from 'constants/query';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { getCurrentLocation, navigate } from 'lib/router/navigation';
import { UpdateTimeInterval } from 'store/actions';
/**
* Starts the link on its shared default range, by writing it where the
* dashboard's time picker looks first (`relativeTime`) and into the global time.
* A viewer's own range is kept only when the link lets them change it. Returns
* whether the dashboard may mount.
*/
export function usePublicDashboardTime({
defaultTimeRange,
timeRangeEnabled,
}: {
defaultTimeRange: string | undefined;
timeRangeEnabled: boolean;
}): boolean {
const dispatch = useDispatch();
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const { pathname, search } = getCurrentLocation();
const params = new URLSearchParams(search);
const hasOwnRange =
params.has(QueryParams.relativeTime) ||
(params.has(QueryParams.startTime) && params.has(QueryParams.endTime));
if (!timeRangeEnabled || !hasOwnRange) {
const range = (defaultTimeRange || DEFAULT_TIME_RANGE) as Time;
params.delete(QueryParams.startTime);
params.delete(QueryParams.endTime);
params.set(QueryParams.relativeTime, range);
navigate(`${pathname}?${params.toString()}`, { replace: true });
dispatch(UpdateTimeInterval(range));
}
setIsReady(true);
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per link
}, []);
return isReady;
}

View File

@@ -0,0 +1,83 @@
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
PublicDashboardSchema,
type ResolvedPublicDashboard,
} from 'hooks/dashboard/useGetResolvedPublicDashboard';
import {
CHARTS_ONLY,
withControls,
} from 'pages/DashboardPage/DashboardContainer/controls/presets';
import type { DashboardControlsInput } from 'pages/DashboardPage/DashboardContainer/controls/types';
import type {
DashboardQuerySource,
QueryRunner,
} from 'pages/DashboardPage/DashboardContainer/store/slices/editContextSlice';
export interface PublicDashboardHost {
source: DashboardQuerySource;
readOnly: true;
controls: DashboardControlsInput;
queryRunner: QueryRunner;
/** The dashboard as mounted: variables removed, keyed by the public id. */
select: (
resolved: ResolvedPublicDashboard,
) => DashboardtypesGettableDashboardV2DTO | undefined;
}
/**
* Everything a public link needs from the shared dashboard: the resolved data's
* cache entry, the public panel endpoint, and no editing. Variables are dropped
* because the public endpoint never substitutes them. Memoise per id.
*/
export function createPublicDashboardHost(
publicDashboardId: string,
{ timeRangeEnabled }: { timeRangeEnabled: boolean },
): PublicDashboardHost {
const select = (
resolved: ResolvedPublicDashboard,
): DashboardtypesGettableDashboardV2DTO | undefined => {
if (
resolved.schema !== PublicDashboardSchema.V2 ||
!resolved.data.dashboard
) {
return undefined;
}
const { dashboard } = resolved.data;
return {
...dashboard,
id: `public:${publicDashboardId}`,
spec: { ...dashboard.spec, variables: [] },
};
};
return {
select,
source: {
queryKey: [REACT_QUERY_KEY.GET_PUBLIC_DASHBOARD_RESOLVED, publicDashboardId],
select,
},
readOnly: true,
controls: withControls(
CHARTS_ONLY,
timeRangeEnabled
? { toolbar: { variables: false } }
: // A fixed link: no way to change the time, including zooming a chart.
{ toolbar: false, panelActions: { zoom: false } },
),
queryRunner: {
key: `public:${publicDashboardId}`,
// The server holds each panel's query; only the window travels.
run: ({ panelId, requestPayload, signal }) =>
getPublicDashboardPanelQueryRangeV2(
{ id: publicDashboardId, key: panelId },
{
startTime: String(requestPayload.start),
endTime: String(requestPayload.end),
},
signal,
),
},
};
}

View File

@@ -1,11 +1,11 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ROUTES from 'constants/routes';
import { renderAtRoute } from '@/storybook/renderAtRoute';
import { screen, userEvent } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { openAutoRefreshMenu } from './__story_mockdata__/publicDashboard.play';
import { publicDashboardMocks } from './PublicDashboard.stories.mocks';
import PublicDashboardPage from '../index';
@@ -34,9 +34,6 @@ const meta = {
export default meta;
/** The viewer fetches the dashboard before it renders its header. */
const untilLoaded = { timeout: 15_000 };
type Story = StoryObj<PublicDashboardArgs>;
/**
@@ -63,16 +60,7 @@ export const Loading: Story = {
/** The public viewer's auto-refresh popover is visibly open. */
export const AutoRefreshMenuOpen: Story = {
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByTestId(
'public-dashboard-auto-refresh',
undefined,
untilLoaded,
),
);
await screen.findByText('Refresh Interval', undefined, untilLoaded);
},
play: openAutoRefreshMenu,
};
/** A dashboard saved before the Perses spec, which falls back to the v1 viewer. */

View File

@@ -0,0 +1,16 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { screen, userEvent } from 'storybook/test';
/** The page fetches before it renders its toolbar, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
export const openAutoRefreshMenu = async (): Promise<void> => {
await userEvent.click(
await screen.findByTestId('auto-refresh-trigger', undefined, untilLoaded),
);
await screen.findByText('Refresh Interval', undefined, untilLoaded);
};

View File

@@ -0,0 +1,483 @@
{
"version": 1,
"definition": {
"schemaVersion": "v6",
"name": "signoz---cost-meter-breakdown",
"tags": [],
"spec": {
"display": {
"name": "Cost Meter Breakdown",
"description": "Logs, spans and metric datapoints ingested, from the signoz.meter.* metrics. Aggregated hourly; billing is calculated in UTC."
},
"variables": [],
"layouts": [
{
"kind": "Grid",
"spec": {
"display": {
"title": "Total"
},
"items": [
{
"x": 0,
"y": 0,
"width": 4,
"height": 3,
"content": {
"$ref": "#/spec/panels/f320363c-f077-5a13-ab53-cbf5c50c58c9"
}
},
{
"x": 4,
"y": 0,
"width": 4,
"height": 3,
"content": {
"$ref": "#/spec/panels/28703c9e-3405-52b8-96bb-c390a2aa07bf"
}
},
{
"x": 8,
"y": 0,
"width": 4,
"height": 3,
"content": {
"$ref": "#/spec/panels/59ea13f9-5e13-54f8-843f-128dcf160c99"
}
}
]
}
},
{
"kind": "Grid",
"spec": {
"display": {
"title": "Logs"
},
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/b32fdbcb-e0c0-55c8-83c8-5f80e6a111ac"
}
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/2f60d067-db2e-57a9-a6d0-b956795f59cb"
}
}
]
}
},
{
"kind": "Grid",
"spec": {
"display": {
"title": "Traces"
},
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/f27dfc76-f87c-5b04-91a3-358b36f6e043"
}
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/99d6f751-fcd0-51d1-a156-c797c48ab65f"
}
}
]
}
},
{
"kind": "Grid",
"spec": {
"display": {
"title": "Metrics"
},
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/686fb25a-5b45-5956-9b37-45cb8ff90f78"
}
}
]
}
}
],
"panels": {
"f320363c-f077-5a13-ab53-cbf5c50c58c9": {
"kind": "Panel",
"spec": {
"display": {
"name": "Total size of log records ingested"
},
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {
"visualization": {
"timePreference": "global_time"
},
"formatting": {
"unit": "bytes"
}
}
},
"queries": [
{
"kind": "scalar",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "meter",
"disabled": false,
"aggregations": [
{
"metricName": "signoz.meter.log.size",
"timeAggregation": "increase",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"legend": "count"
}
}
}
}
]
}
},
"28703c9e-3405-52b8-96bb-c390a2aa07bf": {
"kind": "Panel",
"spec": {
"display": {
"name": "Total size of spans ingested"
},
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {
"visualization": {
"timePreference": "global_time"
},
"formatting": {
"unit": "bytes"
}
}
},
"queries": [
{
"kind": "scalar",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "meter",
"disabled": false,
"aggregations": [
{
"metricName": "signoz.meter.span.size",
"timeAggregation": "increase",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"legend": "count"
}
}
}
}
]
}
},
"59ea13f9-5e13-54f8-843f-128dcf160c99": {
"kind": "Panel",
"spec": {
"display": {
"name": "Total metric datapoints ingested"
},
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {
"visualization": {
"timePreference": "global_time"
},
"formatting": {
"unit": "short"
}
}
},
"queries": [
{
"kind": "scalar",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "meter",
"disabled": false,
"aggregations": [
{
"metricName": "signoz.meter.metric.datapoint.count",
"timeAggregation": "increase",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"legend": "count"
}
}
}
}
]
}
},
"b32fdbcb-e0c0-55c8-83c8-5f80e6a111ac": {
"kind": "Panel",
"spec": {
"display": {
"name": "Count of log records ingested"
},
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {
"visualization": {
"stackedBarChart": false
},
"formatting": {
"unit": "short"
}
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "meter",
"disabled": false,
"aggregations": [
{
"metricName": "signoz.meter.log.count",
"timeAggregation": "increase",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"legend": "count"
}
}
}
}
]
}
},
"2f60d067-db2e-57a9-a6d0-b956795f59cb": {
"kind": "Panel",
"spec": {
"display": {
"name": "Size of log records ingested"
},
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {
"visualization": {
"stackedBarChart": false
},
"formatting": {
"unit": "bytes"
}
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "meter",
"disabled": false,
"aggregations": [
{
"metricName": "signoz.meter.log.size",
"timeAggregation": "increase",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"legend": "size"
}
}
}
}
]
}
},
"f27dfc76-f87c-5b04-91a3-358b36f6e043": {
"kind": "Panel",
"spec": {
"display": {
"name": "Count of spans ingested"
},
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {
"visualization": {
"stackedBarChart": false
},
"formatting": {
"unit": "short"
}
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "meter",
"disabled": false,
"aggregations": [
{
"metricName": "signoz.meter.span.count",
"timeAggregation": "increase",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"legend": "count"
}
}
}
}
]
}
},
"99d6f751-fcd0-51d1-a156-c797c48ab65f": {
"kind": "Panel",
"spec": {
"display": {
"name": "Size of spans ingested"
},
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {
"visualization": {
"stackedBarChart": false
},
"formatting": {
"unit": "bytes"
}
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "meter",
"disabled": false,
"aggregations": [
{
"metricName": "signoz.meter.span.size",
"timeAggregation": "increase",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"legend": "size"
}
}
}
}
]
}
},
"686fb25a-5b45-5956-9b37-45cb8ff90f78": {
"kind": "Panel",
"spec": {
"display": {
"name": "Count of metric datapoints ingested"
},
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {
"visualization": {
"stackedBarChart": false
},
"formatting": {
"unit": "short"
}
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "meter",
"disabled": false,
"aggregations": [
{
"metricName": "signoz.meter.metric.datapoint.count",
"timeAggregation": "increase",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"legend": "count"
}
}
}
}
]
}
}
}
}
}
}

View File

@@ -26,6 +26,13 @@ export const METER_EXPLORER_PATH = '/meter/explorer';
export type MeterSection = 'total' | 'logs' | 'traces' | 'metrics';
const SECTION_TITLES: Record<MeterSection, string> = {
total: 'Total',
logs: 'Logs',
traces: 'Traces',
metrics: 'Metrics',
};
export interface MeterBreakdown {
/** Open `/meter` and return the `query_range` responses its widgets fire. */
goto(): Promise<Response[]>;
@@ -58,7 +65,13 @@ export function meterDriver(page: Page): Meter {
page.off('response', collect);
return responses;
},
section: (name) => page.getByTestId(`meter-breakdown-section-${name}`),
// The breakdown is a system dashboard: section ids derive from panel ids, so match the title.
section: (name) =>
page.locator('[data-testid^="dashboard-section-sec-"]').filter({
has: page
.locator('[data-testid^="dashboard-section-toggle-"]')
.filter({ hasText: SECTION_TITLES[name] }),
}),
};
return {