mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-23 16:40:35 +01:00
Compare commits
144 Commits
issue-5341
...
infraM/v2_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9924fca29d | ||
|
|
949d18f028 | ||
|
|
8180436432 | ||
|
|
c83c6054a1 | ||
|
|
b0a8e4fb36 | ||
|
|
2db3034037 | ||
|
|
41e70ef37f | ||
|
|
d147b0177a | ||
|
|
e833952c66 | ||
|
|
c4a46a5d7d | ||
|
|
8931c593d6 | ||
|
|
2d0a6d80f7 | ||
|
|
4543c0008b | ||
|
|
405c7d13ad | ||
|
|
3c1a4b4103 | ||
|
|
b414fc30af | ||
|
|
7dd64c0d53 | ||
|
|
e4a8c581d1 | ||
|
|
a949993430 | ||
|
|
f34a33e08b | ||
|
|
46e833faba | ||
|
|
4bd7492629 | ||
|
|
24fe9a986d | ||
|
|
56e79be6cd | ||
|
|
92d297ac9d | ||
|
|
b3c352609c | ||
|
|
bdbaa32485 | ||
|
|
9503cdff36 | ||
|
|
5a18786ab2 | ||
|
|
648154df14 | ||
|
|
98eb002e07 | ||
|
|
720379db9f | ||
|
|
6ad14e7151 | ||
|
|
181fca064b | ||
|
|
a5e39ca6bd | ||
|
|
b35c6676f9 | ||
|
|
1095caa123 | ||
|
|
9043b49762 | ||
|
|
d4084a7494 | ||
|
|
27c564b3bf | ||
|
|
f02c491828 | ||
|
|
3d53b8f77f | ||
|
|
dffe94fec4 | ||
|
|
c9360fcf13 | ||
|
|
b5ab45db20 | ||
|
|
08f76aca78 | ||
|
|
d81cec4c29 | ||
|
|
49744c6104 | ||
|
|
2147627baf | ||
|
|
824f92a88f | ||
|
|
983d4fe4f2 | ||
|
|
833af794c3 | ||
|
|
21b51d1fcc | ||
|
|
56f22682c8 | ||
|
|
9c8359940c | ||
|
|
4050880275 | ||
|
|
5e775f64f2 | ||
|
|
0189f23f46 | ||
|
|
49a36d4e3d | ||
|
|
9407d658ab | ||
|
|
5035712485 | ||
|
|
bab17c3615 | ||
|
|
37b44f4db9 | ||
|
|
99dd6e5f1e | ||
|
|
9c7131fa6a | ||
|
|
ad889a2e1d | ||
|
|
a4f6d0cbf5 | ||
|
|
589bed7c16 | ||
|
|
93843a1f48 | ||
|
|
88c43108fc | ||
|
|
ed4cf540e8 | ||
|
|
9e2dfa9033 | ||
|
|
d98d5d68ee | ||
|
|
2cb1c3b73b | ||
|
|
ae7ca497ad | ||
|
|
a579916961 | ||
|
|
4a16d56abf | ||
|
|
642b5ac3f0 | ||
|
|
a12112619c | ||
|
|
014785f1bc | ||
|
|
58ee797b10 | ||
|
|
82d236742f | ||
|
|
397e1ad5be | ||
|
|
8d6b25ca9b | ||
|
|
5fa6bd8b8d | ||
|
|
bd9977483b | ||
|
|
50fbdfeeef | ||
|
|
e2b1b73e87 | ||
|
|
cb9f3fd3e5 | ||
|
|
232acc343d | ||
|
|
2025afdccc | ||
|
|
d2f4d4af93 | ||
|
|
47ff7bbb8e | ||
|
|
724071c5dc | ||
|
|
4d24979358 | ||
|
|
042943b10a | ||
|
|
48a9be7ec8 | ||
|
|
a9504b2120 | ||
|
|
8755887c4a | ||
|
|
4cb4662b3a | ||
|
|
e6900dabc8 | ||
|
|
c1ba389b63 | ||
|
|
3a1f40234f | ||
|
|
2e4891fa63 | ||
|
|
04ebc0bec7 | ||
|
|
271f9b81ed | ||
|
|
6fa815c294 | ||
|
|
63ec518efb | ||
|
|
c4ca20dd90 | ||
|
|
e56cc4222b | ||
|
|
07d2944d7c | ||
|
|
dea01ae36a | ||
|
|
62ea5b54e2 | ||
|
|
e549a7e42f | ||
|
|
90e2ebb11f | ||
|
|
61baa1be7a | ||
|
|
b946fa665f | ||
|
|
2e049556e4 | ||
|
|
492a5e70d7 | ||
|
|
ba1f2771e8 | ||
|
|
7458fb4855 | ||
|
|
5f55f3938b | ||
|
|
3e8102485c | ||
|
|
861c682ea5 | ||
|
|
c8e5895dff | ||
|
|
82d72e7edb | ||
|
|
a3f8ecaaf1 | ||
|
|
19aada656c | ||
|
|
b21bb4280f | ||
|
|
bc0a4fdb5c | ||
|
|
37fb0e9254 | ||
|
|
aecfa1a174 | ||
|
|
b869d23d94 | ||
|
|
6ee3d44f76 | ||
|
|
462e554107 | ||
|
|
66afa73e6f | ||
|
|
54c604bcf4 | ||
|
|
c1be02ba54 | ||
|
|
d3c7ba8f45 | ||
|
|
039c4a0496 | ||
|
|
51a94b6bbc | ||
|
|
bbfbb94f52 | ||
|
|
d1eb9ef16f | ||
|
|
3db00f8bc3 |
69
.github/CODEOWNERS
vendored
69
.github/CODEOWNERS
vendored
@@ -199,3 +199,72 @@ go.mod @therealpandey
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
## Logs
|
||||
/frontend/src/pages/Logs/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LogsExplorer/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LogsModulePage/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LogsSettings/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LiveLogs/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerChart/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerContext/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerList/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerTable/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerViews/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsFilters/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsSearchFilter/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsTable/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsAggregate/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsContextList/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsIndexToFields/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsLoading/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsPanelTable/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogControls/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogDetailedView/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogExplorerQuerySection/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogLiveTail/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LiveLogs/ @SigNoz/events-frontend
|
||||
/frontend/src/container/EmptyLogsSearch/ @SigNoz/events-frontend
|
||||
/frontend/src/container/NoLogs/ @SigNoz/events-frontend
|
||||
/frontend/src/components/Logs/ @SigNoz/events-frontend
|
||||
/frontend/src/components/LogDetail/ @SigNoz/events-frontend
|
||||
/frontend/src/components/LogsFormatOptionsMenu/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/logs/ @SigNoz/events-frontend
|
||||
|
||||
## Logs Pipelines
|
||||
/frontend/src/pages/Pipelines/ @SigNoz/events-frontend
|
||||
/frontend/src/container/PipelinePage/ @SigNoz/events-frontend
|
||||
|
||||
## Traces / Trace Explorer
|
||||
/frontend/src/pages/Trace/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TracesExplorer/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TracesModulePage/ @SigNoz/events-frontend
|
||||
/frontend/src/container/Trace/ @SigNoz/events-frontend
|
||||
/frontend/src/container/TracesExplorer/ @SigNoz/events-frontend
|
||||
/frontend/src/container/TracesTableComponent/ @SigNoz/events-frontend
|
||||
|
||||
## Trace Funnels
|
||||
/frontend/src/pages/TracesFunnels/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TracesFunnelDetails/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/TracesFunnels/ @SigNoz/events-frontend
|
||||
|
||||
## Trace Details
|
||||
/frontend/src/pages/TraceDetailsV3/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TraceDetailOldRedirect/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/trace/ @SigNoz/events-frontend
|
||||
|
||||
## Exceptions
|
||||
/frontend/src/pages/AllErrors/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/ErrorDetails/ @SigNoz/events-frontend
|
||||
/frontend/src/container/AllError/ @SigNoz/events-frontend
|
||||
/frontend/src/container/ErrorDetails/ @SigNoz/events-frontend
|
||||
|
||||
## External APIs
|
||||
/frontend/src/pages/ApiMonitoring/ @SigNoz/events-frontend
|
||||
/frontend/src/container/ApiMonitoring/ @SigNoz/events-frontend
|
||||
|
||||
## Messaging Queues
|
||||
/frontend/src/pages/MessagingQueues/ @SigNoz/events-frontend
|
||||
/frontend/src/components/MessagingQueues/ @SigNoz/events-frontend
|
||||
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend
|
||||
|
||||
@@ -3974,6 +3974,29 @@ components:
|
||||
enabled:
|
||||
type: boolean
|
||||
type: object
|
||||
InframonitoringtypesAssociatedComponent:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesOnboardingComponentType'
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
type: object
|
||||
InframonitoringtypesAttributesComponentEntry:
|
||||
properties:
|
||||
associatedComponent:
|
||||
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
|
||||
attributes:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- attributes
|
||||
- associatedComponent
|
||||
type: object
|
||||
InframonitoringtypesClusterRecord:
|
||||
properties:
|
||||
clusterCPU:
|
||||
@@ -4323,6 +4346,57 @@ components:
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesMetricsComponentEntry:
|
||||
properties:
|
||||
associatedComponent:
|
||||
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
|
||||
metrics:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- metrics
|
||||
- associatedComponent
|
||||
type: object
|
||||
InframonitoringtypesMissingAttributesComponentEntry:
|
||||
properties:
|
||||
associatedComponent:
|
||||
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
|
||||
attributes:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
documentationLink:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- attributes
|
||||
- associatedComponent
|
||||
- message
|
||||
- documentationLink
|
||||
type: object
|
||||
InframonitoringtypesMissingMetricsComponentEntry:
|
||||
properties:
|
||||
associatedComponent:
|
||||
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
|
||||
documentationLink:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
metrics:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- metrics
|
||||
- associatedComponent
|
||||
- message
|
||||
- documentationLink
|
||||
type: object
|
||||
InframonitoringtypesNamespaceRecord:
|
||||
properties:
|
||||
meta:
|
||||
@@ -4447,6 +4521,71 @@ components:
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesOnboarding:
|
||||
properties:
|
||||
missingDefaultEnabledMetrics:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesMissingMetricsComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
missingOptionalMetrics:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesMissingMetricsComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
missingRequiredAttributes:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesMissingAttributesComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
presentDefaultEnabledMetrics:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesMetricsComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
presentOptionalMetrics:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesMetricsComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
presentRequiredAttributes:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesAttributesComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
ready:
|
||||
type: boolean
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesOnboardingType'
|
||||
required:
|
||||
- type
|
||||
- ready
|
||||
- presentDefaultEnabledMetrics
|
||||
- presentOptionalMetrics
|
||||
- presentRequiredAttributes
|
||||
- missingDefaultEnabledMetrics
|
||||
- missingOptionalMetrics
|
||||
- missingRequiredAttributes
|
||||
type: object
|
||||
InframonitoringtypesOnboardingComponentType:
|
||||
enum:
|
||||
- receiver
|
||||
- processor
|
||||
type: string
|
||||
InframonitoringtypesOnboardingType:
|
||||
enum:
|
||||
- hosts
|
||||
- processes
|
||||
- pods
|
||||
- nodes
|
||||
- deployments
|
||||
- daemonsets
|
||||
- statefulsets
|
||||
- jobs
|
||||
- namespaces
|
||||
- clusters
|
||||
- volumes
|
||||
type: string
|
||||
InframonitoringtypesPodCountsByPhase:
|
||||
properties:
|
||||
failed:
|
||||
@@ -15339,6 +15478,72 @@ paths:
|
||||
summary: List Nodes for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/onboarding:
|
||||
get:
|
||||
deprecated: false
|
||||
description: 'Returns the per-tab readiness of the infra-monitoring section
|
||||
selected by the ''type'' query parameter (hosts, processes, pods, nodes, deployments,
|
||||
daemonsets, statefulsets, jobs, namespaces, clusters, volumes). For each collector
|
||||
receiver or processor that contributes required metrics or attributes, lists
|
||||
what is present and what is missing, with a prebuilt user-facing message and
|
||||
a docs link per missing component. Default-enabled metrics are those expected
|
||||
as soon as the receiver is configured; optional metrics require ''enabled:
|
||||
true'' in receiver config. ''ready'' is true only when every missing list
|
||||
is empty.'
|
||||
operationId: GetOnboarding
|
||||
parameters:
|
||||
- in: query
|
||||
name: type
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesOnboardingType'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesOnboarding'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get Onboarding Status for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/pods:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -29,6 +29,18 @@ if (!HTMLElement.prototype.scrollIntoView) {
|
||||
HTMLElement.prototype.scrollIntoView = function (): void {};
|
||||
}
|
||||
|
||||
// jsdom doesn't implement the Pointer Capture API, which Radix UI primitives
|
||||
// (e.g. @signozhq/ui Select) call when opening. Stub them so those components
|
||||
// can be exercised in tests.
|
||||
if (!HTMLElement.prototype.hasPointerCapture) {
|
||||
HTMLElement.prototype.hasPointerCapture = function (): boolean {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
if (!HTMLElement.prototype.releasePointerCapture) {
|
||||
HTMLElement.prototype.releasePointerCapture = function (): void {};
|
||||
}
|
||||
|
||||
if (typeof window.IntersectionObserver === 'undefined') {
|
||||
class IntersectionObserverMock {
|
||||
observe(): void {}
|
||||
|
||||
@@ -122,6 +122,13 @@ export const DashboardWidget = Loadable(
|
||||
import(/* webpackChunkName: "DashboardWidgetPage" */ 'pages/DashboardWidget'),
|
||||
);
|
||||
|
||||
export const DashboardPanelEditorPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "DashboardPanelEditorPage" */ 'pages/DashboardPageV2/PanelEditorPage/PanelEditorPage'
|
||||
),
|
||||
);
|
||||
|
||||
export const EditRulesPage = Loadable(
|
||||
() => import(/* webpackChunkName: "Alerts Edit Page" */ 'pages/EditRules'),
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ChannelsNew,
|
||||
CreateNewAlerts,
|
||||
DashboardPage,
|
||||
DashboardPanelEditorPage,
|
||||
DashboardsListPage,
|
||||
DashboardWidget,
|
||||
EditRulesPage,
|
||||
@@ -196,6 +197,13 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
key: 'DASHBOARD_WIDGET',
|
||||
},
|
||||
{
|
||||
path: ROUTES.DASHBOARD_PANEL_EDITOR,
|
||||
exact: true,
|
||||
component: DashboardPanelEditorPage,
|
||||
isPrivate: true,
|
||||
key: 'DASHBOARD_PANEL_EDITOR',
|
||||
},
|
||||
{
|
||||
path: ROUTES.EDIT_ALERTS,
|
||||
exact: true,
|
||||
|
||||
@@ -4,14 +4,22 @@
|
||||
* * 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 {
|
||||
GetOnboarding200,
|
||||
GetOnboardingParams,
|
||||
InframonitoringtypesPostableClustersDTO,
|
||||
InframonitoringtypesPostableDaemonSetsDTO,
|
||||
InframonitoringtypesPostableDeploymentsDTO,
|
||||
@@ -619,6 +627,104 @@ export const useListNodes = <
|
||||
> => {
|
||||
return useMutation(getListNodesMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the per-tab readiness of the infra-monitoring section selected by the 'type' query parameter (hosts, processes, pods, nodes, deployments, daemonsets, statefulsets, jobs, namespaces, clusters, volumes). For each collector receiver or processor that contributes required metrics or attributes, lists what is present and what is missing, with a prebuilt user-facing message and a docs link per missing component. Default-enabled metrics are those expected as soon as the receiver is configured; optional metrics require 'enabled: true' in receiver config. 'ready' is true only when every missing list is empty.
|
||||
* @summary Get Onboarding Status for Infra Monitoring
|
||||
*/
|
||||
export const getOnboarding = (
|
||||
params: GetOnboardingParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetOnboarding200>({
|
||||
url: `/api/v2/infra_monitoring/onboarding`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetOnboardingQueryKey = (params?: GetOnboardingParams) => {
|
||||
return [
|
||||
`/api/v2/infra_monitoring/onboarding`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetOnboardingQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getOnboarding>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetOnboardingParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getOnboarding>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetOnboardingQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getOnboarding>>> = ({
|
||||
signal,
|
||||
}) => getOnboarding(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getOnboarding>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetOnboardingQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getOnboarding>>
|
||||
>;
|
||||
export type GetOnboardingQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get Onboarding Status for Infra Monitoring
|
||||
*/
|
||||
|
||||
export function useGetOnboarding<
|
||||
TData = Awaited<ReturnType<typeof getOnboarding>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetOnboardingParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getOnboarding>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetOnboardingQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get Onboarding Status for Infra Monitoring
|
||||
*/
|
||||
export const invalidateGetOnboarding = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetOnboardingParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetOnboardingQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Pods for Infra Monitoring
|
||||
|
||||
@@ -5422,6 +5422,26 @@ export interface GlobaltypesConfigDTO {
|
||||
mcp_url: string | null;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesOnboardingComponentTypeDTO {
|
||||
receiver = 'receiver',
|
||||
processor = 'processor',
|
||||
}
|
||||
export interface InframonitoringtypesAssociatedComponentDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
type: InframonitoringtypesOnboardingComponentTypeDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesAttributesComponentEntryDTO {
|
||||
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
attributes: string[] | null;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesClusterRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -5878,6 +5898,46 @@ export interface InframonitoringtypesJobsDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesMetricsComponentEntryDTO {
|
||||
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
metrics: string[] | null;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesMissingAttributesComponentEntryDTO {
|
||||
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
attributes: string[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
documentationLink: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesMissingMetricsComponentEntryDTO {
|
||||
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
documentationLink: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
metrics: string[] | null;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesNamespaceRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -5995,6 +6055,61 @@ export interface InframonitoringtypesNodesDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesOnboardingTypeDTO {
|
||||
hosts = 'hosts',
|
||||
processes = 'processes',
|
||||
pods = 'pods',
|
||||
nodes = 'nodes',
|
||||
deployments = 'deployments',
|
||||
daemonsets = 'daemonsets',
|
||||
statefulsets = 'statefulsets',
|
||||
jobs = 'jobs',
|
||||
namespaces = 'namespaces',
|
||||
clusters = 'clusters',
|
||||
volumes = 'volumes',
|
||||
}
|
||||
export interface InframonitoringtypesOnboardingDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
missingDefaultEnabledMetrics:
|
||||
| InframonitoringtypesMissingMetricsComponentEntryDTO[]
|
||||
| null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
missingOptionalMetrics:
|
||||
| InframonitoringtypesMissingMetricsComponentEntryDTO[]
|
||||
| null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
missingRequiredAttributes:
|
||||
| InframonitoringtypesMissingAttributesComponentEntryDTO[]
|
||||
| null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
presentDefaultEnabledMetrics:
|
||||
| InframonitoringtypesMetricsComponentEntryDTO[]
|
||||
| null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
presentOptionalMetrics: InframonitoringtypesMetricsComponentEntryDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
presentRequiredAttributes:
|
||||
| InframonitoringtypesAttributesComponentEntryDTO[]
|
||||
| null;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
ready: boolean;
|
||||
type: InframonitoringtypesOnboardingTypeDTO;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesPodPhaseDTO {
|
||||
pending = 'pending',
|
||||
running = 'running',
|
||||
@@ -10266,6 +10381,21 @@ export type ListNodes200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetOnboardingParams = {
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
type: InframonitoringtypesOnboardingTypeDTO;
|
||||
};
|
||||
|
||||
export type GetOnboarding200 = {
|
||||
data: InframonitoringtypesOnboardingDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListPods200 = {
|
||||
data: InframonitoringtypesPodsDTO;
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@ const ROUTES = {
|
||||
ALL_DASHBOARD: '/dashboard',
|
||||
DASHBOARD: '/dashboard/:dashboardId',
|
||||
DASHBOARD_WIDGET: '/dashboard/:dashboardId/:widgetId',
|
||||
DASHBOARD_PANEL_EDITOR: '/dashboard/:dashboardId/panel/:panelId',
|
||||
EDIT_ALERTS: '/alerts/edit',
|
||||
LIST_ALL_ALERT: '/alerts',
|
||||
ALERTS_NEW: '/alerts/new',
|
||||
|
||||
@@ -408,6 +408,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
|
||||
const isPublicDashboard = pathname.startsWith('/public/dashboard/');
|
||||
const isAIAssistantPage = pathname.startsWith('/ai-assistant/');
|
||||
// The V2 panel editor is a chromeless full-page route (no side nav / top nav),
|
||||
// like the onboarding and public-dashboard screens.
|
||||
const isPanelEditorV2 = routeKey === 'DASHBOARD_PANEL_EDITOR';
|
||||
|
||||
const renderFullScreen =
|
||||
pathname === ROUTES.GET_STARTED ||
|
||||
@@ -418,7 +421,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT ||
|
||||
pathname === ROUTES.GET_STARTED_AWS_MONITORING ||
|
||||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING ||
|
||||
isPublicDashboard;
|
||||
isPublicDashboard ||
|
||||
isPanelEditorV2;
|
||||
|
||||
const [showTrialExpiryBanner, setShowTrialExpiryBanner] = useState(false);
|
||||
|
||||
|
||||
@@ -7,15 +7,17 @@
|
||||
|
||||
&--legend-right {
|
||||
flex-direction: row;
|
||||
|
||||
.chart-layout__legend-wrapper {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__legend-wrapper {
|
||||
// The inline height is the legend rectangle from calculateChartDimensions;
|
||||
// border-box keeps the padding inside it so the wrapper doesn't grow past
|
||||
// that height and steal space from the chart. overflow:hidden clips to the
|
||||
// rectangle so the virtualized legend scrolls within it.
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
61
frontend/src/hooks/__tests__/useConfirmableAction.test.ts
Normal file
61
frontend/src/hooks/__tests__/useConfirmableAction.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useConfirmableAction } from '../useConfirmableAction';
|
||||
|
||||
describe('useConfirmableAction', () => {
|
||||
it('starts closed and idle', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfirmableAction(jest.fn().mockResolvedValue(undefined)),
|
||||
);
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it('request() opens the prompt without running the action', () => {
|
||||
const action = jest.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useConfirmableAction(action));
|
||||
|
||||
act(() => result.current.request());
|
||||
|
||||
expect(result.current.open).toBe(true);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('confirm() runs the action and closes on success', async () => {
|
||||
const action = jest.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useConfirmableAction(action));
|
||||
|
||||
act(() => result.current.request());
|
||||
await act(async () => {
|
||||
await result.current.confirm();
|
||||
});
|
||||
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the prompt open and resets pending when the action rejects', async () => {
|
||||
const action = jest.fn().mockRejectedValue(new Error('boom'));
|
||||
const { result } = renderHook(() => useConfirmableAction(action));
|
||||
|
||||
act(() => result.current.request());
|
||||
await act(async () => {
|
||||
await expect(result.current.confirm()).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
expect(result.current.open).toBe(true);
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it('cancel() closes the prompt without running the action', () => {
|
||||
const action = jest.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useConfirmableAction(action));
|
||||
|
||||
act(() => result.current.request());
|
||||
act(() => result.current.cancel());
|
||||
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
45
frontend/src/hooks/useConfirmableAction.ts
Normal file
45
frontend/src/hooks/useConfirmableAction.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
export interface ConfirmableAction {
|
||||
/** Whether the confirmation prompt is open. */
|
||||
open: boolean;
|
||||
/** The confirmed action is in flight. */
|
||||
isPending: boolean;
|
||||
/** Open the confirmation prompt (e.g. from a menu item / button). */
|
||||
request: () => void;
|
||||
/** Run the action, tracking the in-flight flag; closes the prompt on success. */
|
||||
confirm: () => Promise<void>;
|
||||
/** Dismiss the prompt without acting. */
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic two-step confirm flow for a (usually destructive) async action.
|
||||
* `request()` opens the prompt, `confirm()` runs `action` while tracking an
|
||||
* in-flight flag and closes on success, `cancel()` dismisses it. Owns only the
|
||||
* confirm state machine — what renders the prompt (dialog, popover) is the
|
||||
* caller's concern, so it stays reusable across confirm surfaces.
|
||||
*/
|
||||
export function useConfirmableAction(
|
||||
action: () => Promise<void>,
|
||||
): ConfirmableAction {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const request = useCallback((): void => setOpen(true), []);
|
||||
const cancel = useCallback((): void => setOpen(false), []);
|
||||
const confirm = useCallback(async (): Promise<void> => {
|
||||
setIsPending(true);
|
||||
try {
|
||||
await action();
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}, [action]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ open, isPending, request, confirm, cancel }),
|
||||
[open, isPending, request, confirm, cancel],
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../../../../styles/scrollbar' as *;
|
||||
|
||||
.legend-search-container {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
@@ -15,6 +17,10 @@
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
// Allow the flex children to shrink below their content height so the
|
||||
// virtualized grid scrolls within the capped legend height instead of
|
||||
// overflowing the wrapper (default min-height:auto would block the shrink).
|
||||
min-height: 0;
|
||||
|
||||
&:has(.legend-item-focused) .legend-item {
|
||||
opacity: 0.3;
|
||||
@@ -33,6 +39,11 @@
|
||||
}
|
||||
|
||||
.legend-virtuoso-container {
|
||||
// flex:1 + min-height:0 pins the scroller to the space left after the
|
||||
// search box (RIGHT legend) and lets it scroll instead of growing to fit
|
||||
// every row — without this the grid overflows a BOTTOM legend's fixed height.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
@@ -67,18 +78,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-background);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,10 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
// Include padding within the width so a full-width row (legend-item-right) fits its
|
||||
// column instead of overflowing by the 16px horizontal padding — there is no global
|
||||
// border-box reset, so the default content-box would make it overflow.
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -87,7 +87,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
lineConfig.fill = `${finalFillColor}40`;
|
||||
} else if (fillMode && fillMode !== FillMode.None) {
|
||||
if (fillMode === FillMode.Solid) {
|
||||
lineConfig.fill = finalFillColor;
|
||||
lineConfig.fill = `${finalFillColor}70`;
|
||||
} else if (fillMode === FillMode.Gradient) {
|
||||
lineConfig.fill = (self: uPlot): CanvasGradient =>
|
||||
generateGradientFill(self, finalFillColor, 'rgba(0, 0, 0, 0)');
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--l1-background-60);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useCallback } from 'react';
|
||||
import { SolidAlertTriangle, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ConfirmDialog } from '@signozhq/ui/dialog';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useConfirmableAction } from 'hooks/useConfirmableAction';
|
||||
|
||||
import styles from './Header.module.scss';
|
||||
|
||||
interface HeaderProps {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Header({
|
||||
isDirty,
|
||||
isSaving,
|
||||
onSave,
|
||||
onClose,
|
||||
}: HeaderProps): JSX.Element {
|
||||
const discard = useConfirmableAction(
|
||||
useCallback(async (): Promise<void> => onClose(), [onClose]),
|
||||
);
|
||||
|
||||
// Confirm before closing with unsaved edits; a pristine panel closes straight away.
|
||||
const handleCloseClick = useCallback((): void => {
|
||||
if (isDirty) {
|
||||
discard.request();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [isDirty, onClose, discard]);
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
suffix={<X size={14} />}
|
||||
data-testid="panel-editor-v2-close"
|
||||
onClick={handleCloseClick}
|
||||
/>
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text>Configure panel</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="panel-editor-v2-save"
|
||||
disabled={!isDirty || isSaving}
|
||||
loading={isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={discard.open}
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
discard.cancel();
|
||||
}
|
||||
}}
|
||||
title="Discard changes?"
|
||||
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
|
||||
confirmText="Discard"
|
||||
confirmColor="destructive"
|
||||
cancelText="Keep editing"
|
||||
onConfirm={discard.confirm}
|
||||
onCancel={discard.cancel}
|
||||
data-testid="panel-editor-v2-discard-modal"
|
||||
>
|
||||
<Typography>Your unsaved edits to this panel will be lost.</Typography>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,28 @@
|
||||
// Full-page editor: fills the route's content area as a header-over-split
|
||||
// column (the editor is its own page now, not a modal overlay).
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.handle {
|
||||
background: var(--l1-border);
|
||||
&:hover {
|
||||
background: var(--l2-border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
@use '../../../../../styles/scrollbar' as *;
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background-color: var(--l1-background);
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tabsContainer {
|
||||
width: 100%;
|
||||
|
||||
:global(.ant-tabs-tab) {
|
||||
background-color: var(--l2-background) !important;
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
:global(.ant-tabs-tab-active) {
|
||||
background-color: var(--l1-background) !important;
|
||||
}
|
||||
:global(.ant-tabs-nav) {
|
||||
&::before {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
.queryTypeTab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.runQueryBtnContainer {
|
||||
padding: 4px 0 8px 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Atom, Terminal } from '@signozhq/icons';
|
||||
import { Tabs } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import PromQLIcon from 'assets/Dashboard/PromQl';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ClickHouseQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse';
|
||||
import PromQLQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL';
|
||||
import { PANEL_TYPE_TO_QUERY_TYPES } from 'container/NewWidget/utils';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
interface PanelEditorQueryBuilderProps {
|
||||
panelType: PANEL_TYPES;
|
||||
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
|
||||
isLoadingQueries: boolean;
|
||||
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
|
||||
onStageRunQuery: () => void;
|
||||
/** Abort the in-flight preview fetch (the button's cancel action). */
|
||||
onCancelQuery: () => void;
|
||||
/** Optional content pinned below the builder (e.g. the List columns editor). */
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder UI for the V2 panel editor's left pane: queryType tabs (Query Builder /
|
||||
* ClickHouse / PromQL) plus the Stage & Run button, all reading/writing the global
|
||||
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
|
||||
*/
|
||||
function PanelEditorQueryBuilder({
|
||||
panelType,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
footer,
|
||||
}: PanelEditorQueryBuilderProps): JSX.Element {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const handleQueryCategoryChange = useCallback(
|
||||
(queryType: string): void => {
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
queryType: queryType as EQueryType,
|
||||
});
|
||||
},
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
// ⌘↵ / Ctrl+↵ stages and runs the query. Handled locally because the global
|
||||
// hotkeys provider ignores keydowns from inputs / the query editor, and on the
|
||||
// capture phase so it still fires for fields that stop bubbling (filter search,
|
||||
// CodeMirror).
|
||||
const handleKeyDownCapture = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onStageRunQuery();
|
||||
}
|
||||
},
|
||||
[onStageRunQuery],
|
||||
);
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
|
||||
() => ({ stepInterval: { isHidden: false, isDisabled: false } }),
|
||||
[],
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const supportedQueryTypes = PANEL_TYPE_TO_QUERY_TYPES[panelType] || [];
|
||||
|
||||
const queryTypeComponents = {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
icon: <Atom size={14} />,
|
||||
label: 'Query Builder',
|
||||
component: (
|
||||
<div className="query-builder-v2-container">
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
filterConfigs={filterConfigs}
|
||||
showTraceOperator={panelType !== PANEL_TYPES.LIST}
|
||||
version="v3"
|
||||
isListViewPanel={panelType === PANEL_TYPES.LIST}
|
||||
queryComponents={{}}
|
||||
signalSourceChangeEnabled
|
||||
savePreviousQuery
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EQueryType.CLICKHOUSE]: {
|
||||
icon: <Terminal size={14} />,
|
||||
label: 'ClickHouse Query',
|
||||
component: <ClickHouseQueryContainer />,
|
||||
},
|
||||
[EQueryType.PROM]: {
|
||||
icon: (
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
/>
|
||||
),
|
||||
label: 'PromQL',
|
||||
component: <PromQLQueryContainer />,
|
||||
},
|
||||
};
|
||||
|
||||
return supportedQueryTypes.map((queryType) => ({
|
||||
key: queryType,
|
||||
label: (
|
||||
<div className={styles.queryTypeTab}>
|
||||
{queryTypeComponents[queryType].icon}
|
||||
<Typography>{queryTypeComponents[queryType].label}</Typography>
|
||||
</div>
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
}));
|
||||
}, [panelType, filterConfigs, isDarkMode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
data-testid="panel-editor-v2-query-builder"
|
||||
onKeyDownCapture={handleKeyDownCapture}
|
||||
role="presentation"
|
||||
>
|
||||
<div className={styles.scrollArea}>
|
||||
<Tabs
|
||||
type="card"
|
||||
className={styles.tabsContainer}
|
||||
activeKey={currentQuery.queryType}
|
||||
onChange={handleQueryCategoryChange}
|
||||
tabBarExtraContent={
|
||||
<span className={styles.runQueryBtnContainer}>
|
||||
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
|
||||
<RunQueryBtn
|
||||
className="run-query-dashboard-btn"
|
||||
label="Stage & Run Query"
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={onCancelQuery}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
items={items}
|
||||
/>
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelEditorQueryBuilder;
|
||||
@@ -0,0 +1,59 @@
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
background-image: radial-gradient(var(--l2-border) 1px, transparent 0);
|
||||
background-size: 20px 20px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.queryType {
|
||||
display: inline-flex;
|
||||
padding: 4px 8px 4px 6px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--l3-background);
|
||||
backdrop-filter: blur(6px);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.surface {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
background: var(--l2-background);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: var(--l2-forground);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Spline } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type {
|
||||
PanelPagination,
|
||||
PanelQueryData,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import styles from './PreviewPane.module.scss';
|
||||
|
||||
interface PreviewPaneProps {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Resolved definition for the panel kind; undefined when the kind is unsupported. */
|
||||
panelDef: RenderablePanelDefinition | undefined;
|
||||
data: PanelQueryData;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
/** Re-run the query (drives PanelBody's error-state retry). */
|
||||
refetch: () => void;
|
||||
/** Drag-to-zoom on a time-axis chart → updates the (URL-synced) time window. */
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
/** Server-side pager for raw/list panels; absent for non-paginated panels. */
|
||||
pagination?: PanelPagination;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live preview for the panel editor. Renders the draft through the same `PanelBody`
|
||||
* the dashboard grid uses (only `panelMode={DASHBOARD_EDIT}` differs), so the preview
|
||||
* is the production render path. The query result is owned by the editor root.
|
||||
*/
|
||||
function PreviewPane({
|
||||
panelId,
|
||||
panel,
|
||||
panelDef,
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
onDragSelect,
|
||||
pagination,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.queryType}>
|
||||
<Spline size={14} />
|
||||
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
|
||||
</div>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.surface}>
|
||||
{panelDef ? (
|
||||
<PanelBody
|
||||
panelDefinition={panelDef}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
pagination={pagination}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
|
||||
This panel type is not yet supported in V2.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreviewPane;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelEditorDraft } from '../usePanelEditorDraft';
|
||||
|
||||
function panel(name = 'CPU', description = 'usage'): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name, description },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('usePanelEditorDraft', () => {
|
||||
it('exposes the panel spec and starts clean', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
expect(result.current.spec).toBe(result.current.draft.spec);
|
||||
expect(result.current.spec.display?.name).toBe('CPU');
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('flags dirty and writes through on a display (title) edit via setSpec', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() =>
|
||||
result.current.setSpec({
|
||||
...result.current.spec,
|
||||
display: { ...result.current.spec.display, name: 'Memory' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(true);
|
||||
expect(result.current.draft.spec?.display?.name).toBe('Memory');
|
||||
});
|
||||
|
||||
it('flags dirty on a plugin-spec (non-display) edit', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() =>
|
||||
result.current.setSpec({
|
||||
...result.current.spec,
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { formatting: { unit: 'bytes' } },
|
||||
},
|
||||
} as typeof result.current.spec),
|
||||
);
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(true);
|
||||
expect(
|
||||
(
|
||||
result.current.draft.spec?.plugin?.spec as {
|
||||
formatting?: { unit?: string };
|
||||
}
|
||||
)?.formatting?.unit,
|
||||
).toBe('bytes');
|
||||
});
|
||||
|
||||
it('does not flag spec-dirty when only spec.queries changes (owned by the builder)', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() =>
|
||||
result.current.setSpec({
|
||||
...result.current.spec,
|
||||
queries: [{ id: 'committed-by-builder' }],
|
||||
} as unknown as typeof result.current.spec),
|
||||
);
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('reset restores the spec and clears dirty after an edit', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() =>
|
||||
result.current.setSpec({
|
||||
...result.current.spec,
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { formatting: { unit: 'ms' } },
|
||||
},
|
||||
} as typeof result.current.spec),
|
||||
);
|
||||
act(() => result.current.reset());
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
expect(result.current.spec.display?.name).toBe('CPU');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getIsQueryModified } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { fromPerses, toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
|
||||
useShareBuilderUrl: jest.fn(),
|
||||
}));
|
||||
jest.mock('container/NewWidget/utils', () => ({
|
||||
getIsQueryModified: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
|
||||
fromPerses: jest.fn(),
|
||||
toPerses: jest.fn(),
|
||||
}));
|
||||
// commitQuery's no-op guard compares queries at the envelope level; with the
|
||||
// adapters mocked, unwrap identity-style so the opaque fixtures stay distinct
|
||||
// (CONVERTED vs SAVED) and the commit decisions are what's under test.
|
||||
jest.mock('../../../queryV5/buildQueryRangeRequest', () => ({
|
||||
toQueryEnvelopes: jest.fn((queries: unknown) => queries),
|
||||
}));
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
const mockUseShareBuilderUrl = useShareBuilderUrl as unknown as jest.Mock;
|
||||
const mockGetIsQueryModified = getIsQueryModified as unknown as jest.Mock;
|
||||
const mockFromPerses = fromPerses as unknown as jest.Mock;
|
||||
const mockToPerses = toPerses as unknown as jest.Mock;
|
||||
|
||||
// Opaque fixtures — the adapters are mocked, so only identity matters here.
|
||||
const SAVED_QUERIES = [{ id: 'saved' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const CONVERTED_QUERIES = [{ id: 'converted' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const SEED_V1 = { id: 'seed', queryType: 'builder' } as unknown as Query;
|
||||
const STAGED_V1 = { id: 'staged', queryType: 'builder' } as unknown as Query;
|
||||
|
||||
function makeDraft(
|
||||
queries = SAVED_QUERIES,
|
||||
kind = 'signoz/TimeSeriesPanel',
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind, spec: {} },
|
||||
queries,
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
function builderState(
|
||||
overrides: Partial<{
|
||||
currentQuery: Query;
|
||||
stagedQuery: Query | null;
|
||||
handleRunQuery: jest.Mock;
|
||||
}> = {},
|
||||
): {
|
||||
currentQuery: Query;
|
||||
stagedQuery: Query | null;
|
||||
handleRunQuery: jest.Mock;
|
||||
} {
|
||||
return {
|
||||
currentQuery: { id: 'current', queryType: 'builder' } as unknown as Query,
|
||||
stagedQuery: STAGED_V1,
|
||||
handleRunQuery: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('usePanelEditorQuerySync', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFromPerses.mockReturnValue(SEED_V1);
|
||||
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
mockUseQueryBuilder.mockReturnValue(builderState());
|
||||
});
|
||||
|
||||
function setup(
|
||||
opts: {
|
||||
draft?: DashboardtypesPanelDTO;
|
||||
setSpec?: jest.Mock;
|
||||
refetch?: jest.Mock;
|
||||
} = {},
|
||||
): {
|
||||
result: {
|
||||
current: {
|
||||
runQuery: () => void;
|
||||
isQueryDirty: boolean;
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
};
|
||||
};
|
||||
setSpec: jest.Mock;
|
||||
refetch: jest.Mock;
|
||||
rerender: () => void;
|
||||
} {
|
||||
const setSpec = opts.setSpec ?? jest.fn();
|
||||
const refetch = opts.refetch ?? jest.fn();
|
||||
const draft = opts.draft ?? makeDraft();
|
||||
const { result, rerender } = renderHook(() =>
|
||||
usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
refetch,
|
||||
}),
|
||||
);
|
||||
return { result, setSpec, refetch, rerender };
|
||||
}
|
||||
|
||||
it('force-resets the builder to the saved queries on mount (discards stale URL)', () => {
|
||||
setup();
|
||||
expect(mockFromPerses).toHaveBeenCalledWith(
|
||||
SAVED_QUERIES,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
expect(mockUseShareBuilderUrl).toHaveBeenCalledWith({
|
||||
defaultValue: SEED_V1,
|
||||
forceReset: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not touch the draft on mount for an unedited panel', () => {
|
||||
const { setSpec, refetch } = setup();
|
||||
// Mount runs the type-change effect once; an unedited query must no-op.
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
expect(refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('compares the live query against the saved query (seed), not the staged query', () => {
|
||||
const currentQuery = { id: 'current', queryType: 'builder' } as Query;
|
||||
mockUseQueryBuilder.mockReturnValue(builderState({ currentQuery }));
|
||||
|
||||
const { result } = setup();
|
||||
result.current.runQuery();
|
||||
|
||||
// Baseline is the saved seed — a stale staged/URL query must not be the
|
||||
// reference, or a real datasource switch would read as "unchanged".
|
||||
expect(mockGetIsQueryModified).toHaveBeenCalledWith(currentQuery, SEED_V1);
|
||||
});
|
||||
|
||||
describe('runQuery', () => {
|
||||
it('stages the query (handleRunQuery)', () => {
|
||||
const handleRunQuery = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(builderState({ handleRunQuery }));
|
||||
const { result } = setup();
|
||||
|
||||
result.current.runQuery();
|
||||
|
||||
expect(handleRunQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('commits a modified query into the draft and does not force a refetch', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result, setSpec, refetch } = setup();
|
||||
|
||||
result.current.runQuery();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
expect(refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces a refetch and leaves the draft alone when the query is unchanged', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result, setSpec, refetch } = setup();
|
||||
|
||||
result.current.runQuery();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
expect(refetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('commits a datasource switch even when the staged query is stale (no revert to saved)', () => {
|
||||
// A stale staged query (e.g. URL-restored after refresh) must not be used
|
||||
// as the baseline; the switch is detected against the saved seed and the
|
||||
// live query is committed so the preview fetches it.
|
||||
mockUseQueryBuilder.mockReturnValue(builderState({ stagedQuery: null }));
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result, setSpec, refetch } = setup();
|
||||
|
||||
result.current.runQuery();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
expect(refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query-type switch', () => {
|
||||
it('commits the active query when the query type changes', () => {
|
||||
const state = builderState({
|
||||
currentQuery: { id: 'a', queryType: 'builder' } as Query,
|
||||
});
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Switch query type → the effect should commit.
|
||||
state.currentQuery = { id: 'b', queryType: 'promql' } as Query;
|
||||
rerender();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not commit when the active query type is unchanged', () => {
|
||||
const state = builderState({
|
||||
currentQuery: { id: 'a', queryType: 'builder' } as Query,
|
||||
});
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Same query type, different object → effect must not re-fire.
|
||||
state.currentQuery = { id: 'b', queryType: 'builder' } as Query;
|
||||
rerender();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('datasource switch', () => {
|
||||
const withSource = (id: string, dataSource: string): Query =>
|
||||
({
|
||||
id,
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ dataSource }] },
|
||||
}) as unknown as Query;
|
||||
|
||||
it('commits the active query when a query datasource changes', () => {
|
||||
const state = builderState({ currentQuery: withSource('a', 'logs') });
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Switch datasource logs → traces → the effect should commit (→ refetch).
|
||||
state.currentQuery = withSource('b', 'traces');
|
||||
rerender();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not commit when the datasource is unchanged', () => {
|
||||
const state = builderState({ currentQuery: withSource('a', 'logs') });
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Same datasource, different object → effect must not re-fire.
|
||||
state.currentQuery = withSource('b', 'logs');
|
||||
rerender();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query dirty + save', () => {
|
||||
it('compares the live query against the builder baseline (first staged query), not the raw seed', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result } = setup();
|
||||
|
||||
// Baseline is the builder's own normalized staged query — immune to the
|
||||
// raw-seed vs builder-normalized serialization drift.
|
||||
expect(mockGetIsQueryModified).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
STAGED_V1,
|
||||
);
|
||||
expect(result.current.isQueryDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('is not query-dirty when the live query matches the baseline', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result } = setup();
|
||||
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('buildSaveSpec bakes the live query in when dirty', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result } = setup();
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
|
||||
...spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('buildSaveSpec returns the spec untouched when the query is unchanged', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result } = setup();
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toBe(spec);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelEditorSave } from '../usePanelEditorSave';
|
||||
|
||||
const mockInvalidateQueries = jest.fn();
|
||||
jest.mock('react-query', () => ({
|
||||
useQueryClient: (): { invalidateQueries: jest.Mock } => ({
|
||||
invalidateQueries: mockInvalidateQueries,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
usePatchDashboardV2: jest.fn(),
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
|
||||
}));
|
||||
|
||||
const mockUsePatch = usePatchDashboardV2 as unknown as jest.Mock;
|
||||
const mockGetQueryKey = getGetDashboardV2QueryKey as unknown as jest.Mock;
|
||||
|
||||
describe('usePanelEditorSave', () => {
|
||||
const mutateAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUsePatch.mockReturnValue({
|
||||
mutateAsync,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits an add patch replacing the whole panel spec and invalidates the dashboard query', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
|
||||
);
|
||||
|
||||
const spec = {
|
||||
display: { name: 'New title', description: 'desc' },
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { formatting: { unit: 'bytes' } },
|
||||
},
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
await result.current.save(spec);
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'dash-1' },
|
||||
data: [
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/panels/panel-9/spec',
|
||||
value: spec,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockGetQueryKey).toHaveBeenCalledWith({ id: 'dash-1' });
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith([
|
||||
'/api/v2/dashboards/dash-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('surfaces the mutation loading state as isSaving', () => {
|
||||
mockUsePatch.mockReturnValue({
|
||||
mutateAsync,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
|
||||
);
|
||||
|
||||
expect(result.current.isSaving).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import type { PanelEditorDraftApi } from '../types';
|
||||
|
||||
/**
|
||||
* Owns the editable draft of a single panel, seeded once from the loaded panel and
|
||||
* mutated locally until save. Kept in the perses `DashboardtypesPanelDTO` shape so the
|
||||
* preview renders it through the dashboard's renderer registry and the save hook
|
||||
* patches it without conversion. Everything the config pane edits flows through the
|
||||
* single `spec`/`setSpec` pair.
|
||||
*/
|
||||
export function usePanelEditorDraft(
|
||||
initialPanel: DashboardtypesPanelDTO,
|
||||
): PanelEditorDraftApi {
|
||||
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
|
||||
|
||||
const setSpec = useCallback((next: DashboardtypesPanelSpecDTO): void => {
|
||||
setDraft((prev) => ({ ...prev, spec: next }));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setDraft(initialPanel);
|
||||
}, [initialPanel]);
|
||||
|
||||
// Deep compare, ignoring `spec.queries`: the query is owned by the builder and
|
||||
// re-serialized into the draft as a preview cache, so its representation drifts
|
||||
// without a real edit. Query dirtiness is tracked separately; here we only flag
|
||||
// divergence in the display + plugin spec slices.
|
||||
const isSpecDirty = useMemo(
|
||||
() =>
|
||||
!isEqual(
|
||||
{ ...draft, spec: { ...draft.spec, queries: null } },
|
||||
{ ...initialPanel, spec: { ...initialPanel.spec, queries: null } },
|
||||
),
|
||||
[draft, initialPanel],
|
||||
);
|
||||
|
||||
return {
|
||||
draft,
|
||||
spec: draft.spec,
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getIsQueryModified } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
|
||||
import { fromPerses, toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
|
||||
interface UsePanelEditorQuerySyncArgs {
|
||||
draft: DashboardtypesPanelDTO;
|
||||
panelType: PANEL_TYPES;
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Re-fetch the preview when the query is unchanged (Stage & Run on a no-op). */
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
interface UsePanelEditorQuerySyncApi {
|
||||
/** Run the current query (Stage & Run / ⌘↵). */
|
||||
runQuery: () => void;
|
||||
/** True when the live builder query differs from the saved query (compared builder-normalized to avoid re-serialization noise). */
|
||||
isQueryDirty: boolean;
|
||||
/** Bake the live query into a spec for saving so unstaged edits persist; returns the spec untouched when unchanged. */
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the shared (URL-synced) query builder and the V2 editor draft: seeds the
|
||||
* builder from the saved panel, then commits the active query into `draft.spec.queries`
|
||||
* (what the preview fetches) on a query-type/datasource switch and on Stage & Run.
|
||||
*/
|
||||
export function usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
|
||||
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
|
||||
|
||||
// Saved queries, captured once: seed the builder and serve as the restore target.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
|
||||
const savedQueries = useMemo(() => draft.spec?.queries ?? [], []);
|
||||
const seedQuery = useMemo(
|
||||
() => fromPerses(savedQueries, panelType),
|
||||
[savedQueries, panelType],
|
||||
);
|
||||
// Force-reset the builder to the SAVED panel on first render only, discarding any
|
||||
// stale URL query from a prior edit — otherwise the QB and preview diverge and the
|
||||
// dirty baseline gets captured from the URL. After mount the URL syncs normally.
|
||||
const isInitialRenderRef = useRef(true);
|
||||
useShareBuilderUrl({
|
||||
defaultValue: seedQuery,
|
||||
forceReset: isInitialRenderRef.current,
|
||||
});
|
||||
useEffect(() => {
|
||||
isInitialRenderRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Commit the live query into the draft (what the preview fetches). The dirty check
|
||||
// compares against the SAVED query (`seedQuery`), not the URL-synced staged query,
|
||||
// which can carry stale state across a refresh and make a real switch read as
|
||||
// "unchanged". Unchanged → restore saved queries; changed → commit. Returns whether
|
||||
// the draft changed.
|
||||
const commitQuery = useCallback(
|
||||
(query: Query): boolean => {
|
||||
const next = getIsQueryModified(query, seedQuery)
|
||||
? toPerses(query, panelType)
|
||||
: savedQueries;
|
||||
// No-op guard at the V5 envelope level: equivalent wrappers (bare
|
||||
// `signoz/BuilderQuery` vs `signoz/CompositeQuery`) unwrap to the same
|
||||
// envelopes, so comparing them structurally would falsely dirty the draft.
|
||||
const current = draft.spec?.queries ?? [];
|
||||
if (isEqual(toQueryEnvelopes(next), toQueryEnvelopes(current))) {
|
||||
return false;
|
||||
}
|
||||
setSpec({ ...draft.spec, queries: next });
|
||||
return true;
|
||||
},
|
||||
[seedQuery, panelType, savedQueries, draft.spec, setSpec],
|
||||
);
|
||||
|
||||
// Latest query/commit, read by the structural-change effect without re-subscribing.
|
||||
const commitRef = useRef(commitQuery);
|
||||
commitRef.current = commitQuery;
|
||||
const queryRef = useRef(currentQuery);
|
||||
queryRef.current = currentQuery;
|
||||
|
||||
// Re-commit on a query-type or datasource switch so the preview refetches. Skip
|
||||
// mount: the draft already holds the saved queries the builder is force-reset to.
|
||||
const dataSourceSignature = useMemo(
|
||||
() =>
|
||||
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
|
||||
[currentQuery.builder],
|
||||
);
|
||||
const didMountRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!didMountRef.current) {
|
||||
didMountRef.current = true;
|
||||
return;
|
||||
}
|
||||
commitRef.current(queryRef.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
|
||||
}, [currentQuery.queryType, dataSourceSignature]);
|
||||
|
||||
// Stage & Run / ⌘↵: stage, commit, and re-fetch when unchanged so it can be re-run.
|
||||
const runQuery = useCallback((): void => {
|
||||
handleRunQuery();
|
||||
if (!commitQuery(currentQuery)) {
|
||||
refetch();
|
||||
}
|
||||
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
|
||||
|
||||
// Dirty baseline: the builder's OWN normalized saved query (first non-null
|
||||
// `stagedQuery` after the mount reset). Comparing builder-normalized to
|
||||
// builder-normalized avoids serialization drift reading an untouched query as
|
||||
// modified. Held in state (not a ref) so capture re-triggers `isQueryDirty`;
|
||||
// captured once and never moved by Stage & Run, so it stays anchored to saved.
|
||||
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
|
||||
useEffect(() => {
|
||||
if (queryBaseline === null && stagedQuery) {
|
||||
setQueryBaseline(stagedQuery);
|
||||
}
|
||||
}, [queryBaseline, stagedQuery]);
|
||||
|
||||
const isQueryDirty =
|
||||
queryBaseline !== null && getIsQueryModified(currentQuery, queryBaseline);
|
||||
|
||||
const buildSaveSpec = useCallback(
|
||||
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>
|
||||
isQueryDirty
|
||||
? { ...spec, queries: toPerses(currentQuery, panelType) }
|
||||
: spec,
|
||||
[isQueryDirty, currentQuery, panelType],
|
||||
);
|
||||
|
||||
return { runQuery, isQueryDirty, buildSaveSpec };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
type DashboardtypesJSONPatchOperationDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesPatchOpDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
interface UsePanelEditorSaveArgs {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
}
|
||||
|
||||
interface UsePanelEditorSaveApi {
|
||||
save: (spec: DashboardtypesPanelSpecDTO) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists panel edits via a single RFC-6902 `add` op that replaces the whole panel
|
||||
* spec at `/spec/panels/{panelId}/spec`, so every config-pane edit is saved (not just
|
||||
* title/description). `add` doubles as create-or-replace, avoiding a separate
|
||||
* existence check.
|
||||
*/
|
||||
export function usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
|
||||
|
||||
const save = useCallback(
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: DashboardtypesPatchOpDTO.add,
|
||||
path: `/spec/panels/${panelId}/spec`,
|
||||
value: spec,
|
||||
},
|
||||
];
|
||||
|
||||
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
|
||||
await queryClient.invalidateQueries(
|
||||
getGetDashboardV2QueryKey({ id: dashboardId }),
|
||||
);
|
||||
},
|
||||
[dashboardId, panelId, mutateAsync, queryClient],
|
||||
);
|
||||
|
||||
return { save, isSaving: isLoading, error: (error as Error) ?? null };
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
useDefaultLayout,
|
||||
} from '@signozhq/ui/resizable';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
|
||||
import Header from './Header/Header';
|
||||
import layoutStorage from './layoutStorage';
|
||||
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import { usePanelQuery } from '../hooks/usePanelQuery';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
|
||||
import styles from './PanelEditor.module.scss';
|
||||
|
||||
interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Leave the editor (navigate back to the dashboard) without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save — navigates back to the dashboard. */
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 panel editor page body (rendered full-page by `PanelEditorPage`): a resizable
|
||||
* split with the live preview + query builder on the left and the config pane on the
|
||||
* right. Owns the draft state and the save round-trip.
|
||||
*/
|
||||
function PanelEditorContainer({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panel,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
const { draft, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
|
||||
const { save, isSaving } = usePanelEditorSave({ dashboardId, panelId });
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: 'panel-editor-v2',
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
const {
|
||||
defaultLayout: mainDefaultLayout,
|
||||
onLayoutChanged: onMainLayoutChanged,
|
||||
} = useDefaultLayout({
|
||||
id: 'panel-editor-v2-main',
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
// Panel kind → V1 panel type, which drives the query builder and preview.
|
||||
const fullKind = draft.spec.plugin.kind;
|
||||
const panelType =
|
||||
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
|
||||
PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
// One shared query result for the whole editor; the preview renders it.
|
||||
const panelDef = getPanelDefinition(draft.spec.plugin.kind);
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
cancelQuery,
|
||||
refetch,
|
||||
pagination,
|
||||
} = usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
enabled: !!panelDef,
|
||||
});
|
||||
|
||||
// Seed the shared query builder from the draft and expose the Stage-&-Run action.
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
});
|
||||
|
||||
// Spec and query dirtiness are tracked independently so query re-serialization
|
||||
// never false-dirties.
|
||||
const isDirty = isSpecDirty || isQueryDirty;
|
||||
|
||||
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
|
||||
const { onDragSelect } = usePanelInteractions();
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
// Bake the live query into the spec so unstaged edits are saved too.
|
||||
await save(buildSaveSpec(draft.spec));
|
||||
toast.success('Panel saved');
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('Failed to save panel');
|
||||
}
|
||||
}, [save, buildSaveSpec, draft.spec, onSaved]);
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
<Header
|
||||
isDirty={isDirty}
|
||||
isSaving={isSaving}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2"
|
||||
orientation="horizontal"
|
||||
defaultLayout={defaultLayout}
|
||||
onLayoutChanged={onLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
|
||||
<div className={styles.left}>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2-main"
|
||||
orientation="vertical"
|
||||
defaultLayout={mainDefaultLayout}
|
||||
onLayoutChanged={onMainLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDef={panelDef}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
|
||||
<PanelEditorQueryBuilder
|
||||
panelType={panelType}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel
|
||||
minSize="20%"
|
||||
maxSize="25%"
|
||||
defaultSize="20%"
|
||||
className={styles.right}
|
||||
/>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelEditorContainer;
|
||||
@@ -0,0 +1,15 @@
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
|
||||
/**
|
||||
* `Storage`-shaped adapter for `useDefaultLayout`, backed by the scoped localStorage
|
||||
* wrappers that prefix keys with the URL base path so layout stays isolated per deployment.
|
||||
*/
|
||||
const layoutStorage: Pick<Storage, 'getItem' | 'setItem'> = {
|
||||
getItem: (key: string): string | null => getLocalStorageApi(key),
|
||||
setItem: (key: string, value: string): void => {
|
||||
setLocalStorageApi(key, value);
|
||||
},
|
||||
};
|
||||
|
||||
export default layoutStorage;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Local draft state for the panel being edited, kept as a perses `DashboardtypesPanelDTO`
|
||||
* so the live preview and the save patch share one shape (no intermediate translation).
|
||||
*/
|
||||
export interface PanelEditorDraftApi {
|
||||
/** The current (possibly edited) panel. Always defined once seeded. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
/** The panel spec — the single editing surface for the config pane. */
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
/** Replace the whole panel spec (the registry lens returns a new one per edit). */
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/**
|
||||
* True when the draft's display/plugin-spec slices diverge from the loaded panel.
|
||||
* Excludes `spec.queries` — owned by the shared builder, tracked via
|
||||
* `usePanelEditorQuerySync.isQueryDirty`.
|
||||
*/
|
||||
isSpecDirty: boolean;
|
||||
/** Restore the draft to the originally-loaded panel. */
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -42,9 +42,7 @@ function BarPanelRenderer({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/BarChartPanel'`, so the cast is a
|
||||
// documented boundary narrowing.
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesBarChartPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
@@ -55,9 +53,8 @@ function BarPanelRenderer({
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
// X-scale clamps come from the request that produced the data (falls back
|
||||
// to the global picker inside the helper). The generated request DTO is
|
||||
// structurally the hand-written V5 request; the cast is the boundary.
|
||||
// X-scale clamps come from the request that produced the data. The generated
|
||||
// request DTO is structurally the V5 request; the cast is the boundary.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
|
||||
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
|
||||
@@ -100,10 +97,8 @@ function BarPanelRenderer({
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
// TooltipPlugin mutates `config` for cursor sync; rebuild on syncMode change
|
||||
// so a fresh instance doesn't inherit stale sync settings (e.g. "No Sync").
|
||||
dashboardPreference?.syncMode,
|
||||
],
|
||||
);
|
||||
@@ -126,10 +121,8 @@ function BarPanelRenderer({
|
||||
[panelId],
|
||||
);
|
||||
|
||||
// The uPlot key prop is the only way to force a full teardown and re-mount
|
||||
// of the chart. Including syncMode/syncFilterMode in the key ensures changes
|
||||
// to these preferences trigger a fresh chart instance, preventing stale
|
||||
// sync wiring from being inherited.
|
||||
// Keying on sync prefs forces a full chart teardown/re-mount so stale sync
|
||||
// settings aren't inherited — the only way to fully reset the uPlot instance.
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
|
||||
@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// Bar stacking lives in `visualization.stackedBarChart`, so it's a `visualization`
|
||||
// control, not `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true, stacking: true } },
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true, mode: true } },
|
||||
{ kind: 'thresholds', controls: { list: true } },
|
||||
{ kind: 'chartAppearance', controls: { stacked: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'label' } },
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
|
||||
@@ -16,10 +16,7 @@ import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
export interface BuildBarChartConfigArgs {
|
||||
panelId: string;
|
||||
spec: DashboardtypesBarChartPanelSpecDTO;
|
||||
/**
|
||||
* Flat list of builder queries on this panel (see `getBuilderQueries`).
|
||||
* Powers per-query legend resolution; empty for non-builder panels.
|
||||
*/
|
||||
/** Flat list of builder queries (see `getBuilderQueries`); powers per-query legend resolution. */
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
@@ -34,14 +31,7 @@ export interface BuildBarChartConfigArgs {
|
||||
maxTimeScale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fully-wired `UPlotConfigBuilder` for a Bar chart panel.
|
||||
*
|
||||
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
|
||||
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
|
||||
* in the Bar-specific concerns: optional stacking via uPlot bands, plus
|
||||
* one bar series per result row.
|
||||
*/
|
||||
/** Builds a `UPlotConfigBuilder` for a Bar chart panel: shared scaffolding, optional stacking, one bar series per result. */
|
||||
export function buildBarChartConfig({
|
||||
panelId,
|
||||
spec,
|
||||
@@ -97,11 +87,8 @@ interface AddSeriesArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one bar series per flattened V5 series, plus uPlot bands for stacking
|
||||
* when `spec.visualization.stackedBarChart` is set. Each series receives its
|
||||
* own per-query step interval so bar widths line up with the actual
|
||||
* sampling cadence reported by the backend.
|
||||
*
|
||||
* Adds one bar series per flattened V5 series (plus stacking bands). Each gets its
|
||||
* own per-query step interval so bar widths match the backend's sampling cadence.
|
||||
* Order must match `prepareAlignedData` — both iterate the same flat list.
|
||||
*/
|
||||
function addSeries({
|
||||
|
||||
@@ -34,9 +34,7 @@ function HistogramPanelRenderer({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/HistogramPanel'`, so the cast is a
|
||||
// documented boundary narrowing.
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesHistogramPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
|
||||
@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,12 +22,10 @@ const BUCKET_OFFSET = 0;
|
||||
const sortAscending = (a: number, b: number): number => a - b;
|
||||
|
||||
/**
|
||||
* Bins raw series values into a uPlot-aligned histogram. Picks a bucket size
|
||||
* either from `bucketWidth` (explicit override) or the smallest predefined
|
||||
* Grafana bucket that fits the data's `range / bucketCount` target while
|
||||
* staying ≥ the data's smallest non-zero delta (so we never sub-divide below
|
||||
* the resolution of the input).
|
||||
*
|
||||
* Bins raw series values into a uPlot-aligned histogram. Bucket size is the
|
||||
* `bucketWidth` override, else the smallest predefined Grafana bucket that fits
|
||||
* the `range / bucketCount` target while staying ≥ the input's smallest non-zero
|
||||
* delta (never sub-dividing below the input resolution).
|
||||
* Empty input → `[[]]` (a valid empty AlignedData uPlot accepts).
|
||||
*/
|
||||
export function prepareHistogramData({
|
||||
@@ -58,10 +56,9 @@ export function prepareHistogramData({
|
||||
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
|
||||
|
||||
const frames = buildFrames(series, mergeAllActiveQueries);
|
||||
// Merged mode folds every query into frame 0 and leaves trailing empty
|
||||
// frames — drop those. Per-query mode must keep one column per result row
|
||||
// (even empty queries), or the data column count drifts below the series
|
||||
// count `buildHistogramConfig` adds per row → uPlot renders nothing.
|
||||
// Merged mode leaves trailing empty frames — drop those. Per-query mode keeps
|
||||
// one column per result row (even empty ones), else the column count falls below
|
||||
// the series count `buildHistogramConfig` adds per row → uPlot renders nothing.
|
||||
const histograms: AlignedData[] = frames
|
||||
.filter((frame) => !mergeAllActiveQueries || frame.length > 0)
|
||||
.map((frame) => buildHistogramBuckets(frame, getBucket, sortAscending));
|
||||
@@ -76,7 +73,7 @@ export function prepareHistogramData({
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Non-finite samples degrade to 0 (legacy `parseFloat(...) || 0` parity).
|
||||
/** Non-finite samples degrade to 0 (legacy `parseFloat(...) || 0` parity). */
|
||||
function toBinnableValue(value: number): number {
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
@@ -128,8 +125,10 @@ function selectBucketSize({
|
||||
return 0;
|
||||
}
|
||||
|
||||
// When merging is on, fold all frames into the first; the trailing empty
|
||||
// frames stay in the array so downstream `.filter(length > 0)` drops them.
|
||||
/**
|
||||
* When merging is on, fold all frames into the first; the trailing empty
|
||||
* frames stay in the array so downstream `.filter(length > 0)` drops them.
|
||||
*/
|
||||
function buildFrames(
|
||||
series: PanelSeries[],
|
||||
mergeAllActiveQueries: boolean,
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'legend', controls: { position: true, mode: true } },
|
||||
{ kind: 'buckets', controls: { count: true } },
|
||||
{
|
||||
kind: 'legend',
|
||||
controls: { position: true },
|
||||
// Merging all queries collapses to one distribution with no legend.
|
||||
isHidden: (spec): boolean =>
|
||||
Boolean(
|
||||
(spec.plugin.spec as DashboardtypesHistogramPanelSpecDTO).histogramBuckets
|
||||
?.mergeAllActiveQueries,
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'buckets',
|
||||
controls: { count: true, width: true, mergeQueries: true },
|
||||
},
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
|
||||
@@ -12,8 +12,7 @@ import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
const POINT_SIZE = 5;
|
||||
const BAR_WIDTH_FACTOR = 1;
|
||||
// Merged-series colors mirror the V1 default — single histogram bin gets a
|
||||
// fixed blue-ish pair so the merged view looks the same as before.
|
||||
// Merged-series colors mirror the V1 default so the merged view looks unchanged.
|
||||
const MERGED_SERIES_LINE_COLOR = '#3f5ecc';
|
||||
const MERGED_SERIES_FILL_COLOR = '#4E74F8';
|
||||
|
||||
@@ -30,13 +29,9 @@ export interface BuildHistogramConfigArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fully-wired `UPlotConfigBuilder` for a Histogram panel.
|
||||
*
|
||||
* Unlike time-axis panels, histograms have no time scale and no drag-to-zoom.
|
||||
* We still reuse `buildBaseConfig` for the consistent scaffolding (thresholds,
|
||||
* axes, click plugin) but then override the X/Y scales to be auto-linear
|
||||
* (`time: false, auto: true`) and install a histogram-specific cursor that
|
||||
* disables drag-pan and tightens focus proximity.
|
||||
* Builds a `UPlotConfigBuilder` for a Histogram panel. Unlike time-axis panels,
|
||||
* histograms have no time scale or drag-to-zoom: reuses `buildBaseConfig`, then
|
||||
* overrides the scales to auto-linear and installs a drag-disabled cursor.
|
||||
*/
|
||||
export function buildHistogramConfig({
|
||||
panelId,
|
||||
@@ -47,8 +42,6 @@ export function buildHistogramConfig({
|
||||
timezone,
|
||||
panelMode,
|
||||
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
|
||||
// Histograms have no time axis — no stepIntervals, and no click plugin
|
||||
// (the renderer passes no onClick), so the base config needs no response.
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
@@ -62,8 +55,7 @@ export function buildHistogramConfig({
|
||||
focus: { prox: 1e3 },
|
||||
});
|
||||
|
||||
// Override the time-axis scales from `buildBaseConfig` — histograms are
|
||||
// distribution plots, not time series.
|
||||
// Override the time-axis scales — histograms are distribution plots, not time series.
|
||||
builder.addScale({ scaleKey: 'x', time: false, auto: true });
|
||||
builder.addScale({ scaleKey: 'y', time: false, auto: true, min: 0 });
|
||||
|
||||
@@ -81,10 +73,9 @@ interface AddSeriesArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds histogram bar series to the builder. When `mergeAllActiveQueries` is
|
||||
* set, `prepareHistogramData` produces a single Y column, so we add exactly
|
||||
* one series with the fixed merged-mode colors. Otherwise one series per
|
||||
* result row, with labels resolved via the standard legend matrix.
|
||||
* Adds histogram bar series. In `mergeAllActiveQueries` mode `prepareHistogramData`
|
||||
* produces a single Y column, so we add exactly one series with the fixed merged-mode
|
||||
* colors; otherwise one series per result row.
|
||||
*/
|
||||
function addSeries({
|
||||
builder,
|
||||
|
||||
@@ -17,9 +17,7 @@ function NumberPanelRenderer({
|
||||
panel,
|
||||
data,
|
||||
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/NumberPanel'`, so the cast is a
|
||||
// documented boundary narrowing.
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesNumberPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
|
||||
@@ -24,9 +24,7 @@ interface ValueDisplayProps {
|
||||
|
||||
/**
|
||||
* Renders a single large scalar with optional prefix/suffix units and threshold
|
||||
* recoloring (text or background). A V2-native replacement for the V1
|
||||
* `ValueGraph` — depends only on V2 threshold utilities and the shared icon/
|
||||
* typography primitives.
|
||||
* recoloring (text or background). V2-native replacement for the V1 `ValueGraph`.
|
||||
*/
|
||||
function ValueDisplay({
|
||||
value,
|
||||
|
||||
@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
/**
|
||||
* Reduces the scalar tables of a V5 response to the single number a
|
||||
* NumberPanel renders.
|
||||
*
|
||||
* V2 always issues `requestType: 'scalar'` for VALUE panels, so the response
|
||||
* is a scalar table per query (see `prepareScalarTables`). The value is the
|
||||
* first row's `isValueColumn` cell of the first table that has rows —
|
||||
* falling back to the row's first cell when no column is marked as the
|
||||
* value (mirrors the V1 `formatForWeb` fallback read).
|
||||
*
|
||||
* Returns `null` when there is no numeric value to show, which the renderer
|
||||
* maps to the "No Data" state.
|
||||
* Reduces the scalar tables of a V5 response to the single number a NumberPanel
|
||||
* renders: the first row's `isValueColumn` cell of the first table with rows,
|
||||
* falling back to the row's first cell (mirrors the V1 `formatForWeb` read).
|
||||
* Returns `null` when there is no numeric value (renderer shows "No Data").
|
||||
*/
|
||||
export function prepareNumberData(tables: PanelTable[]): number | null {
|
||||
for (const table of tables) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// A number panel renders one scalar — no axes, legend, or stacking. Just value
|
||||
// formatting and thresholds that recolor the value/background.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'thresholds', controls: { list: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'comparison' } },
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
|
||||
@@ -1,42 +1,9 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesComparisonThresholdDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesComparisonThresholdDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type {
|
||||
PanelThreshold,
|
||||
ThresholdComparisonOperator,
|
||||
ThresholdDisplayFormat,
|
||||
} from '../../types/threshold';
|
||||
import type { PanelThreshold } from '../../types/threshold';
|
||||
import { toPanelThreshold } from '../../utils/mapComparisonThreshold';
|
||||
|
||||
// Perses comparison operators → the symbol operators V2 threshold evaluation
|
||||
// uses.
|
||||
const OPERATOR_MAP: Record<
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
ThresholdComparisonOperator
|
||||
> = {
|
||||
[DashboardtypesComparisonOperatorDTO.above]: '>',
|
||||
[DashboardtypesComparisonOperatorDTO.below]: '<',
|
||||
[DashboardtypesComparisonOperatorDTO.above_or_equal]: '>=',
|
||||
[DashboardtypesComparisonOperatorDTO.below_or_equal]: '<=',
|
||||
[DashboardtypesComparisonOperatorDTO.equal]: '=',
|
||||
[DashboardtypesComparisonOperatorDTO.not_equal]: '!=',
|
||||
};
|
||||
|
||||
const FORMAT_MAP: Record<
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
ThresholdDisplayFormat
|
||||
> = {
|
||||
[DashboardtypesThresholdFormatDTO.text]: 'text',
|
||||
[DashboardtypesThresholdFormatDTO.background]: 'background',
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps the panel-spec threshold shape (`ComparisonThresholdDTO`) onto the
|
||||
* V2-native `PanelThreshold` consumed by `ValueDisplay` / threshold
|
||||
* evaluation. No dependency on the V1 `ThresholdProps` shape.
|
||||
*/
|
||||
/** Maps spec `ComparisonThresholdDTO`s onto the V2-native `PanelThreshold` (no V1 `ThresholdProps` dependency). */
|
||||
export function mapNumberThresholds(
|
||||
thresholds: DashboardtypesComparisonThresholdDTO[] | null | undefined,
|
||||
): PanelThreshold[] {
|
||||
@@ -44,11 +11,5 @@ export function mapNumberThresholds(
|
||||
return [];
|
||||
}
|
||||
|
||||
return thresholds.map((threshold) => ({
|
||||
color: threshold.color,
|
||||
operator: threshold.operator ? OPERATOR_MAP[threshold.operator] : undefined,
|
||||
value: threshold.value,
|
||||
unit: threshold.unit,
|
||||
format: threshold.format ? FORMAT_MAP[threshold.format] : undefined,
|
||||
}));
|
||||
return thresholds.map(toPanelThreshold);
|
||||
}
|
||||
|
||||
@@ -24,9 +24,7 @@ function PiePanelRenderer({
|
||||
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/PieChartPanel'`, so the cast is a
|
||||
// documented boundary narrowing.
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesPieChartPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesPieChartPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
|
||||
@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
createAlert: false,
|
||||
search: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,11 +12,9 @@ export interface PreparePieDataArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the scalar tables of a V5 response into pie slices: one slice per
|
||||
* group row. The aggregation column holds the value, the group column(s)
|
||||
* form the label. Colours honour `customColors` then fall back to a
|
||||
* deterministic palette colour; non-positive / non-numeric values are
|
||||
* dropped.
|
||||
* Turns the scalar tables of a V5 response into pie slices (one per group row):
|
||||
* value column → value, group column(s) → label. Colours honour `customColors`
|
||||
* then fall back to the deterministic palette; non-positive/non-numeric dropped.
|
||||
*/
|
||||
export function preparePieData({
|
||||
tables,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a
|
||||
// legend. `mode` is omitted: the pie legend is always interactive swatches.
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
|
||||
// Legend `colors` is omitted: the pie legend is always interactive swatches.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
|
||||
@@ -42,10 +42,8 @@ function TimeSeriesPanelRenderer({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/TimeSeriesPanel'`, so the cast is a
|
||||
// documented boundary narrowing — not a blind assertion. Memoized so the
|
||||
// `?? {}` fallback doesn't produce a fresh object on each render.
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
// Memoized so the `?? {}` fallback doesn't produce a fresh object each render.
|
||||
const spec = useMemo<DashboardtypesTimeSeriesPanelSpecDTO>(
|
||||
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesTimeSeriesPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
@@ -56,12 +54,9 @@ function TimeSeriesPanelRenderer({
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
// X-scale clamps come from the request that produced the data, so each
|
||||
// panel pins to the window it actually fetched — important during
|
||||
// drag-zoom transitions when the time picker has moved but new data
|
||||
// hasn't arrived yet. Falls back to the global picker inside the helper.
|
||||
// The generated request DTO is structurally the hand-written V5 request;
|
||||
// the cast is the documented boundary.
|
||||
// X-scale clamps come from the request that produced the data, so each panel
|
||||
// pins to the window it fetched — matters during drag-zoom transitions before
|
||||
// new data arrives. The generated request DTO is structurally the V5 request.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
|
||||
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
|
||||
@@ -104,10 +99,8 @@ function TimeSeriesPanelRenderer({
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
// TooltipPlugin mutates `config` for cursor sync; rebuild on syncMode change
|
||||
// so a fresh instance doesn't inherit stale sync settings (e.g. "No Sync").
|
||||
dashboardPreference?.syncMode,
|
||||
],
|
||||
);
|
||||
@@ -130,12 +123,8 @@ function TimeSeriesPanelRenderer({
|
||||
[panelId],
|
||||
);
|
||||
|
||||
/**
|
||||
* The uPlot key prop is the only way to force a full teardown and re-mount
|
||||
* of the chart. By including the syncMode and syncFilterMode in the key,
|
||||
* we ensure that changes to these preferences trigger a fresh chart instance,
|
||||
* preventing stale sync settings from being inherited.
|
||||
*/
|
||||
// Keying on sync prefs forces a full chart teardown/re-mount so stale sync
|
||||
// settings aren't inherited — the only way to fully reset the uPlot instance.
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
|
||||
@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true, fillSpans: true } },
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true, colors: true } },
|
||||
{
|
||||
kind: 'formatting',
|
||||
kind: 'chartAppearance',
|
||||
controls: {
|
||||
unit: true,
|
||||
decimals: true,
|
||||
lineStyle: true,
|
||||
lineInterpolation: true,
|
||||
fillMode: true,
|
||||
showPoints: true,
|
||||
spanGaps: true,
|
||||
},
|
||||
},
|
||||
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true, mode: true } },
|
||||
{ kind: 'thresholds', controls: { list: true } },
|
||||
{ kind: 'chartAppearance', controls: { lineStyle: true, fillOpacity: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'label' } },
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
|
||||
@@ -31,10 +31,7 @@ const DEFAULT_POINT_SIZE = 5;
|
||||
export interface BuildTimeSeriesConfigArgs {
|
||||
panelId: string;
|
||||
spec: DashboardtypesTimeSeriesPanelSpecDTO;
|
||||
/**
|
||||
* Flat list of builder queries on this panel (see `getBuilderQueries`).
|
||||
* Powers per-query legend resolution; empty for non-builder panels.
|
||||
*/
|
||||
/** Flat list of builder queries (see `getBuilderQueries`); powers per-query legend resolution. */
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
@@ -49,14 +46,7 @@ export interface BuildTimeSeriesConfigArgs {
|
||||
maxTimeScale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fully-wired `UPlotConfigBuilder` for a TimeSeries panel.
|
||||
*
|
||||
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
|
||||
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
|
||||
* in the TimeSeries-specific concern: one series per result, with visuals
|
||||
* resolved from `spec.chartAppearance`.
|
||||
*/
|
||||
/** Builds a `UPlotConfigBuilder` for a TimeSeries panel: shared scaffolding plus one series per result. */
|
||||
export function buildTimeSeriesConfig({
|
||||
panelId,
|
||||
spec,
|
||||
@@ -104,11 +94,7 @@ interface AddSeriesArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one uPlot series per flattened V5 series to the scaffolded builder.
|
||||
* The visual resolution (line style, interpolation, fill mode, span gaps)
|
||||
* reads from `spec.chartAppearance`; the label is resolved via the legend
|
||||
* matrix in `resolveSeriesLabelV5`. Mutates the builder in place.
|
||||
*
|
||||
* Adds one uPlot series per flattened V5 series; mutates the builder in place.
|
||||
* Order must match `prepareAlignedData` — both iterate the same flat list.
|
||||
*/
|
||||
function addSeries({
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
PanelRegistry,
|
||||
RenderablePanelDefinition,
|
||||
} from './types/panelDefinition';
|
||||
import type { DashboardtypesPanelPluginKindDTO as PanelKind } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelKind } from './types/panelKind';
|
||||
|
||||
// Pure assembly: each kind owns its own PanelDefinition (see
|
||||
// `kinds/<Kind>/definition.ts`). Registering a new panel = add its folder and a
|
||||
|
||||
@@ -2,12 +2,7 @@ import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import type { PanelKind } from './panelKind';
|
||||
|
||||
/**
|
||||
* Source-tagged click events. The three uPlot panels share `ChartClickEvent`;
|
||||
* each non-chart kind carries the context its drill-down needs. The `source`
|
||||
* tag lets a kind-agnostic consumer (the render boundary, a shared drill-down
|
||||
* handler) discriminate without assuming a chart shape.
|
||||
*/
|
||||
/** Source-tagged click events; each non-chart kind carries its own drill-down context. */
|
||||
export type ChartClickEvent = ChartClickData;
|
||||
export type TableClickEvent = {
|
||||
rowData: Record<string, unknown>;
|
||||
@@ -28,11 +23,9 @@ export type PanelClickEvent =
|
||||
type DragSelect = (start: number, end: number) => void;
|
||||
|
||||
/**
|
||||
* Per-kind interaction props. Each panel kind exposes ONLY the gestures it
|
||||
* supports: chart panels get a chart-shaped `onClick`, time-axis charts add
|
||||
* `onDragSelect`, histograms have no drag-to-zoom, a NumberPanel has no
|
||||
* interactions at all. Keys mirror `PanelKind`; `PanelRendererProps<K>` in
|
||||
* rendererProps.ts indexes this map, so a missing kind is a compile error there.
|
||||
* Per-kind interaction props — each kind exposes only the gestures it supports.
|
||||
* Keyed by `PanelKind`; `PanelRendererProps<K>` indexes this, so a missing kind
|
||||
* is a compile error there.
|
||||
*/
|
||||
export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
'signoz/TimeSeriesPanel': {
|
||||
@@ -51,9 +44,8 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
};
|
||||
|
||||
/**
|
||||
* Widest interaction surface — used where the panel kind is not known
|
||||
* statically (the registry render boundary; see `getPanelDefinition`). It is
|
||||
* the structural supertype the per-kind shapes are cast to exactly once.
|
||||
* Widest interaction surface — used where the kind isn't known statically (the
|
||||
* registry render boundary). The supertype the per-kind shapes are cast to once.
|
||||
*/
|
||||
export interface AnyPanelInteractionProps {
|
||||
onClick?: (event: PanelClickEvent) => void;
|
||||
|
||||
@@ -6,23 +6,45 @@ import type { AnyPanelInteractionProps } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
|
||||
|
||||
/**
|
||||
* Which panel actions a kind supports. Required field, so registering a new
|
||||
* kind forces an explicit decision for every action. Chrome actions (move to
|
||||
* section, clone, delete) are dashboard-layout concerns available to every
|
||||
* panel and are intentionally not declarable here.
|
||||
*/
|
||||
export interface PanelActionCapabilities {
|
||||
/** Kind has a full-screen view — gates the "View" action. */
|
||||
view: boolean;
|
||||
/** Kind is editable in the V2 panel editor — gates the "Edit panel" action. */
|
||||
edit: boolean;
|
||||
/** Kind can be cloned — gates the "Clone" action. */
|
||||
clone: boolean;
|
||||
/** Gates "Download as CSV". V1 parity: only table panels carry exportable data. */
|
||||
download: boolean;
|
||||
/** Kind's query can seed a new alert — gates "Create Alerts". */
|
||||
createAlert: boolean;
|
||||
/**
|
||||
* Header search box that filters rendered rows client-side (V1 parity: only
|
||||
* tabular kinds). Not a menu action — the renderer must consume `searchTerm`.
|
||||
*/
|
||||
search: boolean;
|
||||
}
|
||||
|
||||
export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
kind: K;
|
||||
displayName: string;
|
||||
Renderer: ComponentType<PanelRendererProps<K>>;
|
||||
sections: SectionConfig[];
|
||||
supportedSignals: DataSource[];
|
||||
actions: PanelActionCapabilities;
|
||||
}
|
||||
|
||||
// Keyed registry that preserves the kind ↔ definition correlation: indexing
|
||||
// with a literal kind yields that kind's exactly-typed PanelDefinition.
|
||||
// Indexing with a literal kind yields that kind's exactly-typed PanelDefinition.
|
||||
export type PanelRegistry = { [K in PanelKind]?: PanelDefinition<K> };
|
||||
|
||||
// A PanelDefinition whose Renderer is widened to the kind-agnostic prop surface.
|
||||
// At the render boundary the concrete kind isn't known statically (a registry
|
||||
// lookup returns a union over kinds), so getPanelDefinition resolves to this —
|
||||
// concentrating the single unavoidable cast in one place instead of leaking it
|
||||
// to every call site.
|
||||
// PanelDefinition with its Renderer widened to the kind-agnostic prop surface.
|
||||
// getPanelDefinition resolves to this, concentrating the unavoidable cast in one
|
||||
// place rather than leaking it to every call site (the kind isn't known statically).
|
||||
export interface RenderablePanelDefinition extends Omit<
|
||||
PanelDefinition,
|
||||
'Renderer'
|
||||
|
||||
@@ -2,11 +2,9 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { DashboardtypesPanelPluginKindDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* String-literal union of every panel kind, derived from the generated enum so
|
||||
* the contract stays the single source of truth. Kept as a `${enum}` union
|
||||
* (not the nominal enum) so plain string-literal kinds — `PanelRendererProps<
|
||||
* 'signoz/TimeSeriesPanel'>`, registry keys, `PanelInteractionMap` keys —
|
||||
* remain assignable without enum-member ceremony at every call site.
|
||||
* String-literal union of every panel kind, derived from the generated enum.
|
||||
* A `${enum}` union (not the nominal enum) so plain string-literal kinds stay
|
||||
* assignable without enum-member ceremony at every call site.
|
||||
*/
|
||||
export type PanelKind = `${DashboardtypesPanelPluginKindDTO}`;
|
||||
|
||||
|
||||
@@ -4,47 +4,32 @@ import type {
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type {
|
||||
PanelPagination,
|
||||
PanelQueryData,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelInteractionMap } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
|
||||
/**
|
||||
* Dashboard-wide rendering preferences propagated down to every panel renderer
|
||||
* on the same dashboard. Lets the shell push cross-panel concerns (cursor
|
||||
* sync, tooltip filter mode, dashboard id for scoped state) without each
|
||||
* renderer rediscovering them via hooks.
|
||||
*/
|
||||
/** Dashboard-wide rendering preferences propagated to every panel renderer. */
|
||||
export interface DashboardPreference {
|
||||
/**
|
||||
* Cursor-sync mode for the dashboard. Drives the uPlot tooltip plugin so
|
||||
* hovering one panel highlights the corresponding x on every other panel.
|
||||
* Always present — `DashboardCursorSync.None` is the off state.
|
||||
*/
|
||||
/** Cursor-sync mode; always present — `DashboardCursorSync.None` is the off state. */
|
||||
syncMode: DashboardCursorSync;
|
||||
/**
|
||||
* Filter applied to the synced tooltip across panels (e.g. only show series
|
||||
* whose label matches the hovered series).
|
||||
*/
|
||||
/** Filter applied to the synced tooltip across panels. */
|
||||
syncFilterMode?: SyncTooltipFilterMode;
|
||||
/**
|
||||
* Dashboard id — useful for renderers that scope per-dashboard state
|
||||
* (e.g. pinned-tooltip persistence, drill-down history).
|
||||
*/
|
||||
/** Dashboard id, for renderers that scope per-dashboard state. */
|
||||
dashboardId?: string;
|
||||
}
|
||||
|
||||
// Kind-agnostic props every renderer receives, regardless of panel kind. The
|
||||
// kind-specific interaction props (onClick payload, onDragSelect) are layered
|
||||
// on per-kind by PanelRendererProps<K>.
|
||||
// Kind-agnostic props every renderer receives. Kind-specific interaction props
|
||||
// are layered on per-kind by PanelRendererProps<K>.
|
||||
export interface BaseRendererProps {
|
||||
panelId: string;
|
||||
/**
|
||||
* The whole perses panel — renderers derive their concrete `spec` and the
|
||||
* perses-shaped `queries` from this. Passing the full panel keeps the prop
|
||||
* surface stable as new panel-level fields are added to the wire format.
|
||||
* Required: the render boundary (`Panel`) only mounts a renderer once the
|
||||
* panel and its kind are resolved, so a renderer never sees an absent panel.
|
||||
* The whole perses panel — renderers derive `spec` and `queries` from this.
|
||||
* Required: the render boundary only mounts a renderer once the panel and its
|
||||
* kind are resolved, so a renderer never sees an absent panel.
|
||||
*/
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Raw V5 fetch result — response + the request that produced it. */
|
||||
@@ -53,24 +38,21 @@ export interface BaseRendererProps {
|
||||
error: Error | null;
|
||||
/** Gate for the drill-down right-click menu. Off by default in V2. */
|
||||
enableDrillDown?: boolean;
|
||||
/**
|
||||
* Render context — varies behavior (e.g. dashboard widget vs. standalone
|
||||
* full-screen vs. inside the editor). See PanelMode for the contract.
|
||||
*/
|
||||
/** Render context (dashboard widget vs. standalone vs. editor); see PanelMode. */
|
||||
panelMode: PanelMode;
|
||||
/**
|
||||
* Dashboard-level preferences that should propagate to every panel
|
||||
* (cursor sync, tooltip filter mode, dashboard id). The shell owns
|
||||
* resolving these; the renderer just consumes them.
|
||||
*/
|
||||
/** Dashboard-level preferences propagated to every panel; shell resolves, renderer consumes. */
|
||||
dashboardPreference?: DashboardPreference;
|
||||
/**
|
||||
* Free-text filter from the header search box, applied client-side. Only
|
||||
* meaningful for kinds that declare `actions.search`; others ignore it.
|
||||
*/
|
||||
searchTerm?: string;
|
||||
/** Server-side paging handles. Present only for raw/list panels; others ignore it. */
|
||||
pagination?: PanelPagination;
|
||||
}
|
||||
|
||||
// Renderer props for a specific panel kind: the shared base plus that kind's
|
||||
// interaction surface (PanelInteractionMap[K]). Each renderer annotates with
|
||||
// its own kind — e.g. PanelRendererProps<'signoz/TimeSeriesPanel'> — so it can
|
||||
// only reference the gestures that kind supports. Indexing PanelInteractionMap
|
||||
// here forces the map to cover every PanelKind. The default K = PanelKind
|
||||
// yields the widest surface (a union over all kinds).
|
||||
// Renderer props for a specific kind: shared base plus that kind's interaction
|
||||
// surface. Indexing PanelInteractionMap forces it to cover every PanelKind; the
|
||||
// default K = PanelKind yields the widest surface (a union over all kinds).
|
||||
export type PanelRendererProps<K extends PanelKind = PanelKind> =
|
||||
BaseRendererProps & PanelInteractionMap[K];
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import type {
|
||||
DashboardLinkDTO,
|
||||
DashboardtypesAxesDTO,
|
||||
DashboardtypesBarChartVisualizationDTO,
|
||||
DashboardtypesComparisonThresholdDTO,
|
||||
DashboardtypesHistogramBucketsDTO,
|
||||
DashboardtypesLegendDTO,
|
||||
DashboardtypesPanelFormattingDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesTableFormattingDTO,
|
||||
DashboardtypesTableThresholdDTO,
|
||||
DashboardtypesThresholdWithLabelDTO,
|
||||
DashboardtypesTimeSeriesChartAppearanceDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
BarChart,
|
||||
Columns3,
|
||||
Hash,
|
||||
ListEnd,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Link,
|
||||
Palette,
|
||||
Ruler,
|
||||
SlidersHorizontal,
|
||||
@@ -18,38 +35,117 @@ export interface SectionMetadata {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Per-kind control toggles (type-only — runtime metadata is in SECTIONS).
|
||||
// Section components type their controls prop via `SectionControls['axes']`.
|
||||
export type SectionControls = {
|
||||
formatting: { unit?: boolean; decimals?: boolean };
|
||||
axes: { minMax?: boolean; unit?: boolean; logScale?: boolean };
|
||||
legend: { position?: boolean; mode?: boolean };
|
||||
thresholds: { list?: boolean };
|
||||
/**
|
||||
* Which threshold editor a kind uses. All three variants persist to the same
|
||||
* `plugin.spec.thresholds` key with different element shapes:
|
||||
* - `label` — value + color + label lines (TimeSeries / Bar)
|
||||
* - `comparison` — value crosses an operator → recolor (Number)
|
||||
* - `table` — per-column comparison (Table)
|
||||
*/
|
||||
export type ThresholdVariant = 'label' | 'comparison' | 'table';
|
||||
|
||||
/** Union of every threshold element shape stored under `plugin.spec.thresholds`. */
|
||||
export type AnyThreshold =
|
||||
| DashboardtypesThresholdWithLabelDTO
|
||||
| DashboardtypesComparisonThresholdDTO
|
||||
| DashboardtypesTableThresholdDTO;
|
||||
|
||||
/**
|
||||
* Each section ↔ one slice of the panel spec it edits. Most slices live under
|
||||
* `spec.plugin.spec.<key>`; `contextLinks` is panel-level (`spec.links`).
|
||||
*/
|
||||
// Superset spanning every kind's formatting DTO; the `controls` bag gates which
|
||||
// fields a kind actually writes.
|
||||
export type PanelFormattingSlice = DashboardtypesPanelFormattingDTO &
|
||||
Pick<DashboardtypesTableFormattingDTO, 'columnUnits'>;
|
||||
|
||||
export interface SectionSpecMap {
|
||||
formatting: PanelFormattingSlice; // spec.plugin.spec.formatting
|
||||
axes: DashboardtypesAxesDTO; // spec.plugin.spec.axes
|
||||
legend: DashboardtypesLegendDTO; // spec.plugin.spec.legend
|
||||
chartAppearance: DashboardtypesTimeSeriesChartAppearanceDTO; // spec.plugin.spec.chartAppearance
|
||||
buckets: DashboardtypesHistogramBucketsDTO; // spec.plugin.spec.histogramBuckets
|
||||
// spec.plugin.spec.visualization — typed as the Bar shape (widest superset);
|
||||
// the `controls` bag gates which fields each kind writes.
|
||||
visualization: DashboardtypesBarChartVisualizationDTO;
|
||||
thresholds: AnyThreshold[]; // spec.plugin.spec.thresholds (variant picks the editor)
|
||||
contextLinks: DashboardLinkDTO[]; // spec.links (PANEL-level)
|
||||
columns: TelemetrytypesTelemetryFieldKeyDTO[]; // spec.plugin.spec.selectFields (List)
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlled sections — a kind exposes a subset of the section's controls (V2
|
||||
* analogue of V1's `allowSoftMinMax` / `allowLegendColors` flags).
|
||||
*/
|
||||
export interface SectionControls {
|
||||
formatting: { unit?: boolean; decimals?: boolean; columnUnits?: boolean };
|
||||
axes: { minMax?: boolean; logScale?: boolean }; // minMax → softMin/softMax
|
||||
legend: { position?: boolean; colors?: boolean }; // colors → customColors
|
||||
chartAppearance: {
|
||||
lineStyle?: boolean;
|
||||
fillOpacity?: boolean;
|
||||
stacked?: boolean;
|
||||
lineInterpolation?: boolean;
|
||||
fillMode?: boolean;
|
||||
showPoints?: boolean;
|
||||
spanGaps?: boolean;
|
||||
};
|
||||
columnUnits: { perColumnUnit?: boolean };
|
||||
buckets: { count?: boolean; min?: boolean; max?: boolean };
|
||||
};
|
||||
buckets: { count?: boolean; width?: boolean; mergeQueries?: boolean };
|
||||
// stacking → stackedBarChart (Bar); fillSpans → fill gaps with 0 (TimeSeries).
|
||||
visualization: {
|
||||
timePreference?: boolean;
|
||||
stacking?: boolean;
|
||||
fillSpans?: boolean;
|
||||
};
|
||||
// Editor discriminator (not a spec field): which threshold variant a kind edits.
|
||||
thresholds: { variant?: ThresholdVariant };
|
||||
}
|
||||
|
||||
// Source of truth for sections. Its keys define SectionKind; its values are the
|
||||
// runtime UI metadata (consumed by PanelEditor in 1.8). Adding a new section =
|
||||
// one entry here + one entry in SectionControls.
|
||||
export const SECTIONS = {
|
||||
export type ControlledSectionKind = keyof SectionControls;
|
||||
|
||||
/** Atomic sections — no sub-controls; a kind either shows them or not. */
|
||||
export type AtomicSectionKind = 'contextLinks' | 'columns';
|
||||
|
||||
export type SectionKind = ControlledSectionKind | AtomicSectionKind;
|
||||
|
||||
/** Predicate to hide a section from the current spec; returning true removes it. */
|
||||
export type SectionVisibilityPredicate = (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* What a kind declares in `kinds/<Kind>/sections.ts`: a controlled section with
|
||||
* its `controls` subset, or an atomic section bare (`{ kind }`).
|
||||
*/
|
||||
export type SectionConfig =
|
||||
| {
|
||||
[K in ControlledSectionKind]: {
|
||||
kind: K;
|
||||
controls: SectionControls[K];
|
||||
isHidden?: SectionVisibilityPredicate;
|
||||
};
|
||||
}[ControlledSectionKind]
|
||||
| { kind: AtomicSectionKind; isHidden?: SectionVisibilityPredicate };
|
||||
|
||||
// Per-section title + sidebar icon. Pure data; the editor component + spec lens
|
||||
// live in the ConfigPane section registry.
|
||||
export const SECTION_METADATA = {
|
||||
formatting: { title: 'Formatting', icon: Hash },
|
||||
axes: { title: 'Axes', icon: Ruler },
|
||||
legend: { title: 'Legend', icon: ListEnd },
|
||||
thresholds: { title: 'Thresholds', icon: SlidersHorizontal },
|
||||
legend: { title: 'Legend', icon: Layers },
|
||||
chartAppearance: { title: 'Chart appearance', icon: Palette },
|
||||
columnUnits: { title: 'Column units', icon: Columns3 },
|
||||
buckets: { title: 'Buckets', icon: BarChart },
|
||||
} as const satisfies Record<string, SectionMetadata>;
|
||||
visualization: { title: 'Visualization', icon: LayoutDashboard },
|
||||
buckets: { title: 'Histogram / Buckets', icon: BarChart },
|
||||
thresholds: { title: 'Thresholds', icon: SlidersHorizontal },
|
||||
contextLinks: { title: 'Context Links', icon: Link },
|
||||
columns: { title: 'Columns', icon: Columns3 },
|
||||
} as const satisfies Record<SectionKind, SectionMetadata>;
|
||||
|
||||
export type SectionKind = keyof typeof SECTIONS;
|
||||
|
||||
// Discriminated union derived from SectionControls — kept in lockstep automatically.
|
||||
export type SectionConfig = {
|
||||
[K in SectionKind]: { kind: K; controls: SectionControls[K] };
|
||||
}[SectionKind];
|
||||
/**
|
||||
* Props every section editor receives: its slice (`value`), an `onChange`, and
|
||||
* (controlled sections only) the per-kind `controls` subset.
|
||||
*/
|
||||
export type SectionEditorProps<K extends SectionKind> = {
|
||||
value: SectionSpecMap[K] | undefined;
|
||||
onChange: (next: SectionSpecMap[K]) => void;
|
||||
} & (K extends ControlledSectionKind
|
||||
? { controls: SectionControls[K] }
|
||||
: unknown);
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
/**
|
||||
* V2-native threshold model.
|
||||
*
|
||||
* The panel spec carries thresholds as `DashboardtypesComparisonThresholdDTO`
|
||||
* (operator/format expressed as `above`/`below`/`text`/`background`). For
|
||||
* evaluation and rendering we work with the symbol operators and lowercase
|
||||
* display formats, kept here so V2 panels never reach into the V1
|
||||
* `container/NewWidget` `ThresholdProps` shape.
|
||||
* V2-native threshold model. The spec carries thresholds as DTOs (operator as
|
||||
* `above`/`below`/…); this maps them to symbol operators + lowercase formats so
|
||||
* V2 panels never reach into the V1 `container/NewWidget` `ThresholdProps` shape.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Comparison-shaped fields shared by every threshold DTO that recolors on an
|
||||
* operator crossing. Container DTOs add their own keys (e.g. a table threshold's
|
||||
* `columnName`) around this core.
|
||||
*/
|
||||
export interface ComparisonThresholdShape {
|
||||
color: string;
|
||||
value: number;
|
||||
operator?: DashboardtypesComparisonOperatorDTO;
|
||||
unit?: string;
|
||||
format?: DashboardtypesThresholdFormatDTO;
|
||||
}
|
||||
|
||||
/** Comparison operators a threshold can use, as evaluable symbols. */
|
||||
export type ThresholdComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '!=';
|
||||
|
||||
@@ -16,8 +30,8 @@ export type ThresholdDisplayFormat = 'text' | 'background';
|
||||
|
||||
/**
|
||||
* A threshold normalized for evaluation/rendering. `operator`/`format` are
|
||||
* optional because the spec allows partially-configured thresholds; a
|
||||
* threshold with no operator never matches.
|
||||
* optional because the spec allows partial config; a threshold with no operator
|
||||
* never matches.
|
||||
*/
|
||||
export interface PanelThreshold {
|
||||
color: string;
|
||||
|
||||
@@ -20,11 +20,9 @@ import {
|
||||
} from './selectionPreferences';
|
||||
|
||||
/**
|
||||
* Inputs for the shared V2 chart pipeline. Mirrors the V1 helper of the same
|
||||
* name but accepts perses-shaped inputs directly (so callers don't translate
|
||||
* once per panel). The series-rendering step is panel-specific and lives in
|
||||
* each panel's `utils.ts` — this helper only wires the scaffolding (scales,
|
||||
* thresholds, axes, drag-to-zoom, click plugin).
|
||||
* Inputs for the shared V2 chart pipeline. Accepts perses-shaped inputs directly
|
||||
* so callers don't translate per panel. Wires only the scaffolding (scales,
|
||||
* thresholds, axes, drag-to-zoom, click plugin); series rendering is per-panel.
|
||||
*/
|
||||
export interface BuildBaseConfigArgs {
|
||||
panelId: string;
|
||||
@@ -46,10 +44,7 @@ export interface BuildBaseConfigArgs {
|
||||
|
||||
/** Per-query step intervals from the response exec stats. */
|
||||
stepIntervals?: Record<string, number>;
|
||||
/**
|
||||
* Tuple-shaped payload for the shared click plugin (see
|
||||
* `toClickPluginPayload`). Omitted by panels without click interactions.
|
||||
*/
|
||||
/** Payload for the shared click plugin; omitted by panels without click interactions. */
|
||||
clickPayload?: MetricRangePayloadProps;
|
||||
|
||||
/** Time-range clamps for the X scale (typically from `getTimeRange(apiResponse)`). */
|
||||
@@ -62,10 +57,9 @@ export interface BuildBaseConfigArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the panel-agnostic scaffolding of a uPlot chart: scales, thresholds,
|
||||
* axes, drag-to-zoom, click plugin. Callers (TimeSeriesPanel, BarPanel, …)
|
||||
* then call `addSeries`/`addPlugin` on the returned builder for their own
|
||||
* panel-specific rendering.
|
||||
* Builds the panel-agnostic scaffolding of a uPlot chart (scales, thresholds,
|
||||
* axes, drag-to-zoom, click plugin). Callers then `addSeries`/`addPlugin` on the
|
||||
* returned builder for their own rendering.
|
||||
*/
|
||||
export function buildBaseConfig({
|
||||
panelId,
|
||||
@@ -165,9 +159,10 @@ function makeTzDate(
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
|
||||
}
|
||||
|
||||
// Perses-shape thresholds → the draw-hook shape uPlotV2 consumes. Exported so
|
||||
// panels that need to feed the same threshold list elsewhere (e.g. to a series
|
||||
// `addSeries` thresholds hook) don't have to redo the mapping.
|
||||
/**
|
||||
* Perses-shape thresholds → the draw-hook shape uPlotV2 consumes. Exported so
|
||||
* panels feeding the same list elsewhere don't redo the mapping.
|
||||
*/
|
||||
export function mapThresholds(
|
||||
thresholds: DashboardtypesThresholdWithLabelDTO[] | null | undefined,
|
||||
): ThresholdsDrawHookOptions['thresholds'] {
|
||||
@@ -183,10 +178,9 @@ export function mapThresholds(
|
||||
}
|
||||
|
||||
/**
|
||||
* V5 backend reports per-query step intervals; we feed the smallest one through
|
||||
* to uPlot so the X-axis tick density matches the densest query. An empty map
|
||||
* yields `Infinity` from `Math.min`, which would corrupt downstream scale math —
|
||||
* fall back to `undefined` (uPlot's "auto") in that case.
|
||||
* Smallest per-query step interval, fed to uPlot so tick density matches the
|
||||
* densest query. Falls back to `undefined` (uPlot "auto") on an empty map, since
|
||||
* `Math.min` returns `Infinity` there and would corrupt scale math.
|
||||
*/
|
||||
function minStepInterval(
|
||||
stepIntervals: Record<string, number>,
|
||||
|
||||
@@ -12,12 +12,9 @@ import {
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
|
||||
/**
|
||||
* Bridges the V2 dashboard wire-format enums (snake_case, generated from Go)
|
||||
* to the uPlotV2 chart enums (PascalCase). String values diverge between the
|
||||
* two — don't coerce, map.
|
||||
*
|
||||
* Kept as a single source of truth so every panel that reads chart-appearance
|
||||
* fields stays in sync as either side's enum evolves.
|
||||
* Bridges the V2 wire-format enums to the uPlotV2 chart enums. String values
|
||||
* diverge between the two — don't coerce, map. Single source of truth shared by
|
||||
* every panel that reads chart-appearance fields.
|
||||
*/
|
||||
|
||||
export const LINE_STYLE_MAP: Record<DashboardtypesLineStyleDTO, LineStyle> = {
|
||||
|
||||
@@ -7,16 +7,13 @@ import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { LEGEND_POSITION_MAP } from './enumMaps';
|
||||
|
||||
/**
|
||||
* Resolvers that turn raw `spec` chart-appearance fields into the chart's
|
||||
* runtime values, falling back to the chart defaults for missing/unknown input.
|
||||
*/
|
||||
// Resolvers turning raw `spec` chart-appearance fields into runtime chart
|
||||
// values, falling back to chart defaults for missing/unknown input.
|
||||
|
||||
/**
|
||||
* `spec.formatting.decimalPrecision` is a stringified-digit enum on the wire
|
||||
* (`'0'`–`'4'` plus the sentinel `'full'`). The chart consumes a numeric
|
||||
* `PrecisionOption` (`0`–`4`) or the same `'full'` sentinel from its own
|
||||
* enum. Missing / unknown → `undefined` (chart uses its default).
|
||||
* (`'0'`–`'4'` plus the `'full'` sentinel). Maps to a numeric `PrecisionOption`
|
||||
* or the `'full'` sentinel; missing/unknown → `undefined` (chart default).
|
||||
*/
|
||||
export function resolveDecimalPrecision(
|
||||
precision: DashboardtypesPrecisionOptionDTO | undefined,
|
||||
@@ -42,8 +39,8 @@ export function resolveDecimalPrecision(
|
||||
|
||||
/**
|
||||
* `spec.chartAppearance.spanGaps.fillLessThan` is a stringified number on the
|
||||
* wire. Empty / missing → span all gaps (the chart default). Numeric → forward
|
||||
* the threshold so uPlot only bridges short runs of nulls.
|
||||
* wire. Empty/missing → span all gaps (default); numeric → forward the threshold
|
||||
* so uPlot only bridges short runs of nulls.
|
||||
*/
|
||||
export function resolveSpanGaps(
|
||||
fillLessThan: string | undefined,
|
||||
@@ -55,10 +52,7 @@ export function resolveSpanGaps(
|
||||
return Number.isFinite(parsed) ? parsed : true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the legend position for a panel. Missing / unknown values fall
|
||||
* back to `BOTTOM` to match the chart's default and the V1 behavior.
|
||||
*/
|
||||
/** Legend position; missing/unknown falls back to `BOTTOM` (chart default, V1 parity). */
|
||||
export function resolveLegendPosition(
|
||||
position: DashboardtypesLegendPositionDTO | undefined,
|
||||
): LegendPosition {
|
||||
|
||||
@@ -13,10 +13,8 @@ import type {
|
||||
|
||||
/**
|
||||
* Threshold evaluation for V2 panels — a self-contained port of the V1
|
||||
* `GridTableComponent`/`ValueGraph` logic that depends only on shared,
|
||||
* non-V1 primitives (`convertValue`, the Y-axis unit catalog). No imports
|
||||
* from `container/NewWidget`, `container/GridTableComponent`, or
|
||||
* `components/ValueGraph`.
|
||||
* `GridTableComponent`/`ValueGraph` logic, depending only on non-V1 primitives
|
||||
* (`convertValue`, the Y-axis unit catalog) so it never imports V1 surfaces.
|
||||
*/
|
||||
|
||||
/** Resolves which unit category a unit id belongs to, or null if unknown. */
|
||||
@@ -25,9 +23,8 @@ function getCategoryName(unitId: string): YAxisCategoryNames | null {
|
||||
|
||||
const foundCategory = categories.find((category) =>
|
||||
category.units.some((unit) => {
|
||||
// Category units use universal ids; thresholds/panel units may use
|
||||
// Grafana-style ids. Match either the universal id directly or its
|
||||
// mapped Grafana id.
|
||||
// Category units use universal ids; panel/threshold units may use
|
||||
// Grafana-style ids. Match the universal id or its mapped Grafana id.
|
||||
if (unit.id === unitId) {
|
||||
return true;
|
||||
}
|
||||
@@ -38,10 +35,7 @@ function getCategoryName(unitId: string): YAxisCategoryNames | null {
|
||||
return foundCategory ? foundCategory.name : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts `value` from `fromUnit` to `toUnit`, returning null when the
|
||||
* conversion is invalid (unknown unit, or units in different categories).
|
||||
*/
|
||||
/** Converts `value` between units; null when invalid (unknown, or different categories). */
|
||||
function convertUnit(
|
||||
value: number,
|
||||
fromUnit?: string,
|
||||
@@ -85,9 +79,8 @@ function evaluateCondition(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `value` (expressed in `panelUnit`) satisfies `threshold`. When the
|
||||
* threshold declares its own unit, the panel value is converted into that unit
|
||||
* before comparing; if the conversion is invalid we compare the raw value.
|
||||
* Whether `value` (in `panelUnit`) satisfies `threshold`. Converts into the
|
||||
* threshold's unit before comparing; falls back to the raw value if invalid.
|
||||
*/
|
||||
export function doesValueMatchThreshold(
|
||||
value: number,
|
||||
@@ -112,9 +105,8 @@ export interface ActiveThreshold {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the threshold to apply for `value`. Among matching thresholds the
|
||||
* one declared earliest (lowest index) wins, mirroring V1 precedence; a match
|
||||
* count greater than one flags a conflict.
|
||||
* Resolves the threshold to apply for `value`. Earliest-declared match wins
|
||||
* (V1 precedence); more than one match flags a conflict.
|
||||
*/
|
||||
export function resolveActiveThreshold(
|
||||
thresholds: PanelThreshold[],
|
||||
|
||||
@@ -2,16 +2,10 @@ import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
/**
|
||||
* Formats a scalar for display in a V2 panel, honoring the configured decimal
|
||||
* precision. The shared, unit-aware `getYAxisFormattedValue` is the single
|
||||
* formatting helper across V2 panels (number/table/list/pie); this wrapper is
|
||||
* the only seam through which panels touch it.
|
||||
*
|
||||
* Precision is applied REGARDLESS of whether a unit is set. When no unit is
|
||||
* configured we format through the `'none'` unit, which still respects
|
||||
* precision — this is the fix for decimal precision being silently dropped on
|
||||
* unitless panels (the old `unit ? format() : value.toString()` gate threw the
|
||||
* precision away whenever the unit was empty).
|
||||
* Formats a scalar for display in a V2 panel, honoring decimal precision. The
|
||||
* single seam through which panels touch `getYAxisFormattedValue`. Unitless
|
||||
* values format through the `'none'` unit, which still respects precision — so
|
||||
* precision isn't silently dropped when no unit is set.
|
||||
*/
|
||||
export function formatPanelValue(
|
||||
value: number,
|
||||
|
||||
@@ -2,13 +2,10 @@ import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schem
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
* Flattens a panel's queries into the list of builder queries it contains —
|
||||
* unwrapping `CompositeQuery` envelopes along the way. Non-builder kinds
|
||||
* (PromQL, ClickHouseSQL, Formula, TraceOperator) are dropped: they don't
|
||||
* carry the legend / groupBy / aggregation context downstream code needs.
|
||||
*
|
||||
* Returns the generated v5 `BuilderQuery` shape directly — no intermediate
|
||||
* summary type — so callers consume the same type the wire format defines.
|
||||
* Flattens a panel's queries into its builder queries, unwrapping
|
||||
* `CompositeQuery` envelopes. Non-builder kinds (PromQL, ClickHouseSQL, Formula,
|
||||
* TraceOperator) are dropped — they lack the legend/groupBy/aggregation context
|
||||
* downstream code needs. Returns the generated v5 `BuilderQuery` shape directly.
|
||||
*/
|
||||
export function getBuilderQueries(
|
||||
queries: DashboardtypesQueryDTO[] | null | undefined,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type {
|
||||
ComparisonThresholdShape,
|
||||
PanelThreshold,
|
||||
ThresholdComparisonOperator,
|
||||
ThresholdDisplayFormat,
|
||||
} from '../types/threshold';
|
||||
|
||||
// Perses comparison operators → the symbol operators V2 threshold evaluation uses.
|
||||
const OPERATOR_MAP: Record<
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
ThresholdComparisonOperator
|
||||
> = {
|
||||
[DashboardtypesComparisonOperatorDTO.above]: '>',
|
||||
[DashboardtypesComparisonOperatorDTO.below]: '<',
|
||||
[DashboardtypesComparisonOperatorDTO.above_or_equal]: '>=',
|
||||
[DashboardtypesComparisonOperatorDTO.below_or_equal]: '<=',
|
||||
[DashboardtypesComparisonOperatorDTO.equal]: '=',
|
||||
[DashboardtypesComparisonOperatorDTO.not_equal]: '!=',
|
||||
};
|
||||
|
||||
const FORMAT_MAP: Record<
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
ThresholdDisplayFormat
|
||||
> = {
|
||||
[DashboardtypesThresholdFormatDTO.text]: 'text',
|
||||
[DashboardtypesThresholdFormatDTO.background]: 'background',
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps a comparison-shaped spec threshold onto the V2-native `PanelThreshold`.
|
||||
* The single place the Perses operator/format enums cross into the symbol model,
|
||||
* shared by every kind that carries comparison thresholds (Number, Table, …).
|
||||
*/
|
||||
export function toPanelThreshold(
|
||||
threshold: ComparisonThresholdShape,
|
||||
): PanelThreshold {
|
||||
return {
|
||||
color: threshold.color,
|
||||
operator: threshold.operator ? OPERATOR_MAP[threshold.operator] : undefined,
|
||||
value: threshold.value,
|
||||
unit: threshold.unit,
|
||||
format: threshold.format ? FORMAT_MAP[threshold.format] : undefined,
|
||||
};
|
||||
}
|
||||
@@ -8,10 +8,9 @@ export interface ParsedFormattedValue {
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a formatted value string (e.g. "$ 1.2K", "295.43 ms") into its
|
||||
* numeric core and any prefix/suffix unit so each part can be styled
|
||||
* independently. Falls back to treating the whole string as the numeric value
|
||||
* when it doesn't match the expected shape.
|
||||
* Splits a formatted value (e.g. "$ 1.2K", "295.43 ms") into its numeric core
|
||||
* and prefix/suffix unit for independent styling. Non-matching input falls back
|
||||
* to the whole string as the numeric value.
|
||||
*/
|
||||
export function parseFormattedValue(value: string): ParsedFormattedValue {
|
||||
const matches = value.match(
|
||||
|
||||
@@ -31,12 +31,10 @@ export function resolveSeriesLabelV5(
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the V1 legend matrix: `single-vs-many builder queries ×
|
||||
* with/without groupBy × single-vs-many aggregations`. Returns `baseLabel`
|
||||
* unchanged for panels without builder queries (PromQL, ClickHouseSQL) and
|
||||
* for builder series whose aggregation carries no alias/expression — metric
|
||||
* aggregations don't have those fields, so they naturally short-circuit to
|
||||
* the base label here.
|
||||
* Applies the V1 legend matrix: single-vs-many builder queries × with/without
|
||||
* groupBy × single-vs-many aggregations. Returns `baseLabel` unchanged for
|
||||
* non-builder panels and for series whose aggregation has no alias/expression
|
||||
* (e.g. metric aggregations, which lack those fields).
|
||||
*/
|
||||
function resolveLabel(
|
||||
identity: SeriesIdentity,
|
||||
@@ -56,9 +54,8 @@ function resolveLabel(
|
||||
const aggregations = matching.aggregations ?? [];
|
||||
const aggregation = aggregations[aggIndex];
|
||||
|
||||
// `alias` / `expression` exist on Log/Trace aggregations only —
|
||||
// `MetricAggregation` carries `metricName`/`temporality`/… instead. The
|
||||
// `in` guards narrow the union without a cast.
|
||||
// `alias`/`expression` exist on Log/Trace aggregations only (not
|
||||
// `MetricAggregation`); the `in` guards narrow the union without a cast.
|
||||
const aggregationAlias =
|
||||
aggregation && 'alias' in aggregation ? (aggregation.alias ?? '') : '';
|
||||
const aggregationExpression =
|
||||
@@ -93,7 +90,7 @@ interface FormatContext {
|
||||
singleAggregation: boolean;
|
||||
}
|
||||
|
||||
// Panel has one builder query — ports V1's `getLegendForSingleAggregation`.
|
||||
/** Panel has one builder query — ports V1's `getLegendForSingleAggregation`. */
|
||||
function formatForSinglePanelQuery({
|
||||
aggregationAlias,
|
||||
aggregationExpression,
|
||||
@@ -114,10 +111,11 @@ function formatForSinglePanelQuery({
|
||||
return aggregationAlias || aggregationExpression;
|
||||
}
|
||||
|
||||
// Panel has multiple builder queries — ports V1's `getLegendForMultipleAggregations`.
|
||||
// Differs from the single-query path in two cells: the no-groupBy / single-agg
|
||||
// cell falls through to `baseLabel` instead of `legend`, and the no-groupBy /
|
||||
// multi-agg cell prepends the base label.
|
||||
/**
|
||||
* Multiple builder queries — ports V1's `getLegendForMultipleAggregations`.
|
||||
* Differs from the single-query path in the no-groupBy cells: single-agg falls
|
||||
* through to `baseLabel` (not `legend`), and multi-agg prepends the base label.
|
||||
*/
|
||||
function formatForMultiplePanelQueries({
|
||||
aggregationAlias,
|
||||
aggregationExpression,
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { SelectionPreferencesSource } from 'lib/uPlotV2/config/types';
|
||||
|
||||
/**
|
||||
* Drag-to-zoom "selection preference" wiring, grouped on its own so the base
|
||||
* config builder stays focused on assembling the chart. Both helpers are driven
|
||||
* purely by the render context (`PanelMode`).
|
||||
*/
|
||||
// Drag-to-zoom "selection preference" wiring, driven by the render context.
|
||||
|
||||
/**
|
||||
* Whether a chart's drag-selection preference should be persisted. Only the
|
||||
* read-only dashboard view persists it; editor/preview contexts keep it
|
||||
* ephemeral so an in-progress edit doesn't mutate saved state.
|
||||
* dashboard view persists it; editor/preview keep it ephemeral so an in-progress
|
||||
* edit doesn't mutate saved state.
|
||||
*/
|
||||
export function shouldSaveSelectionPreference(panelMode: PanelMode): boolean {
|
||||
return panelMode === PanelMode.DASHBOARD_VIEW;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the chart reads/writes its selection preference: localStorage for the
|
||||
* persisted view contexts, in-memory otherwise.
|
||||
*/
|
||||
/** Where the preference is stored: localStorage for view contexts, in-memory otherwise. */
|
||||
export function resolveSelectionPreferencesSource(
|
||||
panelMode: PanelMode,
|
||||
): SelectionPreferencesSource {
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
DashboardtypesPanelPluginKindDTO as PanelKind,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { panelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import type { Warning } from 'types/api';
|
||||
import type { DashboardtypesPanelPluginKindDTO as PanelKind } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import type { DeletePanelArgs } from './hooks/useDeletePanel';
|
||||
import { usePanelInteractions } from './hooks/usePanelInteractions';
|
||||
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
|
||||
import PanelBody from './PanelBody/PanelBody';
|
||||
import UnsupportedPanelBody from './PanelBody/UnsupportedPanelBody';
|
||||
import PanelHeader from './PanelHeader/PanelHeader';
|
||||
import styles from './Panel.module.scss';
|
||||
|
||||
/** Panel action context — present together only in editable sectioned mode. */
|
||||
/**
|
||||
* Layout context for the panel actions menu — pure data, present only in
|
||||
* editable mode. No callbacks: the menu resolves its own mutations from
|
||||
* store-backed hooks (useDeletePanel / useMovePanelToSection), and edit is
|
||||
* URL-driven (useOpenPanelEditor).
|
||||
*/
|
||||
export interface PanelActionsConfig {
|
||||
currentLayoutIndex: number;
|
||||
sections: DashboardSection[];
|
||||
onMovePanel: (args: MovePanelArgs) => void;
|
||||
onDeletePanel: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
@@ -50,15 +54,32 @@ function Panel({
|
||||
const kind = fullKind?.replace(/^signoz\//, '') ?? 'unknown';
|
||||
const queryCount = panel.spec.queries?.length ?? 0;
|
||||
|
||||
// A per-panel relative time preference (anything other than global_time) is
|
||||
// surfaced as a pill in the header. `visualization` is common to every
|
||||
// plugin-spec variant — localized cast reads it without narrowing on kind.
|
||||
const timePreference = (
|
||||
panel.spec.plugin?.spec as
|
||||
| { visualization?: { timePreference?: DashboardtypesTimePreferenceDTO } }
|
||||
| undefined
|
||||
)?.visualization?.timePreference;
|
||||
const timeLabel = panelTimePreferenceLabel(timePreference);
|
||||
|
||||
const panelDefinition = getPanelDefinition(fullKind);
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch } = usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
// Lazy: only fetch once the section is on screen (undefined → treat as
|
||||
// visible) and a renderer exists for the kind.
|
||||
enabled: !!panelDefinition && isVisible !== false,
|
||||
});
|
||||
// Header search: only kinds that declare it (e.g. tables) render the box; the
|
||||
// term is owned here and threaded to both the header (input) and the renderer
|
||||
// (filter), the two being siblings under this orchestrator.
|
||||
const searchable = !!panelDefinition?.actions.search;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch, pagination } =
|
||||
usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
// Lazy: only fetch once the section is on screen (undefined → treat as
|
||||
// visible) and a renderer exists for the kind.
|
||||
enabled: !!panelDefinition && isVisible !== false,
|
||||
});
|
||||
|
||||
const { onDragSelect, dashboardPreference } = usePanelInteractions();
|
||||
|
||||
@@ -81,13 +102,15 @@ function Panel({
|
||||
<PanelHeader
|
||||
title={headerTitle}
|
||||
panelId={panelId}
|
||||
panelKind={fullKind}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
// The V5 response `warning` is the same object the legacy chain
|
||||
// surfaced as `Warning` — passed through untouched; the cast is the
|
||||
// generated-DTO → hand-written-type boundary.
|
||||
warning={data.response?.data?.warning as Warning | undefined}
|
||||
warning={data.response?.data?.warning}
|
||||
timeLabel={timeLabel}
|
||||
panelActions={panelActions}
|
||||
searchable={searchable}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
{panelDefinition ? (
|
||||
<PanelBody
|
||||
@@ -100,6 +123,8 @@ function Panel({
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
/>
|
||||
) : (
|
||||
// TODO: remove this after all panel kinds are supported
|
||||
|
||||
@@ -1,98 +1,70 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons';
|
||||
import { EllipsisVertical } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { DeletePanelArgs } from '../hooks/useDeletePanel';
|
||||
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
|
||||
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import { usePanelActionItems } from './usePanelActionItems';
|
||||
import styles from './PanelActionsMenu.module.scss';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
interface PanelActionsMenuProps {
|
||||
panelId: string;
|
||||
currentLayoutIndex: number;
|
||||
sections: DashboardSection[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`);*/
|
||||
panelKind: PanelKind;
|
||||
/** Layout context for move/delete — absent outside editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Purely presentational: the trigger button + dropdown, plus the delete
|
||||
* confirmation dialog. Which items appear — and the delete-confirm state — is
|
||||
* owned by `usePanelActionItems` (kind ∧ role ∧ context gating per action).
|
||||
*/
|
||||
function PanelActionsMenu({
|
||||
panelId,
|
||||
currentLayoutIndex,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: PanelActionsMenuProps): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const result: MenuItem[] = [];
|
||||
panelKind,
|
||||
panelActions,
|
||||
}: PanelActionsMenuProps): JSX.Element | null {
|
||||
const { items, deleteConfirm } = usePanelActionItems({
|
||||
panelId,
|
||||
panelKind,
|
||||
panelActions,
|
||||
});
|
||||
|
||||
if (onMovePanel) {
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
result.push({
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
disabled: true,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
onMovePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (onDeletePanel) {
|
||||
if (result.length > 0) {
|
||||
result.push({ type: 'divider' });
|
||||
}
|
||||
result.push({
|
||||
key: 'delete-panel',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: 'Delete panel',
|
||||
onClick: (): void =>
|
||||
onDeletePanel({ panelId, layoutIndex: currentLayoutIndex }),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [sections, currentLayoutIndex, panelId, onMovePanel, onDeletePanel]);
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.trigger}
|
||||
aria-label="Panel actions"
|
||||
data-testid={`panel-actions-${panelId}`}
|
||||
// Stop pointer/mouse down from reaching the RGL drag handle this
|
||||
// button lives inside, so opening the menu never starts a panel drag.
|
||||
onPointerDown={(e): void => e.stopPropagation()}
|
||||
onMouseDown={(e): void => e.stopPropagation()}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
<>
|
||||
<DropdownMenuSimple menu={{ items }} align="end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.trigger}
|
||||
aria-label="Panel actions"
|
||||
data-testid={`panel-actions-${panelId}`}
|
||||
// Stop pointer/mouse down from reaching the RGL drag handle this
|
||||
// button lives inside, so opening the menu never starts a panel drag.
|
||||
onPointerDown={(e): void => e.stopPropagation()}
|
||||
onMouseDown={(e): void => e.stopPropagation()}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteConfirm.open}
|
||||
title="Delete panel?"
|
||||
description="This panel will be removed from the dashboard. This action cannot be undone."
|
||||
isLoading={deleteConfirm.isPending}
|
||||
onConfirm={deleteConfirm.confirm}
|
||||
onClose={deleteConfirm.cancel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { ROLES } from 'types/roles';
|
||||
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { usePanelActionItems } from '../usePanelActionItems';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
const mockOpenEditor = jest.fn();
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
|
||||
() => ({
|
||||
useOpenPanelEditor: (): jest.Mock => mockOpenEditor,
|
||||
}),
|
||||
);
|
||||
|
||||
const mockMovePanel = jest.fn();
|
||||
jest.mock('../../hooks/useMovePanelToSection', () => ({
|
||||
useMovePanelToSection: (): jest.Mock => mockMovePanel,
|
||||
}));
|
||||
|
||||
const mockDeletePanel = jest.fn();
|
||||
jest.mock('../../hooks/useDeletePanel', () => ({
|
||||
useDeletePanel: (): jest.Mock => mockDeletePanel,
|
||||
}));
|
||||
|
||||
const mockClonePanel = jest.fn();
|
||||
jest.mock('../../hooks/useClonePanel', () => ({
|
||||
useClonePanel: (): jest.Mock => mockClonePanel,
|
||||
}));
|
||||
|
||||
// Role is the only thing read off the app context; useComponentPermission runs
|
||||
// for real so the tests exercise the actual role → permission mapping.
|
||||
let mockRole: ROLES = 'ADMIN';
|
||||
jest.mock('providers/App/App', () => ({
|
||||
useAppContext: (): { user: { role: ROLES } } => ({
|
||||
user: { role: mockRole },
|
||||
}),
|
||||
}));
|
||||
|
||||
function section(
|
||||
layoutIndex: number,
|
||||
title: string | undefined,
|
||||
): DashboardSection {
|
||||
return {
|
||||
id: `section-${layoutIndex}`,
|
||||
layoutIndex,
|
||||
title,
|
||||
items: [],
|
||||
repeatVariable: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
|
||||
|
||||
const baseArgs = {
|
||||
panelId: 'panel-1',
|
||||
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
|
||||
};
|
||||
|
||||
function itemKeys(result: ReturnType<typeof usePanelActionItems>): unknown[] {
|
||||
return result.items.map((item) =>
|
||||
'key' in item && item.key !== undefined ? item.key : item.type,
|
||||
);
|
||||
}
|
||||
|
||||
describe('usePanelActionItems', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRole = 'ADMIN';
|
||||
useDashboardStore.setState({ isEditable: true });
|
||||
});
|
||||
|
||||
it('ADMIN on an editable dashboard with a known kind gets the full V1-parity set, divider-separated', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
'divider',
|
||||
'create-alert',
|
||||
'divider',
|
||||
'move',
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
// download stays hidden: no current kind declares the capability
|
||||
// (V1 parity — CSV export was table-only).
|
||||
});
|
||||
|
||||
it('AUTHOR loses edit and clone (edit_widget excludes AUTHOR) but keeps the rest', () => {
|
||||
mockRole = 'AUTHOR';
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'create-alert',
|
||||
'divider',
|
||||
'move',
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('VIEWER keeps only the role-ungated actions (view, create-alert)', () => {
|
||||
mockRole = 'VIEWER';
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'create-alert',
|
||||
]);
|
||||
});
|
||||
|
||||
it('unknown panel kind hides all kind-gated actions (incl. clone), keeping only move/delete', () => {
|
||||
const { result } = renderHook(() =>
|
||||
// A kind with no registered definition — exercises the "unsupported kind"
|
||||
// branch. Clone is kind-gated (needs the kind to declare actions.clone),
|
||||
// so it drops too; only the kind-agnostic layout actions remain.
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelKind: 'signoz/UnsupportedPanel' as PanelKind,
|
||||
}),
|
||||
);
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'move',
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('read-only dashboard keeps only View (V1 parity)', () => {
|
||||
useDashboardStore.setState({ isEditable: false });
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
|
||||
);
|
||||
expect(itemKeys(result.current)).toStrictEqual(['view-panel']);
|
||||
});
|
||||
|
||||
it('move is disabled when there is no other titled section to move to', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: {
|
||||
currentLayoutIndex: 0,
|
||||
sections: [section(0, 'Overview'), section(1, undefined)],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find((i) => 'key' in i && i.key === 'move');
|
||||
expect(move).toMatchObject({ disabled: true });
|
||||
});
|
||||
|
||||
it('edit opens the panel editor for this panel', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const edit = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'edit-panel',
|
||||
);
|
||||
(edit as { onClick: () => void }).onClick();
|
||||
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1');
|
||||
});
|
||||
|
||||
it('move targets call the mutation with from/to layout indexes', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as {
|
||||
children: { key: string; onClick: () => void }[];
|
||||
};
|
||||
expect(move.children).toHaveLength(1);
|
||||
move.children[0].onClick();
|
||||
expect(mockMovePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
fromLayoutIndex: 0,
|
||||
toLayoutIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('delete defers to a confirmation: the item opens the dialog, confirm runs the mutation', async () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const del = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'delete-panel',
|
||||
);
|
||||
|
||||
// Clicking the menu item only opens the dialog — no mutation yet.
|
||||
expect(result.current.deleteConfirm.open).toBe(false);
|
||||
act(() => {
|
||||
(del as { onClick: () => void }).onClick();
|
||||
});
|
||||
expect(result.current.deleteConfirm.open).toBe(true);
|
||||
expect(mockDeletePanel).not.toHaveBeenCalled();
|
||||
|
||||
// Confirming runs the delete and closes the dialog.
|
||||
await act(async () => {
|
||||
await result.current.deleteConfirm.confirm();
|
||||
});
|
||||
expect(mockDeletePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
layoutIndex: 0,
|
||||
});
|
||||
expect(result.current.deleteConfirm.open).toBe(false);
|
||||
});
|
||||
|
||||
it('clone calls the clone mutation with the panel and its layout index', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const clone = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'clone-panel',
|
||||
);
|
||||
(clone as { onClick: () => void }).onClick();
|
||||
expect(mockClonePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
layoutIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('not-yet-implemented actions (view/create-alert) fire the placeholder alert with the feature name', () => {
|
||||
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
|
||||
['view-panel', 'create-alert'].forEach((key) => {
|
||||
const item = result.current.items.find((i) => 'key' in i && i.key === key);
|
||||
(item as { onClick: () => void }).onClick();
|
||||
});
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledTimes(2);
|
||||
expect(alertSpy).toHaveBeenCalledWith('View option clicked');
|
||||
expect(alertSpy).toHaveBeenCalledWith('Create Alerts option clicked');
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { PanelActionCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { ComponentTypes } from 'utils/permission';
|
||||
|
||||
/**
|
||||
* Every action the panel menu can offer: per-kind gated capabilities (minus
|
||||
* `search`, a header control) plus the chrome actions every kind gets. The
|
||||
* `Record<PanelActionId, …>` below forces a meta entry per id, so adding an
|
||||
* action without declaring its gates is a compile error.
|
||||
*/
|
||||
export type PanelActionId =
|
||||
| Exclude<keyof PanelActionCapabilities, 'search'>
|
||||
| 'move'
|
||||
| 'delete';
|
||||
|
||||
export interface PanelActionMeta {
|
||||
/**
|
||||
* Role gate: componentPermission key checked against the current user.
|
||||
* Absent = available to every role (V1 parity: view, download and
|
||||
* create-alerts were never role-gated).
|
||||
*/
|
||||
permission?: ComponentTypes;
|
||||
/**
|
||||
* Kind gate: the PanelActionCapabilities flag this action requires.
|
||||
* Chrome actions (move/clone/delete) are layout concerns available for
|
||||
* every panel kind — including kinds V2 can't render — so they declare none.
|
||||
*/
|
||||
capability?: keyof PanelActionCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for how each panel action is gated, mirroring V1's
|
||||
* WidgetHeader rules. The third gate — context (editable, target sections) — is
|
||||
* runtime state resolved in `usePanelActionItems`, not declarable here.
|
||||
*/
|
||||
export const PANEL_ACTION_META: Record<PanelActionId, PanelActionMeta> = {
|
||||
view: { capability: 'view' },
|
||||
edit: { permission: 'edit_widget', capability: 'edit' },
|
||||
clone: { permission: 'edit_widget' },
|
||||
download: { capability: 'download' },
|
||||
createAlert: { capability: 'createAlert' },
|
||||
// Moving a panel between sections mutates the dashboard layout.
|
||||
move: { permission: 'edit_dashboard' },
|
||||
delete: { permission: 'delete_widget' },
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
CloudDownload,
|
||||
Copy,
|
||||
FolderInput,
|
||||
Fullscreen,
|
||||
PenLine,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import {
|
||||
type ConfirmableAction,
|
||||
useConfirmableAction,
|
||||
} from 'hooks/useConfirmableAction';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import { useClonePanel } from '../hooks/useClonePanel';
|
||||
import { useDeletePanel } from '../hooks/useDeletePanel';
|
||||
import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
|
||||
import { PANEL_ACTION_META } from './panelActionMeta';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
// hooks' deps (a fresh [] each render would re-create their callbacks).
|
||||
const EMPTY_SECTIONS: DashboardSection[] = [];
|
||||
|
||||
/** Placeholder for V1-parity actions whose V2 implementations land later. */
|
||||
function notImplementedYet(feature: string): void {
|
||||
// eslint-disable-next-line no-alert -- temporary placeholder, see above
|
||||
alert(`${feature} option clicked`);
|
||||
}
|
||||
|
||||
interface UsePanelActionItemsArgs {
|
||||
panelId: string;
|
||||
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); */
|
||||
panelKind: PanelKind;
|
||||
/** Layout context for move/delete — absent outside editable mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
export interface PanelActionItems {
|
||||
items: MenuItem[];
|
||||
/** Two-step confirm flow for the destructive Delete action. */
|
||||
deleteConfirm: ConfirmableAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the panel actions menu items (V1 WidgetHeader set plus V2's "Move to
|
||||
* section"). Every action passes three gates before it appears:
|
||||
*
|
||||
* kind — what the panel kind declares it supports (PanelDefinition.actions);
|
||||
* unknown kinds support no kind-gated actions.
|
||||
* role — componentPermission lookup for the current user (PANEL_ACTION_META;
|
||||
* actions without a permission key are open to every role, V1 parity).
|
||||
* context — runtime state: dashboard editable (store), layout config present.
|
||||
* View and Download remain available on read-only dashboards, as in V1.
|
||||
*/
|
||||
export function usePanelActionItems({
|
||||
panelId,
|
||||
panelKind,
|
||||
panelActions,
|
||||
}: UsePanelActionItemsArgs): PanelActionItems {
|
||||
const { user } = useAppContext();
|
||||
const [canEditWidget, canMove, canDelete] = useComponentPermission(
|
||||
[
|
||||
// edit_widget gates both Edit and Clone, exactly as in V1.
|
||||
PANEL_ACTION_META.edit.permission ?? 'edit_widget',
|
||||
PANEL_ACTION_META.move.permission ?? 'edit_dashboard',
|
||||
PANEL_ACTION_META.delete.permission ?? 'delete_widget',
|
||||
],
|
||||
user.role,
|
||||
);
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
|
||||
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
|
||||
// supplies data (`sections`), so no callbacks are threaded through it.
|
||||
const sections = panelActions?.sections ?? EMPTY_SECTIONS;
|
||||
const movePanel = useMovePanelToSection({ sections });
|
||||
const deletePanel = useDeletePanel({ sections });
|
||||
const clonePanel = useClonePanel({ sections });
|
||||
|
||||
const kindActions = getPanelDefinition(panelKind)?.actions;
|
||||
|
||||
// Delete runs on confirm, not on click — the menu item opens a prompt.
|
||||
const deleteConfirm = useConfirmableAction(
|
||||
useCallback(async (): Promise<void> => {
|
||||
if (!panelActions) {
|
||||
return;
|
||||
}
|
||||
await deletePanel({
|
||||
panelId,
|
||||
layoutIndex: panelActions.currentLayoutIndex,
|
||||
});
|
||||
}, [deletePanel, panelActions, panelId]),
|
||||
);
|
||||
// Stable opener so the items memo doesn't rebuild on dialog state changes.
|
||||
const { request: requestDelete } = deleteConfirm;
|
||||
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const panelGroup: MenuItem[] = [];
|
||||
if (kindActions?.view) {
|
||||
panelGroup.push({
|
||||
key: 'view-panel',
|
||||
label: 'View',
|
||||
icon: <Fullscreen size={14} />,
|
||||
onClick: (): void => notImplementedYet('View'),
|
||||
});
|
||||
}
|
||||
if (isEditable && canEditWidget && kindActions?.edit) {
|
||||
panelGroup.push({
|
||||
key: 'edit-panel',
|
||||
label: 'Edit panel',
|
||||
icon: <PenLine size={14} />,
|
||||
onClick: (): void => openPanelEditor(panelId),
|
||||
});
|
||||
}
|
||||
// Clone needs the section context (source spec + dimensions) to place the
|
||||
// copy, so — unlike Edit — it requires panelActions.
|
||||
if (isEditable && canEditWidget && panelActions && kindActions?.clone) {
|
||||
panelGroup.push({
|
||||
key: 'clone-panel',
|
||||
label: 'Clone',
|
||||
icon: <Copy size={14} />,
|
||||
onClick: (): void =>
|
||||
void clonePanel({
|
||||
panelId,
|
||||
layoutIndex: panelActions.currentLayoutIndex,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const dataGroup: MenuItem[] = [];
|
||||
if (kindActions?.download) {
|
||||
dataGroup.push({
|
||||
key: 'download-panel',
|
||||
label: 'Download as CSV',
|
||||
icon: <CloudDownload size={14} />,
|
||||
onClick: (): void => notImplementedYet('Download'),
|
||||
});
|
||||
}
|
||||
if (isEditable && kindActions?.createAlert) {
|
||||
dataGroup.push({
|
||||
key: 'create-alert',
|
||||
label: 'Create Alerts',
|
||||
icon: <Bell size={14} />,
|
||||
onClick: (): void => notImplementedYet('Create Alerts'),
|
||||
});
|
||||
}
|
||||
|
||||
const moveGroup: MenuItem[] = [];
|
||||
if (canMove && panelActions) {
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== panelActions.currentLayoutIndex,
|
||||
);
|
||||
moveGroup.push({
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
...(targets.length === 0
|
||||
? { disabled: true }
|
||||
: {
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: panelActions.currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const deleteGroup: MenuItem[] =
|
||||
canDelete && panelActions
|
||||
? [
|
||||
{
|
||||
key: 'delete-panel',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: 'Delete panel',
|
||||
onClick: (): void => requestDelete(),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return [panelGroup, dataGroup, moveGroup, deleteGroup]
|
||||
.filter((group) => group.length > 0)
|
||||
.flatMap((group, index) =>
|
||||
index === 0 ? group : [{ type: 'divider' as const }, ...group],
|
||||
);
|
||||
}, [
|
||||
isEditable,
|
||||
canEditWidget,
|
||||
canMove,
|
||||
canDelete,
|
||||
kindActions,
|
||||
panelActions,
|
||||
sections,
|
||||
panelId,
|
||||
openPanelEditor,
|
||||
movePanel,
|
||||
clonePanel,
|
||||
requestDelete,
|
||||
]);
|
||||
|
||||
return { items, deleteConfirm };
|
||||
}
|
||||
@@ -6,13 +6,17 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type {
|
||||
PanelPagination,
|
||||
PanelQueryData,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import { panelStatusFromError } from '../PanelStatus/utils';
|
||||
import styles from './PanelBody.module.scss';
|
||||
|
||||
interface PanelBodyProps {
|
||||
/** Resolved renderer for the panel kind — always present (`Panel` renders the
|
||||
* unsupported fallback itself when no renderer is registered). */
|
||||
* unsupported fallback itself when none is registered). */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
@@ -21,18 +25,24 @@ interface PanelBodyProps {
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
dashboardPreference: DashboardPreference;
|
||||
/** Dashboard-wide preferences (cursor sync, …); absent in the editor preview. */
|
||||
dashboardPreference?: DashboardPreference;
|
||||
/** Render context — defaults to the dashboard view; the editor preview passes EDIT. */
|
||||
panelMode?: PanelMode;
|
||||
/** Header search term — only consumed by kinds that declare header search. */
|
||||
searchTerm?: string;
|
||||
/** Server-side paging handles — only consumed by raw/list renderers. */
|
||||
pagination?: PanelPagination;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the content of a panel whose kind has a registered renderer, as an
|
||||
* explicit state machine so each state is handled deliberately (no implicit
|
||||
* fall-through):
|
||||
* Renders a panel whose kind has a registered renderer, as an explicit state
|
||||
* machine:
|
||||
*
|
||||
* error + no data → error message with retry
|
||||
* first load (no data) → loading indicator
|
||||
* otherwise → the kind's renderer (which owns its own "No Data" state, and
|
||||
* keeps stale data mounted during background refetches)
|
||||
* otherwise → the kind's renderer (owns its own "No Data" state and keeps
|
||||
* stale data mounted during background refetches)
|
||||
*/
|
||||
function PanelBody({
|
||||
panelDefinition,
|
||||
@@ -44,19 +54,24 @@ function PanelBody({
|
||||
refetch,
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
panelMode = PanelMode.DASHBOARD_VIEW,
|
||||
searchTerm,
|
||||
pagination,
|
||||
}: PanelBodyProps): JSX.Element {
|
||||
// Surface a hard failure only when there's no (stale) data to show; otherwise
|
||||
// keep the last-good chart and let the header indicate the refresh.
|
||||
// react-query keeps the previous response during background refetches, so
|
||||
// `data.response` presence is the "have something to show" signal.
|
||||
// `data.response` presence is the "have something to show" signal — surface a
|
||||
// hard failure only when there's nothing to keep on screen.
|
||||
const hasData = !!data.response;
|
||||
|
||||
if (error && !hasData) {
|
||||
// Parse the API error like the header popover does, so the body shows the
|
||||
// backend message (not the raw axios "status code 4xx").
|
||||
const errorDetail = panelStatusFromError(error);
|
||||
return (
|
||||
<div className={styles.error} data-testid="panel-error">
|
||||
<TriangleAlert size={20} className={styles.errorIcon} />
|
||||
<Typography.Text className={styles.errorMessage}>
|
||||
{error.message || 'Failed to load panel data'}
|
||||
{errorDetail?.message || 'Failed to load panel data'}
|
||||
</Typography.Text>
|
||||
<Button variant="outlined" color="secondary" onClick={refetch}>
|
||||
Retry
|
||||
@@ -65,9 +80,9 @@ function PanelBody({
|
||||
);
|
||||
}
|
||||
|
||||
// First load only — background refetches keep the response populated so the
|
||||
// chart stays mounted instead of blinking.
|
||||
if (isLoading && !hasData) {
|
||||
// First load only — refetches keep the response populated so the chart stays
|
||||
// mounted instead of blinking.
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.body} data-testid="panel-loading">
|
||||
<Spin indicator={<Loader size={14} className="animate-spin" />} />
|
||||
@@ -84,9 +99,11 @@ function PanelBody({
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
panelMode={panelMode}
|
||||
enableDrillDown={false}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchTerm}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,10 +7,8 @@ interface UnsupportedPanelBodyProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Body shown when no renderer is registered for the panel's kind. Split out from
|
||||
* `PanelBody` so that `PanelBody` only ever runs with a resolved renderer — the
|
||||
* "kind not yet supported" path is handled here, before any data fetching is
|
||||
* surfaced.
|
||||
* Body shown when no renderer is registered for the panel's kind. Split out so
|
||||
* `PanelBody` only ever runs with a resolved renderer.
|
||||
*/
|
||||
function UnsupportedPanelBody({
|
||||
kind,
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -39,3 +39,17 @@
|
||||
color: var(--l2-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Per-panel time-preference pill (e.g. `6h`), shown when the panel overrides
|
||||
// the dashboard time window.
|
||||
.timePill {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--l3-foreground);
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l3-border);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -1,39 +1,58 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Loader } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import type { Warning } from 'types/api';
|
||||
import type { PanelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import PanelActionsMenu from '../PanelActionsMenu/PanelActionsMenu';
|
||||
import PanelHeaderSearch from './PanelHeaderSearch';
|
||||
import PanelStatusPopover from '../PanelStatus/PanelStatusPopover';
|
||||
import {
|
||||
panelStatusFromError,
|
||||
panelStatusFromWarning,
|
||||
} from '../PanelStatus/utils';
|
||||
import styles from './PanelHeader.module.scss';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
interface PanelHeaderProps {
|
||||
title: ReactNode;
|
||||
panelId: string;
|
||||
/** Background refresh in flight — shows a subtle spinner without blinking the chart. */
|
||||
/** Full plugin kind — drives kind-gated menu actions; */
|
||||
panelKind: PanelKind;
|
||||
/** Background refresh in flight — shows a spinner without blinking the chart. */
|
||||
isFetching: boolean;
|
||||
/** Latest query error, if any — surfaced as a header error indicator. */
|
||||
/** Latest query error — surfaced as a header error indicator. */
|
||||
error?: Error | null;
|
||||
/** Non-fatal query warning lifted from the response payload. */
|
||||
warning?: Warning;
|
||||
/** Move/delete actions — present only in editable sectioned mode. */
|
||||
warning?: WarningDTO;
|
||||
/** Per-panel time-preference label; null when it follows the dashboard window. */
|
||||
timeLabel?: PanelTimePreferenceLabel | null;
|
||||
/** Layout context for move/delete — absent outside editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
/** Kind declares header search — renders the box. */
|
||||
searchable?: boolean;
|
||||
/** Current search term; shell owns it, the renderer applies the filter. */
|
||||
searchTerm?: string;
|
||||
/** Pushes a new search term up to the shell. */
|
||||
onSearchChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
|
||||
function PanelHeader({
|
||||
title,
|
||||
panelId,
|
||||
panelKind,
|
||||
isFetching,
|
||||
error,
|
||||
warning,
|
||||
timeLabel,
|
||||
panelActions,
|
||||
searchable,
|
||||
searchTerm = '',
|
||||
onSearchChange,
|
||||
}: PanelHeaderProps): JSX.Element {
|
||||
const errorDetail = useMemo(() => panelStatusFromError(error), [error]);
|
||||
|
||||
@@ -57,19 +76,26 @@ function PanelHeader({
|
||||
{/* `panel-no-drag` opts this region out of the grid drag handle so the
|
||||
actions menu is clickable instead of starting a panel drag. */}
|
||||
<div className={cx('panel-no-drag', styles.actions)}>
|
||||
{searchable && onSearchChange && (
|
||||
<PanelHeaderSearch value={searchTerm ?? ''} onChange={onSearchChange} />
|
||||
)}
|
||||
{timeLabel && (
|
||||
<TooltipSimple title={timeLabel.full} arrow>
|
||||
<span className={styles.timePill} data-testid="panel-time-preference">
|
||||
{timeLabel.short}
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
{errorDetail && <PanelStatusPopover variant="error" detail={errorDetail} />}
|
||||
{warningDetail && (
|
||||
<PanelStatusPopover variant="warning" detail={warningDetail} />
|
||||
)}
|
||||
{panelActions && (
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
currentLayoutIndex={panelActions.currentLayoutIndex}
|
||||
sections={panelActions.sections}
|
||||
onMovePanel={panelActions.onMovePanel}
|
||||
onDeletePanel={panelActions.onDeletePanel}
|
||||
/>
|
||||
)}
|
||||
{/* Renders nothing when no action survives its gates (kind/role/context). */}
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
panelKind={panelKind}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Expanded state: a compact input that fits the header row.
|
||||
.input {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.clear {
|
||||
--button-height: 18px;
|
||||
--button-padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Search, X } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './PanelHeaderSearch.module.scss';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
interface PanelHeaderSearchProps {
|
||||
/** Current filter term, owned by the panel shell. */
|
||||
value: string;
|
||||
/** Pushes the new term up; the renderer applies the filter. */
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible header search (V1 parity): an icon that expands into an input and
|
||||
* collapses once empty and blurred. Owns only its chrome, never the term.
|
||||
*/
|
||||
function PanelHeaderSearch({
|
||||
value,
|
||||
onChange,
|
||||
}: PanelHeaderSearchProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const collapseIfEmpty = (): void => {
|
||||
if (!value) {
|
||||
setExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clear = (): void => {
|
||||
onChange('');
|
||||
setExpanded(false);
|
||||
};
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<TooltipSimple title="Search" arrow>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => setExpanded(true)}
|
||||
data-testid="panel-header-search-trigger"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
autoFocus
|
||||
size={14}
|
||||
value={value}
|
||||
placeholder="Search…"
|
||||
containerClassName={styles.input}
|
||||
testId="panel-header-search-input"
|
||||
prefix={<Search size={14} />}
|
||||
suffix={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.clear}
|
||||
onClick={clear}
|
||||
data-testid="panel-header-search-clear"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
onChange(e.target.value)
|
||||
}
|
||||
onBlur={collapseIfEmpty}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Escape') {
|
||||
clear();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelHeaderSearch;
|
||||
@@ -1,41 +1,71 @@
|
||||
import { BookOpenText } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { BookOpenText, CircleX, TriangleAlert } from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import type { PanelStatusDetail } from './types';
|
||||
import type { PanelStatusDetail, PanelStatusVariant } from './types';
|
||||
import styles from './PanelStatusPopover.module.scss';
|
||||
|
||||
interface PanelStatusContentProps {
|
||||
variant: PanelStatusVariant;
|
||||
detail: PanelStatusDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover body for a panel status (error or warning): a code + summary header
|
||||
* with an optional docs link, followed by any per-item messages. Pure
|
||||
* presentation — the variant's icon/colour is owned by `PanelStatusPopover`.
|
||||
*/
|
||||
function PanelStatusContent({ detail }: PanelStatusContentProps): JSX.Element {
|
||||
const VARIANT_ICON = {
|
||||
error: { Icon: CircleX, color: Color.BG_CHERRY_500 },
|
||||
warning: { Icon: TriangleAlert, color: Color.BG_AMBER_500 },
|
||||
};
|
||||
|
||||
/** Popover card for a panel status (error or warning). Pure presentation. */
|
||||
function PanelStatusContent({
|
||||
variant,
|
||||
detail,
|
||||
}: PanelStatusContentProps): JSX.Element {
|
||||
const { code, message, docsUrl, messages } = detail;
|
||||
const { Icon, color } = VARIANT_ICON[variant];
|
||||
|
||||
return (
|
||||
<section className={styles.content} data-testid="panel-status-content">
|
||||
<header className={styles.summary}>
|
||||
<div className={styles.summaryText}>
|
||||
<h2 className={styles.code}>{code}</h2>
|
||||
<p className={styles.message}>{message}</p>
|
||||
<div className={styles.summaryLeft}>
|
||||
<span className={styles.iconWrapper}>
|
||||
<Icon size={16} color={color} />
|
||||
</span>
|
||||
<div className={styles.summaryText}>
|
||||
{code && <h2 className={styles.code}>{code}</h2>}
|
||||
<p className={styles.message}>{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
{docsUrl && (
|
||||
<Typography.Link
|
||||
className={styles.docsLink}
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-testid="panel-status-docs"
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<BookOpenText size={14} />}
|
||||
>
|
||||
<BookOpenText size={14} />
|
||||
<span>Open Docs</span>
|
||||
</Typography.Link>
|
||||
<a
|
||||
href={docsUrl}
|
||||
className={styles.docsLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-testid="panel-status-docs"
|
||||
>
|
||||
Open Docs
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{messages.length > 0 && (
|
||||
<div className={styles.messageBadge}>
|
||||
<span className={styles.badge}>
|
||||
<span className={styles.badgeDot} />
|
||||
<span className={styles.badgeText}>MESSAGES</span>
|
||||
<span className={styles.badgeCount}>{messages.length}</span>
|
||||
</span>
|
||||
<span className={styles.badgeLine} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.length > 0 && (
|
||||
<ul className={styles.messageList}>
|
||||
{messages.map((m) => (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -5,61 +7,150 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Strip the tooltip's own padding/width cap so the card content (which owns its
|
||||
// 16px section padding) frames cleanly — a padding-less surface, like the
|
||||
// shared WarningPopover, restyled with V2 tokens.
|
||||
.tooltipContent {
|
||||
max-width: 520px !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 600px;
|
||||
padding: 12px;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
// === Summary header: icon + code/message, optional docs button ===
|
||||
.summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.summaryLeft {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.code {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--l2-foreground);
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
margin: 0;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.docsLink {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
// === MESSAGES count pill + dotted rule ===
|
||||
.messageBadge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.badgeDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-sakura-500);
|
||||
}
|
||||
|
||||
.badgeText {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badgeCount {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badgeLine {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
var(--l3-background) 1px,
|
||||
transparent 2px
|
||||
);
|
||||
background-size: 8px 11px;
|
||||
}
|
||||
|
||||
// === Per-item messages ===
|
||||
.messageList {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
max-height: 240px;
|
||||
padding: 0 16px 16px;
|
||||
list-style: none;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.messageItem {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
line-height: 18px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.messageItem::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 7px;
|
||||
width: 2px;
|
||||
height: 4px;
|
||||
border-radius: 50px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
@@ -20,9 +20,8 @@ interface PanelStatusPopoverProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Header status indicator: a variant-coloured icon (error → CircleX,
|
||||
* warning → TriangleAlert) that opens a tooltip with the status detail. One
|
||||
* component drives both variants so error and warning surfacing stay in lockstep.
|
||||
* Header status indicator: an icon that opens a tooltip with the status detail.
|
||||
* One component drives both variants so error and warning stay in lockstep.
|
||||
*/
|
||||
function PanelStatusPopover({
|
||||
variant,
|
||||
@@ -32,9 +31,13 @@ function PanelStatusPopover({
|
||||
const Icon = variant === 'error' ? CircleX : TriangleAlert;
|
||||
|
||||
return (
|
||||
<TooltipSimple title={<PanelStatusContent detail={detail} />} arrow>
|
||||
{/* Wrapping span gives a ref-able, hoverable trigger (icon
|
||||
components don't forward refs) and a stable testid anchor. */}
|
||||
<TooltipSimple
|
||||
title={<PanelStatusContent variant={variant} detail={detail} />}
|
||||
side="top"
|
||||
align="end"
|
||||
arrow
|
||||
tooltipContentProps={{ className: styles.tooltipContent }}
|
||||
>
|
||||
<span
|
||||
className={styles.trigger}
|
||||
aria-label={ariaLabel}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import type { Warning } from 'types/api';
|
||||
|
||||
import { panelStatusFromError, panelStatusFromWarning } from '../utils';
|
||||
|
||||
@@ -61,16 +61,14 @@ describe('panelStatusFromWarning', () => {
|
||||
expect(panelStatusFromWarning(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps a warning to the normalized status shape', () => {
|
||||
const warning: Warning = {
|
||||
code: 'partial_data',
|
||||
it('maps a warning to the normalized status shape (no code — V5 warnings carry none)', () => {
|
||||
const warning: WarningDTO = {
|
||||
message: 'Some series were dropped',
|
||||
url: 'https://docs/warn',
|
||||
warnings: [{ message: 'series A truncated' }],
|
||||
};
|
||||
|
||||
expect(panelStatusFromWarning(warning)).toStrictEqual({
|
||||
code: 'partial_data',
|
||||
message: 'Some series were dropped',
|
||||
docsUrl: 'https://docs/warn',
|
||||
messages: ['series A truncated'],
|
||||
|
||||
@@ -3,13 +3,12 @@ export type PanelStatusVariant = 'error' | 'warning';
|
||||
|
||||
/**
|
||||
* Normalized status shape that both an API error and a query warning adapt into,
|
||||
* so a single popover can render either. Mirrors the fields the backend supplies
|
||||
* on its `ErrorV2` / `Warning` envelopes (code + summary + optional docs link +
|
||||
* per-item messages).
|
||||
* so a single popover can render either. Mirrors the backend `ErrorV2`/`Warning`
|
||||
* envelope fields (code + summary + optional docs link + per-item messages).
|
||||
*/
|
||||
export interface PanelStatusDetail {
|
||||
/** Short status code (e.g. an error/warning code) shown as the heading. */
|
||||
code: string;
|
||||
/** Status code shown as the heading. Only present in error cases. */
|
||||
code?: string;
|
||||
/** Human-readable summary line. */
|
||||
message: string;
|
||||
/** Optional docs link; renders an "Open Docs" action when present. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { Warning } from 'types/api';
|
||||
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelStatusDetail } from './types';
|
||||
|
||||
@@ -9,12 +9,9 @@ import type { PanelStatusDetail } from './types';
|
||||
* Adapts a query failure into the normalized status shape.
|
||||
*
|
||||
* The generated `queryRangeV5` client's reject interceptor passes the raw
|
||||
* `AxiosError` through untouched — it is NOT pre-converted to `APIError` — so
|
||||
* the error arriving here is an axios error. `convertToApiError` is the
|
||||
* app-standard normalizer for generated-API axios errors: it pulls the backend
|
||||
* `code / message / url / errors` envelope off the response and supplies
|
||||
* sensible fallbacks for anything missing, so there's always a structured
|
||||
* detail to surface.
|
||||
* `AxiosError` through untouched (NOT pre-converted to `APIError`), so
|
||||
* `convertToApiError` is needed here to pull the backend `code/message/url/
|
||||
* errors` envelope off the response (with fallbacks) into a structured detail.
|
||||
*/
|
||||
export function panelStatusFromError(
|
||||
error: Error | null | undefined,
|
||||
@@ -41,16 +38,17 @@ export function panelStatusFromError(
|
||||
|
||||
/** Adapts a query warning into the normalized status shape. */
|
||||
export function panelStatusFromWarning(
|
||||
warning: Warning | null | undefined,
|
||||
warning: WarningDTO | undefined,
|
||||
): PanelStatusDetail | null {
|
||||
if (!warning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
code: warning.code,
|
||||
message: warning.message,
|
||||
message: warning.message || 'Warning',
|
||||
docsUrl: warning.url || undefined,
|
||||
messages: (warning.warnings ?? []).map((w) => w.message),
|
||||
messages: (warning.warnings ?? [])
|
||||
.map((w) => w.message)
|
||||
.filter((message): message is string => Boolean(message)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { Warning } from 'types/api';
|
||||
|
||||
import PanelHeader from '../PanelHeader/PanelHeader';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
// PanelHeader's status indicators render a radix tooltip, which needs a
|
||||
// TooltipProvider ancestor (supplied globally by AppLayout at runtime).
|
||||
const renderWithProvider = (ui: ReactElement): ReturnType<typeof render> =>
|
||||
render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
|
||||
// The actions menu has its own gating logic (kind/role/context) and its own
|
||||
// tests; stub it so this test exercises only the header's status indicators.
|
||||
jest.mock(
|
||||
'../PanelActionsMenu/PanelActionsMenu',
|
||||
() =>
|
||||
function MockPanelActionsMenu(): null {
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
const baseProps = {
|
||||
title: 'My panel',
|
||||
kind: 'TimeSeries',
|
||||
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
|
||||
panelId: 'panel-1',
|
||||
isFetching: false,
|
||||
};
|
||||
@@ -41,3 +53,69 @@ describe('PanelHeader status indicators', () => {
|
||||
expect(screen.queryByTestId('panel-status-warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PanelHeader search', () => {
|
||||
it('renders no search affordance when the panel is not searchable', () => {
|
||||
renderWithProvider(<PanelHeader {...baseProps} />);
|
||||
expect(
|
||||
screen.queryByTestId('panel-header-search-trigger'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands the collapsed trigger into an input and reports changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = jest.fn();
|
||||
renderWithProvider(
|
||||
<PanelHeader
|
||||
{...baseProps}
|
||||
searchable
|
||||
searchTerm=""
|
||||
onSearchChange={onSearchChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-header-search-trigger'));
|
||||
|
||||
// The input is controlled to the (fixed) `searchTerm` here, so each keystroke
|
||||
// reports a single character — assert one to confirm changes are propagated.
|
||||
const input = screen.getByTestId('panel-header-search-input');
|
||||
await user.type(input, 'f');
|
||||
expect(onSearchChange).toHaveBeenCalledWith('f');
|
||||
});
|
||||
|
||||
it('clears the term and collapses when the clear button is pressed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = jest.fn();
|
||||
renderWithProvider(
|
||||
<PanelHeader
|
||||
{...baseProps}
|
||||
searchable
|
||||
searchTerm="frontend"
|
||||
onSearchChange={onSearchChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-header-search-trigger'));
|
||||
await user.click(screen.getByTestId('panel-header-search-clear'));
|
||||
|
||||
expect(onSearchChange).toHaveBeenCalledWith('');
|
||||
expect(screen.getByTestId('panel-header-search-trigger')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PanelHeader time-preference pill', () => {
|
||||
it('shows the pill with the short label when the panel overrides the dashboard time', () => {
|
||||
renderWithProvider(
|
||||
<PanelHeader
|
||||
{...baseProps}
|
||||
timeLabel={{ short: '6h', full: 'Last 6 hr' }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('panel-time-preference')).toHaveTextContent('6h');
|
||||
});
|
||||
|
||||
it('renders no pill when the panel follows the dashboard time', () => {
|
||||
renderWithProvider(<PanelHeader {...baseProps} timeLabel={null} />);
|
||||
expect(screen.queryByTestId('panel-time-preference')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { useClonePanel } from '../useClonePanel';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
patchDashboardV2: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const mockToastPromise = jest.fn();
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { promise: (...args: unknown[]): unknown => mockToastPromise(...args) },
|
||||
}));
|
||||
|
||||
jest.mock('uuid', () => ({ v4: (): string => 'cloned-id' }));
|
||||
|
||||
const mockPatch = patchDashboardV2 as unknown as jest.Mock;
|
||||
|
||||
const sourcePanel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardSection['items'][number]['panel'];
|
||||
|
||||
function sections(): DashboardSection[] {
|
||||
return [
|
||||
{
|
||||
id: 'section-0',
|
||||
layoutIndex: 0,
|
||||
title: 'Overview',
|
||||
repeatVariable: undefined,
|
||||
items: [
|
||||
{ id: 'p1', x: 0, y: 0, width: 8, height: 5, panel: sourcePanel },
|
||||
{ id: 'p2', x: 8, y: 0, width: 4, height: 5, panel: sourcePanel },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe('useClonePanel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useDashboardStore.setState({ dashboardId: 'dash-1', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('patches an add of the deep-copied spec + a new item under the same section', async () => {
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith({ id: 'dash-1' }, [
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/panels/cloned-id',
|
||||
value: sourcePanel,
|
||||
},
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/layouts/0/spec/items/-',
|
||||
value: {
|
||||
// Same dimensions as the source panel (p1: 8x5).
|
||||
x: 0,
|
||||
// Bottom of the section: max(y + height) over existing items = 5.
|
||||
y: 5,
|
||||
width: 8,
|
||||
height: 5,
|
||||
content: { $ref: '#/spec/panels/cloned-id' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('deep-copies the spec — the cloned value is not the same object reference', async () => {
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
const ops = mockPatch.mock.calls[0][1];
|
||||
expect(ops[0].value).toStrictEqual(sourcePanel);
|
||||
expect(ops[0].value).not.toBe(sourcePanel);
|
||||
});
|
||||
|
||||
it('no-ops when the panel is not found in the section', async () => {
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
await result.current({ panelId: 'missing', layoutIndex: 0 });
|
||||
|
||||
expect(mockPatch).not.toHaveBeenCalled();
|
||||
expect(mockToastPromise).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports in-flight → done/failed state via toast.promise', async () => {
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
expect(mockToastPromise).toHaveBeenCalledWith(
|
||||
expect.any(Promise),
|
||||
expect.objectContaining({
|
||||
loading: 'Cloning panel…',
|
||||
success: 'Panel cloned',
|
||||
error: 'Failed to clone panel',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows a patch rejection (toast owns the error UX) — does not throw', async () => {
|
||||
mockPatch.mockRejectedValueOnce(new Error('boom'));
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
await expect(
|
||||
result.current({ panelId: 'p1', layoutIndex: 0 }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockToastPromise).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
|
||||
import { addPanelToSectionOps, panelRef } from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSection[];
|
||||
}
|
||||
|
||||
export interface ClonePanelArgs {
|
||||
panelId: string;
|
||||
layoutIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates a panel: deep-copies the source spec under a fresh id and drops a
|
||||
* same-size grid item at the bottom of the section, as one atomic patch. Mirrors
|
||||
* V1's clone (verbatim spec copy, no rename).
|
||||
*/
|
||||
export function useClonePanel({
|
||||
sections,
|
||||
}: Params): (args: ClonePanelArgs) => Promise<void> {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
|
||||
return useCallback(
|
||||
async ({ panelId, layoutIndex }: ClonePanelArgs): Promise<void> => {
|
||||
const section = sections.find((s) => s.layoutIndex === layoutIndex);
|
||||
const source = section?.items.find((i) => i.id === panelId);
|
||||
if (!dashboardId || !section || !source?.panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newPanelId = uuid();
|
||||
const nextY = section.items.reduce(
|
||||
(max, i) => Math.max(max, i.y + i.height),
|
||||
0,
|
||||
);
|
||||
|
||||
const clone = patchDashboardV2(
|
||||
{ id: dashboardId },
|
||||
addPanelToSectionOps({
|
||||
panelId: newPanelId,
|
||||
panel: cloneDeep(source.panel),
|
||||
layoutIndex,
|
||||
item: {
|
||||
x: 0,
|
||||
y: nextY,
|
||||
width: source.width,
|
||||
height: source.height,
|
||||
content: { $ref: panelRef(newPanelId) },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// toast.promise reports the failure, so no separate error modal here.
|
||||
toast.promise(clone, {
|
||||
loading: 'Cloning panel…',
|
||||
success: 'Panel cloned',
|
||||
error: 'Failed to clone panel',
|
||||
position: 'top-center',
|
||||
});
|
||||
|
||||
// Refetch only on success; swallow the rejection (toast owns the error
|
||||
// UX) to avoid an unhandled rejection.
|
||||
try {
|
||||
await clone;
|
||||
refetch();
|
||||
} catch {
|
||||
// no-op — toast.promise owns the error UX.
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, refetch],
|
||||
);
|
||||
}
|
||||
@@ -12,23 +12,15 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
|
||||
export interface PanelInteractions {
|
||||
/**
|
||||
* Drag-select a time range on a chart → write the window to the URL + global
|
||||
* time so every panel re-fetches against the same range.
|
||||
*/
|
||||
/** Drag-select a chart range → write it to the URL + global time so every panel re-fetches the same range. */
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
/**
|
||||
* Dashboard-wide rendering preferences (cursor sync, tooltip filter) keyed
|
||||
* off the dashboard id from the route.
|
||||
*/
|
||||
/** Dashboard-wide rendering preferences (cursor sync, tooltip filter). */
|
||||
dashboardPreference: DashboardPreference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates the cross-panel interactions shared by every dashboard-view
|
||||
* panel: drag-to-zoom time selection and the cursor-sync / tooltip-filter
|
||||
* preferences. Keeping this out of the `Panel` component keeps the component a
|
||||
* thin render orchestrator and lets the wiring be unit-tested in isolation.
|
||||
* Cross-panel interactions shared by every dashboard-view panel: drag-to-zoom
|
||||
* time selection and the cursor-sync / tooltip-filter preferences.
|
||||
*/
|
||||
export function usePanelInteractions(): PanelInteractions {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -8,8 +8,6 @@ import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/pan
|
||||
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection';
|
||||
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
|
||||
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
|
||||
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { useDeleteSection } from '../hooks/useDeleteSection';
|
||||
@@ -26,10 +24,8 @@ interface SectionProps {
|
||||
section: DashboardSection;
|
||||
/** Adds a panel to this section; present only in editable sectioned mode. */
|
||||
onAddPanel?: (args: AddPanelArgs) => void;
|
||||
/** All sections + per-panel handlers, for the panel "Move to section" / delete actions. */
|
||||
/** All sections — layout context for the panel menu's move/delete actions. */
|
||||
sections?: DashboardSection[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
|
||||
dragHandle?: SectionDragHandle;
|
||||
}
|
||||
@@ -38,8 +34,6 @@ function Section({
|
||||
section,
|
||||
onAddPanel,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
dragHandle,
|
||||
}: SectionProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
@@ -92,8 +86,6 @@ function Section({
|
||||
layoutIndex={section.layoutIndex}
|
||||
isVisible={isVisible}
|
||||
sections={sections}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useMemo } from 'react';
|
||||
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
|
||||
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
|
||||
import Panel from '../../Panel/Panel';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { usePersistLayout } from '../hooks/usePersistLayout';
|
||||
@@ -16,10 +14,8 @@ interface SectionGridProps {
|
||||
layoutIndex: number;
|
||||
/** Forwarded to panels — true when the parent section is in the viewport. */
|
||||
isVisible?: boolean;
|
||||
/** All sections + handlers — present only in editable sectioned mode (panel "Move to section" / delete). */
|
||||
/** All sections — layout context for the panel menu's move/delete actions. */
|
||||
sections?: DashboardSection[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function SectionGrid({
|
||||
@@ -27,8 +23,6 @@ function SectionGrid({
|
||||
layoutIndex,
|
||||
isVisible,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: SectionGridProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const rglLayout = useMemo<Layout[]>(
|
||||
@@ -62,6 +56,9 @@ function SectionGrid({
|
||||
margin={[8, 8]}
|
||||
>
|
||||
{items.map((item) => (
|
||||
// A layout item can reference a panel id that no longer exists in the
|
||||
// panels map (orphan); render an empty grid cell for it rather than a
|
||||
// panel with no content.
|
||||
<div key={item.id}>
|
||||
{item.panel && (
|
||||
<Panel
|
||||
@@ -69,12 +66,10 @@ function SectionGrid({
|
||||
panelId={item.id}
|
||||
isVisible={isVisible}
|
||||
panelActions={
|
||||
isEditable && onMovePanel && onDeletePanel
|
||||
isEditable
|
||||
? {
|
||||
currentLayoutIndex: layoutIndex,
|
||||
sections: sections ?? [],
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.sche
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import { useAddPanelToSection } from '../Panel/hooks/useAddPanelToSection';
|
||||
import { useDeletePanel } from '../Panel/hooks/useDeletePanel';
|
||||
import { useMovePanelToSection } from '../Panel/hooks/useMovePanelToSection';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
|
||||
import Section from './Section/Section';
|
||||
@@ -38,8 +36,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
} = useSectionDragReorder({ sections, layouts });
|
||||
|
||||
const onAddPanel = useAddPanelToSection({ sections });
|
||||
const onMovePanel = useMovePanelToSection({ sections });
|
||||
const onDeletePanel = useDeletePanel({ sections });
|
||||
|
||||
// Only titled sections participate in reordering; untitled (free-flow)
|
||||
// blocks render in place without a drag handle.
|
||||
@@ -75,8 +71,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
section={section}
|
||||
sections={sections}
|
||||
onAddPanel={onAddPanel}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
) : (
|
||||
<Section
|
||||
@@ -84,8 +78,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
section={section}
|
||||
sections={sections}
|
||||
onAddPanel={onAddPanel}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -3,24 +3,18 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import type { AddPanelArgs } from '../Panel/hooks/useAddPanelToSection';
|
||||
import type { DeletePanelArgs } from '../Panel/hooks/useDeletePanel';
|
||||
import type { MovePanelArgs } from '../Panel/hooks/useMovePanelToSection';
|
||||
import Section from './Section/Section';
|
||||
|
||||
interface SortableSectionProps {
|
||||
section: DashboardSection;
|
||||
sections: DashboardSection[];
|
||||
onAddPanel: (args: AddPanelArgs) => void;
|
||||
onMovePanel: (args: MovePanelArgs) => void;
|
||||
onDeletePanel: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function SortableSection({
|
||||
section,
|
||||
sections,
|
||||
onAddPanel,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: SortableSectionProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
@@ -48,8 +42,6 @@ function SortableSection({
|
||||
section={section}
|
||||
sections={sections}
|
||||
onAddPanel={onAddPanel}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -48,8 +48,11 @@ function PanelsAndSectionsLayout({
|
||||
return <SectionList sections={sections} layouts={layouts} />;
|
||||
}
|
||||
|
||||
// Free-flow (no titled sections): panels still get the layout context so
|
||||
// the menu's delete action can patch the section's items (previously a
|
||||
// silent noop in this mode).
|
||||
return sections.map((section) => (
|
||||
<Section key={section.id} section={section} />
|
||||
<Section key={section.id} section={section} sections={sections} />
|
||||
));
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { resolvePanelTimeWindow } from '../resolvePanelTimeWindow';
|
||||
|
||||
const GLOBAL_START = 1_000_000;
|
||||
const GLOBAL_END = 5_000_000;
|
||||
|
||||
describe('resolvePanelTimeWindow', () => {
|
||||
it('uses the dashboard window when there is no preference', () => {
|
||||
expect(
|
||||
resolvePanelTimeWindow({
|
||||
timePreference: undefined,
|
||||
globalStartMs: GLOBAL_START,
|
||||
globalEndMs: GLOBAL_END,
|
||||
}),
|
||||
).toStrictEqual({ startMs: GLOBAL_START, endMs: GLOBAL_END });
|
||||
});
|
||||
|
||||
it('uses the dashboard window for global_time', () => {
|
||||
expect(
|
||||
resolvePanelTimeWindow({
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
globalStartMs: GLOBAL_START,
|
||||
globalEndMs: GLOBAL_END,
|
||||
}),
|
||||
).toStrictEqual({ startMs: GLOBAL_START, endMs: GLOBAL_END });
|
||||
});
|
||||
|
||||
it('anchors a relative preset to the dashboard end', () => {
|
||||
expect(
|
||||
resolvePanelTimeWindow({
|
||||
timePreference: DashboardtypesTimePreferenceDTO.last_5_min,
|
||||
globalStartMs: GLOBAL_START,
|
||||
globalEndMs: GLOBAL_END,
|
||||
}),
|
||||
).toStrictEqual({ startMs: GLOBAL_END - 5 * 60 * 1000, endMs: GLOBAL_END });
|
||||
});
|
||||
|
||||
it('resolves the larger presets to the V1-equivalent spans', () => {
|
||||
const cases: [DashboardtypesTimePreferenceDTO, number][] = [
|
||||
[DashboardtypesTimePreferenceDTO.last_1_hr, 60],
|
||||
[DashboardtypesTimePreferenceDTO.last_1_day, 24 * 60],
|
||||
[DashboardtypesTimePreferenceDTO.last_1_week, 7 * 24 * 60],
|
||||
[DashboardtypesTimePreferenceDTO.last_1_month, 30 * 24 * 60],
|
||||
];
|
||||
cases.forEach(([pref, minutes]) => {
|
||||
expect(
|
||||
resolvePanelTimeWindow({
|
||||
timePreference: pref,
|
||||
globalStartMs: GLOBAL_START,
|
||||
globalEndMs: GLOBAL_END,
|
||||
}),
|
||||
).toStrictEqual({
|
||||
startMs: GLOBAL_END - minutes * 60 * 1000,
|
||||
endMs: GLOBAL_END,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('lets an explicit override win over the preference', () => {
|
||||
expect(
|
||||
resolvePanelTimeWindow({
|
||||
timePreference: DashboardtypesTimePreferenceDTO.last_5_min,
|
||||
globalStartMs: GLOBAL_START,
|
||||
globalEndMs: GLOBAL_END,
|
||||
override: { startMs: 42, endMs: 99 },
|
||||
}),
|
||||
).toStrictEqual({ startMs: 42, endMs: 99 });
|
||||
});
|
||||
|
||||
it('floors fractional milliseconds', () => {
|
||||
expect(
|
||||
resolvePanelTimeWindow({
|
||||
timePreference: undefined,
|
||||
globalStartMs: 1.9,
|
||||
globalEndMs: 9.9,
|
||||
}),
|
||||
).toStrictEqual({ startMs: 1, endMs: 9 });
|
||||
|
||||
expect(
|
||||
resolvePanelTimeWindow({
|
||||
timePreference: DashboardtypesTimePreferenceDTO.last_5_min,
|
||||
globalStartMs: 0,
|
||||
globalEndMs: 9.9,
|
||||
override: { startMs: 4.7, endMs: 8.2 },
|
||||
}),
|
||||
).toStrictEqual({ startMs: 4, endMs: 8 });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelQuery } from '../usePanelQuery';
|
||||
@@ -10,6 +10,14 @@ jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
// usePanelQuery reads the query client only to cancel in-flight fetches; the
|
||||
// fetch hook itself is mocked, so a stub client is enough.
|
||||
jest.mock('react-query', () => ({
|
||||
useQueryClient: (): { cancelQueries: jest.Mock } => ({
|
||||
cancelQueries: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../useGetQueryRangeV5', () => ({
|
||||
useGetQueryRangeV5: jest.fn(),
|
||||
}));
|
||||
@@ -164,7 +172,9 @@ describe('usePanelQuery', () => {
|
||||
expect(result.current.error?.message).toBe('boom');
|
||||
});
|
||||
|
||||
it('combines isLoading and isFetching into a single isLoading flag', () => {
|
||||
it('exposes isLoading (first fetch) and isFetching (any fetch) as distinct flags', () => {
|
||||
// A background refetch (data present elsewhere) is in flight: isFetching is
|
||||
// true but isLoading stays false so the panel keeps showing its data.
|
||||
mockUseGetQueryRangeV5.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
@@ -174,6 +184,20 @@ describe('usePanelQuery', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isFetching).toBe(true);
|
||||
});
|
||||
|
||||
it('reports isLoading on the first fetch (no cached data yet)', () => {
|
||||
mockUseGetQueryRangeV5.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
@@ -237,10 +261,175 @@ describe('usePanelQuery', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('builds an empty composite and disables the fetch when panel is undefined (no crash)', () => {
|
||||
renderHook(() => usePanelQuery({ panel: undefined, panelId: 'p-none' }));
|
||||
const [{ requestPayload, enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(requestPayload.compositeQuery.queries).toStrictEqual([]);
|
||||
expect(enabled).toBe(false);
|
||||
it('uses the time override (not redux) for the request window and cache key', () => {
|
||||
const panel = builderPanel();
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel,
|
||||
panelId: 'p1',
|
||||
time: { startMs: 1_700_000_000_000, endMs: 1_700_000_600_000 },
|
||||
}),
|
||||
);
|
||||
const [{ requestPayload, queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
// Window comes from the override, not the redux nanosecond time.
|
||||
expect(requestPayload.start).toBe(1_700_000_000_000);
|
||||
expect(requestPayload.end).toBe(1_700_000_600_000);
|
||||
// Cache key keys off the override so the preview refetches independently
|
||||
// of the dashboard and never collides with its redux-keyed entry.
|
||||
expect(queryKey).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
'p1',
|
||||
'override-1700000000000-1700000600000',
|
||||
'signoz/TimeSeriesPanel',
|
||||
panel.spec?.queries,
|
||||
]),
|
||||
);
|
||||
expect(queryKey).not.toContain(DEFAULT_GLOBAL_TIME.minTime);
|
||||
});
|
||||
|
||||
it('floors fractional override ms — V1 time helpers emit floats but start/end are int64', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
time: { startMs: 1_700_000_000_000.546, endMs: 1_700_000_600_000.999 },
|
||||
}),
|
||||
);
|
||||
const [{ requestPayload, queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(requestPayload.start).toBe(1_700_000_000_000);
|
||||
expect(requestPayload.end).toBe(1_700_000_600_000);
|
||||
// The cache key carries the floored values so it matches the request.
|
||||
expect(queryKey).toStrictEqual(
|
||||
expect.arrayContaining(['override-1700000000000-1700000600000']),
|
||||
);
|
||||
});
|
||||
|
||||
describe('list pagination', () => {
|
||||
const listPanel = (
|
||||
querySpec: Record<string, unknown>,
|
||||
): DashboardtypesPanelDTO =>
|
||||
panelWith('signoz/ListPanel', { name: 'A', signal: 'logs', ...querySpec });
|
||||
|
||||
it('exposes server paging at the default page size when the query has no limit', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.pagination).toBeDefined();
|
||||
expect(result.current.pagination?.pageSize).toBe(25);
|
||||
expect(result.current.pagination?.pageSizeOptions).toStrictEqual([
|
||||
10, 25, 50, 100, 200,
|
||||
]);
|
||||
});
|
||||
|
||||
it('disables the server pager when the query has an explicit limit (V1 parity)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({ limit: 100 }), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.pagination).toBeUndefined();
|
||||
});
|
||||
|
||||
it('changes the page size (and re-requests with the new limit) via setPageSize', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
|
||||
act(() => result.current.pagination?.setPageSize(50));
|
||||
|
||||
expect(result.current.pagination?.pageSize).toBe(50);
|
||||
const lastCall = mockUseGetQueryRangeV5.mock.calls.at(-1) as [
|
||||
{ queryKey: unknown[] },
|
||||
];
|
||||
// Page size participates in the cache key so each size is its own entry.
|
||||
expect(lastCall[0].queryKey).toStrictEqual(expect.arrayContaining([50]));
|
||||
});
|
||||
|
||||
// A raw V5 response carrying `rowCount` rows (+ an optional cursor), shaped
|
||||
// the way getRawResults reads it.
|
||||
const rawResponse = (rowCount: number, nextCursor?: string): unknown => ({
|
||||
data: {
|
||||
type: 'raw',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
rows: Array.from({ length: rowCount }, () => ({ data: {} })),
|
||||
...(nextCursor ? { nextCursor } : {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const withResponse = (response: unknown): void => {
|
||||
mockUseGetQueryRangeV5.mockReturnValue({
|
||||
data: response,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
it('starts on page 0 with no prev/next and does not throw before data arrives', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.pagination?.pageIndex).toBe(0);
|
||||
expect(result.current.pagination?.canPrev).toBe(false);
|
||||
expect(result.current.pagination?.canNext).toBe(false);
|
||||
});
|
||||
|
||||
it('flags canNext on a full page and clears it on a partial page', () => {
|
||||
withResponse(rawResponse(25));
|
||||
const full = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(full.result.current.pagination?.canNext).toBe(true);
|
||||
|
||||
withResponse(rawResponse(10));
|
||||
const partial = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(partial.result.current.pagination?.canNext).toBe(false);
|
||||
});
|
||||
|
||||
it('flags canNext from a nextCursor even on a partial page', () => {
|
||||
withResponse(rawResponse(3, 'cursor-1'));
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.pagination?.canNext).toBe(true);
|
||||
});
|
||||
|
||||
it('advances pageIndex and enables canPrev after goNext', () => {
|
||||
withResponse(rawResponse(25));
|
||||
// Stable panel reference: a fresh one each render would change the
|
||||
// `queries` identity and trip the offset-reset effect (real props are stable).
|
||||
const panel = listPanel({});
|
||||
const { result } = renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
|
||||
expect(result.current.pagination?.pageIndex).toBe(0);
|
||||
|
||||
act(() => result.current.pagination?.goNext());
|
||||
|
||||
expect(result.current.pagination?.pageIndex).toBe(1);
|
||||
expect(result.current.pagination?.canPrev).toBe(true);
|
||||
});
|
||||
|
||||
it('stays defined and zero-paged for a non-raw (scalar) response', () => {
|
||||
withResponse({ data: { type: 'scalar', data: { results: [] } } });
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.pagination).toBeDefined();
|
||||
expect(result.current.pagination?.canNext).toBe(false);
|
||||
expect(result.current.pagination?.pageIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a non-positive page size so paging never goes invalid', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
act(() => result.current.pagination?.setPageSize(0));
|
||||
expect(result.current.pagination?.pageSize).toBe(25);
|
||||
expect(result.current.pagination?.pageIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/** Absolute time window in epoch milliseconds — the V5 request's native unit. */
|
||||
export interface PanelTimeWindow {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
// Span per relative preference, in ms. `global_time` is absent (follow the dashboard
|
||||
// window, default branch below). Mirrors V1's `getStartAndEndTime` (last_1_month = 30 days).
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
const PRESET_SPAN_MS: Partial<Record<DashboardtypesTimePreferenceDTO, number>> =
|
||||
{
|
||||
[DashboardtypesTimePreferenceDTO.last_5_min]: 5 * MINUTE_MS,
|
||||
[DashboardtypesTimePreferenceDTO.last_15_min]: 15 * MINUTE_MS,
|
||||
[DashboardtypesTimePreferenceDTO.last_30_min]: 30 * MINUTE_MS,
|
||||
[DashboardtypesTimePreferenceDTO.last_1_hr]: 60 * MINUTE_MS,
|
||||
[DashboardtypesTimePreferenceDTO.last_6_hr]: 6 * 60 * MINUTE_MS,
|
||||
[DashboardtypesTimePreferenceDTO.last_1_day]: 24 * 60 * MINUTE_MS,
|
||||
[DashboardtypesTimePreferenceDTO.last_3_days]: 3 * 24 * 60 * MINUTE_MS,
|
||||
[DashboardtypesTimePreferenceDTO.last_1_week]: 7 * 24 * 60 * MINUTE_MS,
|
||||
[DashboardtypesTimePreferenceDTO.last_1_month]: 30 * 24 * 60 * MINUTE_MS,
|
||||
};
|
||||
|
||||
// Short + full labels per relative preference, for the header time pill. `global_time` is
|
||||
// absent — a panel that follows the dashboard window shows no pill.
|
||||
const TIME_PREFERENCE_LABEL: Partial<
|
||||
Record<DashboardtypesTimePreferenceDTO, { short: string; full: string }>
|
||||
> = {
|
||||
[DashboardtypesTimePreferenceDTO.last_5_min]: {
|
||||
short: '5m',
|
||||
full: 'Last 5 min',
|
||||
},
|
||||
[DashboardtypesTimePreferenceDTO.last_15_min]: {
|
||||
short: '15m',
|
||||
full: 'Last 15 min',
|
||||
},
|
||||
[DashboardtypesTimePreferenceDTO.last_30_min]: {
|
||||
short: '30m',
|
||||
full: 'Last 30 min',
|
||||
},
|
||||
[DashboardtypesTimePreferenceDTO.last_1_hr]: {
|
||||
short: '1h',
|
||||
full: 'Last 1 hr',
|
||||
},
|
||||
[DashboardtypesTimePreferenceDTO.last_6_hr]: {
|
||||
short: '6h',
|
||||
full: 'Last 6 hr',
|
||||
},
|
||||
[DashboardtypesTimePreferenceDTO.last_1_day]: {
|
||||
short: '1d',
|
||||
full: 'Last 1 day',
|
||||
},
|
||||
[DashboardtypesTimePreferenceDTO.last_3_days]: {
|
||||
short: '3d',
|
||||
full: 'Last 3 days',
|
||||
},
|
||||
[DashboardtypesTimePreferenceDTO.last_1_week]: {
|
||||
short: '1w',
|
||||
full: 'Last 1 week',
|
||||
},
|
||||
[DashboardtypesTimePreferenceDTO.last_1_month]: {
|
||||
short: '1mo',
|
||||
full: 'Last 1 month',
|
||||
},
|
||||
};
|
||||
|
||||
export interface PanelTimePreferenceLabel {
|
||||
/** Compact pill label, e.g. `6h`. */
|
||||
short: string;
|
||||
/** Full label for the pill's tooltip, e.g. `Last 6 hr`. */
|
||||
full: string;
|
||||
}
|
||||
|
||||
/** Pill labels for a panel's relative time preference, or `null` when it follows the dashboard window (`global_time`/unset). */
|
||||
export function panelTimePreferenceLabel(
|
||||
timePreference: DashboardtypesTimePreferenceDTO | undefined,
|
||||
): PanelTimePreferenceLabel | null {
|
||||
if (
|
||||
!timePreference ||
|
||||
timePreference === DashboardtypesTimePreferenceDTO.global_time
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return TIME_PREFERENCE_LABEL[timePreference] ?? null;
|
||||
}
|
||||
|
||||
interface ResolvePanelTimeWindowArgs {
|
||||
/** The panel's saved per-panel time preference (`visualization.timePreference`). */
|
||||
timePreference: DashboardtypesTimePreferenceDTO | undefined;
|
||||
/** Dashboard global window (epoch ms) — used as-is for `global_time`. */
|
||||
globalStartMs: number;
|
||||
globalEndMs: number;
|
||||
/** Explicit caller window (epoch ms), e.g. the editor preview. When present it wins outright over the panel preference. */
|
||||
override?: PanelTimeWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the absolute `[startMs, endMs]` window a panel queries over.
|
||||
*
|
||||
* Precedence: `override` → relative `timePreference` preset → dashboard global window. A
|
||||
* preset is anchored to `globalEndMs`, not wall-clock `Date.now()`, so the window is stable
|
||||
* across renders (no refetch loop) and tracks the dashboard's refresh. All values floored:
|
||||
* V5 start/end are int64 on the wire and upstream ms can carry a fraction.
|
||||
*/
|
||||
export function resolvePanelTimeWindow({
|
||||
timePreference,
|
||||
globalStartMs,
|
||||
globalEndMs,
|
||||
override,
|
||||
}: ResolvePanelTimeWindowArgs): PanelTimeWindow {
|
||||
if (override) {
|
||||
return {
|
||||
startMs: Math.floor(override.startMs),
|
||||
endMs: Math.floor(override.endMs),
|
||||
};
|
||||
}
|
||||
|
||||
const endMs = Math.floor(globalEndMs);
|
||||
const spanMs =
|
||||
timePreference &&
|
||||
timePreference !== DashboardtypesTimePreferenceDTO.global_time
|
||||
? PRESET_SPAN_MS[timePreference]
|
||||
: undefined;
|
||||
|
||||
if (spanMs !== undefined) {
|
||||
return { startMs: endMs - spanMs, endMs };
|
||||
}
|
||||
|
||||
return { startMs: Math.floor(globalStartMs), endMs };
|
||||
}
|
||||
@@ -13,13 +13,11 @@ export interface UseGetQueryRangeV5Args {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// 4xx responses are deterministic (bad query, auth) — retrying re-sends a
|
||||
// request that will fail identically. Same policy as V1's useGetQueryRange.
|
||||
// react-query hands the retry callback the *raw* thrown value, which on this
|
||||
// path is the AxiosError the generated client rejects with (it is not yet
|
||||
// normalized to APIError) — so we inspect it at the axios level for the cancel
|
||||
// signal and the HTTP status. Normalization to APIError happens later, at the
|
||||
// display boundary (see PanelStatus `panelStatusFromError`).
|
||||
/**
|
||||
* Don't retry deterministic 4xx (bad query, auth) — they fail identically (V1 parity).
|
||||
* The retry callback gets the raw AxiosError this path rejects with (not yet normalized to
|
||||
* APIError — that happens later at the display boundary), so inspect it at the axios level.
|
||||
*/
|
||||
function retryUnlessClientError(failureCount: number, error: Error): boolean {
|
||||
if (isAxiosError(error)) {
|
||||
if (error.code === 'ERR_CANCELED') {
|
||||
@@ -34,11 +32,9 @@ function retryUnlessClientError(failureCount: number, error: Error): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-V5 query-range fetch: posts the generated request DTO via the
|
||||
* generated `queryRangeV5` call and returns the raw generated response —
|
||||
* no V1 `Query` shape on either leg. Wrapped in `useQuery` (not the
|
||||
* generated `useQueryRangeV5` mutation hook) because panel fetches need
|
||||
* caching, `enabled` gating, and refetch semantics.
|
||||
* Pure-V5 query-range fetch: posts the generated request DTO and returns the raw response.
|
||||
* Wrapped in `useQuery` (not the generated `useQueryRangeV5` mutation) for caching, `enabled`
|
||||
* gating, and refetch.
|
||||
*/
|
||||
export function useGetQueryRangeV5({
|
||||
requestPayload,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user