mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-10 00:20:40 +01:00
Compare commits
7 Commits
issue_5015
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c1b4ad72d | ||
|
|
cfb48d8996 | ||
|
|
8818115f1d | ||
|
|
e79b8c0ab7 | ||
|
|
988831949a | ||
|
|
a91cee2b95 | ||
|
|
51036d6cc4 |
@@ -129,7 +129,7 @@ sqlstore:
|
||||
# The timeout for the sqlite database to wait for a lock.
|
||||
busy_timeout: 10s
|
||||
# The default transaction locking behavior. Supported values: deferred, immediate, exclusive.
|
||||
transaction_mode: deferred
|
||||
transaction_mode: immediate
|
||||
|
||||
##################### APIServer #####################
|
||||
apiserver:
|
||||
|
||||
@@ -18168,6 +18168,16 @@ paths:
|
||||
public dashboard. The panel is addressed by its key in spec.panels.
|
||||
operationId: GetPublicDashboardPanelQueryRangeV2
|
||||
parameters:
|
||||
- in: query
|
||||
name: startTime
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: endTime
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
GetPublicDashboardDataV2200,
|
||||
GetPublicDashboardDataV2PathParameters,
|
||||
GetPublicDashboardPanelQueryRangeV2200,
|
||||
GetPublicDashboardPanelQueryRangeV2Params,
|
||||
GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
@@ -1912,20 +1913,25 @@ export const invalidateGetPublicDashboardDataV2 = async (
|
||||
*/
|
||||
export const getPublicDashboardPanelQueryRangeV2 = (
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetPublicDashboardPanelQueryRangeV2200>({
|
||||
url: `/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = ({
|
||||
id,
|
||||
key,
|
||||
}: GetPublicDashboardPanelQueryRangeV2PathParameters) => {
|
||||
return [`/api/v2/public/dashboards/${id}/panels/${key}/query_range`] as const;
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = (
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
) => {
|
||||
return [
|
||||
`/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
@@ -1933,6 +1939,7 @@ export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
@@ -1945,11 +1952,12 @@ export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key });
|
||||
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }, params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
|
||||
> = ({ signal }) => getPublicDashboardPanelQueryRangeV2({ id, key }, signal);
|
||||
> = ({ signal }) =>
|
||||
getPublicDashboardPanelQueryRangeV2({ id, key }, params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
@@ -1978,6 +1986,7 @@ export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
@@ -1988,6 +1997,7 @@ export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPublicDashboardPanelQueryRangeV2QueryOptions(
|
||||
{ id, key },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -2004,10 +2014,16 @@ export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
|
||||
queryClient: QueryClient,
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }) },
|
||||
{
|
||||
queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey(
|
||||
{ id, key },
|
||||
params,
|
||||
),
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
|
||||
@@ -11570,6 +11570,19 @@ export type GetPublicDashboardPanelQueryRangeV2PathParameters = {
|
||||
id: string;
|
||||
key: string;
|
||||
};
|
||||
export type GetPublicDashboardPanelQueryRangeV2Params = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
startTime?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
endTime?: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardPanelQueryRangeV2200 = {
|
||||
data: Querybuildertypesv5QueryRangeResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ export const REACT_QUERY_KEY = {
|
||||
AUTO_REFRESH_QUERY: 'AUTO_REFRESH_QUERY',
|
||||
|
||||
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
|
||||
GET_PUBLIC_DASHBOARD_RESOLVED: 'GET_PUBLIC_DASHBOARD_RESOLVED',
|
||||
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
|
||||
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',
|
||||
GET_ALL_LICENCES: 'GET_ALL_LICENCES',
|
||||
|
||||
@@ -1031,26 +1031,19 @@ function ExplorerOptions({
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
footer={null}
|
||||
onOk={onCancel(false)}
|
||||
onCancel={onCancel(false)}
|
||||
<ExportPanelContainer
|
||||
open={isExport}
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
<ExportPanelContainer
|
||||
query={isOneChartPerQuery ? queryToExport : query}
|
||||
isLoading={isLoading}
|
||||
onExport={(dashboard, isNewDashboard): void => {
|
||||
if (isOneChartPerQuery && queryToExport) {
|
||||
onExport(dashboard, isNewDashboard, queryToExport);
|
||||
} else {
|
||||
onExport(dashboard, isNewDashboard);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
onClose={onCancel(false)}
|
||||
query={isOneChartPerQuery ? queryToExport : query}
|
||||
isLoading={isLoading}
|
||||
onExport={(dashboard, isNewDashboard): void => {
|
||||
if (isOneChartPerQuery && queryToExport) {
|
||||
onExport(dashboard, isNewDashboard, queryToExport);
|
||||
} else {
|
||||
onExport(dashboard, isNewDashboard);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,10 +179,8 @@ describe('ExplorerOptionWrapper', () => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the "New Dashboard" button
|
||||
const newDashboardButton = screen.getByRole('button', {
|
||||
name: /new dashboard/i,
|
||||
});
|
||||
// Click the "New dashboard" button
|
||||
const newDashboardButton = screen.getByTestId('export-panel-new-dashboard');
|
||||
await user.click(newDashboardButton);
|
||||
|
||||
// Wait for the API call to complete and onExport to be called
|
||||
@@ -229,13 +227,12 @@ describe('ExplorerOptionWrapper', () => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for dashboards to load and then click on the dashboard select dropdown
|
||||
// Wait for the dashboard select dropdown to render inside the dialog
|
||||
const modal = screen.getByRole('dialog');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
|
||||
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Get the modal and find the dashboard select dropdown within it
|
||||
const modal = screen.getByRole('dialog');
|
||||
const dashboardSelect = modal.querySelector(
|
||||
'[role="combobox"]',
|
||||
) as HTMLElement;
|
||||
@@ -251,14 +248,13 @@ describe('ExplorerOptionWrapper', () => {
|
||||
const dashboardOption = screen.getByText(mockDashboard1.data.title);
|
||||
await user.click(dashboardOption);
|
||||
|
||||
// Wait for the selection to be made and the Export button to be enabled
|
||||
// Wait for the selection to be made and the export button to be enabled
|
||||
await waitFor(() => {
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
expect(exportButton).not.toBeDisabled();
|
||||
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Click the Export button
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
// Click the export button
|
||||
const exportButton = screen.getByTestId('export-panel-export');
|
||||
await user.click(exportButton);
|
||||
|
||||
// Wait for onExport to be called with the selected dashboard
|
||||
@@ -326,13 +322,12 @@ describe('ExplorerOptionWrapper', () => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for dashboards to load and then click on the dashboard select dropdown
|
||||
// Wait for the dashboard select dropdown to render inside the dialog
|
||||
const modal = screen.getByRole('dialog');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
|
||||
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Get the modal and find the dashboard select dropdown within it
|
||||
const modal = screen.getByRole('dialog');
|
||||
const dashboardSelect = modal.querySelector(
|
||||
'[role="combobox"]',
|
||||
) as HTMLElement;
|
||||
@@ -348,14 +343,13 @@ describe('ExplorerOptionWrapper', () => {
|
||||
const dashboardOption = screen.getByText(mockDashboard.data.title);
|
||||
await user.click(dashboardOption);
|
||||
|
||||
// Wait for the selection to be made and the Export button to be enabled
|
||||
// Wait for the selection to be made and the export button to be enabled
|
||||
await waitFor(() => {
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
expect(exportButton).not.toBeDisabled();
|
||||
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Click the Export button
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
// Click the export button
|
||||
const exportButton = screen.getByTestId('export-panel-export');
|
||||
await user.click(exportButton);
|
||||
|
||||
// Wait for the handleExport function to be called and navigation to occur
|
||||
|
||||
74
frontend/src/container/ExportPanel/ExportDashboardSelect.tsx
Normal file
74
frontend/src/container/ExportPanel/ExportDashboardSelect.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line signoz/no-antd-components
|
||||
import { Select, SelectProps } from 'antd';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
|
||||
import { getSelectOptions } from './utils';
|
||||
import styles from './ExportPanel.module.scss';
|
||||
|
||||
interface ExportDashboardSelectProps {
|
||||
dashboards: Dashboard[];
|
||||
value: string | null;
|
||||
/** The picked dashboard, pinned as an option so its label survives a later search. */
|
||||
selectedDashboard: Dashboard | null;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (dashboardId: string) => void;
|
||||
onSearch: (search: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard picker for the "Add to dashboard" dialog. Server-side search (`filterOption`
|
||||
* off, typing via `onSearch`); the selected dashboard is pinned as an option so its label
|
||||
* survives a narrowing search, and `getPopupContainer` keeps the overlay from clipping the
|
||||
* dropdown.
|
||||
*/
|
||||
function ExportDashboardSelect({
|
||||
dashboards,
|
||||
value,
|
||||
selectedDashboard,
|
||||
loading,
|
||||
disabled,
|
||||
onChange,
|
||||
onSearch,
|
||||
}: ExportDashboardSelectProps): JSX.Element {
|
||||
const options = useMemo<SelectProps['options']>(() => {
|
||||
const base = getSelectOptions(dashboards) ?? [];
|
||||
if (
|
||||
selectedDashboard &&
|
||||
!base.some((option) => option.value === selectedDashboard.id)
|
||||
) {
|
||||
return [
|
||||
{ label: selectedDashboard.data.title, value: selectedDashboard.id },
|
||||
...base,
|
||||
];
|
||||
}
|
||||
return base;
|
||||
}, [dashboards, selectedDashboard]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
className={styles.dashboardSelect}
|
||||
placeholder="Select a dashboard"
|
||||
showSearch
|
||||
filterOption={false}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSearch={onSearch}
|
||||
data-testid="export-dashboard-select"
|
||||
options={options}
|
||||
getPopupContainer={(trigger): HTMLElement =>
|
||||
trigger.parentElement ?? document.body
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ExportDashboardSelect.defaultProps = {
|
||||
loading: false,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default ExportDashboardSelect;
|
||||
42
frontend/src/container/ExportPanel/ExportPanel.module.scss
Normal file
42
frontend/src/container/ExportPanel/ExportPanel.module.scss
Normal file
@@ -0,0 +1,42 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.dashboardSelect {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.newDashboard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,127 +1,155 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from 'react-query';
|
||||
import { Button } from 'antd';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import createDashboard from 'api/v1/dashboards/create';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { useCreateExportDashboard } from 'hooks/dashboard/useCreateExportDashboard';
|
||||
import { useExportDashboards } from 'hooks/dashboard/useExportDashboards';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { ExportPanelProps } from '.';
|
||||
import {
|
||||
DashboardSelect,
|
||||
NewDashboardButton,
|
||||
SelectWrapper,
|
||||
Title,
|
||||
Wrapper,
|
||||
} from './styles';
|
||||
import { filterOptions, getSelectOptions } from './utils';
|
||||
import ExportDashboardSelect from './ExportDashboardSelect';
|
||||
import styles from './ExportPanel.module.scss';
|
||||
|
||||
export interface ExportPanelProps {
|
||||
isLoading?: boolean;
|
||||
onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void;
|
||||
query: Query | null;
|
||||
/** Controlled open state of the dialog. */
|
||||
open: boolean;
|
||||
/** Called when the dialog requests to close (Cancel / overlay / Esc). */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Add to dashboard" dialog: export the panel into an existing dashboard or a newly
|
||||
* created one. Navigation is the caller's job via `onExport` (flag-aware V1/V2 editor).
|
||||
*/
|
||||
function ExportPanelContainer({
|
||||
isLoading,
|
||||
onExport,
|
||||
open,
|
||||
onClose,
|
||||
}: ExportPanelProps): JSX.Element {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
|
||||
const [dashboardId, setDashboardId] = useState<string | null>(null);
|
||||
// Track the object, not just the id, so export survives a search that narrows it out.
|
||||
const [selectedDashboard, setSelectedDashboard] = useState<Dashboard | null>(
|
||||
null,
|
||||
);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
const {
|
||||
data,
|
||||
dashboards,
|
||||
isLoading: isAllDashboardsLoading,
|
||||
refetch,
|
||||
} = useGetAllDashboard();
|
||||
isFetching: isDashboardsFetching,
|
||||
} = useExportDashboards(searchText);
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const { mutate: createNewDashboard, isLoading: createDashboardLoading } =
|
||||
useMutation(createDashboard, {
|
||||
onSuccess: (data) => {
|
||||
if (data.data) {
|
||||
onExport(data?.data, true);
|
||||
}
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorModal(error as APIError);
|
||||
},
|
||||
const { create: createNewDashboard, isLoading: createDashboardLoading } =
|
||||
useCreateExportDashboard({
|
||||
title: t('new_dashboard_title', { ns: 'dashboard' }),
|
||||
onCreated: (dashboard) => onExport(dashboard, true),
|
||||
});
|
||||
|
||||
const options = useMemo(() => getSelectOptions(data?.data || []), [data]);
|
||||
|
||||
const handleExportClick = useCallback((): void => {
|
||||
const currentSelectedDashboard = data?.data?.find(
|
||||
({ id }) => id === dashboardId,
|
||||
);
|
||||
|
||||
onExport(currentSelectedDashboard || null, false);
|
||||
}, [data, dashboardId, onExport]);
|
||||
// Reset on close so each open starts fresh (the dialog stays mounted).
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedDashboard(null);
|
||||
setSearchText('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selectedDashboardId: string): void => {
|
||||
setDashboardId(selectedDashboardId);
|
||||
(dashboardId: string): void => {
|
||||
setSelectedDashboard(
|
||||
dashboards.find(({ id }) => id === dashboardId) ?? null,
|
||||
);
|
||||
},
|
||||
[setDashboardId],
|
||||
[dashboards],
|
||||
);
|
||||
|
||||
const handleNewDashboard = useCallback(async () => {
|
||||
try {
|
||||
await createNewDashboard({
|
||||
title: t('new_dashboard_title', {
|
||||
ns: 'dashboard',
|
||||
}),
|
||||
uploadedGrafana: false,
|
||||
version: ENTITY_VERSION_V5,
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}, [createNewDashboard, t, showErrorModal]);
|
||||
const handleExportClick = useCallback((): void => {
|
||||
onExport(selectedDashboard, false);
|
||||
}, [selectedDashboard, onExport]);
|
||||
|
||||
const isDashboardLoading = isAllDashboardsLoading || createDashboardLoading;
|
||||
|
||||
const isDisabled =
|
||||
isAllDashboardsLoading || !options?.length || !dashboardId || isLoading;
|
||||
const isExportDisabled =
|
||||
isAllDashboardsLoading || !selectedDashboard || isLoading;
|
||||
|
||||
return (
|
||||
<Wrapper direction="vertical">
|
||||
<Title>Export Panel</Title>
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Add to dashboard"
|
||||
testId="export-panel-dialog"
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
testId="export-panel-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
disabled={isExportDisabled}
|
||||
onClick={handleExportClick}
|
||||
testId="export-panel-export"
|
||||
>
|
||||
Add to dashboard
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Select a dashboard
|
||||
</Typography.Text>
|
||||
<ExportDashboardSelect
|
||||
dashboards={dashboards}
|
||||
value={selectedDashboard?.id ?? null}
|
||||
selectedDashboard={selectedDashboard}
|
||||
loading={isDashboardsFetching}
|
||||
disabled={isAllDashboardsLoading || createDashboardLoading}
|
||||
onChange={handleSelect}
|
||||
onSearch={setSearchText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SelectWrapper direction="horizontal">
|
||||
<DashboardSelect
|
||||
placeholder="Select Dashboard"
|
||||
options={options}
|
||||
showSearch
|
||||
loading={isDashboardLoading}
|
||||
disabled={isDashboardLoading}
|
||||
value={dashboardId}
|
||||
onSelect={handleSelect}
|
||||
filterOption={filterOptions}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isLoading}
|
||||
disabled={isDisabled}
|
||||
onClick={handleExportClick}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</SelectWrapper>
|
||||
|
||||
<Typography>
|
||||
Or create dashboard with this panel -
|
||||
<NewDashboardButton
|
||||
disabled={createDashboardLoading}
|
||||
loading={createDashboardLoading}
|
||||
type="link"
|
||||
onClick={handleNewDashboard}
|
||||
>
|
||||
New Dashboard
|
||||
</NewDashboardButton>
|
||||
</Typography>
|
||||
</Wrapper>
|
||||
<div className={styles.newDashboard}>
|
||||
<Typography.Text className={styles.hint}>
|
||||
Or create a new dashboard with this panel
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Plus size={14} />}
|
||||
loading={createDashboardLoading}
|
||||
disabled={createDashboardLoading}
|
||||
onClick={createNewDashboard}
|
||||
testId="export-panel-new-dashboard"
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
ExportPanelContainer.defaultProps = {
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
export default ExportPanelContainer;
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import ExportPanelContainer from './ExportPanelContainer';
|
||||
|
||||
function ExportPanel({
|
||||
isLoading,
|
||||
onExport,
|
||||
query,
|
||||
}: ExportPanelProps): JSX.Element {
|
||||
const [isExport, setIsExport] = useState<boolean>(false);
|
||||
|
||||
const onModalToggle = useCallback((value: boolean) => {
|
||||
setIsExport(value);
|
||||
}, []);
|
||||
|
||||
const onCancel = (value: boolean) => (): void => {
|
||||
onModalToggle(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={null}
|
||||
onOk={onCancel(false)}
|
||||
onCancel={onCancel(false)}
|
||||
open={isExport}
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
<ExportPanelContainer
|
||||
query={query}
|
||||
isLoading={isLoading}
|
||||
onExport={onExport}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ExportPanelProps {
|
||||
isLoading?: boolean;
|
||||
onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void;
|
||||
query: Query | null;
|
||||
}
|
||||
|
||||
ExportPanel.defaultProps = { isLoading: false };
|
||||
|
||||
export default ExportPanel;
|
||||
@@ -1,34 +0,0 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Button, Select, SelectProps, Space } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const DashboardSelect: FunctionComponent<SelectProps> = styled(
|
||||
Select,
|
||||
)<SelectProps>`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SelectWrapper = styled(Space)`
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.ant-space-item:first-child {
|
||||
width: 100%;
|
||||
max-width: 20rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Wrapper = styled(Space)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const NewDashboardButton = styled(Button)`
|
||||
&&& {
|
||||
padding: 0 0.125rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Title = styled(Typography.Text)`
|
||||
font-size: 1rem;
|
||||
`;
|
||||
@@ -6,11 +6,3 @@ export const getSelectOptions = (data: Dashboard[]): SelectProps['options'] =>
|
||||
label: data.title,
|
||||
value: id,
|
||||
}));
|
||||
|
||||
export const filterOptions: SelectProps['filterOption'] = (
|
||||
input,
|
||||
options,
|
||||
): boolean =>
|
||||
(options?.label?.toString() ?? '')
|
||||
?.toLowerCase()
|
||||
.includes(input.toLowerCase());
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from 'container/LogsExplorerViews/explorerUtils';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -52,7 +53,6 @@ import { Filter } from 'types/api/v5/queryRange';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import LogsActionsContainer from './LogsActionsContainer';
|
||||
@@ -76,6 +76,7 @@ function LogsExplorerViewsContainer({
|
||||
handleChangeSelectedView: ChangeViewFunctionType;
|
||||
}): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const [showFrequencyChart, setShowFrequencyChart] = useState(
|
||||
() => getFromLocalstorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART) === 'true',
|
||||
@@ -286,7 +287,7 @@ function LogsExplorerViewsContainer({
|
||||
dashboardName: dashboard?.data?.title,
|
||||
});
|
||||
|
||||
const dashboardEditView = generateExportToDashboardLink({
|
||||
const dashboardEditView = getExportToDashboardLink({
|
||||
query: exportDefaultQuery,
|
||||
panelType: panelTypeParam,
|
||||
dashboardId: dashboard.id,
|
||||
@@ -295,7 +296,12 @@ function LogsExplorerViewsContainer({
|
||||
|
||||
safeNavigate(dashboardEditView);
|
||||
},
|
||||
[safeNavigate, exportDefaultQuery, selectedPanelType],
|
||||
[
|
||||
safeNavigate,
|
||||
exportDefaultQuery,
|
||||
selectedPanelType,
|
||||
getExportToDashboardLink,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapp
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import {
|
||||
@@ -35,7 +36,6 @@ import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
import { explorerViewToPanelType } from 'utils/explorerUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -63,6 +63,7 @@ function Explorer(): JSX.Element {
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
|
||||
@@ -278,7 +279,7 @@ function Explorer(): JSX.Element {
|
||||
};
|
||||
}
|
||||
|
||||
const dashboardEditView = generateExportToDashboardLink({
|
||||
const dashboardEditView = getExportToDashboardLink({
|
||||
query,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
dashboardId: dashboard.id,
|
||||
@@ -287,7 +288,7 @@ function Explorer(): JSX.Element {
|
||||
|
||||
safeNavigate(dashboardEditView);
|
||||
},
|
||||
[exportDefaultQuery, safeNavigate, yAxisUnit],
|
||||
[exportDefaultQuery, safeNavigate, yAxisUnit, getExportToDashboardLink],
|
||||
);
|
||||
|
||||
const splitedQueries = useMemo(
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import createDashboardV1 from 'api/v1/dashboards/create';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
|
||||
import { useCreateExportDashboard } from '../useCreateExportDashboard';
|
||||
|
||||
jest.mock('hooks/useIsDashboardV2');
|
||||
jest.mock('api/v1/dashboards/create');
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
createDashboardV2: jest.fn(),
|
||||
}));
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
useErrorModal: (): { showErrorModal: jest.Mock } => ({
|
||||
showErrorModal: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
|
||||
typeof useIsDashboardV2
|
||||
>;
|
||||
const mockCreateV1 = createDashboardV1 as jest.Mock;
|
||||
const mockCreateV2 = createDashboardV2 as jest.Mock;
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
const TITLE = 'My dashboard';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useCreateExportDashboard', () => {
|
||||
it('creates via the V1 endpoint and returns the created dashboard when the flag is off', async () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(false);
|
||||
const v1Dashboard = {
|
||||
id: 'v1-new',
|
||||
data: { title: TITLE },
|
||||
} as unknown as Dashboard;
|
||||
mockCreateV1.mockResolvedValue({ httpStatusCode: 200, data: v1Dashboard });
|
||||
const onCreated = jest.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useCreateExportDashboard({ title: TITLE, onCreated }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => result.current.create());
|
||||
|
||||
await waitFor(() => expect(onCreated).toHaveBeenCalledWith(v1Dashboard));
|
||||
expect(mockCreateV1).toHaveBeenCalledWith({
|
||||
title: TITLE,
|
||||
uploadedGrafana: false,
|
||||
version: ENTITY_VERSION_V5,
|
||||
});
|
||||
expect(mockCreateV2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates via the V2 Perses endpoint and normalizes the response when the flag is on', async () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(true);
|
||||
mockCreateV2.mockResolvedValue({ data: { id: 'v2-new' } });
|
||||
const onCreated = jest.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useCreateExportDashboard({ title: TITLE, onCreated }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => result.current.create());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onCreated).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'v2-new', data: { title: TITLE } }),
|
||||
),
|
||||
);
|
||||
expect(mockCreateV2).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
schemaVersion: 'v6',
|
||||
spec: expect.objectContaining({ display: { name: TITLE } }),
|
||||
}),
|
||||
);
|
||||
expect(mockCreateV1).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
|
||||
import { useExportDashboards } from '../useExportDashboards';
|
||||
|
||||
jest.mock('hooks/useIsDashboardV2');
|
||||
jest.mock('hooks/dashboard/useGetAllDashboard');
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useListDashboardsForUserV2: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
|
||||
typeof useIsDashboardV2
|
||||
>;
|
||||
const mockUseGetAllDashboard = useGetAllDashboard as jest.Mock;
|
||||
const mockUseListV2 = useListDashboardsForUserV2 as jest.Mock;
|
||||
|
||||
const v1Refetch = jest.fn();
|
||||
const v2Refetch = jest.fn();
|
||||
|
||||
const v1Dashboard = {
|
||||
id: 'v1-1',
|
||||
data: { title: 'V1 Dashboard' },
|
||||
} as unknown as Dashboard;
|
||||
|
||||
const v1Other = {
|
||||
id: 'v1-2',
|
||||
data: { title: 'Other board' },
|
||||
} as unknown as Dashboard;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseGetAllDashboard.mockReturnValue({
|
||||
data: { data: [v1Dashboard, v1Other] },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: v1Refetch,
|
||||
});
|
||||
mockUseListV2.mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
dashboards: [
|
||||
{
|
||||
id: 'v2-1',
|
||||
name: 'V2 Dashboard',
|
||||
spec: { display: { name: 'V2 Dashboard' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: v2Refetch,
|
||||
});
|
||||
});
|
||||
|
||||
describe('useExportDashboards', () => {
|
||||
it('returns the V1 list and disables the V2 query when the flag is off', () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useExportDashboards());
|
||||
|
||||
expect(result.current.dashboards).toStrictEqual([v1Dashboard, v1Other]);
|
||||
expect(mockUseGetAllDashboard).toHaveBeenCalledWith({ enabled: true });
|
||||
expect(mockUseListV2).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
query: { enabled: false, keepPreviousData: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters the V1 list in memory by title (case-insensitive)', () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useExportDashboards('v1 dash'));
|
||||
|
||||
expect(result.current.dashboards).toStrictEqual([v1Dashboard]);
|
||||
});
|
||||
|
||||
it('returns the V2 list normalized to the dashboard shape when the flag is on', () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() => useExportDashboards());
|
||||
|
||||
expect(result.current.dashboards).toStrictEqual([
|
||||
expect.objectContaining({ id: 'v2-1', data: { title: 'V2 Dashboard' } }),
|
||||
]);
|
||||
expect(mockUseGetAllDashboard).toHaveBeenCalledWith({ enabled: false });
|
||||
expect(mockUseListV2).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ query: undefined }),
|
||||
expect.objectContaining({
|
||||
query: { enabled: true, keepPreviousData: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the search term as a name-contains filter clause to the V2 query param', () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(true);
|
||||
|
||||
renderHook(() => useExportDashboards('payments'));
|
||||
|
||||
expect(mockUseListV2).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ query: "name CONTAINS 'payments'" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('refetches the active source', () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useExportDashboards());
|
||||
|
||||
result.current.refetch();
|
||||
expect(v2Refetch).toHaveBeenCalledTimes(1);
|
||||
expect(v1Refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useGetExportToDashboardLink } from '../useGetExportToDashboardLink';
|
||||
|
||||
jest.mock('hooks/useIsDashboardV2');
|
||||
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
|
||||
typeof useIsDashboardV2
|
||||
>;
|
||||
|
||||
const query = { id: 'q1', queryType: 'builder' } as unknown as Query;
|
||||
const params = {
|
||||
dashboardId: 'dash-1',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
query,
|
||||
widgetId: 'w1',
|
||||
};
|
||||
|
||||
describe('useGetExportToDashboardLink', () => {
|
||||
it('builds a V1 new-widget link when the dashboard-v2 flag is off', () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useGetExportToDashboardLink());
|
||||
const link = result.current(params);
|
||||
|
||||
expect(link.startsWith('/dashboard/dash-1/new?')).toBe(true);
|
||||
expect(link).toContain('graphType=');
|
||||
expect(link).toContain('widgetId=w1');
|
||||
expect(link).toContain('compositeQuery=');
|
||||
});
|
||||
|
||||
it('builds a V2 panel/new link (ignoring widgetId) when the flag is on', () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() => useGetExportToDashboardLink());
|
||||
const link = result.current(params);
|
||||
|
||||
expect(link.startsWith('/dashboard/dash-1/panel/new?')).toBe(true);
|
||||
expect(link).toContain('panelKind=signoz%2FTimeSeriesPanel');
|
||||
expect(link).not.toContain('widgetId');
|
||||
expect(link).toContain('compositeQuery=');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { getPublicDashboardDataV2 } from 'api/generated/services/dashboard';
|
||||
import getPublicDashboardDataAPI from 'api/dashboard/public/getPublicDashboardData';
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import {
|
||||
PublicDashboardSchema,
|
||||
useGetResolvedPublicDashboard,
|
||||
} from '../useGetResolvedPublicDashboard';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
getPublicDashboardDataV2: jest.fn(),
|
||||
}));
|
||||
jest.mock('api/dashboard/public/getPublicDashboardData', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockV2 = getPublicDashboardDataV2 as jest.Mock;
|
||||
const mockV1 = getPublicDashboardDataAPI 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 schema mismatch on the v2 endpoint surfaces as an AxiosError with HTTP 501 and this
|
||||
// error code; anything else must NOT trigger the v1 fallback.
|
||||
const schemaMismatchError = {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 501,
|
||||
data: { error: { code: 'dashboard_invalid_data', message: 'not in v6' } },
|
||||
},
|
||||
};
|
||||
|
||||
describe('useGetResolvedPublicDashboard', () => {
|
||||
beforeEach(() => {
|
||||
mockV2.mockReset();
|
||||
mockV1.mockReset();
|
||||
});
|
||||
|
||||
it('returns the v2 model when the v2 endpoint succeeds and never calls v1', async () => {
|
||||
mockV2.mockResolvedValue({ status: 'success', data: { dashboard: {} } });
|
||||
|
||||
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-1'), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data?.schema).toBe(PublicDashboardSchema.V2);
|
||||
expect(mockV2).toHaveBeenCalledWith({ id: 'id-1' });
|
||||
expect(mockV1).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to v1 when the v2 endpoint reports a schema mismatch', async () => {
|
||||
mockV2.mockRejectedValue(schemaMismatchError);
|
||||
mockV1.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { dashboard: { data: { title: 'v1' } } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-2'), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data?.schema).toBe(PublicDashboardSchema.V1);
|
||||
expect(mockV1).toHaveBeenCalledWith({ id: 'id-2' });
|
||||
});
|
||||
|
||||
it('surfaces a non-schema-mismatch v2 error without falling back to v1', async () => {
|
||||
mockV2.mockRejectedValue({
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: { error: { code: 'internal' } } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-3'), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
expect(mockV1).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch without an id', () => {
|
||||
renderHook(() => useGetResolvedPublicDashboard(''), { wrapper });
|
||||
expect(mockV2).not.toHaveBeenCalled();
|
||||
expect(mockV1).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
88
frontend/src/hooks/dashboard/useCreateExportDashboard.ts
Normal file
88
frontend/src/hooks/dashboard/useCreateExportDashboard.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import createDashboardV1 from 'api/v1/dashboards/create';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
interface UseCreateExportDashboardParams {
|
||||
title: string;
|
||||
/** Receives the created dashboard (normalized) for the caller to navigate/export. */
|
||||
onCreated: (dashboard: Dashboard) => void;
|
||||
}
|
||||
|
||||
interface UseCreateExportDashboardResult {
|
||||
create: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag-aware "create a new dashboard to export into". V2 uses the Perses-spec
|
||||
* `createDashboardV2`; V1 uses the legacy create. Both normalize to a `Dashboard`.
|
||||
*/
|
||||
export function useCreateExportDashboard({
|
||||
title,
|
||||
onCreated,
|
||||
}: UseCreateExportDashboardParams): UseCreateExportDashboardResult {
|
||||
const isDashboardV2 = useIsDashboardV2();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const onError = useCallback(
|
||||
(error: unknown): void => showErrorModal(error as APIError),
|
||||
[showErrorModal],
|
||||
);
|
||||
|
||||
const v1 = useMutation(createDashboardV1, {
|
||||
onSuccess: (data) => {
|
||||
if (data.data) {
|
||||
onCreated(data.data);
|
||||
}
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
const v2 = useMutation(
|
||||
() =>
|
||||
createDashboardV2({
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
tags: null,
|
||||
spec: {
|
||||
display: { name: title },
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
},
|
||||
}),
|
||||
{
|
||||
onSuccess: (created) => {
|
||||
onCreated({
|
||||
id: created.data.id,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
locked: false,
|
||||
data: { title },
|
||||
} as Dashboard);
|
||||
},
|
||||
onError,
|
||||
},
|
||||
);
|
||||
|
||||
const create = useCallback((): void => {
|
||||
if (isDashboardV2) {
|
||||
v2.mutate();
|
||||
} else {
|
||||
v1.mutate({ title, uploadedGrafana: false, version: ENTITY_VERSION_V5 });
|
||||
}
|
||||
}, [isDashboardV2, v1, v2, title]);
|
||||
|
||||
return {
|
||||
create,
|
||||
isLoading: isDashboardV2 ? v2.isLoading : v1.isLoading,
|
||||
};
|
||||
}
|
||||
88
frontend/src/hooks/dashboard/useExportDashboards.ts
Normal file
88
frontend/src/hooks/dashboard/useExportDashboards.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
|
||||
import { DashboardtypesListedDashboardForUserV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
|
||||
const V2_LIST_LIMIT = 1000;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
export interface UseExportDashboardsResult {
|
||||
dashboards: Dashboard[];
|
||||
/** First load only — disables the picker until there are options. */
|
||||
isLoading: boolean;
|
||||
/** Any fetch incl. a search refetch — drives the picker spinner. */
|
||||
isFetching: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
function toDashboard(
|
||||
item: DashboardtypesListedDashboardForUserV2DTO,
|
||||
): Dashboard {
|
||||
return {
|
||||
id: item.id,
|
||||
createdAt: item.createdAt ?? '',
|
||||
updatedAt: item.updatedAt ?? '',
|
||||
createdBy: item.createdBy ?? '',
|
||||
updatedBy: item.updatedBy ?? '',
|
||||
locked: item.locked,
|
||||
data: { title: item.spec.display?.name || item.name },
|
||||
};
|
||||
}
|
||||
|
||||
function filterByTitle(dashboards: Dashboard[], search: string): Dashboard[] {
|
||||
const term = search.trim().toLowerCase();
|
||||
if (!term) {
|
||||
return dashboards;
|
||||
}
|
||||
return dashboards.filter((dashboard) =>
|
||||
(dashboard.data.title ?? '').toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
|
||||
// The V2 list `query` is a filter DSL (`key OP value`), not free text — wrap a typed term
|
||||
// as a name-contains clause (single quotes escaped).
|
||||
function toNameQuery(search: string): string | undefined {
|
||||
const term = search.trim();
|
||||
return term ? `name CONTAINS '${term.replace(/'/g, "\\'")}'` : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag-aware, search-filtered source for the "Add to dashboard" picker. V2 searches
|
||||
* server-side (debounced); V1 filters its already-complete list in memory.
|
||||
*/
|
||||
export function useExportDashboards(search = ''): UseExportDashboardsResult {
|
||||
const isDashboardV2 = useIsDashboardV2();
|
||||
const debouncedSearch = useDebounce(search, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
const v1 = useGetAllDashboard({ enabled: !isDashboardV2 });
|
||||
const v2 = useListDashboardsForUserV2(
|
||||
{ limit: V2_LIST_LIMIT, query: toNameQuery(debouncedSearch) },
|
||||
{ query: { enabled: isDashboardV2, keepPreviousData: true } },
|
||||
);
|
||||
|
||||
const dashboards = useMemo<Dashboard[]>(
|
||||
() =>
|
||||
isDashboardV2
|
||||
? (v2.data?.data?.dashboards ?? []).map(toDashboard)
|
||||
: filterByTitle(v1.data?.data ?? [], search),
|
||||
[isDashboardV2, v1.data, v2.data, search],
|
||||
);
|
||||
|
||||
const refetch = useCallback((): void => {
|
||||
if (isDashboardV2) {
|
||||
void v2.refetch();
|
||||
} else {
|
||||
void v1.refetch();
|
||||
}
|
||||
}, [isDashboardV2, v1, v2]);
|
||||
|
||||
return {
|
||||
dashboards,
|
||||
isLoading: isDashboardV2 ? v2.isLoading : v1.isLoading,
|
||||
isFetching: isDashboardV2 ? v2.isFetching : v1.isFetching,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
37
frontend/src/hooks/dashboard/useGetExportToDashboardLink.ts
Normal file
37
frontend/src/hooks/dashboard/useGetExportToDashboardLink.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import { buildExportPanelLink } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
|
||||
interface ExportToDashboardLinkParams {
|
||||
dashboardId: string;
|
||||
panelType: PANEL_TYPES;
|
||||
query: Query;
|
||||
widgetId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag-aware "Add to Dashboard" link builder for the explorers. V2 targets the panel
|
||||
* editor (`/dashboard/:id/panel/new`); V1 uses the legacy new-widget link. Keeps the V1
|
||||
* `generateExportToDashboardLink` signature (V2 ignores `widgetId` — the id is made on save).
|
||||
*/
|
||||
export function useGetExportToDashboardLink(): (
|
||||
params: ExportToDashboardLinkParams,
|
||||
) => string {
|
||||
const isDashboardV2 = useIsDashboardV2();
|
||||
|
||||
return useCallback(
|
||||
({ dashboardId, panelType, query, widgetId }: ExportToDashboardLinkParams) =>
|
||||
isDashboardV2
|
||||
? buildExportPanelLink({ dashboardId, panelType, query })
|
||||
: generateExportToDashboardLink({
|
||||
query,
|
||||
panelType,
|
||||
dashboardId,
|
||||
widgetId,
|
||||
}),
|
||||
[isDashboardV2],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { getPublicDashboardDataV2 } from 'api/generated/services/dashboard';
|
||||
import { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import getPublicDashboardDataAPI from 'api/dashboard/public/getPublicDashboardData';
|
||||
import { AxiosError, isAxiosError } from 'axios';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import { PublicDashboardDataProps } from 'types/api/dashboard/public/get';
|
||||
|
||||
export enum PublicDashboardSchema {
|
||||
V1 = 'v1',
|
||||
V2 = 'v2',
|
||||
}
|
||||
|
||||
export type ResolvedPublicDashboard =
|
||||
| {
|
||||
schema: PublicDashboardSchema.V2;
|
||||
data: DashboardtypesGettablePublicDashboardDataV2DTO;
|
||||
}
|
||||
| { schema: PublicDashboardSchema.V1; data: PublicDashboardDataProps };
|
||||
|
||||
// The v2 endpoint rejects non-v6 rows with this code — our signal that it's a v1 dashboard.
|
||||
const V2_SCHEMA_MISMATCH_CODE = 'dashboard_invalid_data';
|
||||
|
||||
function isV2SchemaMismatch(error: unknown): boolean {
|
||||
if (!isAxiosError(error)) {
|
||||
return false;
|
||||
}
|
||||
const { response } = error as AxiosError<ErrorV2Resp>;
|
||||
return response?.data?.error?.code === V2_SCHEMA_MISMATCH_CODE;
|
||||
}
|
||||
|
||||
// Probe v2 first, fall back to v1 only on a schema mismatch. v1-first is unsafe: it 200s for a
|
||||
// v2 dashboard with queries un-redacted. Other v2 errors re-throw rather than mis-render as v1.
|
||||
async function resolvePublicDashboard(
|
||||
id: string,
|
||||
): Promise<ResolvedPublicDashboard> {
|
||||
try {
|
||||
const v2 = await getPublicDashboardDataV2({ id });
|
||||
return { schema: PublicDashboardSchema.V2, data: v2.data };
|
||||
} catch (error) {
|
||||
if (!isV2SchemaMismatch(error)) {
|
||||
throw error;
|
||||
}
|
||||
const v1 = await getPublicDashboardDataAPI({ id });
|
||||
return { schema: PublicDashboardSchema.V1, data: v1.data };
|
||||
}
|
||||
}
|
||||
|
||||
export const useGetResolvedPublicDashboard = (
|
||||
id: string,
|
||||
): UseQueryResult<ResolvedPublicDashboard, Error> =>
|
||||
useQuery<ResolvedPublicDashboard, Error>({
|
||||
queryFn: () => resolvePublicDashboard(id),
|
||||
queryKey: [REACT_QUERY_KEY.GET_PUBLIC_DASHBOARD_RESOLVED, id],
|
||||
enabled: !!id,
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
buildExportPanelLink,
|
||||
NEW_PANEL_ID,
|
||||
newPanelSearch,
|
||||
parseNewPanelKind,
|
||||
@@ -31,4 +35,56 @@ describe('newPanelRoute', () => {
|
||||
parseNewPanelKind(NEW_PANEL_ID, '?panelKind=NotARealPanel'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
describe('buildExportPanelLink', () => {
|
||||
const query = { id: 'q1', queryType: 'builder' } as unknown as Query;
|
||||
|
||||
const parseLink = (
|
||||
link: string,
|
||||
): { path: string; params: URLSearchParams } => {
|
||||
const [path, search] = link.split('?');
|
||||
return { path, params: new URLSearchParams(search) };
|
||||
};
|
||||
|
||||
it.each([
|
||||
[PANEL_TYPES.TIME_SERIES, 'signoz/TimeSeriesPanel'],
|
||||
[PANEL_TYPES.TABLE, 'signoz/TablePanel'],
|
||||
[PANEL_TYPES.LIST, 'signoz/ListPanel'],
|
||||
])('maps export panel type %s to kind %s', (panelType, expectedKind) => {
|
||||
const link = buildExportPanelLink({
|
||||
dashboardId: 'dash-1',
|
||||
panelType,
|
||||
query,
|
||||
});
|
||||
const { path, params } = parseLink(link);
|
||||
expect(path).toBe('/dashboard/dash-1/panel/new');
|
||||
expect(params.get('panelKind')).toBe(expectedKind);
|
||||
expect(parseNewPanelKind(NEW_PANEL_ID, `?${params.toString()}`)).toBe(
|
||||
expectedKind,
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the query as a decodable compositeQuery param', () => {
|
||||
const link = buildExportPanelLink({
|
||||
dashboardId: 'dash-1',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
query,
|
||||
});
|
||||
const { params } = parseLink(link);
|
||||
expect(JSON.parse(params.get('compositeQuery') as string)).toStrictEqual(
|
||||
query,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to TimeSeries for a panel type with no V2 kind', () => {
|
||||
const link = buildExportPanelLink({
|
||||
dashboardId: 'dash-1',
|
||||
panelType: PANEL_TYPES.EMPTY_WIDGET,
|
||||
query,
|
||||
});
|
||||
expect(parseLink(link).params.get('panelKind')).toBe(
|
||||
'signoz/TimeSeriesPanel',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,7 +81,6 @@ function PanelEditorContainer({
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
panelDefinition,
|
||||
defaultSignal,
|
||||
query,
|
||||
runQuery,
|
||||
isQueryDirty,
|
||||
@@ -167,10 +166,11 @@ function PanelEditorContainer({
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Seed a new List panel's default columns so the Columns control isn't empty.
|
||||
// Seed a new List panel's columns from the query's resolved signal (not the kind's
|
||||
// default logs signal) so a traces-List export gets traces columns, not logs.
|
||||
useSeedNewListColumns({
|
||||
enabled: isNew && isListPanel,
|
||||
signal: defaultSignal,
|
||||
signal: listSignal,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
PANEL_TYPE_TO_PANEL_KIND,
|
||||
type PanelKind,
|
||||
} from '../Panels/types/panelKind';
|
||||
|
||||
// New (unsaved) panels share a fixed id segment, carrying kind + target section
|
||||
// in the query: `/panel/new?panelKind=signoz/ListPanel&layoutIndex=2`. The real
|
||||
// id is generated on save.
|
||||
// New (unsaved) panels use a fixed id segment, carrying kind + target section in the
|
||||
// query (`/panel/new?panelKind=…&layoutIndex=…`); the real id is generated on save.
|
||||
export const NEW_PANEL_ID = 'new';
|
||||
const PANEL_KIND_PARAM = 'panelKind';
|
||||
const LAYOUT_INDEX_PARAM = 'layoutIndex';
|
||||
@@ -37,6 +43,31 @@ export function parseNewPanelKind(
|
||||
return kind && kind in PANEL_KIND_TO_PANEL_TYPE ? (kind as PanelKind) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* New-panel editor link that exports an explorer query into a V2 dashboard: maps panel
|
||||
* type → kind (falls back to TimeSeries) and carries the raw `Query` as `compositeQuery`,
|
||||
* encoded as the V1 link so `useGetCompositeQueryParam` reads it identically. Conversion
|
||||
* happens in the editor; no `layoutIndex`, so exports land in the first section.
|
||||
*/
|
||||
export function buildExportPanelLink({
|
||||
dashboardId,
|
||||
panelType,
|
||||
query,
|
||||
}: {
|
||||
dashboardId: string;
|
||||
panelType: PANEL_TYPES;
|
||||
query: Query;
|
||||
}): string {
|
||||
const kind = PANEL_TYPE_TO_PANEL_KIND[panelType] ?? 'signoz/TimeSeriesPanel';
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId: NEW_PANEL_ID,
|
||||
});
|
||||
return `${path}${newPanelSearch(kind)}&${
|
||||
QueryParams.compositeQuery
|
||||
}=${encodeURIComponent(JSON.stringify(query))}`;
|
||||
}
|
||||
|
||||
/** Target section index for a new panel, or undefined when unset/invalid. */
|
||||
export function parseNewPanelLayoutIndex(search: string): number | undefined {
|
||||
const raw = new URLSearchParams(search).get(LAYOUT_INDEX_PARAM);
|
||||
|
||||
@@ -20,7 +20,10 @@ export interface UseGetQueryRangeV5Args {
|
||||
* The retry callback gets the raw AxiosError this path rejects with (not yet normalized to
|
||||
* APIError — that happens later at the display boundary), so inspect it at the axios level.
|
||||
*/
|
||||
function retryUnlessClientError(failureCount: number, error: Error): boolean {
|
||||
export function retryUnlessClientError(
|
||||
failureCount: number,
|
||||
error: Error,
|
||||
): boolean {
|
||||
if (isAxiosError(error)) {
|
||||
if (error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
generatePath,
|
||||
Redirect,
|
||||
@@ -8,14 +8,12 @@ import {
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import Spinner from 'components/Spinner';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import { useDashboardFetch } from '../DashboardContainer/hooks/useDashboardFetch';
|
||||
import { useDashboardEditGuard } from '../DashboardContainer/hooks/useDashboardEditGuard';
|
||||
import { useResolvedVariables } from '../DashboardContainer/hooks/useResolvedVariables';
|
||||
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
|
||||
import type { PanelEditorHandoffState } from '../DashboardContainer/PanelEditor/panelEditorHandoff';
|
||||
import {
|
||||
@@ -27,6 +25,7 @@ import { createDefaultPanel } from '../DashboardContainer/patchOps';
|
||||
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
|
||||
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/useSeedVariableSelection';
|
||||
import { withVariablesSearch } from '../DashboardContainer/VariablesBar/variablesUrlState';
|
||||
import { buildNewPanelSeed } from './newPanelSeed';
|
||||
import styles from './PanelEditorPage.module.scss';
|
||||
|
||||
/**
|
||||
@@ -73,17 +72,26 @@ function PanelEditorPage(): JSX.Element {
|
||||
// Feed variables to the query builder autocomplete inside the editor.
|
||||
useSyncVariablesForSuggestions(dashboard);
|
||||
|
||||
// An explorer "Add to Dashboard" export rides the query in `compositeQuery` (V1
|
||||
// parity). Captured once at mount: the editor rewrites `compositeQuery` in the URL
|
||||
// as the user edits, and re-reading it would churn the draft (its reset target and
|
||||
// dirty baseline live in the initially-loaded panel).
|
||||
const exportCompositeQuery = useGetCompositeQueryParam();
|
||||
const exportCompositeQueryRef = useRef(exportCompositeQuery);
|
||||
|
||||
// A `panel/new?panelKind=…` route means "create": seed a default panel of that
|
||||
// kind rather than looking one up. Persisted (with a real id) only on save.
|
||||
// kind rather than looking one up (seeded from the exported query when present).
|
||||
// Persisted (with a real id) only on save.
|
||||
const newKind = parseNewPanelKind(panelId, search);
|
||||
const existingPanel = dashboard?.spec.panels[panelId];
|
||||
const panel = useMemo(() => {
|
||||
if (newKind) {
|
||||
return createDefaultPanel(
|
||||
// `kind` may be coerced from `newKind` (e.g. a ClickHouse query into List).
|
||||
const { kind, pluginSpec, queries } = buildNewPanelSeed(
|
||||
newKind,
|
||||
buildPluginSpec(getPanelDefinition(newKind).sections),
|
||||
buildDefaultQueries(newKind),
|
||||
exportCompositeQueryRef.current,
|
||||
);
|
||||
return createDefaultPanel(kind, pluginSpec, queries);
|
||||
}
|
||||
if (!existingPanel) {
|
||||
return undefined;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { isQueryTypeSupported } from '../DashboardContainer/Panels/capabilities';
|
||||
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { SectionKind } from '../DashboardContainer/Panels/types/sections';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
import { toPerses } from '../DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import { buildNewPanelSeed } from './newPanelSeed';
|
||||
|
||||
jest.mock('../DashboardContainer/queryV5/persesQueryAdapters', () => ({
|
||||
toPerses: jest.fn(),
|
||||
}));
|
||||
jest.mock('../DashboardContainer/Panels/utils/buildDefaultQueries', () => ({
|
||||
buildDefaultQueries: jest.fn(),
|
||||
}));
|
||||
jest.mock('../DashboardContainer/Panels/utils/buildPluginSpec', () => ({
|
||||
buildPluginSpec: jest.fn(),
|
||||
}));
|
||||
jest.mock('../DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
}));
|
||||
jest.mock('../DashboardContainer/Panels/capabilities', () => ({
|
||||
isQueryTypeSupported: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockToPerses = toPerses as jest.Mock;
|
||||
const mockBuildDefaultQueries = buildDefaultQueries as jest.Mock;
|
||||
const mockBuildPluginSpec = buildPluginSpec as jest.Mock;
|
||||
const mockGetPanelDefinition = getPanelDefinition as jest.Mock;
|
||||
const mockIsQueryTypeSupported = isQueryTypeSupported as jest.Mock;
|
||||
|
||||
const DEFAULT_QUERIES = [{ kind: 'default' }];
|
||||
const CONVERTED_QUERIES = [{ kind: 'converted' }];
|
||||
const BASE_SPEC = { visualization: { foo: 1 } };
|
||||
|
||||
const withUnit = [{ kind: SectionKind.Formatting, controls: { unit: true } }];
|
||||
const withoutUnit = [
|
||||
{ kind: SectionKind.Formatting, controls: { decimals: true } },
|
||||
];
|
||||
|
||||
const q = (extra: Partial<Query> = {}): Query =>
|
||||
({ queryType: 'builder', ...extra }) as unknown as Query;
|
||||
|
||||
describe('buildNewPanelSeed', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockBuildDefaultQueries.mockReturnValue(DEFAULT_QUERIES);
|
||||
mockBuildPluginSpec.mockReturnValue(BASE_SPEC);
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: withUnit });
|
||||
mockIsQueryTypeSupported.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('uses the kind default seed when there is no exported query', () => {
|
||||
const seed = buildNewPanelSeed('signoz/TimeSeriesPanel', null);
|
||||
|
||||
expect(seed.kind).toBe('signoz/TimeSeriesPanel');
|
||||
expect(seed.queries).toBe(DEFAULT_QUERIES);
|
||||
expect(seed.pluginSpec).toStrictEqual(BASE_SPEC);
|
||||
expect(mockToPerses).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('converts the exported query into perses queries', () => {
|
||||
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
|
||||
|
||||
const seed = buildNewPanelSeed('signoz/TimeSeriesPanel', q());
|
||||
|
||||
expect(seed.kind).toBe('signoz/TimeSeriesPanel');
|
||||
expect(seed.queries).toBe(CONVERTED_QUERIES);
|
||||
expect(seed.pluginSpec).toStrictEqual(BASE_SPEC);
|
||||
});
|
||||
|
||||
it('coerces a builder-only kind to Table for a ClickHouse query', () => {
|
||||
mockIsQueryTypeSupported.mockReturnValue(false);
|
||||
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
|
||||
|
||||
const seed = buildNewPanelSeed(
|
||||
'signoz/ListPanel',
|
||||
q({ queryType: EQueryType.CLICKHOUSE }),
|
||||
);
|
||||
|
||||
expect(seed.kind).toBe('signoz/TablePanel');
|
||||
expect(seed.queries).toBe(CONVERTED_QUERIES);
|
||||
});
|
||||
|
||||
it('coerces a builder-only kind to TimeSeries for a PromQL query', () => {
|
||||
mockIsQueryTypeSupported.mockReturnValue(false);
|
||||
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
|
||||
|
||||
const seed = buildNewPanelSeed(
|
||||
'signoz/ListPanel',
|
||||
q({ queryType: EQueryType.PROM }),
|
||||
);
|
||||
|
||||
expect(seed.kind).toBe('signoz/TimeSeriesPanel');
|
||||
expect(seed.queries).toBe(CONVERTED_QUERIES);
|
||||
});
|
||||
|
||||
it('falls back to the default seed when conversion yields nothing runnable', () => {
|
||||
mockToPerses.mockReturnValue([]);
|
||||
|
||||
const seed = buildNewPanelSeed('signoz/ListPanel', q());
|
||||
|
||||
expect(seed.queries).toBe(DEFAULT_QUERIES);
|
||||
});
|
||||
|
||||
it('preserves the exported unit when the kind supports a unit', () => {
|
||||
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: withUnit });
|
||||
|
||||
const seed = buildNewPanelSeed('signoz/TimeSeriesPanel', q({ unit: 'ms' }));
|
||||
|
||||
expect(seed.pluginSpec).toStrictEqual({
|
||||
...BASE_SPEC,
|
||||
formatting: { unit: 'ms' },
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the unit for a kind without a unit control', () => {
|
||||
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: withoutUnit });
|
||||
|
||||
const seed = buildNewPanelSeed('signoz/TablePanel', q({ unit: 'ms' }));
|
||||
|
||||
expect(seed.pluginSpec).toStrictEqual(BASE_SPEC);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { isQueryTypeSupported } from '../DashboardContainer/Panels/capabilities';
|
||||
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../DashboardContainer/Panels/types/panelKind';
|
||||
import type { PanelKind } from '../DashboardContainer/Panels/types/panelKind';
|
||||
import { SectionKind } from '../DashboardContainer/Panels/types/sections';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
import {
|
||||
buildPluginSpec,
|
||||
type SeededPluginSpec,
|
||||
} from '../DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
import { toPerses } from '../DashboardContainer/queryV5/persesQueryAdapters';
|
||||
|
||||
interface NewPanelSeed {
|
||||
/** Effective kind to create — may differ from the requested one (see below). */
|
||||
kind: PanelKind;
|
||||
queries: DashboardtypesQueryDTO[];
|
||||
pluginSpec: SeededPluginSpec;
|
||||
}
|
||||
|
||||
/** Whether a kind's Formatting section exposes a `unit` control (Table/List don't). */
|
||||
function kindSupportsUnit(kind: PanelKind): boolean {
|
||||
return getPanelDefinition(kind).sections.some(
|
||||
(section) =>
|
||||
section.kind === SectionKind.Formatting &&
|
||||
'controls' in section &&
|
||||
section.controls.unit === true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective kind for an export: the requested kind, or — when it can't hold the query's
|
||||
* language (only List, which is builder-only) — a compatible one so the query isn't
|
||||
* dropped: PromQL → TimeSeries, ClickHouse → Table.
|
||||
*/
|
||||
function resolveSeededPanelKind(
|
||||
requestedKind: PanelKind,
|
||||
compositeQuery: Query | null,
|
||||
): PanelKind {
|
||||
if (
|
||||
!compositeQuery ||
|
||||
isQueryTypeSupported(requestedKind, compositeQuery.queryType)
|
||||
) {
|
||||
return requestedKind;
|
||||
}
|
||||
return compositeQuery.queryType === EQueryType.PROM
|
||||
? 'signoz/TimeSeriesPanel'
|
||||
: 'signoz/TablePanel';
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed for a new panel from the explorer "Add to Dashboard" export: resolve the effective
|
||||
* kind, convert the exported `compositeQuery` to perses queries, and carry a unit-bearing
|
||||
* kind's explorer `unit`. Falls back to the kind's default seed when there's no query or
|
||||
* conversion yields nothing runnable.
|
||||
*/
|
||||
export function buildNewPanelSeed(
|
||||
requestedKind: PanelKind,
|
||||
compositeQuery: Query | null,
|
||||
): NewPanelSeed {
|
||||
const kind = resolveSeededPanelKind(requestedKind, compositeQuery);
|
||||
const pluginSpec = buildPluginSpec(getPanelDefinition(kind).sections);
|
||||
|
||||
if (!compositeQuery) {
|
||||
return { kind, queries: buildDefaultQueries(kind), pluginSpec };
|
||||
}
|
||||
|
||||
const converted = toPerses(compositeQuery, PANEL_KIND_TO_PANEL_TYPE[kind]);
|
||||
const queries = converted.length > 0 ? converted : buildDefaultQueries(kind);
|
||||
|
||||
if (compositeQuery.unit && kindSupportsUnit(kind)) {
|
||||
return {
|
||||
kind,
|
||||
queries,
|
||||
pluginSpec: {
|
||||
...pluginSpec,
|
||||
formatting: { ...pluginSpec.formatting, unit: compositeQuery.unit },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { kind, queries, pluginSpec };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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;
|
||||
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,
|
||||
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">
|
||||
<Checkbox
|
||||
onChange={(value): void => onToggle(value === true)}
|
||||
value={enabled}
|
||||
disabled={disabled}
|
||||
className="auto-refresh-checkbox"
|
||||
>
|
||||
Auto Refresh
|
||||
</Checkbox>
|
||||
<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;
|
||||
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--l0-background);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brandLogo {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.brandName {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
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/DashboardPageV2/DashboardContainer/utils';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useInterval } from 'react-use';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
|
||||
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
|
||||
import PublicAutoRefresh from './PublicAutoRefresh/PublicAutoRefresh';
|
||||
import PublicSectionGrid from './PublicSectionGrid/PublicSectionGrid';
|
||||
import { getStartTimeAndEndTimeFromTimeRange } from './utils';
|
||||
import styles from './PublicDashboardV2.module.scss';
|
||||
|
||||
interface PublicDashboardV2Props {
|
||||
publicDashboardId: string;
|
||||
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.
|
||||
function PublicDashboardV2({
|
||||
publicDashboardId,
|
||||
data,
|
||||
}: PublicDashboardV2Props): JSX.Element {
|
||||
const { dashboard, publicDashboard } = data;
|
||||
|
||||
const sections = useMemo(
|
||||
() => layoutsToSections(dashboard?.spec?.layouts, dashboard?.spec?.panels),
|
||||
[dashboard?.spec?.layouts, dashboard?.spec?.panels],
|
||||
);
|
||||
|
||||
const [selectedTimeRangeLabel, setSelectedTimeRangeLabel] = useState<string>(
|
||||
publicDashboard?.defaultTimeRange || DEFAULT_TIME_RANGE,
|
||||
);
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<div className={styles.brand}>
|
||||
<img src={signozBrandLogoUrl} alt="SigNoz" className={styles.brandLogo} />
|
||||
<Typography className={styles.brandName}>SigNoz</Typography>
|
||||
</div>
|
||||
<Typography.Text className={styles.title}>
|
||||
{dashboard?.spec?.display?.name}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{isTimeRangeEnabled && (
|
||||
<div className={styles.headerRight}>
|
||||
<PublicAutoRefresh
|
||||
enabled={autoRefreshEnabled}
|
||||
interval={autoRefreshInterval}
|
||||
disabled={isAutoRefreshPaused}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardV2;
|
||||
@@ -0,0 +1,10 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
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: reuses the V2 header/body renderers with interactions disabled.
|
||||
function PublicPanel({
|
||||
panel,
|
||||
panelKey,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
endMs,
|
||||
isVisible,
|
||||
}: PublicPanelProps): JSX.Element {
|
||||
const panelDefinition = getPanelDefinition(panel.spec.plugin.kind);
|
||||
|
||||
const { data, isFetching, isPreviousData, error, refetch } =
|
||||
usePublicPanelQuery({
|
||||
panel,
|
||||
panelKey,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
endMs,
|
||||
enabled: isVisible !== false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.panel} data-panel-root={panelKey}>
|
||||
<PanelHeader
|
||||
panelId={panelKey}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
hideActions
|
||||
/>
|
||||
<PanelBody
|
||||
panelDefinition={panelDefinition}
|
||||
panel={panel}
|
||||
panelId={panelKey}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={noop}
|
||||
dashboardPreference={PUBLIC_DASHBOARD_PREFERENCE}
|
||||
enableDrillDown={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicPanel;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePublicPanelQuery } from '../../hooks/usePublicPanelQuery';
|
||||
import PublicPanel from '../PublicPanel';
|
||||
|
||||
jest.mock('../../hooks/usePublicPanelQuery', () => ({
|
||||
usePublicPanelQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
// Stub the reused V2 renderers so the test targets PublicPanel's own wiring, not uPlot/timezone.
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/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/DashboardPageV2/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('gates the fetch when off screen', () => {
|
||||
render(
|
||||
<PublicPanel panel={timeseriesPanel} {...commonProps} isVisible={false} />,
|
||||
);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { GridItem } from 'pages/DashboardPageV2/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;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import PublicDashboardV2 from '../PublicDashboardV2';
|
||||
|
||||
const mockGrid = jest.fn();
|
||||
|
||||
jest.mock('../PublicSectionGrid/PublicSectionGrid', () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown): JSX.Element => {
|
||||
mockGrid(props);
|
||||
return <div data-testid="public-section-grid" />;
|
||||
},
|
||||
}));
|
||||
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: {
|
||||
schemaVersion: 'v6',
|
||||
spec: {
|
||||
display: { name: 'My V2 Dashboard' },
|
||||
layouts: [
|
||||
{
|
||||
kind: 'Grid',
|
||||
spec: {
|
||||
display: { title: 'Section A' },
|
||||
items: [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 6,
|
||||
height: 6,
|
||||
content: { $ref: '#/spec/panels/p1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
panels: {
|
||||
p1: {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'p1' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
variables: [],
|
||||
},
|
||||
},
|
||||
publicDashboard: { timeRangeEnabled, defaultTimeRange: '30m' },
|
||||
} as unknown as DashboardtypesGettablePublicDashboardDataV2DTO;
|
||||
}
|
||||
|
||||
describe('PublicDashboardV2', () => {
|
||||
beforeEach(() => mockGrid.mockReset());
|
||||
|
||||
it('renders the dashboard title, section title, and a grid per section', () => {
|
||||
render(
|
||||
<PublicDashboardV2 publicDashboardId="pub-1" data={buildData(true)} />,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('shows the time controls only when the publisher enabled the time range', () => {
|
||||
const { rerender } = render(
|
||||
<PublicDashboardV2 publicDashboardId="pub-1" data={buildData(true)} />,
|
||||
);
|
||||
expect(screen.getByTestId('datetime-selection')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('auto-refresh')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<PublicDashboardV2 publicDashboardId="pub-1" data={buildData(false)} />,
|
||||
);
|
||||
expect(screen.queryByTestId('datetime-selection')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('auto-refresh')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesPanelDTO } 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,
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
GetPublicDashboardPanelQueryRangeV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { retryUnlessClientError } from 'pages/DashboardPageV2/DashboardContainer/hooks/useGetQueryRangeV5';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
buildQueryRangeRequest,
|
||||
extractLegendMap,
|
||||
hasRunnableQueries,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
|
||||
import type {
|
||||
PanelQueryData,
|
||||
PanelPagination,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQuery, useQueryClient } from 'react-query';
|
||||
|
||||
export interface UsePublicPanelQueryArgs {
|
||||
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;
|
||||
/** 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,
|
||||
panelKey,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
endMs,
|
||||
enabled = true,
|
||||
}: UsePublicPanelQueryArgs): UsePublicPanelQueryResult {
|
||||
const fullKind = panel.spec.plugin.kind;
|
||||
const panelType =
|
||||
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind]) ?? PANEL_TYPES.TIME_SERIES;
|
||||
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,
|
||||
panelType,
|
||||
startMs,
|
||||
endMs,
|
||||
fillGaps,
|
||||
variables: {},
|
||||
}),
|
||||
[queries, panelType, 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useGetPublicDashboardData } from 'hooks/dashboard/useGetPublicDashboardData';
|
||||
import {
|
||||
PublicDashboardSchema,
|
||||
useGetResolvedPublicDashboard,
|
||||
} from 'hooks/dashboard/useGetResolvedPublicDashboard';
|
||||
import { Frown } from '@signozhq/icons';
|
||||
|
||||
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
|
||||
import PublicDashboardContainer from '../../container/PublicDashboardContainer';
|
||||
import PublicDashboardV2 from './PublicDashboardV2/PublicDashboardV2';
|
||||
|
||||
import './PublicDashboard.styles.scss';
|
||||
|
||||
@@ -14,27 +18,28 @@ function PublicDashboardPage(): JSX.Element {
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
const {
|
||||
data: publicDashboardData,
|
||||
isLoading: isLoadingPublicDashboardData,
|
||||
isFetching: isFetchingPublicDashboardData,
|
||||
isError: isErrorPublicDashboardData,
|
||||
} = useGetPublicDashboardData(dashboardId || '');
|
||||
data: resolved,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
} = useGetResolvedPublicDashboard(dashboardId || '');
|
||||
|
||||
const isLoading =
|
||||
isLoadingPublicDashboardData || isFetchingPublicDashboardData;
|
||||
|
||||
const isError = isErrorPublicDashboardData;
|
||||
const isBusy = isLoading || isFetching;
|
||||
|
||||
return (
|
||||
<div className="public-dashboard-page">
|
||||
{publicDashboardData && (
|
||||
{resolved?.schema === PublicDashboardSchema.V2 && (
|
||||
<PublicDashboardV2 publicDashboardId={dashboardId} data={resolved.data} />
|
||||
)}
|
||||
|
||||
{resolved?.schema === PublicDashboardSchema.V1 && (
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={dashboardId}
|
||||
publicDashboardData={publicDashboardData}
|
||||
publicDashboardData={{ httpStatusCode: 200, data: resolved.data }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isError && !isLoading && (
|
||||
{isError && !isBusy && (
|
||||
<div className="public-dashboard-error-container">
|
||||
<div className="perilin-bg" />
|
||||
|
||||
|
||||
@@ -759,19 +759,18 @@ describe('TracesExplorer -', () => {
|
||||
expect(createDashboardBtn).toBeInTheDocument();
|
||||
fireEvent.click(createDashboardBtn);
|
||||
|
||||
await expect(screen.findByText('Export Panel')).resolves.toBeInTheDocument();
|
||||
const createDashboardModal = document.querySelector(
|
||||
'.ant-modal-content',
|
||||
) as HTMLElement;
|
||||
const createDashboardModal = (await screen.findByRole(
|
||||
'dialog',
|
||||
)) as HTMLElement;
|
||||
expect(createDashboardModal).toBeInTheDocument();
|
||||
|
||||
// assert modal content
|
||||
expect(
|
||||
within(createDashboardModal).getByText('Select Dashboard'),
|
||||
within(createDashboardModal).getByTestId('export-panel-new-dashboard'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
within(createDashboardModal).getByText('New Dashboard'),
|
||||
within(createDashboardModal).getByTestId('export-panel-export'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { defaultSelectedColumns } from 'container/TracesExplorer/ListView/config
|
||||
import QuerySection from 'container/TracesExplorer/QuerySection';
|
||||
import TableView from 'container/TracesExplorer/TableView';
|
||||
import TracesView from 'container/TracesExplorer/TracesView';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
@@ -46,7 +47,6 @@ import { Warning } from 'types/api';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
import {
|
||||
explorerViewToPanelType,
|
||||
getExplorerViewFromUrl,
|
||||
@@ -144,6 +144,7 @@ function TracesExplorer(): JSX.Element {
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const handleChangeSelectedView = useCallback(
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
@@ -231,7 +232,7 @@ function TracesExplorer(): JSX.Element {
|
||||
dashboardName: dashboard?.data?.title,
|
||||
});
|
||||
|
||||
const dashboardEditView = generateExportToDashboardLink({
|
||||
const dashboardEditView = getExportToDashboardLink({
|
||||
query,
|
||||
panelType: panelTypeParam,
|
||||
dashboardId: dashboard.id,
|
||||
@@ -240,7 +241,13 @@ function TracesExplorer(): JSX.Element {
|
||||
|
||||
safeNavigate(dashboardEditView);
|
||||
},
|
||||
[exportDefaultQuery, panelType, safeNavigate, options],
|
||||
[
|
||||
exportDefaultQuery,
|
||||
panelType,
|
||||
safeNavigate,
|
||||
options,
|
||||
getExportToDashboardLink,
|
||||
],
|
||||
);
|
||||
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
|
||||
@@ -466,6 +466,7 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
Summary: "Get query range result (v2)",
|
||||
Description: "This endpoint returns query range results for a panel of a v2-shape public dashboard. The panel is addressed by its key in spec.panels.",
|
||||
Request: nil,
|
||||
RequestQuery: new(dashboardtypes.PublicWidgetQueryRangeParams),
|
||||
RequestContentType: "",
|
||||
Response: new(querybuildertypesv5.QueryRangeResponse),
|
||||
ResponseContentType: "application/json",
|
||||
|
||||
@@ -418,7 +418,13 @@ func (handler *handler) GetPublicWidgetQueryRangeV2(rw http.ResponseWriter, r *h
|
||||
return
|
||||
}
|
||||
|
||||
queryRangeResults, err := handler.module.GetPublicWidgetQueryRangeV2(ctx, id, panelKey, r.URL.Query().Get("startTime"), r.URL.Query().Get("endTime"))
|
||||
params := new(dashboardtypes.PublicWidgetQueryRangeParams)
|
||||
if err := binding.Query.BindQuery(r.URL.Query(), params); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
queryRangeResults, err := handler.module.GetPublicWidgetQueryRangeV2(ctx, id, panelKey, params.StartTime, params.EndTime)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -60,7 +60,7 @@ func newConfig() factory.Config {
|
||||
Path: "/var/lib/signoz/signoz.db",
|
||||
Mode: "wal",
|
||||
BusyTimeout: 10000 * time.Millisecond, // increasing the defaults from https://github.com/mattn/go-sqlite3/blob/master/sqlite3.go#L1098 because of transpilation from C to GO
|
||||
TransactionMode: "deferred",
|
||||
TransactionMode: "immediate",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,12 @@ import (
|
||||
// Gettable
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// PublicWidgetQueryRangeParams are the query params of the public panel query-range endpoint.
|
||||
type PublicWidgetQueryRangeParams struct {
|
||||
StartTime string `query:"startTime" required:"false"`
|
||||
EndTime string `query:"endTime" required:"false"`
|
||||
}
|
||||
|
||||
// GettablePublicDashboardDataV2 is the anonymous-facing payload of a v2 dashboard.
|
||||
type GettablePublicDashboardDataV2 struct {
|
||||
Dashboard *GettableDashboardV2 `json:"dashboard"`
|
||||
|
||||
@@ -62,12 +62,6 @@ def pytest_addoption(parser: pytest.Parser):
|
||||
default="delete",
|
||||
help="sqlite mode",
|
||||
)
|
||||
parser.addoption(
|
||||
"--sqlite-transaction-mode",
|
||||
action="store",
|
||||
default="deferred",
|
||||
help="sqlite transaction mode",
|
||||
)
|
||||
parser.addoption(
|
||||
"--postgres-version",
|
||||
action="store",
|
||||
|
||||
2
tests/fixtures/sqlite.py
vendored
2
tests/fixtures/sqlite.py
vendored
@@ -30,7 +30,6 @@ def sqlite(
|
||||
assert result.fetchone()[0] == 1
|
||||
|
||||
mode = pytestconfig.getoption("--sqlite-mode")
|
||||
transaction_mode = pytestconfig.getoption("--sqlite-transaction-mode")
|
||||
return types.TestContainerSQL(
|
||||
container=types.TestContainerDocker(
|
||||
id="",
|
||||
@@ -42,7 +41,6 @@ def sqlite(
|
||||
"SIGNOZ_SQLSTORE_PROVIDER": "sqlite",
|
||||
"SIGNOZ_SQLSTORE_SQLITE_PATH": str(path),
|
||||
"SIGNOZ_SQLSTORE_SQLITE_MODE": mode,
|
||||
"SIGNOZ_SQLSTORE_SQLITE_TRANSACTION__MODE": transaction_mode,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user