mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-13 03:41:20 +00:00
Compare commits
10 Commits
main
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bae4e74eb | ||
|
|
3ca219d02f | ||
|
|
60c0d73143 | ||
|
|
90f24a0fe9 | ||
|
|
908e44c1e8 | ||
|
|
da94bb975f | ||
|
|
2a3a5fe4ef | ||
|
|
d59bc57394 | ||
|
|
af995d970b | ||
|
|
9d94886c9b |
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,24 +3,4 @@
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-0) var(--spacing-0);
|
||||
//TODO: remove this once we have a proper dashboard page.
|
||||
// The embedded V2 DashboardContainer renders its own page header (dashboard
|
||||
// title + share/feedback chrome) removed it for now
|
||||
:global([class*='dashboardPageHeader']),
|
||||
:global([class*='dashboardInfoWithActions']) {
|
||||
display: none;
|
||||
}
|
||||
// Embedded read-only: the add-variable and panel-action triggers are dead here.
|
||||
:global([class*='addSlot']),
|
||||
:global([data-testid^='panel-actions-']) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.errorState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import Spinner from 'components/Spinner';
|
||||
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
|
||||
import SystemDashboard from 'container/SystemDashboard/SystemDashboard';
|
||||
import { createSystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
|
||||
import { CHARTS_ONLY } from 'pages/DashboardPage/DashboardContainer/controls/presets';
|
||||
|
||||
import { useSystemDashboard } from './hooks/useSystemDashboard';
|
||||
import styles from './Overview.module.scss';
|
||||
|
||||
function Overview(): JSX.Element {
|
||||
const { dashboard, isLoading, isError, error, refetch } = useSystemDashboard();
|
||||
const AI_OVERVIEW = createSystemDashboardHost(
|
||||
'ai-o11y-overview',
|
||||
'llm-observability-overview',
|
||||
);
|
||||
|
||||
function Overview(): JSX.Element {
|
||||
useEffect(() => {
|
||||
void logEvent('AI Observability Overview: Page visited', {});
|
||||
}, []);
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
if (isError || !dashboard) {
|
||||
return (
|
||||
<div className={styles.errorState}>
|
||||
<Typography.Title>Failed to load dashboard</Typography.Title>
|
||||
<Typography.Text>
|
||||
{error?.response?.data?.error?.message ?? error?.message}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardContainer
|
||||
dashboard={dashboard}
|
||||
refetch={refetch}
|
||||
canEditDashboardOverride={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overview} data-testid="llm-observability-overview">
|
||||
{renderContent()}
|
||||
<SystemDashboard host={AI_OVERVIEW} controls={CHARTS_ONLY} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
useGetSystemDashboard,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { GetSystemDashboardQueryError } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const SYSTEM_DASHBOARD_NAME = 'ai-o11y-overview';
|
||||
|
||||
const DASHBOARD_ID = 'llm-observability-overview';
|
||||
|
||||
export interface UseSystemDashboardResult {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: GetSystemDashboardQueryError | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useSystemDashboard(): UseSystemDashboardResult {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, isError, error, refetch } = useGetSystemDashboard(
|
||||
{ name: SYSTEM_DASHBOARD_NAME },
|
||||
{ query: { staleTime: Infinity, refetchOnMount: false } },
|
||||
);
|
||||
|
||||
const dashboard = useMemo(
|
||||
() =>
|
||||
data
|
||||
? ({
|
||||
...data.data,
|
||||
id: DASHBOARD_ID,
|
||||
} as DashboardtypesGettableDashboardV2DTO)
|
||||
: undefined,
|
||||
[data],
|
||||
);
|
||||
|
||||
if (dashboard) {
|
||||
queryClient.setQueryData<GetDashboardV2200>(
|
||||
getGetDashboardV2QueryKey({ id: DASHBOARD_ID }),
|
||||
{ data: dashboard, status: 'success' },
|
||||
);
|
||||
}
|
||||
|
||||
const refetchDashboard = useCallback((): void => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return { dashboard, isLoading, isError, error, refetch: refetchDashboard };
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -2,77 +2,14 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.meter-explorer-date-time {
|
||||
display: flex;
|
||||
min-height: 30px;
|
||||
justify-content: end;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.meter-explorer-graphs {
|
||||
.meter-explorer-notices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
gap: 36px;
|
||||
gap: 12px;
|
||||
padding: 16px 20px 0;
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meter-column-graph {
|
||||
.row-card {
|
||||
background-color: var(--l2-background);
|
||||
padding-left: 10px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.section-title {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.graph-description {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meter-page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
.meter-graph {
|
||||
height: 400px;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.total {
|
||||
.meter-column-graph {
|
||||
.meter-page-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
.meter-graph {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,121 +1,30 @@
|
||||
import { useCallback } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Alert } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import GridCard from 'container/WidgetCard/Card';
|
||||
import { Card, CardContainer } from 'container/WidgetCard/styles';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import SystemDashboard from 'container/SystemDashboard/SystemDashboard';
|
||||
import { createSystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
getLogCountWidgetData,
|
||||
getLogSizeWidgetData,
|
||||
getMetricCountWidgetData,
|
||||
getSpanCountWidgetData,
|
||||
getSpanSizeWidgetData,
|
||||
getTotalLogSizeWidgetData,
|
||||
getTotalMetricDatapointCountWidgetData,
|
||||
getTotalTraceSizeWidgetData,
|
||||
} from './graphs';
|
||||
CHARTS_ONLY,
|
||||
withControls,
|
||||
} from 'pages/DashboardPage/DashboardContainer/controls/presets';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import './BreakDown.styles.scss';
|
||||
|
||||
type MetricSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
graphs: Widgets[];
|
||||
};
|
||||
const COST_METER = createSystemDashboardHost(
|
||||
'cost-meter-breakdown',
|
||||
'cost-meter-breakdown',
|
||||
);
|
||||
|
||||
const sections: MetricSection[] = [
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Total',
|
||||
graphs: [
|
||||
getTotalLogSizeWidgetData(),
|
||||
getTotalTraceSizeWidgetData(),
|
||||
getTotalMetricDatapointCountWidgetData(),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Logs',
|
||||
graphs: [getLogCountWidgetData(), getLogSizeWidgetData()],
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Traces',
|
||||
graphs: [getSpanCountWidgetData(), getSpanSizeWidgetData()],
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Metrics',
|
||||
graphs: [getMetricCountWidgetData()],
|
||||
},
|
||||
];
|
||||
|
||||
function Section(section: MetricSection): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { title, graphs } = section;
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number) => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="meter-column-graph"
|
||||
data-testid={`meter-breakdown-section-${title.toLowerCase()}`}
|
||||
>
|
||||
<CardContainer className="row-card" isDarkMode={isDarkMode}>
|
||||
<Typography.Text className="section-title">{title}</Typography.Text>
|
||||
</CardContainer>
|
||||
<div className="meter-page-grid">
|
||||
{graphs.map((widget) => (
|
||||
<Card
|
||||
key={widget?.id}
|
||||
isDarkMode={isDarkMode}
|
||||
$panelType={PANEL_TYPES.BAR}
|
||||
className="meter-graph"
|
||||
>
|
||||
<GridCard widget={widget} onDragSelect={onDragSelect} version="v5" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const CONTROLS = withControls(CHARTS_ONLY, {
|
||||
toolbar: { autoRefresh: false },
|
||||
});
|
||||
|
||||
function BreakDown(): JSX.Element {
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
@@ -133,10 +42,7 @@ function BreakDown(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="meter-explorer-breakdown">
|
||||
<section className="meter-explorer-date-time">
|
||||
<DateTimeSelectionV2 showAutoRefresh={false} />
|
||||
</section>
|
||||
<section className="meter-explorer-graphs">
|
||||
<section className="meter-explorer-notices">
|
||||
{showInfo && (
|
||||
<Alert
|
||||
type="info"
|
||||
@@ -179,28 +85,8 @@ function BreakDown(): JSX.Element {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<section className="total">
|
||||
<Section
|
||||
id={sections[0].id}
|
||||
title={sections[0].title}
|
||||
graphs={sections[0].graphs}
|
||||
/>
|
||||
</section>
|
||||
{sections.map((section, idx) => {
|
||||
if (idx === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
key={section.id}
|
||||
id={section.id}
|
||||
title={section.title}
|
||||
graphs={section.graphs}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
<SystemDashboard host={COST_METER} controls={CONTROLS} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { DashboardControlsInput } from 'pages/DashboardPage/DashboardContainer/controls/types';
|
||||
import { resolveControls } from 'pages/DashboardPage/DashboardContainer/controls/dashboardControls';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import type { SystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
|
||||
|
||||
import BreakDown from '../BreakDown';
|
||||
|
||||
jest.mock('container/SystemDashboard/SystemDashboard', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
host,
|
||||
controls,
|
||||
}: {
|
||||
host: SystemDashboardHost;
|
||||
controls: DashboardControlsInput;
|
||||
}): JSX.Element => (
|
||||
<div
|
||||
data-testid="system-dashboard"
|
||||
data-name={host.name}
|
||||
data-controls={JSON.stringify(controls)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('Cost meter breakdown', () => {
|
||||
it('mounts the cost meter system dashboard as charts only, without auto-refresh', () => {
|
||||
render(<BreakDown />);
|
||||
|
||||
const dashboard = screen.getByTestId('system-dashboard');
|
||||
expect(dashboard).toHaveAttribute('data-name', 'cost-meter-breakdown');
|
||||
const controls = resolveControls(
|
||||
JSON.parse(dashboard.getAttribute('data-controls') ?? ''),
|
||||
true,
|
||||
);
|
||||
expect(controls.header.breadcrumbs).toBe(false);
|
||||
expect(controls.panelActions.createAlert).toBe(false);
|
||||
expect(controls.panelActions.drilldown).toBe(false);
|
||||
expect(controls.panelActions.zoom).toBe(true);
|
||||
expect(controls.toolbar.autoRefresh).toBe(false);
|
||||
expect(controls.toolbar.timeRange).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the UTC billing notice above the dashboard', () => {
|
||||
render(<BreakDown />);
|
||||
|
||||
expect(screen.getByText(/Billing is calculated in UTC/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,374 +0,0 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetWidgetQueryBuilderProps } from 'container/MetricsApplication/types';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
interface GetWidgetQueryProps {
|
||||
title: string;
|
||||
description: string;
|
||||
queryData: IBuilderQuery[];
|
||||
queryFormulas?: IBuilderFormula[];
|
||||
panelTypes?: PANEL_TYPES;
|
||||
yAxisUnit?: string;
|
||||
columnUnits?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface GetWidgetQueryPropsReturn extends GetWidgetQueryBuilderProps {
|
||||
description?: string;
|
||||
nullZeroValues: string;
|
||||
columnUnits?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const getWidgetQueryBuilder = ({
|
||||
query,
|
||||
title = '',
|
||||
panelTypes,
|
||||
yAxisUnit = '',
|
||||
fillSpans = false,
|
||||
id,
|
||||
nullZeroValues,
|
||||
description,
|
||||
}: GetWidgetQueryPropsReturn): Widgets => ({
|
||||
description: description || '',
|
||||
id: id || uuid(),
|
||||
nullZeroValues: nullZeroValues || '',
|
||||
opacity: '1',
|
||||
panelTypes,
|
||||
query,
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
title,
|
||||
yAxisUnit,
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
selectedLogFields: [],
|
||||
selectedTracesFields: [],
|
||||
fillSpans,
|
||||
});
|
||||
|
||||
export function getWidgetQuery(
|
||||
props: GetWidgetQueryProps,
|
||||
): GetWidgetQueryPropsReturn {
|
||||
const { title, description, panelTypes, yAxisUnit, columnUnits } = props;
|
||||
return {
|
||||
title,
|
||||
yAxisUnit: yAxisUnit || 'none',
|
||||
panelTypes: panelTypes || PANEL_TYPES.TIME_SERIES,
|
||||
fillSpans: false,
|
||||
description,
|
||||
nullZeroValues: 'zero',
|
||||
columnUnits,
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: {
|
||||
queryData: props.queryData,
|
||||
queryFormulas: (props.queryFormulas as IBuilderFormula[]) || [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
};
|
||||
}
|
||||
export const getTotalLogSizeWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.log.size',
|
||||
id: 'signoz.meter.log.size--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.SUM,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Total size of log records ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.VALUE,
|
||||
yAxisUnit: 'bytes',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getTotalTraceSizeWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.span.size',
|
||||
id: 'signoz.meter.span.size--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.SUM,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Total size of spans ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.VALUE,
|
||||
yAxisUnit: 'bytes',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getTotalMetricDatapointCountWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.metric.datapoint.count',
|
||||
id: 'signoz.meter.metric.datapoint.count--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.SUM,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Total metric datapoints ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.VALUE,
|
||||
yAxisUnit: 'short',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getLogCountWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.log.count',
|
||||
id: 'signoz.meter.log.count--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Count of log records ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'short',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getLogSizeWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.log.size',
|
||||
id: 'signoz.meter.log.size--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'size',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Size of log records ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'bytes',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getSpanCountWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.span.count',
|
||||
id: 'signoz.meter.span.count--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Count of spans ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'short',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getSpanSizeWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.span.size',
|
||||
id: 'signoz.meter.span.size--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'size',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Size of spans ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'bytes',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getMetricCountWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.metric.datapoint.count',
|
||||
id: 'signoz.meter.metric.datapoint.count--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Count of metric datapoints ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'short',
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
.errorState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
49
frontend/src/container/SystemDashboard/SystemDashboard.tsx
Normal file
49
frontend/src/container/SystemDashboard/SystemDashboard.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import Spinner from 'components/Spinner';
|
||||
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
|
||||
import type { DashboardControlsInput } from 'pages/DashboardPage/DashboardContainer/controls/types';
|
||||
|
||||
import type { SystemDashboardHost } from './systemDashboardHost';
|
||||
import { useSystemDashboard } from './useSystemDashboard';
|
||||
import styles from './SystemDashboard.module.scss';
|
||||
|
||||
interface SystemDashboardProps {
|
||||
host: SystemDashboardHost;
|
||||
controls?: DashboardControlsInput;
|
||||
}
|
||||
|
||||
/** A shipped, read-only system dashboard, embedded in a feature page. */
|
||||
function SystemDashboard({
|
||||
host,
|
||||
controls,
|
||||
}: SystemDashboardProps): JSX.Element {
|
||||
const { dashboard, isLoading, isError, error, refetch } =
|
||||
useSystemDashboard(host);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
if (isError || !dashboard) {
|
||||
return (
|
||||
<div className={styles.errorState} data-testid="system-dashboard-error">
|
||||
<Typography.Title>Failed to load dashboard</Typography.Title>
|
||||
<Typography.Text>
|
||||
{error?.response?.data?.error?.message ?? error?.message}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardContainer
|
||||
dashboard={dashboard}
|
||||
refetch={refetch}
|
||||
source={host.source}
|
||||
readOnly
|
||||
controls={controls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SystemDashboard;
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
DashboardtypesSourceDTO,
|
||||
type GetSystemDashboard200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import SystemDashboard from '../SystemDashboard';
|
||||
import { createSystemDashboardHost } from '../systemDashboardHost';
|
||||
|
||||
const HOST = createSystemDashboardHost('test-overview', 'test-overview-id');
|
||||
const ENDPOINT = '*/api/v2/dashboards/system/test-overview';
|
||||
|
||||
jest.mock('pages/DashboardPage/DashboardContainer', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
dashboard,
|
||||
readOnly,
|
||||
}: {
|
||||
dashboard: { id: string; spec: { display: { name: string } } };
|
||||
readOnly: boolean;
|
||||
}): JSX.Element => (
|
||||
<div
|
||||
data-testid="dashboard-container"
|
||||
data-dashboard-id={dashboard.id}
|
||||
data-read-only={String(readOnly)}
|
||||
>
|
||||
{dashboard.spec.display.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const response: GetSystemDashboard200 = {
|
||||
status: 'success',
|
||||
data: {
|
||||
orgId: 'org',
|
||||
locked: true,
|
||||
name: 'signoz---test-overview',
|
||||
schemaVersion: 'v6',
|
||||
source: DashboardtypesSourceDTO.system,
|
||||
tags: [],
|
||||
spec: {
|
||||
display: { name: 'Test overview' },
|
||||
variables: [],
|
||||
panels: {},
|
||||
layouts: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('SystemDashboard', () => {
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
it('mounts the named system dashboard read-only under the host id', async () => {
|
||||
server.use(rest.get(ENDPOINT, (_req, res, ctx) => res(ctx.json(response))));
|
||||
|
||||
render(<SystemDashboard host={HOST} />);
|
||||
|
||||
const container = await screen.findByTestId('dashboard-container');
|
||||
expect(container).toHaveTextContent('Test overview');
|
||||
expect(container).toHaveAttribute('data-dashboard-id', 'test-overview-id');
|
||||
expect(container).toHaveAttribute('data-read-only', 'true');
|
||||
});
|
||||
|
||||
it('shows why the dashboard failed to load', async () => {
|
||||
server.use(
|
||||
rest.get(ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(404),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'not_found', message: 'not provisioned' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SystemDashboard host={HOST} />);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('system-dashboard-error'),
|
||||
).resolves.toHaveTextContent('not provisioned');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getGetSystemDashboardQueryKey } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
GetSystemDashboard200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardQuerySource } from 'pages/DashboardPage/DashboardContainer/store/slices/editContextSlice';
|
||||
|
||||
export interface SystemDashboardHost {
|
||||
/** Definition name without the `signoz---` prefix. */
|
||||
name: string;
|
||||
select: (
|
||||
response: GetSystemDashboard200,
|
||||
) => DashboardtypesGettableDashboardV2DTO;
|
||||
source: DashboardQuerySource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call at module level: `select` and `source` must stay referentially stable.
|
||||
* System dashboards carry no `id`; `id` keys the store's per-dashboard state
|
||||
* (saved variable values), so keep it stable across releases.
|
||||
*/
|
||||
export function createSystemDashboardHost(
|
||||
name: string,
|
||||
id: string,
|
||||
): SystemDashboardHost {
|
||||
const select = (
|
||||
response: GetSystemDashboard200,
|
||||
): DashboardtypesGettableDashboardV2DTO =>
|
||||
({ ...response.data, id }) as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
return {
|
||||
name,
|
||||
select,
|
||||
source: { queryKey: getGetSystemDashboardQueryKey({ name }), select },
|
||||
};
|
||||
}
|
||||
41
frontend/src/container/SystemDashboard/useSystemDashboard.ts
Normal file
41
frontend/src/container/SystemDashboard/useSystemDashboard.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useGetSystemDashboard } from 'api/generated/services/dashboard';
|
||||
import type { GetSystemDashboardQueryError } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { SystemDashboardHost } from './systemDashboardHost';
|
||||
|
||||
export interface UseSystemDashboardResult {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: GetSystemDashboardQueryError | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useSystemDashboard(
|
||||
host: SystemDashboardHost,
|
||||
): UseSystemDashboardResult {
|
||||
const {
|
||||
data: dashboard,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetSystemDashboard(
|
||||
{ name: host.name },
|
||||
{
|
||||
query: {
|
||||
select: host.select,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const refetchDashboard = useCallback((): void => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return { dashboard, isLoading, isError, error, refetch: refetchDashboard };
|
||||
}
|
||||
@@ -175,6 +175,7 @@ function AutoRefresh({
|
||||
}
|
||||
>
|
||||
<ButtonContainer
|
||||
data-testid="auto-refresh-trigger"
|
||||
title="Set auto refresh"
|
||||
type={showAutoRefreshBtnPrimary ? 'primary' : 'default'}
|
||||
>
|
||||
|
||||
@@ -26,7 +26,10 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import AuthZDropdown from 'lib/authz/components/AuthZDropdown/AuthZDropdown';
|
||||
import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/types';
|
||||
import type {
|
||||
AuthZDropdownItemType,
|
||||
AuthZGroupChildType,
|
||||
} from 'lib/authz/components/AuthZDropdown/types';
|
||||
import { blockedBy } from 'lib/authz/components/AuthZDropdown/utils';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { DashboardCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
@@ -39,6 +42,7 @@ import styles from './DashboardActions.module.scss';
|
||||
import { useDeleteDashboardAction } from './useDeleteDashboardAction';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
|
||||
|
||||
interface DashboardActionsProps {
|
||||
title: string;
|
||||
@@ -66,6 +70,7 @@ function DashboardActions({
|
||||
deleteChecks,
|
||||
deleteDisabledTooltip,
|
||||
} = useDashboardEditContext();
|
||||
const show = useDashboardControlGroup('dashboardActions');
|
||||
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
|
||||
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
@@ -73,6 +78,7 @@ function DashboardActions({
|
||||
const { disabledTooltip: lockDisabledTooltip } = useDashboardLockPermission({
|
||||
dashboardId: dashboard.id,
|
||||
source: dashboard.source,
|
||||
enabled: show.lock,
|
||||
});
|
||||
|
||||
// Cloning creates a new dashboard, so it needs `create` too, and no lock gate.
|
||||
@@ -162,61 +168,68 @@ function DashboardActions({
|
||||
void handle.enter();
|
||||
}, [dashboard.id, handle]);
|
||||
|
||||
// Unavailable items stay in the menu, carrying the reason.
|
||||
const menuItems = useMemo<AuthZDropdownItemType[]>(
|
||||
() => [
|
||||
{
|
||||
type: 'group',
|
||||
value: 'group-dashboard',
|
||||
label: 'Dashboard',
|
||||
items: [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'rename',
|
||||
testId: 'dashboard-action-rename',
|
||||
label: 'Rename',
|
||||
prefix: <PenLine size={14} />,
|
||||
checks: editChecks,
|
||||
...blockedBy(editDisabledTooltip),
|
||||
onClick: onOpenRename,
|
||||
},
|
||||
// Clone creates a new dashboard, so it's not lock-gated.
|
||||
{
|
||||
type: 'item',
|
||||
value: 'clone',
|
||||
testId: 'dashboard-action-clone',
|
||||
label: 'Clone dashboard',
|
||||
prefix: <Copy size={14} />,
|
||||
checks: cloneChecks,
|
||||
loading: isCloning,
|
||||
loadingTooltip: 'Cloning dashboard',
|
||||
onClick: (): void => void handleClone(),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
value: 'lock',
|
||||
testId: 'dashboard-action-lock',
|
||||
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
|
||||
prefix: <LockKeyhole size={14} />,
|
||||
checks: editChecks,
|
||||
...blockedBy(lockDisabledTooltip),
|
||||
onClick: onLockToggle,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
value: 'fullscreen',
|
||||
testId: 'dashboard-action-fullscreen',
|
||||
label: 'Full screen',
|
||||
prefix: <Fullscreen size={14} />,
|
||||
onClick: handleEnterFullScreen,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
value: 'group-layout',
|
||||
label: 'Layout',
|
||||
items: [
|
||||
// Unavailable items stay in the menu, carrying the reason; hidden ones are dropped.
|
||||
const menuItems = useMemo<AuthZDropdownItemType[]>(() => {
|
||||
const dashboardItems: AuthZGroupChildType[] = [
|
||||
...(show.rename
|
||||
? [
|
||||
{
|
||||
type: 'item' as const,
|
||||
value: 'rename',
|
||||
testId: 'dashboard-action-rename',
|
||||
label: 'Rename',
|
||||
prefix: <PenLine size={14} />,
|
||||
checks: editChecks,
|
||||
...blockedBy(editDisabledTooltip),
|
||||
onClick: onOpenRename,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Clone creates a new dashboard, so it's not lock-gated.
|
||||
...(show.clone
|
||||
? [
|
||||
{
|
||||
type: 'item' as const,
|
||||
value: 'clone',
|
||||
testId: 'dashboard-action-clone',
|
||||
label: 'Clone dashboard',
|
||||
prefix: <Copy size={14} />,
|
||||
checks: cloneChecks,
|
||||
loading: isCloning,
|
||||
loadingTooltip: 'Cloning dashboard',
|
||||
onClick: (): void => void handleClone(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(show.lock
|
||||
? [
|
||||
{
|
||||
type: 'item' as const,
|
||||
value: 'lock',
|
||||
testId: 'dashboard-action-lock',
|
||||
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
|
||||
prefix: <LockKeyhole size={14} />,
|
||||
checks: editChecks,
|
||||
...blockedBy(lockDisabledTooltip),
|
||||
onClick: onLockToggle,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(show.fullscreen
|
||||
? [
|
||||
{
|
||||
type: 'item' as const,
|
||||
value: 'fullscreen',
|
||||
testId: 'dashboard-action-fullscreen',
|
||||
label: 'Full screen',
|
||||
prefix: <Fullscreen size={14} />,
|
||||
onClick: handleEnterFullScreen,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const layoutItems: AuthZGroupChildType[] = show.newSection
|
||||
? [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'new-section',
|
||||
@@ -227,66 +240,96 @@ function DashboardActions({
|
||||
...blockedBy(editDisabledTooltip),
|
||||
onClick: (): void => setIsNewSectionOpen(true),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator', value: 'divider-danger' },
|
||||
{
|
||||
type: 'item',
|
||||
value: 'delete',
|
||||
testId: 'dashboard-action-delete',
|
||||
label: 'Delete dashboard',
|
||||
prefix: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
checks: deleteChecks,
|
||||
// Independent of read/update, but a locked dashboard can't be removed.
|
||||
...blockedBy(deleteDisabledTooltip),
|
||||
onClick: confirmDeleteDashboard,
|
||||
},
|
||||
],
|
||||
[
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
deleteChecks,
|
||||
deleteDisabledTooltip,
|
||||
cloneChecks,
|
||||
isCloning,
|
||||
lockDisabledTooltip,
|
||||
isDashboardLocked,
|
||||
onOpenRename,
|
||||
handleClone,
|
||||
onLockToggle,
|
||||
handleEnterFullScreen,
|
||||
confirmDeleteDashboard,
|
||||
],
|
||||
);
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
...(dashboardItems.length
|
||||
? [
|
||||
{
|
||||
type: 'group' as const,
|
||||
value: 'group-dashboard',
|
||||
label: 'Dashboard',
|
||||
items: dashboardItems,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(layoutItems.length
|
||||
? [
|
||||
{
|
||||
type: 'group' as const,
|
||||
value: 'group-layout',
|
||||
label: 'Layout',
|
||||
items: layoutItems,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(show.delete
|
||||
? [
|
||||
{ type: 'separator' as const, value: 'divider-danger' },
|
||||
{
|
||||
type: 'item' as const,
|
||||
value: 'delete',
|
||||
testId: 'dashboard-action-delete',
|
||||
label: 'Delete dashboard',
|
||||
prefix: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
checks: deleteChecks,
|
||||
// Independent of read/update, but a locked dashboard can't be removed.
|
||||
...blockedBy(deleteDisabledTooltip),
|
||||
onClick: confirmDeleteDashboard,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}, [
|
||||
show,
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
deleteChecks,
|
||||
deleteDisabledTooltip,
|
||||
cloneChecks,
|
||||
isCloning,
|
||||
lockDisabledTooltip,
|
||||
isDashboardLocked,
|
||||
onOpenRename,
|
||||
handleClone,
|
||||
onLockToggle,
|
||||
handleEnterFullScreen,
|
||||
confirmDeleteDashboard,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardActionsContainer}>
|
||||
<AuthZDropdown items={menuItems} nativeButton align="end" side="bottom">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Grid3X3 size="md" />}
|
||||
testId="options"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</AuthZDropdown>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
disabled={!isEditable}
|
||||
onClick={handleOpenSettings}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
{menuItems.length > 0 && (
|
||||
<AuthZDropdown items={menuItems} nativeButton align="end" side="bottom">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Grid3X3 size="md" />}
|
||||
testId="options"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</AuthZDropdown>
|
||||
)}
|
||||
{show.configure && (
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
disabled={!isEditable}
|
||||
onClick={handleOpenSettings}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
@@ -298,30 +341,34 @@ function DashboardActions({
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Braces size="md" />}
|
||||
testId="edit-json"
|
||||
onClick={handleOpenJsonEditor}
|
||||
size="md"
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
{show.json && (
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={!isEditable}
|
||||
color="secondary"
|
||||
prefix={<Braces size="md" />}
|
||||
testId="edit-json"
|
||||
onClick={handleOpenJsonEditor}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
JSON
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
{show.newPanel && (
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={!isEditable}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
<JsonEditorDrawer
|
||||
dashboard={dashboard}
|
||||
isOpen={isJsonEditorOpen}
|
||||
|
||||
@@ -24,9 +24,11 @@ import { openInNewTab } from 'utils/navigation';
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
|
||||
import TagsOverflowTooltip from './TagsOverflowTooltip';
|
||||
import { getLockTooltip } from './utils';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
|
||||
|
||||
// The tag cluster keeps a fixed footprint so a long title ellipsizes around it
|
||||
// instead of collapsing the tags: show up to two tags, then a `+N` overflow badge.
|
||||
@@ -75,27 +77,19 @@ function DashboardInfo({
|
||||
}: DashboardInfoProps): JSX.Element {
|
||||
const { isEditable: canEdit } = useDashboardEditContext();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const show = useDashboardControlGroup('title');
|
||||
|
||||
const hasTags = tags.length > 0;
|
||||
const hasDescription = !isEmpty(description);
|
||||
const hasTags = show.tags && tags.length > 0;
|
||||
const hasDescription = show.description && !isEmpty(description);
|
||||
|
||||
const visibleTags = tags.slice(0, MAX_VISIBLE_TAGS);
|
||||
const remainingTags = tags.slice(MAX_VISIBLE_TAGS);
|
||||
|
||||
// Without a toggle, say why it can't be toggled rather than only restating the
|
||||
// lock state.
|
||||
let lockTooltip: string;
|
||||
if (onToggleLock) {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'Locked — click to unlock'
|
||||
: 'Unlocked — click to lock';
|
||||
} else if (lockDisabledTooltip) {
|
||||
lockTooltip = lockDisabledTooltip;
|
||||
} else {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'This dashboard is locked'
|
||||
: 'This dashboard is unlocked';
|
||||
}
|
||||
const lockTooltip = getLockTooltip({
|
||||
isLocked: isDashboardLocked,
|
||||
canToggle: !!onToggleLock,
|
||||
disabledReason: lockDisabledTooltip,
|
||||
});
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
@@ -128,66 +122,69 @@ function DashboardInfo({
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardInfo}>
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
|
||||
{isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Save title"
|
||||
testId="dashboard-title-save"
|
||||
onClick={onCommit}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Cancel title edit"
|
||||
testId="dashboard-title-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip title={title}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
})}
|
||||
data-testid="dashboard-title"
|
||||
onClick={canEdit ? onStartEdit : undefined}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<CopyButton
|
||||
value={dashboardId}
|
||||
ariaLabel="Copy dashboard ID"
|
||||
testId="dashboard-copy-id"
|
||||
onCopy={handleCopyId}
|
||||
/>
|
||||
</>
|
||||
{show.name && (
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
)}
|
||||
|
||||
{show.name &&
|
||||
(isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Save title"
|
||||
testId="dashboard-title-save"
|
||||
onClick={onCommit}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Cancel title edit"
|
||||
testId="dashboard-title-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip title={title}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
})}
|
||||
data-testid="dashboard-title"
|
||||
onClick={canEdit ? onStartEdit : undefined}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<CopyButton
|
||||
value={dashboardId}
|
||||
ariaLabel="Copy dashboard ID"
|
||||
testId="dashboard-copy-id"
|
||||
onCopy={handleCopyId}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
|
||||
{hasDescription && (
|
||||
<Tooltip
|
||||
side="bottom"
|
||||
@@ -205,7 +202,7 @@ function DashboardInfo({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isPublicDashboard && (
|
||||
{show.publicBadge && isPublicDashboard && (
|
||||
<Tooltip title="This dashboard is publicly accessible. Click to open the public page.">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -222,7 +219,7 @@ function DashboardInfo({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{showLockToggle && (
|
||||
{show.lock && showLockToggle && (
|
||||
<Tooltip title={onToggleLock ? lockTooltip : undefined}>
|
||||
<Button
|
||||
disabledTooltip={lockTooltip}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Without a toggle, say why it can't be toggled rather than only restating the
|
||||
// lock state.
|
||||
export function getLockTooltip({
|
||||
isLocked,
|
||||
canToggle,
|
||||
disabledReason,
|
||||
}: {
|
||||
isLocked: boolean;
|
||||
canToggle: boolean;
|
||||
disabledReason?: string;
|
||||
}): string {
|
||||
if (canToggle) {
|
||||
return isLocked ? 'Locked — click to unlock' : 'Unlocked — click to lock';
|
||||
}
|
||||
if (disabledReason) {
|
||||
return disabledReason;
|
||||
}
|
||||
return isLocked ? 'This dashboard is locked' : 'This dashboard is unlocked';
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { FullScreenHandle } from 'react-full-screen';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useDashboardLockPermission } from 'hooks/dashboards/useDashboardLockPermission';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import type { DashboardControlsInput } from '../../controls/types';
|
||||
|
||||
import { usePublicDashboardMeta } from '../../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import DashboardPageToolbar from '../index';
|
||||
|
||||
const dashboard = {
|
||||
id: 'dash-1',
|
||||
locked: true,
|
||||
source: 'user',
|
||||
spec: {
|
||||
display: { name: 'Checkout' },
|
||||
panels: {},
|
||||
layouts: [],
|
||||
variables: [],
|
||||
},
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
const handle = { enter: jest.fn() } as unknown as FullScreenHandle;
|
||||
|
||||
jest.mock('../DashboardActions/DashboardActions', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="dashboard-actions" />,
|
||||
}));
|
||||
jest.mock('../DashboardInfo/DashboardInfo', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="dashboard-info" />,
|
||||
}));
|
||||
jest.mock('../../VariablesBar/VariablesBar', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="variables-bar" />,
|
||||
}));
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="time-selection" />,
|
||||
}));
|
||||
jest.mock(
|
||||
'../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal',
|
||||
() => ({ __esModule: true, default: (): null => null }),
|
||||
);
|
||||
jest.mock('../../hooks/useCreatePanel', () => ({
|
||||
useCreatePanel: (): unknown => ({
|
||||
isPickerOpen: false,
|
||||
openPicker: jest.fn(),
|
||||
closePicker: jest.fn(),
|
||||
createPanel: jest.fn(),
|
||||
targetLayoutIndex: 0,
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../hooks/useOptimisticPatch', () => ({
|
||||
useOptimisticPatch: (): unknown => ({ patchAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('hooks/dashboards/useToggleDashboardLock', () => ({
|
||||
useToggleDashboardLock: (): unknown => ({ toggleLock: jest.fn() }),
|
||||
}));
|
||||
jest.mock('hooks/dashboards/useDashboardLockPermission', () => ({
|
||||
useDashboardLockPermission: jest.fn(() => ({
|
||||
canToggleLock: false,
|
||||
isLoading: false,
|
||||
disabledTooltip: '',
|
||||
})),
|
||||
}));
|
||||
jest.mock(
|
||||
'../../DashboardSettings/PublicDashboard/usePublicDashboardMeta',
|
||||
() => ({
|
||||
usePublicDashboardMeta: jest.fn(() => ({
|
||||
isPublic: false,
|
||||
publicMeta: undefined,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
function renderToolbar(
|
||||
ctx: { readOnly?: boolean; controls?: DashboardControlsInput } = {},
|
||||
): void {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: 'dash-1', refetch: jest.fn(), ...ctx });
|
||||
render(<DashboardPageToolbar dashboard={dashboard} handle={handle} />);
|
||||
}
|
||||
|
||||
describe('DashboardPageToolbar - controls', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders every row and checks lock + share by default', () => {
|
||||
renderToolbar();
|
||||
|
||||
expect(screen.getByTestId('dashboard-info')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-actions')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('time-selection')).toBeInTheDocument();
|
||||
expect(jest.mocked(useDashboardLockPermission)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: true }),
|
||||
);
|
||||
expect(jest.mocked(usePublicDashboardMeta)).toHaveBeenCalledWith('dash-1', {
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the lock permission check on a read-only mount', () => {
|
||||
renderToolbar({ readOnly: true });
|
||||
|
||||
expect(jest.mocked(useDashboardLockPermission)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the title row and actions the host hides, and their requests', () => {
|
||||
renderToolbar({
|
||||
readOnly: true,
|
||||
controls: { title: false, dashboardActions: false },
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('dashboard-info')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('dashboard-actions')).not.toBeInTheDocument();
|
||||
expect(jest.mocked(usePublicDashboardMeta)).toHaveBeenCalledWith('dash-1', {
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the time range and keeps the variables bar mounted but hidden', () => {
|
||||
renderToolbar({
|
||||
controls: { toolbar: { timeRange: false, variables: false } },
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('time-selection')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('variables-bar')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { useCreatePanel } from '../hooks/useCreatePanel';
|
||||
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
|
||||
import { useDashboardControlGroup } from '../controls/useDashboardControlGroup';
|
||||
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
|
||||
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
@@ -56,6 +57,11 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
[dashboard.tags],
|
||||
);
|
||||
|
||||
const showTitle = useDashboardControlGroup('title');
|
||||
const showActions = useDashboardControlGroup('dashboardActions');
|
||||
const showToolbar = useDashboardControlGroup('toolbar');
|
||||
const hasTitleRow = Object.values(showTitle).some(Boolean);
|
||||
const hasActions = Object.values(showActions).some(Boolean);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const {
|
||||
@@ -71,10 +77,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
useDashboardLockPermission({
|
||||
dashboardId: id,
|
||||
source: dashboard.source,
|
||||
enabled: showTitle.lock || showActions.lock,
|
||||
});
|
||||
|
||||
// Public-sharing meta (deduped react-query read); drives the header globe.
|
||||
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
|
||||
const { isPublic, publicMeta } = usePublicDashboardMeta(id, {
|
||||
enabled: showTitle.publicBadge,
|
||||
});
|
||||
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
|
||||
|
||||
// Shared with the list's row menu — it owns the API call, the toast and the
|
||||
@@ -155,48 +164,66 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<section className={styles.dashboardPageToolbarContainer}>
|
||||
<div className={styles.dashboardInfoWithActions}>
|
||||
<DashboardInfo
|
||||
title={title}
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={isPublic}
|
||||
publicUrl={publicUrl}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
showLockToggle={showLockToggle}
|
||||
onToggleLock={
|
||||
canToggleLock
|
||||
? (): void => handleLockDashboardToggle('header')
|
||||
: undefined
|
||||
}
|
||||
lockDisabledTooltip={lockDisabledTooltip}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onStartEdit={startEdit}
|
||||
onCommit={commit}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
<DashboardActions
|
||||
title={title}
|
||||
dashboard={dashboard}
|
||||
handle={handle}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
onAddPanel={onAddPanel}
|
||||
onLockToggle={(): void => handleLockDashboardToggle('menu')}
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
</div>
|
||||
{(hasTitleRow || hasActions) && (
|
||||
<div className={styles.dashboardInfoWithActions}>
|
||||
{hasTitleRow && (
|
||||
<DashboardInfo
|
||||
title={title}
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={isPublic}
|
||||
publicUrl={publicUrl}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
showLockToggle={showLockToggle}
|
||||
onToggleLock={
|
||||
canToggleLock
|
||||
? (): void => handleLockDashboardToggle('header')
|
||||
: undefined
|
||||
}
|
||||
lockDisabledTooltip={lockDisabledTooltip}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onStartEdit={startEdit}
|
||||
onCommit={commit}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
)}
|
||||
{hasActions && (
|
||||
<DashboardActions
|
||||
title={title}
|
||||
dashboard={dashboard}
|
||||
handle={handle}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
onAddPanel={onAddPanel}
|
||||
onLockToggle={(): void => handleLockDashboardToggle('menu')}
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 2: the time selector floats top-right (declared first so the
|
||||
variables bar's content wraps around it); the variables bar
|
||||
collapses to one line and, when expanded, wraps full-width under it. */}
|
||||
<div className={styles.toolbarRow}>
|
||||
<div className={styles.timeCluster}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
{showToolbar.timeRange && (
|
||||
<div className={styles.timeCluster}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={showToolbar.autoRefresh}
|
||||
hideShareModal
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showToolbar.variables ? (
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
) : (
|
||||
// Still mounted: the selectors seed and auto-select the values panels query with.
|
||||
<div hidden>
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PanelTypeSelectionModal
|
||||
open={isPickerOpen}
|
||||
|
||||
@@ -26,9 +26,13 @@ const PUBLIC_META_STALE_TIME = 5 * 60 * 1000;
|
||||
*/
|
||||
export function usePublicDashboardMeta(
|
||||
dashboardId: string,
|
||||
options?: { enabled?: boolean },
|
||||
): UsePublicDashboardMetaReturn {
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
const enabled = !!dashboardId && (isCloudUser || isEnterpriseSelfHostedUser);
|
||||
const enabled =
|
||||
(options?.enabled ?? true) &&
|
||||
!!dashboardId &&
|
||||
(isCloudUser || isEnterpriseSelfHostedUser);
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch } = useGetPublicDashboard(
|
||||
{ id: dashboardId },
|
||||
|
||||
@@ -2,33 +2,17 @@ import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useContextLinkVariables } from '../useContextLinkVariables';
|
||||
|
||||
const mockUseGetDashboardV2 = jest.fn();
|
||||
const mockUseDashboardVariableNames = jest.fn();
|
||||
const mockUseQueryBuilder = jest.fn();
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { dashboardId: string }) => unknown,
|
||||
): unknown => selector({ dashboardId: 'dash-1' }),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => mockUseQueryBuilder(),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
|
||||
}));
|
||||
|
||||
// dtoToFormModel is exercised by its own suite; here we only need it to surface a name.
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters',
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
|
||||
() => ({
|
||||
dtoToFormModel: (dto: { name: string }): { name: string } => ({
|
||||
name: dto.name,
|
||||
}),
|
||||
useDashboardVariableNames: (): unknown => mockUseDashboardVariableNames(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -45,11 +29,7 @@ describe('useContextLinkVariables', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
mockUseGetDashboardV2.mockReturnValue({
|
||||
data: {
|
||||
data: { spec: { variables: [{ name: 'region' }, { name: 'tier' }] } },
|
||||
},
|
||||
});
|
||||
mockUseDashboardVariableNames.mockReturnValue(['region', 'tier']);
|
||||
});
|
||||
|
||||
it('orders globals, then _prefixed query fields (deduped), then dashboard vars', () => {
|
||||
@@ -69,9 +49,7 @@ describe('useContextLinkVariables', () => {
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { builder: { queryData: [] } },
|
||||
});
|
||||
// Loaded dashboard with no variables — useDashboardFetchRequired guarantees the
|
||||
// dashboard is present, so the empty case is an empty spec, not `undefined`.
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
|
||||
mockUseDashboardVariableNames.mockReturnValue([]);
|
||||
|
||||
const { result } = renderHook(() => useContextLinkVariables());
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { usePanelActionItems } from '../usePanelActionItems';
|
||||
|
||||
/** Keys of the disabled items, in order. */
|
||||
@@ -494,4 +495,43 @@ describe('usePanelActionItems', () => {
|
||||
click(actionRow(result.current.items, 'create-alert'));
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
|
||||
});
|
||||
|
||||
describe('host controls', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('drops every writing action on a read-only mount', () => {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch: jest.fn(),
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'separator',
|
||||
'download',
|
||||
'create-alert',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the actions the host hides', () => {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch: jest.fn(),
|
||||
controls: { panelActions: { createAlert: false, delete: false } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
|
||||
expect(itemKeys(result.current)).not.toContain('create-alert');
|
||||
expect(itemKeys(result.current)).not.toContain('delete-panel');
|
||||
expect(itemKeys(result.current)).toContain('edit-panel');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/t
|
||||
import { blockedBy } from 'lib/authz/components/AuthZDropdown/utils';
|
||||
import { getBlockedPanelActions } from '../utils/getBlockedPanelActions';
|
||||
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControlGroup } from '../../../controls/useDashboardControlGroup';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
// hooks' deps (a fresh [] each render would re-create their callbacks).
|
||||
@@ -66,6 +67,7 @@ export function usePanelActionItems({
|
||||
const blocked = useMemo(() => getBlockedPanelActions(panel), [panel]);
|
||||
const { isEditable, editChecks, editDisabledTooltip } =
|
||||
useDashboardEditContext();
|
||||
const show = useDashboardControlGroup('panelActions');
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { openView } = useViewPanel();
|
||||
@@ -103,7 +105,7 @@ export function usePanelActionItems({
|
||||
const editGate = { checks: editChecks, ...blockedBy(editDisabledTooltip) };
|
||||
|
||||
const panelGroup: AuthZDropdownItemType[] = [];
|
||||
if (panelCapabilities.view) {
|
||||
if (show.view && panelCapabilities.view) {
|
||||
panelGroup.push({
|
||||
type: 'item',
|
||||
value: 'view-panel',
|
||||
@@ -112,7 +114,7 @@ export function usePanelActionItems({
|
||||
onClick: (): void => openView(panelId, panel),
|
||||
});
|
||||
}
|
||||
if (panelCapabilities.edit) {
|
||||
if (show.edit && panelCapabilities.edit) {
|
||||
panelGroup.push({
|
||||
type: 'item',
|
||||
value: 'edit-panel',
|
||||
@@ -122,7 +124,7 @@ export function usePanelActionItems({
|
||||
onClick: (): void => openPanelEditor(panelId, { panel }),
|
||||
});
|
||||
}
|
||||
if (panelCapabilities.clone) {
|
||||
if (show.clone && panelCapabilities.clone) {
|
||||
// Needs section context to place the copy; disabled without it.
|
||||
panelGroup.push({
|
||||
type: 'item',
|
||||
@@ -142,13 +144,13 @@ export function usePanelActionItems({
|
||||
}
|
||||
|
||||
const dataGroup: AuthZDropdownItemType[] = [];
|
||||
if (downloadItem) {
|
||||
if (show.download && downloadItem) {
|
||||
dataGroup.push(downloadItem);
|
||||
}
|
||||
|
||||
// Create Alerts opens a new tab and never mutates the dashboard, so —
|
||||
// unlike edit/clone — it isn't gated on editability (V1 parity).
|
||||
if (panelCapabilities.createAlert) {
|
||||
if (show.createAlert && panelCapabilities.createAlert) {
|
||||
dataGroup.push({
|
||||
type: 'item',
|
||||
value: 'create-alert',
|
||||
@@ -159,8 +161,9 @@ export function usePanelActionItems({
|
||||
});
|
||||
}
|
||||
|
||||
const moveGroup: AuthZDropdownItemType[] =
|
||||
isEditable && panelActions
|
||||
const moveGroup: AuthZDropdownItemType[] = !show.move
|
||||
? []
|
||||
: isEditable && panelActions
|
||||
? buildMoveItems({
|
||||
sections,
|
||||
currentLayoutIndex: panelActions.currentLayoutIndex,
|
||||
@@ -177,17 +180,19 @@ export function usePanelActionItems({
|
||||
},
|
||||
];
|
||||
|
||||
const deleteGroup: AuthZDropdownItemType[] = [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'delete-panel',
|
||||
label: 'Delete panel',
|
||||
prefix: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
...editGate,
|
||||
onClick: (): void => requestDelete(),
|
||||
},
|
||||
];
|
||||
const deleteGroup: AuthZDropdownItemType[] = !show.delete
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'delete-panel',
|
||||
label: 'Delete panel',
|
||||
prefix: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
...editGate,
|
||||
onClick: (): void => requestDelete(),
|
||||
},
|
||||
];
|
||||
|
||||
return [panelGroup, dataGroup, moveGroup, deleteGroup]
|
||||
.filter((group) => group.length > 0)
|
||||
@@ -197,6 +202,7 @@ export function usePanelActionItems({
|
||||
: [{ type: 'separator', value: `separator-${index}` }, ...group],
|
||||
);
|
||||
}, [
|
||||
show,
|
||||
isEditable,
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControl } from '../../../controls/useDashboardControl';
|
||||
|
||||
interface ViewPanelModalHeaderBaseProps {
|
||||
onSwitchToEdit: () => void;
|
||||
@@ -70,6 +71,7 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
} = useDashboardEditContext();
|
||||
const showSwitchToEdit = useDashboardControl('viewModal.switchToEdit');
|
||||
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
|
||||
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
|
||||
const panelTypeItems = usePanelTypeSelectItems({
|
||||
@@ -87,20 +89,22 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
</div>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
disabled={!canSwitchToEdit}
|
||||
onClick={onSwitchToEdit}
|
||||
testId="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
{showSwitchToEdit && (
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
disabled={!canSwitchToEdit}
|
||||
onClick={onSwitchToEdit}
|
||||
testId="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
{query && (
|
||||
<Button
|
||||
size="md"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import ViewPanelModalHeader from '../ViewPanelModalHeader';
|
||||
import type { DashboardControlsInput } from '../../../../controls/types';
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
|
||||
() => ({
|
||||
useDashboardEditContext: (): unknown => ({
|
||||
isEditable: true,
|
||||
editChecks: [],
|
||||
editDisabledTooltip: '',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems',
|
||||
() => ({ usePanelTypeSelectItems: (): unknown[] => [] }),
|
||||
);
|
||||
|
||||
function renderHeader(
|
||||
readOnly: boolean,
|
||||
controls?: DashboardControlsInput,
|
||||
): void {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch: jest.fn(),
|
||||
readOnly,
|
||||
controls,
|
||||
});
|
||||
render(
|
||||
<ViewPanelModalHeader
|
||||
mode="static"
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChangePanelKind={jest.fn()}
|
||||
onSwitchToEdit={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ViewPanelModalHeader - controls', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('offers Switch to Edit Mode on an editable mount', () => {
|
||||
renderHeader(false);
|
||||
|
||||
expect(screen.getByTestId('view-panel-switch-to-edit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Switch to Edit Mode on a read-only mount', () => {
|
||||
renderHeader(true);
|
||||
|
||||
expect(screen.getByTestId('view-panel-type-selector')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('view-panel-switch-to-edit'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Switch to Edit Mode when the host hides it', () => {
|
||||
renderHeader(false, { viewModal: false });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('view-panel-switch-to-edit'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -24,11 +24,16 @@ const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
|
||||
const DYNAMIC_KIND = 'signoz/DynamicVariable';
|
||||
const QUERY_KIND = 'signoz/QueryVariable';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useGetDashboardV2: (): unknown => ({
|
||||
data: { data: { spec: { variables: mockVariables } } },
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired',
|
||||
() => ({
|
||||
useDashboardFetchRequired: (): unknown => ({
|
||||
dashboard: { spec: { variables: mockVariables } },
|
||||
variables: mockVariables,
|
||||
refetch: jest.fn(),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useOptimisticPatch',
|
||||
() => ({
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import { AllTheProviders, renderHook } from 'tests/test-utils';
|
||||
|
||||
import { CHARTS_ONLY } from '../../../../controls/presets';
|
||||
import type { DashboardControlsInput } from '../../../../controls/types';
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { usePanelInteractions } from '../usePanelInteractions';
|
||||
|
||||
function dragSelect(controls?: DashboardControlsInput): void {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: 'd1', refetch: jest.fn(), controls });
|
||||
const { result } = renderHook(() => usePanelInteractions(), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
result.current.onDragSelect(1_000, 2_000);
|
||||
}
|
||||
|
||||
describe('usePanelInteractions drag-to-zoom', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('writes the dragged range to the URL', () => {
|
||||
dragSelect();
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('startTime=1000&endTime=2000'),
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when the host turns zoom off', () => {
|
||||
dragSelect({ panelActions: { zoom: false } });
|
||||
|
||||
expect(safeNavigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still zooms when the host hides the toolbar but leaves zoom on', () => {
|
||||
dragSelect({ toolbar: false });
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still zooms on a charts-only mount', () => {
|
||||
dragSelect(CHARTS_ONLY);
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import { useDrilldownCoordinates } from './useDrilldownCoordinates';
|
||||
import { useDrilldownDashboardVariables } from './useDrilldownDashboardVariables';
|
||||
import { useDrilldownFilter } from './useDrilldownFilter';
|
||||
import { useResolvedDrilldownQuery } from './useResolvedDrilldownQuery';
|
||||
import { useDashboardControl } from 'pages/DashboardPage/DashboardContainer/controls/useDashboardControl';
|
||||
import { useViewPanel } from './useViewPanel';
|
||||
|
||||
/** Which menu the popover shows; extend as submenus are added (e.g. dashboard variables). */
|
||||
@@ -84,12 +85,14 @@ export function useDrilldown(
|
||||
|
||||
// Drilldown only for builder-authored panels with a query to drill into (PromQL /
|
||||
// ClickHouse have no builder context to refine).
|
||||
const showDrilldown = useDashboardControl('panelActions.drilldown');
|
||||
const enableDrillDown = useMemo(
|
||||
() =>
|
||||
showDrilldown &&
|
||||
getPanelDefinition(kind).actions.drilldown &&
|
||||
getPanelQueryType(panel) === EQueryType.QUERY_BUILDER &&
|
||||
getBuilderQueries(queries).length > 0,
|
||||
[kind, panel, queries],
|
||||
[showDrilldown, kind, panel, queries],
|
||||
);
|
||||
|
||||
const v1Query = useMemo(
|
||||
|
||||
@@ -13,6 +13,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { useDashboardControl } from 'pages/DashboardPage/DashboardContainer/controls/useDashboardControl';
|
||||
|
||||
export interface PanelInteractions {
|
||||
/** Drag-select a chart range → write it to the URL + global time so every panel re-fetches the same range. */
|
||||
@@ -43,8 +44,12 @@ export function usePanelInteractions(): PanelInteractions {
|
||||
[syncMode, syncFilterMode, dashboardId],
|
||||
);
|
||||
|
||||
const canZoom = useDashboardControl('panelActions.zoom');
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
if (!canZoom) {
|
||||
return;
|
||||
}
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
@@ -57,7 +62,7 @@ export function usePanelInteractions(): PanelInteractions {
|
||||
void logEvent(DashboardDetailEvents.PanelZoomed, { context: 'panel' });
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, safeNavigate, urlQuery],
|
||||
[canZoom, dispatch, pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
return { onDragSelect, dashboardPreference };
|
||||
|
||||
@@ -8,12 +8,12 @@ import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { useCreatePanel } from '../../../hooks/useCreatePanel';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import { useCloneSection } from '../hooks/useCloneSection';
|
||||
import { useDeleteSection } from '../hooks/useDeleteSection';
|
||||
import { usePanelPickerHighlight } from '../hooks/usePanelPickerHighlight';
|
||||
import { usePanelPickerReveal } from '../hooks/usePanelPickerReveal';
|
||||
import { useRenameSection } from '../hooks/useRenameSection';
|
||||
import { useScrollIntoView } from '../hooks/useScrollIntoView';
|
||||
import { useSectionHeaderActions } from '../hooks/useSectionHeaderActions';
|
||||
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
|
||||
import SectionTitleModal from '../SectionTitleModal';
|
||||
import SectionGrid from '../SectionGrid/SectionGrid';
|
||||
@@ -66,7 +66,14 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
setIsDeleteOpen(false);
|
||||
}, [deleteSection]);
|
||||
|
||||
const cloneSection = useCloneSection();
|
||||
const openRename = useCallback((): void => setIsRenaming(true), []);
|
||||
const openDelete = useCallback((): void => setIsDeleteOpen(true), []);
|
||||
const actions = useSectionHeaderActions({
|
||||
section,
|
||||
openPicker,
|
||||
openRename,
|
||||
openDelete,
|
||||
});
|
||||
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
useScrollIntoView(section.id, sectionRef);
|
||||
@@ -116,35 +123,30 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
dragHandle={dragHandle}
|
||||
disabledChecks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
actions={{
|
||||
onRename: (): void => setIsRenaming(true),
|
||||
onAddPanel: (): void => openPicker(section.layoutIndex),
|
||||
onCloneSection: (): void => void cloneSection(section),
|
||||
onDeleteSection: (): void => setIsDeleteOpen(true),
|
||||
}}
|
||||
actions={actions}
|
||||
/>
|
||||
{open &&
|
||||
(section.items.length > 0 || pickerTarget ? (
|
||||
grid
|
||||
) : (
|
||||
<div className={styles.emptySection}>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
size="md"
|
||||
type="button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={
|
||||
isEditable ? (): void => openPicker(section.layoutIndex) : undefined
|
||||
}
|
||||
testId={`section-add-panel-${section.id}`}
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
))}
|
||||
(section.items.length > 0 || pickerTarget
|
||||
? grid
|
||||
: actions.onAddPanel && (
|
||||
<div className={styles.emptySection}>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
size="md"
|
||||
type="button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={
|
||||
isEditable ? (): void => openPicker(section.layoutIndex) : undefined
|
||||
}
|
||||
testId={`section-add-panel-${section.id}`}
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
))}
|
||||
<SectionTitleModal
|
||||
open={isRenaming}
|
||||
heading="Rename section"
|
||||
|
||||
@@ -25,7 +25,7 @@ function SectionActionsMenu({
|
||||
onRename,
|
||||
onCloneSection,
|
||||
onDeleteSection,
|
||||
}: SectionActionsMenuProps): JSX.Element {
|
||||
}: SectionActionsMenuProps): JSX.Element | null {
|
||||
const items = useMemo<AuthZDropdownItemType[]>(() => {
|
||||
const gate = { checks: disabledChecks, ...blockedBy(disabledTooltip) };
|
||||
const result: AuthZDropdownItemType[] = [];
|
||||
@@ -83,6 +83,10 @@ function SectionActionsMenu({
|
||||
onDeleteSection,
|
||||
]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthZDropdown
|
||||
items={items}
|
||||
|
||||
@@ -16,12 +16,12 @@ export interface SectionDragHandle {
|
||||
setActivatorNodeRef: (element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
/** Editable-mode section actions — present together or not at all. */
|
||||
/** Section actions; an absent one isn't offered. */
|
||||
export interface SectionHeaderActions {
|
||||
onRename: () => void;
|
||||
onAddPanel: () => void;
|
||||
onCloneSection: () => void;
|
||||
onDeleteSection: () => void;
|
||||
onRename?: () => void;
|
||||
onAddPanel?: () => void;
|
||||
onCloneSection?: () => void;
|
||||
onDeleteSection?: () => void;
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useDashboardControlGroup } from '../../../controls/useDashboardControlGroup';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { SectionHeaderActions } from '../SectionHeader/SectionHeader';
|
||||
import { useCloneSection } from './useCloneSection';
|
||||
|
||||
/** The section menu's actions, minus the ones the host hides (`sectionActions`). */
|
||||
export function useSectionHeaderActions({
|
||||
section,
|
||||
openPicker,
|
||||
openRename,
|
||||
openDelete,
|
||||
}: {
|
||||
section: DashboardSection;
|
||||
openPicker: (layoutIndex: number) => void;
|
||||
openRename: () => void;
|
||||
openDelete: () => void;
|
||||
}): SectionHeaderActions {
|
||||
const show = useDashboardControlGroup('sectionActions');
|
||||
const cloneSection = useCloneSection();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
onRename: show.rename ? openRename : undefined,
|
||||
onAddPanel: show.addPanel
|
||||
? (): void => openPicker(section.layoutIndex)
|
||||
: undefined,
|
||||
onCloneSection: show.clone
|
||||
? (): void => void cloneSection(section)
|
||||
: undefined,
|
||||
onDeleteSection: show.delete ? openDelete : undefined,
|
||||
}),
|
||||
[show, openRename, openPicker, section, cloneSection, openDelete],
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { resolveDefaultSelection } from './utils/resolveVariableSelection';
|
||||
import VariableSelector from './components/VariableSelector/VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
|
||||
import { useDashboardControl } from '../controls/useDashboardControl';
|
||||
|
||||
interface VariablesBarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
@@ -39,6 +40,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
// Persisted per dashboard so the full/collapsed view survives reloads.
|
||||
const expanded = useDashboardStore(selectVariablesExpanded(dashboardId));
|
||||
const setVariablesExpanded = useDashboardStore((s) => s.setVariablesExpanded);
|
||||
const showAddVariable = useDashboardControl('toolbar.addVariable');
|
||||
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
|
||||
itemCount: variables.length,
|
||||
gap: 8,
|
||||
@@ -49,7 +51,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
|
||||
// Editors can add a variable even before any exist; viewers with no variables
|
||||
// have nothing to show.
|
||||
if (variables.length === 0 && !isEditable) {
|
||||
if (variables.length === 0 && !(isEditable && showAddVariable)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -131,13 +133,15 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
{/* After the more/less trigger, in every state. Kept inline (not block)
|
||||
so the row still flows under the floated time selector, and always
|
||||
mounted so measuring never toggles it. */}
|
||||
<span className={styles.addSlot}>
|
||||
<AddVariableIcon
|
||||
checks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
</span>
|
||||
{showAddVariable && (
|
||||
<span className={styles.addSlot}>
|
||||
<AddVariableIcon
|
||||
checks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import VariablesBar from '../VariablesBar';
|
||||
import type { DashboardControlsInput } from '../../controls/types';
|
||||
|
||||
const dashboard = {
|
||||
id: 'dash-1',
|
||||
spec: { display: { name: 'D' }, panels: {}, layouts: [], variables: [] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
jest.mock('../../hooks/useDashboardEditContext', () => ({
|
||||
useDashboardEditContext: (): unknown => ({
|
||||
isEditable: false,
|
||||
editChecks: [],
|
||||
editDisabledTooltip: '',
|
||||
}),
|
||||
}));
|
||||
jest.mock('../hooks/useVariableSelection', () => ({
|
||||
useVariableSelection: (): unknown => ({
|
||||
variables: [{ name: 'env' }],
|
||||
selection: {},
|
||||
setSelection: jest.fn(),
|
||||
autoSelect: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest.mock('../components/VariableSelector/VariableSelector', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="variable-selector" />,
|
||||
}));
|
||||
|
||||
function renderBar(readOnly: boolean, controls?: DashboardControlsInput): void {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch: jest.fn(),
|
||||
readOnly,
|
||||
controls,
|
||||
});
|
||||
render(<VariablesBar dashboard={dashboard} />);
|
||||
}
|
||||
|
||||
describe('VariablesBar - controls', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('offers the add-variable trigger, disabled, to a viewer', () => {
|
||||
renderBar(false);
|
||||
|
||||
expect(screen.getByTestId('dashboard-variables-add')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the selectors but no add-variable trigger', () => {
|
||||
renderBar(true);
|
||||
|
||||
expect(screen.getByTestId('variable-selector')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('dashboard-variables-add'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no add-variable trigger when the host hides it', () => {
|
||||
renderBar(false, { toolbar: { addVariable: false } });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('dashboard-variables-add'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { dashboardResponse } from '../../stories/__story_mockdata__/dashboard';
|
||||
import { DEFAULT_CONTROLS } from '../controls/dashboardControls';
|
||||
import type { DashboardControlsInput } from '../controls/types';
|
||||
import DashboardContainer from '../index';
|
||||
import type { DashboardQuerySource } from '../store/slices/editContextSlice';
|
||||
|
||||
const QUERY_KEY = ['lockdown-dashboard'];
|
||||
|
||||
const ALL_CONTROLS_OFF = Object.fromEntries(
|
||||
Object.keys(DEFAULT_CONTROLS).map((group) => [group, false]),
|
||||
) as DashboardControlsInput;
|
||||
|
||||
const select = (
|
||||
response: GetDashboardV2200,
|
||||
): DashboardtypesGettableDashboardV2DTO => response.data;
|
||||
const SOURCE: DashboardQuerySource = { queryKey: QUERY_KEY, select };
|
||||
|
||||
/**
|
||||
* Interactive elements a host can't turn off: they read, never write, and the
|
||||
* page is unusable without them. Anything else here must be a control.
|
||||
*/
|
||||
const ALLOWED_TEST_ID_PREFIXES = [
|
||||
// Collapses a section in this browser only; it also carries the section title.
|
||||
'dashboard-section-toggle-',
|
||||
];
|
||||
|
||||
const INTERACTIVE =
|
||||
'button, a[href], input, select, textarea, [role="button"], [role="menuitem"], [role="combobox"], [role="switch"], [role="checkbox"]';
|
||||
|
||||
function LockdownHost(): JSX.Element | null {
|
||||
const { data } = useQuery(QUERY_KEY, () =>
|
||||
dashboardResponse({
|
||||
panels: 3,
|
||||
sectioned: true,
|
||||
variables: ['custom'],
|
||||
locked: false,
|
||||
}),
|
||||
);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DashboardContainer
|
||||
dashboard={data.data}
|
||||
refetch={jest.fn()}
|
||||
source={SOURCE}
|
||||
readOnly
|
||||
controls={ALL_CONTROLS_OFF}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function visibleInteractive(): Element[] {
|
||||
return Array.from(document.body.querySelectorAll(INTERACTIVE)).filter(
|
||||
(element) => !element.closest('[hidden]'),
|
||||
);
|
||||
}
|
||||
|
||||
function describeElement(element: Element): string {
|
||||
return (
|
||||
element.getAttribute('data-testid') ??
|
||||
`<${element.tagName.toLowerCase()} aria-label="${element.getAttribute('aria-label') ?? ''}">${element.textContent?.trim().slice(0, 40) ?? ''}`
|
||||
);
|
||||
}
|
||||
|
||||
describe('DashboardContainer lockdown', () => {
|
||||
// A control a host can't switch off, or one read-only doesn't remove, fails here.
|
||||
it('renders nothing interactive with every control off on a read-only mount', async () => {
|
||||
render(<LockdownHost />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId(/^panel-/).length).toBeGreaterThan(0),
|
||||
);
|
||||
|
||||
const leaked = visibleInteractive()
|
||||
.map(describeElement)
|
||||
.filter(
|
||||
(id) => !ALLOWED_TEST_ID_PREFIXES.some((prefix) => id.startsWith(prefix)),
|
||||
);
|
||||
|
||||
expect(leaked).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
|
||||
import DashboardPageBreadcrumbs from './DashboardPageBreadcrumbs';
|
||||
import { useShareVariablesOption } from './useShareVariablesOption';
|
||||
|
||||
@@ -16,13 +17,16 @@ function DashboardPageHeader({
|
||||
image,
|
||||
}: DashboardPageHeaderProps): JSX.Element {
|
||||
const shareVariablesOption = useShareVariablesOption();
|
||||
const show = useDashboardControlGroup('header');
|
||||
return (
|
||||
<div className={styles.dashboardPageHeader}>
|
||||
<DashboardPageBreadcrumbs title={title} image={image} />
|
||||
{show.breadcrumbs && (
|
||||
<DashboardPageBreadcrumbs title={title} image={image} />
|
||||
)}
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
enableShare={show.share}
|
||||
enableFeedback={show.feedback}
|
||||
shareModalExtraOption={shareVariablesOption}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
CONTROL_WRITES,
|
||||
DEFAULT_CONTROLS,
|
||||
isControlVisible,
|
||||
resolveControls,
|
||||
} from '../dashboardControls';
|
||||
import type { DashboardControlId } from '../types';
|
||||
|
||||
const ALL_IDS = Object.keys(CONTROL_WRITES) as DashboardControlId[];
|
||||
|
||||
describe('resolveControls', () => {
|
||||
it('shows every control when the host passes nothing', () => {
|
||||
expect(resolveControls(undefined, false)).toStrictEqual(DEFAULT_CONTROLS);
|
||||
});
|
||||
|
||||
it('hides a whole group from one boolean', () => {
|
||||
const controls = resolveControls({ header: false }, false);
|
||||
|
||||
expect(controls.header).toStrictEqual({
|
||||
breadcrumbs: false,
|
||||
share: false,
|
||||
feedback: false,
|
||||
});
|
||||
expect(controls.title.name).toBe(true);
|
||||
});
|
||||
|
||||
it('hides single items and keeps the rest of the group', () => {
|
||||
const controls = resolveControls(
|
||||
{ panelActions: { createAlert: false } },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(controls.panelActions.createAlert).toBe(false);
|
||||
expect(controls.panelActions.download).toBe(true);
|
||||
});
|
||||
|
||||
it('hides every writing control on a read-only mount, whatever the host asked', () => {
|
||||
const controls = resolveControls(
|
||||
{ dashboardActions: true, panelActions: { edit: true } },
|
||||
true,
|
||||
);
|
||||
|
||||
ALL_IDS.forEach((id) => {
|
||||
expect([id, isControlVisible(controls, id)]).toStrictEqual([
|
||||
id,
|
||||
!CONTROL_WRITES[id],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('still lets the host hide non-writing controls on a read-only mount', () => {
|
||||
const controls = resolveControls({ panelActions: { download: false } }, true);
|
||||
|
||||
expect(controls.panelActions.download).toBe(false);
|
||||
expect(controls.panelActions.view).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { resolveControls } from '../dashboardControls';
|
||||
import { CHARTS_ONLY, EMBEDDED, withControls } from '../presets';
|
||||
|
||||
describe('withControls', () => {
|
||||
it('adds an override group the preset leaves alone', () => {
|
||||
expect(
|
||||
withControls(EMBEDDED, { toolbar: { autoRefresh: false } }),
|
||||
).toStrictEqual({
|
||||
header: false,
|
||||
title: false,
|
||||
dashboardActions: false,
|
||||
toolbar: { autoRefresh: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('turns single items back on inside a group the preset hides', () => {
|
||||
const merged = withControls(CHARTS_ONLY, { panelActions: { view: true } });
|
||||
const controls = resolveControls(merged, false);
|
||||
|
||||
expect(controls.panelActions.view).toBe(true);
|
||||
expect(controls.panelActions.download).toBe(false);
|
||||
expect(controls.panelActions.drilldown).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a later whole-group boolean replace earlier items', () => {
|
||||
expect(
|
||||
withControls(
|
||||
{ panelActions: { createAlert: false } },
|
||||
{ panelActions: true },
|
||||
),
|
||||
).toStrictEqual({ panelActions: true });
|
||||
});
|
||||
|
||||
it('lets later items win over earlier ones', () => {
|
||||
expect(
|
||||
withControls(
|
||||
{ toolbar: { timeRange: false, autoRefresh: false } },
|
||||
{ toolbar: { timeRange: true } },
|
||||
),
|
||||
).toStrictEqual({ toolbar: { timeRange: true, autoRefresh: false } });
|
||||
});
|
||||
|
||||
it('cannot turn a writing control back on for a read-only mount', () => {
|
||||
const merged = withControls(EMBEDDED, { dashboardActions: { delete: true } });
|
||||
|
||||
expect(resolveControls(merged, true).dashboardActions.delete).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type {
|
||||
DashboardControlGroup,
|
||||
DashboardControlId,
|
||||
DashboardControls,
|
||||
DashboardControlsInput,
|
||||
} from './types';
|
||||
|
||||
export const DEFAULT_CONTROLS: DashboardControls = {
|
||||
header: { breadcrumbs: true, share: true, feedback: true },
|
||||
title: {
|
||||
name: true,
|
||||
description: true,
|
||||
tags: true,
|
||||
lock: true,
|
||||
publicBadge: true,
|
||||
},
|
||||
toolbar: {
|
||||
timeRange: true,
|
||||
autoRefresh: true,
|
||||
variables: true,
|
||||
addVariable: true,
|
||||
},
|
||||
dashboardActions: {
|
||||
fullscreen: true,
|
||||
rename: true,
|
||||
clone: true,
|
||||
lock: true,
|
||||
newSection: true,
|
||||
delete: true,
|
||||
configure: true,
|
||||
json: true,
|
||||
newPanel: true,
|
||||
},
|
||||
sectionActions: { addPanel: true, rename: true, clone: true, delete: true },
|
||||
panelActions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: true,
|
||||
createAlert: true,
|
||||
move: true,
|
||||
delete: true,
|
||||
drilldown: true,
|
||||
zoom: true,
|
||||
},
|
||||
viewModal: { switchToEdit: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether each control writes to the dashboard. A read-only mount hides every
|
||||
* `true` here regardless of what the host asked for. Exhaustive, so a new
|
||||
* control can't ship without deciding.
|
||||
*/
|
||||
export const CONTROL_WRITES: Record<DashboardControlId, boolean> = {
|
||||
'header.breadcrumbs': false,
|
||||
'header.share': false,
|
||||
'header.feedback': false,
|
||||
'title.name': false,
|
||||
'title.description': false,
|
||||
'title.tags': false,
|
||||
'title.lock': true,
|
||||
'title.publicBadge': false,
|
||||
'toolbar.timeRange': false,
|
||||
'toolbar.autoRefresh': false,
|
||||
'toolbar.variables': false,
|
||||
'toolbar.addVariable': true,
|
||||
'dashboardActions.fullscreen': false,
|
||||
'dashboardActions.rename': true,
|
||||
'dashboardActions.clone': true,
|
||||
'dashboardActions.lock': true,
|
||||
'dashboardActions.newSection': true,
|
||||
'dashboardActions.delete': true,
|
||||
'dashboardActions.configure': true,
|
||||
// Opens read-only on a non-editable mount.
|
||||
'dashboardActions.json': false,
|
||||
'dashboardActions.newPanel': true,
|
||||
'sectionActions.addPanel': true,
|
||||
'sectionActions.rename': true,
|
||||
'sectionActions.clone': true,
|
||||
'sectionActions.delete': true,
|
||||
'panelActions.view': false,
|
||||
'panelActions.edit': true,
|
||||
'panelActions.clone': true,
|
||||
'panelActions.download': false,
|
||||
'panelActions.createAlert': false,
|
||||
'panelActions.move': true,
|
||||
'panelActions.delete': true,
|
||||
'panelActions.drilldown': false,
|
||||
'panelActions.zoom': false,
|
||||
'viewModal.switchToEdit': true,
|
||||
};
|
||||
|
||||
function resolveGroup<G extends DashboardControlGroup>(
|
||||
group: G,
|
||||
input: DashboardControlsInput | undefined,
|
||||
readOnly: boolean,
|
||||
): DashboardControls[G] {
|
||||
const defaults: Record<string, boolean> = DEFAULT_CONTROLS[group];
|
||||
const override = input?.[group] as
|
||||
| boolean
|
||||
| Partial<Record<string, boolean>>
|
||||
| undefined;
|
||||
const items = Object.keys(defaults).map((item): [string, boolean] => {
|
||||
const requested =
|
||||
typeof override === 'boolean'
|
||||
? override
|
||||
: (override?.[item] ?? defaults[item]);
|
||||
const writes = CONTROL_WRITES[`${group}.${item}` as DashboardControlId];
|
||||
return [item, requested && !(readOnly && writes)];
|
||||
});
|
||||
return Object.fromEntries(items) as DashboardControls[G];
|
||||
}
|
||||
|
||||
/** Host input over the defaults, then every writing control off when read-only. */
|
||||
export function resolveControls(
|
||||
input: DashboardControlsInput | undefined,
|
||||
readOnly: boolean,
|
||||
): DashboardControls {
|
||||
return {
|
||||
header: resolveGroup('header', input, readOnly),
|
||||
title: resolveGroup('title', input, readOnly),
|
||||
toolbar: resolveGroup('toolbar', input, readOnly),
|
||||
dashboardActions: resolveGroup('dashboardActions', input, readOnly),
|
||||
sectionActions: resolveGroup('sectionActions', input, readOnly),
|
||||
panelActions: resolveGroup('panelActions', input, readOnly),
|
||||
viewModal: resolveGroup('viewModal', input, readOnly),
|
||||
};
|
||||
}
|
||||
|
||||
export function isControlVisible(
|
||||
controls: DashboardControls,
|
||||
id: DashboardControlId,
|
||||
): boolean {
|
||||
const [group, item] = id.split('.') as [DashboardControlGroup, string];
|
||||
return (controls[group] as Record<string, boolean>)[item];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { DEFAULT_CONTROLS } from './dashboardControls';
|
||||
import type {
|
||||
DashboardControlGroup,
|
||||
DashboardControls,
|
||||
DashboardControlsInput,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* The host page owns the header, the title row and the dashboard-level actions.
|
||||
* Layout presets only: `readOnly` stays a separate switch, so no preset can turn
|
||||
* a writing control back on.
|
||||
*/
|
||||
export const EMBEDDED: DashboardControlsInput = {
|
||||
header: false,
|
||||
title: false,
|
||||
dashboardActions: false,
|
||||
};
|
||||
|
||||
/** Embedded, and panels are just charts: no panel menu, no drilldown. Charts still zoom. */
|
||||
export const CHARTS_ONLY: DashboardControlsInput = {
|
||||
...EMBEDDED,
|
||||
panelActions: {
|
||||
view: false,
|
||||
edit: false,
|
||||
clone: false,
|
||||
download: false,
|
||||
createAlert: false,
|
||||
move: false,
|
||||
delete: false,
|
||||
drilldown: false,
|
||||
},
|
||||
};
|
||||
|
||||
type GroupInput<G extends DashboardControlGroup> =
|
||||
| boolean
|
||||
| Partial<DashboardControls[G]>;
|
||||
|
||||
function mergeGroup<G extends DashboardControlGroup>(
|
||||
group: G,
|
||||
base: GroupInput<G> | undefined,
|
||||
next: GroupInput<G> | undefined,
|
||||
): GroupInput<G> | undefined {
|
||||
if (next === undefined) {
|
||||
return base;
|
||||
}
|
||||
if (typeof next === 'boolean' || base === undefined) {
|
||||
return next;
|
||||
}
|
||||
// A whole-group boolean underneath expands to its items before the override.
|
||||
const expanded: Partial<DashboardControls[G]> =
|
||||
typeof base === 'boolean'
|
||||
? (Object.fromEntries(
|
||||
Object.keys(DEFAULT_CONTROLS[group]).map((item) => [item, base]),
|
||||
) as Partial<DashboardControls[G]>)
|
||||
: base;
|
||||
return { ...expanded, ...next };
|
||||
}
|
||||
|
||||
/** Merges presets and overrides; later inputs win, item by item. */
|
||||
export function withControls(
|
||||
...inputs: DashboardControlsInput[]
|
||||
): DashboardControlsInput {
|
||||
const groups = Object.keys(DEFAULT_CONTROLS) as DashboardControlGroup[];
|
||||
return inputs.reduce<DashboardControlsInput>((merged, input) => {
|
||||
const result: DashboardControlsInput = { ...merged };
|
||||
groups.forEach((group) => {
|
||||
const value = mergeGroup(group, merged[group], input[group]);
|
||||
if (value !== undefined) {
|
||||
(result as Record<string, unknown>)[group] = value;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { PanelActionId } from '../PanelsAndSectionsLayout/Panel/PanelActionsMenu/panelActionMeta';
|
||||
|
||||
/** Every control a host can show or hide, grouped by where it renders. */
|
||||
export interface DashboardControls {
|
||||
header: { breadcrumbs: boolean; share: boolean; feedback: boolean };
|
||||
title: {
|
||||
name: boolean;
|
||||
description: boolean;
|
||||
tags: boolean;
|
||||
lock: boolean;
|
||||
publicBadge: boolean;
|
||||
};
|
||||
toolbar: {
|
||||
timeRange: boolean;
|
||||
autoRefresh: boolean;
|
||||
variables: boolean;
|
||||
addVariable: boolean;
|
||||
};
|
||||
dashboardActions: {
|
||||
fullscreen: boolean;
|
||||
rename: boolean;
|
||||
clone: boolean;
|
||||
lock: boolean;
|
||||
newSection: boolean;
|
||||
delete: boolean;
|
||||
configure: boolean;
|
||||
json: boolean;
|
||||
newPanel: boolean;
|
||||
};
|
||||
sectionActions: {
|
||||
addPanel: boolean;
|
||||
rename: boolean;
|
||||
clone: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
// Keyed by the panel menu's own ids, so a new panel action needs a control.
|
||||
// `drilldown` (click) and `zoom` (drag-select a time range) act on the chart itself.
|
||||
panelActions: Record<PanelActionId | 'drilldown' | 'zoom', boolean>;
|
||||
viewModal: { switchToEdit: boolean };
|
||||
}
|
||||
|
||||
export type DashboardControlGroup = keyof DashboardControls;
|
||||
|
||||
export type DashboardControlId = {
|
||||
[
|
||||
G in DashboardControlGroup
|
||||
]: `${G}.${Extract<keyof DashboardControls[G], string>}`;
|
||||
}[DashboardControlGroup];
|
||||
|
||||
/** A whole group as one boolean, or single items; omitted means shown. */
|
||||
export type DashboardControlsInput = {
|
||||
[G in DashboardControlGroup]?: boolean | Partial<DashboardControls[G]>;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { isControlVisible } from './dashboardControls';
|
||||
import type { DashboardControlId } from './types';
|
||||
|
||||
/**
|
||||
* Whether a control renders on this mount: the host's `controls`, with every
|
||||
* writing control off when read-only. Lock and permission state stay with
|
||||
* `useDashboardEditContext`, which decides disabled + reason.
|
||||
*/
|
||||
export function useDashboardControl(id: DashboardControlId): boolean {
|
||||
return useDashboardStore((s) => isControlVisible(s.controls, id));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import type { DashboardControlGroup, DashboardControls } from './types';
|
||||
|
||||
/** Every control in a group, resolved for this mount (see `useDashboardControl`). */
|
||||
export function useDashboardControlGroup<G extends DashboardControlGroup>(
|
||||
group: G,
|
||||
): DashboardControls[G] {
|
||||
return useDashboardStore((s) => s.controls[group]);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
buildDashboardUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useDashboardEditContext } from '../useDashboardEditContext';
|
||||
|
||||
const DASHBOARD_ID = 'dash-1';
|
||||
@@ -44,6 +45,23 @@ describe('useDashboardEditContext - AuthZ', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
useDashboardStore.setState({ readOnly: false });
|
||||
});
|
||||
|
||||
// A host-forced view-only mount outranks every grant.
|
||||
it('is read-only on a read-only mount despite full permissions', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
useDashboardStore.setState({ readOnly: true });
|
||||
|
||||
const { result } = renderGuard();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.editDisabledTooltip).toBe('dashboard_read_only_here'),
|
||||
);
|
||||
expect(result.current.isEditable).toBe(false);
|
||||
expect(result.current.canEditDashboard).toBe(false);
|
||||
expect(result.current.canDeleteDashboard).toBe(false);
|
||||
expect(result.current.deleteDisabledTooltip).toBe('dashboard_read_only_here');
|
||||
});
|
||||
|
||||
describe('permission granted', () => {
|
||||
|
||||
@@ -1,57 +1,115 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { renderHook } from 'tests/test-utils';
|
||||
import {
|
||||
type DashboardtypesGettableDashboardV2DTO,
|
||||
type DashboardtypesVariableDTO,
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind as TextVariableKind,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardQuerySource } from '../../store/slices/editContextSlice';
|
||||
import { useDashboardFetchRequired } from '../useDashboardFetchRequired';
|
||||
|
||||
const mockUseGetDashboardV2 = jest.fn();
|
||||
const QUERY_KEY = ['/api/v2/dashboards/dash-1'];
|
||||
const mockRefetch = jest.fn();
|
||||
let mockSource: DashboardQuerySource | null;
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { dashboardId: string }) => unknown,
|
||||
): unknown => selector({ dashboardId: 'dash-1' }),
|
||||
selector: (s: {
|
||||
dashboardId: string;
|
||||
dashboardQuerySource: DashboardQuerySource | null;
|
||||
refetch: () => void;
|
||||
}) => unknown,
|
||||
): unknown =>
|
||||
selector({
|
||||
dashboardId: 'dash-1',
|
||||
dashboardQuerySource: mockSource,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
|
||||
}));
|
||||
function textVariable(name: string): DashboardtypesVariableDTO {
|
||||
return {
|
||||
kind: TextVariableKind.TextVariable,
|
||||
spec: { name, display: { name }, value: '' },
|
||||
};
|
||||
}
|
||||
|
||||
function dashboardWith(
|
||||
spec: Partial<DashboardtypesGettableDashboardV2DTO['spec']>,
|
||||
): DashboardtypesGettableDashboardV2DTO {
|
||||
return { id: 'dash-1', spec } as DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function renderWithCache(
|
||||
cached?: unknown,
|
||||
): ReturnType<
|
||||
typeof renderHook<ReturnType<typeof useDashboardFetchRequired>, void>
|
||||
> {
|
||||
const queryClient = new QueryClient();
|
||||
if (cached !== undefined) {
|
||||
queryClient.setQueryData(mockSource?.queryKey ?? QUERY_KEY, cached);
|
||||
}
|
||||
return renderHook(() => useDashboardFetchRequired(), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe('useDashboardFetchRequired', () => {
|
||||
it('returns the loaded dashboard and its variables', () => {
|
||||
const dashboard = {
|
||||
spec: { variables: [{ name: 'region' }, { name: 'tier' }] },
|
||||
};
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: { data: dashboard } });
|
||||
beforeEach(() => {
|
||||
mockSource = null;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDashboardFetchRequired());
|
||||
it('reads the dashboards API entry when the host sets no source', () => {
|
||||
const variables = [textVariable('region'), textVariable('tier')];
|
||||
const dashboard = dashboardWith({ variables });
|
||||
|
||||
const { result } = renderWithCache({ data: dashboard, status: 'success' });
|
||||
|
||||
expect(result.current.dashboard).toBe(dashboard);
|
||||
expect(result.current.variables).toStrictEqual([
|
||||
{ name: 'region' },
|
||||
{ name: 'tier' },
|
||||
]);
|
||||
expect(result.current.variables).toBe(variables);
|
||||
expect(result.current.refetch).toBe(mockRefetch);
|
||||
});
|
||||
|
||||
it("reads a host's own cache entry through its select", () => {
|
||||
const systemDashboard = { name: 'ai-o11y-overview', spec: {} };
|
||||
mockSource = {
|
||||
queryKey: ['/api/v2/dashboards/system/ai-o11y-overview'],
|
||||
select: (response: { data: typeof systemDashboard }) =>
|
||||
({
|
||||
...response.data,
|
||||
id: 'system-id',
|
||||
}) as DashboardtypesGettableDashboardV2DTO,
|
||||
};
|
||||
|
||||
const { result } = renderWithCache({ data: systemDashboard });
|
||||
|
||||
expect(result.current.dashboard).toStrictEqual({
|
||||
name: 'ai-o11y-overview',
|
||||
spec: {},
|
||||
id: 'system-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes variables to an empty array when the spec has none', () => {
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
|
||||
|
||||
const { result } = renderHook(() => useDashboardFetchRequired());
|
||||
const { result } = renderWithCache({ data: dashboardWith({}) });
|
||||
|
||||
expect(result.current.variables).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('throws when used outside a loaded dashboard subtree', () => {
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: undefined });
|
||||
|
||||
it('throws when the source entry is empty', () => {
|
||||
// Suppress React's expected render-error logging for this case.
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
expect(() => renderHook(() => useDashboardFetchRequired())).toThrow(
|
||||
/outside a loaded dashboard subtree/,
|
||||
);
|
||||
expect(() => renderWithCache()).toThrow(/outside a loaded dashboard subtree/);
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
@@ -111,3 +111,32 @@ describe('useGetQueryRangeV5 enabled gating', () => {
|
||||
expect(mockQueryRangeV5).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGetQueryRangeV5 host fetch', () => {
|
||||
it('fetches through the host fetch instead of query_range', async () => {
|
||||
mockQueryRangeV5.mockReset();
|
||||
const fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 'success', data: {} } as QueryRangeV5200);
|
||||
const wrapper = ({ children }: PropsWithChildren): JSX.Element => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useGetQueryRangeV5({
|
||||
requestPayload: REQUEST,
|
||||
queryKey: KEY_A,
|
||||
enabled: true,
|
||||
fetch,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||
expect(fetch.mock.calls[0][0]).toBe(REQUEST);
|
||||
expect(mockQueryRangeV5).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,10 +19,12 @@ jest.mock('api/generated/services/dashboard', () => ({
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
|
||||
}));
|
||||
|
||||
let mockReadOnly = false;
|
||||
|
||||
jest.mock('../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: jest.fn(
|
||||
(selector: (s: { dashboardId: string; isEditable: boolean }) => unknown) =>
|
||||
selector({ dashboardId: 'dash-1', isEditable: true }),
|
||||
(selector: (s: { dashboardId: string; readOnly: boolean }) => unknown) =>
|
||||
selector({ dashboardId: 'dash-1', readOnly: mockReadOnly }),
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -35,6 +37,7 @@ const queryClient = {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let captured: { fn: (ops: any) => unknown; options: any };
|
||||
let mockMutateAsync: jest.Mock;
|
||||
|
||||
function dashboardEnvelope(name: string): GetDashboardV2200 {
|
||||
return {
|
||||
@@ -50,10 +53,12 @@ const replaceNameOp = {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockReadOnly = false;
|
||||
mockMutateAsync = jest.fn().mockResolvedValue(undefined);
|
||||
(useQueryClient as jest.Mock).mockReturnValue(queryClient);
|
||||
(useMutation as jest.Mock).mockImplementation((fn, options) => {
|
||||
captured = { fn, options };
|
||||
return { mutateAsync: jest.fn(), isLoading: false };
|
||||
return { mutateAsync: mockMutateAsync, isLoading: false };
|
||||
});
|
||||
renderHook(() => useOptimisticPatch());
|
||||
});
|
||||
@@ -119,4 +124,22 @@ describe('useOptimisticPatch', () => {
|
||||
// No follow-up GET (that could read stale data and flicker the UI back).
|
||||
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('patchAsync rejects without sending on a read-only mount', async () => {
|
||||
mockReadOnly = true;
|
||||
const { result } = renderHook(() => useOptimisticPatch());
|
||||
|
||||
await expect(result.current.patchAsync([replaceNameOp])).rejects.toThrow(
|
||||
'dashboard_read_only_here',
|
||||
);
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('patchAsync sends the ops when the mount is editable', async () => {
|
||||
const { result } = renderHook(() => useOptimisticPatch());
|
||||
|
||||
await result.current.patchAsync([replaceNameOp]);
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith([replaceNameOp]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
} from 'constants/queryCacheTime';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { usePanelQuery } from '../usePanelQuery';
|
||||
import { useGetQueryRangeV5 } from '../useGetQueryRangeV5';
|
||||
|
||||
@@ -598,4 +599,58 @@ describe('usePanelQuery', () => {
|
||||
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a host query runner', () => {
|
||||
const runner = {
|
||||
key: 'public:abc',
|
||||
run: jest.fn().mockResolvedValue({ status: 'success', data: {} }),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: 'd1',
|
||||
refetch: jest.fn(),
|
||||
queryRunner: runner,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('keys the cache by the runner and runs the query through it with the panel id', async () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPABILITIES,
|
||||
}),
|
||||
);
|
||||
const [{ queryKey, fetch, requestPayload }] =
|
||||
mockUseGetQueryRangeV5.mock.calls[0];
|
||||
|
||||
expect(queryKey).toContain('public:abc');
|
||||
await fetch(requestPayload);
|
||||
expect(runner.run).toHaveBeenCalledWith({
|
||||
panelId: 'p1',
|
||||
requestPayload,
|
||||
signal: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('runs on query_range when no host runner is set', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPABILITIES,
|
||||
}),
|
||||
);
|
||||
const [{ fetch }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
|
||||
expect(fetch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useDashboardFetchRequired } from './useDashboardFetchRequired';
|
||||
*/
|
||||
export function useDashboardEditContext(): DashboardEditContext {
|
||||
const { dashboard } = useDashboardFetchRequired();
|
||||
const readOnly = useDashboardStore((s) => s.readOnly);
|
||||
const {
|
||||
canRead,
|
||||
canEdit,
|
||||
@@ -24,12 +25,8 @@ export function useDashboardEditContext(): DashboardEditContext {
|
||||
editChecks,
|
||||
deletePermission,
|
||||
areOtherPermissionsLoading,
|
||||
} = useDashboardPermissions(dashboard.id);
|
||||
} = useDashboardPermissions(dashboard.id, { enabled: !readOnly });
|
||||
const { t } = useTranslation('dashboard');
|
||||
const readOnlyOverride = useDashboardStore(
|
||||
(s) => s.canEditDashboardOverride === false,
|
||||
);
|
||||
|
||||
const deleteChecks = useMemo(() => [deletePermission], [deletePermission]);
|
||||
|
||||
return deriveEditContext({
|
||||
@@ -44,6 +41,6 @@ export function useDashboardEditContext(): DashboardEditContext {
|
||||
locked: t('dashboard_locked'),
|
||||
readOnly: t('dashboard_read_only_here'),
|
||||
},
|
||||
readOnlyOverride,
|
||||
readOnlyOverride: readOnly,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,34 +1,52 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { getGetDashboardV2QueryKey } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesVariableDTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
|
||||
|
||||
import { useDashboardFetch } from './useDashboardFetch';
|
||||
import type { UseDashboardFetchResult } from './useDashboardFetch';
|
||||
|
||||
export interface UseDashboardFetchRequiredResult {
|
||||
/** Guaranteed present — the hook throws otherwise. */
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
/** Always an array, so callers skip `?? []` guards. */
|
||||
variables: DashboardtypesVariableDTO[];
|
||||
refetch: UseDashboardFetchResult['refetch'];
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
const selectDashboardV2 = (
|
||||
response: GetDashboardV2200,
|
||||
): DashboardtypesGettableDashboardV2DTO => response.data;
|
||||
|
||||
/**
|
||||
* Dashboard accessor for components rendered inside a loaded dashboard subtree. The
|
||||
* root pages own the fetch and gate on a resolved dashboard before mounting their
|
||||
* subtree, so this reuses the shared react-query cache entry and throws if the
|
||||
* dashboard is missing — turning a silent `undefined` into a loud programmer error. It
|
||||
* also derives spec collections (`variables`) since the dashboard is guaranteed here.
|
||||
* root owns the fetch and publishes where it cached the dashboard
|
||||
* (`dashboardQuerySource`); this observes that entry without fetching, and throws if
|
||||
* it's empty — turning a silent `undefined` into a loud programmer error. It also
|
||||
* derives spec collections (`variables`) since the dashboard is guaranteed here.
|
||||
*
|
||||
* Prefer this over `useDashboardFetch` outside the two root pages; the
|
||||
* `signoz/no-dashboard-fetch-outside-root` lint rule enforces the split.
|
||||
*/
|
||||
export function useDashboardFetchRequired(): UseDashboardFetchRequiredResult {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const { dashboard, refetch } = useDashboardFetch(dashboardId);
|
||||
const source = useDashboardStore((s) => s.dashboardQuerySource);
|
||||
const queryKey =
|
||||
source?.queryKey ?? getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
const select = source?.select ?? selectDashboardV2;
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
// Observe only: the root owns the request, so this never fetches.
|
||||
const { data: dashboard } = useQuery<
|
||||
unknown,
|
||||
unknown,
|
||||
DashboardtypesGettableDashboardV2DTO | undefined
|
||||
>({
|
||||
queryKey,
|
||||
select: select as (response: unknown) => DashboardtypesGettableDashboardV2DTO,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const variables = useMemo(
|
||||
() => dashboard?.spec?.variables ?? [],
|
||||
@@ -39,7 +57,7 @@ export function useDashboardFetchRequired(): UseDashboardFetchRequiredResult {
|
||||
throw new Error(
|
||||
'useDashboardFetchRequired was used outside a loaded dashboard subtree — the ' +
|
||||
'dashboard is undefined. Only call it below a root page that gates rendering ' +
|
||||
'on useDashboardFetch (DashboardPage / PanelEditorPage).',
|
||||
'on its fetch and seeds the edit context (DashboardContainer / PanelEditorPage).',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ interface UseDashboardStaleCheck {
|
||||
export function useDashboardStaleCheck(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
refetch: () => void,
|
||||
enabled = true,
|
||||
): UseDashboardStaleCheck {
|
||||
const {
|
||||
id: dashboardId,
|
||||
@@ -34,7 +35,7 @@ export function useDashboardStaleCheck(
|
||||
const [dismissedAt, setDismissedAt] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardId) {
|
||||
if (!dashboardId || !enabled) {
|
||||
return undefined;
|
||||
}
|
||||
const probe = async (): Promise<void> => {
|
||||
@@ -56,7 +57,7 @@ export function useDashboardStaleCheck(
|
||||
document.removeEventListener('visibilitychange', probe);
|
||||
window.removeEventListener('focus', probe);
|
||||
};
|
||||
}, [dashboardId]);
|
||||
}, [dashboardId, enabled]);
|
||||
|
||||
const serverUpdatedAt = server?.updatedAt;
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ export interface UseGetQueryRangeV5Args {
|
||||
keepPreviousData?: boolean;
|
||||
/** Unused-entry TTL; callers drop to 0 under auto-refresh to bound cache growth (V1 parity). */
|
||||
cacheTime?: number;
|
||||
/** Replaces the `query_range` call; the caller keys the cache for it. */
|
||||
fetch?: (
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<QueryRangeV5200>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +54,7 @@ export function useGetQueryRangeV5({
|
||||
enabled,
|
||||
keepPreviousData,
|
||||
cacheTime,
|
||||
fetch,
|
||||
}: UseGetQueryRangeV5Args): UseQueryResult<QueryRangeV5200, Error> {
|
||||
const queryClient = useQueryClient();
|
||||
// An errored key has no data, so it is always stale; keep it enabled so
|
||||
@@ -57,7 +63,7 @@ export function useGetQueryRangeV5({
|
||||
|
||||
return useQuery<QueryRangeV5200, Error>({
|
||||
queryKey,
|
||||
queryFn: ({ signal }) => queryRangeV5(requestPayload, signal),
|
||||
queryFn: ({ signal }) => (fetch ?? queryRangeV5)(requestPayload, signal),
|
||||
enabled: enabled || hasErrored,
|
||||
retry: retryUnlessClientError,
|
||||
keepPreviousData,
|
||||
|
||||
@@ -39,6 +39,7 @@ export function useOptimisticPatch(
|
||||
const storeDashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const dashboardId = dashboardIdOverride ?? storeDashboardId;
|
||||
const { t } = useTranslation('dashboard');
|
||||
const readOnly = useDashboardStore((s) => s.readOnly);
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
|
||||
@@ -78,6 +79,10 @@ export function useOptimisticPatch(
|
||||
const { mutateAsync } = mutation;
|
||||
const patchAsync = useCallback(
|
||||
(ops: DashboardtypesJSONPatchOperationDTO[]): Promise<unknown> => {
|
||||
// The UI renders no edit affordance on a read-only mount, so reaching here is a bug.
|
||||
if (readOnly) {
|
||||
return Promise.reject(new Error(t('dashboard_read_only_here')));
|
||||
}
|
||||
// Defense-in-depth against a lock, read straight from the cache this hook
|
||||
// already owns. Permission is enforced by the UI and, definitively, by the
|
||||
// backend — checking it here would drag authz into every mutation.
|
||||
@@ -87,7 +92,7 @@ export function useOptimisticPatch(
|
||||
}
|
||||
return mutateAsync(ops);
|
||||
},
|
||||
[queryClient, queryKey, mutateAsync],
|
||||
[readOnly, queryClient, queryKey, mutateAsync, t],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
@@ -126,6 +130,7 @@ export function usePanelQuery({
|
||||
// use it (V1 parity). Names come from the fetch context (all variables, even
|
||||
// unresolved ones); null before the variable bar initializes it.
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const queryRunner = useDashboardStore((s) => s.queryRunner);
|
||||
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
|
||||
const fetchContext = useDashboardStore((s) => s.variableFetchContext);
|
||||
|
||||
@@ -233,6 +238,7 @@ export function usePanelQuery({
|
||||
// Only the variables this panel references re-key the cache, so an unrelated
|
||||
// variable change doesn't refetch it.
|
||||
scopedVariables,
|
||||
queryRunner?.key ?? null,
|
||||
],
|
||||
[
|
||||
panelId,
|
||||
@@ -249,10 +255,24 @@ export function usePanelQuery({
|
||||
offset,
|
||||
pageSize,
|
||||
scopedVariables,
|
||||
queryRunner?.key,
|
||||
],
|
||||
);
|
||||
|
||||
const fetch = useMemo(
|
||||
() =>
|
||||
queryRunner
|
||||
? (
|
||||
payload: Querybuildertypesv5QueryRangeRequestDTO,
|
||||
signal?: AbortSignal,
|
||||
): Promise<QueryRangeV5200> =>
|
||||
queryRunner.run({ panelId, requestPayload: payload, signal })
|
||||
: undefined,
|
||||
[queryRunner, panelId],
|
||||
);
|
||||
|
||||
const response = useGetQueryRangeV5({
|
||||
fetch,
|
||||
requestPayload,
|
||||
queryKey,
|
||||
enabled: enabled && runnable && !isWaitingOnVariable,
|
||||
|
||||
@@ -9,6 +9,12 @@ import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
|
||||
import { useResolvedVariables } from './hooks/useResolvedVariables';
|
||||
import { useSyncVariablesForSuggestions } from './hooks/useSyncVariablesForSuggestions';
|
||||
import { useDashboardStore } from './store/useDashboardStore';
|
||||
import type {
|
||||
DashboardQuerySource,
|
||||
QueryRunner,
|
||||
} from './store/slices/editContextSlice';
|
||||
import type { DashboardControlsInput } from './controls/types';
|
||||
import { useDashboardControlGroup } from './controls/useDashboardControlGroup';
|
||||
import styles from './DashboardContainer.module.scss';
|
||||
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
|
||||
import LockedIndicator from './components/LockedIndicator/LockedIndicator';
|
||||
@@ -19,20 +25,23 @@ import { resolveDashboardImage } from 'pages/DashboardPage/DashboardContainer/da
|
||||
interface DashboardContainerProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
refetch: () => void;
|
||||
/**
|
||||
* @deprecated
|
||||
* `canEditDashboardOverride` is a temporary solution to allow the dashboard to be view only.
|
||||
* This is only used for LLM Observability.
|
||||
* It will be removed in the future.
|
||||
* TODO: @Ashwin / @Abhi — remove when the final solution is implemented.
|
||||
*/
|
||||
canEditDashboardOverride?: boolean;
|
||||
/** Where the host cached `dashboard`; defaults to the dashboards API entry. */
|
||||
source?: DashboardQuerySource;
|
||||
/** View-only mount: no edit affordance, no write, no server freshness probe. */
|
||||
readOnly?: boolean;
|
||||
/** What renders; omitted controls are shown. Writing controls are off when read-only. */
|
||||
controls?: DashboardControlsInput;
|
||||
/** Runs panel queries; omitted runs them on `query_range`. */
|
||||
queryRunner?: QueryRunner;
|
||||
}
|
||||
|
||||
function DashboardContainer({
|
||||
dashboard,
|
||||
refetch,
|
||||
canEditDashboardOverride,
|
||||
source,
|
||||
readOnly = false,
|
||||
controls,
|
||||
queryRunner,
|
||||
}: DashboardContainerProps): JSX.Element {
|
||||
const spec = dashboard.spec;
|
||||
const image = resolveDashboardImage(dashboard.image);
|
||||
@@ -55,7 +64,10 @@ function DashboardContainer({
|
||||
setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
refetch,
|
||||
canEditDashboardOverride,
|
||||
source,
|
||||
readOnly,
|
||||
controls,
|
||||
queryRunner,
|
||||
});
|
||||
|
||||
// Resolve the variable selection into the V5 query payload and publish it to
|
||||
@@ -66,7 +78,10 @@ function DashboardContainer({
|
||||
// suggests them ($variable) in the panel editor and dashboards-page builder.
|
||||
useSyncVariablesForSuggestions(dashboard);
|
||||
|
||||
const staleCheck = useDashboardStaleCheck(dashboard, refetch);
|
||||
const staleCheck = useDashboardStaleCheck(dashboard, refetch, !readOnly);
|
||||
const showHeader = Object.values(useDashboardControlGroup('header')).some(
|
||||
Boolean,
|
||||
);
|
||||
|
||||
// In full screen show only the sections and panels — the header/toolbar chrome
|
||||
// is hidden for a clean presentation view (exit with Esc).
|
||||
@@ -78,12 +93,12 @@ function DashboardContainer({
|
||||
<AutoRefreshTicker />
|
||||
) : (
|
||||
<>
|
||||
<DashboardPageHeader title={name} image={image} />
|
||||
{showHeader && <DashboardPageHeader title={name} image={image} />}
|
||||
<DashboardPageToolbar dashboard={dashboard} handle={fullScreenHandle} />
|
||||
</>
|
||||
)}
|
||||
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
|
||||
{!!dashboard.locked && <LockedIndicator />}
|
||||
{!!dashboard.locked && !readOnly && <LockedIndicator />}
|
||||
<DashboardChangedDialog
|
||||
open={staleCheck.showPrompt}
|
||||
onReload={staleCheck.reload}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useDashboardStore } from '../../useDashboardStore';
|
||||
import type { DashboardQuerySource } from '../editContextSlice';
|
||||
|
||||
const refetch = (): void => undefined;
|
||||
|
||||
const systemSource: DashboardQuerySource = {
|
||||
queryKey: ['/api/v2/dashboards/system/ai-o11y-overview'],
|
||||
select: (response: { data: DashboardtypesGettableDashboardV2DTO }) =>
|
||||
response.data,
|
||||
};
|
||||
|
||||
function store(): ReturnType<typeof useDashboardStore.getState> {
|
||||
return useDashboardStore.getState();
|
||||
}
|
||||
|
||||
describe('editContextSlice.setEditContext', () => {
|
||||
beforeEach(() => {
|
||||
store().setEditContext({ dashboardId: '', refetch });
|
||||
});
|
||||
|
||||
it('defaults to the dashboards API entry and an editable mount', () => {
|
||||
store().setEditContext({ dashboardId: 'dash-1', refetch });
|
||||
|
||||
expect(store().dashboardQuerySource).toBeNull();
|
||||
expect(store().readOnly).toBe(false);
|
||||
});
|
||||
|
||||
it("stores a host's source and read-only flag", () => {
|
||||
store().setEditContext({
|
||||
dashboardId: 'system',
|
||||
refetch,
|
||||
source: systemSource,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
expect(store().dashboardQuerySource).toBe(systemSource);
|
||||
expect(store().readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('clears read-only when the next mount does not ask for it', () => {
|
||||
store().setEditContext({
|
||||
dashboardId: 'system',
|
||||
refetch,
|
||||
source: systemSource,
|
||||
readOnly: true,
|
||||
});
|
||||
store().setEditContext({ dashboardId: 'dash-1', refetch });
|
||||
|
||||
expect(store().readOnly).toBe(false);
|
||||
expect(store().dashboardQuerySource).toBeNull();
|
||||
});
|
||||
|
||||
// Called during render: a set on every call would re-render subscribers in a loop.
|
||||
it('is a no-op when called again with equal values', () => {
|
||||
store().setEditContext({ dashboardId: 'dash-1', refetch });
|
||||
const listener = jest.fn();
|
||||
const unsubscribe = useDashboardStore.subscribe(listener);
|
||||
|
||||
store().setEditContext({ dashboardId: 'dash-1', refetch });
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('is a no-op for an equal inline controls object', () => {
|
||||
store().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch,
|
||||
controls: { header: false },
|
||||
});
|
||||
const listener = jest.fn();
|
||||
const unsubscribe = useDashboardStore.subscribe(listener);
|
||||
|
||||
store().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch,
|
||||
controls: { header: false },
|
||||
});
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(store().controls.header.share).toBe(false);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('resolves controls against read-only', () => {
|
||||
store().setEditContext({ dashboardId: 'dash-1', refetch, readOnly: true });
|
||||
|
||||
expect(store().controls.panelActions.edit).toBe(false);
|
||||
expect(store().controls.panelActions.view).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a runner with the same key as unchanged, a new key as a change', () => {
|
||||
const run = jest.fn();
|
||||
store().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch,
|
||||
queryRunner: { key: 'public:a', run },
|
||||
});
|
||||
const listener = jest.fn();
|
||||
const unsubscribe = useDashboardStore.subscribe(listener);
|
||||
|
||||
store().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch,
|
||||
queryRunner: { key: 'public:a', run: jest.fn() },
|
||||
});
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
store().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch,
|
||||
queryRunner: { key: 'public:b', run },
|
||||
});
|
||||
expect(store().queryRunner?.key).toBe('public:b');
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,65 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
import type { QueryKey } from 'react-query';
|
||||
import type { StateCreator } from 'zustand';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { resolveControls } from '../../controls/dashboardControls';
|
||||
import type {
|
||||
DashboardControls,
|
||||
DashboardControlsInput,
|
||||
} from '../../controls/types';
|
||||
import type { DashboardStore } from '../useDashboardStore';
|
||||
|
||||
/**
|
||||
* The one piece of page context the subtree can't derive for itself: which
|
||||
* dashboard is open, and how to refetch it. Editability is deliberately absent —
|
||||
* `useDashboardEditContext` derives it from the caches.
|
||||
* The react-query entry a host loaded the dashboard into, and how to read the
|
||||
* dashboard out of it. Must be referentially stable (module-level).
|
||||
*/
|
||||
export interface DashboardQuerySource {
|
||||
queryKey: QueryKey;
|
||||
// `never` accepts any host's response type; the one caller casts at the boundary.
|
||||
select: (response: never) => DashboardtypesGettableDashboardV2DTO | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a panel's query, when not via `POST /api/v5/query_range`. `key` joins
|
||||
* every panel cache key, so results from different runners never mix.
|
||||
*/
|
||||
export interface QueryRunner {
|
||||
key: string;
|
||||
run: (request: {
|
||||
panelId: string;
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO;
|
||||
signal?: AbortSignal;
|
||||
}) => Promise<QueryRangeV5200>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page context the subtree can't derive for itself: which dashboard is open,
|
||||
* where its cached copy lives, how to refetch it, and whether the host forces it
|
||||
* view-only. Editability is deliberately absent — `useDashboardEditContext`
|
||||
* derives it from the caches.
|
||||
*/
|
||||
export interface EditContextSlice {
|
||||
dashboardId: string;
|
||||
/** `null` reads the dashboards API entry for `dashboardId`. */
|
||||
dashboardQuerySource: DashboardQuerySource | null;
|
||||
refetch: () => void;
|
||||
/**
|
||||
* @deprecated Forces a view-only mount regardless of permissions. Used only by
|
||||
* LLM Observability; see SigNoz/pulse-pod#283.
|
||||
*/
|
||||
canEditDashboardOverride?: boolean;
|
||||
readOnly: boolean;
|
||||
/** Resolved: the host's `controls` over the defaults, writing ones off when read-only. */
|
||||
controls: DashboardControls;
|
||||
/** `null` runs panel queries on `query_range`. */
|
||||
queryRunner: QueryRunner | null;
|
||||
setEditContext: (ctx: {
|
||||
dashboardId: string;
|
||||
refetch: () => void;
|
||||
canEditDashboardOverride?: boolean;
|
||||
source?: DashboardQuerySource;
|
||||
readOnly?: boolean;
|
||||
controls?: DashboardControlsInput;
|
||||
queryRunner?: QueryRunner;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
@@ -29,22 +70,37 @@ export const createEditContextSlice: StateCreator<
|
||||
EditContextSlice
|
||||
> = (set, get) => ({
|
||||
dashboardId: '',
|
||||
dashboardQuerySource: null,
|
||||
refetch: (): void => undefined,
|
||||
canEditDashboardOverride: undefined,
|
||||
readOnly: false,
|
||||
controls: resolveControls(undefined, false),
|
||||
queryRunner: null,
|
||||
// Idempotent (no-op when unchanged) so it's safe to call during render.
|
||||
setEditContext: (ctx): void => {
|
||||
const prev = get();
|
||||
const source = ctx.source ?? null;
|
||||
const readOnly = ctx.readOnly ?? false;
|
||||
// Hosts pass `controls` inline, so compare by value, not reference.
|
||||
const controls = resolveControls(ctx.controls, readOnly);
|
||||
const queryRunner = ctx.queryRunner ?? null;
|
||||
if (
|
||||
prev.dashboardId === ctx.dashboardId &&
|
||||
prev.refetch === ctx.refetch &&
|
||||
prev.canEditDashboardOverride === ctx.canEditDashboardOverride
|
||||
prev.readOnly === readOnly &&
|
||||
prev.dashboardQuerySource === source &&
|
||||
isEqual(prev.controls, controls) &&
|
||||
// The key identifies a runner, so an inline one doesn't re-seed on every render.
|
||||
prev.queryRunner?.key === queryRunner?.key
|
||||
) {
|
||||
return;
|
||||
}
|
||||
set({
|
||||
dashboardId: ctx.dashboardId,
|
||||
dashboardQuerySource: source,
|
||||
refetch: ctx.refetch,
|
||||
canEditDashboardOverride: ctx.canEditDashboardOverride,
|
||||
readOnly,
|
||||
controls,
|
||||
queryRunner,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { rest } from 'msw';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
@@ -16,15 +15,12 @@ import {
|
||||
LLM_MODELS,
|
||||
LLM_SERVICES,
|
||||
llmPanelResponse,
|
||||
llmSystemDashboardResponse,
|
||||
} from './__story_mockdata__/llmOverview';
|
||||
|
||||
const PANELS = 'AI overview · panels';
|
||||
|
||||
/**
|
||||
* The dashboard itself is bundled with the app rather than fetched, so the only
|
||||
* thing left to answer is the panels, the variables and the share state its
|
||||
* header reads.
|
||||
*/
|
||||
/** The system dashboard definition, its panels and its variables. */
|
||||
export const llmOverviewMocks = defineStoryMocks({
|
||||
controls: {
|
||||
series: countControl('Breakdown series', {
|
||||
@@ -36,6 +32,10 @@ export const llmOverviewMocks = defineStoryMocks({
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v2/dashboards/system/ai-o11y-overview',
|
||||
response.json(() => llmSystemDashboardResponse()),
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) =>
|
||||
@@ -58,22 +58,6 @@ export const llmOverviewMocks = defineStoryMocks({
|
||||
};
|
||||
}),
|
||||
),
|
||||
// The header reads the share state on every load, so it answers even while
|
||||
// the panels are held in the loading or failed state.
|
||||
rest.get('http://localhost/api/v1/dashboards/:id/public', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(StatusCodes.NOT_FOUND),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'public_dashboard_not_found',
|
||||
message: "the AI observability overview isn't public",
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: () => ({ route: ROUTES.AI_OBSERVABILITY_OVERVIEW }),
|
||||
});
|
||||
|
||||
@@ -11,8 +11,8 @@ type LLMOverviewArgs = PageStoryArgs<typeof llmOverviewMocks>;
|
||||
const pageStory = storyMocks(llmOverviewMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The AI observability overview: cost, token and latency panels over
|
||||
* `query_range`.
|
||||
* The AI observability overview: the read-only system dashboard, with cost, token
|
||||
* and latency panels over `query_range`.
|
||||
*
|
||||
* Route: `/ai-observability/overview`.
|
||||
*/
|
||||
@@ -20,8 +20,7 @@ const meta = {
|
||||
title: 'Pages/AI Observability/Overview',
|
||||
component: LLMObservabilityPage,
|
||||
...pageStory,
|
||||
// The header's public-dashboard lookup is expected to 404, so every story opts out.
|
||||
parameters: { ...pageStory.parameters, allowConsoleErrors: true },
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<LLMOverviewArgs>;
|
||||
|
||||
export default meta;
|
||||
@@ -29,7 +28,7 @@ export default meta;
|
||||
type Story = StoryObj<LLMOverviewArgs>;
|
||||
|
||||
/**
|
||||
* The bundled overview dashboard: spend, tokens, latency and errors across
|
||||
* The system overview dashboard: spend, tokens, latency and errors across
|
||||
* every model the workspace calls, scoped by the model, environment and
|
||||
* service variables above it.
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
DashboardtypesSourceDTO,
|
||||
type DashboardtypesGettableSystemDashboardDTO,
|
||||
type GetSystemDashboard200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
// The backend's own definition, so the story renders what the server serves.
|
||||
import aiOverviewDefinition from '../../../../../../../pkg/modules/dashboard/impldashboard/fs/definitions/ai-o11y-overview.json';
|
||||
import type {
|
||||
MetricRangePayloadV5,
|
||||
QueryRangeRequestV5,
|
||||
@@ -161,3 +168,19 @@ export const llmPanelResponse = (
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
export function llmSystemDashboardResponse(): GetSystemDashboard200 {
|
||||
const { definition } = aiOverviewDefinition;
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
orgId: 'storybook-org',
|
||||
locked: true,
|
||||
source: DashboardtypesSourceDTO.system,
|
||||
name: definition.name,
|
||||
schemaVersion: definition.schemaVersion,
|
||||
tags: [],
|
||||
spec: definition.spec as DashboardtypesGettableSystemDashboardDTO['spec'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
METER_VOLUMES,
|
||||
meterAttributeKeys,
|
||||
meterExplorerQuery,
|
||||
meterSystemDashboardResponse,
|
||||
meterFieldKeys,
|
||||
meterFieldValues,
|
||||
meterMetricSignal,
|
||||
@@ -179,6 +180,10 @@ export const meterMocks = defineStoryMocks({
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v2/dashboards/system/cost-meter-breakdown',
|
||||
response.json(meterSystemDashboardResponse),
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => {
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type {
|
||||
GetQuickFilters200,
|
||||
ListMetrics200,
|
||||
ListSavedViews200,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
type GetQuickFilters200,
|
||||
type GetSystemDashboard200,
|
||||
type ListMetrics200,
|
||||
type ListSavedViews200,
|
||||
type TelemetrytypesTelemetryFieldKeyDTO,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal as MetricsSignal,
|
||||
@@ -22,16 +21,15 @@ import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
DashboardtypesSourceDTO,
|
||||
type DashboardtypesGettableSystemDashboardDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
// The backend's own definition, so the story renders what the server serves.
|
||||
import costMeterDefinition from '../../../../../../pkg/modules/dashboard/impldashboard/fs/definitions/cost-meter-breakdown.json';
|
||||
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import {
|
||||
getLogCountWidgetData,
|
||||
getLogSizeWidgetData,
|
||||
getMetricCountWidgetData,
|
||||
getSpanCountWidgetData,
|
||||
getSpanSizeWidgetData,
|
||||
} from 'container/MeterExplorer/Breakdown/graphs';
|
||||
import type { Widgets } from 'types/api/widgets/widget';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import type { TimeSeries } from 'types/api/v5/queryRange';
|
||||
|
||||
@@ -248,20 +246,66 @@ export const meterTimeWindow = (range: MeterTimeRange): MeterTimeWindow => {
|
||||
return relativeWindow(24 * HOUR_IN_MS, '1d');
|
||||
};
|
||||
|
||||
const EXPLORER_WIDGETS: Record<MeterVolume, () => Widgets> = {
|
||||
'log-count': getLogCountWidgetData,
|
||||
'log-size': getLogSizeWidgetData,
|
||||
'span-count': getSpanCountWidgetData,
|
||||
'span-size': getSpanSizeWidgetData,
|
||||
'metric-datapoints': getMetricCountWidgetData,
|
||||
/** The query the explorer starts on: the metric's hourly increase, summed. */
|
||||
export const meterExplorerQuery = (volume: MeterVolume): Query => {
|
||||
const metricName = VOLUME_METRICS[volume];
|
||||
|
||||
return {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
id: `meter-explorer-${volume}`,
|
||||
builder: {
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: metricName,
|
||||
id: `${metricName}--float64--Sum--true`,
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: METRICS[metricName].unit === 'bytes' ? 'size' : 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.SUM,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The query the explorer starts on, taken from the breakdown widget for the
|
||||
* same metric so the story asks for what the page itself would build.
|
||||
*/
|
||||
export const meterExplorerQuery = (volume: MeterVolume): Query =>
|
||||
EXPLORER_WIDGETS[volume]().query;
|
||||
/** The breakdown tab's system dashboard, as `GET /api/v2/dashboards/system/:name` serves it. */
|
||||
export const meterSystemDashboardResponse = (): GetSystemDashboard200 => {
|
||||
const { definition } = costMeterDefinition;
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
orgId: 'storybook-org',
|
||||
locked: true,
|
||||
source: DashboardtypesSourceDTO.system,
|
||||
name: definition.name,
|
||||
schemaVersion: definition.schemaVersion,
|
||||
tags: [],
|
||||
spec: definition.spec as DashboardtypesGettableSystemDashboardDTO['spec'],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** The metric picker in the query builder lists what the meter reports. */
|
||||
export const meterMetricsListResponse = (
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Mirrors `.refresh-actions` from DateTimeSelectionV2: one bordered, rounded container
|
||||
// holding borderless buttons split by a divider.
|
||||
.refreshActions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
|
||||
.refreshButton {
|
||||
display: flex;
|
||||
border-right: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
:global(.ant-btn) {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
align-items: center;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Check, ChevronDown, RefreshCw } from '@signozhq/icons';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Button, Popover } from 'antd';
|
||||
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import styles from './PublicAutoRefresh.module.scss';
|
||||
|
||||
// Reuse the app-wide auto-refresh popover styling.
|
||||
import 'container/TopNav/AutoRefreshV2/AutoRefreshV2.styles.scss';
|
||||
|
||||
interface PublicAutoRefreshProps {
|
||||
enabled: boolean;
|
||||
/** Selected interval key, e.g. `30s`. */
|
||||
interval: string;
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: string;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onIntervalChange: (key: string) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
// Prop-driven mirror of the app's refresh + auto-refresh cluster (the public viewer owns its
|
||||
// own time window, not Redux global time).
|
||||
function PublicAutoRefresh({
|
||||
enabled,
|
||||
interval,
|
||||
disabled = false,
|
||||
disabledTooltip,
|
||||
onToggle,
|
||||
onIntervalChange,
|
||||
onRefresh,
|
||||
}: PublicAutoRefreshProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.refreshActions}>
|
||||
<div className={styles.refreshButton}>
|
||||
<Button
|
||||
icon={<RefreshCw size={16} />}
|
||||
onClick={onRefresh}
|
||||
title="Refresh"
|
||||
data-testid="public-dashboard-refresh"
|
||||
/>
|
||||
</div>
|
||||
<Popover
|
||||
getPopupContainer={popupContainer}
|
||||
placement="bottomRight"
|
||||
rootClassName="auto-refresh-root"
|
||||
trigger={['click']}
|
||||
content={
|
||||
<div className="auto-refresh-menu">
|
||||
<div className="auto-refresh-checkbox">
|
||||
<Checkbox
|
||||
color="primary"
|
||||
disabledTooltip={disabledTooltip}
|
||||
onChange={(value): void => onToggle(value === true)}
|
||||
value={enabled}
|
||||
disabled={disabled}
|
||||
>
|
||||
Auto Refresh
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Typography.Text disabled={disabled} className="refresh-interval-text">
|
||||
Refresh Interval
|
||||
</Typography.Text>
|
||||
{refreshIntervalOptions
|
||||
.filter((option) => option.label !== 'off')
|
||||
.map((option) => (
|
||||
<Button
|
||||
type="text"
|
||||
className="refresh-interval-btns"
|
||||
key={option.label + option.value}
|
||||
onClick={(): void => onIntervalChange(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
{option.key === interval && enabled && <Check size={14} />}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
title="Set auto refresh"
|
||||
data-testid="public-dashboard-auto-refresh"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicAutoRefresh;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import PublicAutoRefresh from '../PublicAutoRefresh';
|
||||
|
||||
const props = {
|
||||
enabled: false,
|
||||
interval: '30s',
|
||||
onToggle: jest.fn(),
|
||||
onIntervalChange: jest.fn(),
|
||||
onRefresh: jest.fn(),
|
||||
};
|
||||
|
||||
describe('PublicAutoRefresh', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders the refresh and auto-refresh controls', () => {
|
||||
render(<PublicAutoRefresh {...props} />);
|
||||
expect(screen.getByTestId('public-dashboard-refresh')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('public-dashboard-auto-refresh'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRefresh when the refresh button is clicked', async () => {
|
||||
render(<PublicAutoRefresh {...props} />);
|
||||
await userEvent.click(screen.getByTestId('public-dashboard-refresh'));
|
||||
expect(props.onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('changes the interval from the menu', async () => {
|
||||
render(<PublicAutoRefresh {...props} />);
|
||||
await userEvent.click(screen.getByTestId('public-dashboard-auto-refresh'));
|
||||
await userEvent.click(await screen.findByText('5 seconds'));
|
||||
expect(props.onIntervalChange).toHaveBeenCalledWith('5s');
|
||||
});
|
||||
|
||||
it('toggles auto-refresh from the menu', async () => {
|
||||
render(<PublicAutoRefresh {...props} />);
|
||||
await userEvent.click(screen.getByTestId('public-dashboard-auto-refresh'));
|
||||
await userEvent.click(await screen.findByRole('checkbox'));
|
||||
expect(props.onToggle).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -44,27 +44,7 @@
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
padding: 8px 4px 0;
|
||||
font-weight: 600;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import type { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import { layoutsToSections } from 'pages/DashboardPage/DashboardContainer/utils';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useInterval } from 'react-use';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import type { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PublicDashboardSchema } from 'hooks/dashboard/useGetResolvedPublicDashboard';
|
||||
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
|
||||
|
||||
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
|
||||
import PublicAutoRefresh from './PublicAutoRefresh/PublicAutoRefresh';
|
||||
import PublicSectionGrid from './PublicSectionGrid/PublicSectionGrid';
|
||||
import { getStartTimeAndEndTimeFromTimeRange } from './utils';
|
||||
import { usePublicDashboardTime } from '../hooks/usePublicDashboardTime';
|
||||
import { createPublicDashboardHost } from '../publicDashboardHost';
|
||||
import styles from './PublicDashboardView.module.scss';
|
||||
|
||||
interface PublicDashboardViewProps {
|
||||
@@ -25,74 +15,26 @@ interface PublicDashboardViewProps {
|
||||
data: DashboardtypesGettablePublicDashboardDataV2DTO;
|
||||
}
|
||||
|
||||
// Read-only viewer for a v2 (Perses-spec) public dashboard; reuses the V2 panel renderers.
|
||||
// Variables aren't rendered — the public endpoint doesn't substitute them.
|
||||
const noop = (): void => undefined;
|
||||
|
||||
/** A shared link: the SigNoz header, then the dashboard read-only on the public endpoint. */
|
||||
function PublicDashboardView({
|
||||
publicDashboardId,
|
||||
data,
|
||||
}: PublicDashboardViewProps): JSX.Element {
|
||||
const { dashboard, publicDashboard } = data;
|
||||
|
||||
const sections = useMemo(
|
||||
() => layoutsToSections(dashboard?.spec?.layouts, dashboard?.spec?.panels),
|
||||
[dashboard?.spec?.layouts, dashboard?.spec?.panels],
|
||||
const timeRangeEnabled = data.publicDashboard?.timeRangeEnabled ?? false;
|
||||
const host = useMemo(
|
||||
() => createPublicDashboardHost(publicDashboardId, { timeRangeEnabled }),
|
||||
[publicDashboardId, timeRangeEnabled],
|
||||
);
|
||||
|
||||
const [selectedTimeRangeLabel, setSelectedTimeRangeLabel] = useState<string>(
|
||||
publicDashboard?.defaultTimeRange || DEFAULT_TIME_RANGE,
|
||||
const dashboard = useMemo(
|
||||
() => host.select({ schema: PublicDashboardSchema.V2, data }),
|
||||
[host, data],
|
||||
);
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState(() =>
|
||||
getStartTimeAndEndTimeFromTimeRange(
|
||||
publicDashboard?.defaultTimeRange || DEFAULT_TIME_RANGE,
|
||||
),
|
||||
);
|
||||
|
||||
const isTimeRangeEnabled = publicDashboard?.timeRangeEnabled || false;
|
||||
|
||||
const handleTimeChange = (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
): void => {
|
||||
if (dateTimeRange) {
|
||||
setSelectedTimeRange({
|
||||
startTime: Math.floor(dateTimeRange[0] / 1000),
|
||||
endTime: Math.floor(dateTimeRange[1] / 1000),
|
||||
});
|
||||
} else if (interval !== 'custom') {
|
||||
const { maxTime, minTime } = GetMinMax(interval);
|
||||
setSelectedTimeRange({
|
||||
startTime: Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000),
|
||||
endTime: Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000),
|
||||
});
|
||||
}
|
||||
setSelectedTimeRangeLabel(interval as string);
|
||||
};
|
||||
|
||||
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState<boolean>(false);
|
||||
const [autoRefreshInterval, setAutoRefreshInterval] = useState<string>('30s');
|
||||
|
||||
// Auto-refresh applies only to a rolling relative range, not a fixed custom window.
|
||||
const isAutoRefreshPaused = selectedTimeRangeLabel === 'custom';
|
||||
const refreshIntervalMs = useMemo(
|
||||
() =>
|
||||
autoRefreshEnabled
|
||||
? refreshIntervalOptions.find(
|
||||
(option) => option.key === autoRefreshInterval,
|
||||
)?.value || 0
|
||||
: 0,
|
||||
[autoRefreshEnabled, autoRefreshInterval],
|
||||
);
|
||||
|
||||
useInterval(
|
||||
() => handleTimeChange(selectedTimeRangeLabel as Time),
|
||||
isAutoRefreshPaused || refreshIntervalMs === 0 ? null : refreshIntervalMs,
|
||||
);
|
||||
|
||||
const handleRefresh = (): void =>
|
||||
handleTimeChange(selectedTimeRangeLabel as Time);
|
||||
|
||||
const startMs = selectedTimeRange.startTime * 1000;
|
||||
const endMs = selectedTimeRange.endTime * 1000;
|
||||
const isTimeReady = usePublicDashboardTime({
|
||||
defaultTimeRange: data.publicDashboard?.defaultTimeRange,
|
||||
timeRangeEnabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@@ -106,53 +48,20 @@ function PublicDashboardView({
|
||||
{dashboard?.spec?.display?.name}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{isTimeRangeEnabled && (
|
||||
<div className={styles.headerRight}>
|
||||
<PublicAutoRefresh
|
||||
enabled={autoRefreshEnabled}
|
||||
interval={autoRefreshInterval}
|
||||
disabled={isAutoRefreshPaused}
|
||||
disabledTooltip="Auto refresh is off for a custom time range"
|
||||
onToggle={setAutoRefreshEnabled}
|
||||
onIntervalChange={setAutoRefreshInterval}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={false}
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime={publicDashboard?.defaultTimeRange as Time}
|
||||
isModalTimeSelection
|
||||
modalSelectedInterval={selectedTimeRangeLabel as Time}
|
||||
disableUrlSync
|
||||
showRecentlyUsed={false}
|
||||
modalInitialStartTime={startMs}
|
||||
modalInitialEndTime={endMs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{sections.map((section) => (
|
||||
<section key={section.id} className={styles.section}>
|
||||
{section.title && (
|
||||
<Typography.Text className={styles.sectionTitle}>
|
||||
{section.title}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<PublicSectionGrid
|
||||
items={section.items}
|
||||
publicDashboardId={publicDashboardId}
|
||||
startMs={startMs}
|
||||
endMs={endMs}
|
||||
isVisible
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{isTimeReady && dashboard && (
|
||||
<div className={styles.content}>
|
||||
<DashboardContainer
|
||||
dashboard={dashboard}
|
||||
// A public link is a fixed snapshot of the shared spec.
|
||||
refetch={noop}
|
||||
source={host.source}
|
||||
readOnly={host.readOnly}
|
||||
controls={host.controls}
|
||||
queryRunner={host.queryRunner}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--text-panel-surface, var(--l2-background));
|
||||
border: 1px solid var(--text-panel-border, var(--l2-border));
|
||||
border-radius: 4px;
|
||||
color: var(--text-panel-ink, inherit);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { noop } from 'lodash-es';
|
||||
import PanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
|
||||
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { useTextBackground } from 'pages/DashboardPage/DashboardContainer/Panels/hooks/useTextBackground';
|
||||
import { isPanelHeaderHidden } from 'pages/DashboardPage/DashboardContainer/Panels/utils/isPanelHeaderHidden';
|
||||
|
||||
import { usePublicPanelQuery } from '../hooks/usePublicPanelQuery';
|
||||
import styles from './PublicPanel.module.scss';
|
||||
|
||||
interface PublicPanelProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Panel key in `spec.panels` — addresses the panel on the public endpoint. */
|
||||
panelKey: string;
|
||||
publicDashboardId: string;
|
||||
/** Epoch milliseconds. */
|
||||
startMs: number;
|
||||
/** Epoch milliseconds. */
|
||||
endMs: number;
|
||||
/** True once the panel is on screen — gates the fetch. */
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const PUBLIC_DASHBOARD_PREFERENCE: DashboardPreference = {
|
||||
syncMode: DashboardCursorSync.None,
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only v2 public panel. Forks on the kind's mode before any query
|
||||
* machinery exists. A static body is authored content meant to be read, so it
|
||||
* is not redacted; public dashboards carry no variable runtime, so variable
|
||||
* tokens render literally.
|
||||
*/
|
||||
function PublicPanel(props: PublicPanelProps): JSX.Element {
|
||||
const { panel, panelKey } = props;
|
||||
const panelDefinition = getPanelDefinition(panel.spec.plugin.kind);
|
||||
const background = useTextBackground(panel.spec);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
style={background.style}
|
||||
data-panel-root={panelKey}
|
||||
>
|
||||
{panelDefinition.mode === 'static' ? (
|
||||
<>
|
||||
{!isPanelHeaderHidden(panel.spec) && (
|
||||
<PanelHeader mode="static" panelId={panelKey} panel={panel} hideActions />
|
||||
)}
|
||||
<StaticPanelBody
|
||||
Renderer={panelDefinition.Renderer}
|
||||
panel={panel}
|
||||
panelId={panelKey}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<QueryPublicPanelContent {...props} panelDefinition={panelDefinition} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface QueryPublicPanelContentProps extends PublicPanelProps {
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
}
|
||||
|
||||
// Reuses the V2 header/body renderers with interactions disabled.
|
||||
function QueryPublicPanelContent({
|
||||
panel,
|
||||
panelKey,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
endMs,
|
||||
isVisible,
|
||||
panelDefinition,
|
||||
}: QueryPublicPanelContentProps): JSX.Element {
|
||||
const { data, isFetching, isPreviousData, error, refetch } =
|
||||
usePublicPanelQuery({
|
||||
panel,
|
||||
queryCapabilities: panelDefinition.queryCapabilities,
|
||||
panelKey,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
endMs,
|
||||
enabled: isVisible !== false,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelHeader
|
||||
mode="query"
|
||||
panelId={panelKey}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
hideActions
|
||||
/>
|
||||
<PanelBody
|
||||
Renderer={panelDefinition.Renderer}
|
||||
panel={panel}
|
||||
panelId={panelKey}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={noop}
|
||||
dashboardPreference={PUBLIC_DASHBOARD_PREFERENCE}
|
||||
enableDrillDown={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicPanel;
|
||||
@@ -1,172 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
|
||||
import { usePublicPanelQuery } from '../../hooks/usePublicPanelQuery';
|
||||
import PublicPanel from '../PublicPanel';
|
||||
|
||||
jest.mock('../../hooks/usePublicPanelQuery', () => ({
|
||||
usePublicPanelQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
// Real registry by default; individual tests override to a static definition.
|
||||
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => {
|
||||
const actual = jest.requireActual(
|
||||
'pages/DashboardPage/DashboardContainer/Panels/registry',
|
||||
);
|
||||
return { ...actual, getPanelDefinition: jest.fn(actual.getPanelDefinition) };
|
||||
});
|
||||
|
||||
// Stub the reused V2 renderers so the test targets PublicPanel's own wiring, not uPlot/timezone.
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({ hideActions }: { hideActions?: boolean }): JSX.Element => (
|
||||
<div data-testid="panel-header" data-hide-actions={String(!!hideActions)} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
enableDrillDown,
|
||||
panelId,
|
||||
}: {
|
||||
enableDrillDown?: boolean;
|
||||
panelId: string;
|
||||
}): JSX.Element => (
|
||||
<div
|
||||
data-testid="panel-body"
|
||||
data-drilldown={String(!!enableDrillDown)}
|
||||
data-panel-id={panelId}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const mockQuery = usePublicPanelQuery as jest.Mock;
|
||||
|
||||
const queryResult = {
|
||||
data: { response: undefined, requestPayload: undefined, legendMap: {} },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isPreviousData: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
cancelQuery: jest.fn(),
|
||||
pagination: undefined,
|
||||
};
|
||||
|
||||
const timeseriesPanel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'panel-1' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
const commonProps = {
|
||||
panelKey: 'p1',
|
||||
publicDashboardId: 'pub-1',
|
||||
startMs: 1000,
|
||||
endMs: 2000,
|
||||
};
|
||||
|
||||
describe('PublicPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockQuery.mockReset();
|
||||
mockQuery.mockReturnValue(queryResult);
|
||||
});
|
||||
|
||||
it('renders the reused header/body read-only (hideActions, no drill-down)', () => {
|
||||
render(<PublicPanel panel={timeseriesPanel} {...commonProps} />);
|
||||
|
||||
expect(screen.getByTestId('panel-header')).toHaveAttribute(
|
||||
'data-hide-actions',
|
||||
'true',
|
||||
);
|
||||
const body = screen.getByTestId('panel-body');
|
||||
expect(body).toHaveAttribute('data-drilldown', 'false');
|
||||
expect(body).toHaveAttribute('data-panel-id', 'p1');
|
||||
});
|
||||
|
||||
it('fetches by panel key and time', () => {
|
||||
render(<PublicPanel panel={timeseriesPanel} {...commonProps} />);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panelKey: 'p1',
|
||||
publicDashboardId: 'pub-1',
|
||||
startMs: 1000,
|
||||
endMs: 2000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a static kind with no fetch at all', () => {
|
||||
const StaticRenderer = (): JSX.Element => (
|
||||
<div data-testid="fake-static-renderer" />
|
||||
);
|
||||
(getPanelDefinition as jest.Mock).mockReturnValueOnce({
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
displayName: 'Static',
|
||||
sections: [],
|
||||
actions: {},
|
||||
mode: 'static',
|
||||
Renderer: StaticRenderer,
|
||||
EditorPane: StaticRenderer,
|
||||
});
|
||||
|
||||
render(<PublicPanel panel={timeseriesPanel} {...commonProps} />);
|
||||
|
||||
expect(screen.getByTestId('fake-static-renderer')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-header')).toHaveAttribute(
|
||||
'data-hide-actions',
|
||||
'true',
|
||||
);
|
||||
expect(mockQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a static kind without its header when headerOptions hides it', () => {
|
||||
const StaticRenderer = (): JSX.Element => (
|
||||
<div data-testid="fake-static-renderer" />
|
||||
);
|
||||
(getPanelDefinition as jest.Mock).mockReturnValueOnce({
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
displayName: 'Static',
|
||||
sections: [],
|
||||
actions: {},
|
||||
mode: 'static',
|
||||
Renderer: StaticRenderer,
|
||||
EditorPane: StaticRenderer,
|
||||
});
|
||||
const hiddenHeaderPanel = {
|
||||
...timeseriesPanel,
|
||||
spec: {
|
||||
...timeseriesPanel.spec,
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { headerOptions: { hide: true } },
|
||||
},
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
render(<PublicPanel panel={hiddenHeaderPanel} {...commonProps} />);
|
||||
|
||||
expect(screen.getByTestId('fake-static-renderer')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('panel-header')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gates the fetch when off screen', () => {
|
||||
render(
|
||||
<PublicPanel panel={timeseriesPanel} {...commonProps} isVisible={false} />,
|
||||
);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
83
frontend/src/pages/PublicDashboard/publicDashboardHost.ts
Normal file
83
frontend/src/pages/PublicDashboard/publicDashboardHost.ts
Normal 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,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
16
frontend/src/pages/PublicDashboard/stories/__story_mockdata__/publicDashboard.play.ts
generated
Normal file
16
frontend/src/pages/PublicDashboard/stories/__story_mockdata__/publicDashboard.play.ts
generated
Normal 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);
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
tests/e2e/drivers/meter.ts
generated
15
tests/e2e/drivers/meter.ts
generated
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user