Compare commits

..

7 Commits

Author SHA1 Message Date
Abhi Kumar
8f06859ebf fix(dashboards-v2): refine New Panel modal footer + section labels
- Keep the footer visible at all times; the "Add Panel" confirm is disabled
  until a panel type is selected (instead of hiding the whole footer).
- Footer layout: the section selector fills the available width and the
  confirm button takes its natural width (no more 50/50 split).
- Add a "Select panel type" label above the panel-type grid, matching the
  existing "Add panel to" label.
2026-07-06 19:35:56 +05:30
Abhi Kumar
7ea3ba4e09 feat(dashboards-v2): choose target section when adding a panel
Add a section picker to the New Panel modal so a panel can be placed in
any section (or the untitled root), not just the one the "Add panel"
action was triggered from. Sections are read from the cached dashboard
query via useDashboardSections (no refetch, no prop-drilling).

Picking a panel type now selects it (highlighted card) and reveals a
footer holding the section dropdown and an "Add Panel" button split
50/50; confirming shows a brief loader before navigating to the editor.
The chosen section's layoutIndex is threaded through
useCreatePanel.createPanel.
2026-07-06 19:35:56 +05:30
Abhi Kumar
af02e8b722 fix: ui fixes for panel selection modal 2026-07-06 19:35:56 +05:30
Vikrant Gupta
1644e35d9c feat(authz): make serviceaccount_role and user_role first class citizens (#11977)
* feat(authz): add serviceaccount and user role handlers

* feat(authz): add support for role and service account extractors

* feat(authz): make the user apis v2

* feat(authz): return the existing service account role id
2026-07-06 13:48:46 +00:00
Abhi kumar
379331ee77 feat(dashboards-v2): panel drilldown — View in Logs/Traces (#11895)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(dashboards-v2): add the panel View-modal state and hooks

URL-driven open state (useViewPanel off expandedWidgetId, V1 parity), a per-view
time window isolated from the dashboard (useViewPanelTimeWindow), and
useViewPanelEditor which layers the drilldown reset + type-selector signal/query
type on top of the shared usePanelEditSession.

* refactor(dashboards-v2): address View-mode PR review feedback

Naming/convention:
- UsePanelEditSessionApi -> UsePanelEditSessionReturn; fullKind -> panelKind
- useViewPanelEditor -> useViewPanelMode (hook, types, file, refs, test)
- useOpenPanelEditor `state` param -> `handoffState` (view -> edit handoff)

Types tightened:
- useViewPanelMode: panelDefinition is required (registry always resolves)
- signal is required: fold the default-signal fallback into the hook so the
  query builder + type selector always get a signal; drop the now-dead
  defaultSignal from the return and the `signal ?? defaultSignal` call site.
  Behavior-preserving: only List restricts signals and it's already gated by
  query type under PromQL/ClickHouse.

Misc:
- PanelEditorPage: drop redundant optional chaining on getPanelDefinition
- ViewPanelModal: Typography.Text title with ellipsis truncation
- ViewPanelModalHeader: clearer queryType JSDoc

* feat(dashboards-v2): add panel drilldown click-enrichment utils

* feat(dashboards-v2): declare drilldown as a per-kind panel capability

* feat(dashboards-v2): open a drilldown context menu with View in Logs/Traces

* feat(dashboards-v2): substitute dashboard variables in drilldown View-in-X

Clicking View in Logs/Traces from a panel drilldown now resolves the
panel query's $var/dashboard-variable references before building the
explorer URL (V1 parity with useBaseAggregateOptions). New
useResolvedDrilldownQuery hook fires a /substitute_vars round-trip
(useReplaceVariables + buildQueryRangeRequest) when the aggregate menu
opens and the dashboard has selections, mirroring the V2-native path in
useCreateAlertFromPanel. While resolving, both actions show a spinner
and are disabled; no-variable dashboards skip the round-trip.

* refactor(dashboards-v2): address drilldown review comments

- stepClickTimeRange takes a single stepInterval (defaulted in props);
  callers do the per-query lookup from exec-stats stepIntervals
- move DrilldownAggregateMenu static inline styles into a CSS module,
  keeping only the dynamic series color inline

* feat(dashboards-v2): drill down filter-by-value into the View modal (#11959)

* feat(dashboards-v2): drill down filter-by-value into the View modal

A table group-cell click opens the filter-by-value menu (Is this / Is
not this); picking an operator adds the filter to the panel query and
opens the refined result in the View modal, persisted in the URL (V1
parity). Reuses V1's read-only getGroupContextMenuConfig; no V1 files
modified.

* refactor(dashboards-v2): type the filter-by-value ClickedData without a double-cast

Address review: build a genuinely-typed ClickedData for the group-cell
filter menu instead of an `as unknown as ClickedData` escape hatch.
getGroupContextMenuConfig reads only column.dataIndex, so column carries
just that field and record gets a minimal valid RowData.

* refactor(dashboards-v2): render the filter-by-value menu from a component

Reshape useDrilldownFilter to return data (isGroupColumnClick + onFilter)
instead of JSX, and move the operator-menu rendering into a new
DrilldownFilterMenu component wired in useDrilldown. Keeps hooks to
logic/state and rendering in components (PR review preference).

* refactor(dashboards-v2): strongly-type the drilldown popover coordinates

Replace the drilldown's use of V1's loosely-typed useCoordinates (clickedData:
any) with a V2-local useDrilldownCoordinates hook generic over the click
payload, so DrilldownContext flows through without a cast. Extract the pure
viewport-clamp math into calculatePopoverPosition with unit tests.

* feat(dashboards-v2): panel drilldown — Breakout by .. (#11896)

* feat(dashboards-v2): gate the panel editor through the capability guard

Route the panel editor's query builder and visualization type switcher through
the capability guard instead of V1's PANEL_TYPE_TO_QUERY_TYPES (now no longer
imported by any V2 file):

- PanelEditorQueryBuilder is keyed on PanelKind; its query-type tabs and
  query-builder field visibility come from the guard.
- Switching the panel kind coerces the active query type via the guard.
- The visualization type switcher disables a kind when the active query type or
  datasource is incompatible with it (e.g. List under ClickHouse/PromQL, or List
  with a metrics query). The live query type is read from the query-builder
  provider so a not-yet-staged new panel still gates correctly, and a tooltip
  explains why a type is disabled. ConfigSelect gains opt-in per-option tooltips.

* feat(dashboards-v2): create alerts from a dashboard panel

Wire the panel actions menu's "Create Alerts" item to seed a new alert
from the panel's query. `buildCreateAlertUrl` translates the panel's V5
queries into the V1 compositeQuery the alert page reads (tagged with the
panel type, v5 version and a dashboards source), and
`useCreateAlertFromPanel` opens /alerts/new in a new tab and logs the
action. Available regardless of edit access (V1 parity: create-alert
works on locked dashboards too).

To reach the query, the full panel is threaded through
Panel -> PanelHeader -> PanelActionsMenu -> usePanelActionItems instead
of just its kind; the header now derives its name/description from the
panel as well.

* refactor(dashboards-v2): ready ChartManager for the standalone view

Prep the shared graph-manager so it renders cleanly inside the View
modal: swap the legacy useNotifications save toast for the sonner toast,
and let ChartLayout grow to its content height (auto) so the manager's
legend table isn't clipped under the chart.

* feat(dashboards-v2): add the panel View modal

The View modal UI — a compact, single-host drilldown editor matching V1's
FullView: a draft preview over the per-view window, a toolbar (Reset
Query, Switch to Edit Mode, panel-type selector, time picker, refresh),
and the query-builder-only drilldown editor (no ClickHouse/PromQL tabs,
per V1). Edits are temporary — they never touch the saved dashboard.

* feat(dashboards-v2): open a drilldown context menu with View in Logs/Traces

* feat(dashboards-v2): add the Breakout by .. drilldown submenu

* chore: pr review fixes

* refactor(dashboards-v2): render the Breakout by .. menu from a component

Reshape useDrilldownBreakout to return data (queryData + onBreakout) instead
of JSX, and move the picker rendering into a new DrilldownBreakoutMenu
component (with a CSS module) wired in useDrilldown. Keeps hooks to
logic/state and rendering in components (PR review preference).

* feat(dashboards-v2): panel drilldown — context links (#11897)

* feat(dashboards-v2): resolve panel context links in the drilldown menu

* feat(dashboards-v2): panel drilldown — dashboard variables + trace link (#11964)

* feat(dashboards-v2): complete drilldown V1 parity — dashboard variables + trace link

Add the last two V1 drilldown features to V2 panels:

- Dashboard Variables submenu: from a grouped click, the aggregate menu now
  offers, per group-by field, Set/Unset a matching dynamic variable to the
  clicked value, or Create a new dynamic variable from it. Set/Unset are
  runtime-only (store + URL, matching V2 selections); Create is the one
  persisted path (appends to spec.variables via the optimistic patch).
  New Panel/hooks/useDrilldownDashboardVariables.tsx mirrors V1's
  useDashboardVarConfig; wired into useDrilldown as a new submenu.

- "View Trace Details" data link: the aggregate menu shows a /trace/{id}
  link when the clicked point carries a trace_id (V1 getDataLinks parity).
  New Panels/utils/drilldown/getDataLinks.ts.

Exports variablesUrlParser from useVariableSelection so the submenu reuses the
runtime-selection URL contract without re-running its seeding effect.

* fix(dashboards-v2): restrict drilldown filters to group-by dimensions

The pure-V5 flattening backfills an ungrouped series' labels with
{queryName: queryName} for its legend; the chart/pie enrichers turned every
label into an equality filter, leaking a bogus `A=A` into the drilldown
context. That broke the "filters are the clicked point's group-by labels,
empty when ungrouped" contract — surfacing a spurious Dashboard Variables
entry and a corrupt View-in-Logs query that V1 never shows.

Add getGroupByFilters to intersect the clicked labels with the query's
groupBy, and use it in enrichChartClick/enrichPieClick.

* refactor(dashboards-v2): dashboard-variables drilldown review fixes

Address PR review on the Dashboard Variables submenu:
- render it from a DrilldownDashboardVariablesMenu component; the hook now
  returns data (hasFieldVariables + actions) instead of JSX, taking a required
  filters array + signal (rendering belongs in the component, not the hook)
- identify dynamic variables by plugin kind on the DTO, not by mapping every
  variable to a form model first
- drop the redundant useGetDashboardV2 enabled guard (the generated hook
  already gates on !!id)
- reference the DrilldownSubMenu enum instead of a raw string
- move inline styles to a CSS module

* refactor(dashboards-v2): read drilldown variables via useDashboardFetchRequired

Use the shared useDashboardFetchRequired in useDrilldownDashboardVariables
instead of calling useGetDashboardV2 directly, so it resolves through the one
dashboard cache entry and drops the `?? []` guards. Filter dynamic variables on
the form model's `type` (via dtoToFormModel) rather than casting the raw spec.

That hook throws on a missing dashboard id, and the id was seeded into the store
in a useEffect — undefined on the first Panel render. Seed it synchronously
during render in DashboardContainer instead, and make setEditContext idempotent
so the render-phase call is a safe no-op once seeded.

* chore: fix failing lint
2026-07-06 13:18:39 +00:00
Ashwin Bhatkal
4d079a8338 refactor(dashboard-v2): unify dashboard tag chips into one TagBadge component (#11984)
* fix(dashboard-v2): render tag chips with the sienna Badge component

The key:value tag editor rendered chips as a hand-rolled div + ghost buttons with
sienna colors mixed in by hand. Use the @signozhq Badge (color=sienna, closable)
so the chips match the design system and the rest of the dashboard tag styling.

* fix(dashboard-v2): keep key:key tags intact in the settings tag editor

tagsToStrings collapsed a tag whose key equals its value down to a bare key, so
the settings tag editor showed `env` for an `env:env` tag. Always render both
sides so key:key tags round-trip through the editor unchanged.

* refactor(dashboard-v2): single TagBadge component for all dashboard tags

The list rows, details header, and tag editors each rendered tag chips their own
way (two hand-rolled sienna variants + an inline Badge), so they drifted. Extract
one TagBadge (sienna Badge, optional closable) and use it everywhere, so tag
styling changes in one place. List-row chips were not even sienna before.

* fix(dashboard-v2): tag label inherits the Badge sienna color

The chip label button used --button-variant-ghost-color: inherit, but for a CSS
custom property inherit takes the parent's value of that same variable (unset),
not the parent color — so the label fell back to the muted ghost default. Use
currentColor so it picks up the Badge's sienna foreground.
2026-07-06 13:05:23 +00:00
Vinicius Lourenço
91938925ec feat(infra-monitoring): rework the category selection (#11957) 2026-07-06 12:04:07 +00:00
157 changed files with 5778 additions and 2711 deletions

View File

@@ -515,6 +515,13 @@ components:
url:
type: string
type: object
AuthtypesDeprecatedPostableUserRole:
properties:
id:
type: string
required:
- id
type: object
AuthtypesGettableAuthDomain:
properties:
authNProviderInfo:
@@ -660,17 +667,20 @@ components:
type: string
userRoles:
items:
$ref: '#/components/schemas/AuthtypesPostableUserRole'
$ref: '#/components/schemas/AuthtypesDeprecatedPostableUserRole'
type: array
required:
- email
type: object
AuthtypesPostableUserRole:
properties:
id:
roleId:
type: string
userId:
type: string
required:
- id
- userId
- roleId
type: object
AuthtypesRelation:
enum:
@@ -4251,8 +4261,6 @@ components:
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- clusterName
- clusterCPU
@@ -4261,7 +4269,6 @@ components:
- clusterMemoryAllocatable
- nodeCountsByReadiness
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesClusters:
@@ -4317,8 +4324,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- daemonSetName
- daemonSetCPU
@@ -4330,7 +4335,6 @@ components:
- desiredNodes
- currentNodes
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesDaemonSets:
@@ -4386,8 +4390,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- deploymentName
- deploymentCPU
@@ -4399,7 +4401,6 @@ components:
- desiredPods
- availablePods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesDeployments:
@@ -4532,8 +4533,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
successfulPods:
type: integer
required:
@@ -4549,7 +4548,6 @@ components:
- failedPods
- successfulPods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesJobs:
@@ -4640,14 +4638,11 @@ components:
type: string
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- namespaceName
- namespaceCPU
- namespaceMemory
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesNamespaces:
@@ -4713,14 +4708,11 @@ components:
type: string
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- nodeName
- condition
- nodeCountsByReadiness
- podCountsByPhase
- podCountsByStatus
- nodeCPU
- nodeCPUAllocatable
- nodeMemory
@@ -4766,64 +4758,6 @@ components:
- failed
- unknown
type: object
InframonitoringtypesPodCountsByStatus:
properties:
completed:
type: integer
containerCannotRun:
type: integer
containerCreating:
type: integer
crashLoopBackOff:
type: integer
createContainerConfigError:
type: integer
errImagePull:
type: integer
error:
type: integer
evicted:
type: integer
failed:
type: integer
imagePullBackOff:
type: integer
nodeAffinity:
type: integer
nodeLost:
type: integer
oomKilled:
type: integer
pending:
type: integer
running:
type: integer
shutdown:
type: integer
unexpectedAdmissionError:
type: integer
unknown:
type: integer
required:
- pending
- running
- failed
- unknown
- crashLoopBackOff
- imagePullBackOff
- errImagePull
- createContainerConfigError
- containerCreating
- oomKilled
- completed
- error
- containerCannotRun
- evicted
- nodeAffinity
- nodeLost
- shutdown
- unexpectedAdmissionError
type: object
InframonitoringtypesPodPhase:
enum:
- pending
@@ -4854,8 +4788,6 @@ components:
type: number
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
podMemory:
format: double
type: number
@@ -4867,11 +4799,6 @@ components:
type: number
podPhase:
$ref: '#/components/schemas/InframonitoringtypesPodPhase'
podRestarts:
format: int64
type: integer
podStatus:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
podUID:
type: string
required:
@@ -4884,34 +4811,9 @@ components:
- podMemoryLimit
- podPhase
- podCountsByPhase
- podStatus
- podCountsByStatus
- podRestarts
- podAge
- meta
type: object
InframonitoringtypesPodStatus:
enum:
- pending
- running
- failed
- unknown
- crashloopbackoff
- imagepullbackoff
- errimagepull
- createcontainerconfigerror
- containercreating
- oomkilled
- completed
- error
- containercannotrun
- evicted
- nodeaffinity
- nodelost
- shutdown
- unexpectedadmissionerror
- no_data
type: string
InframonitoringtypesPods:
properties:
endTimeBeforeRetention:
@@ -5210,8 +5112,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
statefulSetCPU:
format: double
type: number
@@ -5243,7 +5143,6 @@ components:
- desiredPods
- currentPods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesStatefulSets:
@@ -7541,6 +7440,13 @@ components:
enum:
- basic
type: string
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
properties:
id:
type: string
required:
- id
type: object
ServiceaccounttypesGettableFactorAPIKey:
properties:
createdAt:
@@ -7597,10 +7503,13 @@ components:
type: object
ServiceaccounttypesPostableServiceAccountRole:
properties:
id:
roleId:
type: string
serviceAccountId:
type: string
required:
- id
- serviceAccountId
- roleId
type: object
ServiceaccounttypesServiceAccount:
properties:
@@ -12363,6 +12272,188 @@ paths:
summary: Update route policy
tags:
- routepolicies
/api/v1/service_account_roles:
post:
deprecated: false
description: This endpoint assigns a role to a service account
operationId: CreateServiceAccountRole
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:attach
- role:attach
- tokenizer:
- serviceaccount:attach
- role:attach
summary: Create service account role
tags:
- serviceaccount
/api/v1/service_account_roles/{id}:
delete:
deprecated: false
description: This endpoint revokes a role from a service account
operationId: DeleteServiceAccountRole
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:detach
- role:detach
- tokenizer:
- serviceaccount:detach
- role:detach
summary: Delete service account role
tags:
- serviceaccount
get:
deprecated: false
description: This endpoint gets an existing service account role
operationId: GetServiceAccountRole
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountRole'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:read
- tokenizer:
- serviceaccount:read
summary: Get service account role
tags:
- serviceaccount
/api/v1/service_accounts:
get:
deprecated: false
@@ -12932,9 +13023,9 @@ paths:
tags:
- serviceaccount
post:
deprecated: false
deprecated: true
description: This endpoint assigns a role to a service account
operationId: CreateServiceAccountRole
operationId: CreateServiceAccountRoleDeprecated
parameters:
- in: path
name: id
@@ -12945,7 +13036,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
$ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole'
responses:
"201":
content:
@@ -12997,9 +13088,9 @@ paths:
- serviceaccount
/api/v1/service_accounts/{id}/roles/{rid}:
delete:
deprecated: false
deprecated: true
description: This endpoint revokes a role from service account
operationId: DeleteServiceAccountRole
operationId: DeleteServiceAccountRoleDeprecated
parameters:
- in: path
name: id
@@ -22101,6 +22192,184 @@ paths:
summary: Rotate session
tags:
- sessions
/api/v2/user_roles:
post:
deprecated: false
description: This endpoint assigns a role to a user
operationId: CreateUserRole
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPostableUserRole'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create user role
tags:
- users
/api/v2/user_roles/{id}:
delete:
deprecated: false
description: This endpoint revokes a role from a user
operationId: DeleteUserRole
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete user role
tags:
- users
get:
deprecated: false
description: This endpoint gets an existing user role
operationId: GetUserRole
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesUserRole'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get user role
tags:
- users
/api/v2/users:
get:
deprecated: false
@@ -22554,7 +22823,7 @@ paths:
tags:
- users
post:
deprecated: false
deprecated: true
description: This endpoint assigns the role to the user roles by user id
operationId: SetRoleByUserID
parameters:
@@ -22605,7 +22874,7 @@ paths:
- users
/api/v2/users/{id}/roles/{roleId}:
delete:
deprecated: false
deprecated: true
description: This endpoint removes a role from the user by user id and role
id
operationId: RemoveUserRoleByUserIDAndRoleID

View File

@@ -519,7 +519,7 @@ func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID,
if err != nil {
return "", err
}
err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
_, err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
if err != nil {
return "", err
}

View File

@@ -141,7 +141,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationServiceAccount(ctx context.Cont
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
}
err = ah.Signoz.Modules.ServiceAccount.SetRoleByName(ctx, orgId, cloudIntegrationServiceAccount.ID, authtypes.SigNozViewerRoleName)
_, err = ah.Signoz.Modules.ServiceAccount.SetRoleByName(ctx, orgId, cloudIntegrationServiceAccount.ID, authtypes.SigNozViewerRoleName)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
}

View File

@@ -22,12 +22,16 @@ import type {
CreateServiceAccountKey201,
CreateServiceAccountKeyPathParameters,
CreateServiceAccountRole201,
CreateServiceAccountRolePathParameters,
CreateServiceAccountRoleDeprecated201,
CreateServiceAccountRoleDeprecatedPathParameters,
DeleteServiceAccountPathParameters,
DeleteServiceAccountRoleDeprecatedPathParameters,
DeleteServiceAccountRolePathParameters,
GetMyServiceAccount200,
GetServiceAccount200,
GetServiceAccountPathParameters,
GetServiceAccountRole200,
GetServiceAccountRolePathParameters,
GetServiceAccountRoles200,
GetServiceAccountRolesPathParameters,
ListServiceAccountKeys200,
@@ -35,6 +39,7 @@ import type {
ListServiceAccounts200,
RenderErrorResponseDTO,
RevokeServiceAccountKeyPathParameters,
ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
ServiceaccounttypesPostableFactorAPIKeyDTO,
ServiceaccounttypesPostableServiceAccountDTO,
ServiceaccounttypesPostableServiceAccountRoleDTO,
@@ -46,6 +51,272 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* This endpoint assigns a role to a service account
* @summary Create service account role
*/
export const createServiceAccountRole = (
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateServiceAccountRole201>({
url: `/api/v1/service_account_roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: serviceaccounttypesPostableServiceAccountRoleDTO,
signal,
});
};
export const getCreateServiceAccountRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
TContext
> => {
const mutationKey = ['createServiceAccountRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createServiceAccountRole>>,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> }
> = (props) => {
const { data } = props ?? {};
return createServiceAccountRole(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRole>>
>;
export type CreateServiceAccountRoleMutationBody =
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
| undefined;
export type CreateServiceAccountRoleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create service account role
*/
export const useCreateServiceAccountRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
TContext
> => {
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
};
/**
* This endpoint revokes a role from a service account
* @summary Delete service account role
*/
export const deleteServiceAccountRole = (
{ id }: DeleteServiceAccountRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/service_account_roles/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteServiceAccountRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
> => {
const mutationKey = ['deleteServiceAccountRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
{ pathParams: DeleteServiceAccountRolePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteServiceAccountRole(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRole>>
>;
export type DeleteServiceAccountRoleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete service account role
*/
export const useDeleteServiceAccountRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
> => {
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
};
/**
* This endpoint gets an existing service account role
* @summary Get service account role
*/
export const getServiceAccountRole = (
{ id }: GetServiceAccountRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetServiceAccountRole200>({
url: `/api/v1/service_account_roles/${id}`,
method: 'GET',
signal,
});
};
export const getGetServiceAccountRoleQueryKey = ({
id,
}: GetServiceAccountRolePathParameters) => {
return [`/api/v1/service_account_roles/${id}`] as const;
};
export const getGetServiceAccountRoleQueryOptions = <
TData = Awaited<ReturnType<typeof getServiceAccountRole>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetServiceAccountRolePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getServiceAccountRole>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetServiceAccountRoleQueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getServiceAccountRole>>
> = ({ signal }) => getServiceAccountRole({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getServiceAccountRole>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetServiceAccountRoleQueryResult = NonNullable<
Awaited<ReturnType<typeof getServiceAccountRole>>
>;
export type GetServiceAccountRoleQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get service account role
*/
export function useGetServiceAccountRole<
TData = Awaited<ReturnType<typeof getServiceAccountRole>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetServiceAccountRolePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getServiceAccountRole>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetServiceAccountRoleQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get service account role
*/
export const invalidateGetServiceAccountRole = async (
queryClient: QueryClient,
{ id }: GetServiceAccountRolePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetServiceAccountRoleQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint lists the service accounts for an organisation
* @summary List service accounts
@@ -984,45 +1255,46 @@ export const invalidateGetServiceAccountRoles = async (
/**
* This endpoint assigns a role to a service account
* @deprecated
* @summary Create service account role
*/
export const createServiceAccountRole = (
{ id }: CreateServiceAccountRolePathParameters,
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
export const createServiceAccountRoleDeprecated = (
{ id }: CreateServiceAccountRoleDeprecatedPathParameters,
serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateServiceAccountRole201>({
return GeneratedAPIInstance<CreateServiceAccountRoleDeprecated201>({
url: `/api/v1/service_accounts/${id}/roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: serviceaccounttypesPostableServiceAccountRoleDTO,
data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
signal,
});
};
export const getCreateServiceAccountRoleMutationOptions = <
export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRole>>,
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRole>>,
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
> => {
const mutationKey = ['createServiceAccountRole'];
const mutationKey = ['createServiceAccountRoleDeprecated'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
@@ -1032,62 +1304,66 @@ export const getCreateServiceAccountRoleMutationOptions = <
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createServiceAccountRole>>,
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
{
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return createServiceAccountRole(pathParams, data);
return createServiceAccountRoleDeprecated(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRole>>
export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>
>;
export type CreateServiceAccountRoleMutationBody =
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
export type CreateServiceAccountRoleDeprecatedMutationBody =
| BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>
| undefined;
export type CreateServiceAccountRoleMutationError =
export type CreateServiceAccountRoleDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create service account role
*/
export const useCreateServiceAccountRole = <
export const useCreateServiceAccountRoleDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRole>>,
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createServiceAccountRole>>,
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
> => {
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
return useMutation(
getCreateServiceAccountRoleDeprecatedMutationOptions(options),
);
};
/**
* This endpoint revokes a role from service account
* @deprecated
* @summary Delete service account role
*/
export const deleteServiceAccountRole = (
{ id, rid }: DeleteServiceAccountRolePathParameters,
export const deleteServiceAccountRoleDeprecated = (
{ id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
@@ -1097,23 +1373,23 @@ export const deleteServiceAccountRole = (
});
};
export const getDeleteServiceAccountRoleMutationOptions = <
export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
> => {
const mutationKey = ['deleteServiceAccountRole'];
const mutationKey = ['deleteServiceAccountRoleDeprecated'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
@@ -1123,44 +1399,47 @@ export const getDeleteServiceAccountRoleMutationOptions = <
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
{ pathParams: DeleteServiceAccountRolePathParameters }
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteServiceAccountRole(pathParams);
return deleteServiceAccountRoleDeprecated(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRole>>
export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>
>;
export type DeleteServiceAccountRoleMutationError =
export type DeleteServiceAccountRoleDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Delete service account role
*/
export const useDeleteServiceAccountRole = <
export const useDeleteServiceAccountRoleDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
> => {
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
return useMutation(
getDeleteServiceAccountRoleDeprecatedMutationOptions(options),
);
};
/**
* This endpoint gets my service account

View File

@@ -2048,6 +2048,13 @@ export interface AuthtypesAuthNSupportDTO {
password?: AuthtypesPasswordAuthNSupportDTO[] | null;
}
export interface AuthtypesDeprecatedPostableUserRoleDTO {
/**
* @type string
*/
id: string;
}
export interface AuthtypesGettableAuthDomainDTO {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
config?: AuthtypesAuthDomainConfigDTO;
@@ -2287,13 +2294,6 @@ export interface AuthtypesPostableRotateTokenDTO {
refreshToken?: string;
}
export interface AuthtypesPostableUserRoleDTO {
/**
* @type string
*/
id: string;
}
export interface AuthtypesPostableUserDTO {
/**
* @type string
@@ -2310,7 +2310,18 @@ export interface AuthtypesPostableUserDTO {
/**
* @type array
*/
userRoles?: AuthtypesPostableUserRoleDTO[];
userRoles?: AuthtypesDeprecatedPostableUserRoleDTO[];
}
export interface AuthtypesPostableUserRoleDTO {
/**
* @type string
*/
roleId: string;
/**
* @type string
*/
userId: string;
}
export interface AuthtypesRoleDTO {
@@ -5693,81 +5704,6 @@ export interface InframonitoringtypesPodCountsByPhaseDTO {
unknown: number;
}
export interface InframonitoringtypesPodCountsByStatusDTO {
/**
* @type integer
*/
completed: number;
/**
* @type integer
*/
containerCannotRun: number;
/**
* @type integer
*/
containerCreating: number;
/**
* @type integer
*/
crashLoopBackOff: number;
/**
* @type integer
*/
createContainerConfigError: number;
/**
* @type integer
*/
errImagePull: number;
/**
* @type integer
*/
error: number;
/**
* @type integer
*/
evicted: number;
/**
* @type integer
*/
failed: number;
/**
* @type integer
*/
imagePullBackOff: number;
/**
* @type integer
*/
nodeAffinity: number;
/**
* @type integer
*/
nodeLost: number;
/**
* @type integer
*/
oomKilled: number;
/**
* @type integer
*/
pending: number;
/**
* @type integer
*/
running: number;
/**
* @type integer
*/
shutdown: number;
/**
* @type integer
*/
unexpectedAdmissionError: number;
/**
* @type integer
*/
unknown: number;
}
export interface InframonitoringtypesClusterRecordDTO {
/**
* @type number
@@ -5799,7 +5735,6 @@ export interface InframonitoringtypesClusterRecordDTO {
meta: InframonitoringtypesClusterRecordDTOMeta;
nodeCountsByReadiness: InframonitoringtypesNodeCountsByReadinessDTO;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export enum InframonitoringtypesResponseTypeDTO {
@@ -5903,7 +5838,6 @@ export interface InframonitoringtypesDaemonSetRecordDTO {
*/
meta: InframonitoringtypesDaemonSetRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesDaemonSetsDTO {
@@ -5981,7 +5915,6 @@ export interface InframonitoringtypesDeploymentRecordDTO {
*/
meta: InframonitoringtypesDeploymentRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesDeploymentsDTO {
@@ -6148,7 +6081,6 @@ export interface InframonitoringtypesJobRecordDTO {
*/
meta: InframonitoringtypesJobRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type integer
*/
@@ -6202,7 +6134,6 @@ export interface InframonitoringtypesNamespaceRecordDTO {
*/
namespaceName: string;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesNamespacesDTO {
@@ -6269,7 +6200,6 @@ export interface InframonitoringtypesNodeRecordDTO {
*/
nodeName: string;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesNodesDTO {
@@ -6307,27 +6237,6 @@ export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
export type InframonitoringtypesPodRecordDTOMeta =
InframonitoringtypesPodRecordDTOMetaAnyOf | null;
export enum InframonitoringtypesPodStatusDTO {
pending = 'pending',
running = 'running',
failed = 'failed',
unknown = 'unknown',
crashloopbackoff = 'crashloopbackoff',
imagepullbackoff = 'imagepullbackoff',
errimagepull = 'errimagepull',
createcontainerconfigerror = 'createcontainerconfigerror',
containercreating = 'containercreating',
oomkilled = 'oomkilled',
completed = 'completed',
error = 'error',
containercannotrun = 'containercannotrun',
evicted = 'evicted',
nodeaffinity = 'nodeaffinity',
nodelost = 'nodelost',
shutdown = 'shutdown',
unexpectedadmissionerror = 'unexpectedadmissionerror',
no_data = 'no_data',
}
export interface InframonitoringtypesPodRecordDTO {
/**
* @type object,null
@@ -6354,7 +6263,6 @@ export interface InframonitoringtypesPodRecordDTO {
*/
podCPURequest: number;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type number
* @format double
@@ -6371,12 +6279,6 @@ export interface InframonitoringtypesPodRecordDTO {
*/
podMemoryRequest: number;
podPhase: InframonitoringtypesPodPhaseDTO;
/**
* @type integer
* @format int64
*/
podRestarts: number;
podStatus: InframonitoringtypesPodStatusDTO;
/**
* @type string
*/
@@ -6694,7 +6596,6 @@ export interface InframonitoringtypesStatefulSetRecordDTO {
*/
meta: InframonitoringtypesStatefulSetRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type number
* @format double
@@ -8618,6 +8519,13 @@ export interface RuletypesRuleDTO {
export enum RuletypesThresholdKindDTO {
basic = 'basic',
}
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
/**
* @type string
*/
id: string;
}
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
@@ -8687,7 +8595,11 @@ export interface ServiceaccounttypesPostableServiceAccountRoleDTO {
/**
* @type string
*/
id: string;
roleId: string;
/**
* @type string
*/
serviceAccountId: string;
}
export interface ServiceaccounttypesServiceAccountDTO {
@@ -10453,6 +10365,28 @@ export type UpdateRoutePolicy200 = {
status: string;
};
export type CreateServiceAccountRole201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteServiceAccountRolePathParameters = {
id: string;
};
export type GetServiceAccountRolePathParameters = {
id: string;
};
export type GetServiceAccountRole200 = {
data: ServiceaccounttypesServiceAccountRoleDTO;
/**
* @type string
*/
status: string;
};
export type ListServiceAccounts200 = {
/**
* @type array
@@ -10536,10 +10470,10 @@ export type GetServiceAccountRoles200 = {
status: string;
};
export type CreateServiceAccountRolePathParameters = {
export type CreateServiceAccountRoleDeprecatedPathParameters = {
id: string;
};
export type CreateServiceAccountRole201 = {
export type CreateServiceAccountRoleDeprecated201 = {
data: TypesIdentifiableDTO;
/**
* @type string
@@ -10547,7 +10481,7 @@ export type CreateServiceAccountRole201 = {
status: string;
};
export type DeleteServiceAccountRolePathParameters = {
export type DeleteServiceAccountRoleDeprecatedPathParameters = {
id: string;
rid: string;
};
@@ -11707,6 +11641,28 @@ export type RotateSession200 = {
status: string;
};
export type CreateUserRole201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteUserRolePathParameters = {
id: string;
};
export type GetUserRolePathParameters = {
id: string;
};
export type GetUserRole200 = {
data: AuthtypesUserRoleDTO;
/**
* @type string
*/
status: string;
};
export type ListUsers200 = {
/**
* @type array

View File

@@ -19,12 +19,15 @@ import type {
import type {
AuthtypesPostableUserDTO,
AuthtypesPostableUserRoleDTO,
CreateInvite201,
CreateResetPasswordToken201,
CreateResetPasswordTokenPathParameters,
CreateUser201,
CreateUserRole201,
DeleteUserDeprecatedPathParameters,
DeleteUserPathParameters,
DeleteUserRolePathParameters,
GetMyUser200,
GetMyUserDeprecated200,
GetResetPasswordToken200,
@@ -37,6 +40,8 @@ import type {
GetUserDeprecated200,
GetUserDeprecatedPathParameters,
GetUserPathParameters,
GetUserRole200,
GetUserRolePathParameters,
GetUsersByRoleID200,
GetUsersByRoleIDPathParameters,
ListUsers200,
@@ -1154,6 +1159,267 @@ export const invalidateGetUsersByRoleID = async (
return queryClient;
};
/**
* This endpoint assigns a role to a user
* @summary Create user role
*/
export const createUserRole = (
authtypesPostableUserRoleDTO?: BodyType<AuthtypesPostableUserRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateUserRole201>({
url: `/api/v2/user_roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: authtypesPostableUserRoleDTO,
signal,
});
};
export const getCreateUserRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createUserRole>>,
TError,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createUserRole>>,
TError,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
TContext
> => {
const mutationKey = ['createUserRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createUserRole>>,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> }
> = (props) => {
const { data } = props ?? {};
return createUserRole(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateUserRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof createUserRole>>
>;
export type CreateUserRoleMutationBody =
| BodyType<AuthtypesPostableUserRoleDTO>
| undefined;
export type CreateUserRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create user role
*/
export const useCreateUserRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createUserRole>>,
TError,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createUserRole>>,
TError,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
TContext
> => {
return useMutation(getCreateUserRoleMutationOptions(options));
};
/**
* This endpoint revokes a role from a user
* @summary Delete user role
*/
export const deleteUserRole = (
{ id }: DeleteUserRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/user_roles/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteUserRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteUserRole>>,
TError,
{ pathParams: DeleteUserRolePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteUserRole>>,
TError,
{ pathParams: DeleteUserRolePathParameters },
TContext
> => {
const mutationKey = ['deleteUserRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteUserRole>>,
{ pathParams: DeleteUserRolePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteUserRole(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteUserRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteUserRole>>
>;
export type DeleteUserRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete user role
*/
export const useDeleteUserRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteUserRole>>,
TError,
{ pathParams: DeleteUserRolePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteUserRole>>,
TError,
{ pathParams: DeleteUserRolePathParameters },
TContext
> => {
return useMutation(getDeleteUserRoleMutationOptions(options));
};
/**
* This endpoint gets an existing user role
* @summary Get user role
*/
export const getUserRole = (
{ id }: GetUserRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetUserRole200>({
url: `/api/v2/user_roles/${id}`,
method: 'GET',
signal,
});
};
export const getGetUserRoleQueryKey = ({ id }: GetUserRolePathParameters) => {
return [`/api/v2/user_roles/${id}`] as const;
};
export const getGetUserRoleQueryOptions = <
TData = Awaited<ReturnType<typeof getUserRole>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetUserRolePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getUserRole>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetUserRoleQueryKey({ id });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getUserRole>>> = ({
signal,
}) => getUserRole({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getUserRole>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetUserRoleQueryResult = NonNullable<
Awaited<ReturnType<typeof getUserRole>>
>;
export type GetUserRoleQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get user role
*/
export function useGetUserRole<
TData = Awaited<ReturnType<typeof getUserRole>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetUserRolePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getUserRole>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetUserRoleQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get user role
*/
export const invalidateGetUserRole = async (
queryClient: QueryClient,
{ id }: GetUserRolePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetUserRoleQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint lists all users for the organization
* @summary List users v2
@@ -1865,6 +2131,7 @@ export const invalidateGetRolesByUserID = async (
/**
* This endpoint assigns the role to the user roles by user id
* @deprecated
* @summary Set user roles
*/
export const setRoleByUserID = (
@@ -1936,6 +2203,7 @@ export type SetRoleByUserIDMutationBody =
export type SetRoleByUserIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Set user roles
*/
export const useSetRoleByUserID = <
@@ -1964,6 +2232,7 @@ export const useSetRoleByUserID = <
};
/**
* This endpoint removes a role from the user by user id and role id
* @deprecated
* @summary Remove a role from user
*/
export const removeUserRoleByUserIDAndRoleID = (
@@ -2022,6 +2291,7 @@ export type RemoveUserRoleByUserIDAndRoleIDMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Remove a role from user
*/
export const useRemoveUserRoleByUserIDAndRoleID = <

View File

@@ -0,0 +1,34 @@
import { type MouseEvent, type ReactNode } from 'react';
import { Badge } from '@signozhq/ui/badge';
interface TagBadgeProps {
children: ReactNode;
// Show a remove button (editable contexts: create modal, settings drawer).
closable?: boolean;
onClose?: (event: MouseEvent<HTMLButtonElement>) => void;
className?: string;
}
// The single sienna tag chip used everywhere dashboards render tags — list rows,
// the details header, and the tag editors. Kept as one component so the tag
// styling stays identical across all of them.
function TagBadge({
children,
closable,
onClose,
className,
}: TagBadgeProps): JSX.Element {
return (
<Badge
color="sienna"
variant="outline"
className={className}
closable={closable}
onClose={onClose}
>
{children}
</Badge>
);
}
export default TagBadge;

View File

@@ -15,59 +15,28 @@
border: 1px solid var(--l2-border);
}
// Sienna chip — matches the dashboard list-row tag badge.
// Sienna chip via the @signozhq Badge; this only constrains its width.
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 240px;
height: 24px;
padding: 2px 4px 2px 8px;
border-radius: 50px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
font-size: 13px;
font-weight: var(--font-weight-normal);
line-height: 20px;
cursor: text;
}
// The tag label is a bare, chrome-less button inside the Badge (double-click to
// edit); strip its Button styling and let it ellipsize.
.tagLabel {
--button-height: auto;
--button-padding: 0;
--button-gap: 0;
--button-variant-ghost-background-color: transparent;
--button-variant-ghost-hover-background-color: transparent;
--button-variant-ghost-color: inherit;
--button-variant-ghost-hover-color: inherit;
--button-variant-ghost-color: currentColor;
--button-variant-ghost-hover-color: currentColor;
overflow: hidden;
max-width: 200px;
font-size: 13px;
font-weight: var(--font-weight-normal);
text-overflow: ellipsis;
white-space: nowrap;
cursor: text;
}
.remove {
// Size overrides to fit the chip, plus a sienna-tinted hover — the Button's
// default ghost hover is a grey that clashes with the chip. Resting color is
// left at the Button default.
--button-height: 16px;
--button-padding: 0;
--button-border-radius: 50%;
--button-variant-ghost-hover-background-color: color-mix(
in srgb,
var(--bg-sienna-500) 22%,
transparent
);
--button-variant-ghost-hover-color: var(--bg-sienna-400);
width: 16px;
min-width: 16px;
flex: none;
}
.input {
flex: 1;
min-width: 120px;

View File

@@ -2,9 +2,9 @@ import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import cx from 'classnames';
import TagBadge from '../TagBadge/TagBadge';
import { parseKeyValueTag } from './utils';
import styles from './TagKeyValueInput.module.scss';
@@ -120,27 +120,23 @@ function TagKeyValueInput({
onBlur={commitEdit}
/>
) : (
<div key={tag} className={styles.tag} data-testid={`${testId}-chip`}>
<TagBadge
key={tag}
className={styles.tag}
closable
onClose={(): void => removeTag(tag)}
>
<Button
variant="ghost"
color="secondary"
className={styles.tagLabel}
title="Double-click to edit"
testId={`${testId}-chip`}
onDoubleClick={(): void => startEdit(index)}
>
{tag}
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.remove}
aria-label={`Remove ${tag}`}
onClick={(): void => removeTag(tag)}
>
<X size={12} />
</Button>
</div>
</TagBadge>
),
)}
<Input

View File

@@ -1,3 +1,4 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
@@ -27,7 +28,7 @@ interface PieArcProps {
fill: string;
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
onLeave: () => void;
onClick?: (slice: PieSlice) => void;
onClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
}
/**
@@ -72,7 +73,7 @@ export default function PieArc({
<g
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
onMouseLeave={onLeave}
onClick={(): void => onClick?.(slice)}
onClick={(event): void => onClick?.(slice, event)}
>
<path d={arcPath} fill={fill} />
{shouldShowLabel && (

View File

@@ -80,6 +80,7 @@ describe('PieArc', () => {
expect(onLeave).toHaveBeenCalledTimes(1);
fireEvent.click(g);
expect(onClick).toHaveBeenCalledWith(SLICE);
// onClick now also receives the DOM event (for drill-down popover positioning).
expect(onClick).toHaveBeenCalledWith(SLICE, expect.anything());
});
});

View File

@@ -1,3 +1,4 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import {
@@ -79,6 +80,10 @@ export interface PieSlice {
label: string;
value: number;
color: string;
/** Source query of the slice's value column — the drill-down target (present for V2 panels). */
queryName?: string;
/** Group-by key→value of the slice's source row, used to build drill-down filters. */
labels?: Record<string, string>;
}
/**
@@ -99,7 +104,7 @@ export interface PieChartProps {
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
*/
id?: string;
/** Fired when a slice (or its legend entry) is clicked. */
onSliceClick?: (slice: PieSlice) => void;
/** Fired when a slice's arc is clicked; carries the DOM event for popover positioning. */
onSliceClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
'data-testid'?: string;
}

View File

@@ -2,7 +2,7 @@
position: relative;
display: flex;
width: 100%;
height: 100%;
height: auto;
flex-direction: column;
// Stacked children (the FullView / standalone graph-manager) sit below the chart

View File

@@ -66,20 +66,6 @@
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.ant-collapse-header) {
border-bottom: 1px solid var(--l1-border);
padding: 12px 8px;
}
:global(.ant-collapse-content-box) {
padding: 0 !important;
padding-block: 0 !important;
:global(.quick-filters .checkbox-filter) {
padding-left: 18px;
}
}
:global(.quick-filters) {
overflow-y: auto;
overflow-x: hidden;
@@ -111,22 +97,6 @@
justify-content: space-between;
}
.quickFiltersCategoryLabel {
display: flex;
align-items: center;
gap: 4px;
}
.quickFiltersCategoryLabelIcon {
margin-right: 8px;
}
.quickFiltersCategoryLabelContainer {
display: flex;
align-items: center;
gap: 4px;
}
.listContainer {
flex: 1;
min-width: 0;
@@ -160,3 +130,106 @@
.listContainerFiltersVisible {
max-width: calc(100% - 280px);
}
.categorySelectorSection {
padding: var(--spacing-4);
}
.sectionHeader {
display: flex;
align-items: center;
gap: var(--spacing-4);
margin-bottom: var(--spacing-4);
&[data-type='filter'] {
padding: 0 var(--spacing-4);
}
}
.sectionLabel {
font-size: var(--periscope-font-size-small);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--l3-foreground);
white-space: nowrap;
}
.sectionLine {
flex: 1;
height: 1px;
background: var(--l1-border);
}
.categoryCard {
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--spacing-3);
padding: var(--spacing-2);
}
.categoryList {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.categoryItem {
display: flex;
align-items: center;
gap: var(--spacing-5);
padding: var(--spacing-3) var(--spacing-4);
border: none;
border-radius: var(--spacing-2);
background: transparent;
cursor: pointer;
width: 100%;
text-align: left;
transition:
background-color 0.2s ease,
color 0.2s ease;
color: var(--l2-foreground);
font-size: var(--periscope-font-size-base);
--typography-color: var(--l1-foreground);
&:hover:not(.categoryItemSelected) {
background: var(--l2-background-hover);
}
svg {
color: var(--l3-foreground);
flex-shrink: 0;
transition: color 0.2s ease;
}
&.categoryItemSelected {
background: var(--accent-primary);
color: var(--l1-background);
font-weight: 600;
&:hover {
background: var(--accent-primary-hover, var(--accent-primary));
}
svg {
color: var(--l1-foreground);
}
}
}
.quickFiltersSection {
flex: 1;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
}

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as Sentry from '@sentry/react';
import { Button, CollapseProps } from 'antd';
import { Collapse, Tooltip } from 'antd';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
@@ -113,192 +112,74 @@ export default function InfraMonitoringK8s(): JSX.Element {
});
};
const renderCategoryLabel = (
icon: JSX.Element,
label: string,
): JSX.Element => (
<div className={styles.quickFiltersCategoryLabel}>
<div className={styles.quickFiltersCategoryLabelContainer}>
{icon}
<Typography.Text>{label}</Typography.Text>
</div>
</div>
const categories = useMemo(
() => [
{
key: K8sCategories.PODS,
label: 'Pods',
icon: <Container size={14} />,
config: GetPodsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.NODES,
label: 'Nodes',
icon: <Workflow size={14} />,
config: GetNodesQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.NAMESPACES,
label: 'Namespaces',
icon: <FilePenLine size={14} />,
config: GetNamespaceQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.CLUSTERS,
label: 'Clusters',
icon: <Boxes size={14} />,
config: GetClustersQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.DEPLOYMENTS,
label: 'Deployments',
icon: <Computer size={14} />,
config: GetDeploymentsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.JOBS,
label: 'Jobs',
icon: <Bolt size={14} />,
config: GetJobsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.DAEMONSETS,
label: 'DaemonSets',
icon: <Group size={14} />,
config: GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.STATEFULSETS,
label: 'StatefulSets',
icon: <ArrowUpDown size={14} />,
config: GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.VOLUMES,
label: 'Volumes',
icon: <HardDrive size={14} />,
config: GetVolumesQuickFiltersConfig(dotMetricsEnabled),
},
],
[dotMetricsEnabled],
);
const items: CollapseProps['items'] = [
{
label: renderCategoryLabel(
<Container size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Pods',
),
key: K8sCategories.PODS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetPodsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Workflow size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Nodes',
),
key: K8sCategories.NODES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetNodesQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<FilePenLine size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Namespaces',
),
key: K8sCategories.NAMESPACES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetNamespaceQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Boxes size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Clusters',
),
key: K8sCategories.CLUSTERS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetClustersQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Computer size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Deployments',
),
key: K8sCategories.DEPLOYMENTS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetDeploymentsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Bolt size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Jobs',
),
key: K8sCategories.JOBS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetJobsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Group size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'DaemonSets',
),
key: K8sCategories.DAEMONSETS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<ArrowUpDown size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'StatefulSets',
),
key: K8sCategories.STATEFULSETS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<HardDrive size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Volumes',
),
key: K8sCategories.VOLUMES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetVolumesQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
// TODO: Enable once we have implemented containers.
// {
// label: (
// <div className="k8s-quick-filters-category-label">
// <div className="k8s-quick-filters-category-label-container">
// <PackageOpen
// size={14}
// className="k8s-quick-filters-category-label-icon"
// />
// <Typography.Text>Containers</Typography.Text>
// </div>
// </div>
// ),
// key: K8sCategories.CONTAINERS,
// showArrow: false,
// children: (
// <QuickFilters
// source={QuickFiltersSource.INFRA_MONITORING}
// config={ContainersQuickFiltersConfig}
// handleFilterVisibilityChange={handleFilterVisibilityChange}
// onFilterChange={handleFilterChange}
// />
// ),
// },
];
const selectedCategoryConfig = useMemo(
() => categories.find((cat) => cat.key === selectedCategory)?.config,
[categories, selectedCategory],
);
const handleCategoryChange = (key: string | string[]): void => {
if (Array.isArray(key) && key.length > 0) {
setSelectedCategory(key[0] as string);
const handleCategorySelect = (key: string): void => {
if (key !== selectedCategory) {
setSelectedCategory(key);
// Reset filters
setUrlFilters(null);
setOrderBy(null);
@@ -332,26 +213,58 @@ export default function InfraMonitoringK8s(): JSX.Element {
<div className={styles.infraContentRow}>
{showFilters && (
<div className={styles.quickFiltersContainer}>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
</Tooltip>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
Viewing · Resource
</Typography.Text>
<div className={styles.sectionLine} />
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
</Tooltip>
</div>
<div className={styles.categoryCard}>
<div className={styles.categoryList}>
{categories.map((category) => (
<button
key={category.key}
type="button"
className={`${styles.categoryItem} ${
selectedCategory === category.key
? styles.categoryItemSelected
: ''
}`}
onClick={(): void => handleCategorySelect(category.key)}
data-testid={`category-${category.key}`}
>
{category.icon}
<Typography.Text>{category.label}</Typography.Text>
</button>
))}
</div>
</div>
</div>
<div className={styles.quickFiltersSection}>
<div className={styles.sectionHeader} data-type="filter">
<Typography.Text className={styles.sectionLabel}>
Filter by
</Typography.Text>
<div className={styles.sectionLine} />
</div>
{selectedCategoryConfig && (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={selectedCategoryConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
)}
</div>
<Collapse
onChange={handleCategoryChange}
items={items}
defaultActiveKey={[selectedCategory]}
activeKey={[selectedCategory]}
accordion
bordered={false}
ghost
/>
</div>
)}

View File

@@ -2,8 +2,8 @@ import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import {
getGetServiceAccountRolesQueryKey,
useCreateServiceAccountRole,
useDeleteServiceAccountRole,
useCreateServiceAccountRoleDeprecated,
useDeleteServiceAccountRoleDeprecated,
useGetServiceAccountRoles,
} from 'api/generated/services/serviceaccount';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
@@ -46,10 +46,10 @@ export function useServiceAccountRoleManager(
);
// the retry for these mutations is safe due to being idempotent on backend
const { mutateAsync: createRole } = useCreateServiceAccountRole({
const { mutateAsync: createRole } = useCreateServiceAccountRoleDeprecated({
mutation: { retry: retryOn429 },
});
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
const { mutateAsync: deleteRole } = useDeleteServiceAccountRoleDeprecated({
mutation: { retry: retryOn429 },
});

View File

@@ -7,7 +7,7 @@ import {
SolidInfoCircle,
X,
} from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import TagBadge from 'components/TagBadge/TagBadge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { TooltipSimple } from '@signozhq/ui/tooltip';
@@ -203,19 +203,13 @@ function DashboardInfo({
data-testid="dashboard-tags"
>
{visibleTags.map((tag) => (
<Badge key={tag} color="sienna" variant="outline">
{tag}
</Badge>
<TagBadge key={tag}>{tag}</TagBadge>
))}
{remainingTags.length > 0 && (
<TooltipSimple title={remainingTags.join(', ')}>
<Badge
color="sienna"
variant="outline"
data-testid="dashboard-tags-overflow"
>
+{remainingTags.length}
</Badge>
<span data-testid="dashboard-tags-overflow">
<TagBadge>+{remainingTags.length}</TagBadge>
</span>
</TooltipSimple>
)}
</div>

View File

@@ -63,8 +63,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const { patchAsync } = useOptimisticPatch();
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const {
isPickerOpen,
openPicker,
closePicker,
createPanel,
targetLayoutIndex,
} = useCreatePanel();
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
@@ -183,6 +188,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
open={isPickerOpen}
onClose={closePicker}
onSelect={createPanel}
defaultLayoutIndex={targetLayoutIndex}
/>
</section>
);

View File

@@ -3,10 +3,10 @@ import type { TagtypesPostableTagDTO } from 'api/generated/services/sigNoz.schem
export { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
export { parseKeyValueTag } from 'components/TagKeyValueInput/utils';
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
// collapsed back to just `x` for display.
// The tag editor is strictly key:value, so always render both sides — a
// `key:key` tag stays `key:key` rather than collapsing to a bare `key`.
export function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
return tags.map((t) => `${t.key}:${t.value}`);
}
export function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {

View File

@@ -14,8 +14,6 @@ import {
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import { useTimezone } from 'providers/Timezone';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
import NoData from '../../components/NoData/NoData';
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
@@ -25,7 +23,10 @@ import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { buildBarChartConfig } from './utils/buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
@@ -40,6 +41,7 @@ function BarPanelRenderer({
dashboardPreference,
panelMode,
onCloseStandaloneView,
enableDrillDown,
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
@@ -56,12 +58,10 @@ function BarPanelRenderer({
[panel.spec.queries],
);
// 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.
// X-scale clamps come from the request that produced the data, so each panel
// pins to the window it fetched.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
);
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data.requestPayload]);
@@ -155,10 +155,29 @@ function BarPanelRenderer({
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
(args: ChartClickData): void => {
if (!onClick) {
return;
}
const payload = enrichChartClick({
clickData: args,
series: flatSeries,
builderQueries,
});
if (!payload) {
return;
}
const timeRange = stepClickTimeRange({
clickedDataTimestamp: args.clickedDataTimestamp,
queryName: payload.context.queryName,
builderQueries,
stepInterval: getExecStats(data.response)?.stepIntervals?.[
payload.context.queryName
],
});
onClick({ ...payload, context: { ...payload.context, timeRange } });
},
[onClick],
[onClick, flatSeries, builderQueries, data.response],
);
return (
@@ -188,7 +207,7 @@ function BarPanelRenderer({
syncFilterMode={dashboardPreference?.syncFilterMode}
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
onClick={enableDrillDown ? handleChartClick : undefined}
/>
)}
</div>

View File

@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
download: false,
createAlert: true,
search: false,
drilldown: true,
},
};

View File

@@ -20,7 +20,6 @@ import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildHistogramConfig } from './utils/buildConfig';
import { prepareHistogramData } from './prepareData';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function HistogramPanelRenderer({
panelId,
@@ -28,7 +27,6 @@ function HistogramPanelRenderer({
data,
refetch,
panelMode,
onClick,
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
@@ -100,13 +98,6 @@ function HistogramPanelRenderer({
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
@@ -127,7 +118,6 @@ function HistogramPanelRenderer({
width={containerDimensions.width}
height={containerDimensions.height}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>

View File

@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
download: false,
createAlert: true,
search: false,
drilldown: false,
},
};

View File

@@ -37,5 +37,6 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
download: false,
createAlert: false,
search: true,
drilldown: false,
},
};

View File

@@ -1,4 +1,9 @@
import { useMemo } from 'react';
import {
useCallback,
useMemo,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
} from 'react';
import type { DashboardtypesNumberPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
@@ -8,6 +13,9 @@ import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { formatPanelValue } from '../../utils/formatPanelValue';
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
import { enrichNumberClick } from '../../utils/drilldown/enrichNumberClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { prepareNumberData } from './prepareData';
import { mapNumberThresholds } from './utils';
@@ -17,24 +25,31 @@ function NumberPanelRenderer({
panel,
data,
refetch,
onClick,
enableDrillDown,
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
const value = useMemo(
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
const tables = useMemo(
() =>
prepareNumberData(
prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap ?? {},
requestPayload: data.requestPayload,
}),
),
prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap ?? {},
requestPayload: data.requestPayload,
}),
[data.response, data.legendMap, data.requestPayload],
);
const value = useMemo(() => prepareNumberData(tables), [tables]);
const thresholds = useMemo(
() => mapNumberThresholds(spec.thresholds),
[spec.thresholds],
@@ -54,10 +69,60 @@ function NumberPanelRenderer({
[value, unit, decimalPrecision],
);
const openDrilldown = useCallback(
(coordinates: { x: number; y: number }): void => {
if (!onClick) {
return;
}
const payload = enrichNumberClick({
tables,
builderQueries,
coordinates,
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {
onClick(payload);
}
},
[onClick, tables, data.requestPayload, builderQueries],
);
const handleClick = useCallback(
(event: ReactMouseEvent<HTMLDivElement>): void =>
openDrilldown({ x: event.clientX, y: event.clientY }),
[openDrilldown],
);
const handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
openDrilldown({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
}
},
[openDrilldown],
);
// The whole panel is the value, so the container itself is the drill-down target.
const isClickable = enableDrillDown && !!onClick && value !== null;
return (
<div
data-testid="number-panel-renderer"
className={PanelStyles.panelContainer}
{...(isClickable
? {
role: 'button',
tabIndex: 0,
onClick: handleClick,
onKeyDown: handleKeyDown,
style: { cursor: 'pointer' },
}
: {})}
>
{value === null ? (
<NoData data-testid="number-panel-no-data" onRetry={refetch} />

View File

@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
download: false,
createAlert: true,
search: false,
drilldown: true,
},
};

View File

@@ -1,4 +1,8 @@
import { useCallback, useMemo } from 'react';
import {
useCallback,
useMemo,
type MouseEvent as ReactMouseEvent,
} from 'react';
import type { DashboardtypesPieChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Pie from 'container/DashboardContainer/visualization/charts/Pie/Pie';
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
@@ -13,6 +17,9 @@ import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import { enrichPieClick } from '../../utils/drilldown/enrichPieClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { preparePieData } from './prepareData';
@@ -22,6 +29,7 @@ function PiePanelRenderer({
data,
refetch,
onClick,
enableDrillDown,
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
const isDarkMode = useIsDarkMode();
@@ -30,6 +38,11 @@ function PiePanelRenderer({
[panel.spec.plugin.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
const slices = useMemo(
() =>
preparePieData({
@@ -61,10 +74,21 @@ function PiePanelRenderer({
);
const handleSliceClick = useCallback(
(slice: PieSlice) => {
onClick?.({ label: slice.label, value: slice.value });
(slice: PieSlice, event: ReactMouseEvent): void => {
if (!onClick) {
return;
}
const payload = enrichPieClick({
slice,
builderQueries,
coordinates: { x: event.clientX, y: event.clientY },
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {
onClick(payload);
}
},
[onClick],
[onClick, builderQueries, data.requestPayload],
);
return (
@@ -79,7 +103,7 @@ function PiePanelRenderer({
isDarkMode={isDarkMode}
position={legendPosition}
id={panelId}
onSliceClick={handleSliceClick}
onSliceClick={enableDrillDown ? handleSliceClick : undefined}
data-testid="pie-chart"
/>
)}

View File

@@ -23,5 +23,6 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
download: false,
createAlert: false,
search: false,
drilldown: true,
},
};

View File

@@ -1,4 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from 'react';
import { Table } from 'antd';
import type { DashboardtypesTablePanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -8,6 +15,9 @@ import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/query
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
import { enrichTableClick } from '../../utils/drilldown/enrichTableClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { useResizableColumns } from '../../hooks/useResizableColumns';
import NoData from '../../components/NoData/NoData';
@@ -27,6 +37,8 @@ function TablePanelRenderer({
data,
refetch,
searchTerm = '',
onClick,
enableDrillDown,
}: PanelRendererProps<'signoz/TablePanel'>): JSX.Element {
// Measure the panel so each page roughly fills it (min 10 rows) with a pinned header.
const containerRef = useRef<HTMLDivElement>(null);
@@ -42,6 +54,11 @@ function TablePanelRenderer({
[panel.spec.plugin.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
// V5 joins every query into a single scalar result, so the first non-empty
// table is the whole panel.
const table = useMemo(
@@ -64,6 +81,34 @@ function TablePanelRenderer({
[spec.thresholds],
);
const handleCellClick = useCallback(
({
columnId,
record,
event,
}: {
columnId: string;
record: TableRowData;
event: ReactMouseEvent<HTMLElement>;
}): void => {
if (!onClick || !table) {
return;
}
const payload = enrichTableClick({
record,
columnId,
table,
builderQueries,
coordinates: { x: event.clientX, y: event.clientY },
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {
onClick(payload);
}
},
[onClick, table, builderQueries, data.requestPayload],
);
const columns = useMemo(
() =>
table
@@ -72,9 +117,17 @@ function TablePanelRenderer({
columnUnits: spec.formatting?.columnUnits ?? {},
decimalPrecision,
thresholdsByColumn,
onCellClick: enableDrillDown ? handleCellClick : undefined,
})
: [],
[table, spec.formatting?.columnUnits, decimalPrecision, thresholdsByColumn],
[
table,
spec.formatting?.columnUnits,
decimalPrecision,
thresholdsByColumn,
enableDrillDown,
handleCellClick,
],
);
// User-resizable columns, persisted per panel to localStorage.

View File

@@ -25,5 +25,6 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
createAlert: false,
// V1 parity: only tables (and lists) expose the header search box.
search: true,
drilldown: true,
},
};

View File

@@ -52,6 +52,12 @@ export interface BuildTableColumnsArgs {
decimalPrecision?: PrecisionOption;
/** Thresholds grouped by column name (see `mapTableThresholds`). */
thresholdsByColumn: Record<string, PanelThreshold[]>;
/** When set, every body cell becomes a drill-down target (keyed by its column id). */
onCellClick?: (args: {
columnId: string;
record: TableRowData;
event: React.MouseEvent<HTMLElement>;
}) => void;
}
/**
@@ -65,6 +71,7 @@ export function buildTableColumns({
columnUnits,
decimalPrecision,
thresholdsByColumn,
onCellClick,
}: BuildTableColumnsArgs): TableProps<TableRowData>['columns'] {
return table.columns.map((col) => {
// Column key = query identifier for value columns, group name otherwise. Units
@@ -97,19 +104,26 @@ export function buildTableColumns({
}
return text;
},
onCell: (record: TableRowData): { style?: React.CSSProperties } => {
if (!col.isValueColumn || colThresholds.length === 0) {
return {};
onCell: (record: TableRowData): React.HTMLAttributes<HTMLElement> => {
const cellProps: React.HTMLAttributes<HTMLElement> = {};
if (col.isValueColumn && colThresholds.length > 0) {
const num = Number(record[key]);
if (Number.isFinite(num)) {
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
if (threshold?.format === 'background') {
cellProps.style = { backgroundColor: threshold.color };
}
}
}
const num = Number(record[key]);
if (!Number.isFinite(num)) {
return {};
if (onCellClick) {
cellProps.onClick = (event): void =>
onCellClick({ columnId: key, record, event });
cellProps.style = { ...cellProps.style, cursor: 'pointer' };
}
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
if (threshold?.format === 'background') {
return { style: { backgroundColor: threshold.color } };
}
return {};
return cellProps;
},
};
});

View File

@@ -14,8 +14,6 @@ import {
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import { useTimezone } from 'providers/Timezone';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
import NoData from '../../components/NoData/NoData';
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
@@ -25,7 +23,10 @@ import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { buildTimeSeriesConfig } from './utils/buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
@@ -40,6 +41,7 @@ function TimeSeriesPanelRenderer({
dashboardPreference,
panelMode,
onCloseStandaloneView,
enableDrillDown,
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
@@ -58,11 +60,9 @@ function TimeSeriesPanelRenderer({
// 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.
// new data arrives.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
);
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data.requestPayload]);
@@ -156,10 +156,29 @@ function TimeSeriesPanelRenderer({
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
(args: ChartClickData): void => {
if (!onClick) {
return;
}
const payload = enrichChartClick({
clickData: args,
series: flatSeries,
builderQueries,
});
if (!payload) {
return;
}
const timeRange = stepClickTimeRange({
clickedDataTimestamp: args.clickedDataTimestamp,
queryName: payload.context.queryName,
builderQueries,
stepInterval: getExecStats(data.response)?.stepIntervals?.[
payload.context.queryName
],
});
onClick({ ...payload, context: { ...payload.context, timeRange } });
},
[onClick],
[onClick, flatSeries, builderQueries, data.response],
);
return (
@@ -188,7 +207,7 @@ function TimeSeriesPanelRenderer({
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
onClick={enableDrillDown ? handleChartClick : undefined}
/>
)}
</div>

View File

@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
download: false,
createAlert: true,
search: false,
drilldown: true,
},
};

View File

@@ -0,0 +1,36 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
// Drilldown is the click-to-context-menu feature ported from V1. Every renderer turns its native
// click into one `DrilldownClickPayload`; the kind-agnostic orchestration layer consumes only that.
// `FilterData` is imported read-only from the V1 util so the payload feeds `buildDrilldownUrl`
// directly, with no intermediate translation.
/** The clicked point's drilldown context, derived from the flattened series/columns the renderer holds. */
export interface DrilldownContext {
/** The clicked series'/column's query. Drives query selection in `getViewQuery`. */
queryName: string;
/** Telemetry signal of the clicked query — picks the explorer the drilldown navigates to. */
signal: TelemetrytypesSignalDTO;
/** Key/value/op filters from the clicked point's group-by labels (empty when ungrouped). */
filters: FilterData[];
/** Explorer time window. Charts use the clicked bucket ±step; scalar panels use the fetched window. */
timeRange?: { startTime: number; endTime: number };
/** Series/slice display name, shown as the menu header's second line. */
label?: string;
/** Series/slice colour; tints the menu header label and item icons (charts/pie only). */
seriesColor?: string;
/** Tables only: a value column opens the aggregate menu; a group column opens filter-by-value. */
columnKind?: 'aggregate' | 'group';
/** Group-column click only: the clicked column's key, for the filter-by-value menu. */
clickedKey?: string;
/** Group-column click only: the clicked cell's value, for the filter-by-value menu. */
clickedValue?: string | number;
}
/** What each renderer's `onClick` emits: where to anchor the popover plus the drilldown context. */
export interface DrilldownClickPayload {
/** Absolute viewport coordinates for the popover anchor. */
coordinates: { x: number; y: number };
context: DrilldownContext;
}

View File

@@ -1,24 +1,5 @@
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import type { PanelKind } from './panelKind';
/** 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>;
columnId?: string;
};
export type ListClickEvent = {
rowData: Record<string, unknown>;
};
export type PieClickEvent = { label: string; value: number };
/** Union of every panel click event — switched on by `source` at the boundary. */
export type PanelClickEvent =
| ChartClickEvent
| TableClickEvent
| ListClickEvent
| PieClickEvent;
import type { DrilldownClickPayload } from './drilldown';
type DragSelect = (start: number, end: number) => void;
@@ -29,23 +10,27 @@ type CloseStandaloneView = () => void;
* 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.
*
* Every interactive kind's `onClick` receives the unified `DrilldownClickPayload`
* its renderer enriches from the native click. Number/Value drills down on its
* single value. Histogram and List are omitted (V1 has no drill-down for either):
* they inherit the empty `object` base, so their renderers get only base props
* with no click gesture.
*/
export type PanelInteractionMap = Record<PanelKind, object> & {
'signoz/TimeSeriesPanel': {
onClick?: (event: ChartClickEvent) => void;
onClick?: (event: DrilldownClickPayload) => void;
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
};
'signoz/BarChartPanel': {
onClick?: (event: ChartClickEvent) => void;
onClick?: (event: DrilldownClickPayload) => void;
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
};
'signoz/HistogramPanel': { onClick?: (event: ChartClickEvent) => void };
'signoz/TablePanel': { onClick?: (event: TableClickEvent) => void };
'signoz/ListPanel': { onClick?: (event: ListClickEvent) => void };
'signoz/PieChartPanel': { onClick?: (event: PieClickEvent) => void };
'signoz/NumberPanel': Record<string, never>;
'signoz/TablePanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/PieChartPanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/NumberPanel': { onClick?: (event: DrilldownClickPayload) => void };
};
/**
@@ -53,7 +38,7 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
* registry render boundary). The supertype the per-kind shapes are cast to once.
*/
export interface AnyPanelInteractionProps {
onClick?: (event: PanelClickEvent) => void;
onClick?: (event: DrilldownClickPayload) => void;
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
}

View File

@@ -30,6 +30,11 @@ export interface PanelActionCapabilities {
* tabular kinds). Not a menu action — the renderer must consume `searchTerm`.
*/
search: boolean;
/**
* Kind supports click-to-drilldown (context menu + View/Breakout). V1 parity: charts + scalar
* Pie/Value/Table; Histogram/List opt out. AND-ed with "has a builder query" in `useDrilldown`.
*/
drilldown: boolean;
}
export interface PanelDefinition<K extends PanelKind = PanelKind> {

View File

@@ -0,0 +1,46 @@
import type { Querybuildertypesv5QueryRangeRequestDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelTimeRange } from '../getPanelTimeRange';
// Fallback path reads the redux global-time selection; stub both so the no-payload branch
// is deterministic.
jest.mock('store', () => ({
__esModule: true,
default: { getState: (): unknown => ({ globalTime: { selectedTime: '5m' } }) },
}));
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
default: (): { start: string; end: string } => ({
start: '1700',
end: '1800',
}),
}));
const request = (
start?: number,
end?: number,
): Querybuildertypesv5QueryRangeRequestDTO =>
({ start, end }) as Querybuildertypesv5QueryRangeRequestDTO;
describe('getPanelTimeRange', () => {
it('converts the request start/end from ms to seconds', () => {
expect(getPanelTimeRange(request(5_000, 9_000))).toStrictEqual({
startTime: 5,
endTime: 9,
});
});
it('falls back to the global-time window when there is no request', () => {
expect(getPanelTimeRange(undefined)).toStrictEqual({
startTime: 1700,
endTime: 1800,
});
});
it('falls back when the request is missing an endpoint', () => {
expect(getPanelTimeRange(request(5_000, undefined))).toStrictEqual({
startTime: 1700,
endTime: 1800,
});
});
});

View File

@@ -0,0 +1,483 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import type {
PanelSeries,
PanelTable,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownContext } from '../../../types/drilldown';
import { buildAggregateData } from '../buildAggregateData';
import { stepClickTimeRange } from '../chartClickTimeRange';
import { enrichChartClick } from '../enrichChartClick';
import { enrichNumberClick } from '../enrichNumberClick';
import { enrichPieClick } from '../enrichPieClick';
import { enrichTableClick } from '../enrichTableClick';
import { getDataLinks } from '../getDataLinks';
import { resolvePanelContextLinks } from '../resolvePanelContextLinks';
import { resolveDrilldownSignal } from '../signal';
// The v5 BuilderQuery union is too verbose to construct field-typed inline; cast at the boundary.
function builderQuery(spec: Record<string, unknown>): BuilderQuery {
return spec as unknown as BuilderQuery;
}
function panelSeries(overrides: Partial<PanelSeries> = {}): PanelSeries {
return {
queryName: 'A',
legend: '',
labels: { 'service.name': 'frontend' },
kind: 'series',
values: [],
aggregation: { index: 0, alias: '' },
...overrides,
};
}
function chartClick(
focusedSeries: ChartClickData['focusedSeries'],
): ChartClickData {
return {
xValue: 0,
yValue: 0,
focusedSeries,
clickedDataTimestamp: 1_700_000_000,
mouseX: 10,
mouseY: 20,
absoluteMouseX: 110,
absoluteMouseY: 220,
};
}
function focused(seriesIndex: number): ChartClickData['focusedSeries'] {
return { seriesIndex, seriesName: 'frontend', value: 1, color: '#fff' };
}
describe('resolveDrilldownSignal', () => {
it('maps logs/traces directly', () => {
expect(resolveDrilldownSignal(builderQuery({ signal: 'logs' }))).toBe('logs');
expect(resolveDrilldownSignal(builderQuery({ signal: 'traces' }))).toBe(
'traces',
);
});
it('falls back to metrics for metrics, meter and unknown/missing signals', () => {
expect(resolveDrilldownSignal(builderQuery({ signal: 'metrics' }))).toBe(
'metrics',
);
expect(resolveDrilldownSignal(builderQuery({ signal: 'meter' }))).toBe(
'metrics',
);
expect(resolveDrilldownSignal(undefined)).toBe('metrics');
});
});
describe('enrichChartClick', () => {
const series = [
panelSeries({ queryName: 'A', labels: { 'service.name': 'frontend' } }),
panelSeries({ queryName: 'B', labels: { 'service.name': 'cart' } }),
];
it('maps the uPlot series index to the (index - 1) flattened series', () => {
// uPlot series[0] is the x-axis, so data series start at 1.
const payload = enrichChartClick({
clickData: chartClick(focused(2)),
series,
builderQueries: [
builderQuery({
name: 'A',
signal: 'metrics',
groupBy: [{ name: 'service.name' }],
}),
builderQuery({
name: 'B',
signal: 'logs',
groupBy: [{ name: 'service.name' }],
}),
],
});
expect(payload?.context.queryName).toBe('B');
expect(payload?.context.signal).toBe('logs');
expect(payload?.context.filters).toStrictEqual([
expect.objectContaining({ filterKey: 'service.name', filterValue: 'cart' }),
]);
expect(payload?.context.seriesColor).toBe('#fff');
expect(payload?.coordinates).toStrictEqual({ x: 110, y: 220 });
});
it('passes through the caller-computed time range and resolves the signal', () => {
const payload = enrichChartClick({
clickData: chartClick(focused(1)),
series,
builderQueries: [builderQuery({ name: 'A', signal: 'traces' })],
timeRange: { startTime: 100, endTime: 200 },
});
expect(payload?.context.signal).toBe('traces');
expect(payload?.context.timeRange).toStrictEqual({
startTime: 100,
endTime: 200,
});
});
it('returns null when there is no focused series', () => {
expect(
enrichChartClick({
clickData: chartClick(null),
series,
builderQueries: [],
}),
).toBeNull();
});
it('returns null when the series index maps to no series', () => {
expect(
enrichChartClick({
clickData: chartClick(focused(99)),
series,
builderQueries: [],
}),
).toBeNull();
});
it('returns null for formula queries (queryName starts with F)', () => {
expect(
enrichChartClick({
clickData: chartClick(focused(1)),
series: [panelSeries({ queryName: 'F1' })],
builderQueries: [],
}),
).toBeNull();
});
it('emits empty filters for an ungrouped series (drops the legend backfill label)', () => {
const payload = enrichChartClick({
clickData: chartClick(focused(1)),
series: [panelSeries({ queryName: 'A', labels: { A: 'A' } })],
builderQueries: [builderQuery({ name: 'A', signal: 'metrics' })],
});
expect(payload?.context.filters).toStrictEqual([]);
expect(payload?.context.queryName).toBe('A');
});
it('drops labels that are not group-by dimensions', () => {
const payload = enrichChartClick({
clickData: chartClick(focused(1)),
series: [
panelSeries({
queryName: 'A',
labels: { 'service.name': 'frontend', __name__: 'http_requests' },
}),
],
builderQueries: [
builderQuery({
name: 'A',
signal: 'metrics',
groupBy: [{ name: 'service.name' }],
}),
],
});
expect(payload?.context.filters).toStrictEqual([
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
]);
});
});
describe('buildAggregateData', () => {
it('projects the drilldown context onto the V1 AggregateData shape', () => {
const context: DrilldownContext = {
queryName: 'A',
signal: TelemetrytypesSignalDTO.logs,
filters: [{ filterKey: 'k', filterValue: 'v', operator: '=' }],
timeRange: { startTime: 1, endTime: 2 },
label: 'frontend',
seriesColor: '#abc',
columnKind: 'aggregate',
};
expect(buildAggregateData(context)).toStrictEqual({
queryName: 'A',
filters: [{ filterKey: 'k', filterValue: 'v', operator: '=' }],
timeRange: { startTime: 1, endTime: 2 },
label: 'frontend',
seriesColor: '#abc',
});
});
});
describe('enrichNumberClick', () => {
const numberTable = (queryName: string): PanelTable => ({
queryName,
legend: '',
columns: [{ name: 'value', queryName, isValueColumn: true, id: queryName }],
rows: [{ data: { [queryName]: 42 } }],
});
it('drills down on the displayed value column with empty filters and no label', () => {
const payload = enrichNumberClick({
tables: [numberTable('A')],
builderQueries: [builderQuery({ name: 'A', signal: 'logs' })],
coordinates: { x: 5, y: 6 },
timeRange: { startTime: 1, endTime: 2 },
});
// No label: the menu header falls back to the aggregation expression (V1 parity).
expect(payload?.context).toStrictEqual({
queryName: 'A',
signal: 'logs',
filters: [],
timeRange: { startTime: 1, endTime: 2 },
});
expect(payload?.coordinates).toStrictEqual({ x: 5, y: 6 });
});
it('drills into the displayed value column, not the first builder query', () => {
// Panel shows query B's value column; drilldown must target B, not A.
const payload = enrichNumberClick({
tables: [numberTable('B')],
builderQueries: [
builderQuery({ name: 'A', signal: 'logs' }),
builderQuery({ name: 'B', signal: 'traces' }),
],
coordinates: { x: 0, y: 0 },
});
expect(payload?.context.queryName).toBe('B');
expect(payload?.context.signal).toBe('traces');
});
it('returns null when there is no drillable query', () => {
expect(
enrichNumberClick({
tables: [],
builderQueries: [],
coordinates: { x: 0, y: 0 },
}),
).toBeNull();
});
it('returns null for a formula query', () => {
expect(
enrichNumberClick({
tables: [numberTable('F1')],
builderQueries: [],
coordinates: { x: 0, y: 0 },
}),
).toBeNull();
});
});
describe('enrichTableClick', () => {
const table: PanelTable = {
queryName: 'A',
legend: '',
columns: [
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{ name: 'p99', queryName: 'A', isValueColumn: true, id: 'A' },
],
rows: [{ data: { 'service.name': 'frontend', A: 42 } }],
};
const record = { 'service.name': 'frontend', A: 42 };
const builderQueries = [builderQuery({ name: 'A', signal: 'traces' })];
it('builds equality filters from the row group cells for a value-column click', () => {
const payload = enrichTableClick({
record,
columnId: 'A',
table,
builderQueries,
coordinates: { x: 1, y: 2 },
timeRange: { startTime: 10, endTime: 20 },
});
expect(payload?.context.queryName).toBe('A');
expect(payload?.context.signal).toBe('traces');
expect(payload?.context.columnKind).toBe('aggregate');
expect(payload?.context.clickedKey).toBeUndefined();
// No label: the aggregate menu header falls back to the aggregation expression,
// not the value column name (V1 parity).
expect(payload?.context.label).toBeUndefined();
expect(payload?.context.filters).toStrictEqual([
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
]);
});
it('falls back to the row value column and carries the clicked cell for a group click', () => {
const payload = enrichTableClick({
record,
columnId: 'service.name',
table,
builderQueries,
coordinates: { x: 1, y: 2 },
});
expect(payload?.context.queryName).toBe('A');
expect(payload?.context.columnKind).toBe('group');
expect(payload?.context.clickedKey).toBe('service.name');
expect(payload?.context.clickedValue).toBe('frontend');
});
it('returns null when the table has no value column', () => {
const groupOnly: PanelTable = {
queryName: 'A',
legend: '',
columns: [
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
],
rows: [{ data: { 'service.name': 'frontend' } }],
};
expect(
enrichTableClick({
record,
columnId: 'service.name',
table: groupOnly,
builderQueries,
coordinates: { x: 1, y: 2 },
}),
).toBeNull();
});
});
describe('enrichPieClick', () => {
it('builds filters from the slice labels and resolves the signal', () => {
const payload = enrichPieClick({
slice: {
label: 'frontend',
value: 12,
color: '#abc',
queryName: 'A',
labels: { 'service.name': 'frontend' },
},
builderQueries: [
builderQuery({
name: 'A',
signal: 'traces',
groupBy: [{ name: 'service.name' }],
}),
],
coordinates: { x: 7, y: 8 },
timeRange: { startTime: 1, endTime: 2 },
});
expect(payload?.context.queryName).toBe('A');
expect(payload?.context.signal).toBe('traces');
expect(payload?.context.filters).toStrictEqual([
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
]);
});
it('returns null for a slice with no source query', () => {
expect(
enrichPieClick({
slice: { label: 'x', value: 1, color: '#000' },
builderQueries: [],
coordinates: { x: 0, y: 0 },
}),
).toBeNull();
});
});
describe('resolvePanelContextLinks', () => {
it('substitutes the clicked field value (_-prefixed) into the label and URL', () => {
const resolved = resolvePanelContextLinks(
[
{
name: 'Runbook for {{_service.name}}',
url: 'https://wiki/{{_service.name}}',
},
],
{ '_service.name': 'frontend' },
);
expect(resolved).toHaveLength(1);
expect(resolved[0].label).toBe('Runbook for frontend');
expect(resolved[0].url).toBe('https://wiki/frontend');
});
it('drops links without a URL', () => {
expect(resolvePanelContextLinks([{ name: 'No URL' }], {})).toStrictEqual([]);
expect(resolvePanelContextLinks(undefined, {})).toStrictEqual([]);
});
it('keeps the raw URL when renderVariables is false', () => {
const resolved = resolvePanelContextLinks(
[
{
name: 'Literal',
url: 'https://wiki/{{_service.name}}',
renderVariables: false,
},
],
{ '_service.name': 'frontend' },
);
expect(resolved[0].url).toBe('https://wiki/{{_service.name}}');
});
});
describe('stepClickTimeRange', () => {
it('returns [clickedTs, clickedTs + step] for a non-APM query', () => {
expect(
stepClickTimeRange({
clickedDataTimestamp: 1000,
queryName: 'A',
builderQueries: [builderQuery({ name: 'A', signal: 'logs' })],
stepInterval: 30,
}),
).toStrictEqual({ startTime: 1000, endTime: 1030 });
});
it('falls back to a 60s step when no interval is provided', () => {
expect(
stepClickTimeRange({
clickedDataTimestamp: 1000,
queryName: 'A',
builderQueries: [
builderQuery({
name: 'A',
signal: 'metrics',
aggregations: [{ metricName: 'custom_metric' }],
}),
],
}),
).toStrictEqual({ startTime: 1000, endTime: 1060 });
});
});
describe('getDataLinks', () => {
it('adds a "View Trace Details" link when the filters carry a trace_id', () => {
expect(
getDataLinks([
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
{ filterKey: 'trace_id', filterValue: 'abc123', operator: '=' },
]),
).toStrictEqual([
{
id: 'view-trace-details',
label: 'View Trace Details',
url: '/trace/abc123',
},
]);
});
it('returns no links when there is no trace_id', () => {
expect(
getDataLinks([
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
]),
).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,18 @@
import type { AggregateData } from 'container/QueryTable/Drilldown/useAggregateDrilldown';
import type { DrilldownContext } from '../../types/drilldown';
/**
* Adapts a V2 `DrilldownContext` to the V1 `AggregateData` that `buildDrilldownUrl`/the drilldown
* navigate hook consume. The single boundary between the V2 click payload and the reused V1
* navigation machinery.
*/
export function buildAggregateData(context: DrilldownContext): AggregateData {
return {
queryName: context.queryName,
filters: context.filters,
timeRange: context.timeRange,
label: context.label,
seriesColor: context.seriesColor,
};
}

View File

@@ -0,0 +1,38 @@
import {
getTimeRangeFromStepInterval,
isApmMetric,
} from 'container/PanelWrapper/utils';
import type { BuilderQuery, MetricAggregation } from 'types/api/v5/queryRange';
/** Fallback step (seconds) when the response carries no per-query step interval (V1 parity). */
const DEFAULT_STEP_INTERVAL = 60;
interface StepClickTimeRangeArgs {
/** Clicked bucket timestamp, in the chart's x-unit (epoch seconds). */
clickedDataTimestamp: number;
/** The clicked series' query — used to detect APM metrics. */
queryName: string;
builderQueries: BuilderQuery[];
/** Clicked series' step (seconds); falls back to DEFAULT_STEP_INTERVAL. */
stepInterval?: number;
}
/**
* Time window for a time-axis chart click: the clicked bucket plus one step (V1 parity). APM-metric
* panels widen the window one step to the left. Shared by the TimeSeries and Bar renderers; the
* matching field remapping happens later inside `getViewQuery`.
*/
export function stepClickTimeRange({
clickedDataTimestamp,
queryName,
builderQueries,
stepInterval = DEFAULT_STEP_INTERVAL,
}: StepClickTimeRangeArgs): { startTime: number; endTime: number } {
const builderQuery = builderQueries.find((query) => query.name === queryName);
const isApm =
builderQuery?.signal === 'metrics' &&
isApmMetric(
(builderQuery?.aggregations?.[0] as MetricAggregation)?.metricName ?? '',
);
return getTimeRangeFromStepInterval(stepInterval, clickedDataTimestamp, isApm);
}

View File

@@ -0,0 +1,62 @@
import { isValidQueryName } from 'container/QueryTable/Drilldown/drilldownUtils';
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownClickPayload } from '../../types/drilldown';
import { getGroupByFilters } from './getGroupByFilters';
import { resolveDrilldownSignal } from './signal';
interface EnrichChartClickArgs {
clickData: ChartClickData;
/** Flattened series in the same order they were added to uPlot (see `prepareAlignedData`/`addSeries`). */
series: PanelSeries[];
/** The panel's builder queries, for resolving the clicked series' signal by `queryName`. */
builderQueries: BuilderQuery[];
/** Explorer time window; the caller computes it (clicked bucket ±step for time charts, panel window for histograms). */
timeRange?: { startTime: number; endTime: number };
}
/**
* Turns a uPlot click (time-series or bar) into a drilldown payload. Resolves the clicked series via
* uPlot's series index (index 0 is the x-axis, so data series start at 1 → `series[seriesIndex - 1]`)
* and builds equality filters from its group-by label values. Returns `null` when the click can't be
* attributed to a drillable series (no focused series, unmapped index, or a formula query).
*/
export function enrichChartClick({
clickData,
series,
builderQueries,
timeRange,
}: EnrichChartClickArgs): DrilldownClickPayload | null {
const { focusedSeries } = clickData;
if (!focusedSeries) {
return null;
}
const panelSeries = series[focusedSeries.seriesIndex - 1];
if (!panelSeries || !isValidQueryName(panelSeries.queryName)) {
return null;
}
const builderQuery = builderQueries.find(
(query) => query.name === panelSeries.queryName,
);
const filters = builderQuery
? getGroupByFilters(panelSeries.labels, builderQuery)
: [];
return {
coordinates: { x: clickData.absoluteMouseX, y: clickData.absoluteMouseY },
context: {
queryName: panelSeries.queryName,
signal: resolveDrilldownSignal(builderQuery),
filters,
timeRange,
label: focusedSeries.seriesName,
seriesColor: focusedSeries.color,
},
};
}

View File

@@ -0,0 +1,49 @@
import { isValidQueryName } from 'container/QueryTable/Drilldown/drilldownUtils';
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownClickPayload } from '../../types/drilldown';
import { resolveDrilldownSignal } from './signal';
interface EnrichNumberClickArgs {
/** The panel's scalar tables — the displayed value's column selects the drilldown query. */
tables: PanelTable[];
/** The panel's builder queries; resolves the clicked query's signal by name. */
builderQueries: BuilderQuery[];
coordinates: { x: number; y: number };
/** Explorer time window — the panel's fetched window (the value has no clicked bucket). */
timeRange?: { startTime: number; endTime: number };
}
/**
* Turns a Number/Value click into a drilldown payload. Drills into the query the panel actually
* displays — the first table-with-rows' value column (mirrors `prepareNumberData`), not blindly
* `builderQueries[0]` (they diverge for multi-query panels). Returns `null` when that query isn't
* drillable (promql/formula).
*/
export function enrichNumberClick({
tables,
builderQueries,
coordinates,
timeRange,
}: EnrichNumberClickArgs): DrilldownClickPayload | null {
const valueColumn = tables
.find((table) => table.rows.length > 0)
?.columns.find((column) => column.isValueColumn);
const queryName = valueColumn?.queryName ?? builderQueries[0]?.name ?? '';
if (!isValidQueryName(queryName)) {
return null;
}
const builderQuery = builderQueries.find((query) => query.name === queryName);
return {
coordinates,
context: {
queryName,
signal: resolveDrilldownSignal(builderQuery),
filters: [],
timeRange,
},
};
}

View File

@@ -0,0 +1,50 @@
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
import { isValidQueryName } from 'container/QueryTable/Drilldown/drilldownUtils';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownClickPayload } from '../../types/drilldown';
import { getGroupByFilters } from './getGroupByFilters';
import { resolveDrilldownSignal } from './signal';
interface EnrichPieClickArgs {
slice: PieSlice;
builderQueries: BuilderQuery[];
coordinates: { x: number; y: number };
/** Explorer time window — the panel's fetched window (pie slices have no clicked bucket). */
timeRange?: { startTime: number; endTime: number };
}
/**
* Turns a pie-slice click into a drilldown payload, using the slice's source-row group-by label
* values (carried by `preparePieData`) as equality filters. Returns `null` when the slice has no
* drillable query.
*/
export function enrichPieClick({
slice,
builderQueries,
coordinates,
timeRange,
}: EnrichPieClickArgs): DrilldownClickPayload | null {
const queryName = slice.queryName ?? '';
if (!isValidQueryName(queryName)) {
return null;
}
const builderQuery = builderQueries.find((query) => query.name === queryName);
const filters = builderQuery
? getGroupByFilters(slice.labels ?? {}, builderQuery)
: [];
return {
coordinates,
context: {
queryName,
signal: resolveDrilldownSignal(builderQuery),
filters: filters,
timeRange,
label: slice.label,
seriesColor: slice.color,
},
};
}

View File

@@ -0,0 +1,91 @@
import { OPERATORS } from 'constants/queryBuilder';
import {
type FilterData,
isValidQueryName,
} from 'container/QueryTable/Drilldown/drilldownUtils';
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownClickPayload } from '../../types/drilldown';
import { resolveDrilldownSignal } from './signal';
interface EnrichTableClickArgs {
/** The clicked row's data, keyed by column id (see `prepareScalarTables`). */
record: Record<string, unknown>;
/** The clicked column's key (`column.id || column.name`). */
columnId: string;
table: PanelTable;
builderQueries: BuilderQuery[];
coordinates: { x: number; y: number };
/** Explorer time window — the panel's fetched window (scalar tables have no clicked bucket). */
timeRange?: { startTime: number; endTime: number };
}
/**
* Turns a table cell click into a drilldown payload. The clicked value column (or the row's first
* value column) selects the aggregate query, and the row's group-by cells become equality filters
* (V1 `getFiltersToAddToView` parity). `columnKind` records whether a value or group column was
* clicked, for the future filter-by-value menu. Returns `null` when the row has no drillable
* aggregate query.
*/
export function enrichTableClick({
record,
columnId,
table,
builderQueries,
coordinates,
timeRange,
}: EnrichTableClickArgs): DrilldownClickPayload | null {
const clickedColumn = table.columns.find(
(col) => (col.id || col.name) === columnId,
);
const valueColumn = clickedColumn?.isValueColumn
? clickedColumn
: table.columns.find((col) => col.isValueColumn);
if (!valueColumn || !isValidQueryName(valueColumn.queryName)) {
return null;
}
const filters = table.columns.reduce<FilterData[]>((acc, col) => {
if (col.isValueColumn) {
return acc;
}
const value = record[col.id || col.name];
if (value != null) {
// Group cell value → equality filter. Cast at the boundary: row data is `unknown`,
// group cells hold scalar label values.
acc.push({
filterKey: col.name,
filterValue: value as string | number,
operator: OPERATORS['='],
});
}
return acc;
}, []);
const builderQuery = builderQueries.find(
(query) => query.name === valueColumn.queryName,
);
// A group-column click filters by that single cell (V1 filter-by-value); a value-column click
// opens the aggregate menu scoped by the whole row.
const isGroupColumn = clickedColumn != null && !clickedColumn.isValueColumn;
return {
coordinates,
context: {
queryName: valueColumn.queryName,
signal: resolveDrilldownSignal(builderQuery),
filters,
timeRange,
// No `label`: like Number/Value, the aggregate menu header falls back to the
// aggregation expression (e.g. `sum(signoz_calls_total)`), not the column name (V1 parity).
columnKind: isGroupColumn ? 'group' : 'aggregate',
clickedKey: isGroupColumn ? clickedColumn?.name : undefined,
clickedValue: isGroupColumn
? (record[columnId] as string | number)
: undefined,
},
};
}

View File

@@ -0,0 +1,29 @@
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
/** An auto-generated drilldown link (label + destination URL). */
export interface DrilldownDataLink {
id: string;
label: string;
url: string;
}
/**
* Links derived automatically from the clicked point's filters — the V2 port of V1's `getDataLinks`.
* Currently a single "View Trace Details" link when the clicked row carries a `trace_id`.
*/
export function getDataLinks(filters: FilterData[]): DrilldownDataLink[] {
const links: DrilldownDataLink[] = [];
const traceId = filters.find(
(filter) => filter.filterKey === 'trace_id',
)?.filterValue;
if (traceId) {
links.push({
id: 'view-trace-details',
label: 'View Trace Details',
url: `/trace/${traceId}`,
});
}
return links;
}

View File

@@ -0,0 +1,26 @@
import {
type FilterData,
getFiltersFromMetric,
} from 'container/QueryTable/Drilldown/drilldownUtils';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Equality filters for the clicked series/slice, restricted to the query's group-by dimensions.
* `labels` also carry a display-only legend backfill (`{queryName: queryName}` when ungrouped, see
* `resolveLegendAndLabels`); intersecting with group-by drops it so `filters` stays empty when
* ungrouped, matching V1.
*/
export function getGroupByFilters(
labels: Record<string, string>,
builderQuery: BuilderQuery,
): FilterData[] {
const groupByKeys = new Set(
(builderQuery.groupBy ?? []).map((group) => group.name),
);
if (groupByKeys.size === 0) {
return [];
}
return getFiltersFromMetric(labels).filter((filter) =>
groupByKeys.has(filter.filterKey),
);
}

View File

@@ -0,0 +1,50 @@
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
import type { ContextLinkProps } from 'types/api/dashboard/getAll';
/** A panel context link with its label and URL templates resolved, ready to render. */
export interface ResolvedDrilldownLink {
id: string;
label: string;
url: string;
}
/**
* Resolves a panel's context links for the drilldown menu. Adapts each `DashboardLinkDTO` to the V1
* `ContextLinkProps` the shared `processContextLinks` resolver expects, substitutes variables in the
* label + URL, and drops links without a URL. Links with `renderVariables === false` keep their raw
* label/URL (no substitution).
*/
export function resolvePanelContextLinks(
links: DashboardLinkDTO[] | undefined,
processedVariables: Record<string, string>,
): ResolvedDrilldownLink[] {
const usable = (links ?? []).filter((link) => !!link.url);
if (usable.length === 0) {
return [];
}
const adapted: ContextLinkProps[] = usable.map((link, index) => ({
id: String(index),
label: link.name || link.url || '',
url: link.url ?? '',
}));
const resolved = processContextLinks(adapted, processedVariables, 50);
return usable.map((link, index) => {
// `renderVariables` defaults to on; only an explicit `false` opts out of substitution.
if (link.renderVariables === false) {
return {
id: String(index),
label: link.name || link.url || '',
url: link.url ?? '',
};
}
return {
id: resolved[index].id,
label: resolved[index].label,
url: resolved[index].url,
};
});
}

View File

@@ -0,0 +1,19 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Maps a V5 builder query's `signal` to the drilldown signal. Meter and unknown signals fall back to
* `metrics` so the drilldown always targets a real explorer.
*/
export function resolveDrilldownSignal(
query: BuilderQuery | undefined,
): TelemetrytypesSignalDTO {
switch (query?.signal) {
case 'logs':
return TelemetrytypesSignalDTO.logs;
case 'traces':
return TelemetrytypesSignalDTO.traces;
default:
return TelemetrytypesSignalDTO.metrics;
}
}

View File

@@ -0,0 +1,28 @@
import type { Querybuildertypesv5QueryRangeRequestDTO } from 'api/generated/services/sigNoz.schemas';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import store from 'store';
/** Panel time window in epoch SECONDS (uPlot X-scale + drilldown explorer window). */
interface PanelTimeRange {
startTime: number;
endTime: number;
}
/**
* Time window a panel's data was fetched over, read off the request's `start`/`end` (ms → s).
* Falls back to the dashboard global-time window when the panel hasn't fetched yet.
*/
export function getPanelTimeRange(
request: Querybuildertypesv5QueryRangeRequestDTO | undefined,
): PanelTimeRange {
if (request?.start && request?.end) {
return { startTime: request.start / 1000, endTime: request.end / 1000 };
}
const { globalTime } = store.getState();
const { start, end } = getStartEndRangeTime({
type: 'GLOBAL_TIME',
interval: globalTime.selectedTime,
});
return { startTime: parseInt(start, 10), endTime: parseInt(end, 10) };
}

View File

@@ -0,0 +1,10 @@
.signal {
text-transform: capitalize;
}
.label {
overflow: hidden;
font-weight: normal;
white-space: nowrap;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,165 @@
import { useMemo } from 'react';
import {
Braces,
ChartBar,
DraftingCompass,
Link,
Loader,
ScrollText,
} from '@signozhq/icons';
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import { getAggregateColumnHeader } from 'container/QueryTable/Drilldown/drilldownUtils';
import ContextMenu from 'periscope/components/ContextMenu';
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
import { getDataLinks } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/getDataLinks';
import { resolvePanelContextLinks } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/resolvePanelContextLinks';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { openInNewTab } from 'utils/navigation';
import { useDrilldownContextVariables } from '../hooks/useDrilldownContextVariables';
import styles from './DrilldownAggregateMenu.module.scss';
interface DrilldownAggregateMenuProps {
context: DrilldownContext;
/** Panel's V5→V1 query — supplies the aggregation-expression header fallback. */
query: Query;
/** While dashboard variables resolve, the actions show a spinner and are disabled. */
isResolving?: boolean;
/** Panel's context links; resolved against the clicked point + variables here. */
links: DashboardLinkDTO[] | undefined;
/** Whether the clicked point exposes group-by fields to bind to dashboard variables. */
canSetDashboardVariables: boolean;
onViewLogs: () => void;
onViewTraces: () => void;
onBreakout: () => void;
/** Open the Dashboard Variables submenu (set/unset/create from the clicked value). */
onSetDashboardVariables: () => void;
/** Close the popover (context-link clicks). */
onClose: () => void;
}
/**
* The base aggregate drill-down menu: tinted header + View in Logs/Traces, Breakout, and the
* panel's context links. Mounted only while open, so the variable/link resolution runs only then.
* Metrics is omitted — V1 surfaces only Logs/Traces.
*/
function DrilldownAggregateMenu({
context,
query,
isResolving = false,
links,
canSetDashboardVariables,
onViewLogs,
onViewTraces,
onBreakout,
onSetDashboardVariables,
onClose,
}: DrilldownAggregateMenuProps): JSX.Element {
const aggregations = useMemo(
() => getAggregateColumnHeader(query, context.queryName).aggregations,
[query, context.queryName],
);
const processedVariables = useDrilldownContextVariables(context);
const contextLinks = useMemo(
() => resolvePanelContextLinks(links, processedVariables),
[links, processedVariables],
);
// Auto links derived from the clicked point itself (e.g. "View Trace Details" for a trace_id).
const dataLinks = useMemo(
() => getDataLinks(context.filters),
[context.filters],
);
return (
<>
<ContextMenu.Header>
<div className={styles.signal}>{context.signal}</div>
<div className={styles.label} style={{ color: context.seriesColor }}>
{context.label || aggregations}
</div>
</ContextMenu.Header>
{canSetDashboardVariables && (
<ContextMenu.Item
icon={
<span style={{ color: context.seriesColor }}>
<Braces size={16} />
</span>
}
onClick={onSetDashboardVariables}
>
<span data-testid="drilldown-dashboard-variables">
Dashboard Variables
</span>
</ContextMenu.Item>
)}
<ContextMenu.Item
icon={
isResolving ? (
<Loader className="animate-spin" size={16} color={context.seriesColor} />
) : (
<span style={{ color: context.seriesColor }}>
<ScrollText size={16} />
</span>
)
}
onClick={onViewLogs}
disabled={isResolving}
>
<span data-testid="drilldown-view-logs">View in Logs</span>
</ContextMenu.Item>
<ContextMenu.Item
icon={
isResolving ? (
<Loader className="animate-spin" color={context.seriesColor} size={16} />
) : (
<span style={{ color: context.seriesColor }}>
<DraftingCompass size={16} />
</span>
)
}
onClick={onViewTraces}
disabled={isResolving}
>
<span data-testid="drilldown-view-traces">View in Traces</span>
</ContextMenu.Item>
<ContextMenu.Item
icon={
<span style={{ color: context.seriesColor }}>
<ChartBar size={16} />
</span>
}
onClick={onBreakout}
>
<span data-testid="drilldown-breakout">Breakout by ..</span>
</ContextMenu.Item>
{dataLinks.map((link) => (
<ContextMenu.Item
key={link.id}
icon={<Link size={16} color={context.seriesColor} />}
onClick={(): void => {
openInNewTab(link.url);
onClose();
}}
>
<span data-testid="drilldown-data-link">{link.label}</span>
</ContextMenu.Item>
))}
{contextLinks.map((link) => (
<ContextMenu.Item
key={link.id}
icon={<Link size={16} color={context.seriesColor} />}
onClick={(): void => {
openInNewTab(link.url);
onClose();
}}
>
<span data-testid="drilldown-context-link">{link.label}</span>
</ContextMenu.Item>
))}
</>
);
}
export default DrilldownAggregateMenu;

View File

@@ -0,0 +1,9 @@
.header {
display: flex;
align-items: center;
gap: 8px;
}
.backArrow {
cursor: pointer;
}

View File

@@ -0,0 +1,45 @@
import { ArrowLeft } from '@signozhq/icons';
import BreakoutOptions from 'container/QueryTable/Drilldown/BreakoutOptions';
import type { BreakoutAttributeType } from 'container/QueryTable/Drilldown/types';
import ContextMenu from 'periscope/components/ContextMenu';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import styles from './DrilldownBreakoutMenu.module.scss';
interface DrilldownBreakoutMenuProps {
/** The clicked query's builder data — supplies the picker's available attributes. */
queryData: IBuilderQuery;
/** Regroup the clicked query by the picked attribute. */
onBreakout: (groupBy: BreakoutAttributeType) => void;
/** Return to the base aggregate menu. */
onBack: () => void;
}
/**
* The "Breakout by .." submenu — a back header + V1's read-only `BreakoutOptions` attribute picker,
* wired to `onBreakout`.
*/
function DrilldownBreakoutMenu({
queryData,
onBreakout,
onBack,
}: DrilldownBreakoutMenuProps): JSX.Element {
return (
<>
<ContextMenu.Header>
<div className={styles.header}>
<ArrowLeft
size={14}
className={styles.backArrow}
onClick={onBack}
data-testid="drilldown-breakout-back"
/>
<span>Breakout by</span>
</div>
</ContextMenu.Header>
<BreakoutOptions queryData={queryData} onColumnClick={onBreakout} />
</>
);
}
export default DrilldownBreakoutMenu;

View File

@@ -0,0 +1,9 @@
.header {
display: flex;
align-items: center;
gap: 8px;
}
.backArrow {
cursor: pointer;
}

View File

@@ -0,0 +1,79 @@
import { ArrowLeft, Plus, Settings, X } from '@signozhq/icons';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import {
type DrilldownVariableAction,
DrilldownVariableActionKind,
} from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDrilldownDashboardVariables';
import ContextMenu from 'periscope/components/ContextMenu';
import styles from './DrilldownDashboardVariablesMenu.module.scss';
interface DrilldownDashboardVariablesMenuProps {
/** Resolved entries from `useDrilldownDashboardVariables`. */
actions: DrilldownVariableAction[];
/** Return to the base aggregate menu. */
onBack: () => void;
}
const ICON_BY_KIND: Record<DrilldownVariableActionKind, JSX.Element> = {
[DrilldownVariableActionKind.Set]: <Settings size={16} />,
[DrilldownVariableActionKind.Unset]: <X size={16} />,
[DrilldownVariableActionKind.Create]: <Plus size={16} />,
};
/**
* The "Dashboard Variables" drilldown submenu — renders the entries resolved by
* `useDrilldownDashboardVariables` (Set/Unset an existing dynamic variable, or Create one).
*/
function DrilldownDashboardVariablesMenu({
actions,
onBack,
}: DrilldownDashboardVariablesMenuProps): JSX.Element {
return (
<>
<ContextMenu.Header>
<div className={styles.header}>
<ArrowLeft
size={14}
className={styles.backArrow}
onClick={onBack}
data-testid="drilldown-var-back"
/>
<span>Dashboard Variables</span>
</div>
</ContextMenu.Header>
<OverlayScrollbar
style={{ maxHeight: '200px' }}
options={{ overflow: { x: 'hidden' } }}
>
<>
{actions.map(({ fieldName, fieldValue, kind, onClick }) => (
<ContextMenu.Item
key={fieldName}
icon={ICON_BY_KIND[kind]}
onClick={onClick}
>
{kind === DrilldownVariableActionKind.Unset && (
<span data-testid="drilldown-var-unset">
Unset <strong>${fieldName}</strong>
</span>
)}
{kind === DrilldownVariableActionKind.Set && (
<span data-testid="drilldown-var-set">
Set <strong>${fieldName}</strong> to <strong>{fieldValue}</strong>
</span>
)}
{kind === DrilldownVariableActionKind.Create && (
<span data-testid="drilldown-var-create">
Create var <strong>${fieldName}</strong>:<strong>{fieldValue}</strong>
</span>
)}
</ContextMenu.Item>
))}
</>
</OverlayScrollbar>
</>
);
}
export default DrilldownDashboardVariablesMenu;

View File

@@ -0,0 +1,40 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getGroupContextMenuConfig } from 'container/QueryTable/Drilldown/contextConfig';
import type { ClickedData } from 'periscope/components/ContextMenu';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
interface DrilldownFilterMenuProps {
/** The panel's V5→V1 query the operator menu builds filters against. */
v1Query: Query;
/** The clicked group column's key. */
clickedKey: string;
/** Apply the chosen operator (adds the filter and opens the View modal). */
onFilter: (operator: string) => void;
}
/**
* The group-column "filter by value" submenu — the operator list from V1's read-only
* `getGroupContextMenuConfig`, wired to `onFilter`.
*/
function DrilldownFilterMenu({
v1Query,
clickedKey,
onFilter,
}: DrilldownFilterMenuProps): JSX.Element {
const clickedData: ClickedData = {
column: { dataIndex: clickedKey },
record: { key: clickedKey, timestamp: 0 },
};
return (
<>
{getGroupContextMenuConfig({
query: v1Query,
clickedData,
panelType: PANEL_TYPES.TABLE,
onColumnClick: onFilter,
}).items ?? null}
</>
);
}
export default DrilldownFilterMenu;

View File

@@ -3,11 +3,13 @@ import type {
DashboardtypesPanelDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import ContextMenu from 'periscope/components/ContextMenu';
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 { DashboardSection } from '../../utils';
import { useDrilldown } from './hooks/useDrilldown';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import PanelBody from './PanelBody/PanelBody';
import PanelHeader from './PanelHeader/PanelHeader';
@@ -68,6 +70,7 @@ function Panel({
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
const drilldown = useDrilldown(panel, panelId);
return (
<div
@@ -100,8 +103,11 @@ function Panel({
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
/>
)}
<ContextMenu {...drilldown.contextMenuProps} />
</div>
);
}

View File

@@ -294,4 +294,13 @@ describe('usePanelActionItems', () => {
(createAlert as { onClick: () => void }).onClick();
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
});
it('create-alert seeds an alert from this panel', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const createAlert = result.current.items.find(
(i) => 'key' in i && i.key === 'create-alert',
);
(createAlert as { onClick: () => void }).onClick();
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
});
});

View File

@@ -3,12 +3,13 @@ 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.
* `search` and `drilldown`, which are renderer-wired controls, not menu items)
* 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'>
| Exclude<keyof PanelActionCapabilities, 'search' | 'drilldown'>
| 'move'
| 'delete';

View File

@@ -3,6 +3,7 @@ import { Loader, RotateCw, SquarePlus, TriangleAlert } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import PanelMessage from 'pages/DashboardPageV2/DashboardContainer/Panels/components/PanelMessage/PanelMessage';
import type { AnyPanelInteractionProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/interactions';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { hasRunnableQueries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
@@ -37,6 +38,10 @@ interface PanelBodyProps {
pagination?: PanelPagination;
/** Close the standalone View modal — only consumed by the time-series/bar graph manager. */
onCloseStandaloneView?: () => void;
/** Opens the drill-down context menu; threaded to interactive renderers. */
onClick?: AnyPanelInteractionProps['onClick'];
/** Gate for the drill-down menu — kind supported and the panel has a builder query. */
enableDrillDown?: boolean;
}
/**
@@ -58,6 +63,8 @@ function PanelBody({
searchTerm,
pagination,
onCloseStandaloneView,
onClick,
enableDrillDown = false,
}: PanelBodyProps): JSX.Element {
// A retained response (keepPreviousData) counts as data only if its type matches the current
// request — else a prior panel kind's response (time_series → raw) flashes NoData on switch.
@@ -120,7 +127,8 @@ function PanelBody({
refetch={refetch}
onDragSelect={onDragSelect}
panelMode={panelMode}
enableDrillDown={false}
enableDrillDown={enableDrillDown}
onClick={onClick}
dashboardPreference={dashboardPreference}
searchTerm={searchTerm}
pagination={pagination}

View File

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

View File

@@ -1,45 +1,135 @@
import { Modal } from 'antd';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import cx from 'classnames';
import { useDashboardSections } from '../../../hooks/useDashboardSections';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from './constants';
import SectionPicker from './SectionPicker';
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
import styles from './PanelTypeSelectionModal.module.scss';
import { Plus } from '@signozhq/icons';
interface PanelTypeSelectionModalProps {
open: boolean;
onClose: () => void;
onSelect: (panelKind: PanelKind) => void;
onSelect: (panelKind: PanelKind, layoutIndex?: number) => void;
/** Section the picker opens on; omit → the untitled root / first section. */
defaultLayoutIndex?: number;
}
/** Fake loader shown on the confirm button before navigating to the editor. */
const CONFIRM_LOADER_MS = 500;
function PanelTypeSelectionModal({
open,
onClose,
onSelect,
defaultLayoutIndex,
}: PanelTypeSelectionModalProps): JSX.Element {
const sections = useDashboardSections();
const options = useMemo(() => buildSectionOptions(sections), [sections]);
const hasSectionPicker = options.length > 1;
const [selectedValue, setSelectedValue] = useState('');
const [selectedPanelKind, setSelectedPanelKind] = useState<PanelKind | null>(
null,
);
const [isSubmitting, setIsSubmitting] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = useCallback((): void => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
// Seed the target section on open; cancel a pending navigation on close.
useEffect(() => {
if (open) {
setSelectedValue(resolveDefaultSectionValue(options, defaultLayoutIndex));
setSelectedPanelKind(null);
setIsSubmitting(false);
} else {
clearTimer();
}
}, [open, options, defaultLayoutIndex, clearTimer]);
useEffect(() => clearTimer, [clearTimer]);
const handleConfirm = (): void => {
if (selectedPanelKind === null || isSubmitting) {
return;
}
setIsSubmitting(true);
const layoutIndex = selectedValue === '' ? undefined : Number(selectedValue);
timerRef.current = setTimeout(() => {
onSelect(selectedPanelKind, layoutIndex);
}, CONFIRM_LOADER_MS);
};
// Footer is always shown; the confirm button is disabled until a panel type is picked.
const footer = (
<div className={styles.footerActions}>
{hasSectionPicker && (
<div className={styles.footerPicker}>
<span className={styles.pickerLabel}>Add panel to</span>
<SectionPicker
options={options}
value={selectedValue}
onChange={setSelectedValue}
/>
</div>
)}
<Button
color="primary"
size="md"
disabled={selectedPanelKind === null}
loading={isSubmitting}
prefix={<Plus size={16} />}
onClick={handleConfirm}
testId="panel-type-confirm"
>
Add Panel
</Button>
</div>
);
return (
<Modal
<DialogWrapper
open={open}
title="Select a panel type"
onCancel={onClose}
footer={null}
destroyOnClose
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="New Panel"
footer={footer}
>
<div className={styles.grid}>
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
<Button
key={panelKind}
type="button"
variant="ghost"
className={styles.typeButton}
data-testid={`panel-type-${panelKind}`}
onClick={(): void => onSelect(panelKind)}
>
<Icon size={14} />
{label}
</Button>
))}
<div className={styles.panelTypeSection}>
<span className={styles.pickerLabel}>Select panel type</span>
<div className={styles.grid}>
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
<button
key={panelKind}
type="button"
className={cx(styles.panelTypeCard, {
[styles.panelTypeCardSelected]: panelKind === selectedPanelKind,
})}
data-testid={`panel-type-${panelKind}`}
aria-pressed={panelKind === selectedPanelKind}
onClick={(): void => setSelectedPanelKind(panelKind)}
>
<Icon size={24} color={Color.BG_ROBIN_400} />
{label}
</button>
))}
</div>
</div>
</Modal>
</DialogWrapper>
);
}

View File

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

View File

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

View File

@@ -14,3 +14,16 @@ export interface PanelType {
/** Icon component — the consumer renders it and controls size/color/etc. */
Icon: ComponentType<IconProps>;
}
export interface SectionOption {
/** The section's `layoutIndex`, stringified for the Select value. */
value: string;
layoutIndex: number;
/** Section title, or "Dashboard (root)" for the untitled top-level layout. */
label: string;
/** Caption under the label. */
description: string;
/** Untitled top-level layout (has no section header). */
isRoot: boolean;
Icon: ComponentType<IconProps>;
}

View File

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

View File

@@ -0,0 +1,64 @@
import { type KeyboardEvent, useCallback } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import styles from './ViewPanelModal.module.scss';
interface ViewPanelQueryBuilderProps {
panelType: PANEL_TYPES;
/** Preview fetch in flight — drives the Run/Cancel button state. */
isLoadingQueries: boolean;
/** Run the current query (Run Query button / ⌘↵). */
onStageRunQuery: () => void;
/** Abort the in-flight preview fetch. */
onCancelQuery: () => void;
}
/**
* Drilldown query editor for the View modal. Mirrors V1's FullView: the query builder
* rows + a "Run Query" button, with NO query-type tabs (ClickHouse/PromQL) — drilldown
* is query-builder only, exactly as V1.
*/
function ViewPanelQueryBuilder({
panelType,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
}: ViewPanelQueryBuilderProps): JSX.Element {
const handleKeyDownCapture = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
onStageRunQuery();
}
},
[onStageRunQuery],
);
return (
<div
className={styles.queryBuilder}
data-testid="view-panel-query-builder"
onKeyDownCapture={handleKeyDownCapture}
role="presentation"
>
<QueryBuilderV2
panelType={panelType}
version="v3"
isListViewPanel={panelType === PANEL_TYPES.LIST}
signalSourceChangeEnabled
/>
<div className={styles.queryBuilderToolbar}>
<RightToolbarActions
handleCancelQuery={onCancelQuery}
onStageRunQuery={onStageRunQuery}
isLoadingQueries={isLoadingQueries}
/>
</div>
</div>
);
}
export default ViewPanelQueryBuilder;

View File

@@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DrilldownDashboardVariablesMenu from '../DrilldownMenu/DrilldownDashboardVariablesMenu';
import {
type DrilldownVariableAction,
DrilldownVariableActionKind,
} from '../hooks/useDrilldownDashboardVariables';
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
function action(
overrides: Partial<DrilldownVariableAction> = {},
): DrilldownVariableAction {
return {
fieldName: 'service.name',
fieldValue: 'frontend',
kind: DrilldownVariableActionKind.Set,
onClick: jest.fn(),
...overrides,
};
}
describe('DrilldownDashboardVariablesMenu', () => {
it('renders each kind with its own label and fires its onClick', async () => {
const onSet = jest.fn();
const onUnset = jest.fn();
const onCreate = jest.fn();
render(
<DrilldownDashboardVariablesMenu
onBack={jest.fn()}
actions={[
action({
fieldName: 'a',
kind: DrilldownVariableActionKind.Set,
onClick: onSet,
}),
action({
fieldName: 'b',
kind: DrilldownVariableActionKind.Unset,
onClick: onUnset,
}),
action({
fieldName: 'c',
kind: DrilldownVariableActionKind.Create,
onClick: onCreate,
}),
]}
/>,
);
expect(screen.getByTestId('drilldown-var-set')).toBeInTheDocument();
expect(screen.getByTestId('drilldown-var-unset')).toBeInTheDocument();
expect(screen.getByTestId('drilldown-var-create')).toBeInTheDocument();
await userEvent.click(screen.getByTestId('drilldown-var-set'));
expect(onSet).toHaveBeenCalledTimes(1);
});
it('returns to the base menu when the back arrow is clicked', async () => {
const onBack = jest.fn();
render(
<DrilldownDashboardVariablesMenu onBack={onBack} actions={[action()]} />,
);
await userEvent.click(screen.getByTestId('drilldown-var-back'));
expect(onBack).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,299 @@
import { act, render, renderHook, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
import { useDrilldown } from '../hooks/useDrilldown';
const mockOpenViewWithQuery = jest.fn();
const mockNavigate = jest.fn();
const mockGetBuilderQueries = jest.fn();
let mockResolved = { resolvedQuery: 'RESOLVED_QUERY', isResolving: false };
let mockDashboardVariables: {
hasFieldVariables: boolean;
actions: unknown[];
} = {
hasFieldVariables: true,
actions: [],
};
// Boundaries tested elsewhere / needing external context — mocked so this suite isolates
// useDrilldown's orchestration (gating, which menu shows, the View-modal handoff).
jest.mock('../hooks/useViewPanel', () => ({
useViewPanel: (): unknown => ({ openViewWithQuery: mockOpenViewWithQuery }),
}));
// Variable-substitution boundary (redux/store/react-query) — its own logic is out of scope here.
jest.mock('../hooks/useResolvedDrilldownQuery', () => ({
useResolvedDrilldownQuery: (): unknown => mockResolved,
}));
jest.mock('../hooks/useDrilldownBreakout', () => ({
useDrilldownBreakout: (): unknown => ({
queryData: { queryName: 'A' },
onBreakout: jest.fn(),
}),
}));
jest.mock('../DrilldownMenu/DrilldownBreakoutMenu', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="breakout-submenu" />,
}));
jest.mock('../hooks/useDrilldownDashboardVariables', () => ({
useDrilldownDashboardVariables: (): unknown => mockDashboardVariables,
}));
jest.mock('../DrilldownMenu/DrilldownDashboardVariablesMenu', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="dashboard-variables-submenu" />,
}));
// Context-link variable map (redux global time + store) — out of scope for this suite.
jest.mock('../hooks/useDrilldownContextVariables', () => ({
useDrilldownContextVariables: (): unknown => ({}),
}));
jest.mock('container/QueryTable/Drilldown/useBaseDrilldownNavigate', () => ({
__esModule: true,
default: (): unknown => mockNavigate,
}));
jest.mock('container/QueryTable/Drilldown/contextConfig', () => ({
getGroupContextMenuConfig: ({
onColumnClick,
}: {
onColumnClick: (op: string) => void;
}): unknown => ({
items: (
<button
type="button"
data-testid="filter-op"
onClick={(): void => onColumnClick('=')}
>
Is this
</button>
),
}),
}));
jest.mock('container/QueryTable/Drilldown/drilldownUtils', () => ({
addFilterToQuery: jest.fn(() => 'REFINED_QUERY'),
getAggregateColumnHeader: (): unknown => ({
aggregations: 'sum(x)',
dataSource: 'metrics',
}),
getBaseMeta: (): unknown => undefined,
isNumberDataType: (): boolean => false,
}));
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
() => ({
fromPerses: (): string => 'V1_QUERY',
toPerses: jest.fn(() => [{ kind: 'REFINED' }]),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries',
() => ({
getBuilderQueries: (...args: unknown[]): unknown =>
mockGetBuilderQueries(...args),
}),
);
// Capability lookup mocked (its per-kind values are data in the definitions); avoids
// importing the whole renderer registry into the test.
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: (kind: string): unknown => ({
actions: { drilldown: kind !== 'signoz/ListPanel' },
}),
}));
function panelOfKind(kind: string): DashboardtypesPanelDTO {
return {
spec: { plugin: { kind, spec: {} }, queries: [{ x: 1 }] },
display: { name: 'P' },
} as unknown as DashboardtypesPanelDTO;
}
const tsPanel = panelOfKind('signoz/TimeSeriesPanel');
const aggregateContext: DrilldownContext = {
queryName: 'A',
signal: TelemetrytypesSignalDTO.metrics,
filters: [],
label: 'frontend',
seriesColor: '#fff',
};
const groupContext: DrilldownContext = {
queryName: 'A',
signal: TelemetrytypesSignalDTO.metrics,
filters: [],
columnKind: 'group',
clickedKey: 'service.name',
clickedValue: 'frontend',
};
describe('useDrilldown', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetBuilderQueries.mockReturnValue([{ name: 'A' }]);
mockResolved = { resolvedQuery: 'RESOLVED_QUERY', isResolving: false };
mockDashboardVariables = {
hasFieldVariables: true,
actions: [],
};
});
describe('enableDrillDown', () => {
it('is true when the kind declares drilldown and has a builder query', () => {
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
expect(result.current.enableDrillDown).toBe(true);
});
it('is false when there is no builder query', () => {
mockGetBuilderQueries.mockReturnValue([]);
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
expect(result.current.enableDrillDown).toBe(false);
});
it('is false for a kind that opts out of drilldown', () => {
const { result } = renderHook(() =>
useDrilldown(panelOfKind('signoz/ListPanel'), 'p1'),
);
expect(result.current.enableDrillDown).toBe(false);
});
});
describe('aggregate menu', () => {
it('shows View in Logs/Traces + Breakout on an aggregate click', () => {
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
expect(screen.getByTestId('drilldown-view-logs')).toBeInTheDocument();
expect(screen.getByTestId('drilldown-view-traces')).toBeInTheDocument();
expect(screen.getByTestId('drilldown-breakout')).toBeInTheDocument();
});
it('navigates to logs when View in Logs is clicked', async () => {
const user = userEvent.setup();
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
await user.click(screen.getByTestId('drilldown-view-logs'));
expect(mockNavigate).toHaveBeenCalledWith('view_logs');
});
it('disables navigation while dashboard variables resolve', async () => {
mockResolved = { resolvedQuery: 'RESOLVED_QUERY', isResolving: true };
const user = userEvent.setup();
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
await user.click(screen.getByTestId('drilldown-view-logs'));
expect(mockNavigate).not.toHaveBeenCalled();
});
it('swaps to the breakout submenu when "Breakout by .." is clicked', async () => {
const user = userEvent.setup();
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
const view = render(<div>{result.current.contextMenuProps.items}</div>);
await user.click(screen.getByTestId('drilldown-breakout'));
view.rerender(<div>{result.current.contextMenuProps.items}</div>);
expect(screen.getByTestId('breakout-submenu')).toBeInTheDocument();
});
it('shows the Dashboard Variables entry when the click has group-by fields', () => {
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
expect(
screen.getByTestId('drilldown-dashboard-variables'),
).toBeInTheDocument();
});
it('hides the Dashboard Variables entry when there are no group-by fields', () => {
mockDashboardVariables = { hasFieldVariables: false, actions: [] };
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
expect(
screen.queryByTestId('drilldown-dashboard-variables'),
).not.toBeInTheDocument();
});
it('swaps to the Dashboard Variables submenu when clicked', async () => {
const user = userEvent.setup();
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
const view = render(<div>{result.current.contextMenuProps.items}</div>);
await user.click(screen.getByTestId('drilldown-dashboard-variables'));
view.rerender(<div>{result.current.contextMenuProps.items}</div>);
expect(
screen.getByTestId('dashboard-variables-submenu'),
).toBeInTheDocument();
});
});
describe('filter-by-value', () => {
it('opens the View modal with the refined query on a group-column filter', async () => {
const user = userEvent.setup();
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: groupContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
await user.click(screen.getByTestId('filter-op'));
// Opens the View modal on the refined query at the panel's kind — persisted in the URL.
expect(mockOpenViewWithQuery).toHaveBeenCalledWith(
'p1',
'REFINED_QUERY',
PANEL_TYPES.TIME_SERIES,
);
});
});
});

View File

@@ -0,0 +1,213 @@
import { render, renderHook, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
import DrilldownDashboardVariablesMenu from '../DrilldownMenu/DrilldownDashboardVariablesMenu';
import { useDrilldownDashboardVariables } from '../hooks/useDrilldownDashboardVariables';
// Fake dashboard variables + runtime selection, mutated per test. `dtoToFormModel` is mocked to
// derive `type` from the plugin kind (like the real adapter), so these DTOs carry the plugin
// discriminant plus the flat form-model fields the hook reads.
let mockVariables: Array<{
name: string;
dynamicAttribute?: string;
multiSelect?: boolean;
spec: { plugin: { kind: string } };
}> = [];
let mockSelectionMap: Record<string, { value: unknown; allSelected: boolean }> =
{};
const mockSetVariableValue = jest.fn();
const mockSetUrlValues = jest.fn();
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
const DYNAMIC_KIND = 'signoz/DynamicVariable';
const QUERY_KIND = 'signoz/QueryVariable';
jest.mock('api/generated/services/dashboard', () => ({
useGetDashboardV2: (): unknown => ({
data: { data: { spec: { variables: mockVariables } } },
}),
}));
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/useOptimisticPatch',
() => ({
useOptimisticPatch: (): unknown => ({ patchAsync: mockPatchAsync }),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
selector({
dashboardId: 'd1',
variableValues: { d1: mockSelectionMap },
setVariableValue: mockSetVariableValue,
}),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters',
() => ({
dtoToFormModel: (dto: {
spec?: { plugin?: { kind?: string } };
}): unknown => ({
...dto,
type:
dto.spec?.plugin?.kind === 'signoz/DynamicVariable' ? 'DYNAMIC' : 'QUERY',
}),
formModelToDto: (model: { name: string }): unknown => ({ dto: model.name }),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel',
() => ({
emptyVariableFormModel: (): unknown => ({}),
DYNAMIC_SIGNAL_ALL: 'all',
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection',
() => ({
ALL_SELECTED: '__ALL__',
variablesUrlParser: { withOptions: (): unknown => ({}) },
}),
);
jest.mock('nuqs', () => ({
useQueryState: (): unknown => [null, mockSetUrlValues],
}));
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const filters: FilterData[] = [
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
];
function renderItems(): void {
const { result } = renderHook(() =>
useDrilldownDashboardVariables({
filters,
signal: TelemetrytypesSignalDTO.metrics,
onClose: jest.fn(),
}),
);
render(
<DrilldownDashboardVariablesMenu
actions={result.current.actions}
onBack={jest.fn()}
/>,
);
}
describe('useDrilldownDashboardVariables', () => {
beforeEach(() => {
jest.clearAllMocks();
mockVariables = [];
mockSelectionMap = {};
});
it('hasFieldVariables reflects the clicked point group-by fields', () => {
const withFields = renderHook(() =>
useDrilldownDashboardVariables({ filters, onClose: jest.fn() }),
);
expect(withFields.result.current.hasFieldVariables).toBe(true);
const noFields = renderHook(() =>
useDrilldownDashboardVariables({ filters: [], onClose: jest.fn() }),
);
expect(noFields.result.current.hasFieldVariables).toBe(false);
});
it('offers Set — writing an array for a multi-select var so the selector shows it', async () => {
mockVariables = [
{
name: 'svc',
dynamicAttribute: 'service.name',
multiSelect: true,
spec: { plugin: { kind: DYNAMIC_KIND } },
},
];
mockSelectionMap = { svc: { value: ['backend'], allSelected: false } };
renderItems();
await userEvent.click(screen.getByTestId('drilldown-var-set'));
expect(mockSetVariableValue).toHaveBeenCalledWith('d1', 'svc', {
value: ['frontend'],
allSelected: false,
});
});
it('offers Set — writing a scalar for a single-select var', async () => {
mockVariables = [
{
name: 'svc',
dynamicAttribute: 'service.name',
multiSelect: false,
spec: { plugin: { kind: DYNAMIC_KIND } },
},
];
mockSelectionMap = { svc: { value: 'backend', allSelected: false } };
renderItems();
await userEvent.click(screen.getByTestId('drilldown-var-set'));
expect(mockSetVariableValue).toHaveBeenCalledWith('d1', 'svc', {
value: 'frontend',
allSelected: false,
});
});
it('offers Unset when the matching dynamic var already holds the clicked value', async () => {
mockVariables = [
{
name: 'svc',
dynamicAttribute: 'service.name',
multiSelect: true,
spec: { plugin: { kind: DYNAMIC_KIND } },
},
];
mockSelectionMap = { svc: { value: ['frontend'], allSelected: false } };
renderItems();
await userEvent.click(screen.getByTestId('drilldown-var-unset'));
expect(mockSetVariableValue).toHaveBeenCalledWith('d1', 'svc', {
value: [],
allSelected: false,
});
});
it('offers Create and persists a new dynamic variable when none matches', async () => {
mockVariables = [];
renderItems();
await userEvent.click(screen.getByTestId('drilldown-var-create'));
// Persisted to the spec via the optimistic patch...
expect(mockPatchAsync).toHaveBeenCalledTimes(1);
// ...and seeded (as an array — the created var is multi-select) with the clicked value.
expect(mockSetVariableValue).toHaveBeenCalledWith('d1', 'service.name', {
value: ['frontend'],
allSelected: false,
});
});
it('ignores a non-dynamic variable with the same attribute (still offers Create)', () => {
mockVariables = [
{
name: 'svc',
dynamicAttribute: 'service.name',
spec: { plugin: { kind: QUERY_KIND } },
},
];
renderItems();
expect(screen.getByTestId('drilldown-var-create')).toBeInTheDocument();
expect(screen.queryByTestId('drilldown-var-set')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,243 @@
import { useCallback, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
import useBaseDrilldownNavigate from 'container/QueryTable/Drilldown/useBaseDrilldownNavigate';
import type {
Coordinates,
PopoverPosition,
} from 'periscope/components/ContextMenu';
import type {
DrilldownClickPayload,
DrilldownContext,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { buildAggregateData } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/buildAggregateData';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import DrilldownAggregateMenu from '../DrilldownMenu/DrilldownAggregateMenu';
import DrilldownBreakoutMenu from '../DrilldownMenu/DrilldownBreakoutMenu';
import DrilldownDashboardVariablesMenu from '../DrilldownMenu/DrilldownDashboardVariablesMenu';
import DrilldownFilterMenu from '../DrilldownMenu/DrilldownFilterMenu';
import { useDrilldownBreakout } from './useDrilldownBreakout';
import { useDrilldownCoordinates } from './useDrilldownCoordinates';
import { useDrilldownDashboardVariables } from './useDrilldownDashboardVariables';
import { useDrilldownFilter } from './useDrilldownFilter';
import { useResolvedDrilldownQuery } from './useResolvedDrilldownQuery';
import { useViewPanel } from './useViewPanel';
/** Which menu the popover shows; extend as submenus are added (e.g. dashboard variables). */
enum DrilldownSubMenu {
Base = 'base',
Breakout = 'breakout',
DashboardVariables = 'dashboardVariables',
}
/** Stable empty-filters ref so the dashboard-variables hook doesn't re-run on every no-click render. */
const EMPTY_FILTERS: FilterData[] = [];
/** Props the panel shell spreads onto `<ContextMenu>`. */
export interface DrilldownContextMenuProps {
coordinates: Coordinates | null;
popoverPosition: PopoverPosition | null;
items: ReactNode;
onClose: () => void;
}
export interface UseDrilldownResult {
/** Whether interactive renderers should arm the drill-down click. */
enableDrillDown: boolean;
/** Renderer `onClick` handler — opens the menu at the clicked point. */
onPanelClick: (payload: DrilldownClickPayload) => void;
contextMenuProps: DrilldownContextMenuProps;
}
/**
* Orchestrates panel drill-down: owns the popover + which submenu is open, and routes the clicked
* point to the base aggregate menu (View in Logs/Traces), the group filter menu, or the breakout picker.
*/
export function useDrilldown(
panel: DashboardtypesPanelDTO,
panelId: string,
): UseDrilldownResult {
const kind = panel.spec.plugin.kind;
const panelType = PANEL_KIND_TO_PANEL_TYPE[kind];
const queries = panel.spec.queries;
// Kind must opt in via its capability AND have a builder query to drill into.
const enableDrillDown = useMemo(
() =>
getPanelDefinition(kind).actions.drilldown &&
getBuilderQueries(queries).length > 0,
[kind, queries],
);
const v1Query = useMemo(
() => fromPerses(queries, panelType),
[queries, panelType],
);
const {
coordinates,
popoverPosition,
clickedData: context,
onClick,
onClose,
} = useDrilldownCoordinates<DrilldownContext>();
const aggregateData = useMemo(
() => (context ? buildAggregateData(context) : null),
[context],
);
// A fresh click and any close reset to the base menu.
const [subMenu, setSubMenu] = useState<DrilldownSubMenu>(
DrilldownSubMenu.Base,
);
const openBreakout = useCallback(
(): void => setSubMenu(DrilldownSubMenu.Breakout),
[],
);
const backToBase = useCallback(
(): void => setSubMenu(DrilldownSubMenu.Base),
[],
);
const openDashboardVariables = useCallback(
(): void => setSubMenu(DrilldownSubMenu.DashboardVariables),
[],
);
const onPanelClick = useCallback(
(payload: DrilldownClickPayload): void => {
setSubMenu(DrilldownSubMenu.Base);
onClick(payload.coordinates, payload.context);
},
[onClick],
);
const handleClose = useCallback((): void => {
setSubMenu(DrilldownSubMenu.Base);
onClose();
}, [onClose]);
const { openViewWithQuery } = useViewPanel();
const breakout = useDrilldownBreakout({
panelId,
v1Query,
panelType,
aggregateData,
openViewWithQuery,
onClose: handleClose,
});
const filter = useDrilldownFilter({
context,
v1Query,
panelId,
panelType,
openViewWithQuery,
onClose: handleClose,
});
const dashboardVariables = useDrilldownDashboardVariables({
filters: context?.filters ?? EMPTY_FILTERS,
signal: context?.signal,
onClose: handleClose,
});
// The aggregate menu (View in Logs/Traces) shows for a non-group click on the base menu; the
// group click routes to filter-by-value instead. Only that menu resolves variables —
// filter/breakout open the View modal, which resolves at query-run time.
const showAggregateMenu =
subMenu === DrilldownSubMenu.Base && !!context && !filter.isGroupColumnClick;
const { resolvedQuery, isResolving } = useResolvedDrilldownQuery({
queries,
panelType,
v1Query,
enabled: showAggregateMenu,
});
const navigate = useBaseDrilldownNavigate({
resolvedQuery,
aggregateData,
callback: handleClose,
});
const items = useMemo<ReactNode>(() => {
if (subMenu === DrilldownSubMenu.Breakout) {
return breakout.queryData ? (
<DrilldownBreakoutMenu
queryData={breakout.queryData}
onBreakout={breakout.onBreakout}
onBack={backToBase}
/>
) : null;
}
if (subMenu === DrilldownSubMenu.DashboardVariables) {
return (
<DrilldownDashboardVariablesMenu
actions={dashboardVariables.actions}
onBack={backToBase}
/>
);
}
if (filter.isGroupColumnClick && context?.clickedKey) {
return (
<DrilldownFilterMenu
v1Query={v1Query}
clickedKey={context.clickedKey}
onFilter={filter.onFilter}
/>
);
}
if (!context) {
return null;
}
return (
<DrilldownAggregateMenu
context={context}
query={v1Query}
isResolving={isResolving}
links={panel.spec.links}
canSetDashboardVariables={dashboardVariables.hasFieldVariables}
onViewLogs={(): void => navigate('view_logs')}
onViewTraces={(): void => navigate('view_traces')}
onBreakout={openBreakout}
onSetDashboardVariables={openDashboardVariables}
onClose={handleClose}
/>
);
}, [
subMenu,
breakout.queryData,
breakout.onBreakout,
dashboardVariables.actions,
dashboardVariables.hasFieldVariables,
filter.isGroupColumnClick,
filter.onFilter,
context,
v1Query,
isResolving,
panel.spec.links,
navigate,
openBreakout,
openDashboardVariables,
backToBase,
handleClose,
]);
return {
enableDrillDown,
onPanelClick,
contextMenuProps: {
coordinates,
popoverPosition,
items,
onClose: handleClose,
},
};
}

View File

@@ -0,0 +1,76 @@
import { useCallback, useMemo } from 'react';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { getQueryData } from 'container/QueryTable/Drilldown/drilldownUtils';
import {
getBreakoutPanelType,
getBreakoutQuery,
} from 'container/QueryTable/Drilldown/tableDrilldownUtils';
import type { BreakoutAttributeType } from 'container/QueryTable/Drilldown/types';
import type { AggregateData } from 'container/QueryTable/Drilldown/useAggregateDrilldown';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
interface UseDrilldownBreakoutArgs {
panelId: string;
/** The panel's V5→V1 query; the breakout regroups the clicked query within it. */
v1Query: Query;
/** The panel's current V1 panel type — drives the breakout target type. */
panelType: PANEL_TYPES;
aggregateData: AggregateData | null;
/** Opens the View modal on the breakout query (at the breakout's target kind), persisting it in the URL. */
openViewWithQuery: (
panelId: string,
query: Query,
panelType: PANEL_TYPES,
) => void;
/** Close the popover after navigating to the View modal. */
onClose: () => void;
}
export interface UseDrilldownBreakoutApi {
/** The clicked query's builder data for the attribute picker; `undefined` when there's no aggregate to break out. */
queryData: IBuilderQuery | undefined;
/** Regroup the clicked query by the picked attribute and open the result in the View modal. */
onBreakout: (groupBy: BreakoutAttributeType) => void;
}
/**
* The "Breakout by .." submenu logic: regroup the clicked query by a picked attribute and open the
* result in the View modal. Reuses V1's read-only `getBreakoutQuery`/`getBreakoutPanelType`; the
* caller renders `DrilldownBreakoutMenu` (V1's `BreakoutOptions` picker) from this hook's return.
*/
export function useDrilldownBreakout({
panelId,
v1Query,
panelType,
aggregateData,
openViewWithQuery,
onClose,
}: UseDrilldownBreakoutArgs): UseDrilldownBreakoutApi {
const onBreakout = useCallback(
(groupBy: BreakoutAttributeType): void => {
if (!aggregateData) {
return;
}
const breakoutQuery = getBreakoutQuery(
v1Query,
aggregateData,
groupBy,
aggregateData.filters ?? [],
);
openViewWithQuery(panelId, breakoutQuery, getBreakoutPanelType(panelType));
onClose();
},
[aggregateData, v1Query, panelType, panelId, openViewWithQuery, onClose],
);
const queryData = useMemo(
() =>
aggregateData ? getQueryData(v1Query, aggregateData.queryName) : undefined,
[aggregateData, v1Query],
);
return { queryData, onBreakout };
}

View File

@@ -0,0 +1,58 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- context links resolve global-time variables off redux (V1 parity)
import { useSelector } from 'react-redux';
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
/**
* Builds the variable map that resolves context-link templates, mirroring V1's `useContextVariables`
* but sourced from V2 state: dashboard variable selections (by name), the global time window
* (`timestamp_start`/`timestamp_end`, ms), and the clicked point's field values (`_`-prefixed, the
* V1 convention that keeps them from clashing with dashboard variable names).
*/
export function useDrilldownContextVariables(
context: DrilldownContext | null,
): Record<string, string> {
// dashboardId from the store's edit context (set once by DashboardContainer), the same
// source the rest of V2 uses — not react-router params.
const dashboardId = useDashboardStore((state) => state.dashboardId);
// Select the stable top-level map (not `state.variableValues[id] ?? {}`, whose fresh `{}` each
// render makes useSyncExternalStore loop); index by dashboard inside the memo.
const variableValuesByDashboard = useDashboardStore(
(state) => state.variableValues,
);
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
return useMemo(() => {
const result: Record<string, string> = {};
const variableValues = variableValuesByDashboard[dashboardId] ?? {};
Object.entries(variableValues).forEach(([name, selection]) => {
const { value } = selection;
if (value == null) {
return;
}
result[name] = Array.isArray(value) ? value.join(', ') : String(value);
});
// Global time window, ns → ms (V1 parity).
result.timestamp_start = String(Math.floor(globalTime.minTime / 1e6));
result.timestamp_end = String(Math.floor(globalTime.maxTime / 1e6));
context?.filters.forEach(({ filterKey, filterValue }) => {
result[`_${filterKey}`] = String(filterValue);
});
return result;
}, [
variableValuesByDashboard,
dashboardId,
globalTime.minTime,
globalTime.maxTime,
context,
]);
}

View File

@@ -0,0 +1,44 @@
import { useCallback, useState } from 'react';
import type {
Coordinates,
PopoverPosition,
} from 'periscope/components/ContextMenu';
import { calculatePopoverPosition } from '../utils/calculatePopoverPosition';
/**
* Popover state for the drill-down context menu. V2's strongly-typed counterpart to V1's
* `useCoordinates` (whose `clickedData` is `any`): the caller pins `TData` to the click payload,
* so it flows through without a cast. `null` fields mean the menu is closed.
*/
export interface UseDrilldownCoordinatesResult<TData> {
coordinates: Coordinates | null;
popoverPosition: PopoverPosition | null;
clickedData: TData | null;
onClick: (coordinates: Coordinates, data: TData) => void;
onClose: () => void;
}
export function useDrilldownCoordinates<
TData,
>(): UseDrilldownCoordinatesResult<TData> {
const [coordinates, setCoordinates] = useState<Coordinates | null>(null);
const [popoverPosition, setPopoverPosition] = useState<PopoverPosition | null>(
null,
);
const [clickedData, setClickedData] = useState<TData | null>(null);
const onClick = useCallback((coords: Coordinates, data: TData): void => {
setClickedData(data);
setCoordinates(coords);
setPopoverPosition(calculatePopoverPosition(coords));
}, []);
const onClose = useCallback((): void => {
setCoordinates(null);
setPopoverPosition(null);
setClickedData(null);
}, []);
return { coordinates, popoverPosition, clickedData, onClick, onClose };
}

View File

@@ -0,0 +1,191 @@
import { useCallback, useMemo } from 'react';
import { toast } from '@signozhq/ui/sonner';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
import { useQueryState } from 'nuqs';
import {
dtoToFormModel,
formModelToDto,
} from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters';
import {
DYNAMIC_SIGNAL_ALL,
emptyVariableFormModel,
type VariableFormModel,
} from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel';
import { buildVariablesPatch } from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variablePatchOps';
import { useDashboardFetchRequired } from 'pages/DashboardPageV2/DashboardContainer/hooks/useDashboardFetchRequired';
import { useOptimisticPatch } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOptimisticPatch';
import { selectVariableValues } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import type { VariableSelection } from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/selectionTypes';
import {
ALL_SELECTED,
variablesUrlParser,
} from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection';
interface UseDrilldownDashboardVariablesArgs {
/** Group-by field filters from the clicked point (empty when the click has no group-by). */
filters: FilterData[];
/** Clicked query's telemetry signal — seeds a created variable's `dynamicSignal`. */
signal?: TelemetrytypesSignalDTO;
/** Close the popover after an action. */
onClose: () => void;
}
/** Set/Unset a matching dynamic variable's value, or Create one when none matches. */
export enum DrilldownVariableActionKind {
Set = 'set',
Unset = 'unset',
Create = 'create',
}
/** A resolved "Dashboard Variables" menu entry; the caller renders it. */
export interface DrilldownVariableAction {
fieldName: string;
fieldValue: string | number;
kind: DrilldownVariableActionKind;
/** Applies the action and closes the popover. */
onClick: () => void;
}
export interface UseDrilldownDashboardVariablesApi {
/** Whether the clicked point exposes any field to bind to a variable (gates the base-menu entry). */
hasFieldVariables: boolean;
/** Resolved menu entries, one per group-by field on the clicked point. */
actions: DrilldownVariableAction[];
}
/**
* "Dashboard Variables" submenu logic (V1 `useDashboardVarConfig` parity). Set/Unset are runtime-only
* (store + URL — V2 selections don't persist); Create is the one path that patches `spec.variables`.
*/
export function useDrilldownDashboardVariables({
filters,
signal,
onClose,
}: UseDrilldownDashboardVariablesArgs): UseDrilldownDashboardVariablesApi {
const dashboardId = useDashboardStore((state) => state.dashboardId);
const { variables } = useDashboardFetchRequired();
const dynamicVariables = useMemo(
() => variables.map(dtoToFormModel).filter((v) => v.type === 'DYNAMIC'),
[variables],
);
const existingNames = useMemo(
() => new Set(variables.map((v) => dtoToFormModel(v).name)),
[variables],
);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setVariableValue = useDashboardStore((state) => state.setVariableValue);
const [, setUrlValues] = useQueryState(
'variables',
variablesUrlParser.withOptions({ history: 'replace' }),
);
const { patchAsync } = useOptimisticPatch();
const fieldVariables = useMemo<[string, string | number][]>(
() =>
filters
.filter(
(f) => f.filterKey && f.filterValue !== undefined && f.filterValue !== '',
)
.map((f) => [f.filterKey, f.filterValue]),
[filters],
);
// Runtime-only write (store + URL), never the spec — mirrors VariablesBar's setSelection.
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
setVariableValue(dashboardId, name, next);
void setUrlValues((prev) => ({
...(prev ?? {}),
[name]: next.allSelected ? ALL_SELECTED : next.value,
}));
},
[dashboardId, setVariableValue, setUrlValues],
);
const handleCreate = useCallback(
async (fieldName: string, fieldValue: string | number): Promise<void> => {
if (existingNames.has(fieldName)) {
toast.error(`Variable "${fieldName}" already exists`);
return;
}
const model: VariableFormModel = {
...emptyVariableFormModel(),
name: fieldName,
description: `Created from panel drilldown (field: ${fieldName})`,
type: 'DYNAMIC',
multiSelect: true,
dynamicAttribute: fieldName,
dynamicSignal: signal ?? DYNAMIC_SIGNAL_ALL,
};
try {
await patchAsync(
buildVariablesPatch([...variables, formModelToDto(model)]),
);
// Multi-select var → seed the value as an array (the selector renders scalars as empty).
setSelection(fieldName, { value: [fieldValue], allSelected: false });
toast.success(`Created variable "${fieldName}"`);
} catch {
toast.error('Failed to create variable');
}
onClose();
},
[existingNames, signal, variables, patchAsync, setSelection, onClose],
);
const actions = useMemo<DrilldownVariableAction[]>(
() =>
fieldVariables.map(([fieldName, fieldValue]) => {
const existing = dynamicVariables.find(
(v) => v.dynamicAttribute === fieldName,
);
if (!existing) {
return {
fieldName,
fieldValue,
kind: DrilldownVariableActionKind.Create,
onClick: (): void => {
void handleCreate(fieldName, fieldValue);
},
};
}
const current = selection[existing.name]?.value;
const isSame = Array.isArray(current)
? current.length === 1 && current[0] === fieldValue
: current === fieldValue;
// Multi-select values must be arrays (a scalar renders as empty in the selector).
const cleared = existing.multiSelect ? [] : '';
const assigned = existing.multiSelect ? [fieldValue] : fieldValue;
return {
fieldName,
fieldValue,
kind: isSame
? DrilldownVariableActionKind.Unset
: DrilldownVariableActionKind.Set,
onClick: (): void => {
setSelection(existing.name, {
value: isSame ? cleared : assigned,
allSelected: false,
});
onClose();
},
};
}),
[
fieldVariables,
dynamicVariables,
selection,
setSelection,
handleCreate,
onClose,
],
);
return { hasFieldVariables: fieldVariables.length > 0, actions };
}

View File

@@ -0,0 +1,72 @@
import { useCallback } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
addFilterToQuery,
getBaseMeta,
isNumberDataType,
} from 'container/QueryTable/Drilldown/drilldownUtils';
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
interface UseDrilldownFilterArgs {
/** The clicked point's context; the filter menu only appears for a group-column click. */
context: DrilldownContext | null;
/** The panel's V5→V1 query the filter is added to. */
v1Query: Query;
panelId: string;
/** Panel's V1 type — the kind the refined query opens the View modal as. */
panelType: PANEL_TYPES;
/** Opens the View modal on the refined query, persisting it in the URL. */
openViewWithQuery: (
panelId: string,
query: Query,
panelType: PANEL_TYPES,
) => void;
/** Close the popover after opening the View modal. */
onClose: () => void;
}
export interface UseDrilldownFilterApi {
/** True for a group-column click — the caller renders the filter-by-value menu. */
isGroupColumnClick: boolean;
/** Apply the chosen operator: add `key <op> value` and open the refined result in the View modal. */
onFilter: (operator: string) => void;
}
/**
* The group-column "filter by value" drill-down (V1 parity): adds `key <op> value` to the panel's
* query and opens the refined result in the View modal. The caller renders `DrilldownFilterMenu`
* (V1's read-only `getGroupContextMenuConfig`) from this hook's return.
*/
export function useDrilldownFilter({
context,
v1Query,
panelId,
panelType,
openViewWithQuery,
onClose,
}: UseDrilldownFilterArgs): UseDrilldownFilterApi {
const onFilter = useCallback(
(operator: string): void => {
if (!context?.clickedKey) {
return;
}
let filterValue: string | number = context.clickedValue ?? '';
const baseMeta = getBaseMeta(v1Query, context.clickedKey);
if (baseMeta && isNumberDataType(baseMeta.dataType) && filterValue !== '') {
filterValue = Number(filterValue);
}
const refinedQuery = addFilterToQuery(v1Query, [
{ filterKey: context.clickedKey, filterValue, operator },
]);
openViewWithQuery(panelId, refinedQuery, panelType);
onClose();
},
[context, v1Query, panelType, panelId, openViewWithQuery, onClose],
);
const isGroupColumnClick =
!!context && context.columnKind === 'group' && !!context.clickedKey;
return { isGroupColumnClick, onFilter };
}

View File

@@ -0,0 +1,88 @@
import { useEffect, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
import { useSelector } from 'react-redux';
import { useReplaceVariables } from 'api/generated/services/querier';
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { buildQueryRangeRequest } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
import { envelopesToQuery } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import { selectResolvedVariables } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import { AppState } from 'store/reducers';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
interface UseResolvedDrilldownQueryArgs {
/** Panel's perses queries — the substitution source (carries the `$var` refs). */
queries: DashboardtypesQueryDTO[];
panelType: PANEL_TYPES;
/** The raw V5→V1 query; the fallback until substitution resolves / when no vars exist. */
v1Query: Query;
/** Resolve only while the aggregate menu is open (V1 parity: fires when it appears). */
enabled: boolean;
}
interface UseResolvedDrilldownQueryResult {
/** The variable-substituted query for View-in-X navigation. */
resolvedQuery: Query;
/** True while the round-trip is in flight — View-in-X shows a spinner and is disabled. */
isResolving: boolean;
}
/**
* Resolves the panel's dashboard-variable references (`$var`) into concrete values before
* View in Logs/Traces builds the explorer URL — V1 parity (`useBaseAggregateOptions` runs the
* same `/substitute_vars` round-trip). Skipped when the dashboard has no selections: the raw
* query already carries the refs verbatim and the round-trip would be a no-op. Mirrors the
* V2-native path in {@link useCreateAlertFromPanel}.
*/
export function useResolvedDrilldownQuery({
queries,
panelType,
v1Query,
enabled,
}: UseResolvedDrilldownQueryArgs): UseResolvedDrilldownQueryResult {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { mutate: substituteVars, data, isLoading } = useReplaceVariables();
const hasVariables = Object.keys(variables).length > 0;
useEffect(() => {
if (!enabled || !hasVariables) {
return;
}
// Redux global time is nanoseconds; the request DTO takes integer epoch ms — the
// backend rejects fractional bounds, so floor after the ns→ms divide.
substituteVars({
data: buildQueryRangeRequest({
queries,
panelType,
startMs: Math.floor(minTime / 1e6),
endMs: Math.floor(maxTime / 1e6),
variables,
}),
});
}, [
enabled,
hasVariables,
queries,
panelType,
minTime,
maxTime,
variables,
substituteVars,
]);
const resolvedQuery = useMemo(() => {
if (!hasVariables || !data) {
return v1Query;
}
return envelopesToQuery(data.data.compositeQuery?.queries ?? [], panelType);
}, [hasVariables, data, v1Query, panelType]);
return { resolvedQuery, isResolving: enabled && hasVariables && isLoading };
}

View File

@@ -1,22 +1,33 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
export interface UseViewPanelApi {
/** Panel id currently expanded in the View modal; null when none is open. */
expandedPanelId: string | null;
/** Open the View modal on the saved panel (clears any leftover in-modal query/kind). */
openView: (panelId: string) => void;
/** Close the View modal by clearing the URL param. */
/**
* Open the View modal pre-seeded with a drilldown query + kind, persisted in the URL so it
* survives refresh (V1 parity); the modal hydrates its draft from these on mount.
*/
openViewWithQuery: (
panelId: string,
query: Query,
panelType: PANEL_TYPES,
) => void;
/** Close the View modal by clearing its URL params. */
closeView: () => void;
}
/**
* Drives the panel View modal off the `expandedWidgetId` URL param (V1 parity):
* the open state is shareable, survives refresh, and the browser back-button
* closes it. Reuses V1's param key so a deep-linked V1 URL maps cleanly.
* Drives the panel View modal off the URL (V1 parity): `expandedWidgetId` holds the open
* panel, and a drilldown additionally seeds `compositeQuery` + `graphType`. URL-backed state
* is shareable, survives refresh, and the browser back-button closes it.
*/
export function useViewPanel(): UseViewPanelApi {
const { safeNavigate } = useSafeNavigate();
@@ -39,6 +50,22 @@ export function useViewPanel(): UseViewPanelApi {
[pathname, safeNavigate, urlQuery],
);
const openViewWithQuery = useCallback(
(panelId: string, query: Query, panelType: PANEL_TYPES): void => {
const next = new URLSearchParams(urlQuery);
next.set(QueryParams.expandedWidgetId, panelId);
next.set(QueryParams.graphType, panelType);
// Same encoding the query builder uses (see `useGetCompositeQueryParam`): the URL
// value is `encodeURIComponent(JSON.stringify(query))`, decoded once on read.
next.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
safeNavigate(`${pathname}?${next.toString()}`);
},
[pathname, safeNavigate, urlQuery],
);
const closeView = useCallback((): void => {
const next = new URLSearchParams(urlQuery);
next.delete(QueryParams.expandedWidgetId);
@@ -50,5 +77,5 @@ export function useViewPanel(): UseViewPanelApi {
safeNavigate(search ? `${pathname}?${search}` : pathname);
}, [pathname, safeNavigate, urlQuery]);
return { expandedPanelId, openView, closeView };
return { expandedPanelId, openView, openViewWithQuery, closeView };
}

View File

@@ -0,0 +1,53 @@
import { calculatePopoverPosition } from '../calculatePopoverPosition';
// Popover is 300×254 with a 10px offset; these are the edges the placement flips against.
const setViewport = (width: number, height: number): void => {
Object.defineProperty(window, 'innerWidth', {
value: width,
configurable: true,
});
Object.defineProperty(window, 'innerHeight', {
value: height,
configurable: true,
});
};
describe('calculatePopoverPosition', () => {
beforeEach(() => setViewport(1920, 1080));
it('anchors to the right of the click when there is room', () => {
expect(calculatePopoverPosition({ x: 100, y: 100 })).toStrictEqual({
left: 110,
top: 90,
placement: 'right',
});
});
it('flips to the left near the right edge', () => {
const { left, placement } = calculatePopoverPosition({ x: 1900, y: 100 });
expect(placement).toBe('left');
expect(left).toBe(1900 - 300 + 10);
});
it('clamps back in when the left flip would overflow a narrow viewport', () => {
setViewport(320, 1080);
// Right flip fires (60 + 300 > 320) but its left (50 - 300 + 10) is off-screen, so it clamps.
expect(calculatePopoverPosition({ x: 50, y: 100 })).toMatchObject({
left: 10,
placement: 'right',
});
});
it('drops below the click near the top edge', () => {
expect(calculatePopoverPosition({ x: 100, y: 2 })).toMatchObject({
top: 10,
placement: 'bottomRight',
});
});
it('lifts above the click near the bottom edge', () => {
const { top, placement } = calculatePopoverPosition({ x: 100, y: 1070 });
expect(placement).toBe('topRight');
expect(top).toBe(1080 - 254 - 10);
});
});

View File

@@ -0,0 +1,46 @@
import type {
Coordinates,
PopoverPosition,
} from 'periscope/components/ContextMenu';
// Kept in sync with the popover's `overlayStyle` in `ContextMenu` (width 300, maxHeight 254).
const POPOVER_WIDTH = 300;
const POPOVER_HEIGHT = 254;
const OFFSET = 10;
/**
* Places the drill-down popover next to the clicked point, flipping/clamping so it stays within the
* viewport. Anchors to the right by default and mirrors to the left (and top/bottom) near an edge.
*/
export function calculatePopoverPosition({
x,
y,
}: Coordinates): PopoverPosition {
const { innerWidth: windowWidth, innerHeight: windowHeight } = window;
let left = x + OFFSET;
let top = y - OFFSET;
let placement: PopoverPosition['placement'] = 'right';
if (left + POPOVER_WIDTH > windowWidth) {
left = x - POPOVER_WIDTH + OFFSET;
placement = 'left';
}
if (left < 0) {
left = OFFSET;
placement = 'right';
}
if (top < 0) {
top = OFFSET;
placement = placement === 'right' ? 'bottomRight' : 'bottomLeft';
}
if (top + POPOVER_HEIGHT > windowHeight) {
top = windowHeight - POPOVER_HEIGHT - OFFSET;
placement = placement === 'right' ? 'topRight' : 'topLeft';
}
return { left, top, placement };
}

View File

@@ -29,8 +29,13 @@ interface SectionProps {
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const {
isPickerOpen,
openPicker,
closePicker,
createPanel,
targetLayoutIndex,
} = useCreatePanel();
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
@@ -141,6 +146,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
open={isPickerOpen}
onClose={closePicker}
onSelect={createPanel}
defaultLayoutIndex={targetLayoutIndex}
/>
<ConfirmDeleteDialog
open={isDeleteOpen}

View File

@@ -30,7 +30,9 @@ function PanelsAndSectionsLayout({
// Single View-modal host for the whole dashboard, driven by the URL
// (`expandedWidgetId`). One mounted modal beats one-per-panel: no N location
// subscriptions, and the expanded panel is looked up by id from the map.
// subscriptions, and the expanded panel is looked up by id from the map. A
// drilldown refinement rides in the URL (`compositeQuery`/`graphType`) and is
// hydrated inside the modal, so the host just hands it the saved panel.
const { expandedPanelId, closeView } = useViewPanel();
const expandedPanel = expandedPanelId ? panels[expandedPanelId] : undefined;

View File

@@ -20,11 +20,12 @@ import {
export const ALL_SELECTED = '__ALL__';
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
(v) =>
typeof v === 'object' && v !== null
? (v as Record<string, SelectedVariableValue>)
: null,
export const variablesUrlParser = parseAsJson<
Record<string, SelectedVariableValue>
>((v) =>
typeof v === 'object' && v !== null
? (v as Record<string, SelectedVariableValue>)
: null,
);
function defaultSelection(model: VariableFormModel): VariableSelection {

View File

@@ -12,7 +12,10 @@ interface UseCreatePanelResult {
/** Pass the target section's layout index; omit → last/new section. */
openPicker: (layoutIndex?: number) => void;
closePicker: () => void;
createPanel: (panelKind: PanelKind) => void;
/** The section the picker was opened against — seeds its section dropdown. */
targetLayoutIndex: number | undefined;
/** `layoutIndex` overrides the opened-against target (the dropdown's choice). */
createPanel: (panelKind: PanelKind, layoutIndex?: number) => void;
}
/**
@@ -38,16 +41,23 @@ export function useCreatePanel(): UseCreatePanelResult {
}, []);
const createPanel = useCallback(
(panelKind: PanelKind): void => {
(panelKind: PanelKind, targetIndex?: number): void => {
setIsPickerOpen(false);
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId: NEW_PANEL_ID,
});
safeNavigate(`${path}${newPanelSearch(panelKind, layoutIndex)}`);
const target = targetIndex ?? layoutIndex;
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
},
[safeNavigate, dashboardId, layoutIndex],
);
return { isPickerOpen, openPicker, closePicker, createPanel };
return {
isPickerOpen,
openPicker,
closePicker,
targetLayoutIndex: layoutIndex,
createPanel,
};
}

View File

@@ -0,0 +1,21 @@
import { useMemo } from 'react';
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
import { useDashboardStore } from '../store/useDashboardStore';
import { type DashboardSection, layoutsToSections } from '../utils';
/**
* The current dashboard's sections, read from the already-loaded dashboard
* query. The page fetches via useGetDashboardV2 keyed by id, so this reuses that
* cache (no extra request) instead of prop-drilling the section list.
*/
export function useDashboardSections(): DashboardSection[] {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { data } = useGetDashboardV2({ id: dashboardId });
const spec = data?.data?.spec;
return useMemo(
() => layoutsToSections(spec?.layouts, spec?.panels),
[spec?.layouts, spec?.panels],
);
}

View File

@@ -38,22 +38,14 @@ function DashboardContainer({
user.role,
);
// Publish edit context to the store so hooks/components read it from there
// instead of receiving dashboardId/isEditable/refetch as props down the tree.
// Seed during render (not an effect) so the first Panel render already sees the id —
// useDashboardFetchRequired throws on a missing id. setEditContext self-guards.
const setEditContext = useDashboardStore((s) => s.setEditContext);
useEffect(() => {
setEditContext({
dashboardId: dashboard.id,
isEditable: !dashboard.locked && editDashboardPermission,
refetch,
});
}, [
dashboard.id,
dashboard.locked,
editDashboardPermission,
setEditContext({
dashboardId: dashboard.id,
isEditable: !dashboard.locked && editDashboardPermission,
refetch,
setEditContext,
]);
});
// Resolve the variable selection into the V5 query payload and publish it to
// the store, so each panel's query substitutes the bar's selected values.

View File

@@ -24,11 +24,20 @@ export const createEditContextSlice: StateCreator<
[['zustand/persist', unknown]],
[],
EditContextSlice
> = (set) => ({
> = (set, get) => ({
dashboardId: '',
isEditable: false,
refetch: (): void => undefined,
// Idempotent (no-op when unchanged) so it's safe to call during render.
setEditContext: (ctx): void => {
const { dashboardId, isEditable, refetch } = get();
if (
dashboardId === ctx.dashboardId &&
isEditable === ctx.isEditable &&
refetch === ctx.refetch
) {
return;
}
set({
dashboardId: ctx.dashboardId,
isEditable: ctx.isEditable,

View File

@@ -130,27 +130,6 @@
justify-content: flex-end;
}
.tag {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
height: 28px;
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
text-align: center;
font-family: Inter;
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 20px;
letter-spacing: -0.07px;
margin-inline-end: 0px;
}
.details {
margin-top: 12px;
display: flex;

View File

@@ -1,4 +1,4 @@
import { Badge } from '@signozhq/ui/badge';
import TagBadge from 'components/TagBadge/TagBadge';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
@@ -105,14 +105,10 @@ function DashboardRow({
{tags.length > 0 && (
<div className={styles.tags}>
{tags.slice(0, 3).map((tag) => (
<Badge className={styles.tag} key={tag}>
{tag}
</Badge>
<TagBadge key={tag}>{tag}</TagBadge>
))}
{tags.length > 3 && (
<Badge className={styles.tag} key={tags[3]}>
+ <Typography.Text> {tags.length - 3} </Typography.Text>
</Badge>
<TagBadge key={tags[3]}>+{tags.length - 3}</TagBadge>
)}
</div>
)}

View File

@@ -65,6 +65,7 @@ type provider struct {
zeusHandler zeus.Handler
querierHandler querier.Handler
serviceAccountHandler serviceaccount.Handler
serviceAccountGetter serviceaccount.Getter
factoryHandler factory.Handler
cloudIntegrationHandler cloudintegration.Handler
ruleStateHistoryHandler rulestatehistory.Handler
@@ -99,6 +100,7 @@ func NewFactory(
zeusHandler zeus.Handler,
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
serviceAccountGetter serviceaccount.Getter,
factoryHandler factory.Handler,
cloudIntegrationHandler cloudintegration.Handler,
ruleStateHistoryHandler rulestatehistory.Handler,
@@ -136,6 +138,7 @@ func NewFactory(
zeusHandler,
querierHandler,
serviceAccountHandler,
serviceAccountGetter,
factoryHandler,
cloudIntegrationHandler,
ruleStateHistoryHandler,
@@ -175,6 +178,7 @@ func newProvider(
zeusHandler zeus.Handler,
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
serviceAccountGetter serviceaccount.Getter,
factoryHandler factory.Handler,
cloudIntegrationHandler cloudintegration.Handler,
ruleStateHistoryHandler rulestatehistory.Handler,
@@ -213,6 +217,7 @@ func newProvider(
zeusHandler: zeusHandler,
querierHandler: querierHandler,
serviceAccountHandler: serviceAccountHandler,
serviceAccountGetter: serviceAccountGetter,
factoryHandler: factoryHandler,
cloudIntegrationHandler: cloudIntegrationHandler,
ruleStateHistoryHandler: ruleStateHistoryHandler,

View File

@@ -141,17 +141,17 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.SetRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccountRole",
ID: "CreateServiceAccountRoleDeprecated",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
Description: "This endpoint assigns a role to a service account",
Request: new(serviceaccounttypes.PostableServiceAccountRole),
Request: new(serviceaccounttypes.DeprecatedPostableServiceAccountRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
Deprecated: true,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
@@ -171,7 +171,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.DeleteRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteServiceAccountRole",
ID: "DeleteServiceAccountRoleDeprecated",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
Description: "This endpoint revokes a role from service account",
@@ -181,7 +181,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
Deprecated: true,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
@@ -398,13 +398,96 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_account_roles", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.CreateServiceAccountRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
Description: "This endpoint assigns a role to a service account",
Request: new(serviceaccounttypes.PostableServiceAccountRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceServiceAccount,
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("serviceAccountId")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_account_roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.GetServiceAccountRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Get service account role",
Description: "This endpoint gets an existing service account role",
Request: nil,
RequestContentType: "",
Response: new(serviceaccounttypes.ServiceAccountRole),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: provider.serviceAccountIDExtractor(),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_account_roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.DeleteServiceAccountRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
Description: "This endpoint revokes a role from a service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceServiceAccount,
SourceIDs: coretypes.OneID(provider.serviceAccountIDExtractor()),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(provider.roleIDExtractor()),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
// roleSelector resolves the FGA selectors for a role from its UUID. The id is
// already extracted by the ResourceDef (path or body); this only does the
// UUID -> name lookup the FGA object string requires. Shared by service account
// and role routes.
func (provider *provider) roleSelector(ctx context.Context, resource coretypes.Resource, id string, orgID valuer.UUID) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(id)
if err != nil {
@@ -421,3 +504,53 @@ func (provider *provider) roleSelector(ctx context.Context, resource coretypes.R
resource.Type().MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func (provider *provider) roleIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
if err != nil {
return "", err
}
serviceAccountRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return "", err
}
serviceAccountRole, err := provider.serviceAccountGetter.GetServiceAccountRole(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), serviceAccountRoleID)
if err != nil {
return "", err
}
return serviceAccountRole.RoleID.String(), nil
})
}
func (provider *provider) serviceAccountIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
if err != nil {
return "", err
}
serviceAccountRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return "", err
}
serviceAccountRole, err := provider.serviceAccountGetter.GetServiceAccountRole(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), serviceAccountRoleID)
if err != nil {
return "", err
}
return serviceAccountRole.ServiceAccountID.String(), nil
})
}

View File

@@ -394,7 +394,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ResponseContentType: "",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
@@ -411,7 +411,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
@@ -434,5 +434,56 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/user_roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUserRole), handler.OpenAPIDef{
ID: "CreateUserRole",
Tags: []string{"users"},
Summary: "Create user role",
Description: "This endpoint assigns a role to a user",
Request: new(authtypes.PostableUserRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUserRole), handler.OpenAPIDef{
ID: "GetUserRole",
Tags: []string{"users"},
Summary: "Get user role",
Description: "This endpoint gets an existing user role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserRole),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUserRole), handler.OpenAPIDef{
ID: "DeleteUserRole",
Tags: []string{"users"},
Summary: "Delete user role",
Description: "This endpoint revokes a role from a user",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -216,6 +216,11 @@ func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string)
}
for _, resource := range resolved {
if err := resource.Err(); err != nil {
render.Error(rw, err)
return
}
if err := middleware.checkResource(ctx, claims, orgID, resource.Verb(), resource.SourceResource(), resource.SourceIDs(), resource.SourceSelector(), roleSelectors); err != nil {
render.Error(rw, err)
return

View File

@@ -117,16 +117,8 @@ var podsSpec = checkSpec{
DocumentationLink: docLinkKubeletStatsReceiver,
},
{
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{
"k8s.pod.phase",
"k8s.container.restarts", // pod restart count (default-on)
},
OptionalMetrics: []string{
// kubectl-style pod display status (default-off in the receiver).
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{"k8s.pod.phase"},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -157,10 +149,6 @@ var nodesSpec = checkSpec{
// By default, only k8s.node.condition_ready is enabled. (Check https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/4f9a578b210a6dcb9f9bf47942f27208b5765298/receiver/k8sclusterreceiver/metadata.yaml#L802)
"k8s.pod.phase", // pod counts per node by phase
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -194,10 +182,6 @@ var deploymentsSpec = checkSpec{
"k8s.deployment.desired",
"k8s.deployment.available",
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -236,10 +220,6 @@ var daemonsetsSpec = checkSpec{
"k8s.daemonset.desired_scheduled_nodes",
"k8s.daemonset.current_scheduled_nodes",
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -278,10 +258,6 @@ var statefulsetsSpec = checkSpec{
"k8s.statefulset.desired_pods",
"k8s.statefulset.current_pods",
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -322,10 +298,6 @@ var jobsSpec = checkSpec{
"k8s.job.failed_pods",
"k8s.job.successful_pods",
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -352,12 +324,8 @@ var namespacesSpec = checkSpec{
DocumentationLink: docLinkKubeletStatsReceiver,
},
{
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{"k8s.pod.phase"},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{"k8s.pod.phase"},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -392,10 +360,6 @@ var clustersSpec = checkSpec{
"k8s.node.condition_ready", // node counts by readiness
"k8s.pod.phase", // pod counts per cluster by phase
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{

View File

@@ -20,7 +20,6 @@ func buildClusterRecords(
metadataMap map[string]map[string]string,
nodeConditionCountsMap map[string]nodeConditionCounts,
podPhaseCountsMap map[string]podPhaseCounts,
podStatusCounts map[string]podStatusCounts,
) []inframonitoringtypes.ClusterRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -70,10 +69,6 @@ func buildClusterRecords(
}
}
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v

View File

@@ -27,8 +27,6 @@ var clustersTableMetricNamesList = []string{
"k8s.node.allocatable_memory",
"k8s.node.condition_ready", //TODO(nikhilmantri0902): should these metrics be used to count groups k8s.node.condition_ready and k8s.pod.phase
"k8s.pod.phase",
"k8s.pod.status_reason",
"k8s.container.status.reason",
}
var clusterAttrKeysForMetadata = []string{

View File

@@ -19,7 +19,6 @@ func buildDaemonSetRecords(
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
phaseCounts map[string]podPhaseCounts,
podStatusCounts map[string]podStatusCounts,
) []inframonitoringtypes.DaemonSetRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -78,10 +77,6 @@ func buildDaemonSetRecords(
}
}
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v

View File

@@ -23,8 +23,6 @@ var daemonSetNameGroupByKey = qbtypes.GroupByKey{
// response to short-circuit cleanly when the phase metric is absent.
var daemonSetsTableMetricNamesList = []string{
"k8s.pod.phase",
"k8s.pod.status_reason",
"k8s.container.status.reason",
"k8s.pod.cpu.usage",
"k8s.pod.cpu_request_utilization",
"k8s.pod.cpu_limit_utilization",

View File

@@ -19,7 +19,6 @@ func buildDeploymentRecords(
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
phaseCounts map[string]podPhaseCounts,
podStatusCounts map[string]podStatusCounts,
) []inframonitoringtypes.DeploymentRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -78,10 +77,6 @@ func buildDeploymentRecords(
}
}
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v

View File

@@ -23,8 +23,6 @@ var deploymentNameGroupByKey = qbtypes.GroupByKey{
// response to short-circuit cleanly when the phase metric is absent.
var deploymentsTableMetricNamesList = []string{
"k8s.pod.phase",
"k8s.pod.status_reason",
"k8s.container.status.reason",
"k8s.pod.cpu.usage",
"k8s.pod.cpu_request_utilization",
"k8s.pod.cpu_limit_utilization",

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