mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-18 06:20:34 +01:00
Compare commits
8 Commits
issue-5388
...
feat/panel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddecb0affb | ||
|
|
effe3220fd | ||
|
|
3691cacefa | ||
|
|
868e21ebb0 | ||
|
|
7f75ccbe78 | ||
|
|
d9228de643 | ||
|
|
bf5fc0e013 | ||
|
|
119ae5d23b |
@@ -29,6 +29,18 @@ if (!HTMLElement.prototype.scrollIntoView) {
|
||||
HTMLElement.prototype.scrollIntoView = function (): void {};
|
||||
}
|
||||
|
||||
// jsdom doesn't implement the Pointer Capture API, which Radix UI primitives
|
||||
// (e.g. @signozhq/ui Select) call when opening. Stub them so those components
|
||||
// can be exercised in tests.
|
||||
if (!HTMLElement.prototype.hasPointerCapture) {
|
||||
HTMLElement.prototype.hasPointerCapture = function (): boolean {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
if (!HTMLElement.prototype.releasePointerCapture) {
|
||||
HTMLElement.prototype.releasePointerCapture = function (): void {};
|
||||
}
|
||||
|
||||
if (typeof window.IntersectionObserver === 'undefined') {
|
||||
class IntersectionObserverMock {
|
||||
observe(): void {}
|
||||
|
||||
@@ -122,6 +122,13 @@ export const DashboardWidget = Loadable(
|
||||
import(/* webpackChunkName: "DashboardWidgetPage" */ 'pages/DashboardWidget'),
|
||||
);
|
||||
|
||||
export const DashboardPanelEditorPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "DashboardPanelEditorPage" */ 'pages/DashboardPageV2/PanelEditorPage/PanelEditorPage'
|
||||
),
|
||||
);
|
||||
|
||||
export const EditRulesPage = Loadable(
|
||||
() => import(/* webpackChunkName: "Alerts Edit Page" */ 'pages/EditRules'),
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CreateAlertChannelAlerts,
|
||||
CreateNewAlerts,
|
||||
DashboardPage,
|
||||
DashboardPanelEditorPage,
|
||||
DashboardsListPage,
|
||||
DashboardWidget,
|
||||
EditRulesPage,
|
||||
@@ -196,6 +197,13 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
key: 'DASHBOARD_WIDGET',
|
||||
},
|
||||
{
|
||||
path: ROUTES.DASHBOARD_PANEL_EDITOR,
|
||||
exact: true,
|
||||
component: DashboardPanelEditorPage,
|
||||
isPrivate: true,
|
||||
key: 'DASHBOARD_PANEL_EDITOR',
|
||||
},
|
||||
{
|
||||
path: ROUTES.EDIT_ALERTS,
|
||||
exact: true,
|
||||
|
||||
@@ -24,6 +24,7 @@ const ROUTES = {
|
||||
ALL_DASHBOARD: '/dashboard',
|
||||
DASHBOARD: '/dashboard/:dashboardId',
|
||||
DASHBOARD_WIDGET: '/dashboard/:dashboardId/:widgetId',
|
||||
DASHBOARD_PANEL_EDITOR: '/dashboard/:dashboardId/panel/:panelId',
|
||||
EDIT_ALERTS: '/alerts/edit',
|
||||
LIST_ALL_ALERT: '/alerts',
|
||||
ALERTS_NEW: '/alerts/new',
|
||||
|
||||
@@ -408,6 +408,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
|
||||
const isPublicDashboard = pathname.startsWith('/public/dashboard/');
|
||||
const isAIAssistantPage = pathname.startsWith('/ai-assistant/');
|
||||
// The V2 panel editor is a chromeless full-page route (no side nav / top nav),
|
||||
// like the onboarding and public-dashboard screens.
|
||||
const isPanelEditorV2 = routeKey === 'DASHBOARD_PANEL_EDITOR';
|
||||
|
||||
const renderFullScreen =
|
||||
pathname === ROUTES.GET_STARTED ||
|
||||
@@ -418,7 +421,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT ||
|
||||
pathname === ROUTES.GET_STARTED_AWS_MONITORING ||
|
||||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING ||
|
||||
isPublicDashboard;
|
||||
isPublicDashboard ||
|
||||
isPanelEditorV2;
|
||||
|
||||
const [showTrialExpiryBanner, setShowTrialExpiryBanner] = useState(false);
|
||||
|
||||
|
||||
@@ -7,15 +7,17 @@
|
||||
|
||||
&--legend-right {
|
||||
flex-direction: row;
|
||||
|
||||
.chart-layout__legend-wrapper {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__legend-wrapper {
|
||||
// The inline height is the legend rectangle from calculateChartDimensions;
|
||||
// border-box keeps the padding inside it so the wrapper doesn't grow past
|
||||
// that height and steal space from the chart. overflow:hidden clips to the
|
||||
// rectangle so the virtualized legend scrolls within it.
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
61
frontend/src/hooks/__tests__/useConfirmableAction.test.ts
Normal file
61
frontend/src/hooks/__tests__/useConfirmableAction.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useConfirmableAction } from '../useConfirmableAction';
|
||||
|
||||
describe('useConfirmableAction', () => {
|
||||
it('starts closed and idle', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfirmableAction(jest.fn().mockResolvedValue(undefined)),
|
||||
);
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it('request() opens the prompt without running the action', () => {
|
||||
const action = jest.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useConfirmableAction(action));
|
||||
|
||||
act(() => result.current.request());
|
||||
|
||||
expect(result.current.open).toBe(true);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('confirm() runs the action and closes on success', async () => {
|
||||
const action = jest.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useConfirmableAction(action));
|
||||
|
||||
act(() => result.current.request());
|
||||
await act(async () => {
|
||||
await result.current.confirm();
|
||||
});
|
||||
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the prompt open and resets pending when the action rejects', async () => {
|
||||
const action = jest.fn().mockRejectedValue(new Error('boom'));
|
||||
const { result } = renderHook(() => useConfirmableAction(action));
|
||||
|
||||
act(() => result.current.request());
|
||||
await act(async () => {
|
||||
await expect(result.current.confirm()).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
expect(result.current.open).toBe(true);
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it('cancel() closes the prompt without running the action', () => {
|
||||
const action = jest.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useConfirmableAction(action));
|
||||
|
||||
act(() => result.current.request());
|
||||
act(() => result.current.cancel());
|
||||
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
45
frontend/src/hooks/useConfirmableAction.ts
Normal file
45
frontend/src/hooks/useConfirmableAction.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
export interface ConfirmableAction {
|
||||
/** Whether the confirmation prompt is open. */
|
||||
open: boolean;
|
||||
/** The confirmed action is in flight. */
|
||||
isPending: boolean;
|
||||
/** Open the confirmation prompt (e.g. from a menu item / button). */
|
||||
request: () => void;
|
||||
/** Run the action, tracking the in-flight flag; closes the prompt on success. */
|
||||
confirm: () => Promise<void>;
|
||||
/** Dismiss the prompt without acting. */
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic two-step confirm flow for a (usually destructive) async action.
|
||||
* `request()` opens the prompt, `confirm()` runs `action` while tracking an
|
||||
* in-flight flag and closes on success, `cancel()` dismisses it. Owns only the
|
||||
* confirm state machine — what renders the prompt (dialog, popover) is the
|
||||
* caller's concern, so it stays reusable across confirm surfaces.
|
||||
*/
|
||||
export function useConfirmableAction(
|
||||
action: () => Promise<void>,
|
||||
): ConfirmableAction {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const request = useCallback((): void => setOpen(true), []);
|
||||
const cancel = useCallback((): void => setOpen(false), []);
|
||||
const confirm = useCallback(async (): Promise<void> => {
|
||||
setIsPending(true);
|
||||
try {
|
||||
await action();
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}, [action]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ open, isPending, request, confirm, cancel }),
|
||||
[open, isPending, request, confirm, cancel],
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../../../../styles/scrollbar' as *;
|
||||
|
||||
.legend-search-container {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
@@ -15,6 +17,10 @@
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
// Allow the flex children to shrink below their content height so the
|
||||
// virtualized grid scrolls within the capped legend height instead of
|
||||
// overflowing the wrapper (default min-height:auto would block the shrink).
|
||||
min-height: 0;
|
||||
|
||||
&:has(.legend-item-focused) .legend-item {
|
||||
opacity: 0.3;
|
||||
@@ -33,6 +39,11 @@
|
||||
}
|
||||
|
||||
.legend-virtuoso-container {
|
||||
// flex:1 + min-height:0 pins the scroller to the space left after the
|
||||
// search box (RIGHT legend) and lets it scroll instead of growing to fit
|
||||
// every row — without this the grid overflows a BOTTOM legend's fixed height.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
@@ -67,18 +78,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-background);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,10 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
// Include padding within the width so a full-width row (legend-item-right) fits its
|
||||
// column instead of overflowing by the 16px horizontal padding — there is no global
|
||||
// border-box reset, so the default content-box would make it overflow.
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -87,7 +87,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
lineConfig.fill = `${finalFillColor}40`;
|
||||
} else if (fillMode && fillMode !== FillMode.None) {
|
||||
if (fillMode === FillMode.Solid) {
|
||||
lineConfig.fill = finalFillColor;
|
||||
lineConfig.fill = `${finalFillColor}70`;
|
||||
} else if (fillMode === FillMode.Gradient) {
|
||||
lineConfig.fill = (self: uPlot): CanvasGradient =>
|
||||
generateGradientFill(self, finalFillColor, 'rgba(0, 0, 0, 0)');
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--l1-background-60);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { SolidAlertTriangle, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ConfirmDialog } from '@signozhq/ui/dialog';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './Header.module.scss';
|
||||
|
||||
interface HeaderProps {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Header({
|
||||
isDirty,
|
||||
isSaving,
|
||||
onSave,
|
||||
onClose,
|
||||
}: HeaderProps): JSX.Element {
|
||||
const [isDiscardOpen, setIsDiscardOpen] = useState(false);
|
||||
|
||||
// Closing with unsaved edits prompts for confirmation; a pristine panel closes
|
||||
// straight away.
|
||||
const handleCloseClick = useCallback((): void => {
|
||||
if (isDirty) {
|
||||
setIsDiscardOpen(true);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [isDirty, onClose]);
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
suffix={<X size={14} />}
|
||||
data-testid="panel-editor-v2-close"
|
||||
onClick={handleCloseClick}
|
||||
/>
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text>Configure panel</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="panel-editor-v2-save"
|
||||
disabled={!isDirty || isSaving}
|
||||
loading={isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isDiscardOpen}
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
setIsDiscardOpen(false);
|
||||
}
|
||||
}}
|
||||
title="Discard changes?"
|
||||
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
|
||||
confirmText="Discard"
|
||||
confirmColor="destructive"
|
||||
cancelText="Keep editing"
|
||||
onConfirm={onClose}
|
||||
onCancel={(): void => setIsDiscardOpen(false)}
|
||||
data-testid="panel-editor-v2-discard-modal"
|
||||
>
|
||||
<Typography>Your unsaved edits to this panel will be lost.</Typography>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,28 @@
|
||||
// Full-page editor: fills the route's content area as a header-over-split
|
||||
// column (the editor is its own page now, not a modal overlay).
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.handle {
|
||||
background: var(--l1-border);
|
||||
&:hover {
|
||||
background: var(--l2-border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
@use '../../../../../styles/scrollbar' as *;
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background-color: var(--l1-background);
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tabsContainer {
|
||||
width: 100%;
|
||||
|
||||
:global(.ant-tabs-tab) {
|
||||
background-color: var(--l2-background) !important;
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
:global(.ant-tabs-tab-active) {
|
||||
background-color: var(--l1-background) !important;
|
||||
}
|
||||
:global(.ant-tabs-nav) {
|
||||
&::before {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
.queryTypeTab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.runQueryBtnContainer {
|
||||
padding: 4px 0 8px 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Atom, Terminal } from '@signozhq/icons';
|
||||
import { Tabs } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import PromQLIcon from 'assets/Dashboard/PromQl';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ClickHouseQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse';
|
||||
import PromQLQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL';
|
||||
import { PANEL_TYPE_TO_QUERY_TYPES } from 'container/NewWidget/utils';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
interface PanelEditorQueryBuilderProps {
|
||||
panelType: PANEL_TYPES;
|
||||
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
|
||||
isLoadingQueries: boolean;
|
||||
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
|
||||
onStageRunQuery: () => void;
|
||||
/** Abort the in-flight preview fetch (the button's cancel action). */
|
||||
onCancelQuery: () => void;
|
||||
/** Optional content pinned below the builder (e.g. the List columns editor). */
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query builder for the V2 panel editor's left pane — the queryType tabs
|
||||
* (Query Builder / ClickHouse / PromQL) over the shared `QueryBuilderV2` and the
|
||||
* V1 ClickHouse/PromQL containers, plus the Stage & Run button. All of these
|
||||
* read/write the global `QueryBuilderProvider`; `usePanelEditorQuerySync` owns
|
||||
* seeding the provider from the panel and pushing Stage-&-Run results back into
|
||||
* the editor draft, so this component is purely the builder UI.
|
||||
*/
|
||||
function PanelEditorQueryBuilder({
|
||||
panelType,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
footer,
|
||||
}: PanelEditorQueryBuilderProps): JSX.Element {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const handleQueryCategoryChange = useCallback(
|
||||
(queryType: string): void => {
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
queryType: queryType as EQueryType,
|
||||
});
|
||||
},
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
// ⌘↵ / Ctrl+↵ stages and runs the query while a query-builder field is
|
||||
// focused. The global keyboard-hotkeys provider deliberately ignores keydowns
|
||||
// originating in inputs / the query editor, so this is handled locally. Uses
|
||||
// the capture phase so it fires even for fields that stop the event from
|
||||
// bubbling (e.g. the filter search, CodeMirror) — the container sees the
|
||||
// keydown on the way down to the focused field.
|
||||
const handleKeyDownCapture = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onStageRunQuery();
|
||||
}
|
||||
},
|
||||
[onStageRunQuery],
|
||||
);
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
|
||||
() => ({ stepInterval: { isHidden: false, isDisabled: false } }),
|
||||
[],
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const supportedQueryTypes = PANEL_TYPE_TO_QUERY_TYPES[panelType] || [];
|
||||
|
||||
const queryTypeComponents = {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
icon: <Atom size={14} />,
|
||||
label: 'Query Builder',
|
||||
component: (
|
||||
<div className="query-builder-v2-container">
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
filterConfigs={filterConfigs}
|
||||
showTraceOperator={panelType !== PANEL_TYPES.LIST}
|
||||
version="v3"
|
||||
isListViewPanel={panelType === PANEL_TYPES.LIST}
|
||||
queryComponents={{}}
|
||||
signalSourceChangeEnabled
|
||||
savePreviousQuery
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EQueryType.CLICKHOUSE]: {
|
||||
icon: <Terminal size={14} />,
|
||||
label: 'ClickHouse Query',
|
||||
component: <ClickHouseQueryContainer />,
|
||||
},
|
||||
[EQueryType.PROM]: {
|
||||
icon: (
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
/>
|
||||
),
|
||||
label: 'PromQL',
|
||||
component: <PromQLQueryContainer />,
|
||||
},
|
||||
};
|
||||
|
||||
return supportedQueryTypes.map((queryType) => ({
|
||||
key: queryType,
|
||||
label: (
|
||||
<div className={styles.queryTypeTab}>
|
||||
{queryTypeComponents[queryType].icon}
|
||||
<Typography>{queryTypeComponents[queryType].label}</Typography>
|
||||
</div>
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
}));
|
||||
}, [panelType, filterConfigs, isDarkMode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
data-testid="panel-editor-v2-query-builder"
|
||||
onKeyDownCapture={handleKeyDownCapture}
|
||||
role="presentation"
|
||||
>
|
||||
<div className={styles.scrollArea}>
|
||||
<Tabs
|
||||
type="card"
|
||||
className={styles.tabsContainer}
|
||||
activeKey={currentQuery.queryType}
|
||||
onChange={handleQueryCategoryChange}
|
||||
tabBarExtraContent={
|
||||
<span className={styles.runQueryBtnContainer}>
|
||||
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
|
||||
<RunQueryBtn
|
||||
className="run-query-dashboard-btn"
|
||||
label="Stage & Run Query"
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={onCancelQuery}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
items={items}
|
||||
/>
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelEditorQueryBuilder;
|
||||
@@ -0,0 +1,59 @@
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
background-image: radial-gradient(var(--l2-border) 1px, transparent 0);
|
||||
background-size: 20px 20px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.queryType {
|
||||
display: inline-flex;
|
||||
padding: 4px 8px 4px 6px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--l3-background);
|
||||
backdrop-filter: blur(6px);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.surface {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
background: var(--l2-background);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: var(--l2-forground);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Spin } from 'antd';
|
||||
import { Loader, Spline } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import styles from './PreviewPane.module.scss';
|
||||
|
||||
interface PreviewPaneProps {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Resolved definition for the panel kind; undefined when the kind is unsupported. */
|
||||
panelDef: RenderablePanelDefinition | undefined;
|
||||
data: PanelQueryData;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
/** Drag-to-zoom on a time-axis chart → updates the (URL-synced) time window. */
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live preview for the panel editor. Presentational: the draft panel renders through the
|
||||
* same registry the dashboard grid uses (`panelDef.Renderer`), so the preview is the
|
||||
* production renderer — only `panelMode` differs (DASHBOARD_EDIT). The query result is
|
||||
* owned by the editor root (`usePanelQuery`) and passed in, so the same result is shared
|
||||
* with the config pane.
|
||||
*/
|
||||
function PreviewPane({
|
||||
panelId,
|
||||
panel,
|
||||
panelDef,
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
onDragSelect,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.queryType}>
|
||||
<Spline size={14} />
|
||||
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
|
||||
</div>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.surface}>
|
||||
{/* eslint-disable-next-line no-nested-ternary -- 3-way branch on render state */}
|
||||
{!panelDef ? (
|
||||
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
|
||||
This panel type is not yet supported in V2.
|
||||
</div>
|
||||
) : isLoading && !data.response ? (
|
||||
<div className={styles.state} data-testid="panel-editor-v2-loading">
|
||||
<Spin indicator={<Loader size={14} className="animate-spin" />} />
|
||||
</div>
|
||||
) : (
|
||||
<panelDef.Renderer
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
enableDrillDown={false}
|
||||
onDragSelect={onDragSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreviewPane;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelEditorDraft } from '../usePanelEditorDraft';
|
||||
|
||||
function panel(name = 'CPU', description = 'usage'): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name, description },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('usePanelEditorDraft', () => {
|
||||
it('exposes the panel spec and starts clean', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
expect(result.current.spec).toBe(result.current.draft.spec);
|
||||
expect(result.current.spec.display?.name).toBe('CPU');
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('flags dirty and writes through on a display (title) edit via setSpec', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() =>
|
||||
result.current.setSpec({
|
||||
...result.current.spec,
|
||||
display: { ...result.current.spec.display, name: 'Memory' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(true);
|
||||
expect(result.current.draft.spec?.display?.name).toBe('Memory');
|
||||
});
|
||||
|
||||
it('flags dirty on a plugin-spec (non-display) edit', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() =>
|
||||
result.current.setSpec({
|
||||
...result.current.spec,
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { formatting: { unit: 'bytes' } },
|
||||
},
|
||||
} as typeof result.current.spec),
|
||||
);
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(true);
|
||||
expect(
|
||||
(
|
||||
result.current.draft.spec?.plugin?.spec as {
|
||||
formatting?: { unit?: string };
|
||||
}
|
||||
)?.formatting?.unit,
|
||||
).toBe('bytes');
|
||||
});
|
||||
|
||||
it('does not flag spec-dirty when only spec.queries changes (owned by the builder)', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() =>
|
||||
result.current.setSpec({
|
||||
...result.current.spec,
|
||||
queries: [{ id: 'committed-by-builder' }],
|
||||
} as unknown as typeof result.current.spec),
|
||||
);
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('reset restores the spec and clears dirty after an edit', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() =>
|
||||
result.current.setSpec({
|
||||
...result.current.spec,
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { formatting: { unit: 'ms' } },
|
||||
},
|
||||
} as typeof result.current.spec),
|
||||
);
|
||||
act(() => result.current.reset());
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
expect(result.current.spec.display?.name).toBe('CPU');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getIsQueryModified } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { fromPerses, toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
|
||||
useShareBuilderUrl: jest.fn(),
|
||||
}));
|
||||
jest.mock('container/NewWidget/utils', () => ({
|
||||
getIsQueryModified: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
|
||||
fromPerses: jest.fn(),
|
||||
toPerses: jest.fn(),
|
||||
}));
|
||||
// commitQuery's no-op guard compares queries at the envelope level; with the
|
||||
// adapters mocked, unwrap identity-style so the opaque fixtures stay distinct
|
||||
// (CONVERTED vs SAVED) and the commit decisions are what's under test.
|
||||
jest.mock('../../../queryV5/buildQueryRangeRequest', () => ({
|
||||
toQueryEnvelopes: jest.fn((queries: unknown) => queries),
|
||||
}));
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
const mockUseShareBuilderUrl = useShareBuilderUrl as unknown as jest.Mock;
|
||||
const mockGetIsQueryModified = getIsQueryModified as unknown as jest.Mock;
|
||||
const mockFromPerses = fromPerses as unknown as jest.Mock;
|
||||
const mockToPerses = toPerses as unknown as jest.Mock;
|
||||
|
||||
// Opaque fixtures — the adapters are mocked, so only identity matters here.
|
||||
const SAVED_QUERIES = [{ id: 'saved' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const CONVERTED_QUERIES = [{ id: 'converted' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const SEED_V1 = { id: 'seed', queryType: 'builder' } as unknown as Query;
|
||||
const STAGED_V1 = { id: 'staged', queryType: 'builder' } as unknown as Query;
|
||||
|
||||
function makeDraft(
|
||||
queries = SAVED_QUERIES,
|
||||
kind = 'signoz/TimeSeriesPanel',
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind, spec: {} },
|
||||
queries,
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
function builderState(
|
||||
overrides: Partial<{
|
||||
currentQuery: Query;
|
||||
stagedQuery: Query | null;
|
||||
handleRunQuery: jest.Mock;
|
||||
}> = {},
|
||||
): {
|
||||
currentQuery: Query;
|
||||
stagedQuery: Query | null;
|
||||
handleRunQuery: jest.Mock;
|
||||
} {
|
||||
return {
|
||||
currentQuery: { id: 'current', queryType: 'builder' } as unknown as Query,
|
||||
stagedQuery: STAGED_V1,
|
||||
handleRunQuery: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('usePanelEditorQuerySync', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFromPerses.mockReturnValue(SEED_V1);
|
||||
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
mockUseQueryBuilder.mockReturnValue(builderState());
|
||||
});
|
||||
|
||||
function setup(
|
||||
opts: {
|
||||
draft?: DashboardtypesPanelDTO;
|
||||
setSpec?: jest.Mock;
|
||||
refetch?: jest.Mock;
|
||||
} = {},
|
||||
): {
|
||||
result: {
|
||||
current: {
|
||||
runQuery: () => void;
|
||||
isQueryDirty: boolean;
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
};
|
||||
};
|
||||
setSpec: jest.Mock;
|
||||
refetch: jest.Mock;
|
||||
rerender: () => void;
|
||||
} {
|
||||
const setSpec = opts.setSpec ?? jest.fn();
|
||||
const refetch = opts.refetch ?? jest.fn();
|
||||
const draft = opts.draft ?? makeDraft();
|
||||
const { result, rerender } = renderHook(() =>
|
||||
usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
refetch,
|
||||
}),
|
||||
);
|
||||
return { result, setSpec, refetch, rerender };
|
||||
}
|
||||
|
||||
it('force-resets the builder to the saved queries on mount (discards stale URL)', () => {
|
||||
setup();
|
||||
expect(mockFromPerses).toHaveBeenCalledWith(
|
||||
SAVED_QUERIES,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
expect(mockUseShareBuilderUrl).toHaveBeenCalledWith({
|
||||
defaultValue: SEED_V1,
|
||||
forceReset: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not touch the draft on mount for an unedited panel', () => {
|
||||
const { setSpec, refetch } = setup();
|
||||
// Mount runs the type-change effect once; an unedited query must no-op.
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
expect(refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('compares the live query against the saved query (seed), not the staged query', () => {
|
||||
const currentQuery = { id: 'current', queryType: 'builder' } as Query;
|
||||
mockUseQueryBuilder.mockReturnValue(builderState({ currentQuery }));
|
||||
|
||||
const { result } = setup();
|
||||
result.current.runQuery();
|
||||
|
||||
// Baseline is the saved seed — a stale staged/URL query must not be the
|
||||
// reference, or a real datasource switch would read as "unchanged".
|
||||
expect(mockGetIsQueryModified).toHaveBeenCalledWith(currentQuery, SEED_V1);
|
||||
});
|
||||
|
||||
describe('runQuery', () => {
|
||||
it('stages the query (handleRunQuery)', () => {
|
||||
const handleRunQuery = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(builderState({ handleRunQuery }));
|
||||
const { result } = setup();
|
||||
|
||||
result.current.runQuery();
|
||||
|
||||
expect(handleRunQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('commits a modified query into the draft and does not force a refetch', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result, setSpec, refetch } = setup();
|
||||
|
||||
result.current.runQuery();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
expect(refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces a refetch and leaves the draft alone when the query is unchanged', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result, setSpec, refetch } = setup();
|
||||
|
||||
result.current.runQuery();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
expect(refetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('commits a datasource switch even when the staged query is stale (no revert to saved)', () => {
|
||||
// A stale staged query (e.g. URL-restored after refresh) must not be used
|
||||
// as the baseline; the switch is detected against the saved seed and the
|
||||
// live query is committed so the preview fetches it.
|
||||
mockUseQueryBuilder.mockReturnValue(builderState({ stagedQuery: null }));
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result, setSpec, refetch } = setup();
|
||||
|
||||
result.current.runQuery();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
expect(refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query-type switch', () => {
|
||||
it('commits the active query when the query type changes', () => {
|
||||
const state = builderState({
|
||||
currentQuery: { id: 'a', queryType: 'builder' } as Query,
|
||||
});
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Switch query type → the effect should commit.
|
||||
state.currentQuery = { id: 'b', queryType: 'promql' } as Query;
|
||||
rerender();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not commit when the active query type is unchanged', () => {
|
||||
const state = builderState({
|
||||
currentQuery: { id: 'a', queryType: 'builder' } as Query,
|
||||
});
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Same query type, different object → effect must not re-fire.
|
||||
state.currentQuery = { id: 'b', queryType: 'builder' } as Query;
|
||||
rerender();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('datasource switch', () => {
|
||||
const withSource = (id: string, dataSource: string): Query =>
|
||||
({
|
||||
id,
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ dataSource }] },
|
||||
}) as unknown as Query;
|
||||
|
||||
it('commits the active query when a query datasource changes', () => {
|
||||
const state = builderState({ currentQuery: withSource('a', 'logs') });
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Switch datasource logs → traces → the effect should commit (→ refetch).
|
||||
state.currentQuery = withSource('b', 'traces');
|
||||
rerender();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not commit when the datasource is unchanged', () => {
|
||||
const state = builderState({ currentQuery: withSource('a', 'logs') });
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Same datasource, different object → effect must not re-fire.
|
||||
state.currentQuery = withSource('b', 'logs');
|
||||
rerender();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query dirty + save', () => {
|
||||
it('compares the live query against the builder baseline (first staged query), not the raw seed', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result } = setup();
|
||||
|
||||
// Baseline is the builder's own normalized staged query — immune to the
|
||||
// raw-seed vs builder-normalized serialization drift.
|
||||
expect(mockGetIsQueryModified).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
STAGED_V1,
|
||||
);
|
||||
expect(result.current.isQueryDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('is not query-dirty when the live query matches the baseline', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result } = setup();
|
||||
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('buildSaveSpec bakes the live query in when dirty', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result } = setup();
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
|
||||
...spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('buildSaveSpec returns the spec untouched when the query is unchanged', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result } = setup();
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toBe(spec);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelEditorSave } from '../usePanelEditorSave';
|
||||
|
||||
const mockInvalidateQueries = jest.fn();
|
||||
jest.mock('react-query', () => ({
|
||||
useQueryClient: (): { invalidateQueries: jest.Mock } => ({
|
||||
invalidateQueries: mockInvalidateQueries,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
usePatchDashboardV2: jest.fn(),
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
|
||||
}));
|
||||
|
||||
const mockUsePatch = usePatchDashboardV2 as unknown as jest.Mock;
|
||||
const mockGetQueryKey = getGetDashboardV2QueryKey as unknown as jest.Mock;
|
||||
|
||||
describe('usePanelEditorSave', () => {
|
||||
const mutateAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUsePatch.mockReturnValue({
|
||||
mutateAsync,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits an add patch replacing the whole panel spec and invalidates the dashboard query', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
|
||||
);
|
||||
|
||||
const spec = {
|
||||
display: { name: 'New title', description: 'desc' },
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { formatting: { unit: 'bytes' } },
|
||||
},
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
await result.current.save(spec);
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'dash-1' },
|
||||
data: [
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/panels/panel-9/spec',
|
||||
value: spec,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockGetQueryKey).toHaveBeenCalledWith({ id: 'dash-1' });
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith([
|
||||
'/api/v2/dashboards/dash-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('surfaces the mutation loading state as isSaving', () => {
|
||||
mockUsePatch.mockReturnValue({
|
||||
mutateAsync,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
|
||||
);
|
||||
|
||||
expect(result.current.isSaving).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import type { PanelEditorDraftApi } from '../types';
|
||||
|
||||
/**
|
||||
* Owns the editable draft of a single panel. Seeded once from the loaded panel
|
||||
* (`useState` initializer), then mutated locally until the user saves. Keeping
|
||||
* the draft in the perses `DashboardtypesPanelDTO` shape lets the preview pane
|
||||
* render it through the same renderer registry the dashboard uses, and lets the
|
||||
* save hook patch it without any conversion.
|
||||
*
|
||||
* Everything the config pane edits — title/description, the per-kind plugin spec
|
||||
* (formatting, axes, …), legend colors, context links — flows through the single
|
||||
* `spec`/`setSpec` pair (the ConfigPane registry lens), so there is one editing path.
|
||||
*/
|
||||
export function usePanelEditorDraft(
|
||||
initialPanel: DashboardtypesPanelDTO,
|
||||
): PanelEditorDraftApi {
|
||||
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
|
||||
|
||||
const setSpec = useCallback((next: DashboardtypesPanelSpecDTO): void => {
|
||||
setDraft((prev) => ({ ...prev, spec: next }));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setDraft(initialPanel);
|
||||
}, [initialPanel]);
|
||||
|
||||
// Deep compare, ignoring `spec.queries`: the live query is owned by the shared
|
||||
// query builder and re-serialized into the draft purely as a preview cache, so
|
||||
// its representation drifts (builder-filled defaults, regenerated ids, wrapper
|
||||
// kind) without a real edit. Query dirtiness is tracked separately against the
|
||||
// builder; here we only flag divergence in display + plugin spec slices
|
||||
// (formatting, axes, thresholds, links, list columns, …).
|
||||
const isSpecDirty = useMemo(
|
||||
() =>
|
||||
!isEqual(
|
||||
{ ...draft, spec: { ...draft.spec, queries: null } },
|
||||
{ ...initialPanel, spec: { ...initialPanel.spec, queries: null } },
|
||||
),
|
||||
[draft, initialPanel],
|
||||
);
|
||||
|
||||
return {
|
||||
draft,
|
||||
spec: draft.spec,
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getIsQueryModified } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
|
||||
import { fromPerses, toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
|
||||
interface UsePanelEditorQuerySyncArgs {
|
||||
draft: DashboardtypesPanelDTO;
|
||||
panelType: PANEL_TYPES;
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Re-fetch the preview when the query is unchanged (Stage & Run on a no-op). */
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
interface UsePanelEditorQuerySyncApi {
|
||||
/** Run the current query (Stage & Run / ⌘↵). */
|
||||
runQuery: () => void;
|
||||
/**
|
||||
* True when the live builder query differs from the saved query. Compared
|
||||
* builder-normalized vs builder-normalized (against the builder's own baseline),
|
||||
* so re-serialization noise never reads as an edit.
|
||||
*/
|
||||
isQueryDirty: boolean;
|
||||
/**
|
||||
* Bake the live query into a spec for saving — so unstaged edits still persist.
|
||||
* Returns the spec untouched when the query is unchanged from saved.
|
||||
*/
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the shared query builder (global `QueryBuilderProvider`, URL-synced) and
|
||||
* the V2 editor draft: seeds the builder from the saved panel, then commits the
|
||||
* active query into `draft.spec.queries` (what the preview fetches) on a query-type
|
||||
* or datasource switch and on Stage & Run.
|
||||
*/
|
||||
export function usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
|
||||
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
|
||||
|
||||
// Saved queries, captured once: seed the builder and serve as the restore target.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
|
||||
const savedQueries = useMemo(() => draft.spec?.queries ?? [], []);
|
||||
const seedQuery = useMemo(
|
||||
() => fromPerses(savedQueries, panelType),
|
||||
[savedQueries, panelType],
|
||||
);
|
||||
// Open the builder from the SAVED panel, discarding any stale URL query left by
|
||||
// a prior edit/refresh — otherwise the QB shows the URL query while the preview
|
||||
// keeps fetching the saved one, and the dirty baseline gets captured from the URL
|
||||
// (so switching back to that datasource reads as "unchanged" and never commits).
|
||||
// Force-reset on the first render only; after that the URL syncs normally.
|
||||
const isInitialRenderRef = useRef(true);
|
||||
useShareBuilderUrl({
|
||||
defaultValue: seedQuery,
|
||||
forceReset: isInitialRenderRef.current,
|
||||
});
|
||||
useEffect(() => {
|
||||
isInitialRenderRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Commit the live query into the draft (what the preview fetches). The dirty
|
||||
// check compares against the SAVED query (`seedQuery`), not the URL-synced
|
||||
// staged query — a staged query can carry stale state across a refresh, which
|
||||
// would make a real datasource switch read as "unchanged" and silently revert.
|
||||
// Unchanged from saved → restore the saved queries (don't dirty the draft);
|
||||
// changed → commit the live query. Returns whether the draft changed.
|
||||
const commitQuery = useCallback(
|
||||
(query: Query): boolean => {
|
||||
const next = getIsQueryModified(query, seedQuery)
|
||||
? toPerses(query, panelType)
|
||||
: savedQueries;
|
||||
// No-op guard at the V5 envelope level: an unchanged query re-serialized
|
||||
// to a different but equivalent wrapper (a bare `signoz/BuilderQuery` vs a
|
||||
// `signoz/CompositeQuery`) unwraps to the same envelopes — comparing the
|
||||
// wrappers structurally would falsely dirty the draft on Stage & Run.
|
||||
const current = draft.spec?.queries ?? [];
|
||||
if (isEqual(toQueryEnvelopes(next), toQueryEnvelopes(current))) {
|
||||
return false;
|
||||
}
|
||||
setSpec({ ...draft.spec, queries: next });
|
||||
return true;
|
||||
},
|
||||
[seedQuery, panelType, savedQueries, draft.spec, setSpec],
|
||||
);
|
||||
|
||||
// Latest query/commit, read by the structural-change effect without re-subscribing.
|
||||
const commitRef = useRef(commitQuery);
|
||||
commitRef.current = commitQuery;
|
||||
const queryRef = useRef(currentQuery);
|
||||
queryRef.current = currentQuery;
|
||||
|
||||
// Re-commit on a query-type or datasource switch so the preview refetches the
|
||||
// structurally-changed query. Skip mount: the draft already holds the saved
|
||||
// queries and the builder is being force-reset to them.
|
||||
const dataSourceSignature = useMemo(
|
||||
() =>
|
||||
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
|
||||
[currentQuery.builder],
|
||||
);
|
||||
const didMountRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!didMountRef.current) {
|
||||
didMountRef.current = true;
|
||||
return;
|
||||
}
|
||||
commitRef.current(queryRef.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
|
||||
}, [currentQuery.queryType, dataSourceSignature]);
|
||||
|
||||
// Stage & Run / ⌘↵: stage, commit, and re-fetch when unchanged so the same query
|
||||
// can be re-run.
|
||||
const runQuery = useCallback((): void => {
|
||||
handleRunQuery();
|
||||
if (!commitQuery(currentQuery)) {
|
||||
refetch();
|
||||
}
|
||||
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
|
||||
|
||||
// Dirty baseline for the query: the builder's OWN normalized form of the saved
|
||||
// query — the first non-null `stagedQuery` the builder produces after the mount
|
||||
// force-reset. Capturing it (instead of the raw `seedQuery`) makes the dirty
|
||||
// check builder-normalized vs builder-normalized, immune to the serialization
|
||||
// drift that otherwise reads an untouched query as modified. Held in state (not
|
||||
// a ref) so capturing it re-renders and `isQueryDirty` recomputes; captured once
|
||||
// and never moved by Stage & Run, so it stays anchored to the saved query.
|
||||
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
|
||||
useEffect(() => {
|
||||
if (queryBaseline === null && stagedQuery) {
|
||||
setQueryBaseline(stagedQuery);
|
||||
}
|
||||
}, [queryBaseline, stagedQuery]);
|
||||
|
||||
const isQueryDirty =
|
||||
queryBaseline !== null && getIsQueryModified(currentQuery, queryBaseline);
|
||||
|
||||
const buildSaveSpec = useCallback(
|
||||
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>
|
||||
isQueryDirty
|
||||
? { ...spec, queries: toPerses(currentQuery, panelType) }
|
||||
: spec,
|
||||
[isQueryDirty, currentQuery, panelType],
|
||||
);
|
||||
|
||||
return { runQuery, isQueryDirty, buildSaveSpec };
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
type DashboardtypesJSONPatchOperationDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesPatchOpDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
interface UsePanelEditorSaveArgs {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
}
|
||||
|
||||
interface UsePanelEditorSaveApi {
|
||||
save: (spec: DashboardtypesPanelSpecDTO) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists panel edits for the V2 editor via RFC-6902 JSON Patch.
|
||||
*
|
||||
* Replaces the whole panel spec in one `add` op against `/spec/panels/{panelId}/spec`
|
||||
* with the editor's draft spec — so every edit the config pane makes (display,
|
||||
* formatting/axes/legend/chart-appearance under `plugin.spec`, `legend.customColors`,
|
||||
* context links) is persisted, not just the title/description. `add` doubles as
|
||||
* create-or-replace, so panels that loaded without a sub-object are handled without a
|
||||
* separate existence check. The draft carries `queries` unchanged until the V2 query
|
||||
* builder lands, so replacing the whole spec is safe.
|
||||
*/
|
||||
export function usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
|
||||
|
||||
const save = useCallback(
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: DashboardtypesPatchOpDTO.add,
|
||||
path: `/spec/panels/${panelId}/spec`,
|
||||
value: spec,
|
||||
},
|
||||
];
|
||||
|
||||
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
|
||||
await queryClient.invalidateQueries(
|
||||
getGetDashboardV2QueryKey({ id: dashboardId }),
|
||||
);
|
||||
},
|
||||
[dashboardId, panelId, mutateAsync, queryClient],
|
||||
);
|
||||
|
||||
return { save, isSaving: isLoading, error: (error as Error) ?? null };
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
useDefaultLayout,
|
||||
} from '@signozhq/ui/resizable';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
|
||||
import Header from './Header/Header';
|
||||
import layoutStorage from './layoutStorage';
|
||||
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import { usePanelQuery } from '../hooks/usePanelQuery';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
|
||||
import styles from './PanelEditor.module.scss';
|
||||
|
||||
interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Leave the editor (navigate back to the dashboard) without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save — navigates back to the dashboard. */
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 panel editor page body. Rendered by the `DASHBOARD_PANEL_EDITOR` route
|
||||
* (`PanelEditorPage`) as a full page — a resizable split holds the live preview
|
||||
* + query builder on the left and the configuration pane on the right. Owns the
|
||||
* draft state and the save round-trip.
|
||||
*/
|
||||
function PanelEditorContainer({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panel,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
const { draft, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
|
||||
const { save, isSaving } = usePanelEditorSave({ dashboardId, panelId });
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: 'panel-editor-v2',
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
const {
|
||||
defaultLayout: mainDefaultLayout,
|
||||
onLayoutChanged: onMainLayoutChanged,
|
||||
} = useDefaultLayout({
|
||||
id: 'panel-editor-v2-main',
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
// Panel kind → V1 panel type (drives the query builder + preview).
|
||||
const fullKind = draft.spec?.plugin?.kind;
|
||||
const panelType =
|
||||
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
|
||||
PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
// One shared query result for the whole editor: the preview renders it.
|
||||
const panelDef = getPanelDefinition(draft.spec?.plugin?.kind);
|
||||
const { data, isLoading, isFetching, error, cancelQuery, refetch } =
|
||||
usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
enabled: !!panelDef,
|
||||
});
|
||||
|
||||
// Seed the shared query builder from the draft and expose the Stage-&-Run
|
||||
// action (writes the query into the draft → preview re-fetches, or forces a
|
||||
// re-fetch when unchanged).
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
});
|
||||
|
||||
// Dirty = an edited config slice (display/plugin spec) OR an edited query. The
|
||||
// two are tracked independently so query re-serialization never false-dirties.
|
||||
const isDirty = isSpecDirty || isQueryDirty;
|
||||
|
||||
// Drag-to-zoom on the preview chart updates the (URL-synced) time window,
|
||||
// exactly as on the dashboard.
|
||||
const { onDragSelect } = usePanelInteractions();
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
// Bake the live query into the spec so unstaged edits are saved too.
|
||||
await save(buildSaveSpec(draft.spec));
|
||||
toast.success('Panel saved');
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('Failed to save panel');
|
||||
}
|
||||
}, [save, buildSaveSpec, draft.spec, onSaved]);
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
<Header
|
||||
isDirty={isDirty}
|
||||
isSaving={isSaving}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2"
|
||||
orientation="horizontal"
|
||||
defaultLayout={defaultLayout}
|
||||
onLayoutChanged={onLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
|
||||
<div className={styles.left}>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2-main"
|
||||
orientation="vertical"
|
||||
defaultLayout={mainDefaultLayout}
|
||||
onLayoutChanged={onMainLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDef={panelDef}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onDragSelect={onDragSelect}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
|
||||
<PanelEditorQueryBuilder
|
||||
panelType={panelType}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel
|
||||
minSize="20%"
|
||||
maxSize="25%"
|
||||
defaultSize="20%"
|
||||
className={styles.right}
|
||||
/>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelEditorContainer;
|
||||
@@ -0,0 +1,17 @@
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
|
||||
/**
|
||||
* `Storage`-shaped adapter (just `getItem`/`setItem`, which is all
|
||||
* `useDefaultLayout` consumes) backed by the scoped localStorage wrappers. The
|
||||
* wrappers prefix keys with the URL base path, so the persisted resizable
|
||||
* layout stays isolated per deployment instead of touching the raw global.
|
||||
*/
|
||||
const layoutStorage: Pick<Storage, 'getItem' | 'setItem'> = {
|
||||
getItem: (key: string): string | null => getLocalStorageApi(key),
|
||||
setItem: (key: string, value: string): void => {
|
||||
setLocalStorageApi(key, value);
|
||||
},
|
||||
};
|
||||
|
||||
export default layoutStorage;
|
||||
@@ -0,0 +1,30 @@
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Local draft state for the panel being edited. The draft is kept as a perses
|
||||
* `DashboardtypesPanelDTO` so the live preview (which feeds the panel renderer)
|
||||
* and the save patch share a single shape — no intermediate translation.
|
||||
*/
|
||||
export interface PanelEditorDraftApi {
|
||||
/** The current (possibly edited) panel. Always a defined object once seeded. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
/**
|
||||
* The panel spec (`draft.spec`) — the single editing surface for the config pane.
|
||||
* Title/description live at `spec.display`; the section registry reads its slices
|
||||
* from here (plugin-level via `spec.plugin.spec.<key>`, panel-level via `spec.links`).
|
||||
*/
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
/** Replace the whole panel spec (the registry lens returns a new one per edit). */
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/**
|
||||
* True when the draft's display/plugin-spec slices diverge from the loaded
|
||||
* panel. Excludes `spec.queries` — the query is owned by the shared builder and
|
||||
* its dirtiness is tracked there (`usePanelEditorQuerySync.isQueryDirty`).
|
||||
*/
|
||||
isSpecDirty: boolean;
|
||||
/** Restore the draft to the originally-loaded panel. */
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
createAlert: true,
|
||||
},
|
||||
headerControls: { search: false },
|
||||
};
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// Bar stacking lives in `visualization.stackedBarChart` (a different spec key from the
|
||||
// time-series `chartAppearance`), so it's a control on the `visualization` section, not
|
||||
// `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true, stacking: true } },
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true, mode: true } },
|
||||
{ kind: 'thresholds', controls: { list: true } },
|
||||
{ kind: 'chartAppearance', controls: { stacked: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'label' } },
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
|
||||
@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
createAlert: true,
|
||||
},
|
||||
headerControls: { search: false },
|
||||
};
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'legend', controls: { position: true, mode: true } },
|
||||
{ kind: 'buckets', controls: { count: true } },
|
||||
{
|
||||
kind: 'legend',
|
||||
controls: { position: true },
|
||||
// Merging all queries collapses the histogram to a single distribution with no
|
||||
// legend — so hide the legend settings when that's on.
|
||||
isHidden: (spec): boolean =>
|
||||
Boolean(
|
||||
(spec.plugin?.spec as DashboardtypesHistogramPanelSpecDTO | undefined)
|
||||
?.histogramBuckets?.mergeAllActiveQueries,
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'buckets',
|
||||
controls: { count: true, width: true, mergeQueries: true },
|
||||
},
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
|
||||
@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
createAlert: true,
|
||||
},
|
||||
headerControls: { search: false },
|
||||
};
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// A number panel renders one scalar — no axes, legend, or stacking. Just value
|
||||
// formatting and thresholds that recolor the value/background.
|
||||
// formatting, thresholds, and context links. Number's thresholds use the `comparison`
|
||||
// variant (value crosses an operator → recolor the displayed number), distinct from the
|
||||
// value+label `label` variant TimeSeries/Bar use.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'thresholds', controls: { list: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'comparison' } },
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
|
||||
@@ -1,36 +1,7 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesComparisonThresholdDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesComparisonThresholdDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type {
|
||||
PanelThreshold,
|
||||
ThresholdComparisonOperator,
|
||||
ThresholdDisplayFormat,
|
||||
} from '../../types/threshold';
|
||||
|
||||
// Perses comparison operators → the symbol operators V2 threshold evaluation
|
||||
// uses.
|
||||
const OPERATOR_MAP: Record<
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
ThresholdComparisonOperator
|
||||
> = {
|
||||
[DashboardtypesComparisonOperatorDTO.above]: '>',
|
||||
[DashboardtypesComparisonOperatorDTO.below]: '<',
|
||||
[DashboardtypesComparisonOperatorDTO.above_or_equal]: '>=',
|
||||
[DashboardtypesComparisonOperatorDTO.below_or_equal]: '<=',
|
||||
[DashboardtypesComparisonOperatorDTO.equal]: '=',
|
||||
[DashboardtypesComparisonOperatorDTO.not_equal]: '!=',
|
||||
};
|
||||
|
||||
const FORMAT_MAP: Record<
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
ThresholdDisplayFormat
|
||||
> = {
|
||||
[DashboardtypesThresholdFormatDTO.text]: 'text',
|
||||
[DashboardtypesThresholdFormatDTO.background]: 'background',
|
||||
};
|
||||
import type { PanelThreshold } from '../../types/threshold';
|
||||
import { toPanelThreshold } from '../../utils/mapComparisonThreshold';
|
||||
|
||||
/**
|
||||
* Maps the panel-spec threshold shape (`ComparisonThresholdDTO`) onto the
|
||||
@@ -44,11 +15,5 @@ export function mapNumberThresholds(
|
||||
return [];
|
||||
}
|
||||
|
||||
return thresholds.map((threshold) => ({
|
||||
color: threshold.color,
|
||||
operator: threshold.operator ? OPERATOR_MAP[threshold.operator] : undefined,
|
||||
value: threshold.value,
|
||||
unit: threshold.unit,
|
||||
format: threshold.format ? FORMAT_MAP[threshold.format] : undefined,
|
||||
}));
|
||||
return thresholds.map(toPanelThreshold);
|
||||
}
|
||||
|
||||
@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
createAlert: false,
|
||||
},
|
||||
headerControls: { search: false },
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a
|
||||
// legend. `mode` is omitted: the pie legend is always interactive swatches.
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
|
||||
// Legend `colors` is omitted: the pie legend is always interactive swatches.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
|
||||
@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
createAlert: true,
|
||||
},
|
||||
headerControls: { search: false },
|
||||
};
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true, fillSpans: true } },
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true, colors: true } },
|
||||
{
|
||||
kind: 'formatting',
|
||||
kind: 'chartAppearance',
|
||||
controls: {
|
||||
unit: true,
|
||||
decimals: true,
|
||||
lineStyle: true,
|
||||
lineInterpolation: true,
|
||||
fillMode: true,
|
||||
showPoints: true,
|
||||
spanGaps: true,
|
||||
},
|
||||
},
|
||||
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true, mode: true } },
|
||||
{ kind: 'thresholds', controls: { list: true } },
|
||||
{ kind: 'chartAppearance', controls: { lineStyle: true, fillOpacity: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'label' } },
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
PanelRegistry,
|
||||
RenderablePanelDefinition,
|
||||
} from './types/panelDefinition';
|
||||
import type { DashboardtypesPanelPluginKindDTO as PanelKind } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelKind } from './types/panelKind';
|
||||
|
||||
// Pure assembly: each kind owns its own PanelDefinition (see
|
||||
// `kinds/<Kind>/definition.ts`). Registering a new panel = add its folder and a
|
||||
|
||||
@@ -6,12 +6,56 @@ import type { AnyPanelInteractionProps } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
|
||||
|
||||
/**
|
||||
* Kind-level action capabilities: which panel actions THIS kind supports.
|
||||
* Declared per-kind in `kinds/<Kind>/definition.ts` — the field is required,
|
||||
* so registering a new kind forces an explicit decision for every action
|
||||
* (mirroring how PanelInteractionMap forces per-kind interaction coverage).
|
||||
*
|
||||
* Chrome actions (move to section, clone, delete) are dashboard-layout
|
||||
* concerns, available for every panel — including kinds V2 can't render —
|
||||
* and are intentionally not declarable here.
|
||||
*/
|
||||
export interface PanelActionCapabilities {
|
||||
/** Kind has a full-screen view — gates the "View" action. */
|
||||
view: boolean;
|
||||
/** Kind is editable in the V2 panel editor — gates the "Edit panel" action. */
|
||||
edit: boolean;
|
||||
/** Kind can be cloned — gates the "Clone" action. */
|
||||
clone: boolean;
|
||||
/**
|
||||
* Kind's data can be exported as CSV — gates "Download as CSV". V1 parity:
|
||||
* only table panels carry tabular data worth exporting.
|
||||
*/
|
||||
download: boolean;
|
||||
/** Kind's query can seed a new alert — gates "Create Alerts". */
|
||||
createAlert: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kind-level header controls: chrome the panel header renders for THIS kind,
|
||||
* beyond the universal title / status / actions. Declared per-kind so the
|
||||
* header stays generic and never branches on kind. Required, mirroring
|
||||
* `actions`, so registering a new kind forces an explicit decision for every
|
||||
* control.
|
||||
*/
|
||||
export interface PanelHeaderControls {
|
||||
/**
|
||||
* Header carries a collapsible search box that filters the rendered rows
|
||||
* client-side. V1 parity: only tabular panels expose it. The kind's renderer
|
||||
* must consume `searchTerm` (see BaseRendererProps) to apply the filter.
|
||||
*/
|
||||
search: boolean;
|
||||
}
|
||||
|
||||
export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
kind: K;
|
||||
displayName: string;
|
||||
Renderer: ComponentType<PanelRendererProps<K>>;
|
||||
sections: SectionConfig[];
|
||||
supportedSignals: DataSource[];
|
||||
actions: PanelActionCapabilities;
|
||||
headerControls: PanelHeaderControls;
|
||||
}
|
||||
|
||||
// Keyed registry that preserves the kind ↔ definition correlation: indexing
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type {
|
||||
PanelPagination,
|
||||
PanelQueryData,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelInteractionMap } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
@@ -64,6 +67,17 @@ export interface BaseRendererProps {
|
||||
* resolving these; the renderer just consumes them.
|
||||
*/
|
||||
dashboardPreference?: DashboardPreference;
|
||||
/**
|
||||
* Free-text filter from the header search box, owned by the shell and
|
||||
* applied client-side by the renderer. Only meaningful for kinds that
|
||||
* declare `headerControls.search`; other renderers ignore it.
|
||||
*/
|
||||
searchTerm?: string;
|
||||
/**
|
||||
* Server-side paging handles, owned by `usePanelQuery`. Present only for
|
||||
* raw/list panels; other renderers ignore it.
|
||||
*/
|
||||
pagination?: PanelPagination;
|
||||
}
|
||||
|
||||
// Renderer props for a specific panel kind: the shared base plus that kind's
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import type {
|
||||
DashboardLinkDTO,
|
||||
DashboardtypesAxesDTO,
|
||||
DashboardtypesBarChartVisualizationDTO,
|
||||
DashboardtypesComparisonThresholdDTO,
|
||||
DashboardtypesHistogramBucketsDTO,
|
||||
DashboardtypesLegendDTO,
|
||||
DashboardtypesPanelFormattingDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesTableFormattingDTO,
|
||||
DashboardtypesTableThresholdDTO,
|
||||
DashboardtypesThresholdWithLabelDTO,
|
||||
DashboardtypesTimeSeriesChartAppearanceDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
BarChart,
|
||||
Columns3,
|
||||
Hash,
|
||||
ListEnd,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Link,
|
||||
Palette,
|
||||
Ruler,
|
||||
SlidersHorizontal,
|
||||
@@ -18,38 +35,150 @@ export interface SectionMetadata {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Per-kind control toggles (type-only — runtime metadata is in SECTIONS).
|
||||
// Section components type their controls prop via `SectionControls['axes']`.
|
||||
export type SectionControls = {
|
||||
formatting: { unit?: boolean; decimals?: boolean };
|
||||
axes: { minMax?: boolean; unit?: boolean; logScale?: boolean };
|
||||
legend: { position?: boolean; mode?: boolean };
|
||||
thresholds: { list?: boolean };
|
||||
/**
|
||||
* Which threshold editor a kind uses. All three variants persist to the same
|
||||
* `plugin.spec.thresholds` key but with different element shapes, so one section
|
||||
* (`thresholds`) drives all of them, discriminated by this variant:
|
||||
* - `label` — value + color + label lines (TimeSeries / Bar)
|
||||
* - `comparison` — value crosses an operator → recolor (Number)
|
||||
* - `table` — per-column comparison (Table)
|
||||
*/
|
||||
export type ThresholdVariant = 'label' | 'comparison' | 'table';
|
||||
|
||||
/** Union of every threshold element shape stored under `plugin.spec.thresholds`. */
|
||||
export type AnyThreshold =
|
||||
| DashboardtypesThresholdWithLabelDTO
|
||||
| DashboardtypesComparisonThresholdDTO
|
||||
| DashboardtypesTableThresholdDTO;
|
||||
|
||||
/**
|
||||
* The single source of truth for sections: each section ↔ exactly one slice of the
|
||||
* panel spec it edits. The slice type is uniform across every panel kind that shows
|
||||
* the section, so a section editor is written once and reused everywhere.
|
||||
*
|
||||
* Most slices live under the plugin spec (`spec.plugin.spec.<key>`); a few are
|
||||
* panel-level (`contextLinks` → `spec.links`). The section registry's lens (see
|
||||
* `ConfigPane/sectionRegistry`) abstracts over both, so this map stays purely about
|
||||
* "what shape does this section edit".
|
||||
*
|
||||
* `SectionKind` is derived below as `ControlledSectionKind | AtomicSectionKind`; the
|
||||
* `satisfies Record<SectionKind, …>` checks on `SectionControls` + `SECTION_METADATA`
|
||||
* keep all three structures covering the exact same set of kinds.
|
||||
*/
|
||||
// Formatting slice spans the union of every kind's formatting DTO: a single `unit`
|
||||
// + `decimalPrecision` (most kinds) plus Table's per-column `columnUnits`. The
|
||||
// per-kind `controls` bag gates which fields each editor writes, so a kind never
|
||||
// writes a field its real DTO lacks (cast localized in the registry).
|
||||
export type PanelFormattingSlice = DashboardtypesPanelFormattingDTO &
|
||||
Pick<DashboardtypesTableFormattingDTO, 'columnUnits'>;
|
||||
|
||||
export interface SectionSpecMap {
|
||||
formatting: PanelFormattingSlice; // spec.plugin.spec.formatting
|
||||
axes: DashboardtypesAxesDTO; // spec.plugin.spec.axes
|
||||
legend: DashboardtypesLegendDTO; // spec.plugin.spec.legend
|
||||
chartAppearance: DashboardtypesTimeSeriesChartAppearanceDTO; // spec.plugin.spec.chartAppearance
|
||||
buckets: DashboardtypesHistogramBucketsDTO; // spec.plugin.spec.histogramBuckets
|
||||
// spec.plugin.spec.visualization. Typed as the Bar shape because it's the widest
|
||||
// superset (stackedBarChart + fillSpans + timePreference); other kinds' visualization
|
||||
// DTOs are subsets. The per-kind `controls` bag gates which fields each editor writes,
|
||||
// so a kind never writes a field its real DTO lacks (cast localized in the registry).
|
||||
visualization: DashboardtypesBarChartVisualizationDTO;
|
||||
// spec.plugin.spec.thresholds. One slice, three element shapes (see ThresholdVariant);
|
||||
// the per-kind `variant` control picks the editor, so a kind only ever reads/writes the
|
||||
// shape its spec actually stores.
|
||||
thresholds: AnyThreshold[];
|
||||
contextLinks: DashboardLinkDTO[]; // spec.links (PANEL-level)
|
||||
columns: TelemetrytypesTelemetryFieldKeyDTO[]; // spec.plugin.spec.selectFields (List)
|
||||
}
|
||||
|
||||
/**
|
||||
* (1) CONTROLLED sections — those with multiple independently-pickable sub-features.
|
||||
* The per-kind bag lets a kind expose a SUBSET of the section's controls (the V2
|
||||
* analogue of V1's `allowSoftMinMax` / `allowLegendColors` flags). Every key here
|
||||
* corresponds to a real, editable field on the section's spec slice.
|
||||
*/
|
||||
export interface SectionControls {
|
||||
formatting: { unit?: boolean; decimals?: boolean; columnUnits?: boolean };
|
||||
axes: { minMax?: boolean; logScale?: boolean }; // minMax → softMin/softMax
|
||||
legend: { position?: boolean; colors?: boolean }; // colors → customColors
|
||||
chartAppearance: {
|
||||
lineStyle?: boolean;
|
||||
fillOpacity?: boolean;
|
||||
stacked?: boolean;
|
||||
lineInterpolation?: boolean;
|
||||
fillMode?: boolean;
|
||||
showPoints?: boolean;
|
||||
spanGaps?: boolean;
|
||||
};
|
||||
columnUnits: { perColumnUnit?: boolean };
|
||||
buckets: { count?: boolean; min?: boolean; max?: boolean };
|
||||
};
|
||||
buckets: { count?: boolean; width?: boolean; mergeQueries?: boolean };
|
||||
// timePreference → per-panel time scope (all kinds); stacking → stackedBarChart (Bar);
|
||||
// fillSpans → fill data gaps with 0 (TimeSeries). Each kind exposes only its subset.
|
||||
visualization: {
|
||||
timePreference?: boolean;
|
||||
stacking?: boolean;
|
||||
fillSpans?: boolean;
|
||||
};
|
||||
// Not a spec field but the editor discriminator: which threshold variant a kind edits
|
||||
// (label / comparison / table). All three persist to plugin.spec.thresholds.
|
||||
thresholds: { variant?: ThresholdVariant };
|
||||
}
|
||||
|
||||
// Source of truth for sections. Its keys define SectionKind; its values are the
|
||||
// runtime UI metadata (consumed by PanelEditor in 1.8). Adding a new section =
|
||||
// one entry here + one entry in SectionControls.
|
||||
export const SECTIONS = {
|
||||
export type ControlledSectionKind = keyof SectionControls;
|
||||
|
||||
/**
|
||||
* (2) ATOMIC sections — no sub-controls; a kind either shows them or not. Thresholds
|
||||
* and Context Links are each just a list editor, so there is nothing to subset.
|
||||
*/
|
||||
export type AtomicSectionKind = 'contextLinks' | 'columns';
|
||||
|
||||
export type SectionKind = ControlledSectionKind | AtomicSectionKind;
|
||||
|
||||
/**
|
||||
* What a panel kind declares in `kinds/<Kind>/sections.ts`. A controlled section is
|
||||
* declared with its `controls` subset; an atomic section is declared bare (`{ kind }`).
|
||||
*
|
||||
* Whether a kind ALLOWS a section at all is governed entirely by whether it appears in
|
||||
* the kind's `sections` array — e.g. Pie/Histogram omit `thresholds`, so it never shows.
|
||||
*/
|
||||
/**
|
||||
* Optional predicate to hide a section based on the current panel spec — for
|
||||
* cross-section rules (e.g. the Histogram legend is irrelevant once its queries are
|
||||
* merged into one distribution). Returning true removes the section from the pane.
|
||||
*/
|
||||
export type SectionVisibilityPredicate = (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => boolean;
|
||||
|
||||
export type SectionConfig =
|
||||
| {
|
||||
[K in ControlledSectionKind]: {
|
||||
kind: K;
|
||||
controls: SectionControls[K];
|
||||
isHidden?: SectionVisibilityPredicate;
|
||||
};
|
||||
}[ControlledSectionKind]
|
||||
| { kind: AtomicSectionKind; isHidden?: SectionVisibilityPredicate };
|
||||
|
||||
// Runtime UI metadata per section (title + sidebar icon). Pure data — no component
|
||||
// coupling. The editor component + spec lens live in the ConfigPane section registry.
|
||||
export const SECTION_METADATA = {
|
||||
formatting: { title: 'Formatting', icon: Hash },
|
||||
axes: { title: 'Axes', icon: Ruler },
|
||||
legend: { title: 'Legend', icon: ListEnd },
|
||||
thresholds: { title: 'Thresholds', icon: SlidersHorizontal },
|
||||
legend: { title: 'Legend', icon: Layers },
|
||||
chartAppearance: { title: 'Chart appearance', icon: Palette },
|
||||
columnUnits: { title: 'Column units', icon: Columns3 },
|
||||
buckets: { title: 'Buckets', icon: BarChart },
|
||||
} as const satisfies Record<string, SectionMetadata>;
|
||||
visualization: { title: 'Visualization', icon: LayoutDashboard },
|
||||
buckets: { title: 'Histogram / Buckets', icon: BarChart },
|
||||
thresholds: { title: 'Thresholds', icon: SlidersHorizontal },
|
||||
contextLinks: { title: 'Context Links', icon: Link },
|
||||
columns: { title: 'Columns', icon: Columns3 },
|
||||
} as const satisfies Record<SectionKind, SectionMetadata>;
|
||||
|
||||
export type SectionKind = keyof typeof SECTIONS;
|
||||
|
||||
// Discriminated union derived from SectionControls — kept in lockstep automatically.
|
||||
export type SectionConfig = {
|
||||
[K in SectionKind]: { kind: K; controls: SectionControls[K] };
|
||||
}[SectionKind];
|
||||
/**
|
||||
* Props every section editor receives — exactly its slice type (`value`), an
|
||||
* `onChange` to write the next slice, and (controlled sections only) the per-kind
|
||||
* `controls` subset. Atomic editors omit `controls`.
|
||||
*/
|
||||
export type SectionEditorProps<K extends SectionKind> = {
|
||||
value: SectionSpecMap[K] | undefined;
|
||||
onChange: (next: SectionSpecMap[K]) => void;
|
||||
} & (K extends ControlledSectionKind
|
||||
? { controls: SectionControls[K] }
|
||||
: unknown);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type {
|
||||
PanelThreshold,
|
||||
ThresholdComparisonOperator,
|
||||
ThresholdDisplayFormat,
|
||||
} from '../types/threshold';
|
||||
|
||||
// Perses comparison operators → the symbol operators V2 threshold evaluation uses.
|
||||
const OPERATOR_MAP: Record<
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
ThresholdComparisonOperator
|
||||
> = {
|
||||
[DashboardtypesComparisonOperatorDTO.above]: '>',
|
||||
[DashboardtypesComparisonOperatorDTO.below]: '<',
|
||||
[DashboardtypesComparisonOperatorDTO.above_or_equal]: '>=',
|
||||
[DashboardtypesComparisonOperatorDTO.below_or_equal]: '<=',
|
||||
[DashboardtypesComparisonOperatorDTO.equal]: '=',
|
||||
[DashboardtypesComparisonOperatorDTO.not_equal]: '!=',
|
||||
};
|
||||
|
||||
const FORMAT_MAP: Record<
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
ThresholdDisplayFormat
|
||||
> = {
|
||||
[DashboardtypesThresholdFormatDTO.text]: 'text',
|
||||
[DashboardtypesThresholdFormatDTO.background]: 'background',
|
||||
};
|
||||
|
||||
/**
|
||||
* The comparison-shaped fields shared by every panel-spec threshold DTO that
|
||||
* recolors on an operator crossing (`ComparisonThresholdDTO`,
|
||||
* `TableThresholdDTO`). The container DTOs add their own keys (e.g. a table
|
||||
* threshold's `columnName`) around this common core.
|
||||
*/
|
||||
export interface ComparisonThresholdShape {
|
||||
color: string;
|
||||
value: number;
|
||||
operator?: DashboardtypesComparisonOperatorDTO;
|
||||
unit?: string;
|
||||
format?: DashboardtypesThresholdFormatDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a comparison-shaped panel-spec threshold onto the V2-native
|
||||
* `PanelThreshold` consumed by threshold evaluation / rendering. The single
|
||||
* place the Perses operator/format enums cross into the symbol model, shared by
|
||||
* every kind that carries comparison thresholds (Number, Table, …).
|
||||
*/
|
||||
export function toPanelThreshold(
|
||||
threshold: ComparisonThresholdShape,
|
||||
): PanelThreshold {
|
||||
return {
|
||||
color: threshold.color,
|
||||
operator: threshold.operator ? OPERATOR_MAP[threshold.operator] : undefined,
|
||||
value: threshold.value,
|
||||
unit: threshold.unit,
|
||||
format: threshold.format ? FORMAT_MAP[threshold.format] : undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,26 +1,30 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
DashboardtypesPanelPluginKindDTO as PanelKind,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { panelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import type { Warning } from 'types/api';
|
||||
import type { DashboardtypesPanelPluginKindDTO as PanelKind } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import type { DeletePanelArgs } from './hooks/useDeletePanel';
|
||||
import { usePanelInteractions } from './hooks/usePanelInteractions';
|
||||
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
|
||||
import PanelBody from './PanelBody/PanelBody';
|
||||
import UnsupportedPanelBody from './PanelBody/UnsupportedPanelBody';
|
||||
import PanelHeader from './PanelHeader/PanelHeader';
|
||||
import styles from './Panel.module.scss';
|
||||
|
||||
/** Panel action context — present together only in editable sectioned mode. */
|
||||
/**
|
||||
* Layout context for the panel actions menu — pure data, present only in
|
||||
* editable mode. No callbacks: the menu resolves its own mutations from
|
||||
* store-backed hooks (useDeletePanel / useMovePanelToSection), and edit is
|
||||
* URL-driven (useOpenPanelEditor).
|
||||
*/
|
||||
export interface PanelActionsConfig {
|
||||
currentLayoutIndex: number;
|
||||
sections: DashboardSection[];
|
||||
onMovePanel: (args: MovePanelArgs) => void;
|
||||
onDeletePanel: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
@@ -50,15 +54,32 @@ function Panel({
|
||||
const kind = fullKind?.replace(/^signoz\//, '') ?? 'unknown';
|
||||
const queryCount = panel.spec.queries?.length ?? 0;
|
||||
|
||||
// A per-panel relative time preference (anything other than global_time) is
|
||||
// surfaced as a pill in the header. `visualization` is common to every
|
||||
// plugin-spec variant — localized cast reads it without narrowing on kind.
|
||||
const timePreference = (
|
||||
panel.spec.plugin?.spec as
|
||||
| { visualization?: { timePreference?: DashboardtypesTimePreferenceDTO } }
|
||||
| undefined
|
||||
)?.visualization?.timePreference;
|
||||
const timeLabel = panelTimePreferenceLabel(timePreference);
|
||||
|
||||
const panelDefinition = getPanelDefinition(fullKind);
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch } = usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
// Lazy: only fetch once the section is on screen (undefined → treat as
|
||||
// visible) and a renderer exists for the kind.
|
||||
enabled: !!panelDefinition && isVisible !== false,
|
||||
});
|
||||
// Header search: only kinds that declare it (e.g. tables) render the box; the
|
||||
// term is owned here and threaded to both the header (input) and the renderer
|
||||
// (filter), the two being siblings under this orchestrator.
|
||||
const searchable = !!panelDefinition?.headerControls.search;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch, pagination } =
|
||||
usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
// Lazy: only fetch once the section is on screen (undefined → treat as
|
||||
// visible) and a renderer exists for the kind.
|
||||
enabled: !!panelDefinition && isVisible !== false,
|
||||
});
|
||||
|
||||
const { onDragSelect, dashboardPreference } = usePanelInteractions();
|
||||
|
||||
@@ -81,13 +102,15 @@ function Panel({
|
||||
<PanelHeader
|
||||
title={headerTitle}
|
||||
panelId={panelId}
|
||||
panelKind={fullKind}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
// The V5 response `warning` is the same object the legacy chain
|
||||
// surfaced as `Warning` — passed through untouched; the cast is the
|
||||
// generated-DTO → hand-written-type boundary.
|
||||
warning={data.response?.data?.warning as Warning | undefined}
|
||||
warning={data.response?.data?.warning}
|
||||
timeLabel={timeLabel}
|
||||
panelActions={panelActions}
|
||||
searchable={searchable}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
{panelDefinition ? (
|
||||
<PanelBody
|
||||
@@ -100,6 +123,8 @@ function Panel({
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
/>
|
||||
) : (
|
||||
// TODO: remove this after all panel kinds are supported
|
||||
|
||||
@@ -1,98 +1,70 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons';
|
||||
import { EllipsisVertical } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { DeletePanelArgs } from '../hooks/useDeletePanel';
|
||||
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
|
||||
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import { usePanelActionItems } from './usePanelActionItems';
|
||||
import styles from './PanelActionsMenu.module.scss';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
interface PanelActionsMenuProps {
|
||||
panelId: string;
|
||||
currentLayoutIndex: number;
|
||||
sections: DashboardSection[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`);*/
|
||||
panelKind: PanelKind;
|
||||
/** Layout context for move/delete — absent outside editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Purely presentational: the trigger button + dropdown, plus the delete
|
||||
* confirmation dialog. Which items appear — and the delete-confirm state — is
|
||||
* owned by `usePanelActionItems` (kind ∧ role ∧ context gating per action).
|
||||
*/
|
||||
function PanelActionsMenu({
|
||||
panelId,
|
||||
currentLayoutIndex,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: PanelActionsMenuProps): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const result: MenuItem[] = [];
|
||||
panelKind,
|
||||
panelActions,
|
||||
}: PanelActionsMenuProps): JSX.Element | null {
|
||||
const { items, deleteConfirm } = usePanelActionItems({
|
||||
panelId,
|
||||
panelKind,
|
||||
panelActions,
|
||||
});
|
||||
|
||||
if (onMovePanel) {
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
result.push({
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
disabled: true,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
onMovePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (onDeletePanel) {
|
||||
if (result.length > 0) {
|
||||
result.push({ type: 'divider' });
|
||||
}
|
||||
result.push({
|
||||
key: 'delete-panel',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: 'Delete panel',
|
||||
onClick: (): void =>
|
||||
onDeletePanel({ panelId, layoutIndex: currentLayoutIndex }),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [sections, currentLayoutIndex, panelId, onMovePanel, onDeletePanel]);
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.trigger}
|
||||
aria-label="Panel actions"
|
||||
data-testid={`panel-actions-${panelId}`}
|
||||
// Stop pointer/mouse down from reaching the RGL drag handle this
|
||||
// button lives inside, so opening the menu never starts a panel drag.
|
||||
onPointerDown={(e): void => e.stopPropagation()}
|
||||
onMouseDown={(e): void => e.stopPropagation()}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
<>
|
||||
<DropdownMenuSimple menu={{ items }} align="end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.trigger}
|
||||
aria-label="Panel actions"
|
||||
data-testid={`panel-actions-${panelId}`}
|
||||
// Stop pointer/mouse down from reaching the RGL drag handle this
|
||||
// button lives inside, so opening the menu never starts a panel drag.
|
||||
onPointerDown={(e): void => e.stopPropagation()}
|
||||
onMouseDown={(e): void => e.stopPropagation()}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteConfirm.open}
|
||||
title="Delete panel?"
|
||||
description="This panel will be removed from the dashboard. This action cannot be undone."
|
||||
isLoading={deleteConfirm.isPending}
|
||||
onConfirm={deleteConfirm.confirm}
|
||||
onClose={deleteConfirm.cancel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { ROLES } from 'types/roles';
|
||||
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { usePanelActionItems } from '../usePanelActionItems';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
const mockOpenEditor = jest.fn();
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
|
||||
() => ({
|
||||
useOpenPanelEditor: (): jest.Mock => mockOpenEditor,
|
||||
}),
|
||||
);
|
||||
|
||||
const mockMovePanel = jest.fn();
|
||||
jest.mock('../../hooks/useMovePanelToSection', () => ({
|
||||
useMovePanelToSection: (): jest.Mock => mockMovePanel,
|
||||
}));
|
||||
|
||||
const mockDeletePanel = jest.fn();
|
||||
jest.mock('../../hooks/useDeletePanel', () => ({
|
||||
useDeletePanel: (): jest.Mock => mockDeletePanel,
|
||||
}));
|
||||
|
||||
const mockClonePanel = jest.fn();
|
||||
jest.mock('../../hooks/useClonePanel', () => ({
|
||||
useClonePanel: (): jest.Mock => mockClonePanel,
|
||||
}));
|
||||
|
||||
// Role is the only thing read off the app context; useComponentPermission runs
|
||||
// for real so the tests exercise the actual role → permission mapping.
|
||||
let mockRole: ROLES = 'ADMIN';
|
||||
jest.mock('providers/App/App', () => ({
|
||||
useAppContext: (): { user: { role: ROLES } } => ({
|
||||
user: { role: mockRole },
|
||||
}),
|
||||
}));
|
||||
|
||||
function section(
|
||||
layoutIndex: number,
|
||||
title: string | undefined,
|
||||
): DashboardSection {
|
||||
return {
|
||||
id: `section-${layoutIndex}`,
|
||||
layoutIndex,
|
||||
title,
|
||||
items: [],
|
||||
repeatVariable: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
|
||||
|
||||
const baseArgs = {
|
||||
panelId: 'panel-1',
|
||||
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
|
||||
};
|
||||
|
||||
function itemKeys(result: ReturnType<typeof usePanelActionItems>): unknown[] {
|
||||
return result.items.map((item) =>
|
||||
'key' in item && item.key !== undefined ? item.key : item.type,
|
||||
);
|
||||
}
|
||||
|
||||
describe('usePanelActionItems', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRole = 'ADMIN';
|
||||
useDashboardStore.setState({ isEditable: true });
|
||||
});
|
||||
|
||||
it('ADMIN on an editable dashboard with a known kind gets the full V1-parity set, divider-separated', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
'divider',
|
||||
'create-alert',
|
||||
'divider',
|
||||
'move',
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
// download stays hidden: no current kind declares the capability
|
||||
// (V1 parity — CSV export was table-only).
|
||||
});
|
||||
|
||||
it('AUTHOR loses edit and clone (edit_widget excludes AUTHOR) but keeps the rest', () => {
|
||||
mockRole = 'AUTHOR';
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'create-alert',
|
||||
'divider',
|
||||
'move',
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('VIEWER keeps only the role-ungated actions (view, create-alert)', () => {
|
||||
mockRole = 'VIEWER';
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'create-alert',
|
||||
]);
|
||||
});
|
||||
|
||||
it('unknown panel kind hides all kind-gated actions (incl. clone), keeping only move/delete', () => {
|
||||
const { result } = renderHook(() =>
|
||||
// A kind with no registered definition — exercises the "unsupported kind"
|
||||
// branch. Clone is kind-gated (needs the kind to declare actions.clone),
|
||||
// so it drops too; only the kind-agnostic layout actions remain.
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelKind: 'signoz/UnsupportedPanel' as PanelKind,
|
||||
}),
|
||||
);
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'move',
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('read-only dashboard keeps only View (V1 parity)', () => {
|
||||
useDashboardStore.setState({ isEditable: false });
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
|
||||
);
|
||||
expect(itemKeys(result.current)).toStrictEqual(['view-panel']);
|
||||
});
|
||||
|
||||
it('move is disabled when there is no other titled section to move to', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: {
|
||||
currentLayoutIndex: 0,
|
||||
sections: [section(0, 'Overview'), section(1, undefined)],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find((i) => 'key' in i && i.key === 'move');
|
||||
expect(move).toMatchObject({ disabled: true });
|
||||
});
|
||||
|
||||
it('edit opens the panel editor for this panel', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const edit = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'edit-panel',
|
||||
);
|
||||
(edit as { onClick: () => void }).onClick();
|
||||
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1');
|
||||
});
|
||||
|
||||
it('move targets call the mutation with from/to layout indexes', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as {
|
||||
children: { key: string; onClick: () => void }[];
|
||||
};
|
||||
expect(move.children).toHaveLength(1);
|
||||
move.children[0].onClick();
|
||||
expect(mockMovePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
fromLayoutIndex: 0,
|
||||
toLayoutIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('delete defers to a confirmation: the item opens the dialog, confirm runs the mutation', async () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const del = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'delete-panel',
|
||||
);
|
||||
|
||||
// Clicking the menu item only opens the dialog — no mutation yet.
|
||||
expect(result.current.deleteConfirm.open).toBe(false);
|
||||
act(() => {
|
||||
(del as { onClick: () => void }).onClick();
|
||||
});
|
||||
expect(result.current.deleteConfirm.open).toBe(true);
|
||||
expect(mockDeletePanel).not.toHaveBeenCalled();
|
||||
|
||||
// Confirming runs the delete and closes the dialog.
|
||||
await act(async () => {
|
||||
await result.current.deleteConfirm.confirm();
|
||||
});
|
||||
expect(mockDeletePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
layoutIndex: 0,
|
||||
});
|
||||
expect(result.current.deleteConfirm.open).toBe(false);
|
||||
});
|
||||
|
||||
it('clone calls the clone mutation with the panel and its layout index', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const clone = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'clone-panel',
|
||||
);
|
||||
(clone as { onClick: () => void }).onClick();
|
||||
expect(mockClonePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
layoutIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('not-yet-implemented actions (view/create-alert) fire the placeholder alert with the feature name', () => {
|
||||
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
|
||||
['view-panel', 'create-alert'].forEach((key) => {
|
||||
const item = result.current.items.find((i) => 'key' in i && i.key === key);
|
||||
(item as { onClick: () => void }).onClick();
|
||||
});
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledTimes(2);
|
||||
expect(alertSpy).toHaveBeenCalledWith('View option clicked');
|
||||
expect(alertSpy).toHaveBeenCalledWith('Create Alerts option clicked');
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { PanelActionCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { ComponentTypes } from 'utils/permission';
|
||||
|
||||
/**
|
||||
* Every action the panel menu can offer. `Record<PanelActionId, …>` below
|
||||
* forces a meta entry per id — adding an action without declaring its gates is
|
||||
* a compile error.
|
||||
*/
|
||||
export type PanelActionId =
|
||||
| 'view'
|
||||
| 'edit'
|
||||
| 'clone'
|
||||
| 'download'
|
||||
| 'createAlert'
|
||||
| 'move'
|
||||
| 'delete';
|
||||
|
||||
export interface PanelActionMeta {
|
||||
/**
|
||||
* Role gate: componentPermission key checked against the current user.
|
||||
* Absent = available to every role (V1 parity: view, download and
|
||||
* create-alerts were never role-gated).
|
||||
*/
|
||||
permission?: ComponentTypes;
|
||||
/**
|
||||
* Kind gate: the PanelActionCapabilities flag this action requires.
|
||||
* Chrome actions (move/clone/delete) are layout concerns available for
|
||||
* every panel kind — including kinds V2 can't render — so they declare none.
|
||||
*/
|
||||
capability?: keyof PanelActionCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for how each panel action is gated, mirroring V1's
|
||||
* WidgetHeader rules. The third gate — context (dashboard editable, target
|
||||
* sections available) — is runtime state resolved in `usePanelActionItems`,
|
||||
* not declarable here.
|
||||
*/
|
||||
export const PANEL_ACTION_META: Record<PanelActionId, PanelActionMeta> = {
|
||||
view: { capability: 'view' },
|
||||
edit: { permission: 'edit_widget', capability: 'edit' },
|
||||
clone: { permission: 'edit_widget' },
|
||||
download: { capability: 'download' },
|
||||
createAlert: { capability: 'createAlert' },
|
||||
// Moving a panel between sections mutates the dashboard layout.
|
||||
move: { permission: 'edit_dashboard' },
|
||||
delete: { permission: 'delete_widget' },
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
CloudDownload,
|
||||
Copy,
|
||||
FolderInput,
|
||||
Fullscreen,
|
||||
PenLine,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import {
|
||||
type ConfirmableAction,
|
||||
useConfirmableAction,
|
||||
} from 'hooks/useConfirmableAction';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import { useClonePanel } from '../hooks/useClonePanel';
|
||||
import { useDeletePanel } from '../hooks/useDeletePanel';
|
||||
import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
|
||||
import { PANEL_ACTION_META } from './panelActionMeta';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
// hooks' deps (a fresh [] each render would re-create their callbacks).
|
||||
const EMPTY_SECTIONS: DashboardSection[] = [];
|
||||
|
||||
// Placeholder for the V1-parity actions whose V2 implementations land in
|
||||
// later milestones (view, clone, download, create-alerts).
|
||||
function notImplementedYet(feature: string): void {
|
||||
// eslint-disable-next-line no-alert -- temporary placeholder, see above
|
||||
alert(`${feature} option clicked`);
|
||||
}
|
||||
|
||||
interface UsePanelActionItemsArgs {
|
||||
panelId: string;
|
||||
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); */
|
||||
panelKind: PanelKind;
|
||||
/** Layout context for move/delete — absent outside editable mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
export interface PanelActionItems {
|
||||
items: MenuItem[];
|
||||
/**
|
||||
* Two-step confirm flow for the destructive Delete action — the menu defers
|
||||
* to it instead of deleting on click. The presentational menu renders
|
||||
* ConfirmDeleteDialog from this.
|
||||
*/
|
||||
deleteConfirm: ConfirmableAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the panel actions menu items (the V1 WidgetHeader action set plus
|
||||
* V2's "Move to section"). Every action passes three gates before it appears:
|
||||
*
|
||||
* kind — what the panel kind declares it supports (PanelDefinition.actions);
|
||||
* unknown kinds support no kind-gated actions.
|
||||
* role — componentPermission lookup for the current user (PANEL_ACTION_META;
|
||||
* actions without a permission key are open to every role, V1 parity).
|
||||
* context — runtime state: dashboard editable (store), layout config present.
|
||||
* View and Download remain available on read-only dashboards, as in V1.
|
||||
*
|
||||
* Items are composed as groups with dividers inserted between non-empty
|
||||
* groups, so adding an action never touches divider bookkeeping.
|
||||
*/
|
||||
export function usePanelActionItems({
|
||||
panelId,
|
||||
panelKind,
|
||||
panelActions,
|
||||
}: UsePanelActionItemsArgs): PanelActionItems {
|
||||
const { user } = useAppContext();
|
||||
const [canEditWidget, canMove, canDelete] = useComponentPermission(
|
||||
[
|
||||
// edit_widget gates both Edit and Clone, exactly as in V1.
|
||||
PANEL_ACTION_META.edit.permission ?? 'edit_widget',
|
||||
PANEL_ACTION_META.move.permission ?? 'edit_dashboard',
|
||||
PANEL_ACTION_META.delete.permission ?? 'delete_widget',
|
||||
],
|
||||
user.role,
|
||||
);
|
||||
// Folds in the dashboard lock + edit_dashboard permission (set once by
|
||||
// DashboardContainer). Mutating actions respect it; view/download don't.
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
|
||||
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
|
||||
// supplies data (`sections`), so no callbacks are threaded through it.
|
||||
const sections = panelActions?.sections ?? EMPTY_SECTIONS;
|
||||
const movePanel = useMovePanelToSection({ sections });
|
||||
const deletePanel = useDeletePanel({ sections });
|
||||
const clonePanel = useClonePanel({ sections });
|
||||
|
||||
const kindActions = getPanelDefinition(panelKind)?.actions;
|
||||
|
||||
// Delete is destructive, so the menu item opens a confirmation prompt rather
|
||||
// than deleting on click; the actual mutation runs on confirm.
|
||||
const deleteConfirm = useConfirmableAction(
|
||||
useCallback(async (): Promise<void> => {
|
||||
if (!panelActions) {
|
||||
return;
|
||||
}
|
||||
await deletePanel({
|
||||
panelId,
|
||||
layoutIndex: panelActions.currentLayoutIndex,
|
||||
});
|
||||
}, [deletePanel, panelActions, panelId]),
|
||||
);
|
||||
// Stable opener — used in the items memo without rebuilding it when the
|
||||
// dialog's open/pending state changes.
|
||||
const { request: requestDelete } = deleteConfirm;
|
||||
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
// Group 1 — open/author the panel: View, Edit, Clone.
|
||||
const panelGroup: MenuItem[] = [];
|
||||
if (kindActions?.view) {
|
||||
panelGroup.push({
|
||||
key: 'view-panel',
|
||||
label: 'View',
|
||||
icon: <Fullscreen size={14} />,
|
||||
onClick: (): void => notImplementedYet('View'),
|
||||
});
|
||||
}
|
||||
if (isEditable && canEditWidget && kindActions?.edit) {
|
||||
panelGroup.push({
|
||||
key: 'edit-panel',
|
||||
label: 'Edit panel',
|
||||
icon: <PenLine size={14} />,
|
||||
onClick: (): void => openPanelEditor(panelId),
|
||||
});
|
||||
}
|
||||
// Clone needs the section context (source spec + dimensions) to place the
|
||||
// copy, so — unlike Edit — it requires panelActions.
|
||||
if (isEditable && canEditWidget && panelActions && kindActions?.clone) {
|
||||
panelGroup.push({
|
||||
key: 'clone-panel',
|
||||
label: 'Clone',
|
||||
icon: <Copy size={14} />,
|
||||
onClick: (): void =>
|
||||
void clonePanel({
|
||||
panelId,
|
||||
layoutIndex: panelActions.currentLayoutIndex,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Group 2 — derive from the panel's data: Download, Create Alerts.
|
||||
const dataGroup: MenuItem[] = [];
|
||||
if (kindActions?.download) {
|
||||
dataGroup.push({
|
||||
key: 'download-panel',
|
||||
label: 'Download as CSV',
|
||||
icon: <CloudDownload size={14} />,
|
||||
onClick: (): void => notImplementedYet('Download'),
|
||||
});
|
||||
}
|
||||
if (isEditable && kindActions?.createAlert) {
|
||||
dataGroup.push({
|
||||
key: 'create-alert',
|
||||
label: 'Create Alerts',
|
||||
icon: <Bell size={14} />,
|
||||
onClick: (): void => notImplementedYet('Create Alerts'),
|
||||
});
|
||||
}
|
||||
|
||||
// Group 3 — layout: Move to section.
|
||||
const moveGroup: MenuItem[] = [];
|
||||
if (canMove && panelActions) {
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== panelActions.currentLayoutIndex,
|
||||
);
|
||||
moveGroup.push({
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
...(targets.length === 0
|
||||
? { disabled: true }
|
||||
: {
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: panelActions.currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Group 4 — danger: Delete.
|
||||
const deleteGroup: MenuItem[] =
|
||||
canDelete && panelActions
|
||||
? [
|
||||
{
|
||||
key: 'delete-panel',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: 'Delete panel',
|
||||
onClick: (): void => requestDelete(),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return [panelGroup, dataGroup, moveGroup, deleteGroup]
|
||||
.filter((group) => group.length > 0)
|
||||
.flatMap((group, index) =>
|
||||
index === 0 ? group : [{ type: 'divider' as const }, ...group],
|
||||
);
|
||||
}, [
|
||||
isEditable,
|
||||
canEditWidget,
|
||||
canMove,
|
||||
canDelete,
|
||||
kindActions,
|
||||
panelActions,
|
||||
sections,
|
||||
panelId,
|
||||
openPanelEditor,
|
||||
movePanel,
|
||||
clonePanel,
|
||||
requestDelete,
|
||||
]);
|
||||
|
||||
return { items, deleteConfirm };
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type {
|
||||
PanelPagination,
|
||||
PanelQueryData,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import styles from './PanelBody.module.scss';
|
||||
|
||||
@@ -22,6 +25,10 @@ interface PanelBodyProps {
|
||||
refetch: () => void;
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
dashboardPreference: DashboardPreference;
|
||||
/** Header search term — only consumed by kinds that declare header search. */
|
||||
searchTerm?: string;
|
||||
/** Server-side paging handles — only consumed by raw/list renderers. */
|
||||
pagination?: PanelPagination;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +51,8 @@ function PanelBody({
|
||||
refetch,
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
searchTerm,
|
||||
pagination,
|
||||
}: PanelBodyProps): JSX.Element {
|
||||
// Surface a hard failure only when there's no (stale) data to show; otherwise
|
||||
// keep the last-good chart and let the header indicate the refresh.
|
||||
@@ -67,7 +76,7 @@ function PanelBody({
|
||||
|
||||
// First load only — background refetches keep the response populated so the
|
||||
// chart stays mounted instead of blinking.
|
||||
if (isLoading && !hasData) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.body} data-testid="panel-loading">
|
||||
<Spin indicator={<Loader size={14} className="animate-spin" />} />
|
||||
@@ -87,6 +96,8 @@ function PanelBody({
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
enableDrillDown={false}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchTerm}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,3 +39,17 @@
|
||||
color: var(--l2-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Per-panel time-preference pill (e.g. `6h`), shown when the panel overrides
|
||||
// the dashboard time window.
|
||||
.timePill {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--l3-foreground);
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l3-border);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -1,39 +1,58 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Loader } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import type { Warning } from 'types/api';
|
||||
import type { PanelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import PanelActionsMenu from '../PanelActionsMenu/PanelActionsMenu';
|
||||
import PanelHeaderSearch from './PanelHeaderSearch';
|
||||
import PanelStatusPopover from '../PanelStatus/PanelStatusPopover';
|
||||
import {
|
||||
panelStatusFromError,
|
||||
panelStatusFromWarning,
|
||||
} from '../PanelStatus/utils';
|
||||
import styles from './PanelHeader.module.scss';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
interface PanelHeaderProps {
|
||||
title: ReactNode;
|
||||
panelId: string;
|
||||
/** Full plugin kind — drives kind-gated menu actions; */
|
||||
panelKind: PanelKind;
|
||||
/** Background refresh in flight — shows a subtle spinner without blinking the chart. */
|
||||
isFetching: boolean;
|
||||
/** Latest query error, if any — surfaced as a header error indicator. */
|
||||
error?: Error | null;
|
||||
/** Non-fatal query warning lifted from the response payload. */
|
||||
warning?: Warning;
|
||||
/** Move/delete actions — present only in editable sectioned mode. */
|
||||
warning?: WarningDTO;
|
||||
/** Per-panel relative time-preference label; null when it follows the dashboard window. */
|
||||
timeLabel?: PanelTimePreferenceLabel | null;
|
||||
/** Layout context for move/delete — absent outside editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
/** Kind declares header search (`headerControls.search`) — renders the box. */
|
||||
searchable?: boolean;
|
||||
/** Current search term; the shell owns it, the renderer applies the filter. */
|
||||
searchTerm?: string;
|
||||
/** Pushes a new search term up to the shell. */
|
||||
onSearchChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
|
||||
function PanelHeader({
|
||||
title,
|
||||
panelId,
|
||||
panelKind,
|
||||
isFetching,
|
||||
error,
|
||||
warning,
|
||||
timeLabel,
|
||||
panelActions,
|
||||
searchable,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
}: PanelHeaderProps): JSX.Element {
|
||||
const errorDetail = useMemo(() => panelStatusFromError(error), [error]);
|
||||
|
||||
@@ -57,19 +76,26 @@ function PanelHeader({
|
||||
{/* `panel-no-drag` opts this region out of the grid drag handle so the
|
||||
actions menu is clickable instead of starting a panel drag. */}
|
||||
<div className={cx('panel-no-drag', styles.actions)}>
|
||||
{searchable && onSearchChange && (
|
||||
<PanelHeaderSearch value={searchTerm ?? ''} onChange={onSearchChange} />
|
||||
)}
|
||||
{timeLabel && (
|
||||
<TooltipSimple title={timeLabel.full} arrow>
|
||||
<span className={styles.timePill} data-testid="panel-time-preference">
|
||||
{timeLabel.short}
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
{errorDetail && <PanelStatusPopover variant="error" detail={errorDetail} />}
|
||||
{warningDetail && (
|
||||
<PanelStatusPopover variant="warning" detail={warningDetail} />
|
||||
)}
|
||||
{panelActions && (
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
currentLayoutIndex={panelActions.currentLayoutIndex}
|
||||
sections={panelActions.sections}
|
||||
onMovePanel={panelActions.onMovePanel}
|
||||
onDeletePanel={panelActions.onDeletePanel}
|
||||
/>
|
||||
)}
|
||||
{/* Renders nothing when no action survives its gates (kind/role/context). */}
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
panelKind={panelKind}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Expanded state: a compact input that fits the header row.
|
||||
.input {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.clear {
|
||||
--button-height: 18px;
|
||||
--button-padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Search, X } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './PanelHeaderSearch.module.scss';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
interface PanelHeaderSearchProps {
|
||||
/** Current filter term, owned by the panel shell. */
|
||||
value: string;
|
||||
/** Pushes the new term up; the renderer applies the filter. */
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible header search (V1 parity): a search icon that expands into an
|
||||
* input on click and collapses again once it's empty and blurred. Purely a
|
||||
* controlled input over `value` — it owns only its expanded/collapsed chrome,
|
||||
* never the term itself.
|
||||
*/
|
||||
function PanelHeaderSearch({
|
||||
value,
|
||||
onChange,
|
||||
}: PanelHeaderSearchProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const collapseIfEmpty = (): void => {
|
||||
if (!value) {
|
||||
setExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clear = (): void => {
|
||||
onChange('');
|
||||
setExpanded(false);
|
||||
};
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<TooltipSimple title="Search" arrow>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => setExpanded(true)}
|
||||
data-testid="panel-header-search-trigger"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
autoFocus
|
||||
size={14}
|
||||
value={value}
|
||||
placeholder="Search…"
|
||||
containerClassName={styles.input}
|
||||
testId="panel-header-search-input"
|
||||
prefix={<Search size={14} />}
|
||||
suffix={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.clear}
|
||||
onClick={clear}
|
||||
data-testid="panel-header-search-clear"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
onChange(e.target.value)
|
||||
}
|
||||
onBlur={collapseIfEmpty}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Escape') {
|
||||
clear();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelHeaderSearch;
|
||||
@@ -20,7 +20,7 @@ function PanelStatusContent({ detail }: PanelStatusContentProps): JSX.Element {
|
||||
<section className={styles.content} data-testid="panel-status-content">
|
||||
<header className={styles.summary}>
|
||||
<div className={styles.summaryText}>
|
||||
<h2 className={styles.code}>{code}</h2>
|
||||
{code && <h2 className={styles.code}>{code}</h2>}
|
||||
<p className={styles.message}>{message}</p>
|
||||
</div>
|
||||
{docsUrl && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import type { Warning } from 'types/api';
|
||||
|
||||
import { panelStatusFromError, panelStatusFromWarning } from '../utils';
|
||||
|
||||
@@ -61,16 +61,14 @@ describe('panelStatusFromWarning', () => {
|
||||
expect(panelStatusFromWarning(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps a warning to the normalized status shape', () => {
|
||||
const warning: Warning = {
|
||||
code: 'partial_data',
|
||||
it('maps a warning to the normalized status shape (no code — V5 warnings carry none)', () => {
|
||||
const warning: WarningDTO = {
|
||||
message: 'Some series were dropped',
|
||||
url: 'https://docs/warn',
|
||||
warnings: [{ message: 'series A truncated' }],
|
||||
};
|
||||
|
||||
expect(panelStatusFromWarning(warning)).toStrictEqual({
|
||||
code: 'partial_data',
|
||||
message: 'Some series were dropped',
|
||||
docsUrl: 'https://docs/warn',
|
||||
messages: ['series A truncated'],
|
||||
|
||||
@@ -8,8 +8,8 @@ export type PanelStatusVariant = 'error' | 'warning';
|
||||
* per-item messages).
|
||||
*/
|
||||
export interface PanelStatusDetail {
|
||||
/** Short status code (e.g. an error/warning code) shown as the heading. */
|
||||
code: string;
|
||||
/** Short status code (e.g. an error/warning code) shown as the heading. Only present in error cases. */
|
||||
code?: string;
|
||||
/** Human-readable summary line. */
|
||||
message: string;
|
||||
/** Optional docs link; renders an "Open Docs" action when present. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { Warning } from 'types/api';
|
||||
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelStatusDetail } from './types';
|
||||
|
||||
@@ -41,16 +41,17 @@ export function panelStatusFromError(
|
||||
|
||||
/** Adapts a query warning into the normalized status shape. */
|
||||
export function panelStatusFromWarning(
|
||||
warning: Warning | null | undefined,
|
||||
warning: WarningDTO | undefined,
|
||||
): PanelStatusDetail | null {
|
||||
if (!warning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
code: warning.code,
|
||||
message: warning.message,
|
||||
message: warning.message || 'Warning',
|
||||
docsUrl: warning.url || undefined,
|
||||
messages: (warning.warnings ?? []).map((w) => w.message),
|
||||
messages: (warning.warnings ?? [])
|
||||
.map((w) => w.message)
|
||||
.filter((message): message is string => Boolean(message)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { Warning } from 'types/api';
|
||||
|
||||
import PanelHeader from '../PanelHeader/PanelHeader';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
// PanelHeader's status indicators render a radix tooltip, which needs a
|
||||
// TooltipProvider ancestor (supplied globally by AppLayout at runtime).
|
||||
const renderWithProvider = (ui: ReactElement): ReturnType<typeof render> =>
|
||||
render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
|
||||
// The actions menu has its own gating logic (kind/role/context) and its own
|
||||
// tests; stub it so this test exercises only the header's status indicators.
|
||||
jest.mock(
|
||||
'../PanelActionsMenu/PanelActionsMenu',
|
||||
() =>
|
||||
function MockPanelActionsMenu(): null {
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
const baseProps = {
|
||||
title: 'My panel',
|
||||
kind: 'TimeSeries',
|
||||
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
|
||||
panelId: 'panel-1',
|
||||
isFetching: false,
|
||||
};
|
||||
@@ -41,3 +52,65 @@ describe('PanelHeader status indicators', () => {
|
||||
expect(screen.queryByTestId('panel-status-warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PanelHeader search', () => {
|
||||
it('renders no search affordance when the panel is not searchable', () => {
|
||||
renderWithProvider(<PanelHeader {...baseProps} />);
|
||||
expect(
|
||||
screen.queryByTestId('panel-header-search-trigger'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands the collapsed trigger into an input and reports changes', () => {
|
||||
const onSearchChange = jest.fn();
|
||||
renderWithProvider(
|
||||
<PanelHeader
|
||||
{...baseProps}
|
||||
searchable
|
||||
searchTerm=""
|
||||
onSearchChange={onSearchChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-header-search-trigger'));
|
||||
|
||||
const input = screen.getByTestId('panel-header-search-input');
|
||||
fireEvent.change(input, { target: { value: 'frontend' } });
|
||||
expect(onSearchChange).toHaveBeenCalledWith('frontend');
|
||||
});
|
||||
|
||||
it('clears the term and collapses when the clear button is pressed', () => {
|
||||
const onSearchChange = jest.fn();
|
||||
renderWithProvider(
|
||||
<PanelHeader
|
||||
{...baseProps}
|
||||
searchable
|
||||
searchTerm="frontend"
|
||||
onSearchChange={onSearchChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-header-search-trigger'));
|
||||
fireEvent.click(screen.getByTestId('panel-header-search-clear'));
|
||||
|
||||
expect(onSearchChange).toHaveBeenCalledWith('');
|
||||
expect(screen.getByTestId('panel-header-search-trigger')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PanelHeader time-preference pill', () => {
|
||||
it('shows the pill with the short label when the panel overrides the dashboard time', () => {
|
||||
renderWithProvider(
|
||||
<PanelHeader
|
||||
{...baseProps}
|
||||
timeLabel={{ short: '6h', full: 'Last 6 hr' }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('panel-time-preference')).toHaveTextContent('6h');
|
||||
});
|
||||
|
||||
it('renders no pill when the panel follows the dashboard time', () => {
|
||||
renderWithProvider(<PanelHeader {...baseProps} timeLabel={null} />);
|
||||
expect(screen.queryByTestId('panel-time-preference')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { useClonePanel } from '../useClonePanel';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
patchDashboardV2: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const mockToastPromise = jest.fn();
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { promise: (...args: unknown[]): unknown => mockToastPromise(...args) },
|
||||
}));
|
||||
|
||||
jest.mock('uuid', () => ({ v4: (): string => 'cloned-id' }));
|
||||
|
||||
const mockPatch = patchDashboardV2 as unknown as jest.Mock;
|
||||
|
||||
const sourcePanel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardSection['items'][number]['panel'];
|
||||
|
||||
function sections(): DashboardSection[] {
|
||||
return [
|
||||
{
|
||||
id: 'section-0',
|
||||
layoutIndex: 0,
|
||||
title: 'Overview',
|
||||
repeatVariable: undefined,
|
||||
items: [
|
||||
{ id: 'p1', x: 0, y: 0, width: 8, height: 5, panel: sourcePanel },
|
||||
{ id: 'p2', x: 8, y: 0, width: 4, height: 5, panel: sourcePanel },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe('useClonePanel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useDashboardStore.setState({ dashboardId: 'dash-1', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('patches an add of the deep-copied spec + a new item under the same section', async () => {
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith({ id: 'dash-1' }, [
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/panels/cloned-id',
|
||||
value: sourcePanel,
|
||||
},
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/layouts/0/spec/items/-',
|
||||
value: {
|
||||
// Same dimensions as the source panel (p1: 8x5).
|
||||
x: 0,
|
||||
// Bottom of the section: max(y + height) over existing items = 5.
|
||||
y: 5,
|
||||
width: 8,
|
||||
height: 5,
|
||||
content: { $ref: '#/spec/panels/cloned-id' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('deep-copies the spec — the cloned value is not the same object reference', async () => {
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
const ops = mockPatch.mock.calls[0][1];
|
||||
expect(ops[0].value).toStrictEqual(sourcePanel);
|
||||
expect(ops[0].value).not.toBe(sourcePanel);
|
||||
});
|
||||
|
||||
it('no-ops when the panel is not found in the section', async () => {
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
await result.current({ panelId: 'missing', layoutIndex: 0 });
|
||||
|
||||
expect(mockPatch).not.toHaveBeenCalled();
|
||||
expect(mockToastPromise).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports in-flight → done/failed state via toast.promise', async () => {
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
expect(mockToastPromise).toHaveBeenCalledWith(
|
||||
expect.any(Promise),
|
||||
expect.objectContaining({
|
||||
loading: 'Cloning panel…',
|
||||
success: 'Panel cloned',
|
||||
error: 'Failed to clone panel',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows a patch rejection (toast owns the error UX) — does not throw', async () => {
|
||||
mockPatch.mockRejectedValueOnce(new Error('boom'));
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
await expect(
|
||||
result.current({ panelId: 'p1', layoutIndex: 0 }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockToastPromise).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
|
||||
import { addPanelToSectionOps, panelRef } from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSection[];
|
||||
}
|
||||
|
||||
export interface ClonePanelArgs {
|
||||
panelId: string;
|
||||
layoutIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates a panel: deep-copies the source panel's spec under a fresh id and
|
||||
* drops a new grid item — same dimensions as the source — at the bottom of the
|
||||
* same section, as one atomic patch. Mirrors V1's clone (verbatim spec copy, no
|
||||
* rename) and reuses the same add-panel op builder as useAddPanelToSection.
|
||||
*/
|
||||
export function useClonePanel({
|
||||
sections,
|
||||
}: Params): (args: ClonePanelArgs) => Promise<void> {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
|
||||
return useCallback(
|
||||
async ({ panelId, layoutIndex }: ClonePanelArgs): Promise<void> => {
|
||||
const section = sections.find((s) => s.layoutIndex === layoutIndex);
|
||||
const source = section?.items.find((i) => i.id === panelId);
|
||||
if (!dashboardId || !section || !source?.panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newPanelId = uuid();
|
||||
// Place at a fresh row at the bottom of the same section.
|
||||
const nextY = section.items.reduce(
|
||||
(max, i) => Math.max(max, i.y + i.height),
|
||||
0,
|
||||
);
|
||||
|
||||
const clone = patchDashboardV2(
|
||||
{ id: dashboardId },
|
||||
addPanelToSectionOps({
|
||||
panelId: newPanelId,
|
||||
panel: cloneDeep(source.panel),
|
||||
layoutIndex,
|
||||
item: {
|
||||
x: 0,
|
||||
y: nextY,
|
||||
width: source.width,
|
||||
height: source.height,
|
||||
content: { $ref: panelRef(newPanelId) },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Surface in-flight → done/failed state to the user (toast.promise also
|
||||
// reports the failure, so no separate error modal is needed here).
|
||||
toast.promise(clone, {
|
||||
loading: 'Cloning panel…',
|
||||
success: 'Panel cloned',
|
||||
error: 'Failed to clone panel',
|
||||
position: 'top-center',
|
||||
});
|
||||
|
||||
// Refetch only on success; the rejection is already surfaced by the
|
||||
// toast, so swallow it to avoid an unhandled rejection.
|
||||
try {
|
||||
await clone;
|
||||
refetch();
|
||||
} catch {
|
||||
// no-op — toast.promise owns the error UX.
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, refetch],
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/pan
|
||||
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection';
|
||||
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
|
||||
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
|
||||
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { useDeleteSection } from '../hooks/useDeleteSection';
|
||||
@@ -26,10 +24,8 @@ interface SectionProps {
|
||||
section: DashboardSection;
|
||||
/** Adds a panel to this section; present only in editable sectioned mode. */
|
||||
onAddPanel?: (args: AddPanelArgs) => void;
|
||||
/** All sections + per-panel handlers, for the panel "Move to section" / delete actions. */
|
||||
/** All sections — layout context for the panel menu's move/delete actions. */
|
||||
sections?: DashboardSection[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
|
||||
dragHandle?: SectionDragHandle;
|
||||
}
|
||||
@@ -38,8 +34,6 @@ function Section({
|
||||
section,
|
||||
onAddPanel,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
dragHandle,
|
||||
}: SectionProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
@@ -92,8 +86,6 @@ function Section({
|
||||
layoutIndex={section.layoutIndex}
|
||||
isVisible={isVisible}
|
||||
sections={sections}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useMemo } from 'react';
|
||||
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
|
||||
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
|
||||
import Panel from '../../Panel/Panel';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { usePersistLayout } from '../hooks/usePersistLayout';
|
||||
@@ -16,10 +14,8 @@ interface SectionGridProps {
|
||||
layoutIndex: number;
|
||||
/** Forwarded to panels — true when the parent section is in the viewport. */
|
||||
isVisible?: boolean;
|
||||
/** All sections + handlers — present only in editable sectioned mode (panel "Move to section" / delete). */
|
||||
/** All sections — layout context for the panel menu's move/delete actions. */
|
||||
sections?: DashboardSection[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function SectionGrid({
|
||||
@@ -27,8 +23,6 @@ function SectionGrid({
|
||||
layoutIndex,
|
||||
isVisible,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: SectionGridProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const rglLayout = useMemo<Layout[]>(
|
||||
@@ -62,6 +56,9 @@ function SectionGrid({
|
||||
margin={[8, 8]}
|
||||
>
|
||||
{items.map((item) => (
|
||||
// A layout item can reference a panel id that no longer exists in the
|
||||
// panels map (orphan); render an empty grid cell for it rather than a
|
||||
// panel with no content.
|
||||
<div key={item.id}>
|
||||
{item.panel && (
|
||||
<Panel
|
||||
@@ -69,12 +66,10 @@ function SectionGrid({
|
||||
panelId={item.id}
|
||||
isVisible={isVisible}
|
||||
panelActions={
|
||||
isEditable && onMovePanel && onDeletePanel
|
||||
isEditable
|
||||
? {
|
||||
currentLayoutIndex: layoutIndex,
|
||||
sections: sections ?? [],
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.sche
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import { useAddPanelToSection } from '../Panel/hooks/useAddPanelToSection';
|
||||
import { useDeletePanel } from '../Panel/hooks/useDeletePanel';
|
||||
import { useMovePanelToSection } from '../Panel/hooks/useMovePanelToSection';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
|
||||
import Section from './Section/Section';
|
||||
@@ -38,8 +36,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
} = useSectionDragReorder({ sections, layouts });
|
||||
|
||||
const onAddPanel = useAddPanelToSection({ sections });
|
||||
const onMovePanel = useMovePanelToSection({ sections });
|
||||
const onDeletePanel = useDeletePanel({ sections });
|
||||
|
||||
// Only titled sections participate in reordering; untitled (free-flow)
|
||||
// blocks render in place without a drag handle.
|
||||
@@ -75,8 +71,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
section={section}
|
||||
sections={sections}
|
||||
onAddPanel={onAddPanel}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
) : (
|
||||
<Section
|
||||
@@ -84,8 +78,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
section={section}
|
||||
sections={sections}
|
||||
onAddPanel={onAddPanel}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -3,24 +3,18 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import type { AddPanelArgs } from '../Panel/hooks/useAddPanelToSection';
|
||||
import type { DeletePanelArgs } from '../Panel/hooks/useDeletePanel';
|
||||
import type { MovePanelArgs } from '../Panel/hooks/useMovePanelToSection';
|
||||
import Section from './Section/Section';
|
||||
|
||||
interface SortableSectionProps {
|
||||
section: DashboardSection;
|
||||
sections: DashboardSection[];
|
||||
onAddPanel: (args: AddPanelArgs) => void;
|
||||
onMovePanel: (args: MovePanelArgs) => void;
|
||||
onDeletePanel: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function SortableSection({
|
||||
section,
|
||||
sections,
|
||||
onAddPanel,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: SortableSectionProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
@@ -48,8 +42,6 @@ function SortableSection({
|
||||
section={section}
|
||||
sections={sections}
|
||||
onAddPanel={onAddPanel}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -48,8 +48,11 @@ function PanelsAndSectionsLayout({
|
||||
return <SectionList sections={sections} layouts={layouts} />;
|
||||
}
|
||||
|
||||
// Free-flow (no titled sections): panels still get the layout context so
|
||||
// the menu's delete action can patch the section's items (previously a
|
||||
// silent noop in this mode).
|
||||
return sections.map((section) => (
|
||||
<Section key={section.id} section={section} />
|
||||
<Section key={section.id} section={section} sections={sections} />
|
||||
));
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { resolvePanelTimeWindow } from '../resolvePanelTimeWindow';
|
||||
|
||||
const GLOBAL_START = 1_000_000;
|
||||
const GLOBAL_END = 5_000_000;
|
||||
|
||||
describe('resolvePanelTimeWindow', () => {
|
||||
it('uses the dashboard window when there is no preference', () => {
|
||||
expect(
|
||||
resolvePanelTimeWindow({
|
||||
timePreference: undefined,
|
||||
globalStartMs: GLOBAL_START,
|
||||
globalEndMs: GLOBAL_END,
|
||||
}),
|
||||
).toStrictEqual({ startMs: GLOBAL_START, endMs: GLOBAL_END });
|
||||
});
|
||||
|
||||
it('uses the dashboard window for global_time', () => {
|
||||
expect(
|
||||
resolvePanelTimeWindow({
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
globalStartMs: GLOBAL_START,
|
||||
globalEndMs: GLOBAL_END,
|
||||
}),
|
||||
).toStrictEqual({ startMs: GLOBAL_START, endMs: GLOBAL_END });
|
||||
});
|
||||
|
||||
it('anchors a relative preset to the dashboard end', () => {
|
||||
expect(
|
||||
resolvePanelTimeWindow({
|
||||
timePreference: DashboardtypesTimePreferenceDTO.last_5_min,
|
||||
globalStartMs: GLOBAL_START,
|
||||
globalEndMs: GLOBAL_END,
|
||||
}),
|
||||
).toStrictEqual({ startMs: GLOBAL_END - 5 * 60 * 1000, endMs: GLOBAL_END });
|
||||
});
|
||||
|
||||
it('resolves the larger presets to the V1-equivalent spans', () => {
|
||||
const cases: [DashboardtypesTimePreferenceDTO, number][] = [
|
||||
[DashboardtypesTimePreferenceDTO.last_1_hr, 60],
|
||||
[DashboardtypesTimePreferenceDTO.last_1_day, 24 * 60],
|
||||
[DashboardtypesTimePreferenceDTO.last_1_week, 7 * 24 * 60],
|
||||
[DashboardtypesTimePreferenceDTO.last_1_month, 30 * 24 * 60],
|
||||
];
|
||||
cases.forEach(([pref, minutes]) => {
|
||||
expect(
|
||||
resolvePanelTimeWindow({
|
||||
timePreference: pref,
|
||||
globalStartMs: GLOBAL_START,
|
||||
globalEndMs: GLOBAL_END,
|
||||
}),
|
||||
).toStrictEqual({
|
||||
startMs: GLOBAL_END - minutes * 60 * 1000,
|
||||
endMs: GLOBAL_END,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('lets an explicit override win over the preference', () => {
|
||||
expect(
|
||||
resolvePanelTimeWindow({
|
||||
timePreference: DashboardtypesTimePreferenceDTO.last_5_min,
|
||||
globalStartMs: GLOBAL_START,
|
||||
globalEndMs: GLOBAL_END,
|
||||
override: { startMs: 42, endMs: 99 },
|
||||
}),
|
||||
).toStrictEqual({ startMs: 42, endMs: 99 });
|
||||
});
|
||||
|
||||
it('floors fractional milliseconds', () => {
|
||||
expect(
|
||||
resolvePanelTimeWindow({
|
||||
timePreference: undefined,
|
||||
globalStartMs: 1.9,
|
||||
globalEndMs: 9.9,
|
||||
}),
|
||||
).toStrictEqual({ startMs: 1, endMs: 9 });
|
||||
|
||||
expect(
|
||||
resolvePanelTimeWindow({
|
||||
timePreference: DashboardtypesTimePreferenceDTO.last_5_min,
|
||||
globalStartMs: 0,
|
||||
globalEndMs: 9.9,
|
||||
override: { startMs: 4.7, endMs: 8.2 },
|
||||
}),
|
||||
).toStrictEqual({ startMs: 4, endMs: 8 });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelQuery } from '../usePanelQuery';
|
||||
@@ -10,6 +10,14 @@ jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
// usePanelQuery reads the query client only to cancel in-flight fetches; the
|
||||
// fetch hook itself is mocked, so a stub client is enough.
|
||||
jest.mock('react-query', () => ({
|
||||
useQueryClient: (): { cancelQueries: jest.Mock } => ({
|
||||
cancelQueries: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../useGetQueryRangeV5', () => ({
|
||||
useGetQueryRangeV5: jest.fn(),
|
||||
}));
|
||||
@@ -164,7 +172,9 @@ describe('usePanelQuery', () => {
|
||||
expect(result.current.error?.message).toBe('boom');
|
||||
});
|
||||
|
||||
it('combines isLoading and isFetching into a single isLoading flag', () => {
|
||||
it('exposes isLoading (first fetch) and isFetching (any fetch) as distinct flags', () => {
|
||||
// A background refetch (data present elsewhere) is in flight: isFetching is
|
||||
// true but isLoading stays false so the panel keeps showing its data.
|
||||
mockUseGetQueryRangeV5.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
@@ -174,6 +184,20 @@ describe('usePanelQuery', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isFetching).toBe(true);
|
||||
});
|
||||
|
||||
it('reports isLoading on the first fetch (no cached data yet)', () => {
|
||||
mockUseGetQueryRangeV5.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
@@ -237,10 +261,93 @@ describe('usePanelQuery', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the time override (not redux) for the request window and cache key', () => {
|
||||
const panel = builderPanel();
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel,
|
||||
panelId: 'p1',
|
||||
time: { startMs: 1_700_000_000_000, endMs: 1_700_000_600_000 },
|
||||
}),
|
||||
);
|
||||
const [{ requestPayload, queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
// Window comes from the override, not the redux nanosecond time.
|
||||
expect(requestPayload.start).toBe(1_700_000_000_000);
|
||||
expect(requestPayload.end).toBe(1_700_000_600_000);
|
||||
// Cache key keys off the override so the preview refetches independently
|
||||
// of the dashboard and never collides with its redux-keyed entry.
|
||||
expect(queryKey).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
'p1',
|
||||
'override-1700000000000-1700000600000',
|
||||
'signoz/TimeSeriesPanel',
|
||||
panel.spec?.queries,
|
||||
]),
|
||||
);
|
||||
expect(queryKey).not.toContain(DEFAULT_GLOBAL_TIME.minTime);
|
||||
});
|
||||
|
||||
it('floors fractional override ms — V1 time helpers emit floats but start/end are int64', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
time: { startMs: 1_700_000_000_000.546, endMs: 1_700_000_600_000.999 },
|
||||
}),
|
||||
);
|
||||
const [{ requestPayload, queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(requestPayload.start).toBe(1_700_000_000_000);
|
||||
expect(requestPayload.end).toBe(1_700_000_600_000);
|
||||
// The cache key carries the floored values so it matches the request.
|
||||
expect(queryKey).toStrictEqual(
|
||||
expect.arrayContaining(['override-1700000000000-1700000600000']),
|
||||
);
|
||||
});
|
||||
|
||||
it('builds an empty composite and disables the fetch when panel is undefined (no crash)', () => {
|
||||
renderHook(() => usePanelQuery({ panel: undefined, panelId: 'p-none' }));
|
||||
const [{ requestPayload, enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(requestPayload.compositeQuery.queries).toStrictEqual([]);
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
|
||||
describe('list pagination', () => {
|
||||
const listPanel = (
|
||||
querySpec: Record<string, unknown>,
|
||||
): DashboardtypesPanelDTO =>
|
||||
panelWith('signoz/ListPanel', { name: 'A', signal: 'logs', ...querySpec });
|
||||
|
||||
it('exposes server paging at the default page size when the query has no limit', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.pagination).toBeDefined();
|
||||
expect(result.current.pagination?.pageSize).toBe(25);
|
||||
expect(result.current.pagination?.pageSizeOptions).toStrictEqual([
|
||||
10, 25, 50, 100, 200,
|
||||
]);
|
||||
});
|
||||
|
||||
it('disables the server pager when the query has an explicit limit (V1 parity)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({ limit: 100 }), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.pagination).toBeUndefined();
|
||||
});
|
||||
|
||||
it('changes the page size (and re-requests with the new limit) via setPageSize', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
|
||||
act(() => result.current.pagination?.setPageSize(50));
|
||||
|
||||
expect(result.current.pagination?.pageSize).toBe(50);
|
||||
const lastCall = mockUseGetQueryRangeV5.mock.calls.at(-1) as [
|
||||
{ queryKey: unknown[] },
|
||||
];
|
||||
// Page size participates in the cache key so each size is its own entry.
|
||||
expect(lastCall[0].queryKey).toStrictEqual(expect.arrayContaining([50]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/** Absolute time window in epoch milliseconds — the V5 request's native unit. */
|
||||
export interface PanelTimeWindow {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Span of each relative time preference, in milliseconds. `global_time` is absent: it
|
||||
* means "follow the dashboard window" and is handled as the default branch below.
|
||||
* Mirrors V1's `getStartAndEndTime` preset durations (last_1_month = 30 days).
|
||||
*/
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
const PRESET_SPAN_MS: Partial<Record<DashboardtypesTimePreferenceDTO, number>> =
|
||||
{
|
||||
[DashboardtypesTimePreferenceDTO.last_5_min]: 5 * MINUTE_MS,
|
||||
[DashboardtypesTimePreferenceDTO.last_15_min]: 15 * MINUTE_MS,
|
||||
[DashboardtypesTimePreferenceDTO.last_30_min]: 30 * MINUTE_MS,
|
||||
[DashboardtypesTimePreferenceDTO.last_1_hr]: 60 * MINUTE_MS,
|
||||
[DashboardtypesTimePreferenceDTO.last_6_hr]: 6 * 60 * MINUTE_MS,
|
||||
[DashboardtypesTimePreferenceDTO.last_1_day]: 24 * 60 * MINUTE_MS,
|
||||
[DashboardtypesTimePreferenceDTO.last_3_days]: 3 * 24 * 60 * MINUTE_MS,
|
||||
[DashboardtypesTimePreferenceDTO.last_1_week]: 7 * 24 * 60 * MINUTE_MS,
|
||||
[DashboardtypesTimePreferenceDTO.last_1_month]: 30 * 24 * 60 * MINUTE_MS,
|
||||
};
|
||||
|
||||
/**
|
||||
* Short + full labels for each relative preference, for the panel header time
|
||||
* pill. `global_time` is absent — a panel that follows the dashboard window
|
||||
* shows no pill.
|
||||
*/
|
||||
const TIME_PREFERENCE_LABEL: Partial<
|
||||
Record<DashboardtypesTimePreferenceDTO, { short: string; full: string }>
|
||||
> = {
|
||||
[DashboardtypesTimePreferenceDTO.last_5_min]: {
|
||||
short: '5m',
|
||||
full: 'Last 5 min',
|
||||
},
|
||||
[DashboardtypesTimePreferenceDTO.last_15_min]: {
|
||||
short: '15m',
|
||||
full: 'Last 15 min',
|
||||
},
|
||||
[DashboardtypesTimePreferenceDTO.last_30_min]: {
|
||||
short: '30m',
|
||||
full: 'Last 30 min',
|
||||
},
|
||||
[DashboardtypesTimePreferenceDTO.last_1_hr]: {
|
||||
short: '1h',
|
||||
full: 'Last 1 hr',
|
||||
},
|
||||
[DashboardtypesTimePreferenceDTO.last_6_hr]: {
|
||||
short: '6h',
|
||||
full: 'Last 6 hr',
|
||||
},
|
||||
[DashboardtypesTimePreferenceDTO.last_1_day]: {
|
||||
short: '1d',
|
||||
full: 'Last 1 day',
|
||||
},
|
||||
[DashboardtypesTimePreferenceDTO.last_3_days]: {
|
||||
short: '3d',
|
||||
full: 'Last 3 days',
|
||||
},
|
||||
[DashboardtypesTimePreferenceDTO.last_1_week]: {
|
||||
short: '1w',
|
||||
full: 'Last 1 week',
|
||||
},
|
||||
[DashboardtypesTimePreferenceDTO.last_1_month]: {
|
||||
short: '1mo',
|
||||
full: 'Last 1 month',
|
||||
},
|
||||
};
|
||||
|
||||
export interface PanelTimePreferenceLabel {
|
||||
/** Compact pill label, e.g. `6h`. */
|
||||
short: string;
|
||||
/** Full label for the pill's tooltip, e.g. `Last 6 hr`. */
|
||||
full: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display labels for a panel's relative time preference, or `null` when the
|
||||
* panel follows the dashboard window (`global_time` / unset) and so needs no
|
||||
* pill.
|
||||
*/
|
||||
export function panelTimePreferenceLabel(
|
||||
timePreference: DashboardtypesTimePreferenceDTO | undefined,
|
||||
): PanelTimePreferenceLabel | null {
|
||||
if (
|
||||
!timePreference ||
|
||||
timePreference === DashboardtypesTimePreferenceDTO.global_time
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return TIME_PREFERENCE_LABEL[timePreference] ?? null;
|
||||
}
|
||||
|
||||
interface ResolvePanelTimeWindowArgs {
|
||||
/** The panel's saved per-panel time preference (`visualization.timePreference`). */
|
||||
timePreference: DashboardtypesTimePreferenceDTO | undefined;
|
||||
/** Dashboard global window (epoch ms) — used as-is for `global_time`. */
|
||||
globalStartMs: number;
|
||||
globalEndMs: number;
|
||||
/**
|
||||
* Explicit caller window (epoch ms), e.g. the editor preview. When present it wins
|
||||
* outright: the preview owns its own time selection and ignores the panel preference.
|
||||
*/
|
||||
override?: PanelTimeWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the absolute `[startMs, endMs]` window a panel should query over.
|
||||
*
|
||||
* Precedence: explicit `override` → relative `timePreference` preset → dashboard global
|
||||
* window. A relative preset is anchored to the dashboard's current end (`globalEndMs`)
|
||||
* rather than wall-clock `Date.now()`, so the window only changes when the dashboard
|
||||
* refreshes — this keeps it stable across renders (no react-query refetch loop) and in
|
||||
* step with the dashboard's refresh cadence. All values are floored: V5 start/end are
|
||||
* int64 on the wire and upstream ms can carry a fraction.
|
||||
*/
|
||||
export function resolvePanelTimeWindow({
|
||||
timePreference,
|
||||
globalStartMs,
|
||||
globalEndMs,
|
||||
override,
|
||||
}: ResolvePanelTimeWindowArgs): PanelTimeWindow {
|
||||
if (override) {
|
||||
return {
|
||||
startMs: Math.floor(override.startMs),
|
||||
endMs: Math.floor(override.endMs),
|
||||
};
|
||||
}
|
||||
|
||||
const endMs = Math.floor(globalEndMs);
|
||||
const spanMs =
|
||||
timePreference &&
|
||||
timePreference !== DashboardtypesTimePreferenceDTO.global_time
|
||||
? PRESET_SPAN_MS[timePreference]
|
||||
: undefined;
|
||||
|
||||
if (spanMs !== undefined) {
|
||||
return { startMs: endMs - spanMs, endMs };
|
||||
}
|
||||
|
||||
return { startMs: Math.floor(globalStartMs), endMs };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
/**
|
||||
* Returns a callback that opens the V2 panel editor for a panel by navigating
|
||||
* to its dedicated route (`/dashboard/:dashboardId/panel/:panelId`). The editor
|
||||
* is a full page (not a modal), mirroring the V1 widget-editor flow. The
|
||||
* dashboard id comes from the edit-context store (set by DashboardContainer),
|
||||
* so any entry point can open the editor with just the panel id.
|
||||
*/
|
||||
export function useOpenPanelEditor(): (panelId: string) => void {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
return useCallback(
|
||||
(panelId: string): void => {
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
|
||||
);
|
||||
},
|
||||
[safeNavigate, dashboardId],
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -12,10 +13,17 @@ import {
|
||||
extractLegendMap,
|
||||
hasRunnableQueries,
|
||||
} from '../queryV5/buildQueryRangeRequest';
|
||||
import type { PanelQueryData } from '../queryV5/types';
|
||||
import type { PanelPagination, PanelQueryData } from '../queryV5/types';
|
||||
import { getRawResults } from '../queryV5/v5ResponseData';
|
||||
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
|
||||
import { resolvePanelTimeWindow } from './resolvePanelTimeWindow';
|
||||
import { useGetQueryRangeV5 } from './useGetQueryRangeV5';
|
||||
|
||||
// V1 list page-size choices (PER_PAGE_OPTIONS); default mirrors V1's list views.
|
||||
const LIST_PAGE_SIZE_OPTIONS = [10, 25, 50, 100, 200];
|
||||
const DEFAULT_LIST_PAGE_SIZE = 25;
|
||||
|
||||
export interface UsePanelQueryArgs {
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
panelId: string;
|
||||
@@ -28,18 +36,48 @@ export interface UsePanelQueryArgs {
|
||||
* queries — callers don't need to compute that themselves.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Override the time window instead of reading global Redux time. Used by the
|
||||
* panel editor preview to stay isolated from the dashboard — changing the
|
||||
* preview time neither touches nor re-runs the dashboard behind the overlay.
|
||||
*/
|
||||
time?: PanelQueryTimeOverride;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor-local time window in epoch milliseconds (the V5 request native unit).
|
||||
* The caller resolves its selection — relative or custom — to an absolute
|
||||
* window so the fetch can ignore global Redux time entirely. Fractional ms are
|
||||
* floored before the request: the V1 time helpers some callers resolve through
|
||||
* (e.g. getStartEndRangeTime → getMicroSeconds) divide without truncating, and
|
||||
* the V5 start/end are int64 — a float breaks the API call.
|
||||
*/
|
||||
export interface PanelQueryTimeOverride {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
export interface UsePanelQueryResult {
|
||||
/** Raw V5 fetch result — response + the request that produced it. */
|
||||
data: PanelQueryData;
|
||||
/** Combines `isLoading` (first fetch) and `isFetching` (background refresh). */
|
||||
/**
|
||||
* First fetch only — a request is in flight and there's no cached data yet.
|
||||
* Drives the full-panel loader (nothing to show). A background refetch with
|
||||
* data already present does NOT set this — watch `isFetching` for that.
|
||||
*/
|
||||
isLoading: boolean;
|
||||
/** Background refresh in flight while data is already present. */
|
||||
/**
|
||||
* Any request in flight, including a background refetch while stale data is
|
||||
* still displayed. Drives a subtle "refreshing" affordance, never a blank panel.
|
||||
*/
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
/** Re-run the query (e.g. a retry button on the error state). */
|
||||
refetch: () => void;
|
||||
/** Abort the in-flight fetch (e.g. the editor's Stage & Run cancel). */
|
||||
cancelQuery: () => void;
|
||||
/** Server-side paging handles — present only for raw/list panels. */
|
||||
pagination?: PanelPagination;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,21 +104,68 @@ export function usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
enabled = true,
|
||||
time,
|
||||
}: UsePanelQueryArgs): UsePanelQueryResult {
|
||||
const fullKind = panel?.spec?.plugin?.kind;
|
||||
const panelType =
|
||||
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind]) ?? PANEL_TYPES.TIME_SERIES;
|
||||
const queries = panel?.spec?.queries;
|
||||
|
||||
// A list query with its own explicit `limit` caps results and shows them
|
||||
// without a server pager (V1 parity: Controls render is gated on no limit).
|
||||
// Without a limit, the list pages server-side at a user-selectable page size.
|
||||
const hasExplicitLimit = useMemo(
|
||||
() => !!getBuilderQueries(queries ?? [])[0]?.limit,
|
||||
[queries],
|
||||
);
|
||||
const isPaginated = panelType === PANEL_TYPES.LIST && !hasExplicitLimit;
|
||||
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_LIST_PAGE_SIZE);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
// Changing page size restarts paging from the first page.
|
||||
const handleSetPageSize = useCallback((size: number): void => {
|
||||
setPageSize(size);
|
||||
setOffset(0);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedInterval,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
// Redux global time is in nanoseconds; the V5 API takes epoch ms.
|
||||
const startMs = Math.floor(minTime / 1e6);
|
||||
const endMs = Math.floor(maxTime / 1e6);
|
||||
// `visualization` carries panel-level options but only on the variants that
|
||||
// declare it — read it via `in` narrowing over the generated union (no cast).
|
||||
// `timePreference` pins the panel to a fixed relative window (every visualization
|
||||
// variant has it); `fillSpans` backend-fills missing points with 0 and exists
|
||||
// only on TimeSeries/Bar (→ formatOptions.fillGaps).
|
||||
const pluginSpec = panel?.spec?.plugin?.spec;
|
||||
const visualization =
|
||||
pluginSpec && 'visualization' in pluginSpec
|
||||
? pluginSpec.visualization
|
||||
: undefined;
|
||||
const timePreference = visualization?.timePreference;
|
||||
const fillGaps = Boolean(
|
||||
visualization && 'fillSpans' in visualization && visualization.fillSpans,
|
||||
);
|
||||
|
||||
// Redux global time is in nanoseconds; the V5 API takes epoch ms. Precedence: an
|
||||
// editor time override (already in ms) wins so the preview stays independent of the
|
||||
// dashboard, then the panel's time preference, then the global window. See
|
||||
// resolvePanelTimeWindow for the flooring/anchoring rationale.
|
||||
const { startMs, endMs } = resolvePanelTimeWindow({
|
||||
timePreference,
|
||||
globalStartMs: minTime / 1e6,
|
||||
globalEndMs: maxTime / 1e6,
|
||||
override: time,
|
||||
});
|
||||
|
||||
// A new query or time window invalidates the current page — snap back to the
|
||||
// first page so we never request an offset past a now-shorter result set.
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
}, [queries, startMs, endMs]);
|
||||
|
||||
const requestPayload = useMemo(
|
||||
() =>
|
||||
@@ -89,41 +174,120 @@ export function usePanelQuery({
|
||||
panelType,
|
||||
startMs,
|
||||
endMs,
|
||||
fillGaps,
|
||||
pagination: isPaginated ? { offset, limit: pageSize } : undefined,
|
||||
}),
|
||||
[queries, panelType, startMs, endMs],
|
||||
[queries, panelType, startMs, endMs, fillGaps, isPaginated, offset, pageSize],
|
||||
);
|
||||
|
||||
const legendMap = useMemo(() => extractLegendMap(queries ?? []), [queries]);
|
||||
|
||||
const runnable = useMemo(() => hasRunnableQueries(queries ?? []), [queries]);
|
||||
|
||||
const response = useGetQueryRangeV5({
|
||||
requestPayload,
|
||||
queryKey: [
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE,
|
||||
panelId,
|
||||
// Dashboard keys off Redux min/max + interval; the editor passes an
|
||||
// explicit ms window. Keep each distinct so they refetch on their own
|
||||
// time without colliding in the react-query cache. The floored values
|
||||
// key the cache so it matches what was actually requested. The panel time
|
||||
// preference participates too: it changes the resolved window off the same
|
||||
// global min/max, so the key must distinguish it (else a preference switch
|
||||
// would read a stale cache entry).
|
||||
...(time
|
||||
? [`override-${startMs}-${endMs}`]
|
||||
: [minTime, maxTime, globalSelectedInterval, timePreference]),
|
||||
// fillGaps changes the request payload (formatOptions), so it must key the
|
||||
// cache too — otherwise toggling it would read a stale response.
|
||||
fillGaps,
|
||||
fullKind,
|
||||
queries,
|
||||
// Offset + page size key the cache so each page is its own entry (0/default
|
||||
// for non-paged kinds).
|
||||
offset,
|
||||
pageSize,
|
||||
],
|
||||
[
|
||||
panelId,
|
||||
time,
|
||||
startMs,
|
||||
endMs,
|
||||
minTime,
|
||||
maxTime,
|
||||
globalSelectedInterval,
|
||||
timePreference,
|
||||
fillGaps,
|
||||
fullKind,
|
||||
queries,
|
||||
offset,
|
||||
pageSize,
|
||||
],
|
||||
);
|
||||
|
||||
const response = useGetQueryRangeV5({
|
||||
requestPayload,
|
||||
queryKey,
|
||||
enabled: enabled && runnable,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const cancelQuery = useCallback((): void => {
|
||||
void queryClient.cancelQueries(queryKey);
|
||||
}, [queryClient, queryKey]);
|
||||
|
||||
const data = useMemo<PanelQueryData>(
|
||||
() => ({ response: response.data, requestPayload, legendMap }),
|
||||
[response.data, requestPayload, legendMap],
|
||||
);
|
||||
|
||||
const goPrev = useCallback(
|
||||
() => setOffset((current) => Math.max(0, current - pageSize)),
|
||||
[pageSize],
|
||||
);
|
||||
const goNext = useCallback(
|
||||
() => setOffset((current) => current + pageSize),
|
||||
[pageSize],
|
||||
);
|
||||
|
||||
// Paging handles for raw/list panels. `canNext` is a heuristic: a full page
|
||||
// or a response `nextCursor` implies more rows (no total count on the wire).
|
||||
const pagination = useMemo<PanelPagination | undefined>(() => {
|
||||
if (!isPaginated) {
|
||||
return undefined;
|
||||
}
|
||||
const result = getRawResults(response.data)[0];
|
||||
const rowCount = result?.rows?.length ?? 0;
|
||||
return {
|
||||
pageIndex: pageSize > 0 ? Math.floor(offset / pageSize) : 0,
|
||||
canPrev: offset > 0,
|
||||
canNext: !!result?.nextCursor || rowCount === pageSize,
|
||||
goPrev,
|
||||
goNext,
|
||||
pageSize,
|
||||
pageSizeOptions: LIST_PAGE_SIZE_OPTIONS,
|
||||
setPageSize: handleSetPageSize,
|
||||
};
|
||||
}, [
|
||||
isPaginated,
|
||||
response.data,
|
||||
offset,
|
||||
pageSize,
|
||||
goPrev,
|
||||
goNext,
|
||||
handleSetPageSize,
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading: response.isLoading || response.isFetching,
|
||||
isLoading: response.isLoading,
|
||||
isFetching: response.isFetching,
|
||||
// Coerce undefined → null so the contract is `Error | null`, not
|
||||
// `Error | null | undefined`. Consumers can rely on a single
|
||||
// "no error" sentinel.
|
||||
error: (response.error as Error | null) ?? null,
|
||||
error: response.error ?? null,
|
||||
refetch: response.refetch,
|
||||
cancelQuery,
|
||||
pagination,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,6 +164,33 @@ describe('buildQueryRangeRequest', () => {
|
||||
expect(request.formatOptions?.formatTableResultForUI).toBe(true);
|
||||
});
|
||||
|
||||
it('passes through fillGaps into formatOptions', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A' }),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
fillGaps: true,
|
||||
});
|
||||
expect(request.formatOptions?.fillGaps).toBe(true);
|
||||
});
|
||||
|
||||
it('stamps offset/limit onto builder queries when pagination is given', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', signal: 'logs' }),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
pagination: { offset: 100, limit: 50 },
|
||||
});
|
||||
expect(request.compositeQuery?.queries?.[0]?.spec).toStrictEqual({
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
offset: 100,
|
||||
limit: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('injects the range-derived stepInterval into BAR builder queries without one', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { fromPerses, toPerses } from '../persesQueryAdapters';
|
||||
|
||||
/** A bare perses query (single plugin, not wrapped in a CompositeQuery). */
|
||||
function bareQuery(
|
||||
pluginKind: string,
|
||||
spec: Record<string, unknown>,
|
||||
): DashboardtypesQueryDTO {
|
||||
return {
|
||||
kind: 'scalar',
|
||||
spec: { plugin: { kind: pluginKind, spec } },
|
||||
} as unknown as DashboardtypesQueryDTO;
|
||||
}
|
||||
|
||||
describe('persesQueryAdapters', () => {
|
||||
describe('fromPerses', () => {
|
||||
it('returns a fresh metrics builder query for an empty panel', () => {
|
||||
const query = fromPerses([], PANEL_TYPES.TIME_SERIES);
|
||||
expect(query.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(query.builder.queryData.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns the metrics default when queries is empty', () => {
|
||||
expect(fromPerses([], PANEL_TYPES.TIME_SERIES)).toStrictEqual(
|
||||
initialQueriesMap[DataSource.METRICS],
|
||||
);
|
||||
});
|
||||
|
||||
it('derives the PromQL query type from a promql plugin', () => {
|
||||
const queries = [
|
||||
bareQuery('signoz/PromQLQuery', {
|
||||
name: 'A',
|
||||
query: 'up',
|
||||
disabled: false,
|
||||
}),
|
||||
];
|
||||
expect(fromPerses(queries, PANEL_TYPES.TIME_SERIES).queryType).toBe(
|
||||
EQueryType.PROM,
|
||||
);
|
||||
});
|
||||
|
||||
it('derives the ClickHouse query type from a clickhouse plugin', () => {
|
||||
const queries = [
|
||||
bareQuery('signoz/ClickHouseSQL', {
|
||||
name: 'A',
|
||||
query: 'SELECT 1',
|
||||
disabled: false,
|
||||
}),
|
||||
];
|
||||
expect(fromPerses(queries, PANEL_TYPES.TIME_SERIES).queryType).toBe(
|
||||
EQueryType.CLICKHOUSE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPerses', () => {
|
||||
it('wraps the query in a single signoz/CompositeQuery keyed to the panel request type', () => {
|
||||
const result = toPerses(
|
||||
initialQueriesMap[DataSource.METRICS],
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].kind).toBe('time_series');
|
||||
expect(result[0].spec.plugin.kind).toBe('signoz/CompositeQuery');
|
||||
});
|
||||
|
||||
it('maps a VALUE panel to the scalar request type', () => {
|
||||
const result = toPerses(
|
||||
initialQueriesMap[DataSource.METRICS],
|
||||
PANEL_TYPES.VALUE,
|
||||
);
|
||||
expect(result[0].kind).toBe('scalar');
|
||||
});
|
||||
|
||||
it('emits a bare signoz/BuilderQuery for a List panel (not a CompositeQuery)', () => {
|
||||
const result = toPerses(
|
||||
initialQueriesMap[DataSource.LOGS],
|
||||
PANEL_TYPES.LIST,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].kind).toBe('raw');
|
||||
expect(result[0].spec.plugin.kind).toBe('signoz/BuilderQuery');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip', () => {
|
||||
it('preserves a builder query through toPerses → fromPerses', () => {
|
||||
const original: Query = initialQueriesMap[DataSource.METRICS];
|
||||
|
||||
const perses = toPerses(original, PANEL_TYPES.TIME_SERIES);
|
||||
const restored = fromPerses(perses, PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
expect(restored.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(restored.builder.queryData).toHaveLength(
|
||||
original.builder.queryData.length,
|
||||
);
|
||||
expect(restored.builder.queryData[0].dataSource).toBe(
|
||||
original.builder.queryData[0].dataSource,
|
||||
);
|
||||
expect(restored.builder.queryData[0].queryName).toBe(
|
||||
original.builder.queryData[0].queryName,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a List builder query through toPerses → fromPerses', () => {
|
||||
const original: Query = initialQueriesMap[DataSource.LOGS];
|
||||
|
||||
const perses = toPerses(original, PANEL_TYPES.LIST);
|
||||
const restored = fromPerses(perses, PANEL_TYPES.LIST);
|
||||
|
||||
expect(restored.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(restored.builder.queryData[0].dataSource).toBe(DataSource.LOGS);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -154,6 +154,23 @@ function withBarStepInterval(
|
||||
});
|
||||
}
|
||||
|
||||
// Stamps offset/limit onto every builder-query envelope — server-side paging for
|
||||
// raw/list panels. Other envelope kinds (promql, clickhouse) are passed through.
|
||||
function withPagination(
|
||||
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
|
||||
{ offset, limit }: { offset: number; limit: number },
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] {
|
||||
return envelopes.map((envelope) => {
|
||||
if (envelope.type !== Querybuildertypesv5QueryTypeDTO.builder_query) {
|
||||
return envelope;
|
||||
}
|
||||
return {
|
||||
...envelope,
|
||||
spec: { ...(envelope.spec as Record<string, unknown>), offset, limit },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface BuildQueryRangeRequestArgs {
|
||||
queries: DashboardtypesQueryDTO[];
|
||||
panelType: PANEL_TYPES;
|
||||
@@ -161,6 +178,16 @@ export interface BuildQueryRangeRequestArgs {
|
||||
startMs: number;
|
||||
/** Epoch milliseconds. */
|
||||
endMs: number;
|
||||
/**
|
||||
* Backend-fill missing data points with 0 (the panel's `visualization.fillSpans`).
|
||||
* Maps to `formatOptions.fillGaps`, mirroring V1's `fillGaps: widget.fillSpans`.
|
||||
*/
|
||||
fillGaps?: boolean;
|
||||
/**
|
||||
* Server-side paging for raw/list panels — written onto the builder queries'
|
||||
* `offset`/`limit`. Absent for non-paginated panels.
|
||||
*/
|
||||
pagination?: { offset: number; limit: number };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,11 +203,16 @@ export function buildQueryRangeRequest({
|
||||
panelType,
|
||||
startMs,
|
||||
endMs,
|
||||
fillGaps = false,
|
||||
pagination,
|
||||
}: BuildQueryRangeRequestArgs): Querybuildertypesv5QueryRangeRequestDTO {
|
||||
let envelopes = toQueryEnvelopes(queries);
|
||||
if (panelType === PANEL_TYPES.BAR) {
|
||||
envelopes = withBarStepInterval(envelopes, startMs, endMs);
|
||||
}
|
||||
if (pagination) {
|
||||
envelopes = withPagination(envelopes, pagination);
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 'v1',
|
||||
@@ -190,7 +222,7 @@ export function buildQueryRangeRequest({
|
||||
compositeQuery: { queries: envelopes },
|
||||
formatOptions: {
|
||||
formatTableResultForUI: panelType === PANEL_TYPES.TABLE,
|
||||
fillGaps: false,
|
||||
fillGaps,
|
||||
},
|
||||
variables: {},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import type {
|
||||
DashboardtypesBuilderQuerySpecDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind as BuilderQueryPluginKind,
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind as CompositeQueryPluginKind,
|
||||
Querybuildertypesv5QueryTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import type { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import type { QueryEnvelope } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
panelTypeToRequestType,
|
||||
toQueryEnvelopes,
|
||||
} from './buildQueryRangeRequest';
|
||||
|
||||
/**
|
||||
* Adapters between the V2 dashboard's perses query shape
|
||||
* (`DashboardtypesPanelDTO.spec.queries`) and the V1 `Query` the shared query
|
||||
* builder (and the global `QueryBuilderProvider`) operates on.
|
||||
*
|
||||
* Both directions pivot through the V5 query-envelope list — the same wire
|
||||
* shape `buildQueryRangeRequest`/`compositeQueryToQueryEnvelope` already produce
|
||||
* — so the conversion reuses the app's existing V1↔V5 mappers rather than
|
||||
* inventing a parallel one. The single unavoidable cast is at the
|
||||
* generated-DTO ↔ hand-written-V5-type boundary (Orval erases the envelope
|
||||
* `spec` to `unknown`); it is localized here.
|
||||
*/
|
||||
|
||||
/** Which query-builder tab the perses queries belong to. */
|
||||
function deriveQueryType(
|
||||
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
|
||||
): EQueryType {
|
||||
if (envelopes.some((e) => e.type === Querybuildertypesv5QueryTypeDTO.promql)) {
|
||||
return EQueryType.PROM;
|
||||
}
|
||||
if (
|
||||
envelopes.some(
|
||||
(e) => e.type === Querybuildertypesv5QueryTypeDTO.clickhouse_sql,
|
||||
)
|
||||
) {
|
||||
return EQueryType.CLICKHOUSE;
|
||||
}
|
||||
return EQueryType.QUERY_BUILDER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perses panel queries → V1 `Query` (to seed the query builder). Unwraps the
|
||||
* perses envelope to the V5 envelope list (`toQueryEnvelopes`) and runs it
|
||||
* through `mapQueryDataFromApi`, which converts every envelope type to its V1
|
||||
* shape. An empty panel opens on a fresh metrics builder query (V1 default).
|
||||
*/
|
||||
export function fromPerses(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
panelType: PANEL_TYPES,
|
||||
): Query {
|
||||
const envelopes = toQueryEnvelopes(queries ?? []);
|
||||
if (envelopes.length === 0) {
|
||||
return initialQueriesMap[DataSource.METRICS];
|
||||
}
|
||||
|
||||
const composite: ICompositeMetricQuery = {
|
||||
queryType: deriveQueryType(envelopes),
|
||||
panelType,
|
||||
builderQueries: {},
|
||||
chQueries: {},
|
||||
promQueries: {},
|
||||
unit: undefined,
|
||||
// generated envelope DTO → hand-written V5 type (spec erased to unknown).
|
||||
queries: envelopes as unknown as QueryEnvelope[],
|
||||
};
|
||||
|
||||
return mapQueryDataFromApi(composite);
|
||||
}
|
||||
|
||||
/**
|
||||
* V1 `Query` → perses panel queries (to write the builder's result back into
|
||||
* the editor draft). `mapCompositeQueryFromQuery` produces the V5 envelope list
|
||||
* (via `compositeQueryToQueryEnvelope`); we wrap it in a single
|
||||
* `signoz/CompositeQuery` perses query — the backend invariant
|
||||
* (`panel.queries.length === 1`) `toQueryEnvelopes` reads back verbatim.
|
||||
*
|
||||
* Exception: the List panel only runs the query builder (a single builder query,
|
||||
* no formulas) and the backend rejects a `signoz/CompositeQuery` for it, so its
|
||||
* one builder query is emitted as a bare `signoz/BuilderQuery` plugin instead.
|
||||
*/
|
||||
export function toPerses(
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
): DashboardtypesQueryDTO[] {
|
||||
const composite = mapCompositeQueryFromQuery(query, panelType);
|
||||
const envelopes = (composite.queries ??
|
||||
[]) as unknown as Querybuildertypesv5QueryEnvelopeDTO[];
|
||||
|
||||
if (panelType === PANEL_TYPES.LIST) {
|
||||
const builder = envelopes.find(
|
||||
(envelope) =>
|
||||
envelope.type === Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
);
|
||||
if (!builder) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: panelTypeToRequestType(panelType),
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: BuilderQueryPluginKind['signoz/BuilderQuery'],
|
||||
// Envelope spec is erased to `unknown` by Orval; it is the builder
|
||||
// query spec — cast at this generated-DTO boundary.
|
||||
spec: builder.spec as DashboardtypesBuilderQuerySpecDTO,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const spec: Querybuildertypesv5CompositeQueryDTO = { queries: envelopes };
|
||||
|
||||
return [
|
||||
{
|
||||
kind: panelTypeToRequestType(panelType),
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: CompositeQueryPluginKind['signoz/CompositeQuery'],
|
||||
spec,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -84,3 +84,26 @@ export interface PanelTable {
|
||||
columns: PanelTableColumn[];
|
||||
rows: PanelTableRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side paging handles for raw/list panels. Owned by `usePanelQuery`
|
||||
* (where the fetch lives) and threaded to the renderer for prev/next controls.
|
||||
* Offset-based: each page re-fetches with a new `offset`. `canNext` is a
|
||||
* heuristic — a full page or a `nextCursor` on the response implies more rows.
|
||||
*/
|
||||
export interface PanelPagination {
|
||||
/** Zero-based page index (`offset / pageSize`). */
|
||||
pageIndex: number;
|
||||
/** A previous page exists (`offset > 0`). */
|
||||
canPrev: boolean;
|
||||
/** Another page likely exists. */
|
||||
canNext: boolean;
|
||||
goPrev: () => void;
|
||||
goNext: () => void;
|
||||
/** Current page size (rows per page). */
|
||||
pageSize: number;
|
||||
/** Selectable page sizes for the size picker. */
|
||||
pageSizeOptions: number[];
|
||||
/** Change the page size; restarts paging from the first page. */
|
||||
setPageSize: (size: number) => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.errorState {
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
generatePath,
|
||||
Redirect,
|
||||
useLocation,
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
|
||||
import styles from './PanelEditorPage.module.scss';
|
||||
|
||||
/**
|
||||
* Dedicated route for editing a V2 dashboard panel (`/dashboard/:dashboardId/
|
||||
* panel/:panelId`). Replaces the former modal overlay: the editor is now a full
|
||||
* page you navigate to and back from, mirroring the V1 widget-editor flow.
|
||||
*
|
||||
* Fetches the dashboard (same hook as DashboardPageV2), resolves the panel from
|
||||
* its spec, and hands `PanelEditorContainer` the navigate-back callbacks. The
|
||||
* save round-trip invalidates the dashboard query, so returning to the
|
||||
* dashboard shows the persisted edit without an explicit refetch here.
|
||||
*/
|
||||
function PanelEditorPage(): JSX.Element {
|
||||
const { dashboardId, panelId } = useParams<{
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
}>();
|
||||
const { search } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const { data, isLoading, isError, error } = useGetDashboardV2({
|
||||
id: dashboardId,
|
||||
});
|
||||
const dashboard = data?.data;
|
||||
const panel = dashboard?.spec.panels[panelId];
|
||||
|
||||
const backToDashboard = useCallback((): void => {
|
||||
// Carry only dashboard-relevant params back; drop editor-only URL state —
|
||||
// chiefly `compositeQuery` (the query builder's URL sync) — so it doesn't
|
||||
// leak into the dashboard view. Mirrors V1's navigateToDashboardPage. Time
|
||||
// lives in Redux, so it survives the navigation without being in the URL.
|
||||
const params = new URLSearchParams();
|
||||
const variables = new URLSearchParams(search).get(QueryParams.variables);
|
||||
if (variables) {
|
||||
params.set(QueryParams.variables, variables);
|
||||
}
|
||||
const query = params.toString();
|
||||
safeNavigate(
|
||||
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}${
|
||||
query ? `?${query}` : ''
|
||||
}`,
|
||||
);
|
||||
}, [safeNavigate, dashboardId, search]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
if (isError || !dashboard) {
|
||||
return (
|
||||
<div className={styles.errorState}>
|
||||
<Typography.Title>Failed to load dashboard</Typography.Title>
|
||||
<Typography.Text>{(error as Error)?.message}</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The URL references a panel that no longer exists on this dashboard (stale
|
||||
// link or deleted panel) — send the user back to the dashboard instead of
|
||||
// rendering an empty editor.
|
||||
if (!panel) {
|
||||
return (
|
||||
<Redirect
|
||||
to={`${generatePath(ROUTES.DASHBOARD, { dashboardId })}${search}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelEditorContainer
|
||||
dashboardId={dashboardId}
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
onClose={backToDashboard}
|
||||
onSaved={backToDashboard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelEditorPage;
|
||||
22
frontend/src/styles/_scrollbar.scss
Normal file
22
frontend/src/styles/_scrollbar.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
// Always-visible thin scrollbar shared across the panel system (chart legend,
|
||||
// table panel, …). Track is transparent; the thumb uses the l3 surface token
|
||||
// and a pill radius. NOTE: distinct from container/AIAssistant/_scrollbar.scss,
|
||||
// which is a hover-reveal variant.
|
||||
@mixin custom-scrollbar($size: 0.4rem) {
|
||||
&::-webkit-scrollbar {
|
||||
width: $size;
|
||||
height: $size;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-background);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--l3-background-hover);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
CHANNELS_NEW: ['ADMIN'],
|
||||
DASHBOARD: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
DASHBOARD_WIDGET: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
DASHBOARD_PANEL_EDITOR: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
EDIT_ALERTS: ['ADMIN', 'EDITOR'],
|
||||
ERROR_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
HOME_PAGE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
|
||||
Reference in New Issue
Block a user