mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-30 15:20:45 +01:00
Compare commits
12 Commits
feat/new-p
...
feat/ai-tr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae0d8cb1a4 | ||
|
|
abb242b1f9 | ||
|
|
61f6370f45 | ||
|
|
f8b22c0feb | ||
|
|
9e3a6bb35d | ||
|
|
47dd1fabf3 | ||
|
|
270988fb48 | ||
|
|
f5f019f61b | ||
|
|
39badeb591 | ||
|
|
ec05bfe755 | ||
|
|
ed1bf7ab89 | ||
|
|
6e979c8318 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -68,6 +68,7 @@ jobs:
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- tracedetail
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
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:
|
||||
@@ -9647,6 +9686,17 @@ 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:
|
||||
@@ -9961,6 +10011,92 @@ 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:
|
||||
@@ -15685,6 +15821,81 @@ 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
|
||||
|
||||
@@ -4,6 +4,53 @@
|
||||
* * 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
|
||||
@@ -11142,6 +11189,165 @@ 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;
|
||||
};
|
||||
@@ -12815,6 +13021,30 @@ 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
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
@@ -16,6 +22,9 @@ import type {
|
||||
GetFlamegraphPathParameters,
|
||||
GetTraceAggregations200,
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetTraceThread200,
|
||||
GetTraceThreadParams,
|
||||
GetTraceThreadPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -27,6 +36,26 @@ 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
|
||||
@@ -127,6 +156,121 @@ 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
|
||||
|
||||
@@ -47,4 +47,5 @@ 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',
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
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%;
|
||||
}
|
||||
@@ -70,7 +74,9 @@
|
||||
|
||||
.chat-support-gateway {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
// 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));
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ 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';
|
||||
@@ -51,6 +52,7 @@ 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';
|
||||
@@ -402,6 +404,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
}, [pathname]);
|
||||
|
||||
const isToDisplayLayout = isLoggedIn;
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
|
||||
const pageTitle = t(routeKey);
|
||||
@@ -868,6 +871,10 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
</OverlayScrollbar>
|
||||
</LayoutContent>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
{isSavedViewEnabled && isToDisplayLayout && !renderFullScreen && (
|
||||
<BottomStrip />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoggedIn && isAIAssistantEnabled && (
|
||||
|
||||
@@ -12,8 +12,12 @@ 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)`
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
36
frontend/src/container/BottomStrip/BottomStrip.module.scss
Normal file
36
frontend/src/container/BottomStrip/BottomStrip.module.scss
Normal file
@@ -0,0 +1,36 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
42
frontend/src/container/BottomStrip/index.tsx
Normal file
42
frontend/src/container/BottomStrip/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
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;
|
||||
@@ -1,6 +1,8 @@
|
||||
.create-alert-v2-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
// 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);
|
||||
left: 63px;
|
||||
right: 0;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.explorer-options-container {
|
||||
position: fixed;
|
||||
bottom: 0px;
|
||||
// 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);
|
||||
left: calc(50% + 240px);
|
||||
transform: translate(calc(-50% - 120px), 0);
|
||||
transition: left 0.2s linear;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.explorer-option-droppable-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
// 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);
|
||||
width: -webkit-fill-available;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.home-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
.licenses-page {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.licenses-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
@@ -32,7 +29,6 @@
|
||||
|
||||
.licenses-page-content {
|
||||
flex: 1;
|
||||
height: calc(100vh - 48px);
|
||||
background: var(--l1-background);
|
||||
padding: 10px 8px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 62px);
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
|
||||
@@ -181,7 +181,9 @@
|
||||
|
||||
.ant-pagination {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
// 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);
|
||||
width: calc(100% - 54px);
|
||||
background: var(--l1-background);
|
||||
padding: 16px;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 62px);
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
padding-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
.version-container {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.version-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
|
||||
11
frontend/src/hooks/useSavedViewEnabled.ts
Normal file
11
frontend/src/hooks/useSavedViewEnabled.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,29 @@
|
||||
.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;
|
||||
}
|
||||
@@ -40,5 +65,9 @@
|
||||
|
||||
.alert-rules-container {
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ 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
|
||||
@@ -58,7 +57,6 @@ function ConfigPane({
|
||||
spec,
|
||||
onChangeSpec,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
queryType,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
@@ -127,7 +125,6 @@ function ConfigPane({
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
@@ -152,7 +149,6 @@ function ConfigPane({
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
|
||||
@@ -1,63 +1,6 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
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 { getPanelTypeDisabledReason } from './utils';
|
||||
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
|
||||
|
||||
interface PanelTypeSwitcherProps {
|
||||
/** The current panel kind (selected value). */
|
||||
@@ -20,96 +15,32 @@ 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): 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.
|
||||
* 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).
|
||||
*/
|
||||
function PanelTypeSwitcher({
|
||||
panelKind,
|
||||
queryType,
|
||||
signal,
|
||||
originalPanelKind,
|
||||
onChange,
|
||||
}: PanelTypeSwitcherProps): JSX.Element {
|
||||
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);
|
||||
}
|
||||
};
|
||||
const items = usePanelTypeSelectItems({ queryType, signal });
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel Type</Typography.Text>
|
||||
<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>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-type-switcher"
|
||||
value={panelKind}
|
||||
items={items}
|
||||
onChange={(value): void => onChange(value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
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(),
|
||||
get PANEL_OPTIONS(): unknown {
|
||||
return OPTIONS;
|
||||
},
|
||||
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 })),
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
@@ -36,34 +28,14 @@ const SUPPORTED_QUERY_TYPES: Record<string, EQueryType[]> = {
|
||||
'signoz/PieChartPanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
};
|
||||
|
||||
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 disabledLabels(): (string | null)[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).map((el) => el.textContent);
|
||||
}
|
||||
|
||||
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-', ''));
|
||||
function openDropdown(): void {
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'));
|
||||
}
|
||||
|
||||
describe('PanelTypeSwitcher', () => {
|
||||
@@ -72,8 +44,7 @@ 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) => ({
|
||||
...OPTIONS.find((option) => option.kind === kind),
|
||||
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
|
||||
mode: 'query',
|
||||
supportedSignals:
|
||||
kind === 'signoz/ListPanel'
|
||||
? ['logs', 'traces']
|
||||
@@ -86,94 +57,83 @@ describe('PanelTypeSwitcher', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('shows the current type and switches to the chosen one', async () => {
|
||||
const { onChange, user } = await renderSwitcher();
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-type-switcher')).toHaveTextContent(
|
||||
'Time SeriesChange',
|
||||
it('fires onChange with the chosen plugin kind', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
|
||||
|
||||
openDropdown();
|
||||
fireEvent.click(screen.getByText('List'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
|
||||
});
|
||||
|
||||
it('does not fire onChange when the current type is picked again', async () => {
|
||||
const { onChange, user } = await renderSwitcher();
|
||||
|
||||
await user.click(screen.getByTestId('panel-type-signoz/TimeSeriesPanel'));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables types whose supported signals exclude the current signal', async () => {
|
||||
const { onChange, user } = await renderSwitcher({
|
||||
signal: TelemetrytypesSignalDTO.metrics,
|
||||
});
|
||||
|
||||
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
|
||||
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not disable any type when the signal is unknown (builder, no signal)', async () => {
|
||||
await renderSwitcher();
|
||||
|
||||
expect(disabledKinds()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('disables Query-Builder-only kinds under PromQL even without a signal', async () => {
|
||||
await renderSwitcher({ queryType: EQueryType.PROM });
|
||||
|
||||
expect(disabledKinds()).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
'signoz/ListPanel',
|
||||
'signoz/TablePanel',
|
||||
'signoz/PieChartPanel',
|
||||
]),
|
||||
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()}
|
||||
/>,
|
||||
);
|
||||
expect(disabledKinds()).not.toContain('signoz/TimeSeriesPanel');
|
||||
expect(disabledKinds()).not.toContain('signoz/TextPanel');
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('disables List under ClickHouse while Table/Pie stay enabled', async () => {
|
||||
await renderSwitcher({
|
||||
panelKind: 'signoz/TablePanel',
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
});
|
||||
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()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
|
||||
openDropdown();
|
||||
expect(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('revert', () => {
|
||||
it('is hidden while the type is the original one', async () => {
|
||||
await renderSwitcher({ originalPanelKind: 'signoz/TimeSeriesPanel' });
|
||||
it('disables Query-Builder-only kinds under PromQL even without a signal', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.PROM}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-type-revert'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
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');
|
||||
});
|
||||
|
||||
it('switches back to the original type', async () => {
|
||||
const { onChange, user } = await renderSwitcher({
|
||||
panelKind: 'signoz/TablePanel',
|
||||
originalPanelKind: 'signoz/TimeSeriesPanel',
|
||||
});
|
||||
it('disables List under ClickHouse while Table/Pie stay enabled', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TablePanel"
|
||||
queryType={EQueryType.CLICKHOUSE}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
openDropdown();
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).not.toContain('Table');
|
||||
expect(disabledLabels()).not.toContain('Pie Chart');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,8 @@ 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, the same rule the editor's `PanelTypeSwitcher` applies.
|
||||
* 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.
|
||||
*/
|
||||
export function usePanelTypeSelectItems({
|
||||
queryType,
|
||||
|
||||
@@ -58,7 +58,6 @@ function SectionSlot({
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
queryType,
|
||||
stepInterval,
|
||||
metricUnit,
|
||||
@@ -125,7 +124,6 @@ function SectionSlot({
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
|
||||
@@ -16,8 +16,6 @@ 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;
|
||||
|
||||
@@ -19,11 +19,7 @@ import styles from './VisualizationSection.module.scss';
|
||||
type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
|
||||
Pick<
|
||||
SectionEditorContext,
|
||||
| 'panelKind'
|
||||
| 'onChangePanelKind'
|
||||
| 'originalPanelKind'
|
||||
| 'signal'
|
||||
| 'queryType'
|
||||
'panelKind' | 'onChangePanelKind' | 'signal' | 'queryType'
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -39,7 +35,6 @@ function VisualizationSection({
|
||||
onChange,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
queryType,
|
||||
signal,
|
||||
}: VisualizationSectionProps): JSX.Element {
|
||||
@@ -52,7 +47,6 @@ function VisualizationSection({
|
||||
// supplied in practice; default to Query Builder at this boundary.
|
||||
queryType={queryType ?? EQueryType.QUERY_BUILDER}
|
||||
signal={signal}
|
||||
originalPanelKind={originalPanelKind}
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -11,8 +11,6 @@ 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'],
|
||||
@@ -176,7 +174,6 @@ 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
|
||||
@@ -192,8 +189,7 @@ describe('VisualizationSection', () => {
|
||||
screen.getByTestId('panel-editor-v2-type-switcher'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-type-switcher'));
|
||||
await user.click(screen.getByTestId('panel-type-signoz/TablePanel'));
|
||||
await pickOption('panel-editor-v2-type-switcher', 'Table');
|
||||
expect(onChangePanelKind).toHaveBeenCalledWith('signoz/TablePanel');
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ 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.
|
||||
@@ -59,7 +58,8 @@ interface QueryEditorBodyProps {
|
||||
savedPanel?: DashboardtypesPanelDTO;
|
||||
/** Creating a new panel (seeded default) vs editing an existing one. */
|
||||
isNew?: boolean;
|
||||
target?: NewPanelTarget;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
/** Leave the editor (navigate back to the dashboard) without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save — navigates back to the dashboard. */
|
||||
@@ -70,7 +70,6 @@ interface QueryEditorBodyProps {
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
originalPanelKind?: PanelKind;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,13 +84,12 @@ function QueryEditorBody({
|
||||
panel,
|
||||
savedPanel,
|
||||
isNew = false,
|
||||
target,
|
||||
layoutIndex,
|
||||
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.
|
||||
@@ -135,7 +133,7 @@ function QueryEditorBody({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew,
|
||||
target,
|
||||
layoutIndex,
|
||||
});
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
@@ -321,7 +319,6 @@ function QueryEditorBody({
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
queryType={currentQuery.queryType}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
|
||||
@@ -23,7 +23,6 @@ interface StaticEditorBodyProps extends PanelEditorContainerProps {
|
||||
draftApi: PanelEditorDraftApi;
|
||||
panelDefinition: RenderableStaticPanelDefinition;
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
originalPanelKind?: PanelKind;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,13 +35,12 @@ function StaticEditorBody({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew = false,
|
||||
target,
|
||||
layoutIndex,
|
||||
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.
|
||||
@@ -56,7 +54,7 @@ function StaticEditorBody({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew,
|
||||
target,
|
||||
layoutIndex,
|
||||
});
|
||||
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
@@ -124,7 +122,6 @@ function StaticEditorBody({
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
legendSeries={[]}
|
||||
tableColumns={[]}
|
||||
|
||||
@@ -6,33 +6,14 @@ import {
|
||||
NEW_PANEL_ID,
|
||||
newPanelSearch,
|
||||
parseNewPanelKind,
|
||||
parseNewPanelTarget,
|
||||
parseNewPanelLayoutIndex,
|
||||
} from '../newPanelRoute';
|
||||
|
||||
describe('newPanelRoute', () => {
|
||||
it('round-trips kind + layoutIndex through the new-panel search', () => {
|
||||
const search = newPanelSearch('signoz/ListPanel', {
|
||||
type: 'section',
|
||||
layoutIndex: 2,
|
||||
});
|
||||
const search = newPanelSearch('signoz/ListPanel', 2);
|
||||
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe('signoz/ListPanel');
|
||||
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();
|
||||
expect(parseNewPanelLayoutIndex(search)).toBe(2);
|
||||
});
|
||||
|
||||
it('omits layoutIndex when not provided', () => {
|
||||
@@ -40,7 +21,7 @@ describe('newPanelRoute', () => {
|
||||
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe(
|
||||
'signoz/TimeSeriesPanel',
|
||||
);
|
||||
expect(parseNewPanelTarget(search)).toBeUndefined();
|
||||
expect(parseNewPanelLayoutIndex(search)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns null for an existing panel id (not the new sentinel)', () => {
|
||||
|
||||
@@ -14,14 +14,15 @@ import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import { transferColumnWidths } from '../../Panels/utils/columnWidthStorage';
|
||||
import { createPanelOps, type NewPanelTarget } from '../../patchOps';
|
||||
import { createPanelOps } from '../../patchOps';
|
||||
|
||||
interface UsePanelEditorSaveArgs {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
/** Creating a new panel (vs editing an existing one) — adds panel + layout. */
|
||||
isNew?: boolean;
|
||||
target?: NewPanelTarget;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
}
|
||||
|
||||
interface UsePanelEditorSaveApi {
|
||||
@@ -41,7 +42,7 @@ export function usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew = false,
|
||||
target,
|
||||
layoutIndex,
|
||||
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
|
||||
const queryClient = useQueryClient();
|
||||
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
|
||||
@@ -59,7 +60,7 @@ export function usePanelEditorSave({
|
||||
savedPanelId = uuid();
|
||||
ops = createPanelOps({
|
||||
layouts: cached?.data.spec.layouts ?? [],
|
||||
target,
|
||||
layoutIndex,
|
||||
panelId: savedPanelId,
|
||||
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
|
||||
});
|
||||
@@ -88,7 +89,7 @@ export function usePanelEditorSave({
|
||||
});
|
||||
return savedPanelId;
|
||||
},
|
||||
[dashboardId, panelId, isNew, target, patchAsync, queryClient],
|
||||
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
|
||||
);
|
||||
|
||||
return { save, isSaving: isPatching, error };
|
||||
|
||||
@@ -2,7 +2,6 @@ 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';
|
||||
@@ -19,7 +18,8 @@ export interface PanelEditorContainerProps {
|
||||
savedPanel?: DashboardtypesPanelDTO;
|
||||
/** Creating a new panel (seeded default) vs editing an existing one. */
|
||||
isNew?: boolean;
|
||||
target?: NewPanelTarget;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
/** Leave the editor (navigate back to the dashboard) without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save — navigates back to the dashboard. */
|
||||
@@ -38,7 +38,6 @@ 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,
|
||||
@@ -53,7 +52,6 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
|
||||
draftApi={draftApi}
|
||||
panelDefinition={panelDefinition}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -64,7 +62,6 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
|
||||
draftApi={draftApi}
|
||||
panelDefinition={panelDefinition}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,33 +4,26 @@ 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 in the query
|
||||
// (`/panel/new?panelKind=…&layoutIndex=<index|root>` or `&newSection=<title>`).
|
||||
// New (unsaved) panels use a fixed id segment, carrying kind + target section in the
|
||||
// query (`/panel/new?panelKind=…&layoutIndex=…`); the real id is generated on save.
|
||||
export const NEW_PANEL_ID = 'new';
|
||||
const PANEL_KIND_PARAM = 'panelKind';
|
||||
const LAYOUT_INDEX_PARAM = 'layoutIndex';
|
||||
const NEW_SECTION_PARAM = 'newSection';
|
||||
const ROOT_LAYOUT = 'root';
|
||||
|
||||
/** Query string (incl. leading `?`) for the new-panel editor route. */
|
||||
export function newPanelSearch(
|
||||
panelKind: PanelKind,
|
||||
target?: NewPanelTarget,
|
||||
layoutIndex?: number,
|
||||
): string {
|
||||
const params = new URLSearchParams({ [PANEL_KIND_PARAM]: panelKind });
|
||||
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);
|
||||
if (layoutIndex !== undefined) {
|
||||
params.set(LAYOUT_INDEX_PARAM, String(layoutIndex));
|
||||
}
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
@@ -82,21 +75,12 @@ export function buildExportPanelLink({
|
||||
}=${encodeURIComponent(encodeURIComponent(JSON.stringify(query)))}`;
|
||||
}
|
||||
|
||||
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' };
|
||||
}
|
||||
/** 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);
|
||||
if (raw === null || raw === '') {
|
||||
return undefined;
|
||||
}
|
||||
const n = Number(raw);
|
||||
return Number.isNaN(n) ? undefined : { type: 'section', layoutIndex: n };
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
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;
|
||||
@@ -1,4 +0,0 @@
|
||||
.nameInput {
|
||||
flex: 0 1 260px;
|
||||
min-width: 140px;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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;
|
||||
@@ -1,184 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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;
|
||||
@@ -1,124 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
@@ -1,18 +1,69 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.body {
|
||||
.panelTypeSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
.grid {
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panelTypeCard {
|
||||
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: 10px;
|
||||
gap: 8px;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { useDashboardSections } from '../../../hooks/useDashboardSections';
|
||||
import { releasePanelPickerTarget } from '../../../store/usePanelPickerTargetStore';
|
||||
import type { NewPanelTarget } from '../../../patchOps';
|
||||
import { PANEL_OPTIONS } from '../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import AddPanelSplitButton from './AddPanelSplitButton';
|
||||
import NewSectionNameInput from './NewSectionNameInput';
|
||||
import PanelTypeBrowser from './PanelTypeBrowser';
|
||||
import { usePanelPickerDraftSection } from './usePanelPickerDraftSection';
|
||||
import { usePanelPickerTarget } from './usePanelPickerTarget';
|
||||
import PanelTypeSelectionModalFooter from './PanelTypeSelectionModalFooter';
|
||||
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, target?: NewPanelTarget) => void;
|
||||
onSelect: (panelKind: PanelKind, layoutIndex?: number) => void;
|
||||
/** Section the picker opens on; omit → the first section. */
|
||||
defaultLayoutIndex?: number;
|
||||
}
|
||||
@@ -33,112 +26,89 @@ 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 [selectedKind, setSelectedKind] =
|
||||
useState<PanelKind>(DEFAULT_PANEL_KIND);
|
||||
const [newSectionTitle, setNewSectionTitle] = useState<string | null>(null);
|
||||
const isCreatingSection = newSectionTitle !== null;
|
||||
const [selectedPanelKind, setSelectedPanelKind] = useState<PanelKind | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Seed the target section on open.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedValue(resolveDefaultSectionValue(options, defaultLayoutIndex));
|
||||
setSelectedKind(DEFAULT_PANEL_KIND);
|
||||
setNewSectionTitle(null);
|
||||
setSelectedPanelKind(null);
|
||||
}
|
||||
}, [open, options, defaultLayoutIndex]);
|
||||
|
||||
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 createPanel = (panelKind: PanelKind): void => {
|
||||
const layoutIndex = selectedValue === '' ? undefined : Number(selectedValue);
|
||||
onSelect(panelKind, layoutIndex);
|
||||
};
|
||||
|
||||
const sectionTitle = newSectionTitle?.trim() ?? '';
|
||||
|
||||
const handleConfirm = (): void => {
|
||||
if (isCreatingSection && !sectionTitle) {
|
||||
const handleTileClick = (panelKind: PanelKind): void => {
|
||||
if (hasSectionPicker) {
|
||||
setSelectedPanelKind(panelKind);
|
||||
return;
|
||||
}
|
||||
releasePanelPickerTarget(false);
|
||||
onSelect(
|
||||
selectedKind,
|
||||
isCreatingSection
|
||||
? { type: 'newSection', title: sectionTitle }
|
||||
: selectedTarget,
|
||||
);
|
||||
createPanel(panelKind);
|
||||
};
|
||||
|
||||
const handleSectionChange = (value: string): void => {
|
||||
setSelectedValue(value);
|
||||
setNewSectionTitle(null);
|
||||
const handleConfirm = (): void => {
|
||||
if (selectedPanelKind === null) {
|
||||
return;
|
||||
}
|
||||
createPanel(selectedPanelKind);
|
||||
};
|
||||
|
||||
let confirmLabel = 'Add panel';
|
||||
if (isCreatingSection) {
|
||||
confirmLabel = 'Add to new section';
|
||||
} else if (hasSectionPicker && selectedOption) {
|
||||
confirmLabel = `Add to ${selectedOption.label}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="New panel"
|
||||
subTitle="Pick a visualization. You can change it later."
|
||||
direction="right"
|
||||
width="wide"
|
||||
testId="panel-type-drawer"
|
||||
drawerDescriptionProps={{ className: styles.body }}
|
||||
title="New Panel"
|
||||
footer={
|
||||
<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}
|
||||
hasSectionPicker ? (
|
||||
<PanelTypeSelectionModalFooter
|
||||
options={options}
|
||||
value={isCreatingSection ? null : selectedValue}
|
||||
onChange={handleSectionChange}
|
||||
onCreate={(): void => setNewSectionTitle('')}
|
||||
selectedValue={selectedValue}
|
||||
onSectionChange={setSelectedValue}
|
||||
isConfirmDisabled={selectedPanelKind === null}
|
||||
onConfirm={handleConfirm}
|
||||
disabled={isCreatingSection && !sectionTitle}
|
||||
/>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<PanelTypeBrowser selectedKind={selectedKind} onSelect={setSelectedKind} />
|
||||
</DrawerWrapper>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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;
|
||||
@@ -1,54 +0,0 @@
|
||||
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;
|
||||
@@ -1,29 +0,0 @@
|
||||
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;
|
||||
@@ -1,34 +0,0 @@
|
||||
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;
|
||||
@@ -0,0 +1,55 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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;
|
||||
@@ -1,327 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
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),
|
||||
),
|
||||
}));
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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} />,
|
||||
};
|
||||
@@ -1,17 +1,15 @@
|
||||
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 {
|
||||
/** `layoutIndex` stringified, or "root" for a root yet to be created. */
|
||||
/** The section's `layoutIndex`, stringified for the Select value. */
|
||||
value: string;
|
||||
target: NewPanelTarget;
|
||||
layoutIndex: number;
|
||||
/** Section title, or "Dashboard (root)" for the untitled top-level layout. */
|
||||
label: string;
|
||||
/** Caption under the label. */
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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]);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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]);
|
||||
}
|
||||
@@ -7,47 +7,33 @@ const ROOT_LABEL = 'Dashboard (root)';
|
||||
const ROOT_DESCRIPTION = 'Top level — no section';
|
||||
const SECTION_DESCRIPTION = 'Section';
|
||||
|
||||
const NEW_ROOT_VALUE = 'root';
|
||||
|
||||
/** Maps dashboard sections to section-picker options; a sectioned dashboard always offers the root. */
|
||||
/** Maps dashboard sections to section-picker options (untitled → "root"). */
|
||||
export function buildSectionOptions(
|
||||
sections: DashboardSection[],
|
||||
): SectionOption[] {
|
||||
const rootSection = findRootSection(sections);
|
||||
const options: SectionOption[] = sections.map((section) => {
|
||||
return sections.map((section) => {
|
||||
const isRoot = rootSection === section;
|
||||
return {
|
||||
value: String(section.layoutIndex),
|
||||
target: { type: 'section', layoutIndex: section.layoutIndex },
|
||||
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 dashboard root.
|
||||
* triggered from when present and still valid, otherwise the first option.
|
||||
*/
|
||||
export function resolveDefaultSectionValue(
|
||||
options: SectionOption[],
|
||||
defaultLayoutIndex: number | undefined,
|
||||
): string {
|
||||
const fallback =
|
||||
(options.find((option) => option.isRoot) ?? options[0])?.value ?? '';
|
||||
const fallback = options[0]?.value ?? '';
|
||||
if (defaultLayoutIndex === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -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 (AddPanelSplitButton).
|
||||
// Matches the root option label in the New Panel picker (SectionPicker).
|
||||
const ROOT_LABEL = 'Dashboard (root)';
|
||||
|
||||
interface MoveItemsArgs {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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;
|
||||
@@ -1,54 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,6 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pickerTarget {
|
||||
border-radius: 4px;
|
||||
outline: 1px dashed var(--bg-robin-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -10,8 +9,6 @@ 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';
|
||||
@@ -70,17 +67,12 @@ 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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -89,7 +81,6 @@ 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}
|
||||
>
|
||||
@@ -101,9 +92,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
ref={sectionRef}
|
||||
className={cx(styles.section, {
|
||||
[styles.pickerTarget]: isOutlined,
|
||||
})}
|
||||
className={styles.section}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
>
|
||||
@@ -124,7 +113,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
}}
|
||||
/>
|
||||
{open &&
|
||||
(section.items.length > 0 || pickerTarget ? (
|
||||
(section.items.length > 0 ? (
|
||||
grid
|
||||
) : (
|
||||
<div className={styles.emptySection}>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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;
|
||||
@@ -1,9 +1,4 @@
|
||||
.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) {
|
||||
|
||||
@@ -1,60 +1,47 @@
|
||||
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[]>(() => {
|
||||
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 rglLayout = useMemo<Layout[]>(
|
||||
() =>
|
||||
items.map((item) => ({
|
||||
i: item.id,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
})),
|
||||
[items],
|
||||
);
|
||||
|
||||
const { handleLayoutChange } = usePersistLayout({ layoutIndex, items });
|
||||
|
||||
return (
|
||||
<ResponsiveGridLayout
|
||||
className={cx(styles.grid, { [styles.instantHeight]: !!placeholderKind })}
|
||||
className={styles.grid}
|
||||
cols={12}
|
||||
rowHeight={GRID_ROW_HEIGHT}
|
||||
rowHeight={45}
|
||||
autoSize
|
||||
useCSSTransforms
|
||||
layout={rglLayout}
|
||||
@@ -64,7 +51,7 @@ function SectionGrid({
|
||||
isResizable={isEditable}
|
||||
onDragStop={handleLayoutChange}
|
||||
onResizeStop={handleLayoutChange}
|
||||
margin={[GRID_MARGIN, GRID_MARGIN]}
|
||||
margin={[8, 8]}
|
||||
>
|
||||
{items.map((item) => (
|
||||
// A layout item can reference a panel id that no longer exists in the
|
||||
@@ -87,11 +74,6 @@ function SectionGrid({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{placeholderKind && (
|
||||
<div key={PLACEHOLDER_ID}>
|
||||
<NewPanelPlaceholder kind={placeholderKind} />
|
||||
</div>
|
||||
)}
|
||||
</ResponsiveGridLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
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)');
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
/** 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
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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]);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ 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';
|
||||
@@ -73,7 +72,6 @@ function PanelsAndSectionsLayout({
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
{renderContent()}
|
||||
<DraftSection />
|
||||
<ViewPanelModal
|
||||
open={!!expandedPanel}
|
||||
panel={expandedPanel}
|
||||
|
||||
@@ -12,9 +12,7 @@ import {
|
||||
createPanelOps,
|
||||
findFreeSlot,
|
||||
itemsOverlap,
|
||||
newPanelSlot,
|
||||
setPanelTextOp,
|
||||
titleLooseLayoutsOps,
|
||||
} from '../patchOps';
|
||||
|
||||
function item(y: number, height: number): DashboardGridItemDTO {
|
||||
@@ -30,10 +28,6 @@ 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',
|
||||
@@ -56,12 +50,7 @@ describe('createPanelOps', () => {
|
||||
|
||||
it('adds the panel + a grid item in the requested section', () => {
|
||||
const layouts = [section([item(0, 6)]), section([])];
|
||||
const ops = createPanelOps({
|
||||
layouts,
|
||||
target: { type: 'section', layoutIndex: 0 },
|
||||
panelId: 'p1',
|
||||
panel,
|
||||
});
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
|
||||
|
||||
expect(ops).toHaveLength(2);
|
||||
expect(ops[0]).toMatchObject({ op: 'add', path: '/spec/panels/p1' });
|
||||
@@ -74,78 +63,10 @@ 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,
|
||||
target: { type: 'section', layoutIndex: 0 },
|
||||
panelId: 'p1',
|
||||
panel,
|
||||
});
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
|
||||
|
||||
const value = ops[1].value as DashboardGridItemDTO;
|
||||
expect(value.x).toBe(6);
|
||||
@@ -155,12 +76,7 @@ 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,
|
||||
target: { type: 'section', layoutIndex: 0 },
|
||||
panelId: 'p1',
|
||||
panel,
|
||||
});
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
|
||||
|
||||
const value = ops[1].value as DashboardGridItemDTO;
|
||||
expect(value.x).toBe(0);
|
||||
@@ -170,12 +86,7 @@ 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,
|
||||
target: { type: 'section', layoutIndex: 0 },
|
||||
panelId: 'p1',
|
||||
panel,
|
||||
});
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
|
||||
|
||||
const value = ops[1].value as DashboardGridItemDTO;
|
||||
expect(value.x).toBe(0);
|
||||
@@ -185,12 +96,7 @@ 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,
|
||||
target: { type: 'section', layoutIndex: 0 },
|
||||
panelId: 'p1',
|
||||
panel,
|
||||
});
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
|
||||
|
||||
const value = ops[1].value as DashboardGridItemDTO;
|
||||
expect(value.x).toBe(6);
|
||||
@@ -203,12 +109,7 @@ describe('createPanelOps', () => {
|
||||
section([itemAt(0, 0, 6, 6)]),
|
||||
section([itemAt(0, 0, 12, 6)]),
|
||||
];
|
||||
const ops = createPanelOps({
|
||||
layouts,
|
||||
target: { type: 'section', layoutIndex: 1 },
|
||||
panelId: 'p1',
|
||||
panel,
|
||||
});
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 1, panelId: 'p1', panel });
|
||||
|
||||
expect(ops[1].path).toBe('/spec/layouts/1/spec/items/-');
|
||||
const value = ops[1].value as DashboardGridItemDTO;
|
||||
@@ -220,6 +121,7 @@ describe('createPanelOps', () => {
|
||||
const layouts = [section([]), section([item(0, 6)])];
|
||||
const ops = createPanelOps({
|
||||
layouts,
|
||||
layoutIndex: undefined,
|
||||
panelId: 'p1',
|
||||
panel,
|
||||
});
|
||||
@@ -229,18 +131,14 @@ 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,
|
||||
target: { type: 'section', layoutIndex: 5 },
|
||||
panelId: 'p1',
|
||||
panel,
|
||||
});
|
||||
const ops = createPanelOps({ layouts, 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,
|
||||
});
|
||||
@@ -263,12 +161,7 @@ describe('createPanelOps', () => {
|
||||
itemAt(0, 6, 3, 6),
|
||||
]),
|
||||
];
|
||||
const ops = createPanelOps({
|
||||
layouts,
|
||||
target: { type: 'section', layoutIndex: 0 },
|
||||
panelId: 'p1',
|
||||
panel,
|
||||
});
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
|
||||
|
||||
const value = ops[1].value as DashboardGridItemDTO;
|
||||
expect(value.x).toBe(0);
|
||||
@@ -380,35 +273,3 @@ 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
// 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);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
|
||||
@@ -35,10 +35,7 @@ describe('useCreatePanel', () => {
|
||||
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useCreatePanel());
|
||||
act(() => {
|
||||
result.current.createPanel('timeSeries' as never, {
|
||||
type: 'section',
|
||||
layoutIndex: 2,
|
||||
});
|
||||
result.current.createPanel('timeSeries' as never, 2);
|
||||
});
|
||||
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
@@ -48,19 +45,6 @@ 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',
|
||||
@@ -69,10 +53,7 @@ describe('useCreatePanel', () => {
|
||||
};
|
||||
const { result } = renderHook(() => useCreatePanel());
|
||||
act(() => {
|
||||
result.current.createPanel('timeSeries' as never, {
|
||||
type: 'section',
|
||||
layoutIndex: 2,
|
||||
});
|
||||
result.current.createPanel('timeSeries' as never, 2);
|
||||
});
|
||||
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -13,8 +12,8 @@ interface UseCreatePanelResult {
|
||||
closePicker: () => void;
|
||||
/** The section the picker was opened against — seeds its section dropdown. */
|
||||
targetLayoutIndex: number | undefined;
|
||||
/** `target` overrides the opened-against section (the picker's choice). */
|
||||
createPanel: (panelKind: PanelKind, target?: NewPanelTarget) => void;
|
||||
/** `layoutIndex` overrides the opened-against target (the dropdown's choice). */
|
||||
createPanel: (panelKind: PanelKind, layoutIndex?: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,13 +38,9 @@ export function useCreatePanel(): UseCreatePanelResult {
|
||||
}, []);
|
||||
|
||||
const createPanel = useCallback(
|
||||
(panelKind: PanelKind, pickedTarget?: NewPanelTarget): void => {
|
||||
(panelKind: PanelKind, targetIndex?: number): void => {
|
||||
setIsPickerOpen(false);
|
||||
const target =
|
||||
pickedTarget ??
|
||||
(layoutIndex === undefined
|
||||
? undefined
|
||||
: { type: 'section' as const, layoutIndex });
|
||||
const target = targetIndex ?? layoutIndex;
|
||||
// Every draft shares the sentinel id, so an abandoned draft's column widths
|
||||
// would otherwise seed this one.
|
||||
clearColumnWidths(NEW_PANEL_ID);
|
||||
|
||||
@@ -154,22 +154,16 @@ 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[];
|
||||
/** Omitted, or a stale section → the first section. */
|
||||
target?: NewPanelTarget;
|
||||
/** Preferred section (from a section's "Add panel" trigger); falls back to the root (first) section. */
|
||||
layoutIndex: number | undefined;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
export const NEW_PANEL_SIZE = { width: 6, height: 6 };
|
||||
const NEW_PANEL_SIZE = { width: 6, height: 6 };
|
||||
|
||||
/** Columns in the section grid — mirrors `cols` on SectionGrid's GridLayout. */
|
||||
export const GRID_COLS = 12;
|
||||
@@ -246,18 +240,14 @@ 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, creating it when needed, and place the panel via `findFreeSlot`.
|
||||
* section (requested index if valid, else the root/first section, else a
|
||||
* freshly-created one) and place the panel via `findFreeSlot`.
|
||||
*/
|
||||
export function createPanelOps({
|
||||
layouts,
|
||||
target,
|
||||
layoutIndex,
|
||||
panelId,
|
||||
panel,
|
||||
}: CreatePanelOpsArgs): DashboardtypesJSONPatchOperationDTO[] {
|
||||
@@ -265,23 +255,10 @@ export function createPanelOps({
|
||||
|
||||
let targetIndex: number;
|
||||
let items: DashboardGridItemDTO[];
|
||||
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
|
||||
) {
|
||||
if (layoutIndex !== undefined && layouts[layoutIndex] !== undefined) {
|
||||
// Explicit section — a section's own "New Panel" trigger.
|
||||
targetIndex = target.layoutIndex;
|
||||
items = layouts[target.layoutIndex]?.spec.items ?? [];
|
||||
targetIndex = layoutIndex;
|
||||
items = layouts[layoutIndex]?.spec.items ?? [];
|
||||
} else if (layouts.length > 0) {
|
||||
// No section specified (toolbar "New Panel") → the root (first) section.
|
||||
targetIndex = 0;
|
||||
@@ -293,13 +270,16 @@ export function createPanelOps({
|
||||
items = [];
|
||||
}
|
||||
|
||||
const { x, y } = findFreeSlot(items, NEW_PANEL_SIZE.width);
|
||||
ops.push(
|
||||
...addPanelToSectionOps({
|
||||
panelId,
|
||||
panel,
|
||||
layoutIndex: targetIndex,
|
||||
item: {
|
||||
...newPanelSlot(items),
|
||||
x,
|
||||
y,
|
||||
...NEW_PANEL_SIZE,
|
||||
content: { $ref: panelRef(panelId) },
|
||||
},
|
||||
}),
|
||||
@@ -354,22 +334,6 @@ 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,
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import PanelEditorContainer from '../DashboardContainer/PanelEditor';
|
||||
import type { PanelEditorHandoffState } from '../DashboardContainer/PanelEditor/panelEditorHandoff';
|
||||
import {
|
||||
parseNewPanelKind,
|
||||
parseNewPanelTarget,
|
||||
parseNewPanelLayoutIndex,
|
||||
} 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 target = parseNewPanelTarget(search);
|
||||
const layoutIndex = parseNewPanelLayoutIndex(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}
|
||||
target={target}
|
||||
layoutIndex={layoutIndex}
|
||||
onClose={backToDashboard}
|
||||
onSaved={backToDashboard}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
.support-page-container {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.support-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.root {
|
||||
height: calc(100vh);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
.traces-funnel-details {
|
||||
display: flex;
|
||||
// 45px -> height of the tab bar
|
||||
height: calc(100vh - 45px);
|
||||
height: 100%;
|
||||
|
||||
&__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%;
|
||||
|
||||
@@ -4,14 +4,17 @@
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
&.funnel-details-page {
|
||||
height: calc(
|
||||
100vh - 170px
|
||||
); // 64px bottom bar + 61px configuration header + 45px page navbar
|
||||
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;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -67,5 +67,23 @@ 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
|
||||
}
|
||||
|
||||
@@ -75,3 +75,25 @@ 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)
|
||||
}
|
||||
|
||||
@@ -173,6 +173,19 @@ 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 {
|
||||
|
||||
@@ -11,12 +11,23 @@ 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:
|
||||
@@ -123,16 +134,8 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
|
||||
return []spantypes.StorableSpan{}, nil
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
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.Select("DISTINCT ON (span_id) timestamp")
|
||||
sb.SelectMore(fullSpanColumns...)
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
ids := make([]any, len(spanIDs))
|
||||
for i, id := range spanIDs {
|
||||
@@ -155,6 +158,44 @@ 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(
|
||||
|
||||
@@ -13,6 +13,7 @@ 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.
|
||||
@@ -20,4 +21,5 @@ 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)
|
||||
}
|
||||
|
||||
@@ -566,24 +566,6 @@ 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.
|
||||
@@ -598,7 +580,7 @@ func mergeSpanAttributeColumns(data map[string]any) {
|
||||
resStr, hasRes := data["resources_string"]
|
||||
if hasStr || hasNum || hasBool || attrJSON != nil || hasRes {
|
||||
attributes := make(map[string]any)
|
||||
flattenJSONPaths("", attrJSON, attributes)
|
||||
attrJSON.FlattenInto("", attributes)
|
||||
if m, ok := attrStr.(map[string]string); ok {
|
||||
for k, v := range m {
|
||||
attributes[k] = v
|
||||
|
||||
@@ -13,7 +13,6 @@ 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"
|
||||
@@ -50,6 +49,11 @@ 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
|
||||
@@ -187,20 +191,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 gettableRule ruletypes.GettableRule
|
||||
if err := json.Unmarshal([]byte(r.Data), &gettableRule); err != nil {
|
||||
var ruleData routePolicyRuleData
|
||||
if err := json.Unmarshal([]byte(r.Data), &ruleData); err != nil {
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "failed to unmarshal rule data for rule ID %s: %v", r.ID, err)
|
||||
}
|
||||
|
||||
if len(gettableRule.PreferredChannels) == 0 {
|
||||
if len(ruleData.PreferredChannels) == 0 {
|
||||
channels, exists := channelsByOrg[r.OrgID]
|
||||
if !exists || len(channels) == 0 {
|
||||
continue
|
||||
}
|
||||
gettableRule.PreferredChannels = channels
|
||||
ruleData.PreferredChannels = channels
|
||||
}
|
||||
severity := "critical"
|
||||
if v, ok := gettableRule.Labels["severity"]; ok {
|
||||
if v, ok := ruleData.Labels["severity"]; ok {
|
||||
severity = v
|
||||
}
|
||||
expression := fmt.Sprintf(`%s == "%s" && %s == "%s"`, "threshold.name", severity, "ruleId", r.ID.String())
|
||||
@@ -218,7 +222,7 @@ func (migration *addRoutePolicies) convertRulesToRoutes(rules []*rule, channelsB
|
||||
},
|
||||
Expression: expression,
|
||||
ExpressionKind: "rule",
|
||||
Channels: gettableRule.PreferredChannels,
|
||||
Channels: ruleData.PreferredChannels,
|
||||
Name: r.ID.StringValue(),
|
||||
Enabled: true,
|
||||
OrgID: r.OrgID,
|
||||
|
||||
@@ -364,12 +364,26 @@ func (provider *provider) gc(ctx context.Context, org *types.Organization) error
|
||||
}
|
||||
|
||||
func (provider *provider) flushLastObservedAt(ctx context.Context, org *types.Organization) error {
|
||||
accessTokenToLastObservedAt, err := provider.listLastObservedAtDesc(ctx, org.ID)
|
||||
tokens, err := provider.tokenStore.ListByOrgID(ctx, org.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.tokenStore.UpdateLastObservedAtByAccessToken(ctx, accessTokenToLastObservedAt); err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -232,15 +232,16 @@ func (store *store) ListByUserID(ctx context.Context, userID valuer.UUID) ([]*au
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func (store *store) UpdateLastObservedAtByAccessToken(ctx context.Context, accessTokenToLastObservedAt []map[string]any) error {
|
||||
if len(accessTokenToLastObservedAt) == 0 {
|
||||
func (store *store) UpdateLastObservedAt(ctx context.Context, tokens []*authtypes.StorableToken) error {
|
||||
if len(tokens) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
values := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewValues(&accessTokenToLastObservedAt)
|
||||
NewValues(&tokens).
|
||||
Column("id", "last_observed_at", "updated_at")
|
||||
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
@@ -250,8 +251,8 @@ func (store *store) UpdateLastObservedAtByAccessToken(ctx context.Context, acces
|
||||
Model((*authtypes.StorableToken)(nil)).
|
||||
TableExpr("update_cte").
|
||||
Set("last_observed_at = update_cte.last_observed_at").
|
||||
Where("auth_token.access_token = update_cte.access_token").
|
||||
Where("auth_token.user_id = update_cte.user_id").
|
||||
Set("updated_at = update_cte.updated_at").
|
||||
Where("auth_token.id = update_cte.id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
307
pkg/types/aiobservabilitytypes/genaimessages/envelope.go
Normal file
307
pkg/types/aiobservabilitytypes/genaimessages/envelope.go
Normal file
@@ -0,0 +1,307 @@
|
||||
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
Reference in New Issue
Block a user