Compare commits

..

11 Commits

Author SHA1 Message Date
Abhi Kumar
e915f6b6bf test(dashboard): wait for picker store effects after starting a new section 2026-09-30 15:38:29 +05:30
Abhi Kumar
dc43d2fb2f test(dashboard): use userEvent in panel type switcher tests
Assisted-by: Claude Opus 5.5
2026-09-30 11:21:58 +05:30
Abhi Kumar
89090b85d3 feat(dashboard): add-to-section split button in the new panel drawer
Replaces the antd section select; the CTA names its destination.

Assisted-by: Claude Opus 5.5
2026-09-30 11:21:55 +05:30
Abhi Kumar
61a6fb5d1f feat(dashboard): switch panel type in the editor via the type browser
A revert button returns to the type the panel was opened with.
2026-09-27 17:22:24 +05:30
Abhi Kumar
c4a6ce85fa fix(dashboard): scroll to a placeholder that grows the dashboard
OverlayScrollbars marks its viewport scrollable only after noticing the
overflow, and the grid animates its height, so the reveal either scrolled
html or stopped short.
2026-09-27 17:06:36 +05:30
Abhi Kumar
45a062babc feat(dashboard): preview the new panel where the picker will add it 2026-09-27 17:06:25 +05:30
Abhi Kumar
276056c4ab feat(dashboard): preview a new section on the dashboard while naming it 2026-09-27 16:36:47 +05:30
Abhi Kumar
ffbba01e29 feat(dashboard): pick or create the section from the new-panel drawer footer
The main New Panel button defaults to the dashboard root.
2026-09-27 16:36:47 +05:30
Abhi Kumar
01f7f6869a feat(dashboard): let a new panel's save create its section or the root
Placement travels in the editor URL as a NewPanelTarget, so nothing is
written until the panel is saved.
2026-09-27 16:36:46 +05:30
Abhi Kumar
de3c3c268f feat(dashboard): highlight the picker's target section behind the drawer 2026-09-27 16:36:46 +05:30
Abhi Kumar
9a602d015a feat(dashboard): redesign the new-panel picker as a searchable drawer 2026-09-27 16:36:46 +05:30
200 changed files with 3471 additions and 5764 deletions

View File

@@ -68,7 +68,6 @@ jobs:
- semconvfamilies
- serviceaccount
- spanmapper
- tracedetail
- querier_json_body
- querier_skip_resource_fingerprint
- ttl

View File

@@ -1,44 +1,5 @@
components:
schemas:
AiobservabilitytypesMessage:
properties:
content:
items:
$ref: '#/components/schemas/AiobservabilitytypesPart'
type: array
finishReason:
type: string
role:
type: string
required:
- content
type: object
AiobservabilitytypesPart:
properties:
arguments: {}
content:
type: string
id:
type: string
isError:
type: boolean
name:
type: string
toolCallId:
type: string
type:
$ref: '#/components/schemas/AiobservabilitytypesPartType'
required:
- type
type: object
AiobservabilitytypesPartType:
enum:
- text
- thinking
- tool_call
- tool_result
- generic
type: string
AlertmanagertypesChannel:
properties:
createdAt:
@@ -9686,17 +9647,6 @@ components:
required:
- aggregations
type: object
SpantypesGettableTraceThread:
properties:
nextCursor:
type: string
spans:
items:
$ref: '#/components/schemas/SpantypesThreadSpan'
type: array
required:
- spans
type: object
SpantypesGettableWaterfallTrace:
properties:
endTimestampMillis:
@@ -10011,92 +9961,6 @@ components:
nullable: true
type: object
type: object
SpantypesThreadSpan:
properties:
attributes:
additionalProperties: {}
nullable: true
type: object
db_name:
type: string
db_operation:
type: string
duration_nano:
minimum: 0
type: integer
events:
items:
$ref: '#/components/schemas/SpantypesEvent'
nullable: true
type: array
external_http_method:
type: string
external_http_url:
type: string
flags:
minimum: 0
type: integer
formatted_input:
items:
$ref: '#/components/schemas/AiobservabilitytypesMessage'
type: array
formatted_output:
items:
$ref: '#/components/schemas/AiobservabilitytypesMessage'
type: array
has_children:
type: boolean
has_error:
type: boolean
http_host:
type: string
http_method:
type: string
http_url:
type: string
is_remote:
type: string
kind_string:
type: string
level:
minimum: 0
type: integer
name:
type: string
parent_span_id:
type: string
references:
items:
$ref: '#/components/schemas/SpantypesOtelSpanRef'
type: array
resource:
additionalProperties:
type: string
nullable: true
type: object
response_status_code:
type: string
span_id:
type: string
status_code:
type: integer
status_code_string:
type: string
status_message:
type: string
sub_tree_node_count:
minimum: 0
type: integer
time_unix:
minimum: 0
type: integer
trace_id:
type: string
trace_state:
type: string
required:
- references
type: object
SpantypesUpdatableSpanMapper:
properties:
config:
@@ -15821,81 +15685,6 @@ paths:
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/traces/{traceID}/thread:
get:
deprecated: false
description: Returns the spans carrying gen_ai input or output messages in timestamp
order, each with the messages normalised into formatted_input and formatted_output.
Pages are fetched with the returned nextCursor.
operationId: GetTraceThread
parameters:
- in: query
name: limit
schema:
type: integer
- in: query
name: cursor
schema:
type: string
- in: path
name: traceID
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableTraceThread'
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
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get thread view for a trace
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/user/me:
get:
deprecated: true

View File

@@ -4,53 +4,6 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
export enum AiobservabilitytypesPartTypeDTO {
text = 'text',
thinking = 'thinking',
tool_call = 'tool_call',
tool_result = 'tool_result',
generic = 'generic',
}
export interface AiobservabilitytypesPartDTO {
arguments?: unknown;
/**
* @type string
*/
content?: string;
/**
* @type string
*/
id?: string;
/**
* @type boolean
*/
isError?: boolean;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
toolCallId?: string;
type: AiobservabilitytypesPartTypeDTO;
}
export interface AiobservabilitytypesMessageDTO {
/**
* @type array
*/
content: AiobservabilitytypesPartDTO[];
/**
* @type string
*/
finishReason?: string;
/**
* @type string
*/
role?: string;
}
export interface AlertmanagertypesChannelDTO {
/**
* @type string
@@ -11189,165 +11142,6 @@ export interface SpantypesOtelSpanRefDTO {
traceId?: string;
}
export type SpantypesThreadSpanDTOAttributesAnyOf = { [key: string]: unknown };
/**
* @nullable
*/
export type SpantypesThreadSpanDTOAttributes =
SpantypesThreadSpanDTOAttributesAnyOf | null;
export type SpantypesThreadSpanDTOResourceAnyOf = { [key: string]: string };
/**
* @nullable
*/
export type SpantypesThreadSpanDTOResource =
SpantypesThreadSpanDTOResourceAnyOf | null;
export interface SpantypesThreadSpanDTO {
/**
* @type object,null
*/
attributes?: SpantypesThreadSpanDTOAttributes;
/**
* @type string
*/
db_name?: string;
/**
* @type string
*/
db_operation?: string;
/**
* @type integer
* @minimum 0
*/
duration_nano?: number;
/**
* @type array,null
*/
events?: SpantypesEventDTO[] | null;
/**
* @type string
*/
external_http_method?: string;
/**
* @type string
*/
external_http_url?: string;
/**
* @type integer
* @minimum 0
*/
flags?: number;
/**
* @type array
*/
formatted_input?: AiobservabilitytypesMessageDTO[];
/**
* @type array
*/
formatted_output?: AiobservabilitytypesMessageDTO[];
/**
* @type boolean
*/
has_children?: boolean;
/**
* @type boolean
*/
has_error?: boolean;
/**
* @type string
*/
http_host?: string;
/**
* @type string
*/
http_method?: string;
/**
* @type string
*/
http_url?: string;
/**
* @type string
*/
is_remote?: string;
/**
* @type string
*/
kind_string?: string;
/**
* @type integer
* @minimum 0
*/
level?: number;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
parent_span_id?: string;
/**
* @type array
*/
references: SpantypesOtelSpanRefDTO[];
/**
* @type object,null
*/
resource?: SpantypesThreadSpanDTOResource;
/**
* @type string
*/
response_status_code?: string;
/**
* @type string
*/
span_id?: string;
/**
* @type integer
*/
status_code?: number;
/**
* @type string
*/
status_code_string?: string;
/**
* @type string
*/
status_message?: string;
/**
* @type integer
* @minimum 0
*/
sub_tree_node_count?: number;
/**
* @type integer
* @minimum 0
*/
time_unix?: number;
/**
* @type string
*/
trace_id?: string;
/**
* @type string
*/
trace_state?: string;
}
export interface SpantypesGettableTraceThreadDTO {
/**
* @type string
*/
nextCursor?: string;
/**
* @type array
*/
spans: SpantypesThreadSpanDTO[];
}
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
@@ -13021,30 +12815,6 @@ export type GetTraceAggregations200 = {
status: string;
};
export type GetTraceThreadPathParameters = {
traceID: string;
};
export type GetTraceThreadParams = {
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type string
* @description undefined
*/
cursor?: string;
};
export type GetTraceThread200 = {
data: SpantypesGettableTraceThreadDTO;
/**
* @type string
*/
status: string;
};
export type ListUserPreferences200 = {
/**
* @type array

View File

@@ -4,17 +4,11 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import { useMutation } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
@@ -22,9 +16,6 @@ import type {
GetFlamegraphPathParameters,
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetTraceThread200,
GetTraceThreadParams,
GetTraceThreadPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
@@ -36,26 +27,6 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* Computes span aggregations grouped by requested field.
* @summary Get aggregations for a trace
@@ -156,121 +127,6 @@ export const useGetTraceAggregations = <
> => {
return useMutation(getGetTraceAggregationsMutationOptions(options));
};
/**
* Returns the spans carrying gen_ai input or output messages in timestamp order, each with the messages normalised into formatted_input and formatted_output. Pages are fetched with the returned nextCursor.
* @summary Get thread view for a trace
*/
export const getTraceThread = (
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetTraceThread200>({
url: `/api/v1/traces/${traceID}/thread`,
method: 'GET',
params,
signal,
});
};
export const getGetTraceThreadQueryKey = (
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
) => {
return [
`/api/v1/traces/${traceID}/thread`,
...(params ? [params] : []),
] as const;
};
export const getGetTraceThreadQueryOptions = <
TData = Awaited<ReturnType<typeof getTraceThread>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetTraceThreadQueryKey({ traceID }, params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceThread>>> = ({
signal,
}) => getTraceThread({ traceID }, params, signal);
return {
queryKey,
queryFn,
enabled: traceID !== null && traceID !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetTraceThreadQueryResult = NonNullable<
Awaited<ReturnType<typeof getTraceThread>>
>;
export type GetTraceThreadQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get thread view for a trace
*/
export function useGetTraceThread<
TData = Awaited<ReturnType<typeof getTraceThread>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetTraceThreadQueryOptions(
{ traceID },
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get thread view for a trace
*/
export const invalidateGetTraceThread = async (
queryClient: QueryClient,
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetTraceThreadQueryKey({ traceID }, params) },
options,
);
return queryClient;
};
/**
* Returns the flamegraph view of spans for a given trace ID.
* @summary Get flamegraph view for a trace

View File

@@ -47,5 +47,4 @@ export enum LOCALSTORAGE {
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
SAVED_VIEW_ENABLED = 'SAVED_VIEW_ENABLED',
}

View File

@@ -53,10 +53,6 @@
z-index: 0;
background: var(--l1-background);
// Column so the bottom strip sits under the scrolling content, not inside it.
display: flex;
flex-direction: column;
&.full-screen-content {
width: 100%;
}
@@ -74,9 +70,7 @@
.chat-support-gateway {
position: fixed;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: calc(20px + var(--bottom-strip-height, 0px));
bottom: 20px;
right: 20px;
z-index: 1000;

View File

@@ -43,7 +43,6 @@ import { USER_PREFERENCES } from 'constants/userPreferences';
import AIAssistantModal from 'container/AIAssistant/AIAssistantModal';
import AIAssistantPanel from 'container/AIAssistant/AIAssistantPanel';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import BottomStrip from 'container/BottomStrip';
import SideNav from 'container/SideNav';
import TopNav from 'container/TopNav';
import dayjs from 'dayjs';
@@ -52,7 +51,6 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useNotifications } from 'hooks/useNotifications';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import useTabVisibility from 'hooks/useTabFocus';
import history from 'lib/history';
import { isNull } from 'lodash-es';
@@ -404,7 +402,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}, [pathname]);
const isToDisplayLayout = isLoggedIn;
const isSavedViewEnabled = useSavedViewEnabled();
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
const pageTitle = t(routeKey);
@@ -871,10 +868,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
</OverlayScrollbar>
</LayoutContent>
</Sentry.ErrorBoundary>
{isSavedViewEnabled && isToDisplayLayout && !renderFullScreen && (
<BottomStrip />
)}
</div>
{isLoggedIn && isAIAssistantEnabled && (

View File

@@ -12,12 +12,8 @@ export const Layout = styled(LayoutComponent)`
}
`;
// Takes the height left in `.app-content` after the bottom strip.
// `min-height: 0` is not needed right now, overlayscrollbars already sets
// `overflow: auto` here. Kept so this does not break if that goes away.
export const LayoutContent = styled(LayoutComponent.Content)`
flex: 1;
min-height: 0;
height: 100%;
&::-webkit-scrollbar {
width: 0.1rem;
}

View File

@@ -1,36 +0,0 @@
.strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
flex-shrink: 0;
height: var(--bottom-strip-height);
padding: 0 var(--spacing-6);
background: var(--l2-background);
border-top: 1px solid var(--l2-border);
font-family: var(--font-family-sf-mono, monospace);
// Above page content, below the body-portalled overlays that are meant to
// cover the strip.
position: relative;
z-index: 1;
}
.left,
.right {
display: flex;
align-items: center;
gap: var(--spacing-6);
min-width: 0;
}
// Temporary placeholder for the left slot. Replaced later.
.version {
color: var(--l2-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -1,49 +0,0 @@
import { render } from 'tests/test-utils';
import BottomStrip, {
BOTTOM_STRIP_HEIGHT,
BOTTOM_STRIP_HEIGHT_VAR,
BOTTOM_STRIP_ON_CLASS,
} from '..';
describe('BottomStrip', () => {
it('publishes the body class and height property while mounted', () => {
const { unmount } = render(<BottomStrip />);
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(true);
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
`${BOTTOM_STRIP_HEIGHT}px`,
);
unmount();
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(false);
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
'',
);
});
// The string is whatever the Go build injected, so it is rendered untouched —
// same as SideNav. Release tags carry the "v", local builds do not.
it.each([['v0.134.67'], ['main-64f1c2a']])(
'renders the build version %p exactly as given',
(version) => {
const { getByTestId } = render(<BottomStrip />, undefined, {
appContextOverrides: {
versionData: { version, ee: 'Y', setupCompleted: true },
},
});
expect(getByTestId('bottom-strip-version')).toHaveTextContent(version);
},
);
it('renders the strip without a version when none is available', () => {
const { getByTestId, queryByTestId } = render(<BottomStrip />, undefined, {
appContextOverrides: { versionData: null },
});
expect(getByTestId('bottom-strip')).toBeInTheDocument();
expect(queryByTestId('bottom-strip-version')).not.toBeInTheDocument();
});
});

View File

@@ -1,42 +0,0 @@
import { useLayoutEffect } from 'react';
import { useAppContext } from 'providers/App/App';
import styles from './BottomStrip.module.scss';
export const BOTTOM_STRIP_HEIGHT = 24;
export const BOTTOM_STRIP_ON_CLASS = 'bottom-strip-on';
export const BOTTOM_STRIP_HEIGHT_VAR = '--bottom-strip-height';
function BottomStrip(): JSX.Element {
const { versionData } = useAppContext();
const version = versionData?.version?.trim();
useLayoutEffect(() => {
document.body.classList.add(BOTTOM_STRIP_ON_CLASS);
document.body.style.setProperty(
BOTTOM_STRIP_HEIGHT_VAR,
`${BOTTOM_STRIP_HEIGHT}px`,
);
return (): void => {
document.body.classList.remove(BOTTOM_STRIP_ON_CLASS);
document.body.style.removeProperty(BOTTOM_STRIP_HEIGHT_VAR);
};
}, []);
return (
<div className={styles.strip} data-testid="bottom-strip">
<div className={styles.left}>
{version && (
<span className={styles.version} data-testid="bottom-strip-version">
{version}
</span>
)}
</div>
<div className={styles.right} />
</div>
);
}
export default BottomStrip;

View File

@@ -1,8 +1,6 @@
.create-alert-v2-footer {
position: fixed;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
bottom: 0;
left: 63px;
right: 0;
background-color: var(--l1-background);

View File

@@ -1,8 +1,6 @@
.explorer-options-container {
position: fixed;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
bottom: 0px;
left: calc(50% + 240px);
transform: translate(calc(-50% - 120px), 0);
transition: left 0.2s linear;

View File

@@ -1,8 +1,6 @@
.explorer-option-droppable-container {
position: fixed;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
bottom: 0;
width: -webkit-fill-available;
height: 24px;
display: flex;

View File

@@ -1,6 +1,7 @@
.home-container {
display: flex;
flex-direction: column;
min-height: 100vh;
overflow-y: auto;
height: 100%;
width: 100%;

View File

@@ -1,4 +1,7 @@
.licenses-page {
max-height: 100vh;
overflow: hidden;
.licenses-page-header {
border-bottom: 1px solid var(--l1-border);
background: var(--l1-background);
@@ -29,6 +32,7 @@
.licenses-page-content {
flex: 1;
height: calc(100vh - 48px);
background: var(--l1-background);
padding: 10px 8px;
overflow-y: auto;

View File

@@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
height: calc(100vh - 62px);
min-height: 400px;
}

View File

@@ -181,9 +181,7 @@
.ant-pagination {
position: fixed;
// Lifted above the bottom strip. Don't extend this pattern — new
// fixed-bottom UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
bottom: 0;
width: calc(100% - 54px);
background: var(--l1-background);
padding: 16px;

View File

@@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
height: calc(100vh - 62px);
min-height: 400px;
padding-top: var(--spacing-8);
}

View File

@@ -1,4 +1,7 @@
.version-container {
max-height: 100vh;
overflow: hidden;
.version-page-header {
border-bottom: 1px solid var(--l1-border);
background: var(--l1-background);

View File

@@ -1,11 +0,0 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useState } from 'react';
export function useSavedViewEnabled(): boolean {
const [isEnabled] = useState(
() => getLocalStorageKey(LOCALSTORAGE.SAVED_VIEW_ENABLED) === 'true',
);
return isEnabled;
}

View File

@@ -1,29 +1,4 @@
.alerts-container {
// Hands the page height down to the active tab so its content can bound itself
// instead of guessing with 100vh. Child combinators only, nested Tabs
// (Configuration) must not be caught.
flex: 1;
min-height: 0;
> .ant-tabs-content-holder {
display: flex;
flex-direction: column;
> .ant-tabs-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
> .ant-tabs-tabpane-active {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
}
}
.top-level-tab.periscope-tab {
padding: 2px 0;
}
@@ -65,9 +40,5 @@
.alert-rules-container {
margin-top: 10px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
}

View File

@@ -25,6 +25,7 @@ interface ConfigPaneProps {
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Switch the panel to another visualization kind. */
onChangePanelKind: (kind: PanelKind) => void;
originalPanelKind?: PanelKind;
/**
* Active query type from the query-builder provider (the selected tab). Drives which
* panel types the visualization switcher disables — read from the provider, not the
@@ -57,6 +58,7 @@ function ConfigPane({
spec,
onChangeSpec,
onChangePanelKind,
originalPanelKind,
queryType,
legendSeries,
tableColumns,
@@ -125,6 +127,7 @@ function ConfigPane({
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
@@ -149,6 +152,7 @@ function ConfigPane({
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}

View File

@@ -1,6 +1,63 @@
@use '../../../../../../styles/scrollbar' as *;
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 5px 5px 5px 12px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l2-background);
font: inherit;
text-align: left;
cursor: pointer;
&:hover {
border-color: var(--l3-border);
}
}
.triggerIcon {
flex-shrink: 0;
color: var(--l2-foreground);
}
.triggerName {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--l1-foreground);
text-overflow: ellipsis;
white-space: nowrap;
}
.triggerAction {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 3px;
background: var(--l3-background);
color: var(--l2-foreground);
font-size: 12px;
}
.revert {
align-self: flex-start;
}
.drawerBody {
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
@include custom-scrollbar;
}

View File

@@ -1,12 +1,17 @@
import { useCallback, useState } from 'react';
import { ArrowRightLeft, Undo2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeBrowser';
import { getPanelDefinition } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
import styles from './PanelTypeSwitcher.module.scss';
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
import { getPanelTypeDisabledReason } from './utils';
interface PanelTypeSwitcherProps {
/** The current panel kind (selected value). */
@@ -15,32 +20,96 @@ interface PanelTypeSwitcherProps {
queryType: EQueryType;
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
/** Kind the panel was opened with; a revert button appears once it differs. */
originalPanelKind?: PanelKind;
onChange: (kind: PanelKind) => void;
}
/**
* Visualization-type selector (rendered inside the Visualization section). A type is
* disabled when the active query type or signal is incompatible with it — resolved
* through the capabilities guard. The signal is unknown for PromQL/ClickHouse, but
* those query types still disable kinds that only support Query Builder (e.g. List).
* Visualization-type selector (rendered inside the Visualization section): opens the
* panel type browser in a drawer. A type is disabled when the active query type or
* signal is incompatible with it — resolved through the capabilities guard.
*/
function PanelTypeSwitcher({
panelKind,
queryType,
signal,
originalPanelKind,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = usePanelTypeSelectItems({ queryType, signal });
const [isOpen, setIsOpen] = useState(false);
const { displayName, icon: Icon } = getPanelDefinition(panelKind);
const getDisabledReason = useCallback(
(kind: PanelKind): string | undefined =>
getPanelTypeDisabledReason({
kind,
queryType,
signal,
label: getPanelDefinition(kind).displayName,
}),
[queryType, signal],
);
const canRevert = !!originalPanelKind && originalPanelKind !== panelKind;
const revertBlockedReason = canRevert
? getDisabledReason(originalPanelKind)
: undefined;
const handleSelect = (kind: PanelKind): void => {
setIsOpen(false);
if (kind !== panelKind) {
onChange(kind);
}
};
return (
<div className={styles.field}>
<Typography.Text>Panel Type</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-type-switcher"
value={panelKind}
items={items}
onChange={(value): void => onChange(value)}
/>
<button
type="button"
className={styles.trigger}
onClick={(): void => setIsOpen(true)}
data-testid="panel-editor-v2-type-switcher"
>
<Icon size={14} className={styles.triggerIcon} />
<span className={styles.triggerName}>{displayName}</span>
<span className={styles.triggerAction}>
<ArrowRightLeft size={14} />
Change
</span>
</button>
{canRevert && (
<Button
variant="link"
color="primary"
size="sm"
prefix={<Undo2 />}
className={styles.revert}
disabled={!!revertBlockedReason}
title={revertBlockedReason}
onClick={(): void => onChange(originalPanelKind)}
testId="panel-editor-v2-type-revert"
>
Revert to {getPanelDefinition(originalPanelKind).displayName}
</Button>
)}
<DrawerWrapper
open={isOpen}
onOpenChange={setIsOpen}
title="Change panel type"
subTitle="Pick a visualization for this panel."
direction="right"
width="wide"
testId="panel-type-switcher-drawer"
drawerDescriptionProps={{ className: styles.drawerBody }}
>
<PanelTypeBrowser
selectedKind={panelKind}
onSelect={handleSelect}
getDisabledReason={getDisabledReason}
/>
</DrawerWrapper>
</div>
);
}

View File

@@ -1,22 +1,30 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import PanelTypeSwitcher from '../PanelTypeSwitcher';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
const OPTIONS = [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/AreaChartPanel', displayName: 'Area' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
{ kind: 'signoz/TextPanel', displayName: 'Text' },
].map((option) => ({ ...option, icon: (): null => null }));
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
].map((option) => ({ ...option, icon: (): null => null })),
get PANEL_OPTIONS(): unknown {
return OPTIONS;
},
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
@@ -28,14 +36,34 @@ const SUPPORTED_QUERY_TYPES: Record<string, EQueryType[]> = {
'signoz/PieChartPanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
};
function disabledLabels(): (string | null)[] {
return Array.from(
document.querySelectorAll('.ant-select-item-option-disabled'),
).map((el) => el.textContent);
type User = ReturnType<typeof userEvent.setup>;
async function renderSwitcher(
props: Partial<Parameters<typeof PanelTypeSwitcher>[0]> = {},
): Promise<{ onChange: jest.Mock; user: User }> {
// The open drawer sets `pointer-events: none` on the body.
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<TooltipProvider>
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={onChange}
{...props}
/>
</TooltipProvider>,
);
await user.click(screen.getByTestId('panel-editor-v2-type-switcher'));
return { onChange, user };
}
function openDropdown(): void {
fireEvent.mouseDown(screen.getByRole('combobox'));
function disabledKinds(): (string | undefined)[] {
return Array.from(
document.querySelectorAll('[data-testid^="panel-type-signoz/"]'),
)
.filter((el) => el.getAttribute('aria-disabled') === 'true')
.map((el) => el.getAttribute('data-testid')?.replace('panel-type-', ''));
}
describe('PanelTypeSwitcher', () => {
@@ -44,7 +72,8 @@ describe('PanelTypeSwitcher', () => {
// List supports only logs/traces; every other kind also supports metrics.
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
mockGetPanelDefinition.mockImplementation((kind: string) => ({
mode: 'query',
...OPTIONS.find((option) => option.kind === kind),
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']
@@ -57,83 +86,94 @@ describe('PanelTypeSwitcher', () => {
}));
});
it('fires onChange with the chosen plugin kind', () => {
const onChange = jest.fn();
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={onChange}
/>,
);
it('shows the current type and switches to the chosen one', async () => {
const { onChange, user } = await renderSwitcher();
openDropdown();
fireEvent.click(screen.getByText('List'));
expect(screen.getByTestId('panel-editor-v2-type-switcher')).toHaveTextContent(
'Time SeriesChange',
);
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
});
it('disables types whose supported signals exclude the current signal', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
signal={TelemetrytypesSignalDTO.metrics}
onChange={jest.fn()}
/>,
);
it('does not fire onChange when the current type is picked again', async () => {
const { onChange, user } = await renderSwitcher();
openDropdown();
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Time Series');
await user.click(screen.getByTestId('panel-type-signoz/TimeSeriesPanel'));
expect(onChange).not.toHaveBeenCalled();
});
it('does not disable any type when the signal is unknown (builder, no signal)', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={jest.fn()}
/>,
);
it('disables types whose supported signals exclude the current signal', async () => {
const { onChange, user } = await renderSwitcher({
signal: TelemetrytypesSignalDTO.metrics,
});
openDropdown();
expect(
document.querySelectorAll('.ant-select-item-option-disabled'),
).toHaveLength(0);
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
expect(onChange).not.toHaveBeenCalled();
});
it('disables Query-Builder-only kinds under PromQL even without a signal', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.PROM}
onChange={jest.fn()}
/>,
);
it('does not disable any type when the signal is unknown (builder, no signal)', async () => {
await renderSwitcher();
openDropdown();
// List/Table/Pie can't be authored in PromQL; Time Series can.
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).toContain('Table');
expect(disabledLabels()).toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
expect(disabledKinds()).toHaveLength(0);
});
it('disables List under ClickHouse while Table/Pie stay enabled', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TablePanel"
queryType={EQueryType.CLICKHOUSE}
onChange={jest.fn()}
/>,
);
it('disables Query-Builder-only kinds under PromQL even without a signal', async () => {
await renderSwitcher({ queryType: EQueryType.PROM });
openDropdown();
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Table');
expect(disabledLabels()).not.toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
expect(disabledKinds()).toStrictEqual(
expect.arrayContaining([
'signoz/ListPanel',
'signoz/TablePanel',
'signoz/PieChartPanel',
]),
);
expect(disabledKinds()).not.toContain('signoz/TimeSeriesPanel');
expect(disabledKinds()).not.toContain('signoz/TextPanel');
});
it('disables List under ClickHouse while Table/Pie stay enabled', async () => {
await renderSwitcher({
panelKind: 'signoz/TablePanel',
queryType: EQueryType.CLICKHOUSE,
});
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
});
describe('revert', () => {
it('is hidden while the type is the original one', async () => {
await renderSwitcher({ originalPanelKind: 'signoz/TimeSeriesPanel' });
expect(
screen.queryByTestId('panel-editor-v2-type-revert'),
).not.toBeInTheDocument();
});
it('switches back to the original type', async () => {
const { onChange, user } = await renderSwitcher({
panelKind: 'signoz/TablePanel',
originalPanelKind: 'signoz/TimeSeriesPanel',
});
const revert = screen.getByTestId('panel-editor-v2-type-revert');
expect(revert).toHaveTextContent('Revert to Time Series');
await user.click(revert);
expect(onChange).toHaveBeenCalledWith('signoz/TimeSeriesPanel');
});
it('is disabled when the original type no longer fits the query', async () => {
await renderSwitcher({
panelKind: 'signoz/TimeSeriesPanel',
originalPanelKind: 'signoz/ListPanel',
queryType: EQueryType.PROM,
});
expect(screen.getByTestId('panel-editor-v2-type-revert')).toBeDisabled();
});
});
});

View File

@@ -18,8 +18,7 @@ interface UsePanelTypeSelectItemsArgs {
/**
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
* tooltip) when the active query type or signal is incompatible — resolved through
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
* modal's header so the two selectors apply the same rule and can't drift.
* the capabilities guard, the same rule the editor's `PanelTypeSwitcher` applies.
*/
export function usePanelTypeSelectItems({
queryType,

View File

@@ -58,6 +58,7 @@ function SectionSlot({
signal,
panelKind,
onChangePanelKind,
originalPanelKind,
queryType,
stepInterval,
metricUnit,
@@ -124,6 +125,7 @@ function SectionSlot({
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}

View File

@@ -16,6 +16,8 @@ export interface SectionEditorContext {
signal?: TelemetrytypesSignalDTO;
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;
/** Kind the panel was opened with, offered as a revert target. */
originalPanelKind?: PanelKind;
yAxisUnit?: string;
queryType?: EQueryType;
stepInterval?: number;

View File

@@ -19,7 +19,11 @@ import styles from './VisualizationSection.module.scss';
type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
Pick<
SectionEditorContext,
'panelKind' | 'onChangePanelKind' | 'signal' | 'queryType'
| 'panelKind'
| 'onChangePanelKind'
| 'originalPanelKind'
| 'signal'
| 'queryType'
>;
/**
@@ -35,6 +39,7 @@ function VisualizationSection({
onChange,
panelKind,
onChangePanelKind,
originalPanelKind,
queryType,
signal,
}: VisualizationSectionProps): JSX.Element {
@@ -47,6 +52,7 @@ function VisualizationSection({
// supplied in practice; default to Query Builder at this boundary.
queryType={queryType ?? EQueryType.QUERY_BUILDER}
signal={signal}
originalPanelKind={originalPanelKind}
onChange={onChangePanelKind}
/>
)}

View File

@@ -11,6 +11,8 @@ import VisualizationSection from '../VisualizationSection';
// the test doesn't pull the whole panel registry (renderers, chart libs).
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(() => ({
displayName: 'Time Series',
icon: (): null => null,
mode: 'query',
supportedSignals: ['metrics', 'logs', 'traces'],
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
@@ -174,6 +176,7 @@ describe('VisualizationSection', () => {
});
it('renders the type switcher and switches kind when switchPanelKind is set', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChangePanelKind = jest.fn();
render(
<VisualizationSection
@@ -189,7 +192,8 @@ describe('VisualizationSection', () => {
screen.getByTestId('panel-editor-v2-type-switcher'),
).toBeInTheDocument();
await pickOption('panel-editor-v2-type-switcher', 'Table');
await user.click(screen.getByTestId('panel-editor-v2-type-switcher'));
await user.click(screen.getByTestId('panel-type-signoz/TablePanel'));
expect(onChangePanelKind).toHaveBeenCalledWith('signoz/TablePanel');
});

View File

@@ -39,6 +39,7 @@ import { useTableColumns } from './hooks/useTableColumns';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
import type { NewPanelTarget } from '../patchOps';
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
// popups (group-by, order-by, having, …) clip when they open into the short pane.
@@ -58,8 +59,7 @@ interface QueryEditorBodyProps {
savedPanel?: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
target?: NewPanelTarget;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
@@ -70,6 +70,7 @@ interface QueryEditorBodyProps {
panelDefinition: RenderableQueryPanelDefinition;
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
onChangePanelKind: (kind: PanelKind) => void;
originalPanelKind?: PanelKind;
}
/**
@@ -84,12 +85,13 @@ function QueryEditorBody({
panel,
savedPanel,
isNew = false,
layoutIndex,
target,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
originalPanelKind,
}: QueryEditorBodyProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
@@ -133,7 +135,7 @@ function QueryEditorBody({
dashboardId,
panelId,
isNew,
layoutIndex,
target,
});
const panelKind = draft.spec.plugin.kind;
@@ -319,6 +321,7 @@ function QueryEditorBody({
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}

View File

@@ -23,6 +23,7 @@ interface StaticEditorBodyProps extends PanelEditorContainerProps {
draftApi: PanelEditorDraftApi;
panelDefinition: RenderableStaticPanelDefinition;
onChangePanelKind: (kind: PanelKind) => void;
originalPanelKind?: PanelKind;
}
/**
@@ -35,12 +36,13 @@ function StaticEditorBody({
dashboardId,
panelId,
isNew = false,
layoutIndex,
target,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
originalPanelKind,
}: StaticEditorBodyProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
@@ -54,7 +56,7 @@ function StaticEditorBody({
dashboardId,
panelId,
isNew,
layoutIndex,
target,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
@@ -122,6 +124,7 @@ function StaticEditorBody({
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={EQueryType.QUERY_BUILDER}
legendSeries={[]}
tableColumns={[]}

View File

@@ -6,14 +6,33 @@ import {
NEW_PANEL_ID,
newPanelSearch,
parseNewPanelKind,
parseNewPanelLayoutIndex,
parseNewPanelTarget,
} from '../newPanelRoute';
describe('newPanelRoute', () => {
it('round-trips kind + layoutIndex through the new-panel search', () => {
const search = newPanelSearch('signoz/ListPanel', 2);
const search = newPanelSearch('signoz/ListPanel', {
type: 'section',
layoutIndex: 2,
});
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe('signoz/ListPanel');
expect(parseNewPanelLayoutIndex(search)).toBe(2);
expect(parseNewPanelTarget(search)).toStrictEqual({
type: 'section',
layoutIndex: 2,
});
});
it.each([
{ type: 'root' as const },
{ type: 'newSection' as const, title: 'Errors & 5xx' },
])('round-trips a $type target', (target) => {
expect(
parseNewPanelTarget(newPanelSearch('signoz/TimeSeriesPanel', target)),
).toStrictEqual(target);
});
it('ignores a blank new section title', () => {
expect(parseNewPanelTarget('?newSection=%20%20')).toBeUndefined();
});
it('omits layoutIndex when not provided', () => {
@@ -21,7 +40,7 @@ describe('newPanelRoute', () => {
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe(
'signoz/TimeSeriesPanel',
);
expect(parseNewPanelLayoutIndex(search)).toBeUndefined();
expect(parseNewPanelTarget(search)).toBeUndefined();
});
it('returns null for an existing panel id (not the new sentinel)', () => {

View File

@@ -14,15 +14,14 @@ import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { transferColumnWidths } from '../../Panels/utils/columnWidthStorage';
import { createPanelOps } from '../../patchOps';
import { createPanelOps, type NewPanelTarget } from '../../patchOps';
interface UsePanelEditorSaveArgs {
dashboardId: string;
panelId: string;
/** Creating a new panel (vs editing an existing one) — adds panel + layout. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
target?: NewPanelTarget;
}
interface UsePanelEditorSaveApi {
@@ -42,7 +41,7 @@ export function usePanelEditorSave({
dashboardId,
panelId,
isNew = false,
layoutIndex,
target,
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
const queryClient = useQueryClient();
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
@@ -60,7 +59,7 @@ export function usePanelEditorSave({
savedPanelId = uuid();
ops = createPanelOps({
layouts: cached?.data.spec.layouts ?? [],
layoutIndex,
target,
panelId: savedPanelId,
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
});
@@ -89,7 +88,7 @@ export function usePanelEditorSave({
});
return savedPanelId;
},
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
[dashboardId, panelId, isNew, target, patchAsync, queryClient],
);
return { save, isSaving: isPatching, error };

View File

@@ -2,6 +2,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { NewPanelTarget } from '../patchOps';
import QueryEditorBody from './QueryEditorBody';
import StaticEditorBody from './StaticEditorBody';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
@@ -18,8 +19,7 @@ export interface PanelEditorContainerProps {
savedPanel?: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
target?: NewPanelTarget;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
@@ -38,6 +38,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
const panelKind = draftApi.draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
const originalPanelKind = (savedPanel ?? panel).spec.plugin.kind;
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
@@ -52,6 +53,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
/>
);
}
@@ -62,6 +64,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
/>
);
}

View File

@@ -4,26 +4,33 @@ import type { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import type { NewPanelTarget } from '../patchOps';
import { PANELS } from '../Panels/registry';
import {
PANEL_TYPE_TO_PANEL_KIND,
type PanelKind,
} from '../Panels/types/panelKind';
// New (unsaved) panels use a fixed id segment, carrying kind + target section in the
// query (`/panel/new?panelKind=…&layoutIndex=…`); the real id is generated on save.
// New (unsaved) panels use a fixed id segment, carrying kind + target in the query
// (`/panel/new?panelKind=…&layoutIndex=<index|root>` or `&newSection=<title>`).
export const NEW_PANEL_ID = 'new';
const PANEL_KIND_PARAM = 'panelKind';
const LAYOUT_INDEX_PARAM = 'layoutIndex';
const NEW_SECTION_PARAM = 'newSection';
const ROOT_LAYOUT = 'root';
/** Query string (incl. leading `?`) for the new-panel editor route. */
export function newPanelSearch(
panelKind: PanelKind,
layoutIndex?: number,
target?: NewPanelTarget,
): string {
const params = new URLSearchParams({ [PANEL_KIND_PARAM]: panelKind });
if (layoutIndex !== undefined) {
params.set(LAYOUT_INDEX_PARAM, String(layoutIndex));
if (target?.type === 'section') {
params.set(LAYOUT_INDEX_PARAM, String(target.layoutIndex));
} else if (target?.type === 'root') {
params.set(LAYOUT_INDEX_PARAM, ROOT_LAYOUT);
} else if (target?.type === 'newSection') {
params.set(NEW_SECTION_PARAM, target.title);
}
return `?${params.toString()}`;
}
@@ -75,12 +82,21 @@ export function buildExportPanelLink({
}=${encodeURIComponent(encodeURIComponent(JSON.stringify(query)))}`;
}
/** Target section index for a new panel, or undefined when unset/invalid. */
export function parseNewPanelLayoutIndex(search: string): number | undefined {
const raw = new URLSearchParams(search).get(LAYOUT_INDEX_PARAM);
export function parseNewPanelTarget(
search: string,
): NewPanelTarget | undefined {
const params = new URLSearchParams(search);
const title = params.get(NEW_SECTION_PARAM)?.trim();
if (title) {
return { type: 'newSection', title };
}
const raw = params.get(LAYOUT_INDEX_PARAM);
if (raw === ROOT_LAYOUT) {
return { type: 'root' };
}
if (raw === null || raw === '') {
return undefined;
}
const n = Number(raw);
return Number.isNaN(n) ? undefined : n;
return Number.isNaN(n) ? undefined : { type: 'section', layoutIndex: n };
}

View File

@@ -0,0 +1,48 @@
.splitButton {
display: inline-flex;
min-width: 0;
max-width: 220px;
> .main {
min-width: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
> .toggle {
flex-shrink: 0;
padding-inline: 10px;
border-left: 1px solid
color-mix(in srgb, var(--l1-foreground) 25%, transparent);
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
.menu {
min-width: 240px;
max-width: 320px;
[data-slot='dropdown-menu-item'] {
box-sizing: border-box;
}
}
.menuLabel {
text-transform: uppercase;
}
.check {
margin-left: auto;
color: var(--bg-robin-400);
}
.createOption {
color: var(--bg-robin-400);
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,113 @@
import { useRef } from 'react';
import { Check, ChevronUp, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@signozhq/ui/dropdown-menu';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import type { SectionOption } from './types';
import styles from './AddPanelSplitButton.module.scss';
interface AddPanelSplitButtonProps {
label: string;
options: SectionOption[];
value: string | null;
onChange: (value: string) => void;
onCreate: () => void;
onConfirm: () => void;
disabled: boolean;
}
function AddPanelSplitButton({
label,
options,
value,
onChange,
onCreate,
onConfirm,
disabled,
}: AddPanelSplitButtonProps): JSX.Element {
const createOnCloseRef = useRef(false);
return (
<div className={styles.splitButton}>
<TooltipSimple title={label} delayDuration={1000}>
<Button
color="primary"
size="md"
className={styles.main}
onClick={onConfirm}
disabled={disabled}
data-testid="panel-type-confirm"
>
<span className={styles.ellipsis}>{label}</span>
</Button>
</TooltipSimple>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
color="primary"
size="icon"
className={styles.toggle}
aria-label="Choose section"
>
<ChevronUp size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="end"
sideOffset={6}
className={styles.menu}
// Mounted after close, so the name input's autofocus isn't undone.
onCloseAutoFocus={(e): void => {
if (createOnCloseRef.current) {
createOnCloseRef.current = false;
e.preventDefault();
onCreate();
}
}}
>
<DropdownMenuLabel className={styles.menuLabel}>
Add to section
</DropdownMenuLabel>
{options.map((option) => (
<DropdownMenuItem
key={option.value}
onSelect={(): void => onChange(option.value)}
rightIcon={
option.value === value ? (
<Check size={14} className={styles.check} />
) : undefined
}
title={option.label}
testId={`panel-section-option-${option.value}`}
>
<span className={styles.ellipsis}>{option.label}</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
className={styles.createOption}
leftIcon={<Plus size={14} />}
onSelect={(): void => {
createOnCloseRef.current = true;
}}
testId="panel-section-create"
>
New section
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export default AddPanelSplitButton;

View File

@@ -0,0 +1,4 @@
.nameInput {
flex: 0 1 260px;
min-width: 140px;
}

View File

@@ -0,0 +1,56 @@
import type { KeyboardEvent } from 'react';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import styles from './NewSectionNameInput.module.scss';
interface NewSectionNameInputProps {
value: string;
onChange: (value: string) => void;
onCancel: () => void;
onSubmit: () => void;
}
function NewSectionNameInput({
value,
onChange,
onCancel,
onSubmit,
}: NewSectionNameInputProps): JSX.Element {
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
onSubmit();
} else if (e.key === 'Escape') {
// Drop the draft only, not the drawer.
e.stopPropagation();
onCancel();
}
};
return (
<Input
autoFocus
value={value}
placeholder="New section name"
containerClassName={styles.nameInput}
onChange={(e): void => onChange(e.target.value)}
onKeyDown={handleKeyDown}
testId="panel-section-name"
suffix={
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Cancel new section"
onClick={onCancel}
testId="panel-section-name-cancel"
>
<X size={14} />
</Button>
}
/>
);
}
export default NewSectionNameInput;

View File

@@ -0,0 +1,184 @@
.browser {
display: flex;
flex-direction: column;
}
.controls {
position: sticky;
// Cover the drawer body's top padding so tiles don't peek above when stuck.
top: -16px;
z-index: 1;
display: flex;
flex-direction: column;
gap: 14px;
margin-top: -16px;
padding-block: 16px;
border-bottom: 1px dashed var(--l1-border);
// Matches the drawer surface, which the app pins to l1 for every drawer.
background: var(--l1-background);
}
.search {
box-sizing: border-box;
--input-wrapper-background: var(--l2-background);
--input-wrapper-border-color: var(--l2-border);
--input-hover-border-color: var(--l3-border);
--input-box-shadow: none;
}
.categories {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.category {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
border: 1px solid var(--l1-border);
border-radius: 999px;
background: transparent;
color: var(--l2-foreground);
font: inherit;
font-size: 12px;
white-space: nowrap;
cursor: pointer;
&:hover {
color: var(--l1-foreground);
}
}
.categoryActive {
border-color: var(--l3-border);
background: var(--l3-background);
color: var(--l1-foreground);
}
.categoryCount {
color: var(--l3-foreground);
font-size: 10.5px;
}
.groups {
display: flex;
flex-direction: column;
gap: 24px;
padding-top: 20px;
}
.group {
display: flex;
flex-direction: column;
gap: 12px;
}
.groupLabel {
color: var(--bg-sienna-400);
font-size: 11px;
letter-spacing: 0.88px;
text-transform: uppercase;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.tile {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border: 1px solid var(--l1-border);
border-radius: 6px;
background: var(--l2-background);
font: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 200ms cubic-bezier(0.08, 0.52, 0.52, 1),
border-color 200ms cubic-bezier(0.08, 0.52, 0.52, 1);
&:hover {
background: var(--l3-background);
}
}
.tileSelected {
border-color: var(--bg-robin-500);
box-shadow: inset 0 0 0 1px var(--bg-robin-500);
background: var(--l3-background);
}
.tileDisabled {
opacity: 0.45;
cursor: not-allowed;
&:hover {
background: var(--l2-background);
}
}
.tileText {
display: flex;
flex-direction: column;
gap: 3px;
}
.tileTitle {
display: flex;
align-items: center;
gap: 7px;
}
.tileName {
color: var(--l1-foreground);
font-size: 13.5px;
font-weight: 500;
}
.newBadge {
padding: 2px 6px;
border: 1px solid var(--bg-sienna-400);
border-radius: 3px;
color: var(--bg-sienna-400);
font-size: 9.5px;
font-weight: 500;
letter-spacing: 0.6px;
text-transform: uppercase;
}
.tileDescription {
color: var(--l2-foreground);
font-size: 12px;
line-height: 1.45;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 48px 0;
color: var(--l2-foreground);
font-size: 13px;
text-align: center;
}
.clearSearch {
padding: 0;
border: none;
background: none;
color: var(--bg-robin-500);
font: inherit;
font-size: 12.5px;
cursor: pointer;
&:hover {
color: var(--bg-robin-400);
}
}

View File

@@ -0,0 +1,118 @@
import { useMemo, useState } from 'react';
import { Search } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import cx from 'classnames';
import type { PanelKind } from '../../../Panels/types/panelKind';
import {
filterPanelTypeGroups,
type PanelTypeGroupId,
} from './panelTypeCatalog';
import PanelTypeTile from './PanelTypeTile';
import styles from './PanelTypeBrowser.module.scss';
type CategoryId = PanelTypeGroupId | 'all';
interface PanelTypeBrowserProps {
selectedKind: PanelKind;
onSelect: (kind: PanelKind) => void;
getDisabledReason?: (kind: PanelKind) => string | undefined;
}
/** Searchable, category-filtered grid of panel types, grouped by purpose. */
function PanelTypeBrowser({
selectedKind,
onSelect,
getDisabledReason,
}: PanelTypeBrowserProps): JSX.Element {
const [query, setQuery] = useState('');
const [category, setCategory] = useState<CategoryId>('all');
const matched = useMemo(() => filterPanelTypeGroups(query), [query]);
const categories = useMemo(
() => [
{
id: 'all' as const,
label: 'All',
count: matched.reduce((sum, group) => sum + group.items.length, 0),
},
...matched.map(({ id, label, items }) => ({
id,
label,
count: items.length,
})),
],
[matched],
);
const visibleGroups = matched.filter(
(group) =>
group.items.length > 0 && (category === 'all' || group.id === category),
);
return (
<div className={styles.browser}>
<div className={styles.controls}>
<Input
value={query}
onChange={(e): void => setQuery(e.target.value)}
placeholder="Search panel types"
prefix={<Search size={14} />}
testId="panel-type-search"
containerClassName={styles.search}
/>
<div className={styles.categories}>
{categories.map(({ id, label, count }) => (
<button
key={id}
type="button"
className={cx(styles.category, {
[styles.categoryActive]: category === id,
})}
aria-pressed={category === id}
onClick={(): void => setCategory(id)}
>
{label}
<span className={styles.categoryCount}>{count}</span>
</button>
))}
</div>
</div>
<div className={styles.groups}>
{visibleGroups.map((group) => (
<section key={group.id} className={styles.group}>
<span className={styles.groupLabel}>{group.label}</span>
<div className={styles.grid}>
{group.items.map((item) => (
<PanelTypeTile
key={item.kind}
item={item}
isSelected={item.kind === selectedKind}
disabledReason={getDisabledReason?.(item.kind)}
onSelect={(): void => onSelect(item.kind)}
/>
))}
</div>
</section>
))}
{visibleGroups.length === 0 && (
<div className={styles.empty}>
<span>No panel types match “{query}”</span>
<button
type="button"
className={styles.clearSearch}
onClick={(): void => setQuery('')}
>
Clear search
</button>
</div>
)}
</div>
</div>
);
}
export default PanelTypeBrowser;

View File

@@ -0,0 +1,124 @@
.preview {
height: 66px;
padding: 10px;
box-sizing: border-box;
overflow: hidden;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l1-background);
}
.svg {
display: block;
width: 100%;
height: 100%;
}
.bars,
.histogram {
display: flex;
align-items: flex-end;
height: 100%;
}
.bars {
gap: 7px;
}
.histogram {
gap: 2px;
}
.bar {
flex: 1;
border-radius: 1px;
background: var(--bg-robin-500);
}
.number {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
height: 100%;
}
.numberValue {
color: var(--l1-foreground);
font-size: 21px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.5px;
}
.numberUnit {
color: var(--l3-foreground);
font-size: 12px;
}
.numberLabel {
color: var(--l3-foreground);
font-size: 8px;
letter-spacing: 0.6px;
text-transform: uppercase;
}
.rows {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
}
.tableRow {
display: grid;
grid-template-columns: 1.4fr 1fr 0.7fr;
gap: 8px;
}
.listRow {
display: flex;
align-items: center;
gap: 6px;
.cell {
flex: 1;
}
}
.cell,
.headCell,
.accentCell {
display: block;
height: 4px;
border-radius: 2px;
}
.cell {
background: var(--l3-background);
}
.headCell {
background: var(--l3-border);
}
.accentCell {
background: var(--bg-robin-500);
opacity: 0.6;
}
.dot,
.accentDot {
width: 4px;
height: 4px;
border-radius: 50%;
}
.dot {
background: var(--l3-border);
}
.accentDot {
background: var(--bg-robin-500);
}

View File

@@ -0,0 +1,14 @@
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPE_PREVIEWS } from './panelTypePreviews';
import styles from './PanelTypePreview.module.scss';
function PanelTypePreview({ kind }: { kind: PanelKind }): JSX.Element {
return (
<div className={styles.preview} aria-hidden>
{PANEL_TYPE_PREVIEWS[kind]}
</div>
);
}
export default PanelTypePreview;

View File

@@ -1,69 +1,18 @@
.panelTypeSection {
@use '../../../../../../styles/scrollbar' as *;
.body {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
gap: 16px;
min-height: 0;
overflow-y: auto;
@include custom-scrollbar;
}
.grid {
align-self: stretch;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.panelTypeCard {
.footer {
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid var(--l2-border);
background: var(--l2-background);
padding: 12px;
gap: 12px;
cursor: pointer;
font: inherit;
border-radius: 4px;
color: var(--l1-foreground);
transition:
transform 180ms ease,
border-color 180ms ease;
&:hover {
background-color: var(--l2-background-hover);
border-color: var(--bg-robin-400);
}
&:active {
transform: translateY(2px);
}
}
.panelTypeCardSelected {
border-color: var(--bg-robin-400);
background-color: var(--l2-background-hover);
box-shadow: inset 0 0 0 1px var(--bg-robin-400);
}
.footerActions {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 8px;
gap: 10px;
width: 100%;
}
.footerPicker {
// Take all the width left over by the (natural-width) confirm button.
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.pickerLabel {
color: var(--l3-foreground);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
}

View File

@@ -1,19 +1,26 @@
import { useEffect, useMemo, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { DialogWrapper } from '@signozhq/ui/dialog';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { useDashboardSections } from '../../../hooks/useDashboardSections';
import { PANEL_OPTIONS } from '../../../Panels/registry';
import { releasePanelPickerTarget } from '../../../store/usePanelPickerTargetStore';
import type { NewPanelTarget } from '../../../patchOps';
import type { PanelKind } from '../../../Panels/types/panelKind';
import PanelTypeSelectionModalFooter from './PanelTypeSelectionModalFooter';
import AddPanelSplitButton from './AddPanelSplitButton';
import NewSectionNameInput from './NewSectionNameInput';
import PanelTypeBrowser from './PanelTypeBrowser';
import { usePanelPickerDraftSection } from './usePanelPickerDraftSection';
import { usePanelPickerTarget } from './usePanelPickerTarget';
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
import styles from './PanelTypeSelectionModal.module.scss';
const DEFAULT_PANEL_KIND: PanelKind = 'signoz/TimeSeriesPanel';
interface PanelTypeSelectionModalProps {
open: boolean;
onClose: () => void;
onSelect: (panelKind: PanelKind, layoutIndex?: number) => void;
onSelect: (panelKind: PanelKind, target?: NewPanelTarget) => void;
/** Section the picker opens on; omit → the first section. */
defaultLayoutIndex?: number;
}
@@ -26,89 +33,112 @@ function PanelTypeSelectionModal({
}: PanelTypeSelectionModalProps): JSX.Element {
const sections = useDashboardSections();
const options = useMemo(() => buildSectionOptions(sections), [sections]);
// With more than one section the user must pick a target section, so we keep
// the select-then-confirm flow. Otherwise there's nothing to choose: hide the
// footer and let a tile click create the panel outright.
const hasSectionPicker = options.length > 1;
const [selectedValue, setSelectedValue] = useState('');
const [selectedPanelKind, setSelectedPanelKind] = useState<PanelKind | null>(
null,
);
const [selectedKind, setSelectedKind] =
useState<PanelKind>(DEFAULT_PANEL_KIND);
const [newSectionTitle, setNewSectionTitle] = useState<string | null>(null);
const isCreatingSection = newSectionTitle !== null;
// Seed the target section on open.
useEffect(() => {
if (open) {
setSelectedValue(resolveDefaultSectionValue(options, defaultLayoutIndex));
setSelectedPanelKind(null);
setSelectedKind(DEFAULT_PANEL_KIND);
setNewSectionTitle(null);
}
}, [open, options, defaultLayoutIndex]);
const createPanel = (panelKind: PanelKind): void => {
const layoutIndex = selectedValue === '' ? undefined : Number(selectedValue);
onSelect(panelKind, layoutIndex);
const selectedOption = options.find((o) => o.value === selectedValue);
const selectedTarget = selectedOption?.target;
const selectedLayoutIndex =
selectedTarget?.type === 'section' ? selectedTarget.layoutIndex : undefined;
usePanelPickerTarget({
open: open && !isCreatingSection,
layoutIndex: selectedLayoutIndex,
panelKind: selectedKind,
outline: hasSectionPicker,
});
usePanelPickerDraftSection(newSectionTitle, selectedKind, open);
const handleClose = (): void => {
releasePanelPickerTarget(true);
onClose();
};
const handleTileClick = (panelKind: PanelKind): void => {
if (hasSectionPicker) {
setSelectedPanelKind(panelKind);
return;
}
createPanel(panelKind);
};
const sectionTitle = newSectionTitle?.trim() ?? '';
const handleConfirm = (): void => {
if (selectedPanelKind === null) {
if (isCreatingSection && !sectionTitle) {
return;
}
createPanel(selectedPanelKind);
releasePanelPickerTarget(false);
onSelect(
selectedKind,
isCreatingSection
? { type: 'newSection', title: sectionTitle }
: selectedTarget,
);
};
const handleSectionChange = (value: string): void => {
setSelectedValue(value);
setNewSectionTitle(null);
};
let confirmLabel = 'Add panel';
if (isCreatingSection) {
confirmLabel = 'Add to new section';
} else if (hasSectionPicker && selectedOption) {
confirmLabel = `Add to ${selectedOption.label}`;
}
return (
<DialogWrapper
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
handleClose();
}
}}
title="New Panel"
title="New panel"
subTitle="Pick a visualization. You can change it later."
direction="right"
width="wide"
testId="panel-type-drawer"
drawerDescriptionProps={{ className: styles.body }}
footer={
hasSectionPicker ? (
<PanelTypeSelectionModalFooter
<div className={styles.footer}>
{isCreatingSection && (
<NewSectionNameInput
value={newSectionTitle}
onChange={setNewSectionTitle}
onCancel={(): void => setNewSectionTitle(null)}
onSubmit={handleConfirm}
/>
)}
<Button
variant="outlined"
color="secondary"
size="md"
onClick={handleClose}
>
Cancel
</Button>
<AddPanelSplitButton
label={confirmLabel}
options={options}
selectedValue={selectedValue}
onSectionChange={setSelectedValue}
isConfirmDisabled={selectedPanelKind === null}
value={isCreatingSection ? null : selectedValue}
onChange={handleSectionChange}
onCreate={(): void => setNewSectionTitle('')}
onConfirm={handleConfirm}
disabled={isCreatingSection && !sectionTitle}
/>
) : undefined
</div>
}
>
<div className={styles.panelTypeSection}>
{hasSectionPicker && (
<span className={styles.pickerLabel}>Select panel type</span>
)}
<div className={styles.grid}>
{PANEL_OPTIONS.map(({ kind, displayName, icon: Icon }) => (
<button
key={kind}
type="button"
className={cx(styles.panelTypeCard, {
[styles.panelTypeCardSelected]: kind === selectedPanelKind,
})}
data-testid={`panel-type-${kind}`}
aria-pressed={kind === selectedPanelKind}
onClick={(): void => handleTileClick(kind)}
>
<Icon size={24} color={Color.BG_ROBIN_400} />
{displayName}
</button>
))}
</div>
</div>
</DialogWrapper>
<PanelTypeBrowser selectedKind={selectedKind} onSelect={setSelectedKind} />
</DrawerWrapper>
);
}

View File

@@ -1,53 +0,0 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import SectionPicker from './SectionPicker';
import type { SectionOption } from './types';
import styles from './PanelTypeSelectionModal.module.scss';
interface PanelTypeSelectionModalFooterProps {
options: SectionOption[];
selectedValue: string;
onSectionChange: (value: string) => void;
/** Disabled until a panel type is picked. */
isConfirmDisabled: boolean;
onConfirm: () => void;
}
/**
* Footer for the New Panel modal: an "Add panel to" section picker and the
* confirm button. Only rendered when the dashboard has more than one section —
* otherwise there's nothing to pick and a tile click creates the panel directly.
*/
function PanelTypeSelectionModalFooter({
options,
selectedValue,
onSectionChange,
isConfirmDisabled,
onConfirm,
}: PanelTypeSelectionModalFooterProps): JSX.Element {
return (
<div className={styles.footerActions}>
<div className={styles.footerPicker}>
<span className={styles.pickerLabel}>Add panel to</span>
<SectionPicker
options={options}
value={selectedValue}
onChange={onSectionChange}
/>
</div>
<Button
color="primary"
size="md"
disabled={isConfirmDisabled}
prefix={<Plus size={16} />}
onClick={onConfirm}
testId="panel-type-confirm"
>
Add Panel
</Button>
</div>
);
}
export default PanelTypeSelectionModalFooter;

View File

@@ -0,0 +1,54 @@
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import type { PanelTypeItem } from './panelTypeCatalog';
import PanelTypePreview from './PanelTypePreview';
import styles from './PanelTypeBrowser.module.scss';
interface PanelTypeTileProps {
item: PanelTypeItem;
isSelected: boolean;
/** Why the kind can't be picked; the tile is disabled when set. */
disabledReason?: string;
onSelect: () => void;
}
function PanelTypeTile({
item,
isSelected,
disabledReason,
onSelect,
}: PanelTypeTileProps): JSX.Element {
const tile = (
<button
type="button"
className={cx(styles.tile, {
[styles.tileSelected]: isSelected,
[styles.tileDisabled]: !!disabledReason,
})}
data-testid={`panel-type-${item.kind}`}
aria-pressed={isSelected}
// aria-disabled, not disabled, so the reason tooltip still gets hover events.
aria-disabled={!!disabledReason}
onClick={disabledReason ? undefined : onSelect}
>
<PanelTypePreview kind={item.kind} />
<span className={styles.tileText}>
<span className={styles.tileTitle}>
<span className={styles.tileName}>{item.displayName}</span>
{item.isNew && <span className={styles.newBadge}>New</span>}
</span>
<span className={styles.tileDescription}>{item.description}</span>
</span>
</button>
);
return disabledReason ? (
<TooltipSimple title={disabledReason}>{tile}</TooltipSimple>
) : (
tile
);
}
export default PanelTypeTile;

View File

@@ -0,0 +1,29 @@
import styles from './PanelTypePreview.module.scss';
interface PreviewBarsProps {
heights: number[];
/** Fade each successive bar (ranked bars) instead of a uniform opacity. */
fade: boolean;
className: string;
}
function PreviewBars({
heights,
fade,
className,
}: PreviewBarsProps): JSX.Element {
return (
<div className={className}>
{heights.map((height, i) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={i}
className={styles.bar}
style={{ height: `${height}%`, opacity: fade ? 1 - i * 0.15 : 0.75 }}
/>
))}
</div>
);
}
export default PreviewBars;

View File

@@ -0,0 +1,34 @@
import styles from './PanelTypePreview.module.scss';
export interface PreviewCell {
className: string;
/** Percent of the row width. */
width?: number;
}
interface PreviewRowsProps {
rows: PreviewCell[][];
rowClassName?: string;
}
function PreviewRows({ rows, rowClassName }: PreviewRowsProps): JSX.Element {
return (
<div className={styles.rows}>
{rows.map((cells, row) => (
// eslint-disable-next-line react/no-array-index-key
<div key={row} className={rowClassName}>
{cells.map(({ className, width }, col) => (
<span
// eslint-disable-next-line react/no-array-index-key
key={col}
className={className}
style={width === undefined ? undefined : { width: `${width}%` }}
/>
))}
</div>
))}
</div>
);
}
export default PreviewRows;

View File

@@ -1,55 +0,0 @@
.select {
width: 100%;
}
.dropdown {
min-width: 260px;
}
.rootIcon {
color: var(--bg-robin-400);
flex-shrink: 0;
}
.sectionIcon {
color: var(--l3-foreground);
flex-shrink: 0;
}
.triggerValue {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.triggerLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.optionRow {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0;
}
.optionText {
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.optionLabel {
color: var(--l1-foreground);
line-height: 1.2;
}
.optionDescription {
color: var(--l3-foreground);
font-size: 12px;
line-height: 1.2;
}

View File

@@ -1,65 +0,0 @@
import { useMemo } from 'react';
// eslint-disable-next-line signoz/no-antd-components
import { Select } from 'antd';
import type { SectionOption } from './types';
import styles from './SectionPicker.module.scss';
interface SectionPickerProps {
options: SectionOption[];
value: string;
onChange: (value: string) => void;
}
function SectionPicker({
options,
value,
onChange,
}: SectionPickerProps): JSX.Element {
// `selectedLabel` (one line) shows in the trigger; `label` (two lines) in the list.
const selectOptions = useMemo(
() =>
options.map((option) => {
const iconClass = option.isRoot ? styles.rootIcon : styles.sectionIcon;
return {
value: option.value,
selectedLabel: (
<span className={styles.triggerValue}>
<option.Icon size={16} className={iconClass} />
<span className={styles.triggerLabel}>{option.label}</span>
</span>
),
label: (
<span
className={styles.optionRow}
data-testid={`panel-section-option-${option.layoutIndex}`}
>
<option.Icon size={16} className={iconClass} />
<span className={styles.optionText}>
<span className={styles.optionLabel}>{option.label}</span>
<span className={styles.optionDescription}>{option.description}</span>
</span>
</span>
),
};
}),
[options],
);
return (
<Select<string>
className={styles.select}
popupClassName={styles.dropdown}
value={value}
onChange={onChange}
data-testid="panel-section-select"
optionLabelProp="selectedLabel"
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement ?? document.body
}
options={selectOptions}
/>
);
}
export default SectionPicker;

View File

@@ -0,0 +1,327 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { useDashboardSections } from '../../../../hooks/useDashboardSections';
import { usePanelPickerTargetStore } from '../../../../store/usePanelPickerTargetStore';
import PanelTypeSelectionModal from '../PanelTypeSelectionModal';
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
jest.mock('../../../../Panels/registry', () => {
const options = [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/AreaChartPanel', displayName: 'Area' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
{ kind: 'signoz/TextPanel', displayName: 'Text' },
].map((option) => ({ ...option, icon: (): null => null }));
return {
PANEL_OPTIONS: options,
getPanelDefinition: (kind: string): unknown =>
options.find((option) => option.kind === kind),
};
});
jest.mock('../../../../hooks/useDashboardSections', () => ({
useDashboardSections: jest.fn(),
}));
const mockUseDashboardSections = useDashboardSections as jest.Mock;
const ROOT_ONLY = [{ layoutIndex: 0, title: undefined, panelIds: [] }];
const WITH_SECTIONS = [
{ layoutIndex: 0, title: 'Overview', panelIds: [] },
{ layoutIndex: 1, title: 'Latency', panelIds: [] },
];
type User = ReturnType<typeof userEvent.setup>;
function renderDrawer(
props: Partial<Parameters<typeof PanelTypeSelectionModal>[0]> = {},
): {
onSelect: jest.Mock;
onClose: jest.Mock;
user: User;
} {
// The open drawer sets `pointer-events: none` on the body.
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSelect = jest.fn();
const onClose = jest.fn();
render(
<TooltipProvider>
<PanelTypeSelectionModal
open
onClose={onClose}
onSelect={onSelect}
{...props}
/>
</TooltipProvider>,
);
return { onSelect, onClose, user };
}
async function openSectionMenu(user: User): Promise<void> {
await user.click(screen.getByRole('button', { name: 'Choose section' }));
}
async function startNewSection(user: User): Promise<void> {
await openSectionMenu(user);
await user.click(screen.getByTestId('panel-section-create'));
await screen.findByTestId('panel-section-name');
}
describe('PanelTypeSelectionModal', () => {
beforeEach(() => {
mockUseDashboardSections.mockReturnValue(ROOT_ONLY);
usePanelPickerTargetStore.getState().reset();
});
it('adds the default Time Series panel when confirmed untouched', async () => {
const { onSelect, user } = renderDrawer();
await user.click(screen.getByTestId('panel-type-confirm'));
expect(onSelect).toHaveBeenCalledWith('signoz/TimeSeriesPanel', {
type: 'section',
layoutIndex: 0,
});
});
it('selects a tile, then adds it on confirm', async () => {
const { onSelect, user } = renderDrawer();
await user.click(screen.getByTestId('panel-type-signoz/TablePanel'));
expect(onSelect).not.toHaveBeenCalled();
expect(screen.getByTestId('panel-type-signoz/TablePanel')).toHaveAttribute(
'aria-pressed',
'true',
);
await user.click(screen.getByTestId('panel-type-confirm'));
expect(onSelect).toHaveBeenCalledWith('signoz/TablePanel', {
type: 'section',
layoutIndex: 0,
});
});
it('names no section in the CTA when the dashboard has a single layout', () => {
renderDrawer();
expect(screen.getByTestId('panel-type-confirm')).toHaveTextContent(
'Add panel',
);
});
it('targets the section it was opened against', async () => {
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
const { onSelect, user } = renderDrawer({ defaultLayoutIndex: 1 });
const confirm = screen.getByTestId('panel-type-confirm');
expect(confirm).toHaveTextContent('Add to Latency');
await user.click(confirm);
expect(onSelect).toHaveBeenCalledWith('signoz/TimeSeriesPanel', {
type: 'section',
layoutIndex: 1,
});
});
it('switches the target section from the menu', async () => {
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
const { onSelect, user } = renderDrawer({ defaultLayoutIndex: 1 });
await openSectionMenu(user);
await user.click(screen.getByTestId('panel-section-option-0'));
const confirm = screen.getByTestId('panel-type-confirm');
expect(confirm).toHaveTextContent('Add to Overview');
await user.click(confirm);
expect(onSelect).toHaveBeenCalledWith('signoz/TimeSeriesPanel', {
type: 'section',
layoutIndex: 0,
});
});
it('defaults to the root on a sectioned dashboard, even one without a root', async () => {
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
const { onSelect, user } = renderDrawer();
await user.click(screen.getByTestId('panel-type-confirm'));
expect(onSelect).toHaveBeenCalledWith('signoz/TimeSeriesPanel', {
type: 'root',
});
});
it('filters tiles by search and offers to clear an empty result', async () => {
const { user } = renderDrawer();
const search = screen.getByTestId('panel-type-search');
await user.clear(search);
await user.type(search, 'markdown');
expect(screen.getByTestId('panel-type-signoz/TextPanel')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-type-signoz/TablePanel'),
).not.toBeInTheDocument();
await user.clear(search);
await user.type(search, 'zzz');
expect(screen.getByText('No panel types match “zzz”')).toBeInTheDocument();
await user.click(screen.getByText('Clear search'));
expect(
screen.getByTestId('panel-type-signoz/TablePanel'),
).toBeInTheDocument();
});
it('narrows tiles to the chosen category', async () => {
const { user } = renderDrawer();
await user.click(screen.getByRole('button', { name: /Raw records/ }));
expect(screen.getByTestId('panel-type-signoz/ListPanel')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-type-signoz/TimeSeriesPanel'),
).not.toBeInTheDocument();
});
it('closes on Cancel', async () => {
const { onClose, onSelect, user } = renderDrawer();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
});
describe('new section', () => {
it('asks for the named section, created when the panel is saved', async () => {
const { onSelect, user } = renderDrawer();
await startNewSection(user);
const confirm = screen.getByTestId('panel-type-confirm');
expect(confirm).toBeDisabled();
expect(confirm).toHaveTextContent('Add to new section');
await user.type(screen.getByTestId('panel-section-name'), ' Errors ');
await user.click(confirm);
expect(onSelect).toHaveBeenCalledWith('signoz/TimeSeriesPanel', {
type: 'newSection',
title: 'Errors',
});
});
it('publishes the draft for the dashboard preview', async () => {
const draft = (): unknown =>
usePanelPickerTargetStore.getState().draftSection;
const { user } = renderDrawer();
await startNewSection(user);
await waitFor(() =>
expect(draft()).toStrictEqual({
title: '',
panelKind: 'signoz/TimeSeriesPanel',
}),
);
await user.type(screen.getByTestId('panel-section-name'), 'Errors');
await user.click(screen.getByTestId('panel-type-signoz/TablePanel'));
expect(draft()).toStrictEqual({
title: 'Errors',
panelKind: 'signoz/TablePanel',
});
await user.click(screen.getByTestId('panel-section-name-cancel'));
expect(draft()).toBeNull();
});
it('returns to the section picker when the draft is cancelled', async () => {
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
const { user } = renderDrawer();
await startNewSection(user);
expect(screen.getByTestId('panel-section-name')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByTestId('panel-section-name')).not.toBeInTheDocument();
expect(screen.getByTestId('panel-type-confirm')).toHaveTextContent(
'Add to Dashboard (root)',
);
});
});
describe('section highlight', () => {
const target = (): unknown => usePanelPickerTargetStore.getState().target;
it('targets the only section without outlining it', () => {
renderDrawer();
expect(target()).toStrictEqual({
layoutIndex: 0,
panelKind: 'signoz/TimeSeriesPanel',
outline: false,
});
});
it('publishes the chosen section and kind while open', async () => {
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
const { user } = renderDrawer({ defaultLayoutIndex: 1 });
await user.click(screen.getByTestId('panel-type-signoz/TablePanel'));
expect(target()).toStrictEqual({
layoutIndex: 1,
panelKind: 'signoz/TablePanel',
outline: true,
});
});
it('drops the target while a new section is being named', async () => {
const { user } = renderDrawer();
await startNewSection(user);
await waitFor(() => expect(target()).toBeNull());
});
it('restores the pre-reveal scroll position on cancel', async () => {
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
const { user } = renderDrawer({ defaultLayoutIndex: 1 });
const scrollTo = jest.fn();
// jsdom elements have no scrollTo.
const scroller = { scrollTo } as unknown as HTMLElement;
usePanelPickerTargetStore
.getState()
.rememberScrollOrigin({ element: scroller, top: 120 });
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(scrollTo).toHaveBeenCalledWith({
top: 120,
behavior: 'smooth',
});
expect(target()).toBeNull();
});
it('keeps the scroll position when a panel is added', async () => {
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
const { user } = renderDrawer({ defaultLayoutIndex: 1 });
const scrollTo = jest.fn();
// jsdom elements have no scrollTo.
const scroller = { scrollTo } as unknown as HTMLElement;
usePanelPickerTargetStore
.getState()
.rememberScrollOrigin({ element: scroller, top: 120 });
await user.click(screen.getByTestId('panel-type-confirm'));
expect(scrollTo).not.toHaveBeenCalled();
expect(usePanelPickerTargetStore.getState().scrollOrigin).toBeNull();
});
});
});

View File

@@ -0,0 +1,87 @@
import { PANEL_OPTIONS, type PanelOption } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
export type PanelTypeGroupId =
| 'trends'
| 'compare'
| 'distributions'
| 'single'
| 'raw'
| 'docs';
export const PANEL_TYPE_GROUPS: { id: PanelTypeGroupId; label: string }[] = [
{ id: 'trends', label: 'Trends over time' },
{ id: 'compare', label: 'Compare & rank' },
{ id: 'distributions', label: 'Distributions' },
{ id: 'single', label: 'Single values' },
{ id: 'raw', label: 'Raw records' },
{ id: 'docs', label: 'Documentation' },
];
interface PanelTypeMeta {
group: PanelTypeGroupId;
description: string;
isNew?: boolean;
}
// Total over PanelKind, so a new kind fails to compile until it's placed in a group.
const PANEL_TYPE_META: Record<PanelKind, PanelTypeMeta> = {
'signoz/TimeSeriesPanel': {
group: 'trends',
description: 'Values plotted against time',
},
'signoz/AreaChartPanel': {
group: 'trends',
description: 'Stacked volume over time',
isNew: true,
},
'signoz/BarChartPanel': {
group: 'compare',
description: 'Compare values across categories',
},
'signoz/PieChartPanel': { group: 'compare', description: 'Share of a whole' },
'signoz/HistogramPanel': {
group: 'distributions',
description: 'Distribution of values into buckets',
},
'signoz/NumberPanel': {
group: 'single',
description: 'One aggregate value, large',
},
'signoz/TablePanel': {
group: 'raw',
description: 'Rows and columns of results',
},
'signoz/ListPanel': { group: 'raw', description: 'Raw log and span records' },
'signoz/TextPanel': {
group: 'docs',
description: 'Markdown notes and context',
isNew: true,
},
};
export type PanelTypeItem = PanelOption & Omit<PanelTypeMeta, 'group'>;
export interface PanelTypeGroup {
id: PanelTypeGroupId;
label: string;
items: PanelTypeItem[];
}
/** Every group with the items matching `query` (name, description or group label); empty groups included. */
export function filterPanelTypeGroups(query: string): PanelTypeGroup[] {
const q = query.trim().toLowerCase();
return PANEL_TYPE_GROUPS.map(({ id, label }) => ({
id,
label,
items: PANEL_OPTIONS.filter(({ kind }) => PANEL_TYPE_META[kind].group === id)
.map((option) => ({ ...option, ...PANEL_TYPE_META[option.kind] }))
.filter(
(item) =>
!q ||
item.displayName.toLowerCase().includes(q) ||
item.description.toLowerCase().includes(q) ||
label.toLowerCase().includes(q),
),
}));
}

View File

@@ -0,0 +1,124 @@
import type { PanelKind } from '../../../Panels/types/panelKind';
import PreviewBars from './PreviewBars';
import PreviewRows, { type PreviewCell } from './PreviewRows';
import styles from './PanelTypePreview.module.scss';
// CSS var() doesn't resolve in SVG presentation attributes, so colors go via `style`.
const LINE = { stroke: 'var(--bg-robin-400)' };
const AREA_FILL = { fill: 'var(--bg-robin-500)', fillOpacity: 0.22 };
const RING_TRACK = { stroke: 'var(--l3-background)' };
const RING_PRIMARY = { stroke: 'var(--bg-robin-500)' };
const RING_SECONDARY = { stroke: 'var(--bg-robin-400)', strokeOpacity: 0.55 };
const LINE_PATH =
'M0 34 12 26 24 30 36 16 48 22 60 10 72 18 84 8 96 14 108 5 120 11';
const AREA_PATH = 'M0 30 20 20 40 26 60 12 80 18 100 8 120 14';
const HEAD: PreviewCell = { className: styles.headCell };
const CELL: PreviewCell = { className: styles.cell };
const ACCENT: PreviewCell = { className: styles.accentCell };
const DOT: PreviewCell = { className: styles.dot };
const ACCENT_DOT: PreviewCell = { className: styles.accentDot };
const TABLE_ROWS = [
[HEAD, HEAD, HEAD],
[CELL, CELL, ACCENT],
[CELL, CELL, CELL],
[CELL, CELL, CELL],
];
const LIST_ROWS = [
[ACCENT_DOT, CELL],
[DOT, CELL],
[DOT, CELL],
[DOT, CELL],
];
const TEXT_ROWS = [45, 100, 92, 64].map((width, i) => [
{ ...(i === 0 ? HEAD : CELL), width },
]);
/** Decorative mini-chart per panel kind; total so a new kind needs a sketch. */
export const PANEL_TYPE_PREVIEWS: Record<PanelKind, JSX.Element> = {
'signoz/TimeSeriesPanel': (
<svg viewBox="0 0 120 44" preserveAspectRatio="none" className={styles.svg}>
<path
d={LINE_PATH}
fill="none"
strokeWidth={1.6}
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
style={LINE}
/>
</svg>
),
'signoz/AreaChartPanel': (
<svg viewBox="0 0 120 44" preserveAspectRatio="none" className={styles.svg}>
<path d={`${AREA_PATH}V44H0Z`} style={AREA_FILL} />
<path
d={AREA_PATH}
fill="none"
strokeWidth={1.6}
vectorEffect="non-scaling-stroke"
style={LINE}
/>
</svg>
),
'signoz/BarChartPanel': (
<PreviewBars heights={[80, 58, 44, 30, 18]} fade className={styles.bars} />
),
'signoz/HistogramPanel': (
<PreviewBars
heights={[14, 26, 52, 88, 100, 70, 40, 20, 10]}
fade={false}
className={styles.histogram}
/>
),
'signoz/PieChartPanel': (
<svg viewBox="0 0 44 44" className={styles.svg}>
<circle
cx="22"
cy="22"
r="16"
fill="none"
strokeWidth={8}
style={RING_TRACK}
/>
<circle
cx="22"
cy="22"
r="16"
fill="none"
strokeWidth={8}
strokeDasharray="50 100.5"
transform="rotate(-90 22 22)"
style={RING_PRIMARY}
/>
<circle
cx="22"
cy="22"
r="16"
fill="none"
strokeWidth={8}
strokeDasharray="28 100.5"
strokeDashoffset={-50}
transform="rotate(-90 22 22)"
style={RING_SECONDARY}
/>
</svg>
),
'signoz/NumberPanel': (
<div className={styles.number}>
<span className={styles.numberValue}>
99.7<span className={styles.numberUnit}>%</span>
</span>
<span className={styles.numberLabel}>Availability</span>
</div>
),
'signoz/TablePanel': (
<PreviewRows rows={TABLE_ROWS} rowClassName={styles.tableRow} />
),
'signoz/ListPanel': (
<PreviewRows rows={LIST_ROWS} rowClassName={styles.listRow} />
),
'signoz/TextPanel': <PreviewRows rows={TEXT_ROWS} />,
};

View File

@@ -1,15 +1,17 @@
import type { IconSize } from '@signozhq/icons';
import type { ComponentType, SVGProps } from 'react';
import type { NewPanelTarget } from '../../../patchOps';
type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
size?: number | IconSize;
strokeWidth?: number;
};
export interface SectionOption {
/** The section's `layoutIndex`, stringified for the Select value. */
/** `layoutIndex` stringified, or "root" for a root yet to be created. */
value: string;
layoutIndex: number;
target: NewPanelTarget;
/** Section title, or "Dashboard (root)" for the untitled top-level layout. */
label: string;
/** Caption under the label. */

View File

@@ -0,0 +1,26 @@
import { useEffect } from 'react';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { usePanelPickerTargetStore } from '../../../store/usePanelPickerTargetStore';
export function usePanelPickerDraftSection(
title: string | null,
panelKind: PanelKind,
enabled: boolean,
): void {
const setDraftSection = usePanelPickerTargetStore((s) => s.setDraftSection);
useEffect(() => {
if (enabled) {
setDraftSection(title === null ? null : { title, panelKind });
}
}, [enabled, title, panelKind, setDraftSection]);
// Cleared only on close, so typing updates the preview without unmounting it.
useEffect(() => {
if (!enabled) {
return undefined;
}
return (): void => setDraftSection(null);
}, [enabled, setDraftSection]);
}

View File

@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { usePanelPickerTargetStore } from '../../../store/usePanelPickerTargetStore';
interface UsePanelPickerTargetArgs {
open: boolean;
layoutIndex: number | undefined;
panelKind: PanelKind;
outline: boolean;
}
/** Publishes where the open picker will add its panel, for the dashboard behind the drawer. */
export function usePanelPickerTarget({
open,
layoutIndex,
panelKind,
outline,
}: UsePanelPickerTargetArgs): void {
const setTarget = usePanelPickerTargetStore((s) => s.setTarget);
// Only the open picker writes, so the closed instances mounted per section don't clobber it.
useEffect(() => {
if (open) {
setTarget(
layoutIndex === undefined ? null : { layoutIndex, panelKind, outline },
);
}
}, [open, layoutIndex, panelKind, outline, setTarget]);
useEffect(() => {
if (!open) {
return undefined;
}
return (): void => setTarget(null);
}, [open, setTarget]);
}

View File

@@ -7,33 +7,47 @@ const ROOT_LABEL = 'Dashboard (root)';
const ROOT_DESCRIPTION = 'Top level — no section';
const SECTION_DESCRIPTION = 'Section';
/** Maps dashboard sections to section-picker options (untitled → "root"). */
const NEW_ROOT_VALUE = 'root';
/** Maps dashboard sections to section-picker options; a sectioned dashboard always offers the root. */
export function buildSectionOptions(
sections: DashboardSection[],
): SectionOption[] {
const rootSection = findRootSection(sections);
return sections.map((section) => {
const options: SectionOption[] = sections.map((section) => {
const isRoot = rootSection === section;
return {
value: String(section.layoutIndex),
layoutIndex: section.layoutIndex,
target: { type: 'section', layoutIndex: section.layoutIndex },
label: isRoot ? ROOT_LABEL : (section.title as string),
description: isRoot ? ROOT_DESCRIPTION : SECTION_DESCRIPTION,
isRoot,
Icon: isRoot ? LayoutDashboard : Rows2,
};
});
if (!rootSection && sections.some((section) => section.title)) {
options.unshift({
value: NEW_ROOT_VALUE,
target: { type: 'root' },
label: ROOT_LABEL,
description: ROOT_DESCRIPTION,
isRoot: true,
Icon: LayoutDashboard,
});
}
return options;
}
/**
* Picks the option the picker should open on: the section the "Add panel" was
* triggered from when present and still valid, otherwise the first option.
* triggered from when present and still valid, otherwise the dashboard root.
*/
export function resolveDefaultSectionValue(
options: SectionOption[],
defaultLayoutIndex: number | undefined,
): string {
const fallback = options[0]?.value ?? '';
const fallback =
(options.find((option) => option.isRoot) ?? options[0])?.value ?? '';
if (defaultLayoutIndex === undefined) {
return fallback;
}

View File

@@ -4,7 +4,7 @@ import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { findRootSection, type DashboardSection } from '../../../utils';
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
// Matches the root option label in the New Panel picker (SectionPicker).
// Matches the root option label in the New Panel picker (AddPanelSplitButton).
const ROOT_LABEL = 'Dashboard (root)';
interface MoveItemsArgs {

View File

@@ -0,0 +1,29 @@
.draftSection {
margin-bottom: 12px;
border: 1px solid var(--l1-border);
border-radius: 4px;
outline: 1px dashed var(--bg-robin-500);
outline-offset: 2px;
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
color: var(--l2-foreground);
}
.title {
color: var(--l1-foreground);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.placeholder {
color: var(--l3-foreground);
font-weight: 500;
}

View File

@@ -0,0 +1,45 @@
import { ChevronDown } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { NEW_PANEL_SIZE } from '../../../patchOps';
import { usePanelPickerTargetStore } from '../../../store/usePanelPickerTargetStore';
import {
GRID_MARGIN,
gridItemHeight,
gridItemWidth,
} from '../SectionGrid/gridMetrics';
import NewPanelPlaceholder from '../SectionGrid/NewPanelPlaceholder';
import styles from './DraftSection.module.scss';
function DraftSection(): JSX.Element | null {
const draft = usePanelPickerTargetStore((s) => s.draftSection);
if (draft === null) {
return null;
}
const title = draft.title.trim();
return (
<div className={styles.draftSection} data-testid="draft-section">
<div className={styles.header}>
<ChevronDown size={14} />
<Typography.Text className={title ? styles.title : styles.placeholder}>
{title || 'New section'}
</Typography.Text>
</div>
<div style={{ padding: GRID_MARGIN }}>
<div
style={{
width: gridItemWidth(NEW_PANEL_SIZE.width),
height: gridItemHeight(NEW_PANEL_SIZE.height),
}}
>
<NewPanelPlaceholder kind={draft.panelKind} />
</div>
</div>
</div>
);
}
export default DraftSection;

View File

@@ -0,0 +1,54 @@
import { act, render, screen } from '@testing-library/react';
import { usePanelPickerTargetStore } from '../../../../store/usePanelPickerTargetStore';
import DraftSection from '../DraftSection';
jest.mock('../../../../Panels/registry', () => ({
getPanelDefinition: (): { displayName: string } => ({ displayName: 'Table' }),
}));
describe('DraftSection', () => {
const scrollTo = jest.fn();
const setDraft = (title: string): void =>
usePanelPickerTargetStore
.getState()
.setDraftSection({ title, panelKind: 'signoz/TablePanel' });
beforeEach(() => {
usePanelPickerTargetStore.getState().reset();
scrollTo.mockClear();
// jsdom elements have no scrollTo.
Object.defineProperty(document.documentElement, 'scrollTo', {
value: scrollTo,
configurable: true,
});
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0);
return 0;
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders nothing while no section is being created', () => {
render(<DraftSection />);
expect(screen.queryByTestId('draft-section')).not.toBeInTheDocument();
});
it('previews the typed name with the new panel, scrolling to it once', () => {
render(<DraftSection />);
act(() => setDraft(''));
expect(screen.getByText('New section')).toBeInTheDocument();
expect(screen.getByTestId('new-panel-placeholder')).toHaveTextContent(
'Table',
);
act(() => setDraft('Errors'));
expect(screen.getByText('Errors')).toBeInTheDocument();
expect(scrollTo).toHaveBeenCalledTimes(1);
});
});

View File

@@ -4,6 +4,12 @@
border-radius: 4px;
}
.pickerTarget {
border-radius: 4px;
outline: 1px dashed var(--bg-robin-500);
outline-offset: 2px;
}
.dragging {
opacity: 0.8;
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useRef, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import cx from 'classnames';
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
@@ -9,6 +10,8 @@ import type { DashboardSection } from '../../../utils';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import { useCloneSection } from '../hooks/useCloneSection';
import { useDeleteSection } from '../hooks/useDeleteSection';
import { usePanelPickerHighlight } from '../hooks/usePanelPickerHighlight';
import { usePanelPickerReveal } from '../hooks/usePanelPickerReveal';
import { useRenameSection } from '../hooks/useRenameSection';
import { useScrollIntoView } from '../hooks/useScrollIntoView';
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
@@ -67,12 +70,17 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
const sectionRef = useRef<HTMLDivElement>(null);
useScrollIntoView(section.id, sectionRef);
const pickerTarget = usePanelPickerHighlight(section.layoutIndex);
const isOutlined = !!pickerTarget?.outline;
// A collapsed section has no grid to show the placeholder in, so reveal its header.
usePanelPickerReveal(sectionRef, !!pickerTarget && !!section.title && !open);
const grid = (
<SectionGrid
items={section.items}
layoutIndex={section.layoutIndex}
sections={sections}
placeholderKind={pickerTarget?.panelKind}
/>
);
@@ -81,6 +89,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
return (
<div
ref={sectionRef}
className={cx({ [styles.pickerTarget]: isOutlined })}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>
@@ -92,7 +101,9 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
return (
<div
ref={sectionRef}
className={styles.section}
className={cx(styles.section, {
[styles.pickerTarget]: isOutlined,
})}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>
@@ -113,7 +124,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
}}
/>
{open &&
(section.items.length > 0 ? (
(section.items.length > 0 || pickerTarget ? (
grid
) : (
<div className={styles.emptySection}>

View File

@@ -0,0 +1,23 @@
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
height: 100%;
box-sizing: border-box;
border: 1px dashed var(--bg-robin-500);
border-radius: 6px;
background: color-mix(in srgb, var(--bg-robin-500) 6%, transparent);
}
.name {
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
}
.label {
color: var(--bg-robin-400);
font-size: 11px;
}

View File

@@ -0,0 +1,25 @@
import { useRef } from 'react';
import { getPanelDefinition } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { usePanelPickerReveal } from '../hooks/usePanelPickerReveal';
import styles from './NewPanelPlaceholder.module.scss';
function NewPanelPlaceholder({ kind }: { kind: PanelKind }): JSX.Element {
const ref = useRef<HTMLDivElement>(null);
usePanelPickerReveal(ref, true);
return (
<div
ref={ref}
className={styles.placeholder}
data-testid="new-panel-placeholder"
>
<span className={styles.name}>{getPanelDefinition(kind).displayName}</span>
<span className={styles.label}>New panel</span>
</div>
);
}
export default NewPanelPlaceholder;

View File

@@ -1,4 +1,9 @@
.grid {
// RGL animates height, which would cut the placeholder's reveal short.
&.instantHeight {
transition: none;
}
// Override react-grid-layout's default red drag/resize placeholder with the
// SigNoz brand blue.
:global(.react-grid-item.react-grid-placeholder) {

View File

@@ -1,47 +1,60 @@
import { useMemo } from 'react';
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
import cx from 'classnames';
import { newPanelSlot } from '../../../patchOps';
import type { PanelKind } from '../../../Panels/types/panelKind';
import type { DashboardSection } from '../../../utils';
import { usePersistLayout } from '../hooks/usePersistLayout';
import { GRID_MARGIN, GRID_ROW_HEIGHT } from './gridMetrics';
import NewPanelPlaceholder from './NewPanelPlaceholder';
import SectionGridItem from './SectionGridItem';
import styles from './SectionGrid.module.scss';
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
const ResponsiveGridLayout = WidthProvider(GridLayout);
const PLACEHOLDER_ID = '__new-panel-placeholder';
interface SectionGridProps {
items: DashboardSection['items'];
layoutIndex: number;
/** All sections — layout context for the panel menu's move/delete actions. */
sections?: DashboardSection[];
/** Shows where a new panel of this kind will land. */
placeholderKind?: PanelKind;
}
function SectionGrid({
items,
layoutIndex,
sections,
placeholderKind,
}: SectionGridProps): JSX.Element {
const { isEditable } = useDashboardEditContext();
const rglLayout = useMemo<Layout[]>(
() =>
items.map((item) => ({
i: item.id,
x: item.x,
y: item.y,
w: item.width,
h: item.height,
})),
[items],
);
const rglLayout = useMemo<Layout[]>(() => {
const layout: Layout[] = items.map((item) => ({
i: item.id,
x: item.x,
y: item.y,
w: item.width,
h: item.height,
}));
if (placeholderKind) {
const { x, y, width, height } = newPanelSlot(items);
layout.push({ i: PLACEHOLDER_ID, x, y, w: width, h: height, static: true });
}
return layout;
}, [items, placeholderKind]);
const { handleLayoutChange } = usePersistLayout({ layoutIndex, items });
return (
<ResponsiveGridLayout
className={styles.grid}
className={cx(styles.grid, { [styles.instantHeight]: !!placeholderKind })}
cols={12}
rowHeight={45}
rowHeight={GRID_ROW_HEIGHT}
autoSize
useCSSTransforms
layout={rglLayout}
@@ -51,7 +64,7 @@ function SectionGrid({
isResizable={isEditable}
onDragStop={handleLayoutChange}
onResizeStop={handleLayoutChange}
margin={[8, 8]}
margin={[GRID_MARGIN, GRID_MARGIN]}
>
{items.map((item) => (
// A layout item can reference a panel id that no longer exists in the
@@ -74,6 +87,11 @@ function SectionGrid({
)}
</div>
))}
{placeholderKind && (
<div key={PLACEHOLDER_ID}>
<NewPanelPlaceholder kind={placeholderKind} />
</div>
)}
</ResponsiveGridLayout>
);
}

View File

@@ -0,0 +1,42 @@
import { render, screen } from '@testing-library/react';
import { usePanelPickerTargetStore } from '../../../../store/usePanelPickerTargetStore';
import NewPanelPlaceholder from '../NewPanelPlaceholder';
jest.mock('../../../../Panels/registry', () => ({
getPanelDefinition: (): { displayName: string } => ({ displayName: 'Table' }),
}));
describe('NewPanelPlaceholder', () => {
const scrollTo = jest.fn();
beforeEach(() => {
usePanelPickerTargetStore.getState().reset();
scrollTo.mockClear();
// jsdom elements have no scrollTo.
Object.defineProperty(document.documentElement, 'scrollTo', {
value: scrollTo,
configurable: true,
});
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0);
return 0;
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('names the kind and scrolls to it, recording the prior scroll position', () => {
render(<NewPanelPlaceholder kind="signoz/TablePanel" />);
expect(screen.getByTestId('new-panel-placeholder')).toHaveTextContent(
'TableNew panel',
);
expect(scrollTo).toHaveBeenCalledWith(
expect.objectContaining({ behavior: 'smooth' }),
);
expect(usePanelPickerTargetStore.getState().scrollOrigin).not.toBeNull();
});
});

View File

@@ -0,0 +1,12 @@
import { gridItemHeight, gridItemWidth } from '../gridMetrics';
describe('gridMetrics', () => {
it('sizes a half-width, six-row item like react-grid-layout', () => {
expect(gridItemWidth(6)).toBe('calc(50% - 4px)');
expect(gridItemHeight(6)).toBe(310);
});
it('spans the full padded width at every column', () => {
expect(gridItemWidth(12)).toBe('calc(100% + 0px)');
});
});

View File

@@ -0,0 +1,16 @@
import { GRID_COLS } from '../../../patchOps';
export const GRID_ROW_HEIGHT = 45;
/** Gap between items, also used as the grid's container padding. */
export const GRID_MARGIN = 8;
/** CSS width of an item spanning `cols` columns, inside the grid's container padding. */
export function gridItemWidth(cols: number): string {
const fraction = cols / GRID_COLS;
const offset = (cols - 1 - fraction * (GRID_COLS - 1)) * GRID_MARGIN;
return `calc(${fraction * 100}% ${offset < 0 ? '-' : '+'} ${Math.abs(offset)}px)`;
}
export function gridItemHeight(rows: number): number {
return rows * GRID_ROW_HEIGHT + (rows - 1) * GRID_MARGIN;
}

View File

@@ -0,0 +1,26 @@
import { getScrollParent } from '../scrollUtils';
describe('getScrollParent', () => {
it('picks the OverlayScrollbars viewport before it has marked itself scrollable', () => {
const viewport = document.createElement('div');
viewport.setAttribute('data-overlayscrollbars-viewport', '');
const child = document.createElement('div');
viewport.appendChild(child);
document.body.appendChild(viewport);
expect(getScrollParent(child)).toBe(viewport);
viewport.remove();
});
it('falls back to the document scroller', () => {
const child = document.createElement('div');
document.body.appendChild(child);
expect(getScrollParent(child)).toBe(
document.scrollingElement ?? document.documentElement,
);
child.remove();
});
});

View File

@@ -0,0 +1,33 @@
/** Nearest ancestor that scrolls vertically, falling back to the document scroller. */
export function getScrollParent(element: HTMLElement): HTMLElement {
let node = element.parentElement;
while (node) {
// OverlayScrollbars marks its viewport scrollable only after it notices new overflow.
if (node.hasAttribute('data-overlayscrollbars-viewport')) {
return node;
}
const { overflowY } = getComputedStyle(node);
if (
(overflowY === 'auto' || overflowY === 'scroll') &&
node.scrollHeight > node.clientHeight
) {
return node;
}
node = node.parentElement;
}
return (document.scrollingElement as HTMLElement) ?? document.documentElement;
}
/** `scrollTop` for `scroller` that centers `element` vertically in its visible area. */
export function centeredScrollTop(
scroller: HTMLElement,
element: HTMLElement,
): number {
const isDocument = scroller === document.scrollingElement;
const viewTop = isDocument ? 0 : scroller.getBoundingClientRect().top;
const viewHeight = isDocument ? window.innerHeight : scroller.clientHeight;
const rect = element.getBoundingClientRect();
return (
scroller.scrollTop + rect.top - viewTop - (viewHeight - rect.height) / 2
);
}

View File

@@ -0,0 +1,13 @@
import {
type PanelPickerTarget,
usePanelPickerTargetStore,
} from '../../../store/usePanelPickerTargetStore';
/** The open new-panel picker's target when it's this section, else null. */
export function usePanelPickerHighlight(
layoutIndex: number,
): PanelPickerTarget | null {
return usePanelPickerTargetStore((s) =>
s.target?.layoutIndex === layoutIndex ? s.target : null,
);
}

View File

@@ -0,0 +1,34 @@
import { RefObject, useEffect } from 'react';
import { usePanelPickerTargetStore } from '../../../store/usePanelPickerTargetStore';
import { centeredScrollTop, getScrollParent } from './scrollUtils';
/** Centers the element when `active` turns on, recording the prior position for a dismissed picker. */
export function usePanelPickerReveal(
ref: RefObject<HTMLElement>,
active: boolean,
): void {
const rememberScrollOrigin = usePanelPickerTargetStore(
(s) => s.rememberScrollOrigin,
);
useEffect(() => {
if (!active) {
return undefined;
}
// A frame later the grid has placed the element and the opening drawer can't cancel the scroll.
const frame = requestAnimationFrame(() => {
const element = ref.current;
if (!element) {
return;
}
const scroller = getScrollParent(element);
rememberScrollOrigin({ element: scroller, top: scroller.scrollTop });
scroller.scrollTo({
top: centeredScrollTop(scroller, element),
behavior: 'smooth',
});
});
return (): void => cancelAnimationFrame(frame);
}, [active, ref, rememberScrollOrigin]);
}

View File

@@ -7,6 +7,7 @@ import type {
import { layoutsToSections } from '../utils';
import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
import DraftSection from './Section/DraftSection/DraftSection';
import { useViewPanel } from './Panel/hooks/useViewPanel';
import ViewPanelModal from './Panel/ViewPanelModal/ViewPanelModal';
import Section from './Section/Section/Section';
@@ -72,6 +73,7 @@ function PanelsAndSectionsLayout({
return (
<div className={styles.body}>
{renderContent()}
<DraftSection />
<ViewPanelModal
open={!!expandedPanel}
panel={expandedPanel}

View File

@@ -12,7 +12,9 @@ import {
createPanelOps,
findFreeSlot,
itemsOverlap,
newPanelSlot,
setPanelTextOp,
titleLooseLayoutsOps,
} from '../patchOps';
function item(y: number, height: number): DashboardGridItemDTO {
@@ -28,6 +30,10 @@ function itemAt(
return { x, y, width, height, content: { $ref: '#/spec/panels/x' } };
}
function untitled(items: DashboardGridItemDTO[]): DashboardtypesLayoutDTO {
return { kind: 'Grid', spec: { items } } as DashboardtypesLayoutDTO;
}
function section(items: DashboardGridItemDTO[]): DashboardtypesLayoutDTO {
return {
kind: 'Grid',
@@ -50,7 +56,12 @@ describe('createPanelOps', () => {
it('adds the panel + a grid item in the requested section', () => {
const layouts = [section([item(0, 6)]), section([])];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const ops = createPanelOps({
layouts,
target: { type: 'section', layoutIndex: 0 },
panelId: 'p1',
panel,
});
expect(ops).toHaveLength(2);
expect(ops[0]).toMatchObject({ op: 'add', path: '/spec/panels/p1' });
@@ -63,10 +74,78 @@ describe('createPanelOps', () => {
);
});
it('creates the requested new section and places the panel in it', () => {
const layouts = [section([item(0, 6)])];
const ops = createPanelOps({
layouts,
target: { type: 'newSection', title: ' Errors ' },
panelId: 'p1',
panel,
});
expect(ops[0]).toMatchObject({ op: 'add', path: '/spec/layouts/-' });
expect(ops[0].value).toMatchObject({
spec: { display: { title: 'Errors' } },
});
expect(ops[2]).toMatchObject({ path: '/spec/layouts/1/spec/items/-' });
expect(ops[2].value).toMatchObject({ x: 0, y: 0 });
});
it('titles loose panels before creating a section on a free-flowing dashboard', () => {
const layouts = [untitled([item(0, 6)])];
const ops = createPanelOps({
layouts,
target: { type: 'newSection', title: 'Errors' },
panelId: 'p1',
panel,
});
expect(ops[0]).toMatchObject({
path: '/spec/layouts/0/spec/display',
value: { title: 'Section 1' },
});
expect(ops[1]).toMatchObject({ path: '/spec/layouts/-' });
expect(ops[3]).toMatchObject({ path: '/spec/layouts/1/spec/items/-' });
});
it('inserts a root ahead of the sections when there is none', () => {
const layouts = [section([item(0, 6)])];
const ops = createPanelOps({
layouts,
target: { type: 'root' },
panelId: 'p1',
panel,
});
expect(ops[0]).toMatchObject({ op: 'add', path: '/spec/layouts/0' });
expect(ops[0].value).toMatchObject({ spec: { items: [] } });
expect(ops[2]).toMatchObject({ path: '/spec/layouts/0/spec/items/-' });
expect(ops[2].value).toMatchObject({ x: 0, y: 0 });
});
it('adds to the existing root', () => {
const layouts = [untitled([item(0, 6)]), section([])];
const ops = createPanelOps({
layouts,
target: { type: 'root' },
panelId: 'p1',
panel,
});
expect(ops).toHaveLength(2);
expect(ops[1]).toMatchObject({ path: '/spec/layouts/0/spec/items/-' });
expect(ops[1].value).toMatchObject({ x: 6, y: 0 });
});
it('fills the empty right half of a row instead of wrapping to a new one', () => {
// Left half filled → new 6-wide panel fits at x:6 in the same row.
const layouts = [section([item(0, 6)])];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const ops = createPanelOps({
layouts,
target: { type: 'section', layoutIndex: 0 },
panelId: 'p1',
panel,
});
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(6);
@@ -76,7 +155,12 @@ describe('createPanelOps', () => {
it('wraps to a new row when the last row is full', () => {
// Full-width (12) row leaves no room → panel drops to the next row.
const layouts = [section([itemAt(0, 0, 12, 6)])];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const ops = createPanelOps({
layouts,
target: { type: 'section', layoutIndex: 0 },
panelId: 'p1',
panel,
});
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(0);
@@ -86,7 +170,12 @@ describe('createPanelOps', () => {
it('ignores a gap in an upper row and only fills the last row', () => {
// Upper-row gap is ignored when the last row is full → starts a fresh row.
const layouts = [section([itemAt(0, 0, 6, 6), itemAt(0, 6, 12, 6)])];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const ops = createPanelOps({
layouts,
target: { type: 'section', layoutIndex: 0 },
panelId: 'p1',
panel,
});
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(0);
@@ -96,7 +185,12 @@ describe('createPanelOps', () => {
it('fills the right of the last row when it has room', () => {
// Half-filled last row → panel sits at x:6 of that row.
const layouts = [section([itemAt(0, 0, 12, 6), itemAt(0, 6, 6, 6)])];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const ops = createPanelOps({
layouts,
target: { type: 'section', layoutIndex: 0 },
panelId: 'p1',
panel,
});
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(6);
@@ -109,7 +203,12 @@ describe('createPanelOps', () => {
section([itemAt(0, 0, 6, 6)]),
section([itemAt(0, 0, 12, 6)]),
];
const ops = createPanelOps({ layouts, layoutIndex: 1, panelId: 'p1', panel });
const ops = createPanelOps({
layouts,
target: { type: 'section', layoutIndex: 1 },
panelId: 'p1',
panel,
});
expect(ops[1].path).toBe('/spec/layouts/1/spec/items/-');
const value = ops[1].value as DashboardGridItemDTO;
@@ -121,7 +220,6 @@ describe('createPanelOps', () => {
const layouts = [section([]), section([item(0, 6)])];
const ops = createPanelOps({
layouts,
layoutIndex: undefined,
panelId: 'p1',
panel,
});
@@ -131,14 +229,18 @@ describe('createPanelOps', () => {
it('falls back to the root (first) section when the requested index is out of range', () => {
const layouts = [section([item(0, 6)]), section([])];
const ops = createPanelOps({ layouts, layoutIndex: 5, panelId: 'p1', panel });
const ops = createPanelOps({
layouts,
target: { type: 'section', layoutIndex: 5 },
panelId: 'p1',
panel,
});
expect(ops[1].path).toBe('/spec/layouts/0/spec/items/-');
});
it('creates a section first when the dashboard has none', () => {
const ops = createPanelOps({
layouts: [],
layoutIndex: undefined,
panelId: 'p1',
panel,
});
@@ -161,7 +263,12 @@ describe('createPanelOps', () => {
itemAt(0, 6, 3, 6),
]),
];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const ops = createPanelOps({
layouts,
target: { type: 'section', layoutIndex: 0 },
panelId: 'p1',
panel,
});
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(0);
@@ -273,3 +380,35 @@ describe('setPanelTextOp', () => {
expect(setPanelTextOp('p1', '').op).toBe(DashboardtypesPatchOpDTO.add);
});
});
describe('titleLooseLayoutsOps', () => {
it('titles only untitled layouts that hold panels', () => {
const ops = titleLooseLayoutsOps([
untitled([item(0, 6)]),
untitled([]),
untitled([item(0, 6)]),
]);
expect(ops.map((op) => [op.path, op.value])).toStrictEqual([
['/spec/layouts/0/spec/display', { title: 'Section 1' }],
['/spec/layouts/2/spec/display', { title: 'Section 2' }],
]);
});
it('does nothing once the dashboard has a titled section', () => {
expect(
titleLooseLayoutsOps([untitled([item(0, 6)]), section([])]),
).toStrictEqual([]);
});
});
describe('newPanelSlot', () => {
it('sizes the free slot like a saved new panel', () => {
expect(newPanelSlot([item(0, 6)])).toStrictEqual({
x: 6,
y: 0,
width: 6,
height: 6,
});
});
});

View File

@@ -2,9 +2,7 @@
display: flex;
flex-direction: column;
position: fixed;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
bottom: 0;
left: 0;
width: 100%;
z-index: 100;

View File

@@ -35,7 +35,10 @@ describe('useCreatePanel', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useCreatePanel());
act(() => {
result.current.createPanel('timeSeries' as never, 2);
result.current.createPanel('timeSeries' as never, {
type: 'section',
layoutIndex: 2,
});
});
const [url] = mockSafeNavigate.mock.calls[0];
@@ -45,6 +48,19 @@ describe('useCreatePanel', () => {
expect(url).toContain('relativeTime=6h');
});
it('carries a new section title for the editor to create on save', () => {
const { result } = renderHook(() => useCreatePanel());
act(() => {
result.current.createPanel('timeSeries' as never, {
type: 'newSection',
title: 'Errors',
});
});
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('newSection=Errors');
});
it('carries a custom absolute window and never a stray relativeTime', () => {
mockGlobalTime = {
selectedTime: 'custom',
@@ -53,7 +69,10 @@ describe('useCreatePanel', () => {
};
const { result } = renderHook(() => useCreatePanel());
act(() => {
result.current.createPanel('timeSeries' as never, 2);
result.current.createPanel('timeSeries' as never, {
type: 'section',
layoutIndex: 2,
});
});
const [url] = mockSafeNavigate.mock.calls[0];

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react';
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
import type { NewPanelTarget } from '../patchOps';
import type { PanelKind } from '../Panels/types/panelKind';
import { clearColumnWidths } from '../Panels/utils/columnWidthStorage';
import { useOpenPanelEditor } from './useOpenPanelEditor';
@@ -12,8 +13,8 @@ interface UseCreatePanelResult {
closePicker: () => void;
/** The section the picker was opened against — seeds its section dropdown. */
targetLayoutIndex: number | undefined;
/** `layoutIndex` overrides the opened-against target (the dropdown's choice). */
createPanel: (panelKind: PanelKind, layoutIndex?: number) => void;
/** `target` overrides the opened-against section (the picker's choice). */
createPanel: (panelKind: PanelKind, target?: NewPanelTarget) => void;
}
/**
@@ -38,9 +39,13 @@ export function useCreatePanel(): UseCreatePanelResult {
}, []);
const createPanel = useCallback(
(panelKind: PanelKind, targetIndex?: number): void => {
(panelKind: PanelKind, pickedTarget?: NewPanelTarget): void => {
setIsPickerOpen(false);
const target = targetIndex ?? layoutIndex;
const target =
pickedTarget ??
(layoutIndex === undefined
? undefined
: { type: 'section' as const, layoutIndex });
// Every draft shares the sentinel id, so an abandoned draft's column widths
// would otherwise seed this one.
clearColumnWidths(NEW_PANEL_ID);

View File

@@ -154,16 +154,22 @@ export function addPanelToSectionOps({
];
}
export type NewPanelTarget =
| { type: 'section'; layoutIndex: number }
| { type: 'newSection'; title: string }
/** Created at the top when missing. */
| { type: 'root' };
interface CreatePanelOpsArgs {
/** Current sections, used to resolve the target and the next free row. */
layouts: DashboardtypesLayoutDTO[];
/** Preferred section (from a section's "Add panel" trigger); falls back to the root (first) section. */
layoutIndex: number | undefined;
/** Omitted, or a stale section → the first section. */
target?: NewPanelTarget;
panelId: string;
panel: DashboardtypesPanelDTO;
}
const NEW_PANEL_SIZE = { width: 6, height: 6 };
export const NEW_PANEL_SIZE = { width: 6, height: 6 };
/** Columns in the section grid — mirrors `cols` on SectionGrid's GridLayout. */
export const GRID_COLS = 12;
@@ -240,14 +246,18 @@ export function findFreeSlot(
return bottomRowSlot(items);
}
/** Where a brand-new panel lands in a section: its free slot plus the default size. */
export function newPanelSlot(items: PlacedItem[]): Required<PlacedItem> {
return { ...findFreeSlot(items, NEW_PANEL_SIZE.width), ...NEW_PANEL_SIZE };
}
/**
* Ops to persist a brand-new panel (editor save path): resolve the target
* section (requested index if valid, else the root/first section, else a
* freshly-created one) and place the panel via `findFreeSlot`.
* section, creating it when needed, and place the panel via `findFreeSlot`.
*/
export function createPanelOps({
layouts,
layoutIndex,
target,
panelId,
panel,
}: CreatePanelOpsArgs): DashboardtypesJSONPatchOperationDTO[] {
@@ -255,10 +265,23 @@ export function createPanelOps({
let targetIndex: number;
let items: DashboardGridItemDTO[];
if (layoutIndex !== undefined && layouts[layoutIndex] !== undefined) {
const newSectionTitle =
target?.type === 'newSection' ? target.title.trim() : '';
if (newSectionTitle) {
ops.push(...titleLooseLayoutsOps(layouts), addSectionOp(newSectionTitle));
targetIndex = layouts.length;
items = [];
} else if (target?.type === 'root' && layouts[0]?.spec?.display?.title) {
ops.push({ op: add, path: '/spec/layouts/0', value: newGridLayout('') });
targetIndex = 0;
items = [];
} else if (
target?.type === 'section' &&
layouts[target.layoutIndex] !== undefined
) {
// Explicit section — a section's own "New Panel" trigger.
targetIndex = layoutIndex;
items = layouts[layoutIndex]?.spec.items ?? [];
targetIndex = target.layoutIndex;
items = layouts[target.layoutIndex]?.spec.items ?? [];
} else if (layouts.length > 0) {
// No section specified (toolbar "New Panel") → the root (first) section.
targetIndex = 0;
@@ -270,16 +293,13 @@ export function createPanelOps({
items = [];
}
const { x, y } = findFreeSlot(items, NEW_PANEL_SIZE.width);
ops.push(
...addPanelToSectionOps({
panelId,
panel,
layoutIndex: targetIndex,
item: {
x,
y,
...NEW_PANEL_SIZE,
...newPanelSlot(items),
content: { $ref: panelRef(panelId) },
},
}),
@@ -334,6 +354,22 @@ export function titleUntitledSectionOp(
};
}
/** Titles loose layouts ("Section 1", …) on a free-flowing dashboard, as panels can't stay loose once a section exists. */
export function titleLooseLayoutsOps(
layouts: DashboardtypesLayoutDTO[],
): DashboardtypesJSONPatchOperationDTO[] {
if (layouts.some((layout) => layout.spec?.display?.title)) {
return [];
}
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
layouts.forEach((layout, index) => {
if ((layout.spec?.items ?? []).length > 0) {
ops.push(titleUntitledSectionOp(index, `Section ${ops.length + 1}`));
}
});
return ops;
}
/** Remove a section. Panel cleanup (orphaned refs) is handled by the caller. */
export function removeSectionOp(
layoutIndex: number,

View File

@@ -0,0 +1,72 @@
import { create } from 'zustand';
import type { PanelKind } from '../Panels/types/panelKind';
export interface ScrollOrigin {
element: HTMLElement;
top: number;
}
export interface PanelPickerTarget {
layoutIndex: number;
panelKind: PanelKind;
/** Outline the section; off when there's only one place to add to. */
outline: boolean;
}
export interface PanelPickerDraftSection {
title: string;
panelKind: PanelKind;
}
/**
* Where the open new-panel picker will add its panel, so the dashboard can mark the
* section and preview the slot behind the drawer. `scrollOrigin` is the scroll
* position before the first reveal, restored if the picker is dismissed.
*/
export interface PanelPickerTargetStore {
target: PanelPickerTarget | null;
/** Section the picker will create; null when not creating one. */
draftSection: PanelPickerDraftSection | null;
scrollOrigin: ScrollOrigin | null;
setTarget: (target: PanelPickerTarget | null) => void;
setDraftSection: (draft: PanelPickerDraftSection | null) => void;
/** No-op once an origin is recorded, so later reveals keep the first position. */
rememberScrollOrigin: (origin: ScrollOrigin) => void;
reset: () => void;
}
export const usePanelPickerTargetStore = create<PanelPickerTargetStore>(
(set, get) => ({
target: null,
draftSection: null,
scrollOrigin: null,
setTarget: (target): void => {
set({ target });
},
setDraftSection: (draftSection): void => {
set({ draftSection });
},
rememberScrollOrigin: (origin): void => {
if (!get().scrollOrigin) {
set({ scrollOrigin: origin });
}
},
reset: (): void => {
set({
target: null,
draftSection: null,
scrollOrigin: null,
});
},
}),
);
/** Clears the target; with `restoreScroll`, scrolls back to where the dashboard was before any reveal. */
export function releasePanelPickerTarget(restoreScroll: boolean): void {
const { scrollOrigin, reset } = usePanelPickerTargetStore.getState();
if (restoreScroll && scrollOrigin) {
scrollOrigin.element.scrollTo({ top: scrollOrigin.top, behavior: 'smooth' });
}
reset();
}

View File

@@ -23,7 +23,7 @@ import PanelEditorContainer from '../DashboardContainer/PanelEditor';
import type { PanelEditorHandoffState } from '../DashboardContainer/PanelEditor/panelEditorHandoff';
import {
parseNewPanelKind,
parseNewPanelLayoutIndex,
parseNewPanelTarget,
} from '../DashboardContainer/PanelEditor/newPanelRoute';
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
import { useTimeSearchParams } from '../DashboardContainer/hooks/useTimeSearchParams';
@@ -99,7 +99,7 @@ function PanelEditorPage(): JSX.Element {
}, [newKind, existingPanel, handoffSpec]);
// Target section for a newly-created panel (set by the "Add panel" trigger).
const layoutIndex = parseNewPanelLayoutIndex(search);
const target = parseNewPanelTarget(search);
const backToDashboard = useCallback((): void => {
// Drop editor-only URL state (variables come from the persisted store), but carry
@@ -137,7 +137,7 @@ function PanelEditorPage(): JSX.Element {
panel={panel}
savedPanel={existingPanel}
isNew={!!newKind}
layoutIndex={layoutIndex}
target={target}
onClose={backToDashboard}
onSaved={backToDashboard}
/>

View File

@@ -1,4 +1,7 @@
.support-page-container {
max-height: 100vh;
overflow: hidden;
.support-page-header {
border-bottom: 1px solid var(--l1-border);
background: var(--l1-background);

View File

@@ -1,6 +1,5 @@
.root {
flex: 1;
min-height: 0;
height: calc(100vh);
display: flex;
flex-direction: column;
}

View File

@@ -1,24 +1,13 @@
.traces-funnel-details {
display: flex;
height: 100%;
// 45px -> height of the tab bar
height: calc(100vh - 45px);
&__steps-config {
flex-shrink: 0;
width: 600px;
border-right: 1px solid var(--l1-border);
// Positioning context for the absolute .steps-footer.
position: relative;
display: flex;
flex-direction: column;
// Scoped here so the modal usage of FunnelConfiguration on trace details
// stays in normal flow.
.funnel-configuration {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
}
&__steps-results {
width: 100%;

View File

@@ -4,17 +4,14 @@
flex-direction: column;
justify-content: flex-start;
&.funnel-details-page {
flex: 1;
min-height: 0;
// .steps-footer is absolute against the config column, so its 64px is
// reserved rather than laid out.
margin-bottom: 64px;
height: calc(
100vh - 170px
); // 64px bottom bar + 61px configuration header + 45px page navbar
overflow: auto;
}
}
&__header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;

View File

@@ -67,23 +67,5 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/traces/{traceID}/thread", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetThread),
handler.OpenAPIDef{
ID: "GetTraceThread",
Tags: []string{"tracedetail"},
Summary: "Get thread view for a trace",
Description: "Returns the spans carrying gen_ai input or output messages in timestamp order, each with the messages normalised into formatted_input and formatted_output. Pages are fetched with the returned nextCursor.",
RequestQuery: new(spantypes.QueryableThread),
Response: new(spantypes.GettableTraceThread),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -75,25 +75,3 @@ func (h *handler) GetFlamegraph(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, result)
}
func (h *handler) GetThread(rw http.ResponseWriter, r *http.Request) {
req := new(spantypes.QueryableThread)
if err := binding.Query.BindQuery(r.URL.Query(), req); err != nil {
render.Error(rw, err)
return
}
query, err := spantypes.NewThreadQuery(req)
if err != nil {
render.Error(rw, err)
return
}
result, err := h.module.GetThread(r.Context(), mux.Vars(r)["traceID"], query)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -173,19 +173,6 @@ func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpan
), nil
}
func (m *module) GetThread(ctx context.Context, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
spans, err := m.store.GetThreadSpans(ctx, traceID, summary, query.Cursor, query.Limit+1)
if err != nil {
return nil, err
}
return spantypes.NewGettableTraceThread(traceID, spans, query.Limit), nil
}
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, nil)
if err != nil {

View File

@@ -11,23 +11,12 @@ import (
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
var fullSpanColumns = []string{
"duration_nano", "span_id", "has_error", "kind",
colServiceName, "name",
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
"flags", "is_remote", "trace_state", "status_code",
"db_name", "db_operation", "http_method", "http_url", "http_host",
"external_http_method", "external_http_url", "response_status_code", "links as references",
}
func buildFieldExpr(fieldKey telemetrytypes.TelemetryFieldKey) (string, error) {
switch fieldKey.FieldContext {
case telemetrytypes.FieldContextResource:
@@ -134,8 +123,16 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
return []spantypes.StorableSpan{}, nil
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("DISTINCT ON (span_id) timestamp")
sb.SelectMore(fullSpanColumns...)
sb.Select(
"DISTINCT ON (span_id) timestamp",
"duration_nano", "span_id", "has_error", "kind",
colServiceName, "name",
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
"flags", "is_remote", "trace_state", "status_code",
"db_name", "db_operation", "http_method", "http_url", "http_host",
"external_http_method", "external_http_url", "response_status_code", "links as references",
)
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
ids := make([]any, len(spanIDs))
for i, id := range spanIDs {
@@ -158,44 +155,6 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
return spans, nil
}
func (s *traceStore) GetThreadSpans(ctx context.Context, traceID string, summary *spantypes.TraceSummary, cursor *spantypes.ThreadCursor, limit int) ([]spantypes.StorableSpan, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("DISTINCT ON (span_id) timestamp")
sb.SelectMore(fullSpanColumns...)
sb.SelectMore("attributes")
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
sb.Where(
sb.E("trace_id", traceID),
sb.GE("ts_bucket_start", summary.Start.Unix()-1800),
sb.LE("ts_bucket_start", summary.End.Unix()),
// Reads only the JSON column; spans with messages only in the legacy maps are skipped.
// todo(nitya): pick the column from the attribute evolution metadata.
sb.Or(
sqlbuilder.Escape(fmt.Sprintf("attributes.%s IS NOT NULL", clickhousesql.Identifier(aiobservabilitytypes.GenAIInputMessages))),
sqlbuilder.Escape(fmt.Sprintf("attributes.%s IS NOT NULL", clickhousesql.Identifier(aiobservabilitytypes.GenAIOutputMessages))),
),
)
if cursor != nil {
// ClickHouse can't use an index for a tuple comparison, so the separate timestamp and
// ts_bucket_start bounds are what skip the data before the cursor.
sb.Where(
sb.GE("ts_bucket_start", int64(cursor.TimeUnixNano/uint64(time.Second))-1800),
sb.GE("timestamp", fmt.Sprintf("%d", cursor.TimeUnixNano)),
sb.GT("(toUnixTimestamp64Nano(timestamp), span_id)", sqlbuilder.Tuple(cursor.TimeUnixNano, cursor.SpanID)),
)
}
sb.OrderByAsc("timestamp")
sb.OrderByAsc("span_id")
sb.Limit(limit)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var spans []spantypes.StorableSpan
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying thread spans")
}
return spans, nil
}
func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(

View File

@@ -13,7 +13,6 @@ type Handler interface {
GetWaterfallV4(http.ResponseWriter, *http.Request)
GetTraceAggregations(http.ResponseWriter, *http.Request)
GetFlamegraph(http.ResponseWriter, *http.Request)
GetThread(http.ResponseWriter, *http.Request)
}
// Module defines the business logic for trace detail operations.
@@ -21,5 +20,4 @@ type Module interface {
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error)
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error)
GetThread(ctx context.Context, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error)
}

View File

@@ -566,6 +566,24 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
}, nil
}
// flattenJSONPaths flattens a decoded JSON document into dotted keys, overwriting existing keys in out.
func flattenJSONPaths(prefix string, m map[string]any, out map[string]any) {
for k, v := range m {
key := k
if prefix != "" {
key = prefix + "." + k
}
switch child := v.(type) {
case map[string]any:
flattenJSONPaths(key, child, out)
case telemetrystoretypes.JSONValue:
flattenJSONPaths(key, child, out)
default:
out[key] = v
}
}
}
// mergeSpanAttributeColumns merges (attributes_string, attributes_number, attributes_bool, resources_string) into
// unified "attributes" and "resource" keys, and parses the stringified `events`
// and `links` columns into structured slices. Raw DB columns are removed.
@@ -580,7 +598,7 @@ func mergeSpanAttributeColumns(data map[string]any) {
resStr, hasRes := data["resources_string"]
if hasStr || hasNum || hasBool || attrJSON != nil || hasRes {
attributes := make(map[string]any)
attrJSON.FlattenInto("", attributes)
flattenJSONPaths("", attrJSON, attributes)
if m, ok := attrStr.(map[string]string); ok {
for k, v := range m {
attributes[k] = v

View File

@@ -13,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
@@ -49,11 +50,6 @@ type rule struct {
OrgID string `bun:"org_id,type:text"`
}
type routePolicyRuleData struct {
PreferredChannels []string `json:"preferredChannels"`
Labels map[string]string `json:"labels"`
}
type addRoutePolicies struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
@@ -191,20 +187,20 @@ func (migration *addRoutePolicies) migrateRulesToRoutePolicies(ctx context.Conte
func (migration *addRoutePolicies) convertRulesToRoutes(rules []*rule, channelsByOrg map[string][]string) ([]*expressionRoute, error) {
var routes []*expressionRoute
for _, r := range rules {
var ruleData routePolicyRuleData
if err := json.Unmarshal([]byte(r.Data), &ruleData); err != nil {
var gettableRule ruletypes.GettableRule
if err := json.Unmarshal([]byte(r.Data), &gettableRule); err != nil {
return nil, errors.NewInternalf(errors.CodeInternal, "failed to unmarshal rule data for rule ID %s: %v", r.ID, err)
}
if len(ruleData.PreferredChannels) == 0 {
if len(gettableRule.PreferredChannels) == 0 {
channels, exists := channelsByOrg[r.OrgID]
if !exists || len(channels) == 0 {
continue
}
ruleData.PreferredChannels = channels
gettableRule.PreferredChannels = channels
}
severity := "critical"
if v, ok := ruleData.Labels["severity"]; ok {
if v, ok := gettableRule.Labels["severity"]; ok {
severity = v
}
expression := fmt.Sprintf(`%s == "%s" && %s == "%s"`, "threshold.name", severity, "ruleId", r.ID.String())
@@ -222,7 +218,7 @@ func (migration *addRoutePolicies) convertRulesToRoutes(rules []*rule, channelsB
},
Expression: expression,
ExpressionKind: "rule",
Channels: ruleData.PreferredChannels,
Channels: gettableRule.PreferredChannels,
Name: r.ID.StringValue(),
Enabled: true,
OrgID: r.OrgID,

View File

@@ -364,26 +364,12 @@ func (provider *provider) gc(ctx context.Context, org *types.Organization) error
}
func (provider *provider) flushLastObservedAt(ctx context.Context, org *types.Organization) error {
tokens, err := provider.tokenStore.ListByOrgID(ctx, org.ID)
accessTokenToLastObservedAt, err := provider.listLastObservedAtDesc(ctx, org.ID)
if err != nil {
return err
}
observedTokens := make([]*authtypes.StorableToken, 0, len(tokens))
for _, token := range tokens {
cachedLastObservedAt, ok := provider.lastObservedAtCache.Get(lastObservedAtCacheKey(token.AccessToken, token.UserID))
if !ok {
continue
}
if err := token.UpdateLastObservedAt(cachedLastObservedAt); err != nil {
continue
}
observedTokens = append(observedTokens, token)
}
if err := provider.tokenStore.UpdateLastObservedAt(ctx, observedTokens); err != nil {
if err := provider.tokenStore.UpdateLastObservedAtByAccessToken(ctx, accessTokenToLastObservedAt); err != nil {
return err
}

View File

@@ -232,16 +232,15 @@ func (store *store) ListByUserID(ctx context.Context, userID valuer.UUID) ([]*au
return tokens, nil
}
func (store *store) UpdateLastObservedAt(ctx context.Context, tokens []*authtypes.StorableToken) error {
if len(tokens) == 0 {
func (store *store) UpdateLastObservedAtByAccessToken(ctx context.Context, accessTokenToLastObservedAt []map[string]any) error {
if len(accessTokenToLastObservedAt) == 0 {
return nil
}
values := store.
sqlstore.
BunDBCtx(ctx).
NewValues(&tokens).
Column("id", "last_observed_at", "updated_at")
NewValues(&accessTokenToLastObservedAt)
_, err := store.
sqlstore.
@@ -251,8 +250,8 @@ func (store *store) UpdateLastObservedAt(ctx context.Context, tokens []*authtype
Model((*authtypes.StorableToken)(nil)).
TableExpr("update_cte").
Set("last_observed_at = update_cte.last_observed_at").
Set("updated_at = update_cte.updated_at").
Where("auth_token.id = update_cte.id").
Where("auth_token.access_token = update_cte.access_token").
Where("auth_token.user_id = update_cte.user_id").
Exec(ctx)
if err != nil {
return err

View File

@@ -1,41 +0,0 @@
package genaimessages
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testdata/captured/<sdk>/<attribute>.txt holds a real attribute value from the scripts
// repo's static-telemetry-generator captures; <attribute>.want.json is its reviewed output.
func TestNormalizeCapturedPayloads(t *testing.T) {
inputs, err := filepath.Glob(filepath.Join("testdata", "captured", "*", "*.txt"))
require.NoError(t, err)
require.NotEmpty(t, inputs)
for _, input := range inputs {
sdk := filepath.Base(filepath.Dir(input))
attribute := strings.TrimSuffix(filepath.Base(input), ".txt")
t.Run(sdk+"/"+attribute, func(t *testing.T) {
raw, err := os.ReadFile(input)
require.NoError(t, err)
wantJSON, err := os.ReadFile(strings.TrimSuffix(input, ".txt") + ".want.json")
require.NoError(t, err)
var want []aiobservabilitytypes.Message
require.NoError(t, json.Unmarshal(wantJSON, &want))
got, err := json.Marshal(Normalize(string(raw)))
require.NoError(t, err)
var gotRoundTrip []aiobservabilitytypes.Message
require.NoError(t, json.Unmarshal(got, &gotRoundTrip))
assert.Equal(t, want, gotRoundTrip)
})
}
}

View File

@@ -1,307 +0,0 @@
package genaimessages
import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
// convertSemconvMessages handles [{role, parts, finish_reason}] and Gemini contents.
func convertSemconvMessages(value any) ([]aiobservabilitytypes.Message, bool) {
list, ok := value.([]any)
if !ok || len(list) == 0 {
return nil, false
}
first, ok := toObject(firstItem(list))
if !ok {
return nil, false
}
if ok := first.has("parts"); !ok {
return nil, false
}
messages := make([]aiobservabilitytypes.Message, 0, len(list))
for _, item := range list {
m, ok := toObject(item)
if !ok {
messages = append(messages, genericMessages(stringOf(item))...)
continue
}
messages = append(messages, semconvMessage(m, "")...)
}
return messages, true
}
// convertChatMessageList handles OpenAI, Anthropic, Vercel and LangChain message lists.
func convertChatMessageList(value any) ([]aiobservabilitytypes.Message, bool) {
list, ok := value.([]any)
if !ok || len(list) == 0 {
return nil, false
}
if !isChatMessage(firstItem(list)) {
return nil, false
}
messages := make([]aiobservabilitytypes.Message, 0, len(list))
for _, item := range list {
messages = append(messages, chatMessage(item, "")...)
}
return messages, true
}
func isChatMessage(value any) bool {
m, ok := toObject(value)
if !ok {
return false
}
if ok := m.has("role"); ok {
return true
}
switch typ := m.str("type"); typ {
case "message", "reasoning", "human", "ai", "tool", "system":
return true
case "constructor":
ok := m.has("kwargs")
return ok
default:
return isResponsesItemType(typ)
}
}
// convertToolCallList handles a bare tool call list, e.g. Vercel ai.response.toolCalls.
func convertToolCallList(value any) ([]aiobservabilitytypes.Message, bool) {
list, ok := value.([]any)
if !ok || len(list) == 0 {
return nil, false
}
msg := aiobservabilitytypes.Message{Role: aiobservabilitytypes.MessageRoleAssistant, Content: make([]aiobservabilitytypes.Part, 0, len(list))}
for _, item := range list {
call, ok := toObject(item)
if !ok || !isToolCall(call) {
return nil, false
}
msg.Content = append(msg.Content, toolCallPart(call))
}
return []aiobservabilitytypes.Message{msg}, true
}
// isToolCall rejects tool definitions, which carry no arguments.
func isToolCall(call object) bool {
if has := call.has("toolName"); has {
return true
}
if fn, ok := call.obj("function"); ok {
has := fn.has("arguments")
return has
}
if has := call.has("name"); !has {
return false
}
_, has := call.lookup("arguments", "args")
return has
}
// convertChatRequest handles OpenAI, Anthropic, Vercel, Gemini and LangChain request objects.
func convertChatRequest(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
conversation, ok := m.lookup("messages", "input", "contents", "prompt")
if !ok {
return nil, false
}
messages := []aiobservabilitytypes.Message{}
if system := systemMessage(m.first("system", "instructions", "system_instruction", "systemInstruction", "system_prompt")); system != nil {
messages = append(messages, *system)
} else if config, ok := m.obj("config"); ok {
if system := systemMessage(config.first("system_instruction", "systemInstruction")); system != nil {
messages = append(messages, *system)
}
}
// {messages: "[...]"}: the list arrives JSON-encoded once more from some SDKs
if s, isString := conversation.(string); isString {
var decoded any
if err := json.Unmarshal([]byte(s), &decoded); err == nil {
if _, isList := decoded.([]any); isList {
conversation = decoded
}
}
}
switch c := conversation.(type) {
case string:
if c != "" {
messages = append(messages, textMessage(aiobservabilitytypes.MessageRoleUser, c))
}
case []any:
for _, item := range flattenOnce(c) {
if s, isString := item.(string); isString {
messages = append(messages, textMessage(aiobservabilitytypes.MessageRoleUser, s))
continue
}
messages = append(messages, semconvMessage(asObject(item), aiobservabilitytypes.MessageRoleUser)...)
}
case map[string]any:
messages = append(messages, semconvMessage(c, aiobservabilitytypes.MessageRoleUser)...)
default:
return nil, false
}
return messages, true
}
// systemMessage returns nil when value is empty or unknown.
func systemMessage(value any) *aiobservabilitytypes.Message {
msg := aiobservabilitytypes.Message{Role: aiobservabilitytypes.MessageRoleSystem, Content: []aiobservabilitytypes.Part{}}
switch v := value.(type) {
case string:
if v == "" {
return nil
}
msg.Content = append(msg.Content, textPart(v))
case []any:
for _, item := range v {
msg.Content = append(msg.Content, contentBlockPart(item))
}
case map[string]any:
parts, ok := object(v).list("parts")
if !ok {
return nil
}
for _, p := range parts {
msg.Content = append(msg.Content, semconvPart(p))
}
default:
return nil
}
if len(msg.Content) == 0 {
return nil
}
return &msg
}
// convertChatResponse handles the OpenAI Chat Completions {choices} response.
func convertChatResponse(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
choices, ok := m.list("choices")
if !ok {
return nil, false
}
messages := make([]aiobservabilitytypes.Message, 0, len(choices))
for _, c := range choices {
choice := asObject(c)
var converted []aiobservabilitytypes.Message
if message, ok := toObject(choice.first("message", "delta")); ok {
converted = chatMessage(message, aiobservabilitytypes.MessageRoleAssistant)
} else if text, ok := choice.get("text"); ok {
converted = []aiobservabilitytypes.Message{textMessage(aiobservabilitytypes.MessageRoleAssistant, stringOf(text))}
} else {
converted = []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{genericPart(choice)}}}
}
messages = append(messages, withFinishReason(converted, choice.str("finish_reason"))...)
}
return messages, true
}
func convertResponsesAPIResponse(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
output, ok := m.list("output")
if !ok {
return nil, false
}
messages := make([]aiobservabilitytypes.Message, 0, len(output))
for _, item := range output {
messages = append(messages, chatMessage(item, aiobservabilitytypes.MessageRoleAssistant)...)
}
if last := lastMessage(messages); last != nil && last.FinishReason == "" {
if details, ok := m.obj("incomplete_details"); ok {
last.FinishReason = normalizeFinishReason(details.str("reason"))
} else if m.str("status") == "completed" {
last.FinishReason = aiobservabilitytypes.FinishReasonStop
}
}
return messages, true
}
// convertGeminiResponse handles Gemini {candidates} and Google ADK {content} responses.
func convertGeminiResponse(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
if content, ok := m.obj("content"); ok {
if hasParts := content.has("parts"); hasParts {
return withFinishReason(semconvMessage(content, aiobservabilitytypes.MessageRoleAssistant), finishReasonOf(m)), true
}
}
candidates, ok := m.list("candidates")
if !ok {
return nil, false
}
messages := make([]aiobservabilitytypes.Message, 0, len(candidates))
for _, c := range candidates {
candidate := asObject(c)
content, ok := candidate.obj("content")
if !ok {
generic := []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{genericPart(candidate)}}}
messages = append(messages, withFinishReason(generic, finishReasonOf(candidate))...)
continue
}
messages = append(messages, withFinishReason(semconvMessage(content, aiobservabilitytypes.MessageRoleAssistant), finishReasonOf(candidate))...)
}
return messages, true
}
func convertLegacyCompletion(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
completion, ok := m.text("completion")
if !ok {
return nil, false
}
msg := aiobservabilitytypes.Message{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{}}
if reasoning, ok := m.text("reasoning"); ok && reasoning != "" {
msg.Content = append(msg.Content, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: reasoning})
}
msg.Content = append(msg.Content, textPart(completion))
return []aiobservabilitytypes.Message{msg}, true
}
func convertLangChainGenerations(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
generations, ok := m.list("generations")
if !ok {
return nil, false
}
messages := []aiobservabilitytypes.Message{}
for _, g := range flattenOnce(generations) {
gen := asObject(g)
var converted []aiobservabilitytypes.Message
if message, ok := gen.obj("message"); ok {
converted = chatMessage(message, aiobservabilitytypes.MessageRoleAssistant)
} else {
converted = []aiobservabilitytypes.Message{textMessage(aiobservabilitytypes.MessageRoleAssistant, gen.str("text"))}
}
messages = append(messages, withFinishReason(converted, asObject(gen.at("generation_info")).str("finish_reason"))...)
}
return messages, true
}
// withFinishReason sets reason on the last message that has none.
func withFinishReason(messages []aiobservabilitytypes.Message, reason string) []aiobservabilitytypes.Message {
if last := lastMessage(messages); last != nil && last.FinishReason == "" {
last.FinishReason = normalizeFinishReason(reason)
}
return messages
}

Some files were not shown because too many files have changed in this diff Show More