mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-06 14:40:38 +01:00
Compare commits
1 Commits
main
...
platform-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
671272a360 |
149
docs/contributing/go/authz.md
Normal file
149
docs/contributing/go/authz.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Authz
|
||||
|
||||
SigNoz uses [OpenFGA](https://openfga.dev/), a relationship-based access control (ReBAC) system, to authorize every request. Permissions are never attached to users directly — they are attached to **roles** (as relationship tuples in OpenFGA), and users or service accounts are made **assignees** of those roles. The central interface is `AuthZ` in [pkg/authz/authz.go](/pkg/authz/authz.go), backed by an embedded OpenFGA server in [pkg/authz/openfgaserver](/pkg/authz/openfgaserver/server.go).
|
||||
|
||||
As a feature author, you will rarely touch OpenFGA directly. You interact with two layers:
|
||||
|
||||
1. **The registries** in [pkg/types/coretypes](/pkg/types/coretypes) — where every resource, verb, and managed-role permission is declared in code.
|
||||
2. **The route wiring** in [pkg/apiserver/signozapiserver](/pkg/apiserver/signozapiserver) — where each route declares what resource it touches and which verb it needs, and the middleware turns that into a check.
|
||||
|
||||
## What are the building blocks?
|
||||
|
||||
### Type
|
||||
|
||||
A `Type` is the coarse FGA type of a resource. The set is finite and defined in [pkg/types/coretypes/registry_type.go](/pkg/types/coretypes/registry_type.go): `user`, `serviceaccount`, `anonymous`, `role`, `organization`, `metaresource`, and `telemetryresource`. Each type carries a selector regex (what a valid ID looks like) and the verbs allowed on it.
|
||||
|
||||
Almost every feature resource is a `metaresource` (dashboards, rules, pipelines, ...) or a `telemetryresource` (logs, traces, metrics). You should almost never need a new type.
|
||||
|
||||
### Verb
|
||||
|
||||
A `Verb` is the action being authorized. All verbs are defined in [pkg/types/coretypes/registry_verb.go](/pkg/types/coretypes/registry_verb.go): `create`, `read`, `update`, `delete`, `list`, `assignee`, `attach`, and `detach`.
|
||||
|
||||
- `assignee` is special: it is the membership relation between a subject and a role ("user X is an assignee of role Y"), not an action a route checks for.
|
||||
- `attach`/`detach` authorize linking two resources together (e.g. assigning a role to a service account).
|
||||
|
||||
### Kind
|
||||
|
||||
A `Kind` is the fine-grained name of your resource — `dashboard`, `rule`, `quick-filter`. Kinds are registered in [pkg/types/coretypes/registry_kind.go](/pkg/types/coretypes/registry_kind.go). A kind rides on top of an existing type, so adding one does **not** require any OpenFGA schema change.
|
||||
|
||||
### Resource
|
||||
|
||||
A `Resource` ties a type and a kind together and knows how to render itself as an FGA object string. The interface lives in [pkg/types/coretypes/resource.go](/pkg/types/coretypes/resource.go):
|
||||
|
||||
```go
|
||||
type Resource interface {
|
||||
Type() Type
|
||||
Kind() Kind
|
||||
Prefix(orgId valuer.UUID) string // metaresource:organization/<orgID>/dashboard
|
||||
Object(orgId valuer.UUID, selector string) string
|
||||
Scope(verb Verb) string // dashboard:read
|
||||
AllowedVerbs() []Verb
|
||||
}
|
||||
```
|
||||
|
||||
All resources are registered in [pkg/types/coretypes/registry_resource.go](/pkg/types/coretypes/registry_resource.go) using constructors like `NewResourceMetaResource(KindDashboard)` or `NewResourceTelemetryResource(KindLogs)`.
|
||||
|
||||
### Selector
|
||||
|
||||
A `Selector` identifies *which* instance(s) of a resource a check is about — a UUID, a role name, or the wildcard `*`. A `SelectorFunc` maps the extracted resource ID to selectors at request time. Two prebuilt ones cover most routes ([pkg/types/coretypes/selector.go](/pkg/types/coretypes/selector.go)):
|
||||
|
||||
- `WildcardSelector` — the check is against all instances of the resource (`create`, `list`).
|
||||
- `IDSelector` — the check is against the specific instance *or* the wildcard (`read`, `update`, `delete` of one object). A subject authorized on `*` is authorized on every instance.
|
||||
|
||||
When the ID in the request is not what FGA needs (e.g. routes receive a role UUID but FGA objects use role names), write a custom `SelectorFunc` — see `roleSelector` in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
|
||||
|
||||
### Roles, transactions, and tuples
|
||||
|
||||
SigNoz ships four managed roles, declared in [pkg/types/coretypes/registry_managed_role.go](/pkg/types/coretypes/registry_managed_role.go): `signoz-admin`, `signoz-editor`, `signoz-viewer`, and `signoz-anonymous`. Their permissions are declared in code as `Transaction`s (a verb on an object) in `ManagedRoleToTransactions` — this map is the single source of truth for what each managed role can do.
|
||||
|
||||
At organization bootstrap, `CreateManagedRoles` and `CreateManagedUserRoleTransactions` (see [pkg/authz/authz.go](/pkg/authz/authz.go)) persist the role rows and write one OpenFGA tuple per transaction, linking `role:organization/<orgID>/role/<name>#assignee` to each permitted object. Users and service accounts are then granted roles via `Grant`/`Revoke`, which writes `assignee` tuples. Custom roles (enterprise) are managed through the roles API in [pkg/authz/signozauthzapi/handler.go](/pkg/authz/signozauthzapi/handler.go).
|
||||
|
||||
### Schema
|
||||
|
||||
The OpenFGA authorization model is a hand-written DSL, embedded at build time: [pkg/authz/openfgaschema/base.fga](/pkg/authz/openfgaschema/base.fga) for community and [ee/authz/openfgaschema/base.fga](/ee/authz/openfgaschema/base.fga) for enterprise. The community model only supports role assignment; the enterprise model defines per-verb relations on every type, enabling genuine per-resource checks. This split is why `CheckWithTupleCreation` behaves differently per edition (see [How does a check work at runtime?](#how-does-a-check-work-at-runtime)). You only touch these files when introducing a brand-new **type** — never for a new kind.
|
||||
|
||||
## How do I add authz to my feature?
|
||||
|
||||
### 1. Register the kind
|
||||
|
||||
Add your kind in [pkg/types/coretypes/registry_kind.go](/pkg/types/coretypes/registry_kind.go) and append it to `Kinds`:
|
||||
|
||||
```go
|
||||
KindThing = MustNewKind("thing")
|
||||
```
|
||||
|
||||
### 2. Register the resource
|
||||
|
||||
Add the resource in [pkg/types/coretypes/registry_resource.go](/pkg/types/coretypes/registry_resource.go) and append it to `Resources`:
|
||||
|
||||
```go
|
||||
ResourceMetaResourceThing = NewResourceMetaResource(KindThing)
|
||||
```
|
||||
|
||||
Pass an explicit verb list to `NewResourceMetaResource` only if your resource supports fewer verbs than the type default.
|
||||
|
||||
### 3. Grant permissions to managed roles
|
||||
|
||||
Decide what each managed role can do with your resource and add the transactions in [pkg/types/coretypes/registry_managed_role.go](/pkg/types/coretypes/registry_managed_role.go):
|
||||
|
||||
```go
|
||||
// thing — editors manage, viewers read
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindThing}, WildCardSelectorString)},
|
||||
```
|
||||
|
||||
The convention so far: admin gets everything, editor gets CRUD on day-to-day observability resources, viewer gets `read`/`list`, anonymous gets nothing (except public dashboards).
|
||||
|
||||
### 4. Wire the route
|
||||
|
||||
Register the route in [pkg/apiserver/signozapiserver](/pkg/apiserver/signozapiserver), wrapping your handler with `CheckResources` and declaring what the route touches via a `ResourceDef` ([pkg/http/handler/resourcedef.go](/pkg/http/handler/resourcedef.go)). A complete example from [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go):
|
||||
|
||||
```go
|
||||
router.Handle("/api/v1/service_accounts", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Create, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccount",
|
||||
// ...
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceServiceAccount,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost)
|
||||
```
|
||||
|
||||
The pieces:
|
||||
|
||||
- **`CheckResources(handlerFn, roles...)`** — the resource-aware authorization wrapper from [pkg/http/middleware/authz.go](/pkg/http/middleware/authz.go). The role list is the community-edition fallback: which managed roles may call this route when per-resource checks are unavailable.
|
||||
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
|
||||
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
|
||||
|
||||
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role) or `AttachDetachParentChildResourceDef` (only the parent is checked; the child is recorded for audit, e.g. creating an API key under a service account).
|
||||
|
||||
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.
|
||||
|
||||
### 5. Backfill existing organizations (only if needed)
|
||||
|
||||
Managed-role tuples are written from the registry at organization creation, so **new resources need no migration for new organizations**. If existing organizations must get the new permissions, add a migration in [pkg/sqlmigration](/pkg/sqlmigration) that inserts the tuples — see [083_add_role_crud_tuples.go](/pkg/sqlmigration/083_add_role_crud_tuples.go) for the pattern.
|
||||
|
||||
## How does a check work at runtime?
|
||||
|
||||
1. The resource middleware ([pkg/http/middleware/resource.go](/pkg/http/middleware/resource.go)) runs on every request. It reads the matched handler's `ResourceDef`s, extracts the resource IDs from path/body, and stores the resolved resources in the request context.
|
||||
2. `CheckResources` reads them back, runs each `SelectorFunc`, and calls `AuthZ.CheckWithTupleCreation(ctx, claims, orgID, relation, resource, selectors, roleSelectors)`.
|
||||
3. What happens next depends on the edition:
|
||||
- **Community** ([pkg/authz/openfgaserver/server.go](/pkg/authz/openfgaserver/server.go)) ignores the resource and selectors and only checks whether the subject is an `assignee` of one of the allowed roles — a plain role gate.
|
||||
- **Enterprise** ([ee/authz/openfgaserver/server.go](/ee/authz/openfgaserver/server.go)) builds tuples via `authtypes.NewTuples` — subject `user:organization/<orgID>/user/<userID>`, relation `create`, object `serviceaccount:organization/<orgID>/serviceaccount/*` — and batch-checks them against OpenFGA: genuine per-resource authorization, including custom roles.
|
||||
|
||||
Because both paths go through the same middleware and the same `ResourceDef` declarations, a route wired once works correctly in both editions.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- Declare authz in the registries ([pkg/types/coretypes](/pkg/types/coretypes)), not in migrations — tuples for new organizations are derived from code at bootstrap.
|
||||
- A new kind never needs an OpenFGA schema change; only a new type does, and then **both** [pkg/authz/openfgaschema/base.fga](/pkg/authz/openfgaschema/base.fga) and [ee/authz/openfgaschema/base.fga](/ee/authz/openfgaschema/base.fga) must be updated together.
|
||||
- Prefer `CheckResources` + `ResourceDef` over the coarse `ViewAccess`/`EditAccess`/`AdminAccess` gates for new routes.
|
||||
- Use `WildcardSelector` for `create`/`list`, `IDSelector` for instance operations, and a custom `SelectorFunc` when the request ID is not the FGA selector.
|
||||
- Attach/detach routes between peer resources must check **both** sides (`AttachDetachSiblingResourceDef`); parent-child creation checks only the parent (`AttachDetachParentChildResourceDef`).
|
||||
- Changing `ManagedRoleToTransactions` only affects organizations created afterwards — add a [pkg/sqlmigration](/pkg/sqlmigration) migration to backfill existing ones.
|
||||
@@ -11,6 +11,7 @@ We adhere to three primary style guides as our foundation:
|
||||
We **recommend** (almost enforce) reviewing these guides before contributing to the codebase. They provide valuable insights into writing idiomatic Go code and will help you understand our approach to backend development. In addition, we have a few additional rules that make certain areas stricter than the above which can be found in area-specific files in this package:
|
||||
|
||||
- [Abstractions](abstractions.md) - When to introduce new types and intermediate representations
|
||||
- [Authz](authz.md) - Authorization, roles, and access control
|
||||
- [Errors](errors.md) - Structured error handling
|
||||
- [Endpoint](endpoint.md) - HTTP endpoint patterns
|
||||
- [Flagger](flagger.md) - Feature flag patterns
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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;
|
||||
@@ -15,28 +15,59 @@
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// Sienna chip via the @signozhq Badge; this only constrains its width.
|
||||
// Sienna chip — matches the dashboard list-row tag badge.
|
||||
.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: currentColor;
|
||||
--button-variant-ghost-hover-color: currentColor;
|
||||
--button-variant-ghost-color: inherit;
|
||||
--button-variant-ghost-hover-color: inherit;
|
||||
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;
|
||||
|
||||
@@ -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,23 +120,27 @@ function TagKeyValueInput({
|
||||
onBlur={commitEdit}
|
||||
/>
|
||||
) : (
|
||||
<TagBadge
|
||||
key={tag}
|
||||
className={styles.tag}
|
||||
closable
|
||||
onClose={(): void => removeTag(tag)}
|
||||
>
|
||||
<div key={tag} className={styles.tag} data-testid={`${testId}-chip`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.tagLabel}
|
||||
title="Double-click to edit"
|
||||
testId={`${testId}-chip`}
|
||||
onDoubleClick={(): void => startEdit(index)}
|
||||
>
|
||||
{tag}
|
||||
</Button>
|
||||
</TagBadge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.remove}
|
||||
aria-label={`Remove ${tag}`}
|
||||
onClick={(): void => removeTag(tag)}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<Input
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
@@ -28,7 +27,7 @@ interface PieArcProps {
|
||||
fill: string;
|
||||
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
|
||||
onLeave: () => void;
|
||||
onClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
onClick?: (slice: PieSlice) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +72,7 @@ export default function PieArc({
|
||||
<g
|
||||
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
|
||||
onMouseLeave={onLeave}
|
||||
onClick={(event): void => onClick?.(slice, event)}
|
||||
onClick={(): void => onClick?.(slice)}
|
||||
>
|
||||
<path d={arcPath} fill={fill} />
|
||||
{shouldShowLabel && (
|
||||
|
||||
@@ -80,7 +80,6 @@ describe('PieArc', () => {
|
||||
expect(onLeave).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(g);
|
||||
// onClick now also receives the DOM event (for drill-down popover positioning).
|
||||
expect(onClick).toHaveBeenCalledWith(SLICE, expect.anything());
|
||||
expect(onClick).toHaveBeenCalledWith(SLICE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
@@ -80,10 +79,6 @@ 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>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +99,7 @@ export interface PieChartProps {
|
||||
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
|
||||
*/
|
||||
id?: string;
|
||||
/** Fired when a slice's arc is clicked; carries the DOM event for popover positioning. */
|
||||
onSliceClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
/** Fired when a slice (or its legend entry) is clicked. */
|
||||
onSliceClick?: (slice: PieSlice) => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
// Stacked children (the FullView / standalone graph-manager) sit below the chart
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
SolidInfoCircle,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import TagBadge from 'components/TagBadge/TagBadge';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
@@ -203,13 +203,19 @@ function DashboardInfo({
|
||||
data-testid="dashboard-tags"
|
||||
>
|
||||
{visibleTags.map((tag) => (
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
<Badge key={tag} color="sienna" variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{remainingTags.length > 0 && (
|
||||
<TooltipSimple title={remainingTags.join(', ')}>
|
||||
<span data-testid="dashboard-tags-overflow">
|
||||
<TagBadge>+{remainingTags.length}</TagBadge>
|
||||
</span>
|
||||
<Badge
|
||||
color="sienna"
|
||||
variant="outline"
|
||||
data-testid="dashboard-tags-overflow"
|
||||
>
|
||||
+{remainingTags.length}
|
||||
</Badge>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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';
|
||||
|
||||
// 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`.
|
||||
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
|
||||
// collapsed back to just `x` for display.
|
||||
export function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
|
||||
return tags.map((t) => `${t.key}:${t.value}`);
|
||||
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
|
||||
}
|
||||
|
||||
export function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
|
||||
|
||||
@@ -14,6 +14,8 @@ 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';
|
||||
@@ -23,10 +25,7 @@ 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';
|
||||
@@ -41,7 +40,6 @@ function BarPanelRenderer({
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
onCloseStandaloneView,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -58,10 +56,12 @@ function BarPanelRenderer({
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
// X-scale clamps come from the request that produced the data, so each panel
|
||||
// pins to the window it fetched.
|
||||
// X-scale clamps come from the request that produced the data. The generated
|
||||
// request DTO is structurally the V5 request; the cast is the boundary.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
|
||||
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
|
||||
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
|
||||
);
|
||||
return { minTimeScale: startTime, maxTimeScale: endTime };
|
||||
}, [data.requestPayload]);
|
||||
|
||||
@@ -155,29 +155,10 @@ function BarPanelRenderer({
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(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 } });
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
},
|
||||
[onClick, flatSeries, builderQueries, data.response],
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -207,7 +188,7 @@ function BarPanelRenderer({
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={enableDrillDown ? handleChartClick : undefined}
|
||||
onClick={handleChartClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,5 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ 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,
|
||||
@@ -27,6 +28,7 @@ function HistogramPanelRenderer({
|
||||
data,
|
||||
refetch,
|
||||
panelMode,
|
||||
onClick,
|
||||
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -98,6 +100,13 @@ function HistogramPanelRenderer({
|
||||
|
||||
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={graphRef}
|
||||
@@ -118,6 +127,7 @@ function HistogramPanelRenderer({
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,5 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -37,6 +37,5 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
download: false,
|
||||
createAlert: false,
|
||||
search: true,
|
||||
drilldown: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import { useMemo } 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';
|
||||
@@ -13,9 +8,6 @@ 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';
|
||||
@@ -25,31 +17,24 @@ 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 builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries || []),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
const tables = useMemo(
|
||||
const value = useMemo(
|
||||
() =>
|
||||
prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
requestPayload: data.requestPayload,
|
||||
}),
|
||||
prepareNumberData(
|
||||
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],
|
||||
@@ -69,60 +54,10 @@ 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} />
|
||||
|
||||
@@ -27,6 +27,5 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import { useCallback, useMemo } 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';
|
||||
@@ -17,9 +13,6 @@ 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';
|
||||
|
||||
@@ -29,7 +22,6 @@ function PiePanelRenderer({
|
||||
data,
|
||||
refetch,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -38,11 +30,6 @@ function PiePanelRenderer({
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries || []),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
const slices = useMemo(
|
||||
() =>
|
||||
preparePieData({
|
||||
@@ -74,21 +61,10 @@ function PiePanelRenderer({
|
||||
);
|
||||
|
||||
const handleSliceClick = useCallback(
|
||||
(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);
|
||||
}
|
||||
(slice: PieSlice) => {
|
||||
onClick?.({ label: slice.label, value: slice.value });
|
||||
},
|
||||
[onClick, builderQueries, data.requestPayload],
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -103,7 +79,7 @@ function PiePanelRenderer({
|
||||
isDarkMode={isDarkMode}
|
||||
position={legendPosition}
|
||||
id={panelId}
|
||||
onSliceClick={enableDrillDown ? handleSliceClick : undefined}
|
||||
onSliceClick={handleSliceClick}
|
||||
data-testid="pie-chart"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,5 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
download: false,
|
||||
createAlert: false,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import type { DashboardtypesTablePanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -15,9 +8,6 @@ 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';
|
||||
|
||||
@@ -37,8 +27,6 @@ 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);
|
||||
@@ -54,11 +42,6 @@ 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(
|
||||
@@ -81,34 +64,6 @@ 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
|
||||
@@ -117,17 +72,9 @@ function TablePanelRenderer({
|
||||
columnUnits: spec.formatting?.columnUnits ?? {},
|
||||
decimalPrecision,
|
||||
thresholdsByColumn,
|
||||
onCellClick: enableDrillDown ? handleCellClick : undefined,
|
||||
})
|
||||
: [],
|
||||
[
|
||||
table,
|
||||
spec.formatting?.columnUnits,
|
||||
decimalPrecision,
|
||||
thresholdsByColumn,
|
||||
enableDrillDown,
|
||||
handleCellClick,
|
||||
],
|
||||
[table, spec.formatting?.columnUnits, decimalPrecision, thresholdsByColumn],
|
||||
);
|
||||
|
||||
// User-resizable columns, persisted per panel to localStorage.
|
||||
|
||||
@@ -25,6 +25,5 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
createAlert: false,
|
||||
// V1 parity: only tables (and lists) expose the header search box.
|
||||
search: true,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -52,12 +52,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,7 +65,6 @@ 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
|
||||
@@ -104,26 +97,19 @@ export function buildTableColumns({
|
||||
}
|
||||
return text;
|
||||
},
|
||||
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 };
|
||||
}
|
||||
}
|
||||
onCell: (record: TableRowData): { style?: React.CSSProperties } => {
|
||||
if (!col.isValueColumn || colThresholds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (onCellClick) {
|
||||
cellProps.onClick = (event): void =>
|
||||
onCellClick({ columnId: key, record, event });
|
||||
cellProps.style = { ...cellProps.style, cursor: 'pointer' };
|
||||
const num = Number(record[key]);
|
||||
if (!Number.isFinite(num)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return cellProps;
|
||||
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
|
||||
if (threshold?.format === 'background') {
|
||||
return { style: { backgroundColor: threshold.color } };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ 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';
|
||||
@@ -23,10 +25,7 @@ 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';
|
||||
@@ -41,7 +40,6 @@ function TimeSeriesPanelRenderer({
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
onCloseStandaloneView,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -60,9 +58,11 @@ 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.
|
||||
// new data arrives. The generated request DTO is structurally the V5 request.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
|
||||
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
|
||||
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
|
||||
);
|
||||
return { minTimeScale: startTime, maxTimeScale: endTime };
|
||||
}, [data.requestPayload]);
|
||||
|
||||
@@ -156,29 +156,10 @@ function TimeSeriesPanelRenderer({
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(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 } });
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
},
|
||||
[onClick, flatSeries, builderQueries, data.response],
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -207,7 +188,7 @@ function TimeSeriesPanelRenderer({
|
||||
syncMode={dashboardPreference?.syncMode}
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={enableDrillDown ? handleChartClick : undefined}
|
||||
onClick={handleChartClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,5 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,5 +1,24 @@
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import type { PanelKind } from './panelKind';
|
||||
import type { DrilldownClickPayload } from './drilldown';
|
||||
|
||||
/** 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;
|
||||
|
||||
type DragSelect = (start: number, end: number) => void;
|
||||
|
||||
@@ -10,27 +29,23 @@ 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: DrilldownClickPayload) => void;
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
'signoz/BarChartPanel': {
|
||||
onClick?: (event: DrilldownClickPayload) => void;
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
'signoz/TablePanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/PieChartPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/NumberPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'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>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -38,7 +53,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: DrilldownClickPayload) => void;
|
||||
onClick?: (event: PanelClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
}
|
||||
|
||||
@@ -30,11 +30,6 @@ 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> {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,483 +0,0 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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) };
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.signal {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
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;
|
||||
@@ -1,9 +0,0 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.backArrow {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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;
|
||||
@@ -1,9 +0,0 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.backArrow {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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;
|
||||
@@ -1,40 +0,0 @@
|
||||
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;
|
||||
@@ -3,13 +3,11 @@ 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';
|
||||
@@ -70,7 +68,6 @@ function Panel({
|
||||
});
|
||||
|
||||
const { onDragSelect, dashboardPreference } = usePanelInteractions();
|
||||
const drilldown = useDrilldown(panel, panelId);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -103,11 +100,8 @@ function Panel({
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onClick={drilldown.onPanelClick}
|
||||
enableDrillDown={drilldown.enableDrillDown}
|
||||
/>
|
||||
)}
|
||||
<ContextMenu {...drilldown.contextMenuProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -294,13 +294,4 @@ 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,12 @@ import type { ComponentTypes } from 'utils/permission';
|
||||
|
||||
/**
|
||||
* Every action the panel menu can offer: per-kind gated capabilities (minus
|
||||
* `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.
|
||||
* `search`, a header control) plus the chrome actions every kind gets. The
|
||||
* `Record<PanelActionId, …>` below forces a meta entry per id, so adding an
|
||||
* action without declaring its gates is a compile error.
|
||||
*/
|
||||
export type PanelActionId =
|
||||
| Exclude<keyof PanelActionCapabilities, 'search' | 'drilldown'>
|
||||
| Exclude<keyof PanelActionCapabilities, 'search'>
|
||||
| 'move'
|
||||
| 'delete';
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ 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';
|
||||
@@ -38,10 +37,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,8 +58,6 @@ 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.
|
||||
@@ -127,8 +120,7 @@ function PanelBody({
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={panelMode}
|
||||
enableDrillDown={enableDrillDown}
|
||||
onClick={onClick}
|
||||
enableDrillDown={false}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchTerm}
|
||||
pagination={pagination}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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;
|
||||
@@ -1,74 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,299 +0,0 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,213 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,243 +0,0 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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,
|
||||
]);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,33 +1,22 @@
|
||||
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;
|
||||
/**
|
||||
* 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. */
|
||||
/** Close the View modal by clearing the URL param. */
|
||||
closeView: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function useViewPanel(): UseViewPanelApi {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
@@ -50,22 +39,6 @@ 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);
|
||||
@@ -77,5 +50,5 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
safeNavigate(search ? `${pathname}?${search}` : pathname);
|
||||
}, [pathname, safeNavigate, urlQuery]);
|
||||
|
||||
return { expandedPanelId, openView, openViewWithQuery, closeView };
|
||||
return { expandedPanelId, openView, closeView };
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -30,9 +30,7 @@ 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. A
|
||||
// drilldown refinement rides in the URL (`compositeQuery`/`graphType`) and is
|
||||
// hydrated inside the modal, so the host just hands it the saved panel.
|
||||
// subscriptions, and the expanded panel is looked up by id from the map.
|
||||
const { expandedPanelId, closeView } = useViewPanel();
|
||||
const expandedPanel = expandedPanelId ? panels[expandedPanelId] : undefined;
|
||||
|
||||
|
||||
@@ -20,12 +20,11 @@ import {
|
||||
export const ALL_SELECTED = '__ALL__';
|
||||
|
||||
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
|
||||
export const variablesUrlParser = parseAsJson<
|
||||
Record<string, SelectedVariableValue>
|
||||
>((v) =>
|
||||
typeof v === 'object' && v !== null
|
||||
? (v as Record<string, SelectedVariableValue>)
|
||||
: null,
|
||||
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
|
||||
(v) =>
|
||||
typeof v === 'object' && v !== null
|
||||
? (v as Record<string, SelectedVariableValue>)
|
||||
: null,
|
||||
);
|
||||
|
||||
function defaultSelection(model: VariableFormModel): VariableSelection {
|
||||
|
||||
@@ -38,14 +38,22 @@ function DashboardContainer({
|
||||
user.role,
|
||||
);
|
||||
|
||||
// Seed during render (not an effect) so the first Panel render already sees the id —
|
||||
// useDashboardFetchRequired throws on a missing id. setEditContext self-guards.
|
||||
// Publish edit context to the store so hooks/components read it from there
|
||||
// instead of receiving dashboardId/isEditable/refetch as props down the tree.
|
||||
const setEditContext = useDashboardStore((s) => s.setEditContext);
|
||||
setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
isEditable: !dashboard.locked && editDashboardPermission,
|
||||
useEffect(() => {
|
||||
setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
isEditable: !dashboard.locked && editDashboardPermission,
|
||||
refetch,
|
||||
});
|
||||
}, [
|
||||
dashboard.id,
|
||||
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.
|
||||
|
||||
@@ -24,20 +24,11 @@ export const createEditContextSlice: StateCreator<
|
||||
[['zustand/persist', unknown]],
|
||||
[],
|
||||
EditContextSlice
|
||||
> = (set, get) => ({
|
||||
> = (set) => ({
|
||||
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,
|
||||
|
||||
@@ -130,6 +130,27 @@
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import TagBadge from 'components/TagBadge/TagBadge';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -105,10 +105,14 @@ function DashboardRow({
|
||||
{tags.length > 0 && (
|
||||
<div className={styles.tags}>
|
||||
{tags.slice(0, 3).map((tag) => (
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
<Badge className={styles.tag} key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<TagBadge key={tags[3]}>+{tags.length - 3}</TagBadge>
|
||||
<Badge className={styles.tag} key={tags[3]}>
|
||||
+ <Typography.Text> {tags.length - 3} </Typography.Text>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user