mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-06 14:40:38 +01:00
Compare commits
3 Commits
infraM/gor
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
379331ee77 | ||
|
|
4d079a8338 | ||
|
|
91938925ec |
34
frontend/src/components/TagBadge/TagBadge.tsx
Normal file
34
frontend/src/components/TagBadge/TagBadge.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { type MouseEvent, type ReactNode } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
interface TagBadgeProps {
|
||||
children: ReactNode;
|
||||
// Show a remove button (editable contexts: create modal, settings drawer).
|
||||
closable?: boolean;
|
||||
onClose?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// The single sienna tag chip used everywhere dashboards render tags — list rows,
|
||||
// the details header, and the tag editors. Kept as one component so the tag
|
||||
// styling stays identical across all of them.
|
||||
function TagBadge({
|
||||
children,
|
||||
closable,
|
||||
onClose,
|
||||
className,
|
||||
}: TagBadgeProps): JSX.Element {
|
||||
return (
|
||||
<Badge
|
||||
color="sienna"
|
||||
variant="outline"
|
||||
className={className}
|
||||
closable={closable}
|
||||
onClose={onClose}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagBadge;
|
||||
@@ -15,59 +15,28 @@
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// Sienna chip — matches the dashboard list-row tag badge.
|
||||
// Sienna chip via the @signozhq Badge; this only constrains its width.
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 240px;
|
||||
height: 24px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
|
||||
color: var(--bg-sienna-400);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
// The tag label is a bare, chrome-less button inside the Badge (double-click to
|
||||
// edit); strip its Button styling and let it ellipsize.
|
||||
.tagLabel {
|
||||
--button-height: auto;
|
||||
--button-padding: 0;
|
||||
--button-gap: 0;
|
||||
--button-variant-ghost-background-color: transparent;
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
--button-variant-ghost-color: inherit;
|
||||
--button-variant-ghost-hover-color: inherit;
|
||||
--button-variant-ghost-color: currentColor;
|
||||
--button-variant-ghost-hover-color: currentColor;
|
||||
overflow: hidden;
|
||||
max-width: 200px;
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.remove {
|
||||
// Size overrides to fit the chip, plus a sienna-tinted hover — the Button's
|
||||
// default ghost hover is a grey that clashes with the chip. Resting color is
|
||||
// left at the Button default.
|
||||
--button-height: 16px;
|
||||
--button-padding: 0;
|
||||
--button-border-radius: 50%;
|
||||
--button-variant-ghost-hover-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--bg-sienna-500) 22%,
|
||||
transparent
|
||||
);
|
||||
--button-variant-ghost-hover-color: var(--bg-sienna-400);
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
|
||||
@@ -2,9 +2,9 @@ import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { X } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import TagBadge from '../TagBadge/TagBadge';
|
||||
import { parseKeyValueTag } from './utils';
|
||||
|
||||
import styles from './TagKeyValueInput.module.scss';
|
||||
@@ -120,27 +120,23 @@ function TagKeyValueInput({
|
||||
onBlur={commitEdit}
|
||||
/>
|
||||
) : (
|
||||
<div key={tag} className={styles.tag} data-testid={`${testId}-chip`}>
|
||||
<TagBadge
|
||||
key={tag}
|
||||
className={styles.tag}
|
||||
closable
|
||||
onClose={(): void => removeTag(tag)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.tagLabel}
|
||||
title="Double-click to edit"
|
||||
testId={`${testId}-chip`}
|
||||
onDoubleClick={(): void => startEdit(index)}
|
||||
>
|
||||
{tag}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.remove}
|
||||
aria-label={`Remove ${tag}`}
|
||||
onClick={(): void => removeTag(tag)}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</TagBadge>
|
||||
),
|
||||
)}
|
||||
<Input
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
@@ -27,7 +28,7 @@ interface PieArcProps {
|
||||
fill: string;
|
||||
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
|
||||
onLeave: () => void;
|
||||
onClick?: (slice: PieSlice) => void;
|
||||
onClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +73,7 @@ export default function PieArc({
|
||||
<g
|
||||
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
|
||||
onMouseLeave={onLeave}
|
||||
onClick={(): void => onClick?.(slice)}
|
||||
onClick={(event): void => onClick?.(slice, event)}
|
||||
>
|
||||
<path d={arcPath} fill={fill} />
|
||||
{shouldShowLabel && (
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('PieArc', () => {
|
||||
expect(onLeave).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(g);
|
||||
expect(onClick).toHaveBeenCalledWith(SLICE);
|
||||
// onClick now also receives the DOM event (for drill-down popover positioning).
|
||||
expect(onClick).toHaveBeenCalledWith(SLICE, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
@@ -79,6 +80,10 @@ export interface PieSlice {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
/** Source query of the slice's value column — the drill-down target (present for V2 panels). */
|
||||
queryName?: string;
|
||||
/** Group-by key→value of the slice's source row, used to build drill-down filters. */
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,7 +104,7 @@ export interface PieChartProps {
|
||||
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
|
||||
*/
|
||||
id?: string;
|
||||
/** Fired when a slice (or its legend entry) is clicked. */
|
||||
onSliceClick?: (slice: PieSlice) => void;
|
||||
/** Fired when a slice's arc is clicked; carries the DOM event for popover positioning. */
|
||||
onSliceClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
flex-direction: column;
|
||||
|
||||
// Stacked children (the FullView / standalone graph-manager) sit below the chart
|
||||
|
||||
@@ -66,20 +66,6 @@
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0 !important;
|
||||
padding-block: 0 !important;
|
||||
|
||||
:global(.quick-filters .checkbox-filter) {
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.quick-filters) {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@@ -111,22 +97,6 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabelIcon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.listContainer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -160,3 +130,106 @@
|
||||
.listContainerFiltersVisible {
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.categorySelectorSection {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
margin-bottom: var(--spacing-4);
|
||||
|
||||
&[data-type='filter'] {
|
||||
padding: 0 var(--spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l3-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sectionLine {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.categoryCard {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--spacing-3);
|
||||
padding: var(--spacing-2);
|
||||
}
|
||||
|
||||
.categoryList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.categoryItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border: none;
|
||||
border-radius: var(--spacing-2);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
--typography-color: var(--l1-foreground);
|
||||
|
||||
&:hover:not(.categoryItemSelected) {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
&.categoryItemSelected {
|
||||
background: var(--accent-primary);
|
||||
color: var(--l1-background);
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-primary-hover, var(--accent-primary));
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quickFiltersSection {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button, CollapseProps } from 'antd';
|
||||
import { Collapse, Tooltip } from 'antd';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
@@ -113,192 +112,74 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
const renderCategoryLabel = (
|
||||
icon: JSX.Element,
|
||||
label: string,
|
||||
): JSX.Element => (
|
||||
<div className={styles.quickFiltersCategoryLabel}>
|
||||
<div className={styles.quickFiltersCategoryLabelContainer}>
|
||||
{icon}
|
||||
<Typography.Text>{label}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
const categories = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: K8sCategories.PODS,
|
||||
label: 'Pods',
|
||||
icon: <Container size={14} />,
|
||||
config: GetPodsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NODES,
|
||||
label: 'Nodes',
|
||||
icon: <Workflow size={14} />,
|
||||
config: GetNodesQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NAMESPACES,
|
||||
label: 'Namespaces',
|
||||
icon: <FilePenLine size={14} />,
|
||||
config: GetNamespaceQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.CLUSTERS,
|
||||
label: 'Clusters',
|
||||
icon: <Boxes size={14} />,
|
||||
config: GetClustersQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DEPLOYMENTS,
|
||||
label: 'Deployments',
|
||||
icon: <Computer size={14} />,
|
||||
config: GetDeploymentsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.JOBS,
|
||||
label: 'Jobs',
|
||||
icon: <Bolt size={14} />,
|
||||
config: GetJobsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DAEMONSETS,
|
||||
label: 'DaemonSets',
|
||||
icon: <Group size={14} />,
|
||||
config: GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.STATEFULSETS,
|
||||
label: 'StatefulSets',
|
||||
icon: <ArrowUpDown size={14} />,
|
||||
config: GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.VOLUMES,
|
||||
label: 'Volumes',
|
||||
icon: <HardDrive size={14} />,
|
||||
config: GetVolumesQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
],
|
||||
[dotMetricsEnabled],
|
||||
);
|
||||
|
||||
const items: CollapseProps['items'] = [
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Container size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Pods',
|
||||
),
|
||||
key: K8sCategories.PODS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetPodsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Workflow size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Nodes',
|
||||
),
|
||||
key: K8sCategories.NODES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetNodesQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<FilePenLine size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Namespaces',
|
||||
),
|
||||
key: K8sCategories.NAMESPACES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetNamespaceQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Boxes size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Clusters',
|
||||
),
|
||||
key: K8sCategories.CLUSTERS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetClustersQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Computer size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Deployments',
|
||||
),
|
||||
key: K8sCategories.DEPLOYMENTS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetDeploymentsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Bolt size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Jobs',
|
||||
),
|
||||
key: K8sCategories.JOBS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetJobsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Group size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'DaemonSets',
|
||||
),
|
||||
key: K8sCategories.DAEMONSETS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<ArrowUpDown size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'StatefulSets',
|
||||
),
|
||||
key: K8sCategories.STATEFULSETS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<HardDrive size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Volumes',
|
||||
),
|
||||
key: K8sCategories.VOLUMES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetVolumesQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
// TODO: Enable once we have implemented containers.
|
||||
// {
|
||||
// label: (
|
||||
// <div className="k8s-quick-filters-category-label">
|
||||
// <div className="k8s-quick-filters-category-label-container">
|
||||
// <PackageOpen
|
||||
// size={14}
|
||||
// className="k8s-quick-filters-category-label-icon"
|
||||
// />
|
||||
// <Typography.Text>Containers</Typography.Text>
|
||||
// </div>
|
||||
// </div>
|
||||
// ),
|
||||
// key: K8sCategories.CONTAINERS,
|
||||
// showArrow: false,
|
||||
// children: (
|
||||
// <QuickFilters
|
||||
// source={QuickFiltersSource.INFRA_MONITORING}
|
||||
// config={ContainersQuickFiltersConfig}
|
||||
// handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
// onFilterChange={handleFilterChange}
|
||||
// />
|
||||
// ),
|
||||
// },
|
||||
];
|
||||
const selectedCategoryConfig = useMemo(
|
||||
() => categories.find((cat) => cat.key === selectedCategory)?.config,
|
||||
[categories, selectedCategory],
|
||||
);
|
||||
|
||||
const handleCategoryChange = (key: string | string[]): void => {
|
||||
if (Array.isArray(key) && key.length > 0) {
|
||||
setSelectedCategory(key[0] as string);
|
||||
const handleCategorySelect = (key: string): void => {
|
||||
if (key !== selectedCategory) {
|
||||
setSelectedCategory(key);
|
||||
// Reset filters
|
||||
setUrlFilters(null);
|
||||
setOrderBy(null);
|
||||
@@ -332,26 +213,58 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
|
||||
<Tooltip title="Collapse Filters">
|
||||
<ArrowUpToLine
|
||||
style={{ transform: 'rotate(270deg)' }}
|
||||
onClick={handleFilterVisibilityChange}
|
||||
size="md"
|
||||
/>
|
||||
</Tooltip>
|
||||
<div className={styles.categorySelectorSection}>
|
||||
<div className={styles.sectionHeader} data-type="resource">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
Viewing · Resource
|
||||
</Typography.Text>
|
||||
<div className={styles.sectionLine} />
|
||||
<Tooltip title="Collapse Filters">
|
||||
<ArrowUpToLine
|
||||
style={{ transform: 'rotate(270deg)' }}
|
||||
onClick={handleFilterVisibilityChange}
|
||||
size="md"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.categoryCard}>
|
||||
<div className={styles.categoryList}>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.key}
|
||||
type="button"
|
||||
className={`${styles.categoryItem} ${
|
||||
selectedCategory === category.key
|
||||
? styles.categoryItemSelected
|
||||
: ''
|
||||
}`}
|
||||
onClick={(): void => handleCategorySelect(category.key)}
|
||||
data-testid={`category-${category.key}`}
|
||||
>
|
||||
{category.icon}
|
||||
<Typography.Text>{category.label}</Typography.Text>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.quickFiltersSection}>
|
||||
<div className={styles.sectionHeader} data-type="filter">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
Filter by
|
||||
</Typography.Text>
|
||||
<div className={styles.sectionLine} />
|
||||
</div>
|
||||
{selectedCategoryConfig && (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={selectedCategoryConfig}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Collapse
|
||||
onChange={handleCategoryChange}
|
||||
items={items}
|
||||
defaultActiveKey={[selectedCategory]}
|
||||
activeKey={[selectedCategory]}
|
||||
accordion
|
||||
bordered={false}
|
||||
ghost
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
SolidInfoCircle,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import TagBadge from 'components/TagBadge/TagBadge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
@@ -203,19 +203,13 @@ function DashboardInfo({
|
||||
data-testid="dashboard-tags"
|
||||
>
|
||||
{visibleTags.map((tag) => (
|
||||
<Badge key={tag} color="sienna" variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
{remainingTags.length > 0 && (
|
||||
<TooltipSimple title={remainingTags.join(', ')}>
|
||||
<Badge
|
||||
color="sienna"
|
||||
variant="outline"
|
||||
data-testid="dashboard-tags-overflow"
|
||||
>
|
||||
+{remainingTags.length}
|
||||
</Badge>
|
||||
<span data-testid="dashboard-tags-overflow">
|
||||
<TagBadge>+{remainingTags.length}</TagBadge>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,10 @@ import type { TagtypesPostableTagDTO } from 'api/generated/services/sigNoz.schem
|
||||
export { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
export { parseKeyValueTag } from 'components/TagKeyValueInput/utils';
|
||||
|
||||
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
|
||||
// collapsed back to just `x` for display.
|
||||
// The tag editor is strictly key:value, so always render both sides — a
|
||||
// `key:key` tag stays `key:key` rather than collapsing to a bare `key`.
|
||||
export function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
|
||||
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
|
||||
return tags.map((t) => `${t.key}:${t.value}`);
|
||||
}
|
||||
|
||||
export function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
|
||||
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
|
||||
@@ -25,7 +23,10 @@ import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearance/resolvers';
|
||||
import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
|
||||
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
|
||||
|
||||
import { buildBarChartConfig } from './utils/buildConfig';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
@@ -40,6 +41,7 @@ function BarPanelRenderer({
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
onCloseStandaloneView,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -56,12 +58,10 @@ function BarPanelRenderer({
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
// X-scale clamps come from the request that produced the data. The generated
|
||||
// request DTO is structurally the V5 request; the cast is the boundary.
|
||||
// X-scale clamps come from the request that produced the data, so each panel
|
||||
// pins to the window it fetched.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
|
||||
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
|
||||
);
|
||||
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
|
||||
return { minTimeScale: startTime, maxTimeScale: endTime };
|
||||
}, [data.requestPayload]);
|
||||
|
||||
@@ -155,10 +155,29 @@ function BarPanelRenderer({
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
(args: ChartClickData): void => {
|
||||
if (!onClick) {
|
||||
return;
|
||||
}
|
||||
const payload = enrichChartClick({
|
||||
clickData: args,
|
||||
series: flatSeries,
|
||||
builderQueries,
|
||||
});
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
const timeRange = stepClickTimeRange({
|
||||
clickedDataTimestamp: args.clickedDataTimestamp,
|
||||
queryName: payload.context.queryName,
|
||||
builderQueries,
|
||||
stepInterval: getExecStats(data.response)?.stepIntervals?.[
|
||||
payload.context.queryName
|
||||
],
|
||||
});
|
||||
onClick({ ...payload, context: { ...payload.context, timeRange } });
|
||||
},
|
||||
[onClick],
|
||||
[onClick, flatSeries, builderQueries, data.response],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -188,7 +207,7 @@ function BarPanelRenderer({
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
onClick={enableDrillDown ? handleChartClick : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,7 +20,6 @@ import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
|
||||
import { buildHistogramConfig } from './utils/buildConfig';
|
||||
import { prepareHistogramData } from './prepareData';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
function HistogramPanelRenderer({
|
||||
panelId,
|
||||
@@ -28,7 +27,6 @@ function HistogramPanelRenderer({
|
||||
data,
|
||||
refetch,
|
||||
panelMode,
|
||||
onClick,
|
||||
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -100,13 +98,6 @@ function HistogramPanelRenderer({
|
||||
|
||||
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={graphRef}
|
||||
@@ -127,7 +118,6 @@ function HistogramPanelRenderer({
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -37,5 +37,6 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
download: false,
|
||||
createAlert: false,
|
||||
search: true,
|
||||
drilldown: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import type { DashboardtypesNumberPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
@@ -8,6 +13,9 @@ import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import { formatPanelValue } from '../../utils/formatPanelValue';
|
||||
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
|
||||
import { enrichNumberClick } from '../../utils/drilldown/enrichNumberClick';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
|
||||
|
||||
import { prepareNumberData } from './prepareData';
|
||||
import { mapNumberThresholds } from './utils';
|
||||
@@ -17,24 +25,31 @@ function NumberPanelRenderer({
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
|
||||
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries || []),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
const tables = useMemo(
|
||||
() =>
|
||||
prepareNumberData(
|
||||
prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
requestPayload: data.requestPayload,
|
||||
}),
|
||||
),
|
||||
prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
requestPayload: data.requestPayload,
|
||||
}),
|
||||
[data.response, data.legendMap, data.requestPayload],
|
||||
);
|
||||
|
||||
const value = useMemo(() => prepareNumberData(tables), [tables]);
|
||||
|
||||
const thresholds = useMemo(
|
||||
() => mapNumberThresholds(spec.thresholds),
|
||||
[spec.thresholds],
|
||||
@@ -54,10 +69,60 @@ function NumberPanelRenderer({
|
||||
[value, unit, decimalPrecision],
|
||||
);
|
||||
|
||||
const openDrilldown = useCallback(
|
||||
(coordinates: { x: number; y: number }): void => {
|
||||
if (!onClick) {
|
||||
return;
|
||||
}
|
||||
const payload = enrichNumberClick({
|
||||
tables,
|
||||
builderQueries,
|
||||
coordinates,
|
||||
timeRange: getPanelTimeRange(data.requestPayload),
|
||||
});
|
||||
if (payload) {
|
||||
onClick(payload);
|
||||
}
|
||||
},
|
||||
[onClick, tables, data.requestPayload, builderQueries],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: ReactMouseEvent<HTMLDivElement>): void =>
|
||||
openDrilldown({ x: event.clientX, y: event.clientY }),
|
||||
[openDrilldown],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: ReactKeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
openDrilldown({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
});
|
||||
}
|
||||
},
|
||||
[openDrilldown],
|
||||
);
|
||||
|
||||
// The whole panel is the value, so the container itself is the drill-down target.
|
||||
const isClickable = enableDrillDown && !!onClick && value !== null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="number-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
{...(isClickable
|
||||
? {
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
onClick: handleClick,
|
||||
onKeyDown: handleKeyDown,
|
||||
style: { cursor: 'pointer' },
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{value === null ? (
|
||||
<NoData data-testid="number-panel-no-data" onRetry={refetch} />
|
||||
|
||||
@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import type { DashboardtypesPieChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import Pie from 'container/DashboardContainer/visualization/charts/Pie/Pie';
|
||||
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
|
||||
@@ -13,6 +17,9 @@ import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearance/resolvers';
|
||||
import { enrichPieClick } from '../../utils/drilldown/enrichPieClick';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
|
||||
|
||||
import { preparePieData } from './prepareData';
|
||||
|
||||
@@ -22,6 +29,7 @@ function PiePanelRenderer({
|
||||
data,
|
||||
refetch,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -30,6 +38,11 @@ function PiePanelRenderer({
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries || []),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
const slices = useMemo(
|
||||
() =>
|
||||
preparePieData({
|
||||
@@ -61,10 +74,21 @@ function PiePanelRenderer({
|
||||
);
|
||||
|
||||
const handleSliceClick = useCallback(
|
||||
(slice: PieSlice) => {
|
||||
onClick?.({ label: slice.label, value: slice.value });
|
||||
(slice: PieSlice, event: ReactMouseEvent): void => {
|
||||
if (!onClick) {
|
||||
return;
|
||||
}
|
||||
const payload = enrichPieClick({
|
||||
slice,
|
||||
builderQueries,
|
||||
coordinates: { x: event.clientX, y: event.clientY },
|
||||
timeRange: getPanelTimeRange(data.requestPayload),
|
||||
});
|
||||
if (payload) {
|
||||
onClick(payload);
|
||||
}
|
||||
},
|
||||
[onClick],
|
||||
[onClick, builderQueries, data.requestPayload],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -79,7 +103,7 @@ function PiePanelRenderer({
|
||||
isDarkMode={isDarkMode}
|
||||
position={legendPosition}
|
||||
id={panelId}
|
||||
onSliceClick={handleSliceClick}
|
||||
onSliceClick={enableDrillDown ? handleSliceClick : undefined}
|
||||
data-testid="pie-chart"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -23,5 +23,6 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
download: false,
|
||||
createAlert: false,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import { Table } from 'antd';
|
||||
import type { DashboardtypesTablePanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -8,6 +15,9 @@ import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/query
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
|
||||
import { enrichTableClick } from '../../utils/drilldown/enrichTableClick';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
|
||||
import { useResizableColumns } from '../../hooks/useResizableColumns';
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
|
||||
@@ -27,6 +37,8 @@ function TablePanelRenderer({
|
||||
data,
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/TablePanel'>): JSX.Element {
|
||||
// Measure the panel so each page roughly fills it (min 10 rows) with a pinned header.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -42,6 +54,11 @@ function TablePanelRenderer({
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries || []),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
// V5 joins every query into a single scalar result, so the first non-empty
|
||||
// table is the whole panel.
|
||||
const table = useMemo(
|
||||
@@ -64,6 +81,34 @@ function TablePanelRenderer({
|
||||
[spec.thresholds],
|
||||
);
|
||||
|
||||
const handleCellClick = useCallback(
|
||||
({
|
||||
columnId,
|
||||
record,
|
||||
event,
|
||||
}: {
|
||||
columnId: string;
|
||||
record: TableRowData;
|
||||
event: ReactMouseEvent<HTMLElement>;
|
||||
}): void => {
|
||||
if (!onClick || !table) {
|
||||
return;
|
||||
}
|
||||
const payload = enrichTableClick({
|
||||
record,
|
||||
columnId,
|
||||
table,
|
||||
builderQueries,
|
||||
coordinates: { x: event.clientX, y: event.clientY },
|
||||
timeRange: getPanelTimeRange(data.requestPayload),
|
||||
});
|
||||
if (payload) {
|
||||
onClick(payload);
|
||||
}
|
||||
},
|
||||
[onClick, table, builderQueries, data.requestPayload],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
table
|
||||
@@ -72,9 +117,17 @@ function TablePanelRenderer({
|
||||
columnUnits: spec.formatting?.columnUnits ?? {},
|
||||
decimalPrecision,
|
||||
thresholdsByColumn,
|
||||
onCellClick: enableDrillDown ? handleCellClick : undefined,
|
||||
})
|
||||
: [],
|
||||
[table, spec.formatting?.columnUnits, decimalPrecision, thresholdsByColumn],
|
||||
[
|
||||
table,
|
||||
spec.formatting?.columnUnits,
|
||||
decimalPrecision,
|
||||
thresholdsByColumn,
|
||||
enableDrillDown,
|
||||
handleCellClick,
|
||||
],
|
||||
);
|
||||
|
||||
// User-resizable columns, persisted per panel to localStorage.
|
||||
|
||||
@@ -25,5 +25,6 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
createAlert: false,
|
||||
// V1 parity: only tables (and lists) expose the header search box.
|
||||
search: true,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -52,6 +52,12 @@ export interface BuildTableColumnsArgs {
|
||||
decimalPrecision?: PrecisionOption;
|
||||
/** Thresholds grouped by column name (see `mapTableThresholds`). */
|
||||
thresholdsByColumn: Record<string, PanelThreshold[]>;
|
||||
/** When set, every body cell becomes a drill-down target (keyed by its column id). */
|
||||
onCellClick?: (args: {
|
||||
columnId: string;
|
||||
record: TableRowData;
|
||||
event: React.MouseEvent<HTMLElement>;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,6 +71,7 @@ export function buildTableColumns({
|
||||
columnUnits,
|
||||
decimalPrecision,
|
||||
thresholdsByColumn,
|
||||
onCellClick,
|
||||
}: BuildTableColumnsArgs): TableProps<TableRowData>['columns'] {
|
||||
return table.columns.map((col) => {
|
||||
// Column key = query identifier for value columns, group name otherwise. Units
|
||||
@@ -97,19 +104,26 @@ export function buildTableColumns({
|
||||
}
|
||||
return text;
|
||||
},
|
||||
onCell: (record: TableRowData): { style?: React.CSSProperties } => {
|
||||
if (!col.isValueColumn || colThresholds.length === 0) {
|
||||
return {};
|
||||
onCell: (record: TableRowData): React.HTMLAttributes<HTMLElement> => {
|
||||
const cellProps: React.HTMLAttributes<HTMLElement> = {};
|
||||
|
||||
if (col.isValueColumn && colThresholds.length > 0) {
|
||||
const num = Number(record[key]);
|
||||
if (Number.isFinite(num)) {
|
||||
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
|
||||
if (threshold?.format === 'background') {
|
||||
cellProps.style = { backgroundColor: threshold.color };
|
||||
}
|
||||
}
|
||||
}
|
||||
const num = Number(record[key]);
|
||||
if (!Number.isFinite(num)) {
|
||||
return {};
|
||||
|
||||
if (onCellClick) {
|
||||
cellProps.onClick = (event): void =>
|
||||
onCellClick({ columnId: key, record, event });
|
||||
cellProps.style = { ...cellProps.style, cursor: 'pointer' };
|
||||
}
|
||||
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
|
||||
if (threshold?.format === 'background') {
|
||||
return { style: { backgroundColor: threshold.color } };
|
||||
}
|
||||
return {};
|
||||
|
||||
return cellProps;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
|
||||
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
|
||||
@@ -25,7 +23,10 @@ import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearance/resolvers';
|
||||
import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
|
||||
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
|
||||
|
||||
import { buildTimeSeriesConfig } from './utils/buildConfig';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
@@ -40,6 +41,7 @@ function TimeSeriesPanelRenderer({
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
onCloseStandaloneView,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -58,11 +60,9 @@ function TimeSeriesPanelRenderer({
|
||||
|
||||
// X-scale clamps come from the request that produced the data, so each panel
|
||||
// pins to the window it fetched — matters during drag-zoom transitions before
|
||||
// new data arrives. The generated request DTO is structurally the V5 request.
|
||||
// new data arrives.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
|
||||
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
|
||||
);
|
||||
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
|
||||
return { minTimeScale: startTime, maxTimeScale: endTime };
|
||||
}, [data.requestPayload]);
|
||||
|
||||
@@ -156,10 +156,29 @@ function TimeSeriesPanelRenderer({
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
(args: ChartClickData): void => {
|
||||
if (!onClick) {
|
||||
return;
|
||||
}
|
||||
const payload = enrichChartClick({
|
||||
clickData: args,
|
||||
series: flatSeries,
|
||||
builderQueries,
|
||||
});
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
const timeRange = stepClickTimeRange({
|
||||
clickedDataTimestamp: args.clickedDataTimestamp,
|
||||
queryName: payload.context.queryName,
|
||||
builderQueries,
|
||||
stepInterval: getExecStats(data.response)?.stepIntervals?.[
|
||||
payload.context.queryName
|
||||
],
|
||||
});
|
||||
onClick({ ...payload, context: { ...payload.context, timeRange } });
|
||||
},
|
||||
[onClick],
|
||||
[onClick, flatSeries, builderQueries, data.response],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -188,7 +207,7 @@ function TimeSeriesPanelRenderer({
|
||||
syncMode={dashboardPreference?.syncMode}
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
onClick={enableDrillDown ? handleChartClick : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
|
||||
// Drilldown is the click-to-context-menu feature ported from V1. Every renderer turns its native
|
||||
// click into one `DrilldownClickPayload`; the kind-agnostic orchestration layer consumes only that.
|
||||
// `FilterData` is imported read-only from the V1 util so the payload feeds `buildDrilldownUrl`
|
||||
// directly, with no intermediate translation.
|
||||
|
||||
/** The clicked point's drilldown context, derived from the flattened series/columns the renderer holds. */
|
||||
export interface DrilldownContext {
|
||||
/** The clicked series'/column's query. Drives query selection in `getViewQuery`. */
|
||||
queryName: string;
|
||||
/** Telemetry signal of the clicked query — picks the explorer the drilldown navigates to. */
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
/** Key/value/op filters from the clicked point's group-by labels (empty when ungrouped). */
|
||||
filters: FilterData[];
|
||||
/** Explorer time window. Charts use the clicked bucket ±step; scalar panels use the fetched window. */
|
||||
timeRange?: { startTime: number; endTime: number };
|
||||
/** Series/slice display name, shown as the menu header's second line. */
|
||||
label?: string;
|
||||
/** Series/slice colour; tints the menu header label and item icons (charts/pie only). */
|
||||
seriesColor?: string;
|
||||
/** Tables only: a value column opens the aggregate menu; a group column opens filter-by-value. */
|
||||
columnKind?: 'aggregate' | 'group';
|
||||
/** Group-column click only: the clicked column's key, for the filter-by-value menu. */
|
||||
clickedKey?: string;
|
||||
/** Group-column click only: the clicked cell's value, for the filter-by-value menu. */
|
||||
clickedValue?: string | number;
|
||||
}
|
||||
|
||||
/** What each renderer's `onClick` emits: where to anchor the popover plus the drilldown context. */
|
||||
export interface DrilldownClickPayload {
|
||||
/** Absolute viewport coordinates for the popover anchor. */
|
||||
coordinates: { x: number; y: number };
|
||||
context: DrilldownContext;
|
||||
}
|
||||
@@ -1,24 +1,5 @@
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import type { PanelKind } from './panelKind';
|
||||
|
||||
/** Source-tagged click events; each non-chart kind carries its own drill-down context. */
|
||||
export type ChartClickEvent = ChartClickData;
|
||||
export type TableClickEvent = {
|
||||
rowData: Record<string, unknown>;
|
||||
columnId?: string;
|
||||
};
|
||||
export type ListClickEvent = {
|
||||
rowData: Record<string, unknown>;
|
||||
};
|
||||
export type PieClickEvent = { label: string; value: number };
|
||||
|
||||
/** Union of every panel click event — switched on by `source` at the boundary. */
|
||||
export type PanelClickEvent =
|
||||
| ChartClickEvent
|
||||
| TableClickEvent
|
||||
| ListClickEvent
|
||||
| PieClickEvent;
|
||||
import type { DrilldownClickPayload } from './drilldown';
|
||||
|
||||
type DragSelect = (start: number, end: number) => void;
|
||||
|
||||
@@ -29,23 +10,27 @@ type CloseStandaloneView = () => void;
|
||||
* Per-kind interaction props — each kind exposes only the gestures it supports.
|
||||
* Keyed by `PanelKind`; `PanelRendererProps<K>` indexes this, so a missing kind
|
||||
* is a compile error there.
|
||||
*
|
||||
* Every interactive kind's `onClick` receives the unified `DrilldownClickPayload`
|
||||
* its renderer enriches from the native click. Number/Value drills down on its
|
||||
* single value. Histogram and List are omitted (V1 has no drill-down for either):
|
||||
* they inherit the empty `object` base, so their renderers get only base props
|
||||
* with no click gesture.
|
||||
*/
|
||||
export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
'signoz/TimeSeriesPanel': {
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onClick?: (event: DrilldownClickPayload) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
'signoz/BarChartPanel': {
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onClick?: (event: DrilldownClickPayload) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
'signoz/HistogramPanel': { onClick?: (event: ChartClickEvent) => void };
|
||||
'signoz/TablePanel': { onClick?: (event: TableClickEvent) => void };
|
||||
'signoz/ListPanel': { onClick?: (event: ListClickEvent) => void };
|
||||
'signoz/PieChartPanel': { onClick?: (event: PieClickEvent) => void };
|
||||
'signoz/NumberPanel': Record<string, never>;
|
||||
'signoz/TablePanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/PieChartPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/NumberPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -53,7 +38,7 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
* registry render boundary). The supertype the per-kind shapes are cast to once.
|
||||
*/
|
||||
export interface AnyPanelInteractionProps {
|
||||
onClick?: (event: PanelClickEvent) => void;
|
||||
onClick?: (event: DrilldownClickPayload) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ export interface PanelActionCapabilities {
|
||||
* tabular kinds). Not a menu action — the renderer must consume `searchTerm`.
|
||||
*/
|
||||
search: boolean;
|
||||
/**
|
||||
* Kind supports click-to-drilldown (context menu + View/Breakout). V1 parity: charts + scalar
|
||||
* Pie/Value/Table; Histogram/List opt out. AND-ed with "has a builder query" in `useDrilldown`.
|
||||
*/
|
||||
drilldown: boolean;
|
||||
}
|
||||
|
||||
export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Querybuildertypesv5QueryRangeRequestDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getPanelTimeRange } from '../getPanelTimeRange';
|
||||
|
||||
// Fallback path reads the redux global-time selection; stub both so the no-payload branch
|
||||
// is deterministic.
|
||||
jest.mock('store', () => ({
|
||||
__esModule: true,
|
||||
default: { getState: (): unknown => ({ globalTime: { selectedTime: '5m' } }) },
|
||||
}));
|
||||
jest.mock('lib/getStartEndRangeTime', () => ({
|
||||
__esModule: true,
|
||||
default: (): { start: string; end: string } => ({
|
||||
start: '1700',
|
||||
end: '1800',
|
||||
}),
|
||||
}));
|
||||
|
||||
const request = (
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Querybuildertypesv5QueryRangeRequestDTO =>
|
||||
({ start, end }) as Querybuildertypesv5QueryRangeRequestDTO;
|
||||
|
||||
describe('getPanelTimeRange', () => {
|
||||
it('converts the request start/end from ms to seconds', () => {
|
||||
expect(getPanelTimeRange(request(5_000, 9_000))).toStrictEqual({
|
||||
startTime: 5,
|
||||
endTime: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the global-time window when there is no request', () => {
|
||||
expect(getPanelTimeRange(undefined)).toStrictEqual({
|
||||
startTime: 1700,
|
||||
endTime: 1800,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when the request is missing an endpoint', () => {
|
||||
expect(getPanelTimeRange(request(5_000, undefined))).toStrictEqual({
|
||||
startTime: 1700,
|
||||
endTime: 1800,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import type {
|
||||
PanelSeries,
|
||||
PanelTable,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
import type { DrilldownContext } from '../../../types/drilldown';
|
||||
import { buildAggregateData } from '../buildAggregateData';
|
||||
import { stepClickTimeRange } from '../chartClickTimeRange';
|
||||
import { enrichChartClick } from '../enrichChartClick';
|
||||
import { enrichNumberClick } from '../enrichNumberClick';
|
||||
import { enrichPieClick } from '../enrichPieClick';
|
||||
import { enrichTableClick } from '../enrichTableClick';
|
||||
import { getDataLinks } from '../getDataLinks';
|
||||
import { resolvePanelContextLinks } from '../resolvePanelContextLinks';
|
||||
import { resolveDrilldownSignal } from '../signal';
|
||||
|
||||
// The v5 BuilderQuery union is too verbose to construct field-typed inline; cast at the boundary.
|
||||
function builderQuery(spec: Record<string, unknown>): BuilderQuery {
|
||||
return spec as unknown as BuilderQuery;
|
||||
}
|
||||
|
||||
function panelSeries(overrides: Partial<PanelSeries> = {}): PanelSeries {
|
||||
return {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
labels: { 'service.name': 'frontend' },
|
||||
kind: 'series',
|
||||
values: [],
|
||||
aggregation: { index: 0, alias: '' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function chartClick(
|
||||
focusedSeries: ChartClickData['focusedSeries'],
|
||||
): ChartClickData {
|
||||
return {
|
||||
xValue: 0,
|
||||
yValue: 0,
|
||||
focusedSeries,
|
||||
clickedDataTimestamp: 1_700_000_000,
|
||||
mouseX: 10,
|
||||
mouseY: 20,
|
||||
absoluteMouseX: 110,
|
||||
absoluteMouseY: 220,
|
||||
};
|
||||
}
|
||||
|
||||
function focused(seriesIndex: number): ChartClickData['focusedSeries'] {
|
||||
return { seriesIndex, seriesName: 'frontend', value: 1, color: '#fff' };
|
||||
}
|
||||
|
||||
describe('resolveDrilldownSignal', () => {
|
||||
it('maps logs/traces directly', () => {
|
||||
expect(resolveDrilldownSignal(builderQuery({ signal: 'logs' }))).toBe('logs');
|
||||
expect(resolveDrilldownSignal(builderQuery({ signal: 'traces' }))).toBe(
|
||||
'traces',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to metrics for metrics, meter and unknown/missing signals', () => {
|
||||
expect(resolveDrilldownSignal(builderQuery({ signal: 'metrics' }))).toBe(
|
||||
'metrics',
|
||||
);
|
||||
expect(resolveDrilldownSignal(builderQuery({ signal: 'meter' }))).toBe(
|
||||
'metrics',
|
||||
);
|
||||
expect(resolveDrilldownSignal(undefined)).toBe('metrics');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichChartClick', () => {
|
||||
const series = [
|
||||
panelSeries({ queryName: 'A', labels: { 'service.name': 'frontend' } }),
|
||||
panelSeries({ queryName: 'B', labels: { 'service.name': 'cart' } }),
|
||||
];
|
||||
|
||||
it('maps the uPlot series index to the (index - 1) flattened series', () => {
|
||||
// uPlot series[0] is the x-axis, so data series start at 1.
|
||||
const payload = enrichChartClick({
|
||||
clickData: chartClick(focused(2)),
|
||||
series,
|
||||
builderQueries: [
|
||||
builderQuery({
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
groupBy: [{ name: 'service.name' }],
|
||||
}),
|
||||
builderQuery({
|
||||
name: 'B',
|
||||
signal: 'logs',
|
||||
groupBy: [{ name: 'service.name' }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(payload?.context.queryName).toBe('B');
|
||||
expect(payload?.context.signal).toBe('logs');
|
||||
expect(payload?.context.filters).toStrictEqual([
|
||||
expect.objectContaining({ filterKey: 'service.name', filterValue: 'cart' }),
|
||||
]);
|
||||
expect(payload?.context.seriesColor).toBe('#fff');
|
||||
expect(payload?.coordinates).toStrictEqual({ x: 110, y: 220 });
|
||||
});
|
||||
|
||||
it('passes through the caller-computed time range and resolves the signal', () => {
|
||||
const payload = enrichChartClick({
|
||||
clickData: chartClick(focused(1)),
|
||||
series,
|
||||
builderQueries: [builderQuery({ name: 'A', signal: 'traces' })],
|
||||
timeRange: { startTime: 100, endTime: 200 },
|
||||
});
|
||||
|
||||
expect(payload?.context.signal).toBe('traces');
|
||||
expect(payload?.context.timeRange).toStrictEqual({
|
||||
startTime: 100,
|
||||
endTime: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when there is no focused series', () => {
|
||||
expect(
|
||||
enrichChartClick({
|
||||
clickData: chartClick(null),
|
||||
series,
|
||||
builderQueries: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the series index maps to no series', () => {
|
||||
expect(
|
||||
enrichChartClick({
|
||||
clickData: chartClick(focused(99)),
|
||||
series,
|
||||
builderQueries: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for formula queries (queryName starts with F)', () => {
|
||||
expect(
|
||||
enrichChartClick({
|
||||
clickData: chartClick(focused(1)),
|
||||
series: [panelSeries({ queryName: 'F1' })],
|
||||
builderQueries: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('emits empty filters for an ungrouped series (drops the legend backfill label)', () => {
|
||||
const payload = enrichChartClick({
|
||||
clickData: chartClick(focused(1)),
|
||||
series: [panelSeries({ queryName: 'A', labels: { A: 'A' } })],
|
||||
builderQueries: [builderQuery({ name: 'A', signal: 'metrics' })],
|
||||
});
|
||||
|
||||
expect(payload?.context.filters).toStrictEqual([]);
|
||||
expect(payload?.context.queryName).toBe('A');
|
||||
});
|
||||
|
||||
it('drops labels that are not group-by dimensions', () => {
|
||||
const payload = enrichChartClick({
|
||||
clickData: chartClick(focused(1)),
|
||||
series: [
|
||||
panelSeries({
|
||||
queryName: 'A',
|
||||
labels: { 'service.name': 'frontend', __name__: 'http_requests' },
|
||||
}),
|
||||
],
|
||||
builderQueries: [
|
||||
builderQuery({
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
groupBy: [{ name: 'service.name' }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(payload?.context.filters).toStrictEqual([
|
||||
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAggregateData', () => {
|
||||
it('projects the drilldown context onto the V1 AggregateData shape', () => {
|
||||
const context: DrilldownContext = {
|
||||
queryName: 'A',
|
||||
signal: TelemetrytypesSignalDTO.logs,
|
||||
filters: [{ filterKey: 'k', filterValue: 'v', operator: '=' }],
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
label: 'frontend',
|
||||
seriesColor: '#abc',
|
||||
columnKind: 'aggregate',
|
||||
};
|
||||
|
||||
expect(buildAggregateData(context)).toStrictEqual({
|
||||
queryName: 'A',
|
||||
filters: [{ filterKey: 'k', filterValue: 'v', operator: '=' }],
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
label: 'frontend',
|
||||
seriesColor: '#abc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichNumberClick', () => {
|
||||
const numberTable = (queryName: string): PanelTable => ({
|
||||
queryName,
|
||||
legend: '',
|
||||
columns: [{ name: 'value', queryName, isValueColumn: true, id: queryName }],
|
||||
rows: [{ data: { [queryName]: 42 } }],
|
||||
});
|
||||
|
||||
it('drills down on the displayed value column with empty filters and no label', () => {
|
||||
const payload = enrichNumberClick({
|
||||
tables: [numberTable('A')],
|
||||
builderQueries: [builderQuery({ name: 'A', signal: 'logs' })],
|
||||
coordinates: { x: 5, y: 6 },
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
});
|
||||
|
||||
// No label: the menu header falls back to the aggregation expression (V1 parity).
|
||||
expect(payload?.context).toStrictEqual({
|
||||
queryName: 'A',
|
||||
signal: 'logs',
|
||||
filters: [],
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
});
|
||||
expect(payload?.coordinates).toStrictEqual({ x: 5, y: 6 });
|
||||
});
|
||||
|
||||
it('drills into the displayed value column, not the first builder query', () => {
|
||||
// Panel shows query B's value column; drilldown must target B, not A.
|
||||
const payload = enrichNumberClick({
|
||||
tables: [numberTable('B')],
|
||||
builderQueries: [
|
||||
builderQuery({ name: 'A', signal: 'logs' }),
|
||||
builderQuery({ name: 'B', signal: 'traces' }),
|
||||
],
|
||||
coordinates: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
expect(payload?.context.queryName).toBe('B');
|
||||
expect(payload?.context.signal).toBe('traces');
|
||||
});
|
||||
|
||||
it('returns null when there is no drillable query', () => {
|
||||
expect(
|
||||
enrichNumberClick({
|
||||
tables: [],
|
||||
builderQueries: [],
|
||||
coordinates: { x: 0, y: 0 },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a formula query', () => {
|
||||
expect(
|
||||
enrichNumberClick({
|
||||
tables: [numberTable('F1')],
|
||||
builderQueries: [],
|
||||
coordinates: { x: 0, y: 0 },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichTableClick', () => {
|
||||
const table: PanelTable = {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [
|
||||
{
|
||||
name: 'service.name',
|
||||
queryName: 'A',
|
||||
isValueColumn: false,
|
||||
id: 'service.name',
|
||||
},
|
||||
{ name: 'p99', queryName: 'A', isValueColumn: true, id: 'A' },
|
||||
],
|
||||
rows: [{ data: { 'service.name': 'frontend', A: 42 } }],
|
||||
};
|
||||
const record = { 'service.name': 'frontend', A: 42 };
|
||||
const builderQueries = [builderQuery({ name: 'A', signal: 'traces' })];
|
||||
|
||||
it('builds equality filters from the row group cells for a value-column click', () => {
|
||||
const payload = enrichTableClick({
|
||||
record,
|
||||
columnId: 'A',
|
||||
table,
|
||||
builderQueries,
|
||||
coordinates: { x: 1, y: 2 },
|
||||
timeRange: { startTime: 10, endTime: 20 },
|
||||
});
|
||||
|
||||
expect(payload?.context.queryName).toBe('A');
|
||||
expect(payload?.context.signal).toBe('traces');
|
||||
expect(payload?.context.columnKind).toBe('aggregate');
|
||||
expect(payload?.context.clickedKey).toBeUndefined();
|
||||
// No label: the aggregate menu header falls back to the aggregation expression,
|
||||
// not the value column name (V1 parity).
|
||||
expect(payload?.context.label).toBeUndefined();
|
||||
expect(payload?.context.filters).toStrictEqual([
|
||||
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the row value column and carries the clicked cell for a group click', () => {
|
||||
const payload = enrichTableClick({
|
||||
record,
|
||||
columnId: 'service.name',
|
||||
table,
|
||||
builderQueries,
|
||||
coordinates: { x: 1, y: 2 },
|
||||
});
|
||||
|
||||
expect(payload?.context.queryName).toBe('A');
|
||||
expect(payload?.context.columnKind).toBe('group');
|
||||
expect(payload?.context.clickedKey).toBe('service.name');
|
||||
expect(payload?.context.clickedValue).toBe('frontend');
|
||||
});
|
||||
|
||||
it('returns null when the table has no value column', () => {
|
||||
const groupOnly: PanelTable = {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [
|
||||
{
|
||||
name: 'service.name',
|
||||
queryName: 'A',
|
||||
isValueColumn: false,
|
||||
id: 'service.name',
|
||||
},
|
||||
],
|
||||
rows: [{ data: { 'service.name': 'frontend' } }],
|
||||
};
|
||||
expect(
|
||||
enrichTableClick({
|
||||
record,
|
||||
columnId: 'service.name',
|
||||
table: groupOnly,
|
||||
builderQueries,
|
||||
coordinates: { x: 1, y: 2 },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichPieClick', () => {
|
||||
it('builds filters from the slice labels and resolves the signal', () => {
|
||||
const payload = enrichPieClick({
|
||||
slice: {
|
||||
label: 'frontend',
|
||||
value: 12,
|
||||
color: '#abc',
|
||||
queryName: 'A',
|
||||
labels: { 'service.name': 'frontend' },
|
||||
},
|
||||
builderQueries: [
|
||||
builderQuery({
|
||||
name: 'A',
|
||||
signal: 'traces',
|
||||
groupBy: [{ name: 'service.name' }],
|
||||
}),
|
||||
],
|
||||
coordinates: { x: 7, y: 8 },
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
});
|
||||
|
||||
expect(payload?.context.queryName).toBe('A');
|
||||
expect(payload?.context.signal).toBe('traces');
|
||||
expect(payload?.context.filters).toStrictEqual([
|
||||
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null for a slice with no source query', () => {
|
||||
expect(
|
||||
enrichPieClick({
|
||||
slice: { label: 'x', value: 1, color: '#000' },
|
||||
builderQueries: [],
|
||||
coordinates: { x: 0, y: 0 },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePanelContextLinks', () => {
|
||||
it('substitutes the clicked field value (_-prefixed) into the label and URL', () => {
|
||||
const resolved = resolvePanelContextLinks(
|
||||
[
|
||||
{
|
||||
name: 'Runbook for {{_service.name}}',
|
||||
url: 'https://wiki/{{_service.name}}',
|
||||
},
|
||||
],
|
||||
{ '_service.name': 'frontend' },
|
||||
);
|
||||
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0].label).toBe('Runbook for frontend');
|
||||
expect(resolved[0].url).toBe('https://wiki/frontend');
|
||||
});
|
||||
|
||||
it('drops links without a URL', () => {
|
||||
expect(resolvePanelContextLinks([{ name: 'No URL' }], {})).toStrictEqual([]);
|
||||
expect(resolvePanelContextLinks(undefined, {})).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the raw URL when renderVariables is false', () => {
|
||||
const resolved = resolvePanelContextLinks(
|
||||
[
|
||||
{
|
||||
name: 'Literal',
|
||||
url: 'https://wiki/{{_service.name}}',
|
||||
renderVariables: false,
|
||||
},
|
||||
],
|
||||
{ '_service.name': 'frontend' },
|
||||
);
|
||||
|
||||
expect(resolved[0].url).toBe('https://wiki/{{_service.name}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stepClickTimeRange', () => {
|
||||
it('returns [clickedTs, clickedTs + step] for a non-APM query', () => {
|
||||
expect(
|
||||
stepClickTimeRange({
|
||||
clickedDataTimestamp: 1000,
|
||||
queryName: 'A',
|
||||
builderQueries: [builderQuery({ name: 'A', signal: 'logs' })],
|
||||
stepInterval: 30,
|
||||
}),
|
||||
).toStrictEqual({ startTime: 1000, endTime: 1030 });
|
||||
});
|
||||
|
||||
it('falls back to a 60s step when no interval is provided', () => {
|
||||
expect(
|
||||
stepClickTimeRange({
|
||||
clickedDataTimestamp: 1000,
|
||||
queryName: 'A',
|
||||
builderQueries: [
|
||||
builderQuery({
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
aggregations: [{ metricName: 'custom_metric' }],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).toStrictEqual({ startTime: 1000, endTime: 1060 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDataLinks', () => {
|
||||
it('adds a "View Trace Details" link when the filters carry a trace_id', () => {
|
||||
expect(
|
||||
getDataLinks([
|
||||
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
|
||||
{ filterKey: 'trace_id', filterValue: 'abc123', operator: '=' },
|
||||
]),
|
||||
).toStrictEqual([
|
||||
{
|
||||
id: 'view-trace-details',
|
||||
label: 'View Trace Details',
|
||||
url: '/trace/abc123',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns no links when there is no trace_id', () => {
|
||||
expect(
|
||||
getDataLinks([
|
||||
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
|
||||
]),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AggregateData } from 'container/QueryTable/Drilldown/useAggregateDrilldown';
|
||||
|
||||
import type { DrilldownContext } from '../../types/drilldown';
|
||||
|
||||
/**
|
||||
* Adapts a V2 `DrilldownContext` to the V1 `AggregateData` that `buildDrilldownUrl`/the drilldown
|
||||
* navigate hook consume. The single boundary between the V2 click payload and the reused V1
|
||||
* navigation machinery.
|
||||
*/
|
||||
export function buildAggregateData(context: DrilldownContext): AggregateData {
|
||||
return {
|
||||
queryName: context.queryName,
|
||||
filters: context.filters,
|
||||
timeRange: context.timeRange,
|
||||
label: context.label,
|
||||
seriesColor: context.seriesColor,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
getTimeRangeFromStepInterval,
|
||||
isApmMetric,
|
||||
} from 'container/PanelWrapper/utils';
|
||||
import type { BuilderQuery, MetricAggregation } from 'types/api/v5/queryRange';
|
||||
|
||||
/** Fallback step (seconds) when the response carries no per-query step interval (V1 parity). */
|
||||
const DEFAULT_STEP_INTERVAL = 60;
|
||||
|
||||
interface StepClickTimeRangeArgs {
|
||||
/** Clicked bucket timestamp, in the chart's x-unit (epoch seconds). */
|
||||
clickedDataTimestamp: number;
|
||||
/** The clicked series' query — used to detect APM metrics. */
|
||||
queryName: string;
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Clicked series' step (seconds); falls back to DEFAULT_STEP_INTERVAL. */
|
||||
stepInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time window for a time-axis chart click: the clicked bucket plus one step (V1 parity). APM-metric
|
||||
* panels widen the window one step to the left. Shared by the TimeSeries and Bar renderers; the
|
||||
* matching field remapping happens later inside `getViewQuery`.
|
||||
*/
|
||||
export function stepClickTimeRange({
|
||||
clickedDataTimestamp,
|
||||
queryName,
|
||||
builderQueries,
|
||||
stepInterval = DEFAULT_STEP_INTERVAL,
|
||||
}: StepClickTimeRangeArgs): { startTime: number; endTime: number } {
|
||||
const builderQuery = builderQueries.find((query) => query.name === queryName);
|
||||
const isApm =
|
||||
builderQuery?.signal === 'metrics' &&
|
||||
isApmMetric(
|
||||
(builderQuery?.aggregations?.[0] as MetricAggregation)?.metricName ?? '',
|
||||
);
|
||||
return getTimeRangeFromStepInterval(stepInterval, clickedDataTimestamp, isApm);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { isValidQueryName } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
import type { DrilldownClickPayload } from '../../types/drilldown';
|
||||
|
||||
import { getGroupByFilters } from './getGroupByFilters';
|
||||
import { resolveDrilldownSignal } from './signal';
|
||||
|
||||
interface EnrichChartClickArgs {
|
||||
clickData: ChartClickData;
|
||||
/** Flattened series in the same order they were added to uPlot (see `prepareAlignedData`/`addSeries`). */
|
||||
series: PanelSeries[];
|
||||
/** The panel's builder queries, for resolving the clicked series' signal by `queryName`. */
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Explorer time window; the caller computes it (clicked bucket ±step for time charts, panel window for histograms). */
|
||||
timeRange?: { startTime: number; endTime: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a uPlot click (time-series or bar) into a drilldown payload. Resolves the clicked series via
|
||||
* uPlot's series index (index 0 is the x-axis, so data series start at 1 → `series[seriesIndex - 1]`)
|
||||
* and builds equality filters from its group-by label values. Returns `null` when the click can't be
|
||||
* attributed to a drillable series (no focused series, unmapped index, or a formula query).
|
||||
*/
|
||||
export function enrichChartClick({
|
||||
clickData,
|
||||
series,
|
||||
builderQueries,
|
||||
timeRange,
|
||||
}: EnrichChartClickArgs): DrilldownClickPayload | null {
|
||||
const { focusedSeries } = clickData;
|
||||
if (!focusedSeries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const panelSeries = series[focusedSeries.seriesIndex - 1];
|
||||
if (!panelSeries || !isValidQueryName(panelSeries.queryName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const builderQuery = builderQueries.find(
|
||||
(query) => query.name === panelSeries.queryName,
|
||||
);
|
||||
|
||||
const filters = builderQuery
|
||||
? getGroupByFilters(panelSeries.labels, builderQuery)
|
||||
: [];
|
||||
|
||||
return {
|
||||
coordinates: { x: clickData.absoluteMouseX, y: clickData.absoluteMouseY },
|
||||
context: {
|
||||
queryName: panelSeries.queryName,
|
||||
signal: resolveDrilldownSignal(builderQuery),
|
||||
filters,
|
||||
timeRange,
|
||||
label: focusedSeries.seriesName,
|
||||
seriesColor: focusedSeries.color,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { isValidQueryName } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
import type { DrilldownClickPayload } from '../../types/drilldown';
|
||||
|
||||
import { resolveDrilldownSignal } from './signal';
|
||||
|
||||
interface EnrichNumberClickArgs {
|
||||
/** The panel's scalar tables — the displayed value's column selects the drilldown query. */
|
||||
tables: PanelTable[];
|
||||
/** The panel's builder queries; resolves the clicked query's signal by name. */
|
||||
builderQueries: BuilderQuery[];
|
||||
coordinates: { x: number; y: number };
|
||||
/** Explorer time window — the panel's fetched window (the value has no clicked bucket). */
|
||||
timeRange?: { startTime: number; endTime: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a Number/Value click into a drilldown payload. Drills into the query the panel actually
|
||||
* displays — the first table-with-rows' value column (mirrors `prepareNumberData`), not blindly
|
||||
* `builderQueries[0]` (they diverge for multi-query panels). Returns `null` when that query isn't
|
||||
* drillable (promql/formula).
|
||||
*/
|
||||
export function enrichNumberClick({
|
||||
tables,
|
||||
builderQueries,
|
||||
coordinates,
|
||||
timeRange,
|
||||
}: EnrichNumberClickArgs): DrilldownClickPayload | null {
|
||||
const valueColumn = tables
|
||||
.find((table) => table.rows.length > 0)
|
||||
?.columns.find((column) => column.isValueColumn);
|
||||
const queryName = valueColumn?.queryName ?? builderQueries[0]?.name ?? '';
|
||||
if (!isValidQueryName(queryName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const builderQuery = builderQueries.find((query) => query.name === queryName);
|
||||
return {
|
||||
coordinates,
|
||||
context: {
|
||||
queryName,
|
||||
signal: resolveDrilldownSignal(builderQuery),
|
||||
filters: [],
|
||||
timeRange,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
|
||||
import { isValidQueryName } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
import type { DrilldownClickPayload } from '../../types/drilldown';
|
||||
|
||||
import { getGroupByFilters } from './getGroupByFilters';
|
||||
import { resolveDrilldownSignal } from './signal';
|
||||
|
||||
interface EnrichPieClickArgs {
|
||||
slice: PieSlice;
|
||||
builderQueries: BuilderQuery[];
|
||||
coordinates: { x: number; y: number };
|
||||
/** Explorer time window — the panel's fetched window (pie slices have no clicked bucket). */
|
||||
timeRange?: { startTime: number; endTime: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a pie-slice click into a drilldown payload, using the slice's source-row group-by label
|
||||
* values (carried by `preparePieData`) as equality filters. Returns `null` when the slice has no
|
||||
* drillable query.
|
||||
*/
|
||||
export function enrichPieClick({
|
||||
slice,
|
||||
builderQueries,
|
||||
coordinates,
|
||||
timeRange,
|
||||
}: EnrichPieClickArgs): DrilldownClickPayload | null {
|
||||
const queryName = slice.queryName ?? '';
|
||||
if (!isValidQueryName(queryName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const builderQuery = builderQueries.find((query) => query.name === queryName);
|
||||
|
||||
const filters = builderQuery
|
||||
? getGroupByFilters(slice.labels ?? {}, builderQuery)
|
||||
: [];
|
||||
return {
|
||||
coordinates,
|
||||
context: {
|
||||
queryName,
|
||||
signal: resolveDrilldownSignal(builderQuery),
|
||||
filters: filters,
|
||||
timeRange,
|
||||
label: slice.label,
|
||||
seriesColor: slice.color,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import {
|
||||
type FilterData,
|
||||
isValidQueryName,
|
||||
} from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
import type { DrilldownClickPayload } from '../../types/drilldown';
|
||||
|
||||
import { resolveDrilldownSignal } from './signal';
|
||||
|
||||
interface EnrichTableClickArgs {
|
||||
/** The clicked row's data, keyed by column id (see `prepareScalarTables`). */
|
||||
record: Record<string, unknown>;
|
||||
/** The clicked column's key (`column.id || column.name`). */
|
||||
columnId: string;
|
||||
table: PanelTable;
|
||||
builderQueries: BuilderQuery[];
|
||||
coordinates: { x: number; y: number };
|
||||
/** Explorer time window — the panel's fetched window (scalar tables have no clicked bucket). */
|
||||
timeRange?: { startTime: number; endTime: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a table cell click into a drilldown payload. The clicked value column (or the row's first
|
||||
* value column) selects the aggregate query, and the row's group-by cells become equality filters
|
||||
* (V1 `getFiltersToAddToView` parity). `columnKind` records whether a value or group column was
|
||||
* clicked, for the future filter-by-value menu. Returns `null` when the row has no drillable
|
||||
* aggregate query.
|
||||
*/
|
||||
export function enrichTableClick({
|
||||
record,
|
||||
columnId,
|
||||
table,
|
||||
builderQueries,
|
||||
coordinates,
|
||||
timeRange,
|
||||
}: EnrichTableClickArgs): DrilldownClickPayload | null {
|
||||
const clickedColumn = table.columns.find(
|
||||
(col) => (col.id || col.name) === columnId,
|
||||
);
|
||||
const valueColumn = clickedColumn?.isValueColumn
|
||||
? clickedColumn
|
||||
: table.columns.find((col) => col.isValueColumn);
|
||||
if (!valueColumn || !isValidQueryName(valueColumn.queryName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filters = table.columns.reduce<FilterData[]>((acc, col) => {
|
||||
if (col.isValueColumn) {
|
||||
return acc;
|
||||
}
|
||||
const value = record[col.id || col.name];
|
||||
if (value != null) {
|
||||
// Group cell value → equality filter. Cast at the boundary: row data is `unknown`,
|
||||
// group cells hold scalar label values.
|
||||
acc.push({
|
||||
filterKey: col.name,
|
||||
filterValue: value as string | number,
|
||||
operator: OPERATORS['='],
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const builderQuery = builderQueries.find(
|
||||
(query) => query.name === valueColumn.queryName,
|
||||
);
|
||||
|
||||
// A group-column click filters by that single cell (V1 filter-by-value); a value-column click
|
||||
// opens the aggregate menu scoped by the whole row.
|
||||
const isGroupColumn = clickedColumn != null && !clickedColumn.isValueColumn;
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
context: {
|
||||
queryName: valueColumn.queryName,
|
||||
signal: resolveDrilldownSignal(builderQuery),
|
||||
filters,
|
||||
timeRange,
|
||||
// No `label`: like Number/Value, the aggregate menu header falls back to the
|
||||
// aggregation expression (e.g. `sum(signoz_calls_total)`), not the column name (V1 parity).
|
||||
columnKind: isGroupColumn ? 'group' : 'aggregate',
|
||||
clickedKey: isGroupColumn ? clickedColumn?.name : undefined,
|
||||
clickedValue: isGroupColumn
|
||||
? (record[columnId] as string | number)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
|
||||
/** An auto-generated drilldown link (label + destination URL). */
|
||||
export interface DrilldownDataLink {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Links derived automatically from the clicked point's filters — the V2 port of V1's `getDataLinks`.
|
||||
* Currently a single "View Trace Details" link when the clicked row carries a `trace_id`.
|
||||
*/
|
||||
export function getDataLinks(filters: FilterData[]): DrilldownDataLink[] {
|
||||
const links: DrilldownDataLink[] = [];
|
||||
|
||||
const traceId = filters.find(
|
||||
(filter) => filter.filterKey === 'trace_id',
|
||||
)?.filterValue;
|
||||
if (traceId) {
|
||||
links.push({
|
||||
id: 'view-trace-details',
|
||||
label: 'View Trace Details',
|
||||
url: `/trace/${traceId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
type FilterData,
|
||||
getFiltersFromMetric,
|
||||
} from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
* Equality filters for the clicked series/slice, restricted to the query's group-by dimensions.
|
||||
* `labels` also carry a display-only legend backfill (`{queryName: queryName}` when ungrouped, see
|
||||
* `resolveLegendAndLabels`); intersecting with group-by drops it so `filters` stays empty when
|
||||
* ungrouped, matching V1.
|
||||
*/
|
||||
export function getGroupByFilters(
|
||||
labels: Record<string, string>,
|
||||
builderQuery: BuilderQuery,
|
||||
): FilterData[] {
|
||||
const groupByKeys = new Set(
|
||||
(builderQuery.groupBy ?? []).map((group) => group.name),
|
||||
);
|
||||
if (groupByKeys.size === 0) {
|
||||
return [];
|
||||
}
|
||||
return getFiltersFromMetric(labels).filter((filter) =>
|
||||
groupByKeys.has(filter.filterKey),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
|
||||
import type { ContextLinkProps } from 'types/api/dashboard/getAll';
|
||||
|
||||
/** A panel context link with its label and URL templates resolved, ready to render. */
|
||||
export interface ResolvedDrilldownLink {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a panel's context links for the drilldown menu. Adapts each `DashboardLinkDTO` to the V1
|
||||
* `ContextLinkProps` the shared `processContextLinks` resolver expects, substitutes variables in the
|
||||
* label + URL, and drops links without a URL. Links with `renderVariables === false` keep their raw
|
||||
* label/URL (no substitution).
|
||||
*/
|
||||
export function resolvePanelContextLinks(
|
||||
links: DashboardLinkDTO[] | undefined,
|
||||
processedVariables: Record<string, string>,
|
||||
): ResolvedDrilldownLink[] {
|
||||
const usable = (links ?? []).filter((link) => !!link.url);
|
||||
if (usable.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const adapted: ContextLinkProps[] = usable.map((link, index) => ({
|
||||
id: String(index),
|
||||
label: link.name || link.url || '',
|
||||
url: link.url ?? '',
|
||||
}));
|
||||
|
||||
const resolved = processContextLinks(adapted, processedVariables, 50);
|
||||
|
||||
return usable.map((link, index) => {
|
||||
// `renderVariables` defaults to on; only an explicit `false` opts out of substitution.
|
||||
if (link.renderVariables === false) {
|
||||
return {
|
||||
id: String(index),
|
||||
label: link.name || link.url || '',
|
||||
url: link.url ?? '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: resolved[index].id,
|
||||
label: resolved[index].label,
|
||||
url: resolved[index].url,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
* Maps a V5 builder query's `signal` to the drilldown signal. Meter and unknown signals fall back to
|
||||
* `metrics` so the drilldown always targets a real explorer.
|
||||
*/
|
||||
export function resolveDrilldownSignal(
|
||||
query: BuilderQuery | undefined,
|
||||
): TelemetrytypesSignalDTO {
|
||||
switch (query?.signal) {
|
||||
case 'logs':
|
||||
return TelemetrytypesSignalDTO.logs;
|
||||
case 'traces':
|
||||
return TelemetrytypesSignalDTO.traces;
|
||||
default:
|
||||
return TelemetrytypesSignalDTO.metrics;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Querybuildertypesv5QueryRangeRequestDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
||||
import store from 'store';
|
||||
|
||||
/** Panel time window in epoch SECONDS (uPlot X-scale + drilldown explorer window). */
|
||||
interface PanelTimeRange {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time window a panel's data was fetched over, read off the request's `start`/`end` (ms → s).
|
||||
* Falls back to the dashboard global-time window when the panel hasn't fetched yet.
|
||||
*/
|
||||
export function getPanelTimeRange(
|
||||
request: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
): PanelTimeRange {
|
||||
if (request?.start && request?.end) {
|
||||
return { startTime: request.start / 1000, endTime: request.end / 1000 };
|
||||
}
|
||||
|
||||
const { globalTime } = store.getState();
|
||||
const { start, end } = getStartEndRangeTime({
|
||||
type: 'GLOBAL_TIME',
|
||||
interval: globalTime.selectedTime,
|
||||
});
|
||||
return { startTime: parseInt(start, 10), endTime: parseInt(end, 10) };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.signal {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Braces,
|
||||
ChartBar,
|
||||
DraftingCompass,
|
||||
Link,
|
||||
Loader,
|
||||
ScrollText,
|
||||
} from '@signozhq/icons';
|
||||
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getAggregateColumnHeader } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
|
||||
import { getDataLinks } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/getDataLinks';
|
||||
import { resolvePanelContextLinks } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/resolvePanelContextLinks';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { useDrilldownContextVariables } from '../hooks/useDrilldownContextVariables';
|
||||
|
||||
import styles from './DrilldownAggregateMenu.module.scss';
|
||||
|
||||
interface DrilldownAggregateMenuProps {
|
||||
context: DrilldownContext;
|
||||
/** Panel's V5→V1 query — supplies the aggregation-expression header fallback. */
|
||||
query: Query;
|
||||
/** While dashboard variables resolve, the actions show a spinner and are disabled. */
|
||||
isResolving?: boolean;
|
||||
/** Panel's context links; resolved against the clicked point + variables here. */
|
||||
links: DashboardLinkDTO[] | undefined;
|
||||
/** Whether the clicked point exposes group-by fields to bind to dashboard variables. */
|
||||
canSetDashboardVariables: boolean;
|
||||
onViewLogs: () => void;
|
||||
onViewTraces: () => void;
|
||||
onBreakout: () => void;
|
||||
/** Open the Dashboard Variables submenu (set/unset/create from the clicked value). */
|
||||
onSetDashboardVariables: () => void;
|
||||
/** Close the popover (context-link clicks). */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The base aggregate drill-down menu: tinted header + View in Logs/Traces, Breakout, and the
|
||||
* panel's context links. Mounted only while open, so the variable/link resolution runs only then.
|
||||
* Metrics is omitted — V1 surfaces only Logs/Traces.
|
||||
*/
|
||||
function DrilldownAggregateMenu({
|
||||
context,
|
||||
query,
|
||||
isResolving = false,
|
||||
links,
|
||||
canSetDashboardVariables,
|
||||
onViewLogs,
|
||||
onViewTraces,
|
||||
onBreakout,
|
||||
onSetDashboardVariables,
|
||||
onClose,
|
||||
}: DrilldownAggregateMenuProps): JSX.Element {
|
||||
const aggregations = useMemo(
|
||||
() => getAggregateColumnHeader(query, context.queryName).aggregations,
|
||||
[query, context.queryName],
|
||||
);
|
||||
|
||||
const processedVariables = useDrilldownContextVariables(context);
|
||||
const contextLinks = useMemo(
|
||||
() => resolvePanelContextLinks(links, processedVariables),
|
||||
[links, processedVariables],
|
||||
);
|
||||
// Auto links derived from the clicked point itself (e.g. "View Trace Details" for a trace_id).
|
||||
const dataLinks = useMemo(
|
||||
() => getDataLinks(context.filters),
|
||||
[context.filters],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Header>
|
||||
<div className={styles.signal}>{context.signal}</div>
|
||||
<div className={styles.label} style={{ color: context.seriesColor }}>
|
||||
{context.label || aggregations}
|
||||
</div>
|
||||
</ContextMenu.Header>
|
||||
{canSetDashboardVariables && (
|
||||
<ContextMenu.Item
|
||||
icon={
|
||||
<span style={{ color: context.seriesColor }}>
|
||||
<Braces size={16} />
|
||||
</span>
|
||||
}
|
||||
onClick={onSetDashboardVariables}
|
||||
>
|
||||
<span data-testid="drilldown-dashboard-variables">
|
||||
Dashboard Variables
|
||||
</span>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
<ContextMenu.Item
|
||||
icon={
|
||||
isResolving ? (
|
||||
<Loader className="animate-spin" size={16} color={context.seriesColor} />
|
||||
) : (
|
||||
<span style={{ color: context.seriesColor }}>
|
||||
<ScrollText size={16} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
onClick={onViewLogs}
|
||||
disabled={isResolving}
|
||||
>
|
||||
<span data-testid="drilldown-view-logs">View in Logs</span>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
icon={
|
||||
isResolving ? (
|
||||
<Loader className="animate-spin" color={context.seriesColor} size={16} />
|
||||
) : (
|
||||
<span style={{ color: context.seriesColor }}>
|
||||
<DraftingCompass size={16} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
onClick={onViewTraces}
|
||||
disabled={isResolving}
|
||||
>
|
||||
<span data-testid="drilldown-view-traces">View in Traces</span>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
icon={
|
||||
<span style={{ color: context.seriesColor }}>
|
||||
<ChartBar size={16} />
|
||||
</span>
|
||||
}
|
||||
onClick={onBreakout}
|
||||
>
|
||||
<span data-testid="drilldown-breakout">Breakout by ..</span>
|
||||
</ContextMenu.Item>
|
||||
{dataLinks.map((link) => (
|
||||
<ContextMenu.Item
|
||||
key={link.id}
|
||||
icon={<Link size={16} color={context.seriesColor} />}
|
||||
onClick={(): void => {
|
||||
openInNewTab(link.url);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span data-testid="drilldown-data-link">{link.label}</span>
|
||||
</ContextMenu.Item>
|
||||
))}
|
||||
{contextLinks.map((link) => (
|
||||
<ContextMenu.Item
|
||||
key={link.id}
|
||||
icon={<Link size={16} color={context.seriesColor} />}
|
||||
onClick={(): void => {
|
||||
openInNewTab(link.url);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span data-testid="drilldown-context-link">{link.label}</span>
|
||||
</ContextMenu.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DrilldownAggregateMenu;
|
||||
@@ -0,0 +1,9 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.backArrow {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ArrowLeft } from '@signozhq/icons';
|
||||
import BreakoutOptions from 'container/QueryTable/Drilldown/BreakoutOptions';
|
||||
import type { BreakoutAttributeType } from 'container/QueryTable/Drilldown/types';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import styles from './DrilldownBreakoutMenu.module.scss';
|
||||
|
||||
interface DrilldownBreakoutMenuProps {
|
||||
/** The clicked query's builder data — supplies the picker's available attributes. */
|
||||
queryData: IBuilderQuery;
|
||||
/** Regroup the clicked query by the picked attribute. */
|
||||
onBreakout: (groupBy: BreakoutAttributeType) => void;
|
||||
/** Return to the base aggregate menu. */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Breakout by .." submenu — a back header + V1's read-only `BreakoutOptions` attribute picker,
|
||||
* wired to `onBreakout`.
|
||||
*/
|
||||
function DrilldownBreakoutMenu({
|
||||
queryData,
|
||||
onBreakout,
|
||||
onBack,
|
||||
}: DrilldownBreakoutMenuProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Header>
|
||||
<div className={styles.header}>
|
||||
<ArrowLeft
|
||||
size={14}
|
||||
className={styles.backArrow}
|
||||
onClick={onBack}
|
||||
data-testid="drilldown-breakout-back"
|
||||
/>
|
||||
<span>Breakout by</span>
|
||||
</div>
|
||||
</ContextMenu.Header>
|
||||
<BreakoutOptions queryData={queryData} onColumnClick={onBreakout} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DrilldownBreakoutMenu;
|
||||
@@ -0,0 +1,9 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.backArrow {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ArrowLeft, Plus, Settings, X } from '@signozhq/icons';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import {
|
||||
type DrilldownVariableAction,
|
||||
DrilldownVariableActionKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDrilldownDashboardVariables';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
|
||||
import styles from './DrilldownDashboardVariablesMenu.module.scss';
|
||||
|
||||
interface DrilldownDashboardVariablesMenuProps {
|
||||
/** Resolved entries from `useDrilldownDashboardVariables`. */
|
||||
actions: DrilldownVariableAction[];
|
||||
/** Return to the base aggregate menu. */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const ICON_BY_KIND: Record<DrilldownVariableActionKind, JSX.Element> = {
|
||||
[DrilldownVariableActionKind.Set]: <Settings size={16} />,
|
||||
[DrilldownVariableActionKind.Unset]: <X size={16} />,
|
||||
[DrilldownVariableActionKind.Create]: <Plus size={16} />,
|
||||
};
|
||||
|
||||
/**
|
||||
* The "Dashboard Variables" drilldown submenu — renders the entries resolved by
|
||||
* `useDrilldownDashboardVariables` (Set/Unset an existing dynamic variable, or Create one).
|
||||
*/
|
||||
function DrilldownDashboardVariablesMenu({
|
||||
actions,
|
||||
onBack,
|
||||
}: DrilldownDashboardVariablesMenuProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Header>
|
||||
<div className={styles.header}>
|
||||
<ArrowLeft
|
||||
size={14}
|
||||
className={styles.backArrow}
|
||||
onClick={onBack}
|
||||
data-testid="drilldown-var-back"
|
||||
/>
|
||||
<span>Dashboard Variables</span>
|
||||
</div>
|
||||
</ContextMenu.Header>
|
||||
<OverlayScrollbar
|
||||
style={{ maxHeight: '200px' }}
|
||||
options={{ overflow: { x: 'hidden' } }}
|
||||
>
|
||||
<>
|
||||
{actions.map(({ fieldName, fieldValue, kind, onClick }) => (
|
||||
<ContextMenu.Item
|
||||
key={fieldName}
|
||||
icon={ICON_BY_KIND[kind]}
|
||||
onClick={onClick}
|
||||
>
|
||||
{kind === DrilldownVariableActionKind.Unset && (
|
||||
<span data-testid="drilldown-var-unset">
|
||||
Unset <strong>${fieldName}</strong>
|
||||
</span>
|
||||
)}
|
||||
{kind === DrilldownVariableActionKind.Set && (
|
||||
<span data-testid="drilldown-var-set">
|
||||
Set <strong>${fieldName}</strong> to <strong>{fieldValue}</strong>
|
||||
</span>
|
||||
)}
|
||||
{kind === DrilldownVariableActionKind.Create && (
|
||||
<span data-testid="drilldown-var-create">
|
||||
Create var <strong>${fieldName}</strong>:<strong>{fieldValue}</strong>
|
||||
</span>
|
||||
)}
|
||||
</ContextMenu.Item>
|
||||
))}
|
||||
</>
|
||||
</OverlayScrollbar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DrilldownDashboardVariablesMenu;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getGroupContextMenuConfig } from 'container/QueryTable/Drilldown/contextConfig';
|
||||
import type { ClickedData } from 'periscope/components/ContextMenu';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
interface DrilldownFilterMenuProps {
|
||||
/** The panel's V5→V1 query the operator menu builds filters against. */
|
||||
v1Query: Query;
|
||||
/** The clicked group column's key. */
|
||||
clickedKey: string;
|
||||
/** Apply the chosen operator (adds the filter and opens the View modal). */
|
||||
onFilter: (operator: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The group-column "filter by value" submenu — the operator list from V1's read-only
|
||||
* `getGroupContextMenuConfig`, wired to `onFilter`.
|
||||
*/
|
||||
function DrilldownFilterMenu({
|
||||
v1Query,
|
||||
clickedKey,
|
||||
onFilter,
|
||||
}: DrilldownFilterMenuProps): JSX.Element {
|
||||
const clickedData: ClickedData = {
|
||||
column: { dataIndex: clickedKey },
|
||||
record: { key: clickedKey, timestamp: 0 },
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{getGroupContextMenuConfig({
|
||||
query: v1Query,
|
||||
clickedData,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
onColumnClick: onFilter,
|
||||
}).items ?? null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DrilldownFilterMenu;
|
||||
@@ -3,11 +3,13 @@ import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { panelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import { useDrilldown } from './hooks/useDrilldown';
|
||||
import { usePanelInteractions } from './hooks/usePanelInteractions';
|
||||
import PanelBody from './PanelBody/PanelBody';
|
||||
import PanelHeader from './PanelHeader/PanelHeader';
|
||||
@@ -68,6 +70,7 @@ function Panel({
|
||||
});
|
||||
|
||||
const { onDragSelect, dashboardPreference } = usePanelInteractions();
|
||||
const drilldown = useDrilldown(panel, panelId);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -100,8 +103,11 @@ function Panel({
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onClick={drilldown.onPanelClick}
|
||||
enableDrillDown={drilldown.enableDrillDown}
|
||||
/>
|
||||
)}
|
||||
<ContextMenu {...drilldown.contextMenuProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -294,4 +294,13 @@ describe('usePanelActionItems', () => {
|
||||
(createAlert as { onClick: () => void }).onClick();
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
|
||||
});
|
||||
|
||||
it('create-alert seeds an alert from this panel', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const createAlert = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'create-alert',
|
||||
);
|
||||
(createAlert as { onClick: () => void }).onClick();
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,13 @@ import type { ComponentTypes } from 'utils/permission';
|
||||
|
||||
/**
|
||||
* Every action the panel menu can offer: per-kind gated capabilities (minus
|
||||
* `search`, a header control) plus the chrome actions every kind gets. The
|
||||
* `Record<PanelActionId, …>` below forces a meta entry per id, so adding an
|
||||
* action without declaring its gates is a compile error.
|
||||
* `search` and `drilldown`, which are renderer-wired controls, not menu items)
|
||||
* plus the chrome actions every kind gets. The `Record<PanelActionId, …>` below
|
||||
* forces a meta entry per id, so adding an action without declaring its gates is
|
||||
* a compile error.
|
||||
*/
|
||||
export type PanelActionId =
|
||||
| Exclude<keyof PanelActionCapabilities, 'search'>
|
||||
| Exclude<keyof PanelActionCapabilities, 'search' | 'drilldown'>
|
||||
| 'move'
|
||||
| 'delete';
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Loader, RotateCw, SquarePlus, TriangleAlert } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import PanelMessage from 'pages/DashboardPageV2/DashboardContainer/Panels/components/PanelMessage/PanelMessage';
|
||||
import type { AnyPanelInteractionProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/interactions';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { hasRunnableQueries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
|
||||
@@ -37,6 +38,10 @@ interface PanelBodyProps {
|
||||
pagination?: PanelPagination;
|
||||
/** Close the standalone View modal — only consumed by the time-series/bar graph manager. */
|
||||
onCloseStandaloneView?: () => void;
|
||||
/** Opens the drill-down context menu; threaded to interactive renderers. */
|
||||
onClick?: AnyPanelInteractionProps['onClick'];
|
||||
/** Gate for the drill-down menu — kind supported and the panel has a builder query. */
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +63,8 @@ function PanelBody({
|
||||
searchTerm,
|
||||
pagination,
|
||||
onCloseStandaloneView,
|
||||
onClick,
|
||||
enableDrillDown = false,
|
||||
}: PanelBodyProps): JSX.Element {
|
||||
// A retained response (keepPreviousData) counts as data only if its type matches the current
|
||||
// request — else a prior panel kind's response (time_series → raw) flashes NoData on switch.
|
||||
@@ -120,7 +127,8 @@ function PanelBody({
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={panelMode}
|
||||
enableDrillDown={false}
|
||||
enableDrillDown={enableDrillDown}
|
||||
onClick={onClick}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchTerm}
|
||||
pagination={pagination}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { type KeyboardEvent, useCallback } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
|
||||
interface ViewPanelQueryBuilderProps {
|
||||
panelType: PANEL_TYPES;
|
||||
/** Preview fetch in flight — drives the Run/Cancel button state. */
|
||||
isLoadingQueries: boolean;
|
||||
/** Run the current query (Run Query button / ⌘↵). */
|
||||
onStageRunQuery: () => void;
|
||||
/** Abort the in-flight preview fetch. */
|
||||
onCancelQuery: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drilldown query editor for the View modal. Mirrors V1's FullView: the query builder
|
||||
* rows + a "Run Query" button, with NO query-type tabs (ClickHouse/PromQL) — drilldown
|
||||
* is query-builder only, exactly as V1.
|
||||
*/
|
||||
function ViewPanelQueryBuilder({
|
||||
panelType,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
}: ViewPanelQueryBuilderProps): JSX.Element {
|
||||
const handleKeyDownCapture = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onStageRunQuery();
|
||||
}
|
||||
},
|
||||
[onStageRunQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.queryBuilder}
|
||||
data-testid="view-panel-query-builder"
|
||||
onKeyDownCapture={handleKeyDownCapture}
|
||||
role="presentation"
|
||||
>
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
version="v3"
|
||||
isListViewPanel={panelType === PANEL_TYPES.LIST}
|
||||
signalSourceChangeEnabled
|
||||
/>
|
||||
<div className={styles.queryBuilderToolbar}>
|
||||
<RightToolbarActions
|
||||
handleCancelQuery={onCancelQuery}
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewPanelQueryBuilder;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import DrilldownDashboardVariablesMenu from '../DrilldownMenu/DrilldownDashboardVariablesMenu';
|
||||
import {
|
||||
type DrilldownVariableAction,
|
||||
DrilldownVariableActionKind,
|
||||
} from '../hooks/useDrilldownDashboardVariables';
|
||||
|
||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
function action(
|
||||
overrides: Partial<DrilldownVariableAction> = {},
|
||||
): DrilldownVariableAction {
|
||||
return {
|
||||
fieldName: 'service.name',
|
||||
fieldValue: 'frontend',
|
||||
kind: DrilldownVariableActionKind.Set,
|
||||
onClick: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DrilldownDashboardVariablesMenu', () => {
|
||||
it('renders each kind with its own label and fires its onClick', async () => {
|
||||
const onSet = jest.fn();
|
||||
const onUnset = jest.fn();
|
||||
const onCreate = jest.fn();
|
||||
render(
|
||||
<DrilldownDashboardVariablesMenu
|
||||
onBack={jest.fn()}
|
||||
actions={[
|
||||
action({
|
||||
fieldName: 'a',
|
||||
kind: DrilldownVariableActionKind.Set,
|
||||
onClick: onSet,
|
||||
}),
|
||||
action({
|
||||
fieldName: 'b',
|
||||
kind: DrilldownVariableActionKind.Unset,
|
||||
onClick: onUnset,
|
||||
}),
|
||||
action({
|
||||
fieldName: 'c',
|
||||
kind: DrilldownVariableActionKind.Create,
|
||||
onClick: onCreate,
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('drilldown-var-set')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drilldown-var-unset')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drilldown-var-create')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByTestId('drilldown-var-set'));
|
||||
expect(onSet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns to the base menu when the back arrow is clicked', async () => {
|
||||
const onBack = jest.fn();
|
||||
render(
|
||||
<DrilldownDashboardVariablesMenu onBack={onBack} actions={[action()]} />,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('drilldown-var-back'));
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
import { act, render, renderHook, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
|
||||
|
||||
import { useDrilldown } from '../hooks/useDrilldown';
|
||||
|
||||
const mockOpenViewWithQuery = jest.fn();
|
||||
const mockNavigate = jest.fn();
|
||||
const mockGetBuilderQueries = jest.fn();
|
||||
let mockResolved = { resolvedQuery: 'RESOLVED_QUERY', isResolving: false };
|
||||
let mockDashboardVariables: {
|
||||
hasFieldVariables: boolean;
|
||||
actions: unknown[];
|
||||
} = {
|
||||
hasFieldVariables: true,
|
||||
actions: [],
|
||||
};
|
||||
|
||||
// Boundaries tested elsewhere / needing external context — mocked so this suite isolates
|
||||
// useDrilldown's orchestration (gating, which menu shows, the View-modal handoff).
|
||||
jest.mock('../hooks/useViewPanel', () => ({
|
||||
useViewPanel: (): unknown => ({ openViewWithQuery: mockOpenViewWithQuery }),
|
||||
}));
|
||||
// Variable-substitution boundary (redux/store/react-query) — its own logic is out of scope here.
|
||||
jest.mock('../hooks/useResolvedDrilldownQuery', () => ({
|
||||
useResolvedDrilldownQuery: (): unknown => mockResolved,
|
||||
}));
|
||||
jest.mock('../hooks/useDrilldownBreakout', () => ({
|
||||
useDrilldownBreakout: (): unknown => ({
|
||||
queryData: { queryName: 'A' },
|
||||
onBreakout: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest.mock('../DrilldownMenu/DrilldownBreakoutMenu', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="breakout-submenu" />,
|
||||
}));
|
||||
jest.mock('../hooks/useDrilldownDashboardVariables', () => ({
|
||||
useDrilldownDashboardVariables: (): unknown => mockDashboardVariables,
|
||||
}));
|
||||
jest.mock('../DrilldownMenu/DrilldownDashboardVariablesMenu', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="dashboard-variables-submenu" />,
|
||||
}));
|
||||
// Context-link variable map (redux global time + store) — out of scope for this suite.
|
||||
jest.mock('../hooks/useDrilldownContextVariables', () => ({
|
||||
useDrilldownContextVariables: (): unknown => ({}),
|
||||
}));
|
||||
jest.mock('container/QueryTable/Drilldown/useBaseDrilldownNavigate', () => ({
|
||||
__esModule: true,
|
||||
default: (): unknown => mockNavigate,
|
||||
}));
|
||||
jest.mock('container/QueryTable/Drilldown/contextConfig', () => ({
|
||||
getGroupContextMenuConfig: ({
|
||||
onColumnClick,
|
||||
}: {
|
||||
onColumnClick: (op: string) => void;
|
||||
}): unknown => ({
|
||||
items: (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="filter-op"
|
||||
onClick={(): void => onColumnClick('=')}
|
||||
>
|
||||
Is this
|
||||
</button>
|
||||
),
|
||||
}),
|
||||
}));
|
||||
jest.mock('container/QueryTable/Drilldown/drilldownUtils', () => ({
|
||||
addFilterToQuery: jest.fn(() => 'REFINED_QUERY'),
|
||||
getAggregateColumnHeader: (): unknown => ({
|
||||
aggregations: 'sum(x)',
|
||||
dataSource: 'metrics',
|
||||
}),
|
||||
getBaseMeta: (): unknown => undefined,
|
||||
isNumberDataType: (): boolean => false,
|
||||
}));
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
|
||||
() => ({
|
||||
fromPerses: (): string => 'V1_QUERY',
|
||||
toPerses: jest.fn(() => [{ kind: 'REFINED' }]),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries',
|
||||
() => ({
|
||||
getBuilderQueries: (...args: unknown[]): unknown =>
|
||||
mockGetBuilderQueries(...args),
|
||||
}),
|
||||
);
|
||||
// Capability lookup mocked (its per-kind values are data in the definitions); avoids
|
||||
// importing the whole renderer registry into the test.
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: (kind: string): unknown => ({
|
||||
actions: { drilldown: kind !== 'signoz/ListPanel' },
|
||||
}),
|
||||
}));
|
||||
|
||||
function panelOfKind(kind: string): DashboardtypesPanelDTO {
|
||||
return {
|
||||
spec: { plugin: { kind, spec: {} }, queries: [{ x: 1 }] },
|
||||
display: { name: 'P' },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
const tsPanel = panelOfKind('signoz/TimeSeriesPanel');
|
||||
|
||||
const aggregateContext: DrilldownContext = {
|
||||
queryName: 'A',
|
||||
signal: TelemetrytypesSignalDTO.metrics,
|
||||
filters: [],
|
||||
label: 'frontend',
|
||||
seriesColor: '#fff',
|
||||
};
|
||||
|
||||
const groupContext: DrilldownContext = {
|
||||
queryName: 'A',
|
||||
signal: TelemetrytypesSignalDTO.metrics,
|
||||
filters: [],
|
||||
columnKind: 'group',
|
||||
clickedKey: 'service.name',
|
||||
clickedValue: 'frontend',
|
||||
};
|
||||
|
||||
describe('useDrilldown', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetBuilderQueries.mockReturnValue([{ name: 'A' }]);
|
||||
mockResolved = { resolvedQuery: 'RESOLVED_QUERY', isResolving: false };
|
||||
mockDashboardVariables = {
|
||||
hasFieldVariables: true,
|
||||
actions: [],
|
||||
};
|
||||
});
|
||||
|
||||
describe('enableDrillDown', () => {
|
||||
it('is true when the kind declares drilldown and has a builder query', () => {
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
expect(result.current.enableDrillDown).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when there is no builder query', () => {
|
||||
mockGetBuilderQueries.mockReturnValue([]);
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
expect(result.current.enableDrillDown).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a kind that opts out of drilldown', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDrilldown(panelOfKind('signoz/ListPanel'), 'p1'),
|
||||
);
|
||||
expect(result.current.enableDrillDown).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregate menu', () => {
|
||||
it('shows View in Logs/Traces + Breakout on an aggregate click', () => {
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
act(() =>
|
||||
result.current.onPanelClick({
|
||||
coordinates: { x: 1, y: 1 },
|
||||
context: aggregateContext,
|
||||
}),
|
||||
);
|
||||
render(<div>{result.current.contextMenuProps.items}</div>);
|
||||
|
||||
expect(screen.getByTestId('drilldown-view-logs')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drilldown-view-traces')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drilldown-breakout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to logs when View in Logs is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
act(() =>
|
||||
result.current.onPanelClick({
|
||||
coordinates: { x: 1, y: 1 },
|
||||
context: aggregateContext,
|
||||
}),
|
||||
);
|
||||
render(<div>{result.current.contextMenuProps.items}</div>);
|
||||
|
||||
await user.click(screen.getByTestId('drilldown-view-logs'));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('view_logs');
|
||||
});
|
||||
|
||||
it('disables navigation while dashboard variables resolve', async () => {
|
||||
mockResolved = { resolvedQuery: 'RESOLVED_QUERY', isResolving: true };
|
||||
const user = userEvent.setup();
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
act(() =>
|
||||
result.current.onPanelClick({
|
||||
coordinates: { x: 1, y: 1 },
|
||||
context: aggregateContext,
|
||||
}),
|
||||
);
|
||||
render(<div>{result.current.contextMenuProps.items}</div>);
|
||||
|
||||
await user.click(screen.getByTestId('drilldown-view-logs'));
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swaps to the breakout submenu when "Breakout by .." is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
act(() =>
|
||||
result.current.onPanelClick({
|
||||
coordinates: { x: 1, y: 1 },
|
||||
context: aggregateContext,
|
||||
}),
|
||||
);
|
||||
const view = render(<div>{result.current.contextMenuProps.items}</div>);
|
||||
|
||||
await user.click(screen.getByTestId('drilldown-breakout'));
|
||||
view.rerender(<div>{result.current.contextMenuProps.items}</div>);
|
||||
|
||||
expect(screen.getByTestId('breakout-submenu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the Dashboard Variables entry when the click has group-by fields', () => {
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
act(() =>
|
||||
result.current.onPanelClick({
|
||||
coordinates: { x: 1, y: 1 },
|
||||
context: aggregateContext,
|
||||
}),
|
||||
);
|
||||
render(<div>{result.current.contextMenuProps.items}</div>);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('drilldown-dashboard-variables'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the Dashboard Variables entry when there are no group-by fields', () => {
|
||||
mockDashboardVariables = { hasFieldVariables: false, actions: [] };
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
act(() =>
|
||||
result.current.onPanelClick({
|
||||
coordinates: { x: 1, y: 1 },
|
||||
context: aggregateContext,
|
||||
}),
|
||||
);
|
||||
render(<div>{result.current.contextMenuProps.items}</div>);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('drilldown-dashboard-variables'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('swaps to the Dashboard Variables submenu when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
act(() =>
|
||||
result.current.onPanelClick({
|
||||
coordinates: { x: 1, y: 1 },
|
||||
context: aggregateContext,
|
||||
}),
|
||||
);
|
||||
const view = render(<div>{result.current.contextMenuProps.items}</div>);
|
||||
|
||||
await user.click(screen.getByTestId('drilldown-dashboard-variables'));
|
||||
view.rerender(<div>{result.current.contextMenuProps.items}</div>);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('dashboard-variables-submenu'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter-by-value', () => {
|
||||
it('opens the View modal with the refined query on a group-column filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
act(() =>
|
||||
result.current.onPanelClick({
|
||||
coordinates: { x: 1, y: 1 },
|
||||
context: groupContext,
|
||||
}),
|
||||
);
|
||||
render(<div>{result.current.contextMenuProps.items}</div>);
|
||||
|
||||
await user.click(screen.getByTestId('filter-op'));
|
||||
// Opens the View modal on the refined query at the panel's kind — persisted in the URL.
|
||||
expect(mockOpenViewWithQuery).toHaveBeenCalledWith(
|
||||
'p1',
|
||||
'REFINED_QUERY',
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { render, renderHook, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
|
||||
import DrilldownDashboardVariablesMenu from '../DrilldownMenu/DrilldownDashboardVariablesMenu';
|
||||
import { useDrilldownDashboardVariables } from '../hooks/useDrilldownDashboardVariables';
|
||||
|
||||
// Fake dashboard variables + runtime selection, mutated per test. `dtoToFormModel` is mocked to
|
||||
// derive `type` from the plugin kind (like the real adapter), so these DTOs carry the plugin
|
||||
// discriminant plus the flat form-model fields the hook reads.
|
||||
let mockVariables: Array<{
|
||||
name: string;
|
||||
dynamicAttribute?: string;
|
||||
multiSelect?: boolean;
|
||||
spec: { plugin: { kind: string } };
|
||||
}> = [];
|
||||
let mockSelectionMap: Record<string, { value: unknown; allSelected: boolean }> =
|
||||
{};
|
||||
|
||||
const mockSetVariableValue = jest.fn();
|
||||
const mockSetUrlValues = jest.fn();
|
||||
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const DYNAMIC_KIND = 'signoz/DynamicVariable';
|
||||
const QUERY_KIND = 'signoz/QueryVariable';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useGetDashboardV2: (): unknown => ({
|
||||
data: { data: { spec: { variables: mockVariables } } },
|
||||
}),
|
||||
}));
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/hooks/useOptimisticPatch',
|
||||
() => ({
|
||||
useOptimisticPatch: (): unknown => ({ patchAsync: mockPatchAsync }),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({
|
||||
dashboardId: 'd1',
|
||||
variableValues: { d1: mockSelectionMap },
|
||||
setVariableValue: mockSetVariableValue,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters',
|
||||
() => ({
|
||||
dtoToFormModel: (dto: {
|
||||
spec?: { plugin?: { kind?: string } };
|
||||
}): unknown => ({
|
||||
...dto,
|
||||
type:
|
||||
dto.spec?.plugin?.kind === 'signoz/DynamicVariable' ? 'DYNAMIC' : 'QUERY',
|
||||
}),
|
||||
formModelToDto: (model: { name: string }): unknown => ({ dto: model.name }),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel',
|
||||
() => ({
|
||||
emptyVariableFormModel: (): unknown => ({}),
|
||||
DYNAMIC_SIGNAL_ALL: 'all',
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection',
|
||||
() => ({
|
||||
ALL_SELECTED: '__ALL__',
|
||||
variablesUrlParser: { withOptions: (): unknown => ({}) },
|
||||
}),
|
||||
);
|
||||
jest.mock('nuqs', () => ({
|
||||
useQueryState: (): unknown => [null, mockSetUrlValues],
|
||||
}));
|
||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const filters: FilterData[] = [
|
||||
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
|
||||
];
|
||||
|
||||
function renderItems(): void {
|
||||
const { result } = renderHook(() =>
|
||||
useDrilldownDashboardVariables({
|
||||
filters,
|
||||
signal: TelemetrytypesSignalDTO.metrics,
|
||||
onClose: jest.fn(),
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<DrilldownDashboardVariablesMenu
|
||||
actions={result.current.actions}
|
||||
onBack={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('useDrilldownDashboardVariables', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockVariables = [];
|
||||
mockSelectionMap = {};
|
||||
});
|
||||
|
||||
it('hasFieldVariables reflects the clicked point group-by fields', () => {
|
||||
const withFields = renderHook(() =>
|
||||
useDrilldownDashboardVariables({ filters, onClose: jest.fn() }),
|
||||
);
|
||||
expect(withFields.result.current.hasFieldVariables).toBe(true);
|
||||
|
||||
const noFields = renderHook(() =>
|
||||
useDrilldownDashboardVariables({ filters: [], onClose: jest.fn() }),
|
||||
);
|
||||
expect(noFields.result.current.hasFieldVariables).toBe(false);
|
||||
});
|
||||
|
||||
it('offers Set — writing an array for a multi-select var so the selector shows it', async () => {
|
||||
mockVariables = [
|
||||
{
|
||||
name: 'svc',
|
||||
dynamicAttribute: 'service.name',
|
||||
multiSelect: true,
|
||||
spec: { plugin: { kind: DYNAMIC_KIND } },
|
||||
},
|
||||
];
|
||||
mockSelectionMap = { svc: { value: ['backend'], allSelected: false } };
|
||||
renderItems();
|
||||
|
||||
await userEvent.click(screen.getByTestId('drilldown-var-set'));
|
||||
expect(mockSetVariableValue).toHaveBeenCalledWith('d1', 'svc', {
|
||||
value: ['frontend'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('offers Set — writing a scalar for a single-select var', async () => {
|
||||
mockVariables = [
|
||||
{
|
||||
name: 'svc',
|
||||
dynamicAttribute: 'service.name',
|
||||
multiSelect: false,
|
||||
spec: { plugin: { kind: DYNAMIC_KIND } },
|
||||
},
|
||||
];
|
||||
mockSelectionMap = { svc: { value: 'backend', allSelected: false } };
|
||||
renderItems();
|
||||
|
||||
await userEvent.click(screen.getByTestId('drilldown-var-set'));
|
||||
expect(mockSetVariableValue).toHaveBeenCalledWith('d1', 'svc', {
|
||||
value: 'frontend',
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('offers Unset when the matching dynamic var already holds the clicked value', async () => {
|
||||
mockVariables = [
|
||||
{
|
||||
name: 'svc',
|
||||
dynamicAttribute: 'service.name',
|
||||
multiSelect: true,
|
||||
spec: { plugin: { kind: DYNAMIC_KIND } },
|
||||
},
|
||||
];
|
||||
mockSelectionMap = { svc: { value: ['frontend'], allSelected: false } };
|
||||
renderItems();
|
||||
|
||||
await userEvent.click(screen.getByTestId('drilldown-var-unset'));
|
||||
expect(mockSetVariableValue).toHaveBeenCalledWith('d1', 'svc', {
|
||||
value: [],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('offers Create and persists a new dynamic variable when none matches', async () => {
|
||||
mockVariables = [];
|
||||
renderItems();
|
||||
|
||||
await userEvent.click(screen.getByTestId('drilldown-var-create'));
|
||||
// Persisted to the spec via the optimistic patch...
|
||||
expect(mockPatchAsync).toHaveBeenCalledTimes(1);
|
||||
// ...and seeded (as an array — the created var is multi-select) with the clicked value.
|
||||
expect(mockSetVariableValue).toHaveBeenCalledWith('d1', 'service.name', {
|
||||
value: ['frontend'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a non-dynamic variable with the same attribute (still offers Create)', () => {
|
||||
mockVariables = [
|
||||
{
|
||||
name: 'svc',
|
||||
dynamicAttribute: 'service.name',
|
||||
spec: { plugin: { kind: QUERY_KIND } },
|
||||
},
|
||||
];
|
||||
renderItems();
|
||||
|
||||
expect(screen.getByTestId('drilldown-var-create')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('drilldown-var-set')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useBaseDrilldownNavigate from 'container/QueryTable/Drilldown/useBaseDrilldownNavigate';
|
||||
import type {
|
||||
Coordinates,
|
||||
PopoverPosition,
|
||||
} from 'periscope/components/ContextMenu';
|
||||
import type {
|
||||
DrilldownClickPayload,
|
||||
DrilldownContext,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { buildAggregateData } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/buildAggregateData';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
|
||||
import DrilldownAggregateMenu from '../DrilldownMenu/DrilldownAggregateMenu';
|
||||
import DrilldownBreakoutMenu from '../DrilldownMenu/DrilldownBreakoutMenu';
|
||||
import DrilldownDashboardVariablesMenu from '../DrilldownMenu/DrilldownDashboardVariablesMenu';
|
||||
import DrilldownFilterMenu from '../DrilldownMenu/DrilldownFilterMenu';
|
||||
import { useDrilldownBreakout } from './useDrilldownBreakout';
|
||||
import { useDrilldownCoordinates } from './useDrilldownCoordinates';
|
||||
import { useDrilldownDashboardVariables } from './useDrilldownDashboardVariables';
|
||||
import { useDrilldownFilter } from './useDrilldownFilter';
|
||||
import { useResolvedDrilldownQuery } from './useResolvedDrilldownQuery';
|
||||
import { useViewPanel } from './useViewPanel';
|
||||
|
||||
/** Which menu the popover shows; extend as submenus are added (e.g. dashboard variables). */
|
||||
enum DrilldownSubMenu {
|
||||
Base = 'base',
|
||||
Breakout = 'breakout',
|
||||
DashboardVariables = 'dashboardVariables',
|
||||
}
|
||||
|
||||
/** Stable empty-filters ref so the dashboard-variables hook doesn't re-run on every no-click render. */
|
||||
const EMPTY_FILTERS: FilterData[] = [];
|
||||
|
||||
/** Props the panel shell spreads onto `<ContextMenu>`. */
|
||||
export interface DrilldownContextMenuProps {
|
||||
coordinates: Coordinates | null;
|
||||
popoverPosition: PopoverPosition | null;
|
||||
items: ReactNode;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface UseDrilldownResult {
|
||||
/** Whether interactive renderers should arm the drill-down click. */
|
||||
enableDrillDown: boolean;
|
||||
/** Renderer `onClick` handler — opens the menu at the clicked point. */
|
||||
onPanelClick: (payload: DrilldownClickPayload) => void;
|
||||
contextMenuProps: DrilldownContextMenuProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates panel drill-down: owns the popover + which submenu is open, and routes the clicked
|
||||
* point to the base aggregate menu (View in Logs/Traces), the group filter menu, or the breakout picker.
|
||||
*/
|
||||
export function useDrilldown(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
panelId: string,
|
||||
): UseDrilldownResult {
|
||||
const kind = panel.spec.plugin.kind;
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[kind];
|
||||
const queries = panel.spec.queries;
|
||||
|
||||
// Kind must opt in via its capability AND have a builder query to drill into.
|
||||
const enableDrillDown = useMemo(
|
||||
() =>
|
||||
getPanelDefinition(kind).actions.drilldown &&
|
||||
getBuilderQueries(queries).length > 0,
|
||||
[kind, queries],
|
||||
);
|
||||
|
||||
const v1Query = useMemo(
|
||||
() => fromPerses(queries, panelType),
|
||||
[queries, panelType],
|
||||
);
|
||||
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
clickedData: context,
|
||||
onClick,
|
||||
onClose,
|
||||
} = useDrilldownCoordinates<DrilldownContext>();
|
||||
|
||||
const aggregateData = useMemo(
|
||||
() => (context ? buildAggregateData(context) : null),
|
||||
[context],
|
||||
);
|
||||
|
||||
// A fresh click and any close reset to the base menu.
|
||||
const [subMenu, setSubMenu] = useState<DrilldownSubMenu>(
|
||||
DrilldownSubMenu.Base,
|
||||
);
|
||||
const openBreakout = useCallback(
|
||||
(): void => setSubMenu(DrilldownSubMenu.Breakout),
|
||||
[],
|
||||
);
|
||||
const backToBase = useCallback(
|
||||
(): void => setSubMenu(DrilldownSubMenu.Base),
|
||||
[],
|
||||
);
|
||||
const openDashboardVariables = useCallback(
|
||||
(): void => setSubMenu(DrilldownSubMenu.DashboardVariables),
|
||||
[],
|
||||
);
|
||||
|
||||
const onPanelClick = useCallback(
|
||||
(payload: DrilldownClickPayload): void => {
|
||||
setSubMenu(DrilldownSubMenu.Base);
|
||||
onClick(payload.coordinates, payload.context);
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setSubMenu(DrilldownSubMenu.Base);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const { openViewWithQuery } = useViewPanel();
|
||||
|
||||
const breakout = useDrilldownBreakout({
|
||||
panelId,
|
||||
v1Query,
|
||||
panelType,
|
||||
aggregateData,
|
||||
openViewWithQuery,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
const filter = useDrilldownFilter({
|
||||
context,
|
||||
v1Query,
|
||||
panelId,
|
||||
panelType,
|
||||
openViewWithQuery,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
const dashboardVariables = useDrilldownDashboardVariables({
|
||||
filters: context?.filters ?? EMPTY_FILTERS,
|
||||
signal: context?.signal,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
// The aggregate menu (View in Logs/Traces) shows for a non-group click on the base menu; the
|
||||
// group click routes to filter-by-value instead. Only that menu resolves variables —
|
||||
// filter/breakout open the View modal, which resolves at query-run time.
|
||||
const showAggregateMenu =
|
||||
subMenu === DrilldownSubMenu.Base && !!context && !filter.isGroupColumnClick;
|
||||
|
||||
const { resolvedQuery, isResolving } = useResolvedDrilldownQuery({
|
||||
queries,
|
||||
panelType,
|
||||
v1Query,
|
||||
enabled: showAggregateMenu,
|
||||
});
|
||||
|
||||
const navigate = useBaseDrilldownNavigate({
|
||||
resolvedQuery,
|
||||
aggregateData,
|
||||
callback: handleClose,
|
||||
});
|
||||
|
||||
const items = useMemo<ReactNode>(() => {
|
||||
if (subMenu === DrilldownSubMenu.Breakout) {
|
||||
return breakout.queryData ? (
|
||||
<DrilldownBreakoutMenu
|
||||
queryData={breakout.queryData}
|
||||
onBreakout={breakout.onBreakout}
|
||||
onBack={backToBase}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
if (subMenu === DrilldownSubMenu.DashboardVariables) {
|
||||
return (
|
||||
<DrilldownDashboardVariablesMenu
|
||||
actions={dashboardVariables.actions}
|
||||
onBack={backToBase}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (filter.isGroupColumnClick && context?.clickedKey) {
|
||||
return (
|
||||
<DrilldownFilterMenu
|
||||
v1Query={v1Query}
|
||||
clickedKey={context.clickedKey}
|
||||
onFilter={filter.onFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DrilldownAggregateMenu
|
||||
context={context}
|
||||
query={v1Query}
|
||||
isResolving={isResolving}
|
||||
links={panel.spec.links}
|
||||
canSetDashboardVariables={dashboardVariables.hasFieldVariables}
|
||||
onViewLogs={(): void => navigate('view_logs')}
|
||||
onViewTraces={(): void => navigate('view_traces')}
|
||||
onBreakout={openBreakout}
|
||||
onSetDashboardVariables={openDashboardVariables}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
subMenu,
|
||||
breakout.queryData,
|
||||
breakout.onBreakout,
|
||||
dashboardVariables.actions,
|
||||
dashboardVariables.hasFieldVariables,
|
||||
filter.isGroupColumnClick,
|
||||
filter.onFilter,
|
||||
context,
|
||||
v1Query,
|
||||
isResolving,
|
||||
panel.spec.links,
|
||||
navigate,
|
||||
openBreakout,
|
||||
openDashboardVariables,
|
||||
backToBase,
|
||||
handleClose,
|
||||
]);
|
||||
|
||||
return {
|
||||
enableDrillDown,
|
||||
onPanelClick,
|
||||
contextMenuProps: {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
items,
|
||||
onClose: handleClose,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getQueryData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import {
|
||||
getBreakoutPanelType,
|
||||
getBreakoutQuery,
|
||||
} from 'container/QueryTable/Drilldown/tableDrilldownUtils';
|
||||
import type { BreakoutAttributeType } from 'container/QueryTable/Drilldown/types';
|
||||
import type { AggregateData } from 'container/QueryTable/Drilldown/useAggregateDrilldown';
|
||||
import type {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
interface UseDrilldownBreakoutArgs {
|
||||
panelId: string;
|
||||
/** The panel's V5→V1 query; the breakout regroups the clicked query within it. */
|
||||
v1Query: Query;
|
||||
/** The panel's current V1 panel type — drives the breakout target type. */
|
||||
panelType: PANEL_TYPES;
|
||||
aggregateData: AggregateData | null;
|
||||
/** Opens the View modal on the breakout query (at the breakout's target kind), persisting it in the URL. */
|
||||
openViewWithQuery: (
|
||||
panelId: string,
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
) => void;
|
||||
/** Close the popover after navigating to the View modal. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface UseDrilldownBreakoutApi {
|
||||
/** The clicked query's builder data for the attribute picker; `undefined` when there's no aggregate to break out. */
|
||||
queryData: IBuilderQuery | undefined;
|
||||
/** Regroup the clicked query by the picked attribute and open the result in the View modal. */
|
||||
onBreakout: (groupBy: BreakoutAttributeType) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Breakout by .." submenu logic: regroup the clicked query by a picked attribute and open the
|
||||
* result in the View modal. Reuses V1's read-only `getBreakoutQuery`/`getBreakoutPanelType`; the
|
||||
* caller renders `DrilldownBreakoutMenu` (V1's `BreakoutOptions` picker) from this hook's return.
|
||||
*/
|
||||
export function useDrilldownBreakout({
|
||||
panelId,
|
||||
v1Query,
|
||||
panelType,
|
||||
aggregateData,
|
||||
openViewWithQuery,
|
||||
onClose,
|
||||
}: UseDrilldownBreakoutArgs): UseDrilldownBreakoutApi {
|
||||
const onBreakout = useCallback(
|
||||
(groupBy: BreakoutAttributeType): void => {
|
||||
if (!aggregateData) {
|
||||
return;
|
||||
}
|
||||
const breakoutQuery = getBreakoutQuery(
|
||||
v1Query,
|
||||
aggregateData,
|
||||
groupBy,
|
||||
aggregateData.filters ?? [],
|
||||
);
|
||||
openViewWithQuery(panelId, breakoutQuery, getBreakoutPanelType(panelType));
|
||||
onClose();
|
||||
},
|
||||
[aggregateData, v1Query, panelType, panelId, openViewWithQuery, onClose],
|
||||
);
|
||||
|
||||
const queryData = useMemo(
|
||||
() =>
|
||||
aggregateData ? getQueryData(v1Query, aggregateData.queryName) : undefined,
|
||||
[aggregateData, v1Query],
|
||||
);
|
||||
|
||||
return { queryData, onBreakout };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- context links resolve global-time variables off redux (V1 parity)
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
/**
|
||||
* Builds the variable map that resolves context-link templates, mirroring V1's `useContextVariables`
|
||||
* but sourced from V2 state: dashboard variable selections (by name), the global time window
|
||||
* (`timestamp_start`/`timestamp_end`, ms), and the clicked point's field values (`_`-prefixed, the
|
||||
* V1 convention that keeps them from clashing with dashboard variable names).
|
||||
*/
|
||||
export function useDrilldownContextVariables(
|
||||
context: DrilldownContext | null,
|
||||
): Record<string, string> {
|
||||
// dashboardId from the store's edit context (set once by DashboardContainer), the same
|
||||
// source the rest of V2 uses — not react-router params.
|
||||
const dashboardId = useDashboardStore((state) => state.dashboardId);
|
||||
// Select the stable top-level map (not `state.variableValues[id] ?? {}`, whose fresh `{}` each
|
||||
// render makes useSyncExternalStore loop); index by dashboard inside the memo.
|
||||
const variableValuesByDashboard = useDashboardStore(
|
||||
(state) => state.variableValues,
|
||||
);
|
||||
const globalTime = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
const variableValues = variableValuesByDashboard[dashboardId] ?? {};
|
||||
Object.entries(variableValues).forEach(([name, selection]) => {
|
||||
const { value } = selection;
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
result[name] = Array.isArray(value) ? value.join(', ') : String(value);
|
||||
});
|
||||
|
||||
// Global time window, ns → ms (V1 parity).
|
||||
result.timestamp_start = String(Math.floor(globalTime.minTime / 1e6));
|
||||
result.timestamp_end = String(Math.floor(globalTime.maxTime / 1e6));
|
||||
|
||||
context?.filters.forEach(({ filterKey, filterValue }) => {
|
||||
result[`_${filterKey}`] = String(filterValue);
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [
|
||||
variableValuesByDashboard,
|
||||
dashboardId,
|
||||
globalTime.minTime,
|
||||
globalTime.maxTime,
|
||||
context,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type {
|
||||
Coordinates,
|
||||
PopoverPosition,
|
||||
} from 'periscope/components/ContextMenu';
|
||||
|
||||
import { calculatePopoverPosition } from '../utils/calculatePopoverPosition';
|
||||
|
||||
/**
|
||||
* Popover state for the drill-down context menu. V2's strongly-typed counterpart to V1's
|
||||
* `useCoordinates` (whose `clickedData` is `any`): the caller pins `TData` to the click payload,
|
||||
* so it flows through without a cast. `null` fields mean the menu is closed.
|
||||
*/
|
||||
export interface UseDrilldownCoordinatesResult<TData> {
|
||||
coordinates: Coordinates | null;
|
||||
popoverPosition: PopoverPosition | null;
|
||||
clickedData: TData | null;
|
||||
onClick: (coordinates: Coordinates, data: TData) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function useDrilldownCoordinates<
|
||||
TData,
|
||||
>(): UseDrilldownCoordinatesResult<TData> {
|
||||
const [coordinates, setCoordinates] = useState<Coordinates | null>(null);
|
||||
const [popoverPosition, setPopoverPosition] = useState<PopoverPosition | null>(
|
||||
null,
|
||||
);
|
||||
const [clickedData, setClickedData] = useState<TData | null>(null);
|
||||
|
||||
const onClick = useCallback((coords: Coordinates, data: TData): void => {
|
||||
setClickedData(data);
|
||||
setCoordinates(coords);
|
||||
setPopoverPosition(calculatePopoverPosition(coords));
|
||||
}, []);
|
||||
|
||||
const onClose = useCallback((): void => {
|
||||
setCoordinates(null);
|
||||
setPopoverPosition(null);
|
||||
setClickedData(null);
|
||||
}, []);
|
||||
|
||||
return { coordinates, popoverPosition, clickedData, onClick, onClose };
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import {
|
||||
dtoToFormModel,
|
||||
formModelToDto,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters';
|
||||
import {
|
||||
DYNAMIC_SIGNAL_ALL,
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel';
|
||||
import { buildVariablesPatch } from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variablePatchOps';
|
||||
import { useDashboardFetchRequired } from 'pages/DashboardPageV2/DashboardContainer/hooks/useDashboardFetchRequired';
|
||||
import { useOptimisticPatch } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOptimisticPatch';
|
||||
import { selectVariableValues } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
import type { VariableSelection } from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/selectionTypes';
|
||||
import {
|
||||
ALL_SELECTED,
|
||||
variablesUrlParser,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection';
|
||||
|
||||
interface UseDrilldownDashboardVariablesArgs {
|
||||
/** Group-by field filters from the clicked point (empty when the click has no group-by). */
|
||||
filters: FilterData[];
|
||||
/** Clicked query's telemetry signal — seeds a created variable's `dynamicSignal`. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/** Close the popover after an action. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Set/Unset a matching dynamic variable's value, or Create one when none matches. */
|
||||
export enum DrilldownVariableActionKind {
|
||||
Set = 'set',
|
||||
Unset = 'unset',
|
||||
Create = 'create',
|
||||
}
|
||||
|
||||
/** A resolved "Dashboard Variables" menu entry; the caller renders it. */
|
||||
export interface DrilldownVariableAction {
|
||||
fieldName: string;
|
||||
fieldValue: string | number;
|
||||
kind: DrilldownVariableActionKind;
|
||||
/** Applies the action and closes the popover. */
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface UseDrilldownDashboardVariablesApi {
|
||||
/** Whether the clicked point exposes any field to bind to a variable (gates the base-menu entry). */
|
||||
hasFieldVariables: boolean;
|
||||
/** Resolved menu entries, one per group-by field on the clicked point. */
|
||||
actions: DrilldownVariableAction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* "Dashboard Variables" submenu logic (V1 `useDashboardVarConfig` parity). Set/Unset are runtime-only
|
||||
* (store + URL — V2 selections don't persist); Create is the one path that patches `spec.variables`.
|
||||
*/
|
||||
export function useDrilldownDashboardVariables({
|
||||
filters,
|
||||
signal,
|
||||
onClose,
|
||||
}: UseDrilldownDashboardVariablesArgs): UseDrilldownDashboardVariablesApi {
|
||||
const dashboardId = useDashboardStore((state) => state.dashboardId);
|
||||
const { variables } = useDashboardFetchRequired();
|
||||
|
||||
const dynamicVariables = useMemo(
|
||||
() => variables.map(dtoToFormModel).filter((v) => v.type === 'DYNAMIC'),
|
||||
[variables],
|
||||
);
|
||||
const existingNames = useMemo(
|
||||
() => new Set(variables.map((v) => dtoToFormModel(v).name)),
|
||||
[variables],
|
||||
);
|
||||
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
const setVariableValue = useDashboardStore((state) => state.setVariableValue);
|
||||
const [, setUrlValues] = useQueryState(
|
||||
'variables',
|
||||
variablesUrlParser.withOptions({ history: 'replace' }),
|
||||
);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
|
||||
const fieldVariables = useMemo<[string, string | number][]>(
|
||||
() =>
|
||||
filters
|
||||
.filter(
|
||||
(f) => f.filterKey && f.filterValue !== undefined && f.filterValue !== '',
|
||||
)
|
||||
.map((f) => [f.filterKey, f.filterValue]),
|
||||
[filters],
|
||||
);
|
||||
|
||||
// Runtime-only write (store + URL), never the spec — mirrors VariablesBar's setSelection.
|
||||
const setSelection = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
setVariableValue(dashboardId, name, next);
|
||||
void setUrlValues((prev) => ({
|
||||
...(prev ?? {}),
|
||||
[name]: next.allSelected ? ALL_SELECTED : next.value,
|
||||
}));
|
||||
},
|
||||
[dashboardId, setVariableValue, setUrlValues],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (fieldName: string, fieldValue: string | number): Promise<void> => {
|
||||
if (existingNames.has(fieldName)) {
|
||||
toast.error(`Variable "${fieldName}" already exists`);
|
||||
return;
|
||||
}
|
||||
const model: VariableFormModel = {
|
||||
...emptyVariableFormModel(),
|
||||
name: fieldName,
|
||||
description: `Created from panel drilldown (field: ${fieldName})`,
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: true,
|
||||
dynamicAttribute: fieldName,
|
||||
dynamicSignal: signal ?? DYNAMIC_SIGNAL_ALL,
|
||||
};
|
||||
try {
|
||||
await patchAsync(
|
||||
buildVariablesPatch([...variables, formModelToDto(model)]),
|
||||
);
|
||||
// Multi-select var → seed the value as an array (the selector renders scalars as empty).
|
||||
setSelection(fieldName, { value: [fieldValue], allSelected: false });
|
||||
toast.success(`Created variable "${fieldName}"`);
|
||||
} catch {
|
||||
toast.error('Failed to create variable');
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[existingNames, signal, variables, patchAsync, setSelection, onClose],
|
||||
);
|
||||
|
||||
const actions = useMemo<DrilldownVariableAction[]>(
|
||||
() =>
|
||||
fieldVariables.map(([fieldName, fieldValue]) => {
|
||||
const existing = dynamicVariables.find(
|
||||
(v) => v.dynamicAttribute === fieldName,
|
||||
);
|
||||
if (!existing) {
|
||||
return {
|
||||
fieldName,
|
||||
fieldValue,
|
||||
kind: DrilldownVariableActionKind.Create,
|
||||
onClick: (): void => {
|
||||
void handleCreate(fieldName, fieldValue);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const current = selection[existing.name]?.value;
|
||||
const isSame = Array.isArray(current)
|
||||
? current.length === 1 && current[0] === fieldValue
|
||||
: current === fieldValue;
|
||||
|
||||
// Multi-select values must be arrays (a scalar renders as empty in the selector).
|
||||
const cleared = existing.multiSelect ? [] : '';
|
||||
const assigned = existing.multiSelect ? [fieldValue] : fieldValue;
|
||||
|
||||
return {
|
||||
fieldName,
|
||||
fieldValue,
|
||||
kind: isSame
|
||||
? DrilldownVariableActionKind.Unset
|
||||
: DrilldownVariableActionKind.Set,
|
||||
onClick: (): void => {
|
||||
setSelection(existing.name, {
|
||||
value: isSame ? cleared : assigned,
|
||||
allSelected: false,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
}),
|
||||
[
|
||||
fieldVariables,
|
||||
dynamicVariables,
|
||||
selection,
|
||||
setSelection,
|
||||
handleCreate,
|
||||
onClose,
|
||||
],
|
||||
);
|
||||
|
||||
return { hasFieldVariables: fieldVariables.length > 0, actions };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback } from 'react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
addFilterToQuery,
|
||||
getBaseMeta,
|
||||
isNumberDataType,
|
||||
} from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
interface UseDrilldownFilterArgs {
|
||||
/** The clicked point's context; the filter menu only appears for a group-column click. */
|
||||
context: DrilldownContext | null;
|
||||
/** The panel's V5→V1 query the filter is added to. */
|
||||
v1Query: Query;
|
||||
panelId: string;
|
||||
/** Panel's V1 type — the kind the refined query opens the View modal as. */
|
||||
panelType: PANEL_TYPES;
|
||||
/** Opens the View modal on the refined query, persisting it in the URL. */
|
||||
openViewWithQuery: (
|
||||
panelId: string,
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
) => void;
|
||||
/** Close the popover after opening the View modal. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface UseDrilldownFilterApi {
|
||||
/** True for a group-column click — the caller renders the filter-by-value menu. */
|
||||
isGroupColumnClick: boolean;
|
||||
/** Apply the chosen operator: add `key <op> value` and open the refined result in the View modal. */
|
||||
onFilter: (operator: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The group-column "filter by value" drill-down (V1 parity): adds `key <op> value` to the panel's
|
||||
* query and opens the refined result in the View modal. The caller renders `DrilldownFilterMenu`
|
||||
* (V1's read-only `getGroupContextMenuConfig`) from this hook's return.
|
||||
*/
|
||||
export function useDrilldownFilter({
|
||||
context,
|
||||
v1Query,
|
||||
panelId,
|
||||
panelType,
|
||||
openViewWithQuery,
|
||||
onClose,
|
||||
}: UseDrilldownFilterArgs): UseDrilldownFilterApi {
|
||||
const onFilter = useCallback(
|
||||
(operator: string): void => {
|
||||
if (!context?.clickedKey) {
|
||||
return;
|
||||
}
|
||||
let filterValue: string | number = context.clickedValue ?? '';
|
||||
const baseMeta = getBaseMeta(v1Query, context.clickedKey);
|
||||
if (baseMeta && isNumberDataType(baseMeta.dataType) && filterValue !== '') {
|
||||
filterValue = Number(filterValue);
|
||||
}
|
||||
const refinedQuery = addFilterToQuery(v1Query, [
|
||||
{ filterKey: context.clickedKey, filterValue, operator },
|
||||
]);
|
||||
openViewWithQuery(panelId, refinedQuery, panelType);
|
||||
onClose();
|
||||
},
|
||||
[context, v1Query, panelType, panelId, openViewWithQuery, onClose],
|
||||
);
|
||||
|
||||
const isGroupColumnClick =
|
||||
!!context && context.columnKind === 'group' && !!context.clickedKey;
|
||||
|
||||
return { isGroupColumnClick, onFilter };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useReplaceVariables } from 'api/generated/services/querier';
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { buildQueryRangeRequest } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
|
||||
import { envelopesToQuery } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import { selectResolvedVariables } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
interface UseResolvedDrilldownQueryArgs {
|
||||
/** Panel's perses queries — the substitution source (carries the `$var` refs). */
|
||||
queries: DashboardtypesQueryDTO[];
|
||||
panelType: PANEL_TYPES;
|
||||
/** The raw V5→V1 query; the fallback until substitution resolves / when no vars exist. */
|
||||
v1Query: Query;
|
||||
/** Resolve only while the aggregate menu is open (V1 parity: fires when it appears). */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface UseResolvedDrilldownQueryResult {
|
||||
/** The variable-substituted query for View-in-X navigation. */
|
||||
resolvedQuery: Query;
|
||||
/** True while the round-trip is in flight — View-in-X shows a spinner and is disabled. */
|
||||
isResolving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the panel's dashboard-variable references (`$var`) into concrete values before
|
||||
* View in Logs/Traces builds the explorer URL — V1 parity (`useBaseAggregateOptions` runs the
|
||||
* same `/substitute_vars` round-trip). Skipped when the dashboard has no selections: the raw
|
||||
* query already carries the refs verbatim and the round-trip would be a no-op. Mirrors the
|
||||
* V2-native path in {@link useCreateAlertFromPanel}.
|
||||
*/
|
||||
export function useResolvedDrilldownQuery({
|
||||
queries,
|
||||
panelType,
|
||||
v1Query,
|
||||
enabled,
|
||||
}: UseResolvedDrilldownQueryArgs): UseResolvedDrilldownQueryResult {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const { mutate: substituteVars, data, isLoading } = useReplaceVariables();
|
||||
|
||||
const hasVariables = Object.keys(variables).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !hasVariables) {
|
||||
return;
|
||||
}
|
||||
// Redux global time is nanoseconds; the request DTO takes integer epoch ms — the
|
||||
// backend rejects fractional bounds, so floor after the ns→ms divide.
|
||||
substituteVars({
|
||||
data: buildQueryRangeRequest({
|
||||
queries,
|
||||
panelType,
|
||||
startMs: Math.floor(minTime / 1e6),
|
||||
endMs: Math.floor(maxTime / 1e6),
|
||||
variables,
|
||||
}),
|
||||
});
|
||||
}, [
|
||||
enabled,
|
||||
hasVariables,
|
||||
queries,
|
||||
panelType,
|
||||
minTime,
|
||||
maxTime,
|
||||
variables,
|
||||
substituteVars,
|
||||
]);
|
||||
|
||||
const resolvedQuery = useMemo(() => {
|
||||
if (!hasVariables || !data) {
|
||||
return v1Query;
|
||||
}
|
||||
return envelopesToQuery(data.data.compositeQuery?.queries ?? [], panelType);
|
||||
}, [hasVariables, data, v1Query, panelType]);
|
||||
|
||||
return { resolvedQuery, isResolving: enabled && hasVariables && isLoading };
|
||||
}
|
||||
@@ -1,22 +1,33 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface UseViewPanelApi {
|
||||
/** Panel id currently expanded in the View modal; null when none is open. */
|
||||
expandedPanelId: string | null;
|
||||
/** Open the View modal on the saved panel (clears any leftover in-modal query/kind). */
|
||||
openView: (panelId: string) => void;
|
||||
/** Close the View modal by clearing the URL param. */
|
||||
/**
|
||||
* Open the View modal pre-seeded with a drilldown query + kind, persisted in the URL so it
|
||||
* survives refresh (V1 parity); the modal hydrates its draft from these on mount.
|
||||
*/
|
||||
openViewWithQuery: (
|
||||
panelId: string,
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
) => void;
|
||||
/** Close the View modal by clearing its URL params. */
|
||||
closeView: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the panel View modal off the `expandedWidgetId` URL param (V1 parity):
|
||||
* the open state is shareable, survives refresh, and the browser back-button
|
||||
* closes it. Reuses V1's param key so a deep-linked V1 URL maps cleanly.
|
||||
* Drives the panel View modal off the URL (V1 parity): `expandedWidgetId` holds the open
|
||||
* panel, and a drilldown additionally seeds `compositeQuery` + `graphType`. URL-backed state
|
||||
* is shareable, survives refresh, and the browser back-button closes it.
|
||||
*/
|
||||
export function useViewPanel(): UseViewPanelApi {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
@@ -39,6 +50,22 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
[pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
const openViewWithQuery = useCallback(
|
||||
(panelId: string, query: Query, panelType: PANEL_TYPES): void => {
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.set(QueryParams.expandedWidgetId, panelId);
|
||||
next.set(QueryParams.graphType, panelType);
|
||||
// Same encoding the query builder uses (see `useGetCompositeQueryParam`): the URL
|
||||
// value is `encodeURIComponent(JSON.stringify(query))`, decoded once on read.
|
||||
next.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
safeNavigate(`${pathname}?${next.toString()}`);
|
||||
},
|
||||
[pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
const closeView = useCallback((): void => {
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.delete(QueryParams.expandedWidgetId);
|
||||
@@ -50,5 +77,5 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
safeNavigate(search ? `${pathname}?${search}` : pathname);
|
||||
}, [pathname, safeNavigate, urlQuery]);
|
||||
|
||||
return { expandedPanelId, openView, closeView };
|
||||
return { expandedPanelId, openView, openViewWithQuery, closeView };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { calculatePopoverPosition } from '../calculatePopoverPosition';
|
||||
|
||||
// Popover is 300×254 with a 10px offset; these are the edges the placement flips against.
|
||||
const setViewport = (width: number, height: number): void => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: width,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, 'innerHeight', {
|
||||
value: height,
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
|
||||
describe('calculatePopoverPosition', () => {
|
||||
beforeEach(() => setViewport(1920, 1080));
|
||||
|
||||
it('anchors to the right of the click when there is room', () => {
|
||||
expect(calculatePopoverPosition({ x: 100, y: 100 })).toStrictEqual({
|
||||
left: 110,
|
||||
top: 90,
|
||||
placement: 'right',
|
||||
});
|
||||
});
|
||||
|
||||
it('flips to the left near the right edge', () => {
|
||||
const { left, placement } = calculatePopoverPosition({ x: 1900, y: 100 });
|
||||
expect(placement).toBe('left');
|
||||
expect(left).toBe(1900 - 300 + 10);
|
||||
});
|
||||
|
||||
it('clamps back in when the left flip would overflow a narrow viewport', () => {
|
||||
setViewport(320, 1080);
|
||||
// Right flip fires (60 + 300 > 320) but its left (50 - 300 + 10) is off-screen, so it clamps.
|
||||
expect(calculatePopoverPosition({ x: 50, y: 100 })).toMatchObject({
|
||||
left: 10,
|
||||
placement: 'right',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops below the click near the top edge', () => {
|
||||
expect(calculatePopoverPosition({ x: 100, y: 2 })).toMatchObject({
|
||||
top: 10,
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
});
|
||||
|
||||
it('lifts above the click near the bottom edge', () => {
|
||||
const { top, placement } = calculatePopoverPosition({ x: 100, y: 1070 });
|
||||
expect(placement).toBe('topRight');
|
||||
expect(top).toBe(1080 - 254 - 10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
Coordinates,
|
||||
PopoverPosition,
|
||||
} from 'periscope/components/ContextMenu';
|
||||
|
||||
// Kept in sync with the popover's `overlayStyle` in `ContextMenu` (width 300, maxHeight 254).
|
||||
const POPOVER_WIDTH = 300;
|
||||
const POPOVER_HEIGHT = 254;
|
||||
const OFFSET = 10;
|
||||
|
||||
/**
|
||||
* Places the drill-down popover next to the clicked point, flipping/clamping so it stays within the
|
||||
* viewport. Anchors to the right by default and mirrors to the left (and top/bottom) near an edge.
|
||||
*/
|
||||
export function calculatePopoverPosition({
|
||||
x,
|
||||
y,
|
||||
}: Coordinates): PopoverPosition {
|
||||
const { innerWidth: windowWidth, innerHeight: windowHeight } = window;
|
||||
|
||||
let left = x + OFFSET;
|
||||
let top = y - OFFSET;
|
||||
let placement: PopoverPosition['placement'] = 'right';
|
||||
|
||||
if (left + POPOVER_WIDTH > windowWidth) {
|
||||
left = x - POPOVER_WIDTH + OFFSET;
|
||||
placement = 'left';
|
||||
}
|
||||
|
||||
if (left < 0) {
|
||||
left = OFFSET;
|
||||
placement = 'right';
|
||||
}
|
||||
|
||||
if (top < 0) {
|
||||
top = OFFSET;
|
||||
placement = placement === 'right' ? 'bottomRight' : 'bottomLeft';
|
||||
}
|
||||
|
||||
if (top + POPOVER_HEIGHT > windowHeight) {
|
||||
top = windowHeight - POPOVER_HEIGHT - OFFSET;
|
||||
placement = placement === 'right' ? 'topRight' : 'topLeft';
|
||||
}
|
||||
|
||||
return { left, top, placement };
|
||||
}
|
||||
@@ -30,7 +30,9 @@ function PanelsAndSectionsLayout({
|
||||
|
||||
// Single View-modal host for the whole dashboard, driven by the URL
|
||||
// (`expandedWidgetId`). One mounted modal beats one-per-panel: no N location
|
||||
// subscriptions, and the expanded panel is looked up by id from the map.
|
||||
// subscriptions, and the expanded panel is looked up by id from the map. A
|
||||
// drilldown refinement rides in the URL (`compositeQuery`/`graphType`) and is
|
||||
// hydrated inside the modal, so the host just hands it the saved panel.
|
||||
const { expandedPanelId, closeView } = useViewPanel();
|
||||
const expandedPanel = expandedPanelId ? panels[expandedPanelId] : undefined;
|
||||
|
||||
|
||||
@@ -20,11 +20,12 @@ import {
|
||||
export const ALL_SELECTED = '__ALL__';
|
||||
|
||||
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
|
||||
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
|
||||
(v) =>
|
||||
typeof v === 'object' && v !== null
|
||||
? (v as Record<string, SelectedVariableValue>)
|
||||
: null,
|
||||
export const variablesUrlParser = parseAsJson<
|
||||
Record<string, SelectedVariableValue>
|
||||
>((v) =>
|
||||
typeof v === 'object' && v !== null
|
||||
? (v as Record<string, SelectedVariableValue>)
|
||||
: null,
|
||||
);
|
||||
|
||||
function defaultSelection(model: VariableFormModel): VariableSelection {
|
||||
|
||||
@@ -38,22 +38,14 @@ function DashboardContainer({
|
||||
user.role,
|
||||
);
|
||||
|
||||
// Publish edit context to the store so hooks/components read it from there
|
||||
// instead of receiving dashboardId/isEditable/refetch as props down the tree.
|
||||
// Seed during render (not an effect) so the first Panel render already sees the id —
|
||||
// useDashboardFetchRequired throws on a missing id. setEditContext self-guards.
|
||||
const setEditContext = useDashboardStore((s) => s.setEditContext);
|
||||
useEffect(() => {
|
||||
setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
isEditable: !dashboard.locked && editDashboardPermission,
|
||||
refetch,
|
||||
});
|
||||
}, [
|
||||
dashboard.id,
|
||||
dashboard.locked,
|
||||
editDashboardPermission,
|
||||
setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
isEditable: !dashboard.locked && editDashboardPermission,
|
||||
refetch,
|
||||
setEditContext,
|
||||
]);
|
||||
});
|
||||
|
||||
// Resolve the variable selection into the V5 query payload and publish it to
|
||||
// the store, so each panel's query substitutes the bar's selected values.
|
||||
|
||||
@@ -24,11 +24,20 @@ export const createEditContextSlice: StateCreator<
|
||||
[['zustand/persist', unknown]],
|
||||
[],
|
||||
EditContextSlice
|
||||
> = (set) => ({
|
||||
> = (set, get) => ({
|
||||
dashboardId: '',
|
||||
isEditable: false,
|
||||
refetch: (): void => undefined,
|
||||
// Idempotent (no-op when unchanged) so it's safe to call during render.
|
||||
setEditContext: (ctx): void => {
|
||||
const { dashboardId, isEditable, refetch } = get();
|
||||
if (
|
||||
dashboardId === ctx.dashboardId &&
|
||||
isEditable === ctx.isEditable &&
|
||||
refetch === ctx.refetch
|
||||
) {
|
||||
return;
|
||||
}
|
||||
set({
|
||||
dashboardId: ctx.dashboardId,
|
||||
isEditable: ctx.isEditable,
|
||||
|
||||
@@ -130,27 +130,6 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
|
||||
color: var(--bg-sienna-400);
|
||||
text-align: center;
|
||||
font-family: Inter;
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
|
||||
.details {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import TagBadge from 'components/TagBadge/TagBadge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -105,14 +105,10 @@ function DashboardRow({
|
||||
{tags.length > 0 && (
|
||||
<div className={styles.tags}>
|
||||
{tags.slice(0, 3).map((tag) => (
|
||||
<Badge className={styles.tag} key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<Badge className={styles.tag} key={tags[3]}>
|
||||
+ <Typography.Text> {tags.length - 3} </Typography.Text>
|
||||
</Badge>
|
||||
<TagBadge key={tags[3]}>+{tags.length - 3}</TagBadge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user