Compare commits

..

8 Commits

90 changed files with 4158 additions and 722 deletions

View File

@@ -4889,19 +4889,6 @@ components:
- offset
- limit
type: object
LlmpricingruletypesGettableUnmappedModels:
properties:
items:
items:
$ref: '#/components/schemas/LlmpricingruletypesUnmappedModel'
nullable: true
type: array
total:
type: integer
required:
- items
- total
type: object
LlmpricingruletypesLLMPricingCacheCosts:
properties:
mode:
@@ -4991,19 +4978,6 @@ components:
type: string
nullable: true
type: array
LlmpricingruletypesUnmappedModel:
properties:
modelName:
type: string
provider:
type: string
spanCount:
minimum: 0
type: integer
required:
- modelName
- spanCount
type: object
LlmpricingruletypesUpdatableLLMPricingRule:
properties:
enabled:
@@ -10477,60 +10451,6 @@ paths:
summary: Get a pricing rule
tags:
- llmpricingrules
/api/v1/llm_pricing_rules/unmapped_models:
get:
deprecated: false
description: Returns models seen in the last hour of trace data (gen_ai.request.model)
that no pricing rule pattern matches, so the user can add them to an existing
rule or create a new one.
operationId: ListUnmappedLLMModels
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/LlmpricingruletypesGettableUnmappedModels'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List unmapped models
tags:
- llmpricingrules
/api/v1/logs/promote_paths:
get:
deprecated: false

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,6 @@ import type {
GetLLMPricingRulePathParameters,
ListLLMPricingRules200,
ListLLMPricingRulesParams,
ListUnmappedLLMModels200,
LlmpricingruletypesUpdatableLLMPricingRulesDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -394,87 +393,3 @@ export const invalidateGetLLMPricingRule = async (
return queryClient;
};
/**
* Returns models seen in the last hour of trace data (gen_ai.request.model) that no pricing rule pattern matches, so the user can add them to an existing rule or create a new one.
* @summary List unmapped models
*/
export const listUnmappedLLMModels = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListUnmappedLLMModels200>({
url: `/api/v1/llm_pricing_rules/unmapped_models`,
method: 'GET',
signal,
});
};
export const getListUnmappedLLMModelsQueryKey = () => {
return [`/api/v1/llm_pricing_rules/unmapped_models`] as const;
};
export const getListUnmappedLLMModelsQueryOptions = <
TData = Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListUnmappedLLMModelsQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listUnmappedLLMModels>>
> = ({ signal }) => listUnmappedLLMModels(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListUnmappedLLMModelsQueryResult = NonNullable<
Awaited<ReturnType<typeof listUnmappedLLMModels>>
>;
export type ListUnmappedLLMModelsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List unmapped models
*/
export function useListUnmappedLLMModels<
TData = Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListUnmappedLLMModelsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List unmapped models
*/
export const invalidateListUnmappedLLMModels = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListUnmappedLLMModelsQueryKey() },
options,
);
return queryClient;
};

View File

@@ -6537,33 +6537,6 @@ export interface LlmpricingruletypesGettablePricingRulesDTO {
total: number;
}
export interface LlmpricingruletypesUnmappedModelDTO {
/**
* @type string
*/
modelName: string;
/**
* @type string
*/
provider?: string;
/**
* @type integer
* @minimum 0
*/
spanCount: number;
}
export interface LlmpricingruletypesGettableUnmappedModelsDTO {
/**
* @type array,null
*/
items: LlmpricingruletypesUnmappedModelDTO[] | null;
/**
* @type integer
*/
total: number;
}
export interface LlmpricingruletypesUpdatableLLMPricingRuleDTO {
/**
* @type boolean
@@ -9501,14 +9474,6 @@ export type GetLLMPricingRule200 = {
status: string;
};
export type ListUnmappedLLMModels200 = {
data: LlmpricingruletypesGettableUnmappedModelsDTO;
/**
* @type string
*/
status: string;
};
export type ListPromotedAndIndexedPaths200 = {
/**
* @type array,null

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { Skeleton } from 'antd';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { FeatureKeys } from 'constants/features';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -123,14 +124,24 @@ function ServiceOverview({
/>
<Card data-testid="service_latency">
<GraphContainer>
<Graph
onDragSelect={onDragSelect}
widget={latencyWidget}
onClickHandler={handleGraphClick('Service')}
isQueryEnabled={isQueryEnabled}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
{topLevelOperationsIsLoading && (
<Skeleton
style={{
height: '100%',
padding: '16px',
}}
/>
)}
{!topLevelOperationsIsLoading && (
<Graph
onDragSelect={onDragSelect}
widget={latencyWidget}
onClickHandler={handleGraphClick('Service')}
isQueryEnabled={isQueryEnabled}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
)}
</GraphContainer>
</Card>
</>

View File

@@ -1,3 +1,4 @@
import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import axios from 'axios';
import { SOMETHING_WENT_WRONG } from 'constants/api';
@@ -28,14 +29,24 @@ function TopLevelOperation({
</Typography>
) : (
<GraphContainer>
<Graph
widget={widget}
onClickHandler={handleGraphClick(opName)}
onDragSelect={onDragSelect}
isQueryEnabled={!topLevelOperationsIsLoading}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
{topLevelOperationsIsLoading && (
<Skeleton
style={{
height: '100%',
padding: '16px',
}}
/>
)}
{!topLevelOperationsIsLoading && (
<Graph
widget={widget}
onClickHandler={handleGraphClick(opName)}
onDragSelect={onDragSelect}
isQueryEnabled={!topLevelOperationsIsLoading}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
)}
</GraphContainer>
)}
</Card>

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
// Expanded state: a compact input that fits the header row.
.input {
width: 180px;
}
.clear {
--button-height: 18px;
--button-padding: 0;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
.errorState {
padding: 24px;
}

View File

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

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

View File

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

View File

@@ -49,26 +49,6 @@ func (provider *provider) addLLMPricingRuleRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/llm_pricing_rules/unmapped_models", handler.New(
provider.authzMiddleware.ViewAccess(provider.llmPricingRuleHandler.ListUnmappedModels),
handler.OpenAPIDef{
ID: "ListUnmappedLLMModels",
Tags: []string{"llmpricingrules"},
Summary: "List unmapped models",
Description: "Returns models seen in the last hour of trace data (gen_ai.request.model) that no pricing rule pattern matches, so the user can add them to an existing rule or create a new one.",
Request: nil,
RequestContentType: "",
Response: new(llmpricingruletypes.GettableUnmappedModels),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/llm_pricing_rules/{id}", handler.New(
provider.authzMiddleware.ViewAccess(provider.llmPricingRuleHandler.Get),
handler.OpenAPIDef{

View File

@@ -22,7 +22,7 @@ func newConfig() factory.Config {
Agent: AgentConfig{
// we will maintain the latest version of cloud integration agent from here,
// till we automate it externally or figure out a way to validate it.
Version: "v0.0.13",
Version: "v0.0.12",
},
}
}

View File

@@ -118,28 +118,6 @@ func (h *handler) CreateOrUpdate(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
// ListUnmappedModels handles GET /api/v1/llm_pricing_rules/unmapped_models.
func (h *handler) ListUnmappedModels(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
models, err := h.module.ListUnmappedModels(ctx, orgID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, llmpricingruletypes.NewGettableUnmappedModels(models))
}
// Delete handles DELETE /api/v1/llm_pricing_rules/{id}.
func (h *handler) Delete(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)

View File

@@ -3,30 +3,22 @@ package impllmpricingrule
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
"github.com/SigNoz/signoz/pkg/types/opamptypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// unmappedModelsLookback is the trace data window scanned to discover models in use.
const unmappedModelsLookback = time.Hour
type module struct {
store llmpricingruletypes.Store
querier querier.Querier
store llmpricingruletypes.Store
}
func NewModule(store llmpricingruletypes.Store, querier querier.Querier) llmpricingrule.Module {
return &module{store: store, querier: querier}
func NewModule(store llmpricingruletypes.Store) llmpricingrule.Module {
return &module{store: store}
}
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
@@ -37,28 +29,6 @@ func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID
return module.store.Get(ctx, orgID, id)
}
// ListUnmappedModels discovers the models present in the last hour of trace data
// (gen_ai.request.model) and returns the ones that no pricing rule pattern matches.
func (module *module) ListUnmappedModels(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.UnmappedModel, error) {
models, err := module.discoverModels(ctx, orgID)
if err != nil {
return nil, err
}
rules, _, err := module.store.List(ctx, orgID, 0, 10000)
if err != nil {
return nil, err
}
unmapped := make([]*llmpricingruletypes.UnmappedModel, 0, len(models))
for _, m := range models {
if !llmpricingruletypes.ModelMatchesAnyRule(m.ModelName, rules) {
unmapped = append(unmapped, m)
}
}
return unmapped, nil
}
// CreateOrUpdate applies a batch of pricing rule changes:
// - ID set → match by id, overwrite fields.
// - SourceID set → match by source_id; if found overwrite, else insert.
@@ -165,108 +135,3 @@ func (module *module) findExisting(ctx context.Context, orgID valuer.UUID, u *ll
return nil, errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "rule has neither id nor sourceId")
}
}
// discoverModels runs a QBv5 traces aggregation grouped by gen_ai.request.model
// over the lookback window and returns each distinct model with its span count.
func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.UnmappedModel, error) {
now := time.Now()
req := &qbtypes.QueryRangeRequest{
Start: uint64(now.Add(-unmappedModelsLookback).UnixMilli()),
End: uint64(now.UnixMilli()),
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", llmpricingruletypes.GenAIRequestModel)},
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count()", Alias: "spanCount"},
},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: llmpricingruletypes.GenAIRequestModel,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: llmpricingruletypes.GenAIProviderName,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
},
Limit: 1000,
},
},
},
},
}
resp, err := module.querier.QueryRange(ctx, orgID, req)
if err != nil {
return nil, err
}
return parseModels(resp), nil
}
// parseModels extracts the grouped model names and their span counts from a scalar response.
func parseModels(resp *qbtypes.QueryRangeResponse) []*llmpricingruletypes.UnmappedModel {
if resp == nil || len(resp.Data.Results) == 0 {
return nil
}
sd, ok := resp.Data.Results[0].(*qbtypes.ScalarData)
if !ok || sd == nil {
return nil
}
modelIdx, providerIdx, countIdx := -1, -1, -1
for i, c := range sd.Columns {
switch c.Type {
case qbtypes.ColumnTypeGroup:
switch c.Name {
case llmpricingruletypes.GenAIRequestModel:
modelIdx = i
case llmpricingruletypes.GenAIProviderName:
providerIdx = i
}
case qbtypes.ColumnTypeAggregation:
countIdx = i
}
}
if modelIdx == -1 {
return nil
}
models := make([]*llmpricingruletypes.UnmappedModel, 0, len(sd.Data))
for _, row := range sd.Data {
name, _ := row[modelIdx].(string)
if name == "" {
continue
}
provider := ""
if providerIdx != -1 {
provider, _ = row[providerIdx].(string)
}
models = append(models, &llmpricingruletypes.UnmappedModel{ModelName: name, Provider: provider, SpanCount: toUint64(row, countIdx)})
}
return models
}
func toUint64(row []any, idx int) uint64 {
if idx < 0 || idx >= len(row) {
return 0
}
switch v := row[idx].(type) {
case uint64:
return v
case int64:
return uint64(v)
case float64:
return uint64(v)
default:
return 0
}
}

View File

@@ -17,7 +17,6 @@ type Module interface {
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error)
CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []*llmpricingruletypes.UpdatableLLMPricingRule) (err error)
Delete(ctx context.Context, orgID, id valuer.UUID) error
ListUnmappedModels(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.UnmappedModel, error)
}
// Handler defines the HTTP handler interface for pricing rule endpoints.
@@ -26,5 +25,4 @@ type Handler interface {
Get(rw http.ResponseWriter, r *http.Request)
CreateOrUpdate(rw http.ResponseWriter, r *http.Request)
Delete(rw http.ResponseWriter, r *http.Request)
ListUnmappedModels(rw http.ResponseWriter, r *http.Request)
}

View File

@@ -155,7 +155,7 @@ func NewModules(
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore)),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore)),
Tag: tagModule,
}
}

View File

@@ -3,7 +3,6 @@ package llmpricingruletypes
import (
"database/sql/driver"
"encoding/json"
"path"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -17,7 +16,6 @@ const (
LLMCostFeatureType agentConf.AgentFeatureType = "llm_pricing"
GenAIRequestModel = "gen_ai.request.model"
GenAIProviderName = "gen_ai.provider.name"
GenAIUsageInputTokens = "gen_ai.usage.input_tokens"
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
@@ -138,32 +136,6 @@ type GettablePricingRules struct {
Limit int `json:"limit" required:"true"`
}
// UnmappedModel is a model observed in trace data (gen_ai.request.model) that
// no pricing rule pattern matches, so no cost is being computed for it.
type UnmappedModel struct {
ModelName string `json:"modelName" required:"true"`
Provider string `json:"provider"`
SpanCount uint64 `json:"spanCount" required:"true"`
}
type GettableUnmappedModels struct {
Items []*UnmappedModel `json:"items" required:"true"`
Total int `json:"total" required:"true"`
}
// ModelMatchesAnyRule reports whether model matches any rule's glob pattern,
// mirroring the path.Match semantics the signozllmpricing OTel processor uses.
func ModelMatchesAnyRule(model string, rules []*LLMPricingRule) bool {
for _, r := range rules {
for _, pattern := range r.ModelPattern {
if ok, err := path.Match(pattern, model); err == nil && ok {
return true
}
}
}
return false
}
func (LLMPricingRuleUnit) Enum() []any {
return []any{UnitPerMillionTokens}
}
@@ -232,13 +204,6 @@ func NewGettableLLMPricingRulesFromLLMPricingRules(items []*LLMPricingRule, tota
}
}
func NewGettableUnmappedModels(items []*UnmappedModel) *GettableUnmappedModels {
return &GettableUnmappedModels{
Items: items,
Total: len(items),
}
}
func NewLLMPricingRuleFromUpdatable(u *UpdatableLLMPricingRule, orgID valuer.UUID, userEmail string, now time.Time) *LLMPricingRule {
isOverride := true
if u.IsOverride != nil {