mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-09 08:00:42 +01:00
Compare commits
10 Commits
issue_5601
...
platform-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3de95472ec | ||
|
|
ff3ec133b4 | ||
|
|
0ea5399c15 | ||
|
|
f7aa3cee9c | ||
|
|
f2af5b3697 | ||
|
|
d5e72145f9 | ||
|
|
3ac9e1d6e1 | ||
|
|
73aa7d32b1 | ||
|
|
8cbdf311af | ||
|
|
81995f54bb |
@@ -543,6 +543,31 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
AuthtypesGettableRole:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- description
|
||||
- type
|
||||
- orgId
|
||||
type: object
|
||||
AuthtypesGettableToken:
|
||||
properties:
|
||||
accessToken:
|
||||
@@ -694,43 +719,6 @@ components:
|
||||
- detach
|
||||
type: string
|
||||
AuthtypesRole:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- description
|
||||
- type
|
||||
- orgId
|
||||
type: object
|
||||
AuthtypesRoleMapping:
|
||||
properties:
|
||||
defaultRole:
|
||||
type: string
|
||||
groupMappings:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
useRoleAttribute:
|
||||
type: boolean
|
||||
type: object
|
||||
AuthtypesRoleWithTransactionGroups:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
@@ -758,6 +746,18 @@ components:
|
||||
- orgId
|
||||
- transactionGroups
|
||||
type: object
|
||||
AuthtypesRoleMapping:
|
||||
properties:
|
||||
defaultRole:
|
||||
type: string
|
||||
groupMappings:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
useRoleAttribute:
|
||||
type: boolean
|
||||
type: object
|
||||
AuthtypesSamlConfig:
|
||||
properties:
|
||||
attributeMapping:
|
||||
@@ -11828,7 +11828,7 @@ paths:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
$ref: '#/components/schemas/AuthtypesGettableRole'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
@@ -12012,7 +12012,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
|
||||
@@ -119,10 +119,6 @@ func (provider *provider) CheckTransactions(ctx context.Context, subject string,
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
|
||||
return provider.openfgaServer.ListObjects(ctx, subject, relation, objectType)
|
||||
}
|
||||
|
||||
func (provider *provider) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
|
||||
return provider.openfgaServer.Write(ctx, additions, deletions)
|
||||
}
|
||||
@@ -131,10 +127,6 @@ func (provider *provider) ReadTuples(ctx context.Context, tupleKey *openfgav1.Re
|
||||
return provider.openfgaServer.ReadTuples(ctx, tupleKey)
|
||||
}
|
||||
|
||||
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
|
||||
return provider.pkgAuthzService.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
|
||||
return provider.pkgAuthzService.GetByOrgIDAndName(ctx, orgID, name)
|
||||
}
|
||||
@@ -183,7 +175,7 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
|
||||
return provider.Write(ctx, tuples, nil)
|
||||
}
|
||||
|
||||
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.RoleWithTransactionGroups) error {
|
||||
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
@@ -208,70 +200,31 @@ func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *a
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.store.Create(ctx, role.Role); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return provider.store.Create(ctx, role)
|
||||
}
|
||||
|
||||
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) (*authtypes.Role, error) {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
existingRole, err := provider.store.GetByOrgIDAndName(ctx, role.OrgID, role.Name)
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if existingRole != nil {
|
||||
return existingRole, nil
|
||||
}
|
||||
|
||||
err = provider.store.Create(ctx, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return role, nil
|
||||
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
|
||||
return provider.store.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
role, err := provider.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transactionGroups := authtypes.MustNewTransactionGroupsFromTuples(tuples)
|
||||
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
|
||||
}
|
||||
|
||||
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
|
||||
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.Role) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
existingRole, err := provider.GetWithTransactionGroups(ctx, orgID, updatedRole.ID)
|
||||
existingRole, err := provider.Get(ctx, orgID, updatedRole.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
additions, deletions := existingRole.TransactionGroups.Diff(updatedRole.TransactionGroups)
|
||||
existingTuples, err := provider.readAllTuplesForRole(ctx, existingRole.Name, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingGroups := authtypes.MustNewTransactionGroupsFromTuples(existingTuples)
|
||||
additions, deletions := existingGroups.Diff(updatedRole.TransactionGroups)
|
||||
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -287,7 +240,7 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
|
||||
return err
|
||||
}
|
||||
|
||||
return provider.store.Update(ctx, orgID, updatedRole.Role)
|
||||
return provider.store.Update(ctx, orgID, updatedRole)
|
||||
}
|
||||
|
||||
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
@@ -296,7 +249,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
role, err := provider.GetWithTransactionGroups(ctx, orgID, id)
|
||||
role, err := provider.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -312,7 +265,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
}
|
||||
}
|
||||
|
||||
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
|
||||
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -324,6 +277,24 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
return provider.store.Delete(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
|
||||
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
|
||||
|
||||
tuples := make([]*openfgav1.TupleKey, 0)
|
||||
for _, objectType := range provider.registry.Types() {
|
||||
typeTuples, err := provider.openfgaServer.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
|
||||
User: subject,
|
||||
Object: objectType.StringValue() + ":",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tuples = append(tuples, typeTuples...)
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) []*openfgav1.TupleKey {
|
||||
tuples := []*openfgav1.TupleKey{}
|
||||
|
||||
@@ -375,21 +346,3 @@ func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*
|
||||
|
||||
return tuples
|
||||
}
|
||||
|
||||
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
|
||||
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
|
||||
|
||||
tuples := make([]*openfgav1.TupleKey, 0)
|
||||
for _, objectType := range provider.registry.Types() {
|
||||
typeTuples, err := provider.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
|
||||
User: subject,
|
||||
Object: objectType.StringValue() + ":",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tuples = append(tuples, typeTuples...)
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
@@ -105,10 +105,6 @@ func (server *Server) BatchCheck(ctx context.Context, tupleReq map[string]*openf
|
||||
return server.pkgAuthzService.BatchCheck(ctx, tupleReq)
|
||||
}
|
||||
|
||||
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
|
||||
return server.pkgAuthzService.ListObjects(ctx, subject, relation, objectType)
|
||||
}
|
||||
|
||||
func (server *Server) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
|
||||
return server.pkgAuthzService.Write(ctx, additions, deletions)
|
||||
}
|
||||
|
||||
@@ -2082,6 +2082,39 @@ export interface AuthtypesGettableAuthDomainDTO {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableTokenDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2325,39 +2358,6 @@ export interface AuthtypesPostableUserRoleDTO {
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleWithTransactionGroupsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -10399,7 +10399,7 @@ export type ListRoles200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: AuthtypesRoleDTO[];
|
||||
data: AuthtypesGettableRoleDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -10421,7 +10421,7 @@ export type GetRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetRole200 = {
|
||||
data: AuthtypesRoleWithTransactionGroupsDTO;
|
||||
data: AuthtypesRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useListRoles } from 'api/generated/services/role';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import APIError from 'types/api/error';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
@@ -16,7 +16,7 @@ export interface RoleOption {
|
||||
}
|
||||
|
||||
export function useRoles(): {
|
||||
roles: AuthtypesRoleDTO[];
|
||||
roles: AuthtypesGettableRoleDTO[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: APIError | undefined;
|
||||
@@ -33,7 +33,7 @@ export function useRoles(): {
|
||||
}
|
||||
|
||||
export function getRoleOptions(
|
||||
roles: AuthtypesRoleDTO[],
|
||||
roles: AuthtypesGettableRoleDTO[],
|
||||
valueField: 'id' | 'name',
|
||||
): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
@@ -79,7 +79,7 @@ interface BaseProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
|
||||
roles?: AuthtypesRoleDTO[];
|
||||
roles?: AuthtypesGettableRoleDTO[];
|
||||
loading?: boolean;
|
||||
isError?: boolean;
|
||||
error?: APIError;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
@@ -24,7 +24,7 @@ interface OverviewTabProps {
|
||||
onRolesChange: (v: string[]) => void;
|
||||
isDisabled: boolean;
|
||||
canUpdate?: boolean;
|
||||
availableRoles: AuthtypesRoleDTO[];
|
||||
availableRoles: AuthtypesGettableRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
rolesError?: boolean;
|
||||
rolesErrorObj?: APIError | undefined;
|
||||
|
||||
@@ -65,7 +65,9 @@ function TagKeyValueInput({
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
// Plain Enter adds the tag; let Cmd/Ctrl+Enter pass through so a host form
|
||||
// (e.g. a modal) can submit on it.
|
||||
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
@@ -93,11 +95,17 @@ function TagKeyValueInput({
|
||||
};
|
||||
|
||||
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
// Plain Enter commits the edit; let Cmd/Ctrl+Enter pass through so a host
|
||||
// form (e.g. a modal) can submit on it.
|
||||
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
commitEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
// Contain Escape so it cancels the inline edit instead of bubbling up and
|
||||
// closing the host drawer/modal.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.wrapper :global(button[data-color]) {
|
||||
--checkbox-checked-background: var(--series-color);
|
||||
--checkbox-border-color: var(--series-color);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import { CheckBoxProps } from '../types';
|
||||
import styles from './CustomCheckBox.module.scss';
|
||||
|
||||
function CustomCheckBox({
|
||||
data,
|
||||
@@ -15,12 +16,11 @@ function CustomCheckBox({
|
||||
const isChecked = graphVisibilityState[index] || false;
|
||||
|
||||
const colorStyle = {
|
||||
'--checkbox-checked-background': color,
|
||||
'--checkbox-border-color': color,
|
||||
'--series-color': color,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<span style={colorStyle}>
|
||||
<span className={styles.wrapper} style={colorStyle}>
|
||||
<Checkbox
|
||||
onChange={(checked): void => checkBoxOnChangeHandler(checked, index)}
|
||||
value={isChecked}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -17,6 +22,13 @@ import dialsUrl from '@/assets/Icons/dials.svg';
|
||||
|
||||
import { getItemIcon } from '../constants';
|
||||
|
||||
// The five most-recent dashboards, normalised across the v1 and v2 list APIs.
|
||||
interface RecentDashboard {
|
||||
id: string;
|
||||
title: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export default function Dashboards({
|
||||
onUpdateChecklistDoneItem,
|
||||
loadingUserPreferences,
|
||||
@@ -26,33 +38,58 @@ export default function Dashboards({
|
||||
}): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { user } = useAppContext();
|
||||
const isDashboardV2 = useIsDashboardV2();
|
||||
|
||||
const [sortedDashboards, setSortedDashboards] = useState<Dashboard[]>([]);
|
||||
|
||||
// Fetch Dashboards
|
||||
// Fetch the recent dashboards from whichever API the `use_dashboard_v2` flag
|
||||
// selects; the inactive one stays disabled so it never fires.
|
||||
const {
|
||||
data: dashboardsList,
|
||||
isLoading: isDashboardListLoading,
|
||||
isError: isDashboardListError,
|
||||
} = useGetAllDashboard();
|
||||
data: v1List,
|
||||
isLoading: v1Loading,
|
||||
isError: v1Error,
|
||||
} = useGetAllDashboard({ enabled: !isDashboardV2 });
|
||||
|
||||
const {
|
||||
data: v2List,
|
||||
isLoading: v2Loading,
|
||||
isError: v2Error,
|
||||
} = useListDashboardsForUserV2(
|
||||
{
|
||||
sort: DashboardtypesListSortDTO.updated_at,
|
||||
order: DashboardtypesListOrderDTO.desc,
|
||||
limit: 5,
|
||||
offset: 0,
|
||||
},
|
||||
{ query: { enabled: isDashboardV2 } },
|
||||
);
|
||||
|
||||
const isDashboardListLoading = isDashboardV2 ? v2Loading : v1Loading;
|
||||
const isDashboardListError = isDashboardV2 ? v2Error : v1Error;
|
||||
|
||||
const sortedDashboards = useMemo<RecentDashboard[]>(() => {
|
||||
if (isDashboardV2) {
|
||||
return (v2List?.data?.dashboards ?? []).map((d) => ({
|
||||
id: d.id,
|
||||
title: d.spec?.display?.name ?? d.name,
|
||||
tags: (d.tags ?? []).map((t) => (t.value ? `${t.key}:${t.value}` : t.key)),
|
||||
}));
|
||||
}
|
||||
return [...(v1List?.data ?? [])]
|
||||
.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
)
|
||||
.slice(0, 5)
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.data.title,
|
||||
tags: d.data.tags ?? [],
|
||||
}));
|
||||
}, [isDashboardV2, v1List, v2List]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardsList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedDashboards = dashboardsList.data.sort((a, b) => {
|
||||
const aUpdateAt = new Date(a.updatedAt).getTime();
|
||||
const bUpdateAt = new Date(b.updatedAt).getTime();
|
||||
return bUpdateAt - aUpdateAt;
|
||||
});
|
||||
|
||||
if (sortedDashboards.length > 0 && !loadingUserPreferences) {
|
||||
onUpdateChecklistDoneItem('SETUP_DASHBOARDS');
|
||||
}
|
||||
|
||||
setSortedDashboards(sortedDashboards.slice(0, 5));
|
||||
}, [dashboardsList, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
}, [sortedDashboards, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
|
||||
const emptyStateCard = (): JSX.Element => (
|
||||
<div className="empty-state-container">
|
||||
@@ -113,7 +150,7 @@ export default function Dashboards({
|
||||
event.stopPropagation();
|
||||
logEvent('Homepage: Dashboard clicked', {
|
||||
dashboardId: dashboard.id,
|
||||
dashboardName: dashboard.data.title,
|
||||
dashboardName: dashboard.title,
|
||||
});
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
openInNewTab(getLink());
|
||||
@@ -143,12 +180,12 @@ export default function Dashboards({
|
||||
/>
|
||||
|
||||
<div className="alert-rule-item-name home-data-item-name">
|
||||
{dashboard.data.title}
|
||||
{dashboard.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-rule-item-description home-data-item-tag">
|
||||
{dashboard.data.tags?.map((tag) => (
|
||||
{dashboard.tags.map((tag) => (
|
||||
<Badge color="sienna" variant="outline" key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom';
|
||||
import cx from 'classnames';
|
||||
import { Pagination, Skeleton } from 'antd';
|
||||
import { useListRoles } from 'api/generated/services/role';
|
||||
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -22,7 +22,7 @@ const PAGE_SIZE = 20;
|
||||
|
||||
type DisplayItem =
|
||||
| { type: 'section'; label: string; count?: number }
|
||||
| { type: 'role'; role: AuthtypesRoleDTO };
|
||||
| { type: 'role'; role: AuthtypesGettableRoleDTO };
|
||||
|
||||
interface RolesListingTableProps {
|
||||
searchQuery: string;
|
||||
@@ -190,7 +190,7 @@ function RolesListingTable({
|
||||
);
|
||||
}
|
||||
|
||||
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
|
||||
const renderRow = (role: AuthtypesGettableRoleDTO): JSX.Element => (
|
||||
<div
|
||||
key={role.id}
|
||||
className={cx(styles.tableRow, {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from 'react-query';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import type {
|
||||
AuthtypesPostableRoleDTO,
|
||||
AuthtypesRoleWithTransactionGroupsDTO,
|
||||
AuthtypesRoleDTO,
|
||||
AuthtypesTransactionGroupDTO,
|
||||
AuthtypesUpdatableRoleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -133,7 +133,7 @@ export function transformTransactionGroupsToResourcePermissions(
|
||||
}
|
||||
|
||||
export function transformApiToRolePermissions(
|
||||
role: AuthtypesRoleWithTransactionGroupsDTO,
|
||||
role: AuthtypesRoleDTO,
|
||||
): RolePermissionsData {
|
||||
return {
|
||||
roleId: role.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
getGetRolesByUserIDQueryKey,
|
||||
useGetRolesByUserID,
|
||||
@@ -21,11 +21,11 @@ export interface MemberRoleUpdateFailure {
|
||||
}
|
||||
|
||||
interface UseMemberRoleManagerResult {
|
||||
currentRoles: AuthtypesRoleDTO[];
|
||||
currentRoles: AuthtypesGettableRoleDTO[];
|
||||
isLoading: boolean;
|
||||
applyDiff: (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
availableRoles: AuthtypesGettableRoleDTO[],
|
||||
) => Promise<MemberRoleUpdateFailure[]>;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useMemberRoleManager(
|
||||
{ query: { enabled: !!userId && enabled } },
|
||||
);
|
||||
|
||||
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
|
||||
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
|
||||
() => data?.data ?? [],
|
||||
[data?.data],
|
||||
);
|
||||
@@ -61,7 +61,7 @@ export function useMemberRoleManager(
|
||||
const applyDiff = useCallback(
|
||||
async (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
availableRoles: AuthtypesGettableRoleDTO[],
|
||||
): Promise<MemberRoleUpdateFailure[]> => {
|
||||
const currentRoleIds = new Set(
|
||||
currentRoles.map((r) => r.id).filter(Boolean),
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useDeleteServiceAccountRoleDeprecated,
|
||||
useGetServiceAccountRoles,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { retryOn429 } from 'utils/errorUtils';
|
||||
|
||||
const enum PromiseStatus {
|
||||
@@ -21,11 +21,11 @@ export interface RoleUpdateFailure {
|
||||
}
|
||||
|
||||
interface UseServiceAccountRoleManagerResult {
|
||||
currentRoles: AuthtypesRoleDTO[];
|
||||
currentRoles: AuthtypesGettableRoleDTO[];
|
||||
isLoading: boolean;
|
||||
applyDiff: (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
availableRoles: AuthtypesGettableRoleDTO[],
|
||||
) => Promise<RoleUpdateFailure[]>;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useServiceAccountRoleManager(
|
||||
{ query: { enabled: options?.enabled ?? true } },
|
||||
);
|
||||
|
||||
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
|
||||
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
|
||||
() => data?.data ?? [],
|
||||
[data?.data],
|
||||
);
|
||||
@@ -64,7 +64,7 @@ export function useServiceAccountRoleManager(
|
||||
const applyDiff = useCallback(
|
||||
async (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
availableRoles: AuthtypesGettableRoleDTO[],
|
||||
): Promise<RoleUpdateFailure[]> => {
|
||||
const currentRoleIds = new Set(
|
||||
currentRoles.map((r) => r.id).filter(Boolean),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
|
||||
export type Dimensions = {
|
||||
@@ -15,6 +15,15 @@ export function useResizeObserver<T extends HTMLElement>(
|
||||
height: ref.current?.clientHeight || 0,
|
||||
});
|
||||
|
||||
// Measure before paint so the first frame has real dimensions, not 0 (the
|
||||
// debounced observer below only catches up after paint → layout jump).
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
if (node) {
|
||||
setSize({ width: node.clientWidth, height: node.clientHeight });
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = debounce(
|
||||
(entries: ResizeObserverEntry[]) => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AuthtypesGettableRoleDTO,
|
||||
AuthtypesRoleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const orgId = '019ba2bb-2fa1-7b24-8159-cfca08617ef9';
|
||||
|
||||
export const managedRoles: AuthtypesRoleDTO[] = [
|
||||
export const managedRoles: AuthtypesGettableRoleDTO[] = [
|
||||
{
|
||||
id: '019c24aa-2248-756f-9833-984f1ab63819',
|
||||
createdAt: '2026-02-03T18:00:55.624356Z',
|
||||
@@ -35,7 +38,7 @@ export const managedRoles: AuthtypesRoleDTO[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const customRoles: AuthtypesRoleDTO[] = [
|
||||
export const customRoles: AuthtypesGettableRoleDTO[] = [
|
||||
{
|
||||
id: '019c24aa-3333-0001-aaaa-111111111111',
|
||||
createdAt: '2026-02-10T10:30:00.000Z',
|
||||
@@ -56,12 +59,24 @@ export const customRoles: AuthtypesRoleDTO[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const allRoles: AuthtypesRoleDTO[] = [...managedRoles, ...customRoles];
|
||||
export const allRoles: AuthtypesGettableRoleDTO[] = [
|
||||
...managedRoles,
|
||||
...customRoles,
|
||||
];
|
||||
|
||||
export const listRolesSuccessResponse = {
|
||||
status: 'success',
|
||||
data: allRoles,
|
||||
};
|
||||
|
||||
export const customRoleResponse = { status: 'success', data: customRoles[0] };
|
||||
export const managedRoleResponse = { status: 'success', data: managedRoles[0] };
|
||||
const customRole: AuthtypesRoleDTO = {
|
||||
...customRoles[0],
|
||||
transactionGroups: [],
|
||||
};
|
||||
const managedRole: AuthtypesRoleDTO = {
|
||||
...managedRoles[0],
|
||||
transactionGroups: [],
|
||||
};
|
||||
|
||||
export const customRoleResponse = { status: 'success', data: customRole };
|
||||
export const managedRoleResponse = { status: 'success', data: managedRole };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { KeyboardEvent } from 'react';
|
||||
import { type FocusEvent, KeyboardEvent } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Globe,
|
||||
@@ -90,12 +90,20 @@ function DashboardInfo({
|
||||
}
|
||||
};
|
||||
|
||||
// Clicking outside the editor commits, matching the input's Enter behaviour.
|
||||
// Guard against blurs that move focus to the Save/Cancel buttons within it.
|
||||
const onEditorBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
onCommit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardInfo}>
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
|
||||
{isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor}>
|
||||
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.trigger {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--l3-background);
|
||||
|
||||
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.options {
|
||||
min-width: min-content;
|
||||
}
|
||||
|
||||
.item {
|
||||
width: min-content;
|
||||
|
||||
span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@signozhq/ui/select';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
|
||||
import styles from './DashboardImagePicker.module.scss';
|
||||
|
||||
interface Props {
|
||||
// The selected image — one of the base64 icon data-URIs.
|
||||
image: string;
|
||||
onChange: (value: string) => void;
|
||||
// Consumers set the trigger's border-radius (e.g. rounded-left when joined to
|
||||
// a name input); this component owns size / background / icon-only styling.
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
// Icon picker shared by the dashboard-details settings and the create-dashboard
|
||||
// modal so both choose from the same `Base64Icons` set.
|
||||
function DashboardImagePicker({
|
||||
image,
|
||||
onChange,
|
||||
triggerClassName,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<Select value={image} onChange={(value): void => onChange(value as string)}>
|
||||
<SelectTrigger className={cx(styles.trigger, triggerClassName)} />
|
||||
<SelectContent className={styles.options} withPortal={false}>
|
||||
{Base64Icons.map((icon) => (
|
||||
<SelectItem key={icon} value={icon} className={styles.item}>
|
||||
<img src={icon} alt="dashboard-icon" className={styles.image} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardImagePicker;
|
||||
@@ -25,38 +25,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The image-picker owns its size/background; here we only round the left edge so
|
||||
// it joins flush with the name input to its right.
|
||||
.dashboardImageInput {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l3-background);
|
||||
|
||||
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardImageOptions {
|
||||
min-width: min-content;
|
||||
}
|
||||
|
||||
.dashboardImageSelectItem {
|
||||
width: min-content;
|
||||
|
||||
span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
|
||||
import { Input as AntdInput } from 'antd';
|
||||
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
import DashboardImagePicker from '../DashboardImagePicker/DashboardImagePicker';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
|
||||
import settingsStyles from '../../DashboardSettings.module.scss';
|
||||
import styles from './DashboardInfoForm.module.scss';
|
||||
@@ -43,30 +37,11 @@ function DashboardInfoForm({
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
value={image}
|
||||
onChange={(value): void => onImageChange(value as string)}
|
||||
>
|
||||
<SelectTrigger className={styles.dashboardImageInput} />
|
||||
<SelectContent
|
||||
className={styles.dashboardImageOptions}
|
||||
withPortal={false}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<SelectItem
|
||||
key={icon}
|
||||
value={icon}
|
||||
className={styles.dashboardImageSelectItem}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DashboardImagePicker
|
||||
image={image}
|
||||
onChange={onImageChange}
|
||||
triggerClassName={styles.dashboardImageInput}
|
||||
/>
|
||||
|
||||
<Input
|
||||
testId="dashboard-name"
|
||||
|
||||
@@ -83,7 +83,18 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
if (updatedImage !== image) {
|
||||
ops.push(replace('/image', updatedImage));
|
||||
// `replace` fails when the image doesn't exist yet, so add it when the
|
||||
// dashboard has none (`add` creates or replaces the member). Key off the
|
||||
// raw stored value, not the `Base64Icons[0]`-defaulted local `image`.
|
||||
ops.push(
|
||||
op(
|
||||
dashboard.image
|
||||
? DashboardtypesPatchOpDTO.replace
|
||||
: DashboardtypesPatchOpDTO.add,
|
||||
'/image',
|
||||
updatedImage,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!isEqual(updatedTags, tagsAsStrings)) {
|
||||
ops.push(replace('/tags', stringsToTags(updatedTags)));
|
||||
@@ -96,6 +107,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
description,
|
||||
updatedImage,
|
||||
image,
|
||||
dashboard.image,
|
||||
updatedTags,
|
||||
tagsAsStrings,
|
||||
]);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Pan
|
||||
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { LegendSeries } from '../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Input } from 'antd';
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import type { LegendSeries } from '../../../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../../../utils/legendSeries';
|
||||
import LegendColorRow from './LegendColorRow';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import LegendColors from '../LegendColors';
|
||||
|
||||
const SERIES: LegendSeries[] = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
filterLegendSeries,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { LegendSeries } from '../../../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../../../utils/legendSeries';
|
||||
|
||||
/** Case-insensitive substring filter over series labels. Empty query → all series. */
|
||||
export function filterLegendSeries(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelKind } from '../../Panels/types/panelKind';
|
||||
import type { LegendSeries } from '../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
|
||||
@@ -7,4 +7,9 @@
|
||||
|
||||
.dropdown {
|
||||
width: 260px;
|
||||
/* The dropdown portals to body; lift it above the query builder's antd
|
||||
* popups (1050) so it stays clickable, incl. inside the View modal.
|
||||
* TODO: remove after the antd -> @signozhq/ui migration
|
||||
**/
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ function AddColumnDropdown({
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent arrow side="top" align="end" className={styles.dropdown}>
|
||||
<ComboboxContent arrow side="bottom" align="end" className={styles.dropdown}>
|
||||
<ComboboxCommand shouldFilter={false}>
|
||||
<ComboboxInput
|
||||
value={searchText}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
padding: 12px;
|
||||
// Top padding lives on the sticky tab nav instead, so the nav keeps its
|
||||
// breathing room both at rest and when pinned to the top.
|
||||
padding: 0 12px 12px 12px;
|
||||
}
|
||||
|
||||
.tabsContainer {
|
||||
@@ -24,6 +26,15 @@
|
||||
background-color: var(--l1-background) !important;
|
||||
}
|
||||
:global(.ant-tabs-nav) {
|
||||
// Pin the query-type tabs + Run Query button to the top of the
|
||||
// `.container` scroll area while the query body scrolls underneath.
|
||||
// `padding-top` owns the nav's top spacing (moved off `.scrollArea`).
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
z-index: 1100;
|
||||
padding-top: 12px;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
&::before {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { preparePieData } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { flattenTimeSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
@@ -29,18 +30,36 @@ jest.mock(
|
||||
() => ({
|
||||
flattenTimeSeries: jest.fn(),
|
||||
getTimeSeriesResults: jest.fn(() => []),
|
||||
getScalarResults: jest.fn(() => []),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables',
|
||||
() => ({ prepareScalarTables: jest.fn(() => []) }),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData',
|
||||
() => ({ preparePieData: jest.fn(() => []) }),
|
||||
);
|
||||
|
||||
const mockUseIsDarkMode = useIsDarkMode as unknown as jest.Mock;
|
||||
const mockFlatten = flattenTimeSeries as unknown as jest.Mock;
|
||||
const mockResolveLabel = resolveSeriesLabelV5 as unknown as jest.Mock;
|
||||
const mockGenerateColor = generateColor as unknown as jest.Mock;
|
||||
const mockPreparePie = preparePieData as unknown as jest.Mock;
|
||||
|
||||
const PANEL = {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} }, queries: [] },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
const PIE_PANEL = {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/PieChartPanel', spec: {} }, queries: [] },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
const HISTOGRAM_PANEL = {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/HistogramPanel', spec: {} }, queries: [] },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
const DATA = { response: {}, legendMap: {} } as unknown as PanelQueryData;
|
||||
|
||||
// Each flattened series carries the label resolveSeriesLabelV5 should report.
|
||||
@@ -88,6 +107,32 @@ describe('useLegendSeries', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves histogram panels via the time-series path', () => {
|
||||
mockFlatten.mockReturnValue(seriesWithLabels(['a', 'b']));
|
||||
const { result } = renderHook(() => useLegendSeries(HISTOGRAM_PANEL, DATA));
|
||||
expect(result.current).toStrictEqual([
|
||||
{ label: 'a', defaultColor: 'color:a' },
|
||||
{ label: 'b', defaultColor: 'color:b' },
|
||||
]);
|
||||
// The pie path must not run for a histogram panel.
|
||||
expect(mockPreparePie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves pie panels from their scalar slices, deduped by label', () => {
|
||||
mockPreparePie.mockReturnValue([
|
||||
{ label: 'x', color: 'c1' },
|
||||
{ label: 'y', color: 'c2' },
|
||||
{ label: 'x', color: 'c1' },
|
||||
]);
|
||||
const { result } = renderHook(() => useLegendSeries(PIE_PANEL, DATA));
|
||||
expect(result.current).toStrictEqual([
|
||||
{ label: 'x', defaultColor: 'c1' },
|
||||
{ label: 'y', defaultColor: 'c2' },
|
||||
]);
|
||||
// The time-series path must not run for a pie panel.
|
||||
expect(mockFlatten).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the dark palette in dark mode and the light palette otherwise', () => {
|
||||
mockFlatten.mockReturnValue(seriesWithLabels(['a']));
|
||||
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
import { useMemo } from 'react';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
export interface LegendSeries {
|
||||
/** Resolved display label — the key `legend.customColors` is indexed by. */
|
||||
label: string;
|
||||
/** The series' auto-assigned color, shown when no override is set. */
|
||||
defaultColor: string;
|
||||
}
|
||||
import {
|
||||
type LegendSeries,
|
||||
resolvePieLegendSeries,
|
||||
resolveTimeSeriesLegendSeries,
|
||||
} from '../utils/legendSeries';
|
||||
|
||||
/**
|
||||
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs, using the
|
||||
* exact label resolution the time-series renderer applies (`flattenTimeSeries` →
|
||||
* `resolveSeriesLabelV5`) and the same `generateColor` default. The legend-colors control
|
||||
* keys overrides by these labels, so they must match what the chart draws. Deduplicated,
|
||||
* order-preserving; empty until data arrives or for kinds without flat time-series data.
|
||||
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs so the
|
||||
* legend-colors control can key overrides by the exact labels the chart draws. Only the
|
||||
* kinds that expose a colors control resolve series (Pie from its scalar slices, Time
|
||||
* Series from its flat series); every other kind returns none.
|
||||
*/
|
||||
export function useLegendSeries(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
@@ -33,27 +22,15 @@ export function useLegendSeries(
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return useMemo(() => {
|
||||
const palette = isDarkMode
|
||||
? themeColors.chartcolors
|
||||
: themeColors.lightModeColor;
|
||||
const series = flattenTimeSeries(
|
||||
getTimeSeriesResults(data?.response),
|
||||
data.legendMap,
|
||||
);
|
||||
const builderQueries = getBuilderQueries(panel.spec.queries);
|
||||
|
||||
const byLabel = new Map<string, string>();
|
||||
series.forEach((s) => {
|
||||
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
|
||||
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
|
||||
if (label && !byLabel.has(label)) {
|
||||
byLabel.set(label, generateColor(label, palette));
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(byLabel, ([label, defaultColor]) => ({
|
||||
label,
|
||||
defaultColor,
|
||||
}));
|
||||
}, [panel.spec.queries, data.response, data.legendMap, isDarkMode]);
|
||||
switch (panel.spec.plugin.kind) {
|
||||
case 'signoz/PieChartPanel':
|
||||
return resolvePieLegendSeries(data, isDarkMode);
|
||||
case 'signoz/TimeSeriesPanel':
|
||||
case 'signoz/BarChartPanel':
|
||||
case 'signoz/HistogramPanel':
|
||||
return resolveTimeSeriesLegendSeries(panel.spec.queries, data, isDarkMode);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}, [panel.spec.plugin.kind, panel.spec.queries, data, isDarkMode]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { preparePieData } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getScalarResults,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
export interface LegendSeries {
|
||||
/** Resolved display label — the key `legend.customColors` is indexed by. */
|
||||
label: string;
|
||||
/** The series' auto-assigned color, shown when no override is set. */
|
||||
defaultColor: string;
|
||||
}
|
||||
|
||||
type PanelQueries = DashboardtypesPanelDTO['spec']['queries'];
|
||||
|
||||
/**
|
||||
* Dedupes `labels` (first-seen order, empties dropped) into `{ label, defaultColor }`
|
||||
* pairs, resolving each unique label's color lazily via `colorFor` — so a repeated
|
||||
* label never resolves a second color.
|
||||
*/
|
||||
function buildLegendSeries(
|
||||
labels: readonly string[],
|
||||
colorFor: (label: string, index: number) => string,
|
||||
): LegendSeries[] {
|
||||
const byLabel = new Map<string, string>();
|
||||
labels.forEach((label, index) => {
|
||||
if (label && !byLabel.has(label)) {
|
||||
byLabel.set(label, colorFor(label, index));
|
||||
}
|
||||
});
|
||||
return Array.from(byLabel, ([label, defaultColor]) => ({
|
||||
label,
|
||||
defaultColor,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pie is fed by scalar results, not time series. Reuse the exact slices the renderer
|
||||
* draws (without overrides, so their colors are the defaults) so the color control keys
|
||||
* overrides by the same labels the chart does.
|
||||
*/
|
||||
export function resolvePieLegendSeries(
|
||||
data: PanelQueryData,
|
||||
isDarkMode: boolean,
|
||||
): LegendSeries[] {
|
||||
const slices = preparePieData({
|
||||
tables: prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap,
|
||||
requestPayload: data.requestPayload,
|
||||
}),
|
||||
isDarkMode,
|
||||
});
|
||||
return buildLegendSeries(
|
||||
slices.map((slice) => slice.label),
|
||||
(_, index) => slices[index].color,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-series kinds: resolve each flattened series' label the way the renderer does
|
||||
* (`getLabelName` → `resolveSeriesLabelV5`) and color it with `generateColor`.
|
||||
*/
|
||||
export function resolveTimeSeriesLegendSeries(
|
||||
queries: PanelQueries,
|
||||
data: PanelQueryData,
|
||||
isDarkMode: boolean,
|
||||
): LegendSeries[] {
|
||||
const palette = isDarkMode
|
||||
? themeColors.chartcolors
|
||||
: themeColors.lightModeColor;
|
||||
const builderQueries = getBuilderQueries(queries);
|
||||
const series = flattenTimeSeries(
|
||||
getTimeSeriesResults(data.response),
|
||||
data.legendMap,
|
||||
);
|
||||
return buildLegendSeries(
|
||||
series.map((s) =>
|
||||
resolveSeriesLabelV5(
|
||||
s,
|
||||
builderQueries,
|
||||
getLabelName(s.labels, s.queryName, s.legend),
|
||||
),
|
||||
),
|
||||
(label) => generateColor(label, palette),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CalendarRange, Clock, RotateCw } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { panelHasFixedTimePreference } from '../../../hooks/resolvePanelTimeWindow';
|
||||
import {
|
||||
selectViewPanelExtendWindow,
|
||||
useViewPanelStore,
|
||||
@@ -15,6 +17,8 @@ interface NoDataProps {
|
||||
isFetching?: boolean;
|
||||
/** When provided, renders a Retry button that re-runs the query. */
|
||||
onRetry?: () => void;
|
||||
/** Hides the global "Extend time range" action when this panel is locked to a fixed time preference. */
|
||||
panel?: DashboardtypesPanelDTO;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -29,19 +33,30 @@ function NoData({
|
||||
description = 'Nothing in the selected window. Try widening the range.',
|
||||
isFetching = false,
|
||||
onRetry,
|
||||
panel,
|
||||
'data-testid': testId = 'panel-no-data',
|
||||
}: NoDataProps): JSX.Element {
|
||||
const viewExtend = useViewPanelStore(selectViewPanelExtendWindow);
|
||||
const globalExtend = useExtendTimeWindow();
|
||||
const { canExtend, actionLabel, extend } = viewExtend ?? globalExtend;
|
||||
// The View modal's local extender wins; the global one only applies to a panel that
|
||||
// follows the ambient window (a fixed preference can't be widened by it).
|
||||
const hasFixedTimePreference = panel
|
||||
? panelHasFixedTimePreference(panel)
|
||||
: false;
|
||||
const activeExtend =
|
||||
viewExtend ?? (hasFixedTimePreference ? undefined : globalExtend);
|
||||
|
||||
if (isFetching) {
|
||||
return <PanelLoader />;
|
||||
}
|
||||
|
||||
const extendAction: PanelMessageAction | undefined =
|
||||
canExtend && actionLabel
|
||||
? { label: actionLabel, onClick: extend, icon: <CalendarRange size={14} /> }
|
||||
activeExtend?.canExtend && activeExtend.actionLabel
|
||||
? {
|
||||
label: activeExtend.actionLabel,
|
||||
onClick: activeExtend.extend,
|
||||
icon: <CalendarRange size={14} />,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const retryAction: PanelMessageAction | undefined = onRetry
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
type DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { useViewPanelStore } from '../../../../store/useViewPanelStore';
|
||||
@@ -24,6 +28,15 @@ function extender(over?: Partial<ExtendTimeWindow>): ExtendTimeWindow {
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal panel whose plugin spec carries the given time preference. */
|
||||
function panelWith(
|
||||
timePreference?: DashboardtypesTimePreferenceDTO,
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
spec: { plugin: { spec: { visualization: { timePreference } } } },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('NoData', () => {
|
||||
beforeEach(() => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(inert);
|
||||
@@ -119,4 +132,47 @@ describe('NoData', () => {
|
||||
|
||||
expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the global extend action for a panel with a fixed time preference', () => {
|
||||
const onRetry = jest.fn();
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(
|
||||
<NoData
|
||||
onRetry={onRetry}
|
||||
panel={panelWith(DashboardtypesTimePreferenceDTO.last_6_hr)}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Only Retry — extending the dashboard window can't widen a locked panel span.
|
||||
const action = screen.getByTestId('panel-no-data-action');
|
||||
expect(action).toHaveTextContent('Retry');
|
||||
expect(
|
||||
screen.queryByTestId('panel-no-data-secondary-action'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still offers the global extend action for a panel that follows the global window', () => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(
|
||||
<NoData panel={panelWith(DashboardtypesTimePreferenceDTO.global_time)} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent(
|
||||
'Extend time range',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the View modal extender even when the panel has a fixed time preference', () => {
|
||||
const storeExtend = jest.fn();
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
useViewPanelStore.setState({
|
||||
viewPanelExtendWindow: extender({ extend: storeExtend }),
|
||||
});
|
||||
render(
|
||||
<NoData panel={panelWith(DashboardtypesTimePreferenceDTO.last_6_hr)} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-no-data-action'));
|
||||
expect(storeExtend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,7 +193,7 @@ function BarPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
|
||||
@@ -13,7 +13,7 @@ export const sections: SectionConfig[] = [
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.LABEL },
|
||||
|
||||
@@ -106,7 +106,7 @@ function HistogramPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
|
||||
@@ -9,7 +9,7 @@ export const sections: SectionConfig[] = [
|
||||
},
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true },
|
||||
controls: { position: true, colors: true },
|
||||
// Merging all queries collapses to one distribution with no legend.
|
||||
isHidden: (spec): boolean =>
|
||||
Boolean(
|
||||
|
||||
@@ -143,7 +143,7 @@ function ListPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
|
||||
@@ -130,6 +130,7 @@ function NumberPanelRenderer({
|
||||
data-testid="number-panel-no-data"
|
||||
isFetching={isFetching}
|
||||
onRetry={refetch}
|
||||
panel={panel}
|
||||
/>
|
||||
) : (
|
||||
<ValueDisplay
|
||||
|
||||
@@ -95,7 +95,7 @@ function PiePanelRenderer({
|
||||
return (
|
||||
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
|
||||
{slices.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
) : (
|
||||
<Pie
|
||||
data={slices}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
|
||||
// Legend `colors` is omitted: the pie legend is always interactive swatches.
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a legend
|
||||
// (position + per-slice color overrides).
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
|
||||
@@ -160,7 +160,7 @@ function TablePanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
) : (
|
||||
<div className={styles.container}>
|
||||
<Table
|
||||
|
||||
@@ -194,7 +194,7 @@ function TimeSeriesPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesPanelDTO } 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 {
|
||||
getPanelTimePreference,
|
||||
panelTimePreferenceLabel,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
@@ -27,7 +27,7 @@ export interface PanelActionsConfig {
|
||||
interface PanelProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** True once this panel's section enters the viewport — gates the fetch. */
|
||||
/** True once this panel enters the viewport — gates the fetch (owned by SectionGridItem). */
|
||||
isVisible?: boolean;
|
||||
/** Move/delete actions — present only in editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
@@ -43,15 +43,7 @@ function Panel({
|
||||
isVisible,
|
||||
panelActions,
|
||||
}: PanelProps): JSX.Element {
|
||||
// A per-panel time preference is surfaced as a header pill. `visualization` is
|
||||
// common to every plugin-spec variant — localized cast reads it without
|
||||
// narrowing on kind.
|
||||
const timePreference = (
|
||||
panel.spec.plugin.spec as
|
||||
| { visualization?: { timePreference?: DashboardtypesTimePreferenceDTO } }
|
||||
| undefined
|
||||
)?.visualization?.timePreference;
|
||||
const timeLabel = panelTimePreferenceLabel(timePreference);
|
||||
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
|
||||
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
|
||||
@@ -89,6 +89,14 @@ function section(
|
||||
const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
|
||||
// Index 0 is the untitled root (free-flow) section; index 1 is a titled section.
|
||||
const TITLED_WITH_ROOT = [section(0, undefined), section(1, 'Latency')];
|
||||
// Untitled root plus two titled sections — exercises the multi-target submenu.
|
||||
const ROOT_AND_TWO_TITLED = [
|
||||
section(0, undefined),
|
||||
section(1, 'A'),
|
||||
section(2, 'B'),
|
||||
];
|
||||
// Just the free-flow root: an ungrouped board with no sections to move between.
|
||||
const ONLY_ROOT = [section(0, undefined)];
|
||||
|
||||
// Minimal panel — only its presence gates "Create Alerts"; the query→URL
|
||||
// translation it drives is covered by buildCreateAlertUrl's own tests.
|
||||
@@ -111,7 +119,9 @@ const baseArgs = {
|
||||
panelId: 'panel-1',
|
||||
panel: mockPanel,
|
||||
data: mockData,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
|
||||
// Panel sits in a titled section with an untitled root present, so every
|
||||
// action — including "Move to section" (→ Dashboard root) — is available.
|
||||
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
|
||||
};
|
||||
|
||||
function itemKeys(result: ReturnType<typeof usePanelActionItems>): unknown[] {
|
||||
@@ -210,7 +220,7 @@ describe('usePanelActionItems', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('move is disabled when there is no other titled section to move to', () => {
|
||||
it('hides "Move to section" when the only untitled section is not the root (index 0)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
@@ -220,8 +230,8 @@ describe('usePanelActionItems', () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find((i) => 'key' in i && i.key === 'move');
|
||||
expect(move).toMatchObject({ disabled: true });
|
||||
// An untitled section only counts as the root at layoutIndex 0.
|
||||
expect(itemKeys(result.current)).not.toContain('move');
|
||||
});
|
||||
|
||||
it('edit opens the panel editor for this panel', () => {
|
||||
@@ -233,14 +243,77 @@ describe('usePanelActionItems', () => {
|
||||
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1');
|
||||
});
|
||||
|
||||
it('move targets call the mutation with from/to layout indexes', () => {
|
||||
it('"Move to section" offers a single "Dashboard (root)" target', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as {
|
||||
children: { key: string; onClick: () => void }[];
|
||||
};
|
||||
expect(move.children).toHaveLength(1);
|
||||
expect(move.children.map((c) => c.key)).toStrictEqual(['move-to-root']);
|
||||
});
|
||||
|
||||
it('the "Dashboard (root)" target moves the panel to the untitled root section', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as {
|
||||
children: { onClick: () => void }[];
|
||||
};
|
||||
move.children[0].onClick();
|
||||
expect(mockMovePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
fromLayoutIndex: 1,
|
||||
toLayoutIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('an ungrouped panel (in the root) can move into each titled section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 0, sections: ROOT_AND_TWO_TITLED },
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as { children: { key: string; label: string }[] };
|
||||
expect(move.children.map((c) => c.key)).toStrictEqual(['move-1', 'move-2']);
|
||||
expect(move.children.map((c) => c.label)).toStrictEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('a panel in a titled section can move to the root and the other titled sections', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 1, sections: ROOT_AND_TWO_TITLED },
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as { children: { key: string; label: string }[] };
|
||||
// Root leads, then the other titled section — never the current one (A).
|
||||
expect(move.children.map((c) => c.key)).toStrictEqual([
|
||||
'move-to-root',
|
||||
'move-2',
|
||||
]);
|
||||
expect(move.children.map((c) => c.label)).toStrictEqual([
|
||||
'Dashboard (root)',
|
||||
'B',
|
||||
]);
|
||||
});
|
||||
|
||||
it('moves between titled sections even when the board has no untitled root', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as { children: { key: string; onClick: () => void }[] };
|
||||
expect(move.children.map((c) => c.key)).toStrictEqual(['move-1']);
|
||||
move.children[0].onClick();
|
||||
expect(mockMovePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
@@ -249,47 +322,14 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('offers "Move out of section" for a panel in a titled section when an untitled root exists', () => {
|
||||
it('hides "Move to section" when the board has no sections (only the root)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
|
||||
panelActions: { currentLayoutIndex: 0, sections: ONLY_ROOT },
|
||||
}),
|
||||
);
|
||||
expect(itemKeys(result.current)).toContain('move-to-root');
|
||||
});
|
||||
|
||||
it('"Move out of section" moves the panel to the untitled root section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
|
||||
}),
|
||||
);
|
||||
const moveOut = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move-to-root',
|
||||
);
|
||||
(moveOut as { onClick: () => void }).onClick();
|
||||
expect(mockMovePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
fromLayoutIndex: 1,
|
||||
toLayoutIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides "Move out of section" when the panel already sits in the root section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TITLED_WITH_ROOT },
|
||||
}),
|
||||
);
|
||||
expect(itemKeys(result.current)).not.toContain('move-to-root');
|
||||
});
|
||||
|
||||
it('hides "Move out of section" when every section is titled (no root)', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).not.toContain('move-to-root');
|
||||
expect(itemKeys(result.current)).not.toContain('move');
|
||||
});
|
||||
|
||||
it('delete defers to a confirmation: the item opens the dialog, confirm runs the mutation', async () => {
|
||||
@@ -312,7 +352,7 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
expect(mockDeletePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
layoutIndex: 0,
|
||||
layoutIndex: 1,
|
||||
});
|
||||
expect(result.current.deleteConfirm.open).toBe(false);
|
||||
});
|
||||
@@ -325,7 +365,7 @@ describe('usePanelActionItems', () => {
|
||||
(clone as { onClick: () => void }).onClick();
|
||||
expect(mockClonePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
layoutIndex: 0,
|
||||
layoutIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
cursor: grab;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { Info, Loader } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
@@ -74,6 +74,14 @@ function PanelHeader({
|
||||
[warning],
|
||||
);
|
||||
|
||||
/**
|
||||
* Hide the entire header when there's no title, description, or status to show,
|
||||
* and the actions menu is suppressed (editor preview).
|
||||
*/
|
||||
if (!name && !description && !errorDetail && !warningDetail && hideActions) {
|
||||
return <Fragment />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.header, 'panel-drag-handle')}>
|
||||
<div className={styles.headerLeft}>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.modal {
|
||||
:global(.ant-modal-body) {
|
||||
padding: 0px;
|
||||
}
|
||||
.dialog[data-width] {
|
||||
max-width: 85%;
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
// Truncate a long panel name so the header stays on one line; the wrapping tooltip
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@signozhq/ui/dialog';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Modal } from 'antd';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import ViewPanelModalContent from './ViewPanelModalContent';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
@@ -25,27 +33,48 @@ function ViewPanelModal({
|
||||
}: ViewPanelModalProps): JSX.Element {
|
||||
const name = panel?.spec.display.name ?? '';
|
||||
|
||||
// Render antd popups into the dialog (not document.body) so they stay inside the
|
||||
// modal's interactive, focus-trapped layer instead of being blocked by Radix.
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<Dialog
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
centered
|
||||
width="85%"
|
||||
destroyOnClose
|
||||
className={styles.modal}
|
||||
title={
|
||||
<TooltipSimple title={name} arrow>
|
||||
<Typography.Text className={styles.title}>
|
||||
{name} - (View mode)
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{open && panel && panelId && (
|
||||
<ViewPanelModalContent panel={panel} panelId={panelId} onClose={onClose} />
|
||||
)}
|
||||
</Modal>
|
||||
<DialogContent
|
||||
ref={contentRef}
|
||||
position="center"
|
||||
width="extra-wide"
|
||||
className={styles.dialog}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<TooltipSimple title={name} arrow>
|
||||
<Typography.Text className={styles.title}>
|
||||
{name ? `${name} - (View mode)` : 'View mode'}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogCloseButton />
|
||||
{open && panel && panelId && (
|
||||
<ConfigProvider
|
||||
getPopupContainer={(): HTMLElement => contentRef.current ?? document.body}
|
||||
>
|
||||
<ViewPanelModalContent
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import ListColumnsEditor from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ListColumnsEditor/ListColumnsEditor';
|
||||
import PanelEditorQueryBuilder from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
@@ -44,6 +45,7 @@ function ViewPanelModalContent({
|
||||
|
||||
const {
|
||||
draft,
|
||||
setSpec,
|
||||
panelDefinition,
|
||||
signal,
|
||||
queryType,
|
||||
@@ -54,7 +56,17 @@ function ViewPanelModalContent({
|
||||
buildSaveSpec,
|
||||
applyDrilldownQuery,
|
||||
} = useViewPanelMode({ panel, panelId, time: timeOverride });
|
||||
const { data, isFetching, error, refetch, cancelQuery, pagination } = query;
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
refetch,
|
||||
cancelQuery,
|
||||
pagination,
|
||||
} = query;
|
||||
|
||||
const isListPanel = draft.spec.plugin.kind === 'signoz/ListPanel';
|
||||
|
||||
// Grid drill-down, but filter-by-value / breakout refine this view in place. Drills the draft
|
||||
// so it reflects in-modal edits (and the click's time range follows the per-view window).
|
||||
@@ -119,6 +131,15 @@ function ViewPanelModalContent({
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
footer={
|
||||
isListPanel ? (
|
||||
<ListColumnsEditor
|
||||
spec={draft.spec}
|
||||
onChangeSpec={setSpec}
|
||||
signal={signal}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
@@ -128,6 +149,7 @@ function ViewPanelModalContent({
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
|
||||
@@ -35,6 +35,8 @@ interface UseViewPanelModeArgs {
|
||||
export interface UseViewPanelModeReturn {
|
||||
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
/** Update the draft's spec in place (e.g. the List columns editor). */
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Resolved renderer for the draft's current kind (registry always resolves a kind). */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
/**
|
||||
@@ -159,6 +161,7 @@ export function useViewPanelMode({
|
||||
|
||||
return {
|
||||
draft,
|
||||
setSpec,
|
||||
panelDefinition,
|
||||
signal,
|
||||
queryType: currentQuery.queryType,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { FolderInput, FolderOutput } from '@signozhq/icons';
|
||||
import { FolderInput } from '@signozhq/icons';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import { findRootSection, type DashboardSection } from '../../../utils';
|
||||
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
|
||||
|
||||
// Matches the root option label in the New Panel picker (SectionPicker).
|
||||
const ROOT_LABEL = 'Dashboard (root)';
|
||||
|
||||
interface MoveItemsArgs {
|
||||
sections: DashboardSection[];
|
||||
currentLayoutIndex: number;
|
||||
@@ -12,9 +15,11 @@ interface MoveItemsArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Move to section" submenu plus a direct "Move out of section" to the
|
||||
* untitled root, shown only when the panel sits in a titled section and a root
|
||||
* section exists to receive it.
|
||||
* "Move to section" submenu listing every section the panel can move to — the
|
||||
* dashboard root (untitled top level, labelled "Dashboard (root)") plus every
|
||||
* titled section, minus the one the panel already sits in. Hidden entirely when
|
||||
* there is nowhere to move: an ungrouped board with no titled sections, or a
|
||||
* panel that is alone in the only section.
|
||||
*/
|
||||
export function buildMoveItems({
|
||||
sections,
|
||||
@@ -22,44 +27,38 @@ export function buildMoveItems({
|
||||
panelId,
|
||||
movePanel,
|
||||
}: MoveItemsArgs): MenuItem[] {
|
||||
const rootSection = findRootSection(sections);
|
||||
|
||||
// Sections are already in layout order, so the root (index 0, when present)
|
||||
// naturally leads. Untitled non-root layouts are never a move target.
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
|
||||
(section) =>
|
||||
section.layoutIndex !== currentLayoutIndex &&
|
||||
(section === rootSection || Boolean(section.title)),
|
||||
);
|
||||
const items: MenuItem[] = [
|
||||
|
||||
if (targets.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
...(targets.length === 0
|
||||
? { disabled: true }
|
||||
: {
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
children: targets.map((section) => {
|
||||
const isRoot = section === rootSection;
|
||||
return {
|
||||
key: isRoot ? 'move-to-root' : `move-${section.layoutIndex}`,
|
||||
label: isRoot ? ROOT_LABEL : (section.title as string),
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: section.layoutIndex,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const rootSection = findRootSection(sections);
|
||||
if (rootSection && rootSection.layoutIndex !== currentLayoutIndex) {
|
||||
items.push({
|
||||
key: 'move-to-root',
|
||||
label: 'Move out of section',
|
||||
icon: <FolderOutput size={14} />,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: rootSection.layoutIndex,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
|
||||
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import DisabledControlTooltip from '../../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
import { DASHBOARD_LOCKED_REASON } from '../../../hooks/useDashboardEditGuard';
|
||||
@@ -41,12 +39,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
targetLayoutIndex,
|
||||
} = useCreatePanel();
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
|
||||
// true once the section scrolls into (or near) the viewport.
|
||||
const isVisible = useIntersectionObserver(containerRef, {
|
||||
rootMargin: '200px',
|
||||
});
|
||||
|
||||
const { open, toggle } = useToggleSectionCollapse({ sectionId: section.id });
|
||||
|
||||
@@ -77,17 +69,14 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
<SectionGrid
|
||||
items={section.items}
|
||||
layoutIndex={section.layoutIndex}
|
||||
isVisible={isVisible}
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!section.title) {
|
||||
// Untitled section — just the grid (no header chrome), but still observed
|
||||
// for the viewport signal.
|
||||
// Untitled section — just the grid, no header chrome.
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
>
|
||||
@@ -98,7 +87,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.section}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
|
||||
@@ -20,3 +20,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy-load observer boundary (SectionGridItem); fills the grid cell.
|
||||
.panelWrapper {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useMemo } from 'react';
|
||||
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import Panel from '../../Panel/Panel';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { usePersistLayout } from '../hooks/usePersistLayout';
|
||||
import SectionGridItem from './SectionGridItem';
|
||||
import styles from './SectionGrid.module.scss';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(GridLayout);
|
||||
@@ -12,8 +12,6 @@ const ResponsiveGridLayout = WidthProvider(GridLayout);
|
||||
interface SectionGridProps {
|
||||
items: DashboardSection['items'];
|
||||
layoutIndex: number;
|
||||
/** Forwarded to panels — true when the parent section is in the viewport. */
|
||||
isVisible?: boolean;
|
||||
/** All sections — layout context for the panel menu's move/delete actions. */
|
||||
sections?: DashboardSection[];
|
||||
}
|
||||
@@ -21,7 +19,6 @@ interface SectionGridProps {
|
||||
function SectionGrid({
|
||||
items,
|
||||
layoutIndex,
|
||||
isVisible,
|
||||
sections,
|
||||
}: SectionGridProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
@@ -61,10 +58,9 @@ function SectionGrid({
|
||||
// panel with no content.
|
||||
<div key={item.id}>
|
||||
{item.panel && (
|
||||
<Panel
|
||||
<SectionGridItem
|
||||
panel={item.panel}
|
||||
panelId={item.id}
|
||||
isVisible={isVisible}
|
||||
panelActions={
|
||||
isEditable
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useRef } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
|
||||
import Panel, { type PanelActionsConfig } from '../../Panel/Panel';
|
||||
import styles from './SectionGrid.module.scss';
|
||||
|
||||
const VIEWPORT_OBSERVER_OPTIONS: IntersectionObserverInit = {
|
||||
rootMargin: '200px',
|
||||
};
|
||||
|
||||
interface SectionGridItemProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-loads a single panel: watches its own viewport intersection (latched) and
|
||||
* passes it to the presentational Panel as `isVisible`, so a board of many panels
|
||||
* only fetches what's on screen.
|
||||
*/
|
||||
function SectionGridItem({
|
||||
panel,
|
||||
panelId,
|
||||
panelActions,
|
||||
}: SectionGridItemProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useIntersectionObserver(
|
||||
containerRef,
|
||||
VIEWPORT_OBSERVER_OPTIONS,
|
||||
true,
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.panelWrapper}>
|
||||
<Panel
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
isVisible={isVisible}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionGridItem;
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
@@ -6,6 +5,7 @@ import cx from 'classnames';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useInlineOverflowCount } from 'hooks/useInlineOverflowCount';
|
||||
|
||||
import { selectVariablesExpanded } from '../store/slices/collapseSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import AddVariableFull from './AddVariableFull';
|
||||
import AddVariableIcon from './AddVariableIcon';
|
||||
@@ -45,10 +45,13 @@ interface VariablesBarProps {
|
||||
* either way so auto-selection and option fetching keep driving the panels.
|
||||
*/
|
||||
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
const dashboardId = dashboard.id ?? '';
|
||||
const { variables, selection, setSelection, autoSelect } =
|
||||
useVariableSelection(dashboard);
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Persisted per dashboard so the full/collapsed view survives reloads.
|
||||
const expanded = useDashboardStore(selectVariablesExpanded(dashboardId));
|
||||
const setVariablesExpanded = useDashboardStore((s) => s.setVariablesExpanded);
|
||||
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
|
||||
itemCount: variables.length,
|
||||
gap: 8,
|
||||
@@ -75,7 +78,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
|
||||
aria-expanded={expanded}
|
||||
testId="dashboard-variables-more"
|
||||
onClick={(): void => setExpanded((prev) => !prev)}
|
||||
onClick={(): void => setVariablesExpanded(dashboardId, !expanded)}
|
||||
>
|
||||
{expanded ? 'Less' : `+${overflowCount}`}
|
||||
</Button>
|
||||
@@ -120,7 +123,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasOverflow && (
|
||||
{(expanded || hasOverflow) && (
|
||||
<span className={styles.moreButton}>
|
||||
{expanded ? (
|
||||
moreButton
|
||||
|
||||
@@ -57,6 +57,8 @@ function ValueSelector({
|
||||
onRetry={onRetry}
|
||||
showSearch
|
||||
placeholder="Select value"
|
||||
maxTagCount={2}
|
||||
maxTagTextLength={20}
|
||||
enableAllSelection={showAllOption}
|
||||
onChange={(next): void => {
|
||||
const values = Array.isArray(next)
|
||||
|
||||
@@ -1,74 +1,196 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { getDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useDashboardStaleCheck } from '../useDashboardStaleCheck';
|
||||
|
||||
const mockUseQuery = jest.fn();
|
||||
const mockUseIsMutating = jest.fn();
|
||||
jest.mock('react-query', () => ({
|
||||
useQuery: (...args: unknown[]): unknown => mockUseQuery(...args),
|
||||
useIsMutating: (): number => mockUseIsMutating(),
|
||||
useQueryClient: (): unknown => ({ getQueryData: jest.fn() }),
|
||||
}));
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
getDashboardV2: jest.fn(),
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/d1']),
|
||||
}));
|
||||
|
||||
function setServerUpdatedAt(updatedAt: string | undefined): void {
|
||||
mockUseQuery.mockReturnValue({ data: { data: { updatedAt } } });
|
||||
const mockGetDashboard = getDashboardV2 as jest.Mock;
|
||||
|
||||
const SPEC_A = { panels: { p1: {} } };
|
||||
const SPEC_B = { panels: { p1: {}, p2: {} } };
|
||||
|
||||
interface Content {
|
||||
spec?: unknown;
|
||||
tags?: unknown;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
// Defaults make loaded and server content identical unless a field is overridden.
|
||||
function withDefaults(content: Content): Record<string, unknown> {
|
||||
return { spec: SPEC_A, tags: [], locked: false, ...content };
|
||||
}
|
||||
|
||||
function loaded(
|
||||
updatedAt: string,
|
||||
content: Content = {},
|
||||
): DashboardtypesGettableDashboardV2DTO {
|
||||
return {
|
||||
id: 'd1',
|
||||
updatedAt,
|
||||
...withDefaults(content),
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function setVisibility(state: 'visible' | 'hidden'): void {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => state,
|
||||
});
|
||||
}
|
||||
|
||||
/** Simulate the tab/window coming back with the given server copy. */
|
||||
async function comeBack(
|
||||
serverUpdatedAt: string,
|
||||
content: Content = {},
|
||||
via: 'visibilitychange' | 'focus' = 'visibilitychange',
|
||||
): Promise<void> {
|
||||
mockGetDashboard.mockResolvedValueOnce({
|
||||
data: { updatedAt: serverUpdatedAt, ...withDefaults(content) },
|
||||
});
|
||||
setVisibility('visible');
|
||||
await act(async () => {
|
||||
if (via === 'focus') {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
} else {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('useDashboardStaleCheck', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseIsMutating.mockReturnValue(0);
|
||||
setVisibility('visible');
|
||||
});
|
||||
|
||||
it('prompts when the server copy is newer than the loaded one', () => {
|
||||
setServerUpdatedAt('2026-07-08T10:00:00Z');
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
|
||||
it('does not fetch on first load — only when the tab/window returns', async () => {
|
||||
renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
expect(result.current.showPrompt).toBe(true);
|
||||
expect(mockGetDashboard).not.toHaveBeenCalled();
|
||||
|
||||
await comeBack('2026-07-08T09:00:00Z');
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: 'd1' });
|
||||
});
|
||||
|
||||
it('does not prompt when the versions match', () => {
|
||||
setServerUpdatedAt('2026-07-08T09:00:00Z');
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
|
||||
it('also probes on window focus (returning from another app)', async () => {
|
||||
renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T09:00:00Z', {}, 'focus');
|
||||
expect(mockGetDashboard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prompts when newer and the spec differs', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
});
|
||||
|
||||
it('prompts when newer and the lock state differs (external lock)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(
|
||||
loaded('2026-07-08T09:00:00Z', { locked: false }),
|
||||
jest.fn(),
|
||||
),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', { locked: true });
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
});
|
||||
|
||||
it('prompts when newer and the tags differ', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(
|
||||
loaded('2026-07-08T09:00:00Z', { tags: [] }),
|
||||
jest.fn(),
|
||||
),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', {
|
||||
tags: [{ key: 'env', value: 'prod' }],
|
||||
});
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
});
|
||||
|
||||
it('does NOT prompt when newer but content (spec/tags/locked) is unchanged', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
// updatedAt bumped but every content field matches — e.g. our own lock, already
|
||||
// reflected in the render cache.
|
||||
await comeBack('2026-07-08T10:00:00Z');
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it('does not prompt while a mutation is in flight (optimistic save)', () => {
|
||||
it('does not prompt when the loaded copy is newer (own edit)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T10:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T09:00:00Z', { spec: SPEC_B });
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it('does not prompt while a mutation is in flight (optimistic save)', async () => {
|
||||
mockUseIsMutating.mockReturnValue(1);
|
||||
setServerUpdatedAt('2026-07-08T10:00:00Z');
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it('stops prompting for a version once dismissed', () => {
|
||||
setServerUpdatedAt('2026-07-08T10:00:00Z');
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
|
||||
it('does not probe while the tab is hidden', async () => {
|
||||
renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
expect(result.current.showPrompt).toBe(true);
|
||||
setVisibility('hidden');
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
expect(mockGetDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops prompting for a version once dismissed', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
act(() => result.current.dismiss());
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it('reload refetches and clears the dismissed state', () => {
|
||||
setServerUpdatedAt('2026-07-08T10:00:00Z');
|
||||
it('reload refetches and closes the prompt immediately', async () => {
|
||||
const refetch = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', refetch),
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), refetch),
|
||||
);
|
||||
act(() => result.current.dismiss());
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
act(() => result.current.reload());
|
||||
expect(refetch).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.showPrompt).toBe(true);
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it('re-prompts when a newer, different version appears after a reload', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
|
||||
act(() => result.current.reload());
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
|
||||
await comeBack('2026-07-08T10:05:00Z', { spec: { panels: { p3: {} } } });
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/** Absolute time window in epoch milliseconds — the V5 request's native unit. */
|
||||
export interface PanelTimeWindow {
|
||||
@@ -65,6 +68,28 @@ const TIME_PREFERENCE_LABEL: Partial<
|
||||
},
|
||||
};
|
||||
|
||||
/** A panel's saved `visualization.timePreference`, or `undefined` when the kind has none. */
|
||||
export function getPanelTimePreference(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
): DashboardtypesTimePreferenceDTO | undefined {
|
||||
const pluginSpec = panel.spec.plugin.spec;
|
||||
if (pluginSpec && 'visualization' in pluginSpec) {
|
||||
return pluginSpec.visualization?.timePreference;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** True when the panel is locked to a fixed relative window, so it ignores the dashboard's ambient time. */
|
||||
export function panelHasFixedTimePreference(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
): boolean {
|
||||
const timePreference = getPanelTimePreference(panel);
|
||||
return (
|
||||
timePreference !== undefined &&
|
||||
timePreference !== DashboardtypesTimePreferenceDTO.global_time
|
||||
);
|
||||
}
|
||||
|
||||
export interface PanelTimePreferenceLabel {
|
||||
/** Compact pill label, e.g. `6h`. */
|
||||
short: string;
|
||||
|
||||
@@ -1,65 +1,92 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useIsMutating, useQuery, useQueryClient } from 'react-query';
|
||||
import {
|
||||
getDashboardV2,
|
||||
getGetDashboardV2QueryKey,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useIsMutating } from 'react-query';
|
||||
|
||||
interface UseDashboardStaleCheck {
|
||||
// Server copy has a newer updatedAt than the loaded one, and not yet dismissed.
|
||||
// Server copy is a newer version with different content, and not yet dismissed.
|
||||
showPrompt: boolean;
|
||||
reload: () => void;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects when the open dashboard changed on the server (another tab/user) without
|
||||
* touching the render cache: a separate query (own key) refetches on window focus
|
||||
* and its updatedAt is compared to the loaded copy's. Guarded by in-flight
|
||||
* mutations so an optimistic save doesn't false-positive.
|
||||
* Prompts when another tab/user changed the open dashboard. The page's own query is frozen
|
||||
* (`staleTime: Infinity`), so we re-probe the server on tab/app return (not on first load) and
|
||||
* prompt only when it's strictly newer AND its content (spec, tags, or lock state) differs from
|
||||
* what we're viewing — so our own edits, which advance the render cache, don't trip it.
|
||||
* In-flight mutations are skipped.
|
||||
*/
|
||||
export function useDashboardStaleCheck(
|
||||
dashboardId: string,
|
||||
loadedUpdatedAt: string | undefined,
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
refetch: () => void,
|
||||
): UseDashboardStaleCheck {
|
||||
const {
|
||||
id: dashboardId,
|
||||
updatedAt: loadedUpdatedAt,
|
||||
spec: loadedSpec,
|
||||
tags: loadedTags,
|
||||
locked: loadedLocked,
|
||||
} = dashboard;
|
||||
const isMutating = useIsMutating() > 0;
|
||||
const queryClient = useQueryClient();
|
||||
const [server, setServer] = useState<DashboardtypesGettableDashboardV2DTO>();
|
||||
const [dismissedAt, setDismissedAt] = useState<string | null>(null);
|
||||
|
||||
const { data } = useQuery(
|
||||
['dashboard-freshness', dashboardId],
|
||||
({ signal }) => getDashboardV2({ id: dashboardId }, signal),
|
||||
{
|
||||
enabled: !!dashboardId,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: false,
|
||||
retry: false,
|
||||
// Seed from the already-loaded dashboard so mount makes no extra GET; the
|
||||
// query only hits the network on a later window focus.
|
||||
initialData: () =>
|
||||
queryClient.getQueryData<GetDashboardV2200>(
|
||||
getGetDashboardV2QueryKey({ id: dashboardId }),
|
||||
),
|
||||
},
|
||||
useEffect(() => {
|
||||
if (!dashboardId) {
|
||||
return undefined;
|
||||
}
|
||||
const probe = async (): Promise<void> => {
|
||||
// `visibilitychange` also fires on the hidden transition — only probe on return.
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await getDashboardV2({ id: dashboardId });
|
||||
setServer(response.data);
|
||||
} catch {
|
||||
// A failed freshness probe must never surface to the viewer.
|
||||
}
|
||||
};
|
||||
// `visibilitychange` catches tab switches; `focus` catches app/window switches.
|
||||
document.addEventListener('visibilitychange', probe);
|
||||
window.addEventListener('focus', probe);
|
||||
return (): void => {
|
||||
document.removeEventListener('visibilitychange', probe);
|
||||
window.removeEventListener('focus', probe);
|
||||
};
|
||||
}, [dashboardId]);
|
||||
|
||||
const serverUpdatedAt = server?.updatedAt;
|
||||
|
||||
// Content the viewer cares about: panels/layout/variables (spec), tags, and lock state.
|
||||
// Deep, order-independent so an optimistic patch's key reordering isn't read as a change.
|
||||
const contentDiffers = useMemo(
|
||||
() =>
|
||||
!!server &&
|
||||
(!isEqual(server.spec, loadedSpec) ||
|
||||
!isEqual(server.tags, loadedTags) ||
|
||||
server.locked !== loadedLocked),
|
||||
[server, loadedSpec, loadedTags, loadedLocked],
|
||||
);
|
||||
|
||||
const serverUpdatedAt = data?.data?.updatedAt;
|
||||
const changed =
|
||||
!isMutating &&
|
||||
!!serverUpdatedAt &&
|
||||
!!loadedUpdatedAt &&
|
||||
serverUpdatedAt !== loadedUpdatedAt;
|
||||
new Date(serverUpdatedAt).getTime() > new Date(loadedUpdatedAt).getTime() &&
|
||||
contentDiffers;
|
||||
|
||||
const dismiss = useCallback(
|
||||
(): void => setDismissedAt(serverUpdatedAt ?? null),
|
||||
[serverUpdatedAt],
|
||||
);
|
||||
// Dismiss + refetch: close on click and pull the latest; a newer, different version re-prompts.
|
||||
const reload = useCallback((): void => {
|
||||
setDismissedAt(serverUpdatedAt ?? null);
|
||||
refetch();
|
||||
setDismissedAt(null);
|
||||
}, [refetch]);
|
||||
}, [refetch, serverUpdatedAt]);
|
||||
|
||||
return {
|
||||
showPrompt: changed && serverUpdatedAt !== dismissedAt,
|
||||
|
||||
@@ -55,11 +55,7 @@ function DashboardContainer({
|
||||
// suggests them ($variable) in the panel editor and dashboards-page builder.
|
||||
useSyncVariablesForSuggestions(dashboard);
|
||||
|
||||
const staleCheck = useDashboardStaleCheck(
|
||||
dashboard.id,
|
||||
dashboard.updatedAt,
|
||||
refetch,
|
||||
);
|
||||
const staleCheck = useDashboardStaleCheck(dashboard, refetch);
|
||||
|
||||
// In full screen show only the sections and panels — the header/toolbar chrome
|
||||
// is hidden for a clean presentation view (exit with Esc).
|
||||
|
||||
@@ -11,6 +11,10 @@ import type { DashboardStore } from '../useDashboardStore';
|
||||
export interface CollapseSlice {
|
||||
collapsed: Record<string, Record<string, boolean>>;
|
||||
toggleSectionCollapse: (dashboardId: string, sectionId: string) => void;
|
||||
// Whether the variables bar shows its full (expanded) list, per dashboard.
|
||||
// Absent → collapsed (the default).
|
||||
variablesExpanded: Record<string, boolean>;
|
||||
setVariablesExpanded: (dashboardId: string, expanded: boolean) => void;
|
||||
}
|
||||
|
||||
export const createCollapseSlice: StateCreator<
|
||||
@@ -32,4 +36,17 @@ export const createCollapseSlice: StateCreator<
|
||||
},
|
||||
});
|
||||
},
|
||||
variablesExpanded: {},
|
||||
setVariablesExpanded: (dashboardId, expanded): void => {
|
||||
const { variablesExpanded } = get();
|
||||
set({
|
||||
variablesExpanded: { ...variablesExpanded, [dashboardId]: expanded },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Selector: is the variables bar expanded for this dashboard? Default collapsed. */
|
||||
export const selectVariablesExpanded =
|
||||
(dashboardId: string) =>
|
||||
(state: DashboardStore): boolean =>
|
||||
Boolean(state.variablesExpanded[dashboardId]);
|
||||
|
||||
@@ -49,6 +49,7 @@ export const useDashboardStore = create<DashboardStore>()(
|
||||
// Persist UI-only state (context incl. the refetch fn is transient).
|
||||
partialize: (state) => ({
|
||||
collapsed: state.collapsed,
|
||||
variablesExpanded: state.variablesExpanded,
|
||||
variableValues: state.variableValues,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
SquareArrowOutUpRight,
|
||||
Tag,
|
||||
} from '@signozhq/icons';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import DeleteActionItem from './DeleteActionItem';
|
||||
import EditTagsModal from './EditTagsModal';
|
||||
import RenameDashboardModal from './RenameDashboardModal';
|
||||
import styles from './ActionsPopover.module.scss';
|
||||
|
||||
@@ -39,6 +41,8 @@ interface Props {
|
||||
dashboardName: string;
|
||||
createdBy: string;
|
||||
isLocked: boolean;
|
||||
// Current tags as `key:value` strings, for the inline tag editor.
|
||||
tags: string[];
|
||||
// Edit permission (edit_dashboard). Read actions show regardless; edit actions are hidden without it.
|
||||
canEdit: boolean;
|
||||
onView: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
@@ -50,6 +54,7 @@ function ActionsPopover({
|
||||
dashboardName,
|
||||
createdBy,
|
||||
isLocked,
|
||||
tags,
|
||||
canEdit,
|
||||
onView,
|
||||
}: Props): JSX.Element {
|
||||
@@ -57,6 +62,7 @@ function ActionsPopover({
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const [isEditTagsOpen, setIsEditTagsOpen] = useState(false);
|
||||
|
||||
// Clone keeps the source's name/panels/tags as a new unlocked dashboard owned
|
||||
// by the caller; open the copy so it can be tweaked right away.
|
||||
@@ -165,6 +171,35 @@ function ActionsPopover({
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Tooltip
|
||||
placement="left"
|
||||
title={
|
||||
isLocked
|
||||
? 'This dashboard is locked, so its tags cannot be edited.'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<span className={styles.menuItemWrap}>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Tag size={14} />}
|
||||
disabled={isLocked}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!isLocked) {
|
||||
setIsEditTagsOpen(true);
|
||||
}
|
||||
}}
|
||||
testId="dashboard-action-edit-tags"
|
||||
>
|
||||
{tags.length > 0 ? 'Edit Tags' : 'Add Tags'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
color="secondary"
|
||||
@@ -231,6 +266,12 @@ function ActionsPopover({
|
||||
currentName={dashboardName}
|
||||
onClose={(): void => setIsRenameOpen(false)}
|
||||
/>
|
||||
<EditTagsModal
|
||||
open={isEditTagsOpen}
|
||||
dashboardId={dashboardId}
|
||||
currentTags={tags}
|
||||
onClose={(): void => setIsEditTagsOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
invalidateListDashboardsForUserV2,
|
||||
// eslint-disable-next-line no-restricted-imports -- list tag-edit targets another dashboard by id; useOptimisticPatch is bound to the open dashboard's store/cache.
|
||||
patchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { keyValueStringsToTags } from '../../utils/helpers';
|
||||
import styles from './ActionsPopover.module.scss';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
dashboardId: string;
|
||||
// Current tags as `key:value` strings.
|
||||
currentTags: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits a dashboard's tags from the list via an `add /tags` patch, then refreshes
|
||||
* the list. `add` is used (not `replace`) so it works whether the tags field is
|
||||
* already present or absent on the stored dashboard.
|
||||
*/
|
||||
function EditTagsModal({
|
||||
open,
|
||||
dashboardId,
|
||||
currentTags,
|
||||
onClose,
|
||||
}: Props): JSX.Element {
|
||||
const [tags, setTags] = useState<string[]>(currentTags);
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
// Reset to the dashboard's tags when the modal opens. Keyed by content
|
||||
// (`tagsKey`) rather than array identity, since `currentTags` is a fresh array
|
||||
// on each parent render; reconstructing from the key keeps the effect deps
|
||||
// stable and the reset off the per-render path.
|
||||
const tagsKey = currentTags.join('\n');
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTags(tagsKey ? tagsKey.split('\n') : []);
|
||||
}
|
||||
}, [open, tagsKey]);
|
||||
|
||||
const { mutate: runSave, isLoading } = useMutation({
|
||||
mutationFn: () => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: 'add' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/tags',
|
||||
value: keyValueStringsToTags(tags),
|
||||
},
|
||||
];
|
||||
return patchDashboardV2({ id: dashboardId }, ops);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('Tags updated');
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
onClose();
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
});
|
||||
|
||||
// Save on Cmd/Ctrl+Enter from anywhere in the modal (not just the tag input),
|
||||
// while it is open.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runSave();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return (): void => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open, runSave]);
|
||||
|
||||
const title = currentTags.length > 0 ? 'Edit tags' : 'Add tags';
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title={title}
|
||||
open={open}
|
||||
width="narrow"
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
footer={
|
||||
<div className={styles.renameFooter}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
testId="edit-tags-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
onClick={(): void => runSave()}
|
||||
testId="edit-tags-submit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TagKeyValueInput
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
placeholder="key:value (press Enter)"
|
||||
testId="edit-dashboard-tags"
|
||||
/>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditTagsModal;
|
||||
@@ -60,6 +60,12 @@ function DashboardRow({
|
||||
);
|
||||
|
||||
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
|
||||
// Clicks inside portaled overlays (the actions menu, edit modals) bubble here
|
||||
// through React's tree even though they render outside the row in the DOM.
|
||||
// Only navigate when the click actually landed inside the row.
|
||||
if (!event.currentTarget.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
markViewed(id);
|
||||
safeNavigate(link, { newTab: isModifierKeyPressed(event) });
|
||||
@@ -159,6 +165,7 @@ function DashboardRow({
|
||||
dashboardName={name}
|
||||
createdBy={createdBy}
|
||||
isLocked={isLocked}
|
||||
tags={tags}
|
||||
canEdit={canEdit}
|
||||
onView={onClickHandler}
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,8 @@ import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
|
||||
import { keyValueStringsToTags } from '../../utils/helpers';
|
||||
|
||||
import DashboardImagePicker from '../../../DashboardPageV2/DashboardContainer/DashboardSettings/Overview/DashboardImagePicker/DashboardImagePicker';
|
||||
import { Base64Icons } from '../../../DashboardPageV2/DashboardContainer/DashboardSettings/Overview/utils';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../DashboardPageV2/DashboardContainer/constants';
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
@@ -32,6 +34,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
|
||||
const [name, setName] = useState(DEFAULT_NAME);
|
||||
const [description, setDescription] = useState('');
|
||||
const [image, setImage] = useState<string>(Base64Icons[0]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -48,6 +51,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
const created = await createDashboardV2({
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
image,
|
||||
tags: postableTags.length ? postableTags : null,
|
||||
spec: {
|
||||
display: {
|
||||
@@ -77,21 +81,29 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
<Typography.Text className={styles.label}>
|
||||
Title <Typography.Text className={styles.required}>*</Typography.Text>
|
||||
</Typography.Text>
|
||||
<Input
|
||||
value={name}
|
||||
autoFocus
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
placeholder="e.g. Sample Dashboard"
|
||||
testId="create-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleCreate();
|
||||
<div className={styles.titleRow}>
|
||||
<DashboardImagePicker
|
||||
image={image}
|
||||
onChange={setImage}
|
||||
triggerClassName={styles.imageTrigger}
|
||||
/>
|
||||
<Input
|
||||
className={styles.titleInput}
|
||||
value={name}
|
||||
autoFocus
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
placeholder="e.g. Sample Dashboard"
|
||||
testId="create-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
|
||||
@@ -35,6 +35,28 @@
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
// Title row: icon picker joined flush to the left of the name input.
|
||||
.titleRow {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
// The icon picker renders its dropdown inline (no portal); lift it above the
|
||||
// description/tags fields below it.
|
||||
:global([data-radix-popper-content-wrapper]) {
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.imageTrigger {
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
|
||||
.titleInput {
|
||||
flex: 1;
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
@@ -60,28 +82,64 @@
|
||||
}
|
||||
}
|
||||
|
||||
.cardName {
|
||||
color: var(--l1-foreground);
|
||||
// "From a template" tab: a centered browse-and-request placeholder until the
|
||||
// templates BE lands. Fixed height matches .panel so switching tabs won't resize.
|
||||
.templatesPanel {
|
||||
height: 460px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 6px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.templatesIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 10px;
|
||||
color: var(--bg-robin-400);
|
||||
background: var(--callout-primary-background);
|
||||
border: 1px solid var(--callout-primary-border);
|
||||
}
|
||||
|
||||
.templatesDesc {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.browseLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
color: var(--bg-robin-400);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.requestRow {
|
||||
border-top: 1px solid var(--l2-border);
|
||||
padding-top: 12px;
|
||||
|
||||
:global(.request-entity-container) {
|
||||
gap: 10px 16px;
|
||||
padding: 14px 16px;
|
||||
&:hover {
|
||||
color: var(--bg-robin-300);
|
||||
}
|
||||
}
|
||||
|
||||
.requestForm {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.requestInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.importHeader {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l2-foreground);
|
||||
@@ -108,73 +166,6 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.templatesLayout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.templatesList {
|
||||
flex: none;
|
||||
width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.templateItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s,
|
||||
border-color 0.12s;
|
||||
}
|
||||
|
||||
.templateItem:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.templateItemActive {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--primary-background);
|
||||
}
|
||||
|
||||
.templateName {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.templateCat {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.templatesPreview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.previewHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.jsonError {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -43,7 +43,7 @@ function NewDashboardModal({ open, onClose }: Props): JSX.Element {
|
||||
{
|
||||
key: 'template',
|
||||
label: 'From a template',
|
||||
children: <TemplatesPanel onClose={onClose} />,
|
||||
children: <TemplatesPanel />,
|
||||
},
|
||||
{
|
||||
key: 'import',
|
||||
|
||||
@@ -1,67 +1,120 @@
|
||||
import { useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
LayoutDashboard,
|
||||
LoaderCircle,
|
||||
SquareArrowOutUpRight,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { AxiosError } from 'axios';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import DashboardTemplatesContent from 'container/ListOfDashboard/DashboardTemplates/DashboardTemplatesContent';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
const TEMPLATES_DOCS_URL =
|
||||
'https://signoz.io/docs/dashboards/dashboard-templates/overview/';
|
||||
|
||||
// Until the templates BE API lands, the V2 "From a template" tab embeds the V1
|
||||
// template gallery inline (no modal-in-modal). The V1 templates are placeholders,
|
||||
// so the action creates a blank dashboard.
|
||||
function TemplatesPanel({ onClose }: Props): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const [creating, setCreating] = useState(false);
|
||||
// Templates aren't served by the BE yet, so this tab is a browse-and-request
|
||||
// placeholder: link out to the published template library, and let cloud users
|
||||
// request one we haven't built.
|
||||
function TemplatesPanel(): JSX.Element {
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
const [name, setName] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (creating) {
|
||||
const requestName = name.trim();
|
||||
|
||||
const handleRequest = async (): Promise<void> => {
|
||||
if (!requestName || submitting) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCreating(true);
|
||||
logEvent('Dashboard List: Use template clicked', {});
|
||||
const created = await createDashboardV2({
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
tags: null,
|
||||
spec: {
|
||||
display: { name: 'Sample Dashboard' },
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
},
|
||||
setSubmitting(true);
|
||||
const response = await logEvent('Dashboard Requested', {
|
||||
screen: 'Dashboard list page',
|
||||
dashboard: requestName,
|
||||
});
|
||||
onClose();
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorModal(e as APIError);
|
||||
toast.error((e as AxiosError).toString() || 'Failed to create dashboard');
|
||||
setCreating(false);
|
||||
if (response.statusCode === 200) {
|
||||
toast.success('Dashboard request submitted');
|
||||
setName('');
|
||||
} else {
|
||||
toast.error(response.error || 'Something went wrong');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Something went wrong');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className="new-dashboard-templates-modal">
|
||||
<DashboardTemplatesContent
|
||||
onCreateNewDashboard={(): void => {
|
||||
void handleCreate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.templatesPanel}>
|
||||
<span className={styles.templatesIcon}>
|
||||
<LayoutDashboard size={20} />
|
||||
</span>
|
||||
<Typography variant="title" size="lg" weight="semibold">
|
||||
Dashboard templates
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="text"
|
||||
size="sm"
|
||||
color="muted"
|
||||
className={styles.templatesDesc}
|
||||
>
|
||||
Browse our library of ready-made dashboards, or request a new one and
|
||||
we'll build it for you.
|
||||
</Typography>
|
||||
|
||||
<a
|
||||
className={styles.browseLink}
|
||||
href={TEMPLATES_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Browse dashboard templates
|
||||
<SquareArrowOutUpRight size={14} />
|
||||
</a>
|
||||
|
||||
{isCloudUser && (
|
||||
<div className={styles.requestForm}>
|
||||
<Input
|
||||
className={styles.requestInput}
|
||||
placeholder="Enter dashboard name..."
|
||||
value={name}
|
||||
testId="request-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleRequest();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={submitting || requestName.length === 0}
|
||||
testId="request-dashboard-submit"
|
||||
prefix={
|
||||
submitting ? (
|
||||
<LoaderCircle size={14} className={styles.spinner} />
|
||||
) : (
|
||||
<Check size={14} />
|
||||
)
|
||||
}
|
||||
onClick={(): void => {
|
||||
void handleRequest();
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ export type DashboardListItem = DashboardtypesListedDashboardForUserV2DTO;
|
||||
export const tagsToStrings = (
|
||||
tags: { key: string; value: string }[] | null | undefined,
|
||||
): string[] =>
|
||||
(tags ?? []).map((tag) =>
|
||||
tag.key === tag.value ? tag.key : `${tag.key}:${tag.value}`,
|
||||
);
|
||||
// Always render both sides (so an env:env tag reads "env:env", not a lone
|
||||
// "env"); fall back to the bare key only when there's no value.
|
||||
(tags ?? []).map((tag) => (tag.value ? `${tag.key}:${tag.value}` : tag.key));
|
||||
|
||||
// Convert validated `key:value` tag strings (from TagKeyValueInput) into the
|
||||
// postable tag DTO shape. The first colon separates key from value.
|
||||
|
||||
@@ -47,7 +47,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
|
||||
Description: "This endpoint lists all roles",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*authtypes.Role, 0),
|
||||
Response: make([]*authtypes.GettableRole, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
@@ -73,7 +73,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
|
||||
Description: "This endpoint gets a role",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.RoleWithTransactionGroups),
|
||||
Response: new(authtypes.Role),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
|
||||
@@ -30,17 +30,14 @@ type AuthZ interface {
|
||||
// Write accepts the insertion tuples and the deletion tuples.
|
||||
Write(context.Context, []*openfgav1.TupleKey, []*openfgav1.TupleKey) error
|
||||
|
||||
// Lists the selectors for objects assigned to subject (s) with relation (r) on resource (s)
|
||||
ListObjects(context.Context, string, authtypes.Relation, coretypes.Type) ([]*coretypes.Object, error)
|
||||
// ReadTuples reads tuples from the authorization server matching the given tuple key filter.
|
||||
ReadTuples(context.Context, *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error)
|
||||
|
||||
// Creates the role with its transaction groups.
|
||||
Create(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
|
||||
|
||||
// Gets the role if it exists or creates one.
|
||||
GetOrCreate(context.Context, valuer.UUID, *authtypes.Role) (*authtypes.Role, error)
|
||||
Create(context.Context, valuer.UUID, *authtypes.Role) error
|
||||
|
||||
// Updates the role's metadata and reconciles its transaction groups.
|
||||
Update(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
|
||||
Update(context.Context, valuer.UUID, *authtypes.Role) error
|
||||
|
||||
// Deletes the role and tuples in authorization server.
|
||||
Delete(context.Context, valuer.UUID, valuer.UUID) error
|
||||
@@ -48,9 +45,6 @@ type AuthZ interface {
|
||||
// Gets the role
|
||||
Get(context.Context, valuer.UUID, valuer.UUID) (*authtypes.Role, error)
|
||||
|
||||
// Gets the role with transaction groups
|
||||
GetWithTransactionGroups(context.Context, valuer.UUID, valuer.UUID) (*authtypes.RoleWithTransactionGroups, error)
|
||||
|
||||
// Gets the role by org_id and name
|
||||
GetByOrgIDAndName(context.Context, valuer.UUID, string) (*authtypes.Role, error)
|
||||
|
||||
@@ -80,9 +74,6 @@ type AuthZ interface {
|
||||
|
||||
// Bootstrap managed roles transactions and user assignments
|
||||
CreateManagedUserRoleTransactions(context.Context, valuer.UUID, valuer.UUID) error
|
||||
|
||||
// ReadTuples reads tuples from the authorization server matching the given tuple key filter.
|
||||
ReadTuples(context.Context, *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error)
|
||||
}
|
||||
|
||||
// OnBeforeRoleDelete is a callback invoked before a role is deleted.
|
||||
|
||||
@@ -83,10 +83,6 @@ func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.
|
||||
return provider.store.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
|
||||
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
|
||||
return provider.store.GetByOrgIDAndName(ctx, orgID, name)
|
||||
}
|
||||
@@ -181,15 +177,11 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
|
||||
return provider.Grant(ctx, orgID, []string{authtypes.SigNozAdminRoleName}, authtypes.MustNewSubject(coretypes.NewResourceUser(), userID.String(), orgID, nil))
|
||||
}
|
||||
|
||||
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.RoleWithTransactionGroups) error {
|
||||
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
|
||||
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) GetOrCreate(_ context.Context, _ valuer.UUID, _ *authtypes.Role) (*authtypes.Role, error) {
|
||||
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) Update(_ context.Context, _ valuer.UUID, _ *authtypes.RoleWithTransactionGroups) error {
|
||||
func (provider *provider) Update(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
|
||||
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
|
||||
@@ -36,14 +36,14 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
roleWithTransactionGroups := authtypes.NewRoleWithTransactionGroups(req.Name, req.Description, authtypes.RoleTypeCustom, valuer.MustNewUUID(claims.OrgID), req.TransactionGroups)
|
||||
err = handler.authz.Create(ctx, valuer.MustNewUUID(claims.OrgID), roleWithTransactionGroups)
|
||||
role := authtypes.NewRole(req.Name, req.Description, authtypes.RoleTypeCustom, valuer.MustNewUUID(claims.OrgID), req.TransactionGroups)
|
||||
err = handler.authz.Create(ctx, valuer.MustNewUUID(claims.OrgID), role)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, types.Identifiable{ID: roleWithTransactionGroups.ID})
|
||||
render.Success(rw, http.StatusCreated, types.Identifiable{ID: role.ID})
|
||||
}
|
||||
|
||||
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -65,13 +65,13 @@ func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
roleWithTransactionGroups, err := handler.authz.GetWithTransactionGroups(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
|
||||
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, roleWithTransactionGroups)
|
||||
render.Success(rw, http.StatusOK, role)
|
||||
}
|
||||
|
||||
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -88,7 +88,7 @@ func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, roles)
|
||||
render.Success(rw, http.StatusOK, authtypes.NewGettableRolesFromRoles(roles))
|
||||
}
|
||||
|
||||
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -117,14 +117,13 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
roleWithTransactionGroups := authtypes.MakeRoleWithTransactionGroups(role, nil)
|
||||
err = roleWithTransactionGroups.Update(req.Description, req.TransactionGroups)
|
||||
err = role.Update(req.Description, req.TransactionGroups)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), roleWithTransactionGroups)
|
||||
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), role)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -42,7 +42,6 @@ type querier struct {
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
promEngine prometheus.Prometheus
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
@@ -62,7 +61,6 @@ func New(
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
promEngine prometheus.Prometheus,
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
|
||||
@@ -84,7 +82,6 @@ func New(
|
||||
metadataStore: metadataStore,
|
||||
promEngine: promEngine,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
aiTraceStmtBuilder: aiTraceStmtBuilder,
|
||||
logStmtBuilder: logStmtBuilder,
|
||||
auditStmtBuilder: auditStmtBuilder,
|
||||
metricStmtBuilder: metricStmtBuilder,
|
||||
@@ -238,18 +235,10 @@ func (q *querier) buildQueries(
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
||||
stmtBuilder := q.traceStmtBuilder
|
||||
if spec.Source == telemetrytypes.SourceAI {
|
||||
event.Source = telemetrytypes.SourceAI.StringValue()
|
||||
stmtBuilder = q.aiTraceStmtBuilder
|
||||
}
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, stmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
queries[spec.Name] = bq
|
||||
steps[spec.Name] = spec.StepInterval
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
||||
if spec.Source == telemetrytypes.SourceAI {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "source \"ai\" is only supported for the traces signal, not logs")
|
||||
}
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
||||
stmtBuilder := q.logStmtBuilder
|
||||
@@ -260,9 +249,6 @@ func (q *querier) buildQueries(
|
||||
queries[spec.Name] = bq
|
||||
steps[spec.Name] = spec.StepInterval
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
|
||||
if spec.Source == telemetrytypes.SourceAI {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "source \"ai\" is only supported for the traces signal, not metrics")
|
||||
}
|
||||
// Spec was already patched by resolveMetricMetadata. Queries
|
||||
// whose every aggregation was missing live in
|
||||
// missingMetricQuerySet and produce empty preseeded results
|
||||
@@ -872,11 +858,7 @@ func (q *querier) createRangedQuery(originalQuery qbtypes.Query, timeRange qbtyp
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
shiftStmtBuilder := q.traceStmtBuilder
|
||||
if qt.spec.Source == telemetrytypes.SourceAI {
|
||||
shiftStmtBuilder = q.aiTraceStmtBuilder
|
||||
}
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, shiftStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
|
||||
case *builderQuery[qbtypes.LogAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
|
||||
@@ -49,7 +49,6 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
@@ -122,7 +121,6 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
&mockMetricStmtBuilder{}, // metricStmtBuilder
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryai"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryaudit"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetadata"
|
||||
@@ -93,30 +92,6 @@ func newProvider(
|
||||
cfg.SkipResourceFingerprint.Threshold,
|
||||
)
|
||||
|
||||
// AI trace statement builder (source=ai). The gen_ai gate/column keys are
|
||||
// surfaced by the metadata store itself (enrichWithGenAIKeys), so queries work
|
||||
// before any gen_ai metadata is ingested — no per-builder decoration needed.
|
||||
aiBaseCondition := telemetryai.NewGenAIBaseConditionProvider()
|
||||
aiDelegateTraceStmtBuilder := telemetrytraces.NewTraceQueryStatementBuilder(
|
||||
settings,
|
||||
telemetryMetadataStore,
|
||||
traceFieldMapper,
|
||||
traceConditionBuilder,
|
||||
traceAggExprRewriter,
|
||||
telemetryStore,
|
||||
flagger,
|
||||
cfg.SkipResourceFingerprint.Enabled,
|
||||
cfg.SkipResourceFingerprint.Threshold,
|
||||
)
|
||||
aiTraceStmtBuilder := telemetryai.NewAITraceStatementBuilder(
|
||||
settings,
|
||||
telemetryMetadataStore,
|
||||
traceFieldMapper,
|
||||
traceConditionBuilder,
|
||||
aiBaseCondition,
|
||||
aiDelegateTraceStmtBuilder,
|
||||
)
|
||||
|
||||
// Create trace operator statement builder
|
||||
traceOperatorStmtBuilder := telemetrytraces.NewTraceOperatorStatementBuilder(
|
||||
settings,
|
||||
@@ -210,7 +185,6 @@ func newProvider(
|
||||
telemetryMetadataStore,
|
||||
prometheus,
|
||||
traceStmtBuilder,
|
||||
aiTraceStmtBuilder,
|
||||
logStmtBuilder,
|
||||
auditStmtBuilder,
|
||||
metricStmtBuilder,
|
||||
|
||||
@@ -48,7 +48,6 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
metricStmtBuilder,
|
||||
@@ -104,7 +103,6 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
logStmtBuilder, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
@@ -154,7 +152,6 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
traceStmtBuilder, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/antlr4-go/antlr/v4"
|
||||
)
|
||||
|
||||
// SplitFilterForAggregates partitions a single filter expression into a span-level
|
||||
// part (a WHERE over spans) and a trace-level part (a HAVING over per-trace
|
||||
// aggregates), splitting on the top-level AND.
|
||||
//
|
||||
// A key is trace-level when it carries the `trace`/`tracefield` field context (e.g.
|
||||
// `trace.completion_tokens`) or, with no explicit context, its name is in
|
||||
// aggregateNames. Trace-level and span-level keys may be AND-combined (they run at
|
||||
// different query stages) but not OR-combined; an OR that mixes the two is an error.
|
||||
//
|
||||
// Syntax errors are ignored here — each part is re-parsed downstream (PrepareWhereClause
|
||||
// for the span part, the HAVING rewriter for the trace part), which surface them.
|
||||
func SplitFilterForAggregates(query string, aggregateNames map[string]struct{}) (spanExpr string, havingExpr string, err error) {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
s := filterSplitter{query: query, aggregateNames: aggregateNames}
|
||||
s.visit(parseFilterQuery(query))
|
||||
|
||||
if s.mixedOR {
|
||||
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"trace-level and span-level filters cannot be combined with OR; use AND")
|
||||
}
|
||||
return strings.Join(s.span, " AND "), strings.Join(s.having, " AND "), nil
|
||||
}
|
||||
|
||||
func parseFilterQuery(query string) antlr.Tree {
|
||||
lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(query))
|
||||
lexer.RemoveErrorListeners()
|
||||
parser := grammar.NewFilterQueryParser(antlr.NewCommonTokenStream(lexer, 0))
|
||||
parser.RemoveErrorListeners()
|
||||
return parser.Query()
|
||||
}
|
||||
|
||||
// filterSplitter walks the parse tree once, flattening the top-level AND chain and
|
||||
// routing each atom (a comparison, a NOT expression, or a whole multi-branch OR group)
|
||||
// to the span or having bucket by the class of the keys it references.
|
||||
type filterSplitter struct {
|
||||
query string
|
||||
aggregateNames map[string]struct{}
|
||||
span []string
|
||||
having []string
|
||||
mixedOR bool
|
||||
}
|
||||
|
||||
func (s *filterSplitter) visit(node antlr.Tree) {
|
||||
switch n := node.(type) {
|
||||
case *grammar.QueryContext:
|
||||
if n.Expression() != nil {
|
||||
s.visit(n.Expression())
|
||||
}
|
||||
case *grammar.ExpressionContext:
|
||||
if n.OrExpression() != nil {
|
||||
s.visit(n.OrExpression())
|
||||
}
|
||||
case *grammar.OrExpressionContext:
|
||||
// a single branch is just an AND chain; multiple branches are a real OR, kept
|
||||
// whole so a class-mixing OR can be rejected.
|
||||
if ands := n.AllAndExpression(); len(ands) == 1 {
|
||||
s.visit(ands[0])
|
||||
} else {
|
||||
s.route(n)
|
||||
}
|
||||
case *grammar.AndExpressionContext:
|
||||
for _, u := range n.AllUnaryExpression() {
|
||||
s.visit(u)
|
||||
}
|
||||
case *grammar.UnaryExpressionContext:
|
||||
if n.NOT() != nil {
|
||||
s.route(n)
|
||||
} else if n.Primary() != nil {
|
||||
s.visit(n.Primary())
|
||||
}
|
||||
case *grammar.PrimaryContext:
|
||||
if n.OrExpression() != nil { // parenthesized sub-expression
|
||||
s.visit(n.OrExpression())
|
||||
} else {
|
||||
s.route(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// route classifies an atom and appends its original source text to the right bucket.
|
||||
func (s *filterSplitter) route(atom antlr.ParserRuleContext) {
|
||||
isTrace, isSpan := classifyKeys(atom, s.aggregateNames)
|
||||
if isTrace && isSpan {
|
||||
s.mixedOR = true
|
||||
return
|
||||
}
|
||||
text := atomSourceText(s.query, atom)
|
||||
if isTrace {
|
||||
s.having = append(s.having, text)
|
||||
} else {
|
||||
s.span = append(s.span, text)
|
||||
}
|
||||
}
|
||||
|
||||
// classifyKeys reports whether a subtree references trace-level and/or span-level keys.
|
||||
// Explicit contexts win; an unspecified-context name is trace-level if it is an
|
||||
// aggregate name (bare or with the `trace.` prefix).
|
||||
func classifyKeys(node antlr.Tree, aggregateNames map[string]struct{}) (isTrace, isSpan bool) {
|
||||
kc, ok := node.(*grammar.KeyContext)
|
||||
if ok {
|
||||
key := telemetrytypes.GetFieldKeyFromKeyText(kc.GetText())
|
||||
switch {
|
||||
case key.FieldContext == telemetrytypes.FieldContextTrace:
|
||||
isTrace = true
|
||||
case key.FieldContext == telemetrytypes.FieldContextUnspecified:
|
||||
if _, agg := aggregateNames[strings.TrimPrefix(key.Name, "trace.")]; agg {
|
||||
isTrace = true
|
||||
} else {
|
||||
isSpan = true
|
||||
}
|
||||
default:
|
||||
isSpan = true
|
||||
}
|
||||
return
|
||||
}
|
||||
for i := 0; i < node.GetChildCount(); i++ {
|
||||
t, s := classifyKeys(node.GetChild(i), aggregateNames)
|
||||
isTrace = isTrace || t
|
||||
isSpan = isSpan || s
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// atomSourceText returns the original source substring for an atom, preserving
|
||||
// whitespace. The token stream drops skipped whitespace, which would glue word
|
||||
// operators (OR/AND/NOT) to their operands, so slice the input by char offsets.
|
||||
func atomSourceText(query string, atom antlr.ParserRuleContext) string {
|
||||
start, stop := atom.GetStart(), atom.GetStop()
|
||||
if start == nil || stop == nil || start.GetStart() < 0 || stop.GetStop() >= len(query) || stop.GetStop() < start.GetStart() {
|
||||
return atom.GetText()
|
||||
}
|
||||
return query[start.GetStart() : stop.GetStop()+1]
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitFilterForAggregates(t *testing.T) {
|
||||
agg := map[string]struct{}{"completion_tokens": {}, "span_count": {}, "prompt_tokens": {}}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
query string
|
||||
wantSpan string // substring expected in span part ("" => span empty)
|
||||
wantHaving string // substring expected in having part ("" => having empty)
|
||||
wantErr bool
|
||||
}{
|
||||
{"span only", "service.name = 'x'", "service.name = 'x'", "", false},
|
||||
{"agg only bare", "completion_tokens > 1000", "", "completion_tokens > 1000", false},
|
||||
{"agg only trace ctx", "trace.completion_tokens > 1000", "", "trace.completion_tokens > 1000", false},
|
||||
{"span AND agg", "service.name = 'x' AND completion_tokens > 1000", "service.name = 'x'", "completion_tokens > 1000", false},
|
||||
// the whitespace-preservation case: OR between two aggregates must keep spaces
|
||||
{"agg OR agg", "completion_tokens > 1000 OR span_count > 3", "", "completion_tokens > 1000 OR span_count > 3", false},
|
||||
{"span OR span", "service.name = 'x' OR kind_string = 'Internal'", "service.name = 'x' OR kind_string = 'Internal'", "", false},
|
||||
{"agg OR span rejected", "completion_tokens > 1000 OR service.name = 'x'", "", "", true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
span, having, err := SplitFilterForAggregates(tc.query, agg)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("query %q: err=%v wantErr=%v", tc.query, err, tc.wantErr)
|
||||
}
|
||||
if tc.wantErr {
|
||||
return
|
||||
}
|
||||
if tc.wantSpan == "" && span != "" {
|
||||
t.Errorf("query %q: expected empty span, got %q", tc.query, span)
|
||||
}
|
||||
if tc.wantSpan != "" && !strings.Contains(span, tc.wantSpan) {
|
||||
t.Errorf("query %q: span %q missing %q", tc.query, span, tc.wantSpan)
|
||||
}
|
||||
if tc.wantHaving == "" && having != "" {
|
||||
t.Errorf("query %q: expected empty having, got %q", tc.query, having)
|
||||
}
|
||||
if tc.wantHaving != "" && !strings.Contains(having, tc.wantHaving) {
|
||||
t.Errorf("query %q: having %q missing %q", tc.query, having, tc.wantHaving)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,19 +18,6 @@ func NewHavingExpressionRewriter() *HavingExpressionRewriter {
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite rewrites and validates a HAVING expression against a caller-supplied
|
||||
// column map (user-facing name -> SQL identifier/expression). Values are inlined, so
|
||||
// the result is a bare SQL boolean expression with no bound args. Used by callers
|
||||
// that project their own aggregate columns (e.g. the AI trace list) rather than the
|
||||
// query's Aggregations.
|
||||
func (r *HavingExpressionRewriter) Rewrite(expression string, columnMap map[string]string) (string, error) {
|
||||
if len(strings.TrimSpace(expression)) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
r.columnMap = columnMap
|
||||
return r.rewriteAndValidate(expression)
|
||||
}
|
||||
|
||||
// RewriteForTraces rewrites and validates the HAVING expression for a traces query.
|
||||
func (r *HavingExpressionRewriter) RewriteForTraces(expression string, aggregations []qbtypes.TraceAggregation) (string, error) {
|
||||
if len(strings.TrimSpace(expression)) == 0 {
|
||||
|
||||
@@ -218,6 +218,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewMigrateSSORoleMappingNamesFactory(sqlstore),
|
||||
sqlmigration.NewAddMetricReductionRulesFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewRemoveOrganizationTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddRoleTransactionGroupsFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package sqlmigration
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
@@ -18,6 +19,30 @@ type addManagedRoles struct {
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
type role struct {
|
||||
bun.BaseModel `bun:"table:role"`
|
||||
|
||||
ID valuer.UUID `bun:"id,pk,type:text"`
|
||||
CreatedAt time.Time `bun:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at"`
|
||||
Name string `bun:"name,type:string"`
|
||||
Description string `bun:"description,type:string"`
|
||||
Type string `bun:"type,type:string"`
|
||||
OrgID valuer.UUID `bun:"org_id,type:string"`
|
||||
}
|
||||
|
||||
func newManagedRole(name, description string, orgID valuer.UUID) *role {
|
||||
return &role{
|
||||
ID: valuer.GenerateUUID(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Name: name,
|
||||
Description: description,
|
||||
Type: authtypes.RoleTypeManaged.StringValue(),
|
||||
OrgID: orgID,
|
||||
}
|
||||
}
|
||||
|
||||
func NewAddManagedRolesFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_managed_roles"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return newAddManagedRoles(ctx, ps, c, sqlstore, sqlschema)
|
||||
@@ -54,28 +79,19 @@ func (migration *addManagedRoles) Up(ctx context.Context, db *bun.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
managedRoles := []*authtypes.Role{}
|
||||
managedRoles := []*role{}
|
||||
for _, orgIDStr := range orgIDs {
|
||||
orgID, err := valuer.NewUUID(orgIDStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// signoz admin
|
||||
signozAdminRole := authtypes.NewRole(authtypes.SigNozAdminRoleName, authtypes.SigNozAdminRoleDescription, authtypes.RoleTypeManaged, orgID)
|
||||
managedRoles = append(managedRoles, signozAdminRole)
|
||||
|
||||
// signoz editor
|
||||
signozEditorRole := authtypes.NewRole(authtypes.SigNozEditorRoleName, authtypes.SigNozEditorRoleDescription, authtypes.RoleTypeManaged, orgID)
|
||||
managedRoles = append(managedRoles, signozEditorRole)
|
||||
|
||||
// signoz viewer
|
||||
signozViewerRole := authtypes.NewRole(authtypes.SigNozViewerRoleName, authtypes.SigNozViewerRoleDescription, authtypes.RoleTypeManaged, orgID)
|
||||
managedRoles = append(managedRoles, signozViewerRole)
|
||||
|
||||
// signoz anonymous
|
||||
signozAnonymousRole := authtypes.NewRole(authtypes.SigNozAnonymousRoleName, authtypes.SigNozAnonymousRoleDescription, authtypes.RoleTypeManaged, orgID)
|
||||
managedRoles = append(managedRoles, signozAnonymousRole)
|
||||
managedRoles = append(managedRoles,
|
||||
newManagedRole(authtypes.SigNozAdminRoleName, authtypes.SigNozAdminRoleDescription, orgID),
|
||||
newManagedRole(authtypes.SigNozEditorRoleName, authtypes.SigNozEditorRoleDescription, orgID),
|
||||
newManagedRole(authtypes.SigNozViewerRoleName, authtypes.SigNozViewerRoleDescription, orgID),
|
||||
newManagedRole(authtypes.SigNozAnonymousRoleName, authtypes.SigNozAnonymousRoleDescription, orgID),
|
||||
)
|
||||
}
|
||||
|
||||
if len(managedRoles) > 0 {
|
||||
|
||||
164
pkg/sqlmigration/099_add_role_transaction_groups.go
Normal file
164
pkg/sqlmigration/099_add_role_transaction_groups.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addRoleTransactionGroups struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
type roles struct {
|
||||
bun.BaseModel `bun:"table:role"`
|
||||
|
||||
ID string `bun:"id,pk"`
|
||||
Name string `bun:"name"`
|
||||
OrgID string `bun:"org_id"`
|
||||
}
|
||||
|
||||
func NewAddRoleTransactionGroupsFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("add_role_transaction_groups"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addRoleTransactionGroups{
|
||||
sqlstore: sqlstore,
|
||||
sqlschema: sqlschema,
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *addRoleTransactionGroups) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addRoleTransactionGroups) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
table, _, err := migration.sqlschema.GetTable(ctx, "role")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
column := &sqlschema.Column{
|
||||
Name: sqlschema.ColumnName("transaction_groups"),
|
||||
DataType: sqlschema.DataTypeText,
|
||||
Nullable: true,
|
||||
}
|
||||
|
||||
sqls := migration.sqlschema.Operator().AddColumn(table, nil, column, nil)
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var storeID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
customRoles := make([]*roles, 0)
|
||||
err = tx.NewSelect().
|
||||
Model(&customRoles).
|
||||
Column("id", "name", "org_id").
|
||||
Where("type = ?", authtypes.RoleTypeCustom.StringValue()).
|
||||
Where("transaction_groups IS NULL").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
for _, role := range customRoles {
|
||||
roleSubject := "organization/" + role.OrgID + "/role/" + role.Name
|
||||
|
||||
var tupleRows *sql.Rows
|
||||
if isPG {
|
||||
tupleRows, err = tx.QueryContext(ctx, `
|
||||
SELECT object_type, object_id, relation FROM tuple
|
||||
WHERE store = ? AND _user = ?`,
|
||||
storeID, "role:"+roleSubject+"#assignee",
|
||||
)
|
||||
} else {
|
||||
tupleRows, err = tx.QueryContext(ctx, `
|
||||
SELECT object_type, object_id, relation FROM tuple
|
||||
WHERE store = ? AND user_object_type = ? AND user_object_id = ? AND user_relation = ?`,
|
||||
storeID, "role", roleSubject, "assignee",
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tuples := make([]*openfgav1.TupleKey, 0)
|
||||
for tupleRows.Next() {
|
||||
var objectType, objectID, relation string
|
||||
if err := tupleRows.Scan(&objectType, &objectID, &relation); err != nil {
|
||||
tupleRows.Close()
|
||||
return err
|
||||
}
|
||||
tuples = append(tuples, &openfgav1.TupleKey{Object: objectType + ":" + objectID, Relation: relation})
|
||||
}
|
||||
if err := tupleRows.Err(); err != nil {
|
||||
tupleRows.Close()
|
||||
return err
|
||||
}
|
||||
tupleRows.Close()
|
||||
|
||||
groups := authtypes.MustNewTransactionGroupsFromTuples(tuples)
|
||||
data, err := json.Marshal(groups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.NewUpdate().
|
||||
Model(new(roles)).
|
||||
Set("transaction_groups = ?", string(data)).
|
||||
Where("id = ?", role.ID).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
|
||||
groups := authtypes.NewTransactionGroupsFromTransactions(transactions)
|
||||
data, err := json.Marshal(groups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.NewUpdate().
|
||||
Model(new(roles)).
|
||||
Set("transaction_groups = ?", string(data)).
|
||||
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
|
||||
Where("name = ?", roleName).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addRoleTransactionGroups) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package telemetryai
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// BaseConditionProvider defines which spans (and traces) are in scope, decoupled
|
||||
// from the query. Swap it to redefine "an AI trace" without touching the builder.
|
||||
//
|
||||
// It only *declares* the gate (a grammar expression + its field keys); the builder
|
||||
// resolves those keys through the field mapper, so all attribute access is
|
||||
// materialization/evolution aware — no hardcoded map lookups.
|
||||
type BaseConditionProvider interface {
|
||||
// FilterExpression is the grammar-level (EXISTS) gate, resolved via the visitor.
|
||||
FilterExpression() string
|
||||
// FieldKeys are the gate's keys: registered in metadata and used to build the
|
||||
// per-span mask (OR of resolved EXISTS conditions).
|
||||
FieldKeys() []*telemetrytypes.TelemetryFieldKey
|
||||
}
|
||||
|
||||
// genAIBaseConditionProvider: an AI trace has >=1 gen_ai LLM, tool, or agent span.
|
||||
type genAIBaseConditionProvider struct {
|
||||
keys []string
|
||||
}
|
||||
|
||||
var _ BaseConditionProvider = (*genAIBaseConditionProvider)(nil)
|
||||
|
||||
func NewGenAIBaseConditionProvider() BaseConditionProvider {
|
||||
return &genAIBaseConditionProvider{
|
||||
keys: []string{telemetrytypes.GenAIRequestModel, telemetrytypes.GenAIToolName, telemetrytypes.GenAIAgentName},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *genAIBaseConditionProvider) FilterExpression() string {
|
||||
parts := make([]string, 0, len(p.keys))
|
||||
for _, k := range p.keys {
|
||||
parts = append(parts, k+" EXISTS")
|
||||
}
|
||||
return strings.Join(parts, " OR ")
|
||||
}
|
||||
|
||||
func (p *genAIBaseConditionProvider) FieldKeys() []*telemetrytypes.TelemetryFieldKey {
|
||||
keys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(p.keys))
|
||||
for _, k := range p.keys {
|
||||
keys = append(keys, &telemetrytypes.TelemetryFieldKey{
|
||||
Name: k,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
})
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package telemetryai
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
type TraceColumn struct {
|
||||
Alias string
|
||||
// Orderable marks a column computable in the mask-pruned `matched` pass (a
|
||||
// gen_ai-scoped aggregate): the only columns usable for ORDER BY and the
|
||||
// aggregate filter. All-span columns (span_count, duration_nano, …) are false —
|
||||
// they are output-only, since the matched pass sees only gen_ai spans.
|
||||
Orderable bool
|
||||
Intrinsic string // fixed SQL over intrinsic columns; used verbatim (nil Attr)
|
||||
Attr *AttrAgg // field-mapper-resolved aggregate (nil Intrinsic)
|
||||
}
|
||||
|
||||
// AttrAgg describes an aggregate the builder assembles after resolving fields:
|
||||
// - Func "count" + ExistsKey -> countIf(<ExistsKey EXISTS>) (e.g. llm_call_count)
|
||||
// - Func + ValueKey -> Func(<resolved value>) (e.g. sum tokens)
|
||||
// - Func + ValueExpr + Scoped -> FuncIf(<value>, <gate mask>) (e.g. last_activity_time)
|
||||
type AttrAgg struct {
|
||||
Func string // sum | max | min | count
|
||||
ValueKey *telemetrytypes.TelemetryFieldKey // attribute value to aggregate (resolved as Float64)
|
||||
ValueExpr string // fixed value expr when ValueKey is nil (e.g. "timestamp")
|
||||
Scoped bool // wrap as <Func>If(value, <gate mask>)
|
||||
ExistsKey *telemetrytypes.TelemetryFieldKey // count spans where this key exists (countIf)
|
||||
}
|
||||
|
||||
// ProjectionProvider decides which per-trace columns the list computes and which
|
||||
// are sortable, decoupled from selection and topology.
|
||||
type ProjectionProvider interface {
|
||||
Columns() []TraceColumn
|
||||
// DefaultOrderAlias is sorted by (desc) when the query gives no order.
|
||||
DefaultOrderAlias() string
|
||||
// AggregateAliases are the computed per-trace (trace-level) column names, used to
|
||||
// classify which filter keys are trace-level vs span-level. Only the Orderable
|
||||
// subset is actually usable in ORDER BY / the aggregate filter; the rest are
|
||||
// output-only. Excludes aliases that are also real span/resource keys (service.name).
|
||||
AggregateAliases() []string
|
||||
}
|
||||
|
||||
// CommonTraceColumns are domain-neutral intrinsic columns any trace list can reuse.
|
||||
// All are over-all-spans intrinsics, so none is Orderable (not computable in the
|
||||
// mask-pruned matched pass) — they are output-only, computed in the enrichment scan.
|
||||
func CommonTraceColumns() []TraceColumn {
|
||||
return []TraceColumn{
|
||||
{Alias: "start_time", Intrinsic: "min(timestamp)", Orderable: false},
|
||||
{Alias: "end_time", Intrinsic: "max(timestamp)", Orderable: false},
|
||||
{Alias: "duration_nano", Intrinsic: "(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp)))", Orderable: false},
|
||||
{Alias: "span_count", Intrinsic: "count()", Orderable: false},
|
||||
|
||||
{Alias: "root_span_name", Intrinsic: "anyIf(name, parent_span_id = '')", Orderable: false},
|
||||
{Alias: "service.name", Intrinsic: "any(resource_string_service$$name)", Orderable: false},
|
||||
}
|
||||
}
|
||||
|
||||
// genAIProjectionProvider adds AI/LLM per-trace metrics to the common columns.
|
||||
type genAIProjectionProvider struct{}
|
||||
|
||||
var _ ProjectionProvider = (*genAIProjectionProvider)(nil)
|
||||
|
||||
func NewGenAIProjectionProvider() ProjectionProvider {
|
||||
return &genAIProjectionProvider{}
|
||||
}
|
||||
|
||||
func (genAIProjectionProvider) Columns() []TraceColumn {
|
||||
// Key definitions (name/context/type) live once in GenAIFieldDefinitions, shared
|
||||
// with the metadata enrichment. Copy to locals so we can take their address.
|
||||
reqModel := telemetrytypes.GenAIFieldDefinitions[telemetrytypes.GenAIRequestModel]
|
||||
inTok := telemetrytypes.GenAIFieldDefinitions[telemetrytypes.GenAIUsageInputTokens]
|
||||
outTok := telemetrytypes.GenAIFieldDefinitions[telemetrytypes.GenAIUsageOutputTokens]
|
||||
|
||||
cols := CommonTraceColumns()
|
||||
return append(cols,
|
||||
// LLM calls only (request model present), not the full gate (tool/agent too).
|
||||
TraceColumn{Alias: "llm_call_count", Orderable: true, Attr: &AttrAgg{Func: "count", ExistsKey: &reqModel}},
|
||||
// tokens live only on LLM spans, so a plain sum over resolved values (NULL
|
||||
// elsewhere) is correct without scoping to the mask. Aliases match the OTel
|
||||
// source attributes (gen_ai.usage.input_tokens / output_tokens).
|
||||
TraceColumn{Alias: "input_tokens", Orderable: true, Attr: &AttrAgg{Func: "sum", ValueKey: &inTok}},
|
||||
TraceColumn{Alias: "output_tokens", Orderable: true, Attr: &AttrAgg{Func: "sum", ValueKey: &outTok}},
|
||||
// timestamp of the last in-scope (gen_ai: LLM/tool/agent) span.
|
||||
TraceColumn{Alias: "last_activity_time", Orderable: true, Attr: &AttrAgg{Func: "max", ValueExpr: "timestamp", Scoped: true}},
|
||||
)
|
||||
}
|
||||
|
||||
func (genAIProjectionProvider) DefaultOrderAlias() string { return "last_activity_time" }
|
||||
|
||||
func (genAIProjectionProvider) AggregateAliases() []string {
|
||||
// every computed column except service.name (a real resource key, filterable
|
||||
// at the span level). Of these, only the gen_ai-scoped ones (llm_call_count,
|
||||
// input_tokens, output_tokens, last_activity_time) are Orderable and thus usable
|
||||
// in ORDER BY / the aggregate filter; the rest are output-only.
|
||||
return []string{
|
||||
"start_time", "end_time", "duration_nano", "span_count", "root_span_name",
|
||||
"llm_call_count", "input_tokens", "output_tokens", "last_activity_time",
|
||||
}
|
||||
}
|
||||
@@ -1,722 +0,0 @@
|
||||
package telemetryai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsupportedRequestType = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type for source=ai")
|
||||
)
|
||||
|
||||
// scopedTraceStatementBuilder builds a trace list scoped to a span-selection
|
||||
// category. Topology is fixed; selection (BaseConditionProvider) and columns
|
||||
// (ProjectionProvider) are pluggable, so a new category is a new pair of
|
||||
// providers, not new topology.
|
||||
type scopedTraceStatementBuilder struct {
|
||||
logger *slog.Logger
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
fm qbtypes.FieldMapper
|
||||
cb qbtypes.ConditionBuilder
|
||||
baseCond BaseConditionProvider
|
||||
projection ProjectionProvider
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
}
|
||||
|
||||
var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*scopedTraceStatementBuilder)(nil)
|
||||
|
||||
// NewScopedTraceStatementBuilder wires the generic trace-list builder. The trace
|
||||
// builder is reused for the span-list (raw) path.
|
||||
func NewScopedTraceStatementBuilder(
|
||||
settings factory.ProviderSettings,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
fieldMapper qbtypes.FieldMapper,
|
||||
conditionBuilder qbtypes.ConditionBuilder,
|
||||
baseCond BaseConditionProvider,
|
||||
projection ProjectionProvider,
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
) *scopedTraceStatementBuilder {
|
||||
aiSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetryai")
|
||||
return &scopedTraceStatementBuilder{
|
||||
logger: aiSettings.Logger(),
|
||||
metadataStore: metadataStore,
|
||||
fm: fieldMapper,
|
||||
cb: conditionBuilder,
|
||||
baseCond: baseCond,
|
||||
projection: projection,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
}
|
||||
}
|
||||
|
||||
// NewAITraceStatementBuilder is the scoped builder with the gen_ai gate + AI projection.
|
||||
func NewAITraceStatementBuilder(
|
||||
settings factory.ProviderSettings,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
fieldMapper qbtypes.FieldMapper,
|
||||
conditionBuilder qbtypes.ConditionBuilder,
|
||||
baseCond BaseConditionProvider,
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
) *scopedTraceStatementBuilder {
|
||||
return NewScopedTraceStatementBuilder(settings, metadataStore, fieldMapper, conditionBuilder, baseCond, NewGenAIProjectionProvider(), traceStmtBuilder)
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) Build(
|
||||
ctx context.Context,
|
||||
start uint64,
|
||||
end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
switch requestType {
|
||||
case qbtypes.RequestTypeTrace:
|
||||
return b.buildTraceListQuery(ctx, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), query, variables)
|
||||
case qbtypes.RequestTypeRaw:
|
||||
return b.buildDelegated(ctx, start, end, requestType, query, variables)
|
||||
default:
|
||||
return nil, ErrUnsupportedRequestType
|
||||
}
|
||||
}
|
||||
|
||||
// buildDelegated ANDs the base gate into the user filter and delegates to the
|
||||
// standard trace builder (the span-list / raw path).
|
||||
func (b *scopedTraceStatementBuilder) buildDelegated(
|
||||
ctx context.Context,
|
||||
start, end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
gate := b.baseCond.FilterExpression()
|
||||
expr := gate
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
expr = fmt.Sprintf("(%s) AND (%s)", gate, query.Filter.Expression)
|
||||
}
|
||||
|
||||
// shallow copy; only Filter is replaced, caller's query untouched
|
||||
gated := query
|
||||
gated.Filter = &qbtypes.Filter{Expression: expr}
|
||||
|
||||
return b.traceStmtBuilder.Build(ctx, start, end, requestType, gated, variables)
|
||||
}
|
||||
|
||||
// buildTraceListQuery is the map for the whole file. It resolves the columns, then
|
||||
// wires the CTE pipeline that was benchmarked (see ai-qb-handoff.md): a single
|
||||
// windowed pass picks the top-N traces, then a bucket-pruned pass enriches only those.
|
||||
// The helpers appear in this file in the order they run here.
|
||||
//
|
||||
// RESOLVE (turn keys/columns into SQL, field-mapper aware)
|
||||
// fetchKeys → metadata for the keys we reference
|
||||
// resolveMask → the "is a gen_ai span" predicate (OR of EXISTS) [existsExpr]
|
||||
// resolveColumns → per-trace column SQL: intrinsics + resolved aggregates
|
||||
// resolveListOrders→ which resolved columns to ORDER BY
|
||||
// splitFilter → span-level predicate + trace-level HAVING expression
|
||||
//
|
||||
// BUILD (compose the CTE pipeline)
|
||||
// matched [buildMatchedCTE] ONE windowed, mask-pruned GROUP BY trace_id pass over
|
||||
// │ the span index that applies the gate (+ span filter as
|
||||
// │ countIf existence), the trace-level HAVING, ORDER BY and
|
||||
// │ LIMIT/OFFSET in a single scan → top-N trace_ids + their
|
||||
// │ gen_ai-scoped ranking metrics. No giant gate id-set.
|
||||
// ▼
|
||||
// ranked [buildRankedCTE] per-trace [start,end] bounds for those N traces, read
|
||||
// │ from the small distributed_trace_summary table.
|
||||
// ▼
|
||||
// buckets [buildBucketsCTE] the exact ts_bucket_start values those N traces touch,
|
||||
// │ so the enrichment scan is primary-key pruned.
|
||||
// ▼
|
||||
// enrichment[buildEnrichmentSelect] all per-trace columns for the N traces, scanning
|
||||
// only their buckets (full trace, not window-clipped).
|
||||
//
|
||||
// Only gen_ai-scoped aggregates (tokens, llm activity, llm_call_count) are computable in
|
||||
// the mask-pruned `matched` pass, so only those are orderable / usable in the aggregate
|
||||
// filter. All-span columns (span_count, duration_nano, …) are output-only.
|
||||
//
|
||||
// start/end are nanoseconds.
|
||||
func (b *scopedTraceStatementBuilder) buildTraceListQuery(
|
||||
ctx context.Context,
|
||||
start, end uint64,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
|
||||
startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment
|
||||
endBucket := end / querybuilder.NsToSeconds
|
||||
|
||||
limit := query.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// Resolve the gate keys + columns once; every attribute access below goes through
|
||||
// the field mapper (materialization/evolution aware), never a hardcoded map lookup.
|
||||
keys, err := b.fetchKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maskExpr, maskArgs, err := b.resolveMask(ctx, start, end, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved, err := b.resolveColumns(ctx, start, end, keys, maskExpr, maskArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orders, err := b.resolveListOrders(query.Order, resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderableSet := orderableAliasSet(resolved)
|
||||
|
||||
// Split the user filter: span-level predicate + trace-level HAVING expression.
|
||||
fp, err := b.splitFilter(ctx, query, b.aggregateAliasSet(), orderableSet, start, end, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// matched → ranked → buckets → enrichment
|
||||
matchedFrag, matchedArgs, err := b.buildMatchedCTE(start, end, startBucket, endBucket, resolved, orders, orderableSet, maskExpr, maskArgs, fp, limit, query.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rankedFrag, rankedArgs := b.buildRankedCTE(start, end)
|
||||
bucketsFrag := buildBucketsCTE()
|
||||
mainSQL, mainArgs := b.buildEnrichmentSelect(resolved, orders)
|
||||
|
||||
cteFragments := []string{matchedFrag, rankedFrag, bucketsFrag}
|
||||
cteArgs := [][]any{matchedArgs, rankedArgs, nil}
|
||||
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL + " SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000"
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
return &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: fp.warnings,
|
||||
WarningsDocURL: fp.warningsURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RESOLVE — turn keys/columns into field-mapper-aware SQL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (b *scopedTraceStatementBuilder) fetchKeys(ctx context.Context) (map[string][]*telemetrytypes.TelemetryFieldKey, error) {
|
||||
fields := b.resolverFieldKeys()
|
||||
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(fields))
|
||||
for _, k := range fields {
|
||||
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: k.Name,
|
||||
Signal: k.Signal,
|
||||
FieldContext: k.FieldContext,
|
||||
})
|
||||
}
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, selectors)
|
||||
return keys, err
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) resolverFieldKeys() []*telemetrytypes.TelemetryFieldKey {
|
||||
seen := make(map[string]struct{})
|
||||
var out []*telemetrytypes.TelemetryFieldKey
|
||||
add := func(k *telemetrytypes.TelemetryFieldKey) {
|
||||
if k == nil {
|
||||
return
|
||||
}
|
||||
if _, dup := seen[k.Name]; dup {
|
||||
return
|
||||
}
|
||||
seen[k.Name] = struct{}{}
|
||||
out = append(out, k)
|
||||
}
|
||||
for _, k := range b.baseCond.FieldKeys() {
|
||||
add(k)
|
||||
}
|
||||
for _, c := range b.projection.Columns() {
|
||||
if c.Attr != nil {
|
||||
add(c.Attr.ValueKey)
|
||||
add(c.Attr.ExistsKey)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveMask builds the per-span in-scope mask: OR of resolved EXISTS predicates
|
||||
// over the base condition's field keys.
|
||||
func (b *scopedTraceStatementBuilder) resolveMask(ctx context.Context, start, end uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) (string, []any, error) {
|
||||
fieldKeys := b.baseCond.FieldKeys()
|
||||
parts := make([]string, 0, len(fieldKeys))
|
||||
var args []any
|
||||
for _, key := range fieldKeys {
|
||||
e, a, err := b.existsExpr(ctx, start, end, keys, key)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
parts = append(parts, e)
|
||||
args = append(args, a...)
|
||||
}
|
||||
return "(" + strings.Join(parts, " OR ") + ")", args, nil
|
||||
}
|
||||
|
||||
// existsExpr resolves a field-mapper-aware EXISTS predicate for key (materialized
|
||||
// column when present, else the map). Escaped once so it round-trips when embedded
|
||||
// in an outer builder.
|
||||
func (b *scopedTraceStatementBuilder) existsExpr(ctx context.Context, start, end uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey, key *telemetrytypes.TelemetryFieldKey) (string, []any, error) {
|
||||
resolvedKey := key
|
||||
cands := keys[key.Name]
|
||||
if len(cands) == 0 {
|
||||
cands = []*telemetrytypes.TelemetryFieldKey{key}
|
||||
} else {
|
||||
resolvedKey = cands[0]
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
conds, _, err := b.cb.ConditionFor(ctx, start, end, resolvedKey, cands, qbtypes.FilterOperatorExists, nil, sb)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
sb.Where(conds[0])
|
||||
expr, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
expr = strings.TrimPrefix(expr, "WHERE ")
|
||||
return sqlbuilder.Escape(expr), args, nil
|
||||
}
|
||||
|
||||
// resolvedColumn is a projection column whose attribute access has been resolved to
|
||||
// SQL via the field mapper. expr is escaped once, ready to embed in an outer SELECT.
|
||||
type resolvedColumn struct {
|
||||
alias string
|
||||
expr string
|
||||
args []any
|
||||
orderable bool
|
||||
}
|
||||
|
||||
// resolveColumns turns the projection's declarative columns into SQL, resolving all
|
||||
// attribute access through the field mapper.
|
||||
func (b *scopedTraceStatementBuilder) resolveColumns(ctx context.Context, start, end uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey, maskExpr string, maskArgs []any) ([]resolvedColumn, error) {
|
||||
cols := b.projection.Columns()
|
||||
out := make([]resolvedColumn, 0, len(cols))
|
||||
for _, c := range cols {
|
||||
rc := resolvedColumn{alias: c.Alias, orderable: c.Orderable}
|
||||
|
||||
if c.Attr == nil {
|
||||
// intrinsic: escape once (no-op unless it references a $$ column).
|
||||
rc.expr = sqlbuilder.Escape(c.Intrinsic)
|
||||
out = append(out, rc)
|
||||
continue
|
||||
}
|
||||
|
||||
a := c.Attr
|
||||
switch {
|
||||
case a.Func == "count" && a.ExistsKey != nil:
|
||||
cond, cargs, err := b.existsExpr(ctx, start, end, keys, a.ExistsKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rc.expr = fmt.Sprintf("countIf(%s)", cond)
|
||||
rc.args = cargs
|
||||
default:
|
||||
var vexpr string
|
||||
var vargs []any
|
||||
if a.ValueKey != nil {
|
||||
e, ar, err := querybuilder.CollisionHandledFinalExpr(ctx, start, end, a.ValueKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeFloat64, nil, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vexpr, vargs = e, ar
|
||||
} else {
|
||||
vexpr = a.ValueExpr
|
||||
}
|
||||
if a.Scoped {
|
||||
rc.expr = fmt.Sprintf("%sIf(%s, %s)", a.Func, vexpr, maskExpr)
|
||||
rc.args = append(append([]any{}, vargs...), maskArgs...)
|
||||
} else {
|
||||
rc.expr = fmt.Sprintf("%s(%s)", a.Func, vexpr)
|
||||
rc.args = vargs
|
||||
}
|
||||
}
|
||||
out = append(out, rc)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listOrder resolves a sort key to an aggregate-column alias + direction. Both the
|
||||
// matched CTE and the enrichment select that alias, so both ORDER BY it.
|
||||
type listOrder struct {
|
||||
alias string
|
||||
direction string
|
||||
}
|
||||
|
||||
// resolveListOrders maps order keys to the resolved orderable columns; non-orderable
|
||||
// columns are rejected. Defaults to the projection's default order.
|
||||
func (b *scopedTraceStatementBuilder) resolveListOrders(order []qbtypes.OrderBy, resolved []resolvedColumn) ([]listOrder, error) {
|
||||
byAlias := make(map[string]resolvedColumn, len(resolved))
|
||||
orderable := make([]string, 0, len(resolved))
|
||||
for _, rc := range resolved {
|
||||
byAlias[rc.alias] = rc
|
||||
if rc.orderable {
|
||||
orderable = append(orderable, rc.alias)
|
||||
}
|
||||
}
|
||||
|
||||
if len(order) == 0 {
|
||||
return []listOrder{{alias: b.projection.DefaultOrderAlias(), direction: "DESC"}}, nil
|
||||
}
|
||||
|
||||
orders := make([]listOrder, 0, len(order))
|
||||
for _, o := range order {
|
||||
direction := "DESC"
|
||||
if o.Direction == qbtypes.OrderDirectionAsc {
|
||||
direction = "ASC"
|
||||
}
|
||||
rc, ok := byAlias[o.Key.Name]
|
||||
if !ok || !rc.orderable {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"unsupported order key %q for the trace list; orderable keys: %s", o.Key.Name, strings.Join(orderable, ", "))
|
||||
}
|
||||
orders = append(orders, listOrder{alias: rc.alias, direction: direction})
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// filterParts is the split of the user filter into a resolved span-level predicate
|
||||
// (used both to widen the matched WHERE prune and as a countIf existence in HAVING)
|
||||
// and a trace-level HAVING expression.
|
||||
type filterParts struct {
|
||||
spanPred string
|
||||
spanArgs []any
|
||||
hasSpanFilter bool
|
||||
havingExpr string
|
||||
warnings []string
|
||||
warningsURL string
|
||||
}
|
||||
|
||||
// splitFilter partitions query.Filter into a span-level predicate and a trace-level
|
||||
// HAVING expression; an explicit query.Having is ANDed onto the latter. The trace-level
|
||||
// expression is validated against the aggregates computable in the matched pass.
|
||||
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet, orderableSet map[string]struct{}, start, end uint64, variables map[string]qbtypes.VariableItem) (filterParts, error) {
|
||||
var fp filterParts
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
spanExpr, traceExpr, err := querybuilder.SplitFilterForAggregates(query.Filter.Expression, classifySet)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
fp.havingExpr = traceExpr
|
||||
if strings.TrimSpace(spanExpr) != "" {
|
||||
pred, args, warnings, url, err := b.resolveSpanPredicate(ctx, start, end, spanExpr, variables)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
fp.spanPred, fp.spanArgs, fp.hasSpanFilter = pred, args, true
|
||||
fp.warnings, fp.warningsURL = warnings, url
|
||||
}
|
||||
}
|
||||
if query.Having != nil && strings.TrimSpace(query.Having.Expression) != "" {
|
||||
if fp.havingExpr != "" {
|
||||
fp.havingExpr = fmt.Sprintf("(%s) AND (%s)", fp.havingExpr, query.Having.Expression)
|
||||
} else {
|
||||
fp.havingExpr = query.Having.Expression
|
||||
}
|
||||
}
|
||||
if err := validateAggregateFilter(fp.havingExpr, orderableSet); err != nil {
|
||||
return fp, err
|
||||
}
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
// resolveSpanPredicate resolves a span-level filter expression to a bare boolean SQL
|
||||
// predicate (escaped) + args via the field mapper.
|
||||
func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context, start, end uint64, expr string, variables map[string]qbtypes.VariableItem) (string, []any, []string, string, error) {
|
||||
selectors := querybuilder.QueryStringToKeysSelectors(expr)
|
||||
for i := range selectors {
|
||||
selectors[i].Signal = telemetrytypes.SignalTraces
|
||||
}
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, selectors)
|
||||
if err != nil {
|
||||
return "", nil, nil, "", err
|
||||
}
|
||||
prepared, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{
|
||||
Context: ctx,
|
||||
Logger: b.logger,
|
||||
FieldMapper: b.fm,
|
||||
ConditionBuilder: b.cb,
|
||||
FieldKeys: keys,
|
||||
Variables: variables,
|
||||
StartNs: start,
|
||||
EndNs: end,
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, nil, "", err
|
||||
}
|
||||
if prepared.IsEmpty() {
|
||||
return "", nil, nil, "", nil
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("1")
|
||||
sb.AddWhereClause(prepared.WhereClause)
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
pred := sql[strings.Index(sql, "WHERE ")+len("WHERE "):]
|
||||
return sqlbuilder.Escape(pred), args, prepared.Warnings, prepared.WarningsDocURL, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BUILD — compose the CTE pipeline (matched → ranked → buckets → enrichment)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// buildMatchedCTE builds `matched`: a single windowed GROUP BY trace_id pass that
|
||||
// picks the top-N traces. The WHERE prunes to gen_ai spans (widened with the span-level
|
||||
// predicate when present); HAVING enforces the gate (countIf(mask) > 0), the span-level
|
||||
// filter as an existence check, and the trace-level aggregate filter; ORDER BY + LIMIT
|
||||
// select the winners. Only gen_ai-scoped aggregates are computable here, and only those
|
||||
// actually referenced by ORDER BY / the aggregate filter are selected (the rest are
|
||||
// computed once, later, in the enrichment scan) — this keeps the hot ranking pass lean.
|
||||
func (b *scopedTraceStatementBuilder) buildMatchedCTE(start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, orderableSet map[string]struct{}, maskExpr string, maskArgs []any, fp filterParts, limit, offset int) (string, []any, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
// SELECT trace_id + only the aggregates ORDER BY / HAVING reference (as aliases).
|
||||
needed := neededMatchedAliases(orders, fp.havingExpr, orderableSet)
|
||||
selects := []string{"trace_id"}
|
||||
for _, rc := range resolved {
|
||||
if _, ok := needed[rc.alias]; !ok {
|
||||
continue
|
||||
}
|
||||
selects = append(selects, embedExpr(sb, rc.expr, rc.args)+" AS "+quoteAlias(rc.alias))
|
||||
}
|
||||
sb.Select(selects...)
|
||||
sb.From(spanTable())
|
||||
|
||||
// WHERE: window + coarse prune to gen_ai spans (widened so span-filter spans are
|
||||
// visible for the countIf existence check below).
|
||||
win := windowWhere(sb, start, end, startBucket, endBucket)
|
||||
prune := "(" + embedExpr(sb, maskExpr, maskArgs)
|
||||
if fp.hasSpanFilter {
|
||||
prune += " OR " + embedExpr(sb, fp.spanPred, fp.spanArgs)
|
||||
}
|
||||
prune += ")"
|
||||
sb.Where(append(win, prune)...)
|
||||
sb.GroupBy("trace_id")
|
||||
|
||||
// HAVING: the gate + span-existence checks are only needed once the WHERE has been
|
||||
// widened by a span filter; otherwise WHERE = mask already enforces the gate.
|
||||
var having []string
|
||||
if fp.hasSpanFilter {
|
||||
having = append(having, "countIf("+embedExpr(sb, maskExpr, maskArgs)+") > 0")
|
||||
having = append(having, "countIf("+embedExpr(sb, fp.spanPred, fp.spanArgs)+") > 0")
|
||||
}
|
||||
if strings.TrimSpace(fp.havingExpr) != "" {
|
||||
hv, err := b.buildHaving(fp.havingExpr, orderableSet)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if hv != "" {
|
||||
having = append(having, hv)
|
||||
}
|
||||
}
|
||||
if len(having) > 0 {
|
||||
sb.Having(strings.Join(having, " AND "))
|
||||
}
|
||||
|
||||
sb.OrderBy(orderClause(orders)...)
|
||||
sb.Limit(limit)
|
||||
if offset > 0 {
|
||||
sb.Offset(offset)
|
||||
}
|
||||
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return fmt.Sprintf("matched AS (%s)", sql), args, nil
|
||||
}
|
||||
|
||||
// buildRankedCTE builds `ranked`: per-trace [start,end] bounds for the matched traces,
|
||||
// read from the small trace-summary table (used to derive the bucket prune).
|
||||
func (b *scopedTraceStatementBuilder) buildRankedCTE(start, end uint64) (string, []any) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("trace_id", "min(start) AS t_start", "max(end) AS t_end")
|
||||
sb.From(summaryTable())
|
||||
sb.Where(
|
||||
"trace_id GLOBAL IN (SELECT trace_id FROM matched)",
|
||||
"end >= fromUnixTimestamp64Nano("+sb.Var(start)+")",
|
||||
"start < fromUnixTimestamp64Nano("+sb.Var(end)+")",
|
||||
)
|
||||
sb.GroupBy("trace_id")
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return fmt.Sprintf("ranked AS (%s)", sql), args
|
||||
}
|
||||
|
||||
// buildBucketsCTE builds `buckets`: the exact ts_bucket_start values the matched traces
|
||||
// span, so the enrichment scan is pruned to those primary-key buckets. No args.
|
||||
func buildBucketsCTE() string {
|
||||
adj := querybuilder.BucketAdjustment // 30-min bucket width in seconds
|
||||
return fmt.Sprintf("buckets AS (SELECT DISTINCT b AS ts_bucket FROM ranked "+
|
||||
"ARRAY JOIN range("+
|
||||
"toUInt64(intDiv(toUnixTimestamp(t_start), %d) * %d - %d), "+
|
||||
"toUInt64(intDiv(toUnixTimestamp(t_end), %d) * %d + %d), "+
|
||||
"%d) AS b)", adj, adj, adj, adj, adj, adj, adj)
|
||||
}
|
||||
|
||||
// buildEnrichmentSelect builds the final SELECT: all per-trace columns for the matched
|
||||
// traces, scanning only their buckets (full trace, not window-clipped). SELECT-expr
|
||||
// args lead; the WHERE / ORDER BY carry none.
|
||||
func (b *scopedTraceStatementBuilder) buildEnrichmentSelect(resolved []resolvedColumn, orders []listOrder) (string, []any) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
selects, selectArgs := selectAllColumns(resolved)
|
||||
sb.Select(selects...)
|
||||
sb.From(spanTable())
|
||||
sb.Where(
|
||||
"ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)",
|
||||
"trace_id GLOBAL IN (SELECT trace_id FROM ranked)",
|
||||
)
|
||||
sb.GroupBy("trace_id")
|
||||
sb.OrderBy(orderClause(orders)...)
|
||||
sql, builtArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return sql, append(append([]any{}, selectArgs...), builtArgs...)
|
||||
}
|
||||
|
||||
// buildHaving rewrites a trace-level HAVING expression against the aggregate column
|
||||
// aliases computable in the matched pass; bare, trace., and tracefield. forms all map
|
||||
// to the selected alias.
|
||||
func (b *scopedTraceStatementBuilder) buildHaving(havingExpr string, orderableSet map[string]struct{}) (string, error) {
|
||||
columnMap := make(map[string]string, len(orderableSet)*3)
|
||||
for a := range orderableSet {
|
||||
columnMap[a] = quoteAlias(a)
|
||||
columnMap["trace."+a] = quoteAlias(a)
|
||||
columnMap["tracefield."+a] = quoteAlias(a)
|
||||
}
|
||||
return querybuilder.NewHavingExpressionRewriter().Rewrite(havingExpr, columnMap)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small shared SQL-builder utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// spanTable is the fully-qualified span index table.
|
||||
func spanTable() string {
|
||||
return fmt.Sprintf("%s.%s", telemetrytraces.DBName, telemetrytraces.SpanIndexV3TableName)
|
||||
}
|
||||
|
||||
// summaryTable is the fully-qualified trace-summary table.
|
||||
func summaryTable() string {
|
||||
return fmt.Sprintf("%s.%s", telemetrytraces.DBName, telemetrytraces.TraceSummaryTableName)
|
||||
}
|
||||
|
||||
// aggregateAliasSet is the set of all trace-level (computed) column aliases, used to
|
||||
// classify which filter keys are trace-level vs span-level.
|
||||
func (b *scopedTraceStatementBuilder) aggregateAliasSet() map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(b.projection.AggregateAliases()))
|
||||
for _, a := range b.projection.AggregateAliases() {
|
||||
set[a] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// orderableAliasSet is the subset of aggregate aliases computable in the matched pass
|
||||
// (gen_ai-scoped): the only ones usable for ORDER BY and the aggregate filter.
|
||||
func orderableAliasSet(resolved []resolvedColumn) map[string]struct{} {
|
||||
set := make(map[string]struct{})
|
||||
for _, rc := range resolved {
|
||||
if rc.orderable {
|
||||
set[rc.alias] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// neededMatchedAliases is the minimal set of aggregate aliases the matched pass must
|
||||
// select: those referenced by ORDER BY plus those referenced in the aggregate HAVING
|
||||
// (bare / trace. / tracefield. forms). Anything else is left to the enrichment scan.
|
||||
func neededMatchedAliases(orders []listOrder, havingExpr string, orderableSet map[string]struct{}) map[string]struct{} {
|
||||
needed := make(map[string]struct{})
|
||||
for _, o := range orders {
|
||||
needed[o.alias] = struct{}{}
|
||||
}
|
||||
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
|
||||
name := strings.TrimPrefix(strings.TrimPrefix(sel.Name, "trace."), "tracefield.")
|
||||
if _, ok := orderableSet[name]; ok {
|
||||
needed[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return needed
|
||||
}
|
||||
|
||||
// validateAggregateFilter rejects a trace-level filter that references an aggregate not
|
||||
// computable in the matched pass (e.g. span_count, duration_nano), with a clear message.
|
||||
func validateAggregateFilter(havingExpr string, orderableSet map[string]struct{}) error {
|
||||
if strings.TrimSpace(havingExpr) == "" {
|
||||
return nil
|
||||
}
|
||||
allowed := make([]string, 0, len(orderableSet))
|
||||
for a := range orderableSet {
|
||||
allowed = append(allowed, a)
|
||||
}
|
||||
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
|
||||
name := strings.TrimPrefix(strings.TrimPrefix(sel.Name, "trace."), "tracefield.")
|
||||
if _, ok := orderableSet[name]; !ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"aggregate %q cannot be used in an AI trace-list filter; filterable aggregates: %s", name, strings.Join(allowed, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// embedExpr inlines a resolved (escaped) expr carrying `?` placeholders into sb by
|
||||
// replacing each `?` with a builder Var, so go-sqlbuilder tracks the args in appearance
|
||||
// order and un-escapes the expr at Build time.
|
||||
func embedExpr(sb *sqlbuilder.SelectBuilder, expr string, args []any) string {
|
||||
var out strings.Builder
|
||||
ai := 0
|
||||
for i := 0; i < len(expr); i++ {
|
||||
if expr[i] == '?' && ai < len(args) {
|
||||
out.WriteString(sb.Var(args[ai]))
|
||||
ai++
|
||||
continue
|
||||
}
|
||||
out.WriteByte(expr[i])
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// windowWhere binds the shared time-window predicates to sb and returns them, so a
|
||||
// caller can add its own predicate in the same Where call.
|
||||
func windowWhere(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64) []string {
|
||||
return []string{
|
||||
sb.GE("timestamp", fmt.Sprintf("%d", start)),
|
||||
sb.L("timestamp", fmt.Sprintf("%d", end)),
|
||||
sb.GE("ts_bucket_start", startBucket),
|
||||
sb.LE("ts_bucket_start", endBucket),
|
||||
}
|
||||
}
|
||||
|
||||
// orderClause renders the ORDER BY terms (by column alias) + the trace_id tiebreak.
|
||||
func orderClause(orders []listOrder) []string {
|
||||
out := make([]string, 0, len(orders)+1)
|
||||
for _, o := range orders {
|
||||
out = append(out, fmt.Sprintf("%s %s", quoteAlias(o.alias), o.direction))
|
||||
}
|
||||
return append(out, "trace_id DESC")
|
||||
}
|
||||
|
||||
// selectAllColumns renders `expr AS alias` for every resolved column and returns their
|
||||
// field-mapper args in select order.
|
||||
func selectAllColumns(resolved []resolvedColumn) ([]string, []any) {
|
||||
selects := []string{"trace_id"}
|
||||
var args []any
|
||||
for _, rc := range resolved {
|
||||
selects = append(selects, rc.expr+" AS "+quoteAlias(rc.alias))
|
||||
args = append(args, rc.args...)
|
||||
}
|
||||
return selects, args
|
||||
}
|
||||
|
||||
// quoteAlias backticks an alias that carries characters special to the SQL builder.
|
||||
func quoteAlias(alias string) string {
|
||||
if strings.ContainsAny(alias, ".$`") {
|
||||
return "`" + alias + "`"
|
||||
}
|
||||
return alias
|
||||
}
|
||||
@@ -1,700 +0,0 @@
|
||||
package telemetryai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// otelKeysMap seeds the OpenTelemetry gen_ai semantic-convention keys the AI
|
||||
// queries reference, so the metadata-backed field resolution succeeds in tests.
|
||||
func otelKeysMap() map[string][]*telemetrytypes.TelemetryFieldKey {
|
||||
strKey := func(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
}
|
||||
numKey := func(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
|
||||
}
|
||||
}
|
||||
return map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"gen_ai.request.model": {strKey("gen_ai.request.model")},
|
||||
"gen_ai.tool.name": {strKey("gen_ai.tool.name")},
|
||||
"gen_ai.agent.name": {strKey("gen_ai.agent.name")},
|
||||
"gen_ai.user.id": {strKey("gen_ai.user.id")},
|
||||
"gen_ai.usage.input_tokens": {numKey("gen_ai.usage.input_tokens")},
|
||||
"gen_ai.usage.output_tokens": {numKey("gen_ai.usage.output_tokens")},
|
||||
"gen_ai.usage.cost": {numKey("gen_ai.usage.cost")},
|
||||
"gen_ai.usage.cached_input_tokens": {numKey("gen_ai.usage.cached_input_tokens")},
|
||||
"has_error": {{
|
||||
Name: "has_error",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeBool,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// standard test window (ms), matching the traces builder tests.
|
||||
const (
|
||||
testStartMs = uint64(1747947419000)
|
||||
testEndMs = uint64(1747983448000)
|
||||
)
|
||||
|
||||
func newTestBuilder(t *testing.T) *scopedTraceStatementBuilder {
|
||||
return newTestBuilderWithKeys(t, otelKeysMap())
|
||||
}
|
||||
|
||||
// newTestBuilderWithKeys mirrors the production wiring in signozquerier's provider.
|
||||
// The gen_ai keys are seeded via keysMap here; in production the metadata store
|
||||
// surfaces them itself (enrichWithGenAIKeys).
|
||||
func newTestBuilderWithKeys(t *testing.T, keysMap map[string][]*telemetrytypes.TelemetryFieldKey) *scopedTraceStatementBuilder {
|
||||
t.Helper()
|
||||
settings := instrumentationtest.New().ToProviderSettings()
|
||||
fm := telemetrytraces.NewFieldMapper()
|
||||
cb := telemetrytraces.NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = keysMap
|
||||
fl := flaggertest.New(t)
|
||||
baseCond := NewGenAIBaseConditionProvider()
|
||||
// In production the metadata store enriches gen_ai keys (enrichWithGenAIKeys);
|
||||
// here the mock is seeded directly via keysMap.
|
||||
metadataStore := telemetrytypes.MetadataStore(mockMetadataStore)
|
||||
rewriter := querybuilder.NewAggExprRewriter(settings, nil, fm, cb, nil, fl)
|
||||
traceStmtBuilder := telemetrytraces.NewTraceQueryStatementBuilder(
|
||||
settings,
|
||||
metadataStore,
|
||||
fm,
|
||||
cb,
|
||||
rewriter,
|
||||
nil,
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
return NewAITraceStatementBuilder(
|
||||
settings,
|
||||
metadataStore,
|
||||
fm,
|
||||
cb,
|
||||
baseCond,
|
||||
traceStmtBuilder,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full-query golden tests
|
||||
//
|
||||
// Each pins the WHOLE generated statement, with bound args inlined into the `?`
|
||||
// placeholders, as ONE self-contained literal — so a failure diff shows the entire
|
||||
// query and the expected SQL can be copied straight into a ClickHouse client. The
|
||||
// `want` strings are formatted for readability; the comparison is whitespace- and
|
||||
// backtick-insensitive (see normalizeSQL), so only the SQL tokens themselves matter.
|
||||
//
|
||||
// The four trace-list goldens cover the corners of how `matched` is assembled —
|
||||
// {no span filter, span filter} × {no aggregate filter, aggregate filter} — plus a
|
||||
// mixed filter + multi-key order, plus the delegated span list. Note `matched` selects
|
||||
// only the aggregates ORDER BY / HAVING reference; the rest appear only in enrichment.
|
||||
//
|
||||
// Run `go test ./pkg/telemetryai/ -run TestBuild_FullSQL -v` to also print each query.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// renderSQL substitutes bound args into the `?` placeholders so the whole statement
|
||||
// reads as one literal SQL string.
|
||||
func renderSQL(t *testing.T, stmt *qbtypes.Statement) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
argi := 0
|
||||
for i := 0; i < len(stmt.Query); i++ {
|
||||
if stmt.Query[i] == '?' {
|
||||
require.Less(t, argi, len(stmt.Args), "more ? than args in query")
|
||||
b.WriteString(formatArg(stmt.Args[argi]))
|
||||
argi++
|
||||
continue
|
||||
}
|
||||
b.WriteByte(stmt.Query[i])
|
||||
}
|
||||
require.Equal(t, len(stmt.Args), argi, "arg count does not match number of placeholders")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatArg(a any) string {
|
||||
if s, ok := a.(string); ok {
|
||||
return "'" + s + "'"
|
||||
}
|
||||
return fmt.Sprintf("%v", a)
|
||||
}
|
||||
|
||||
// normalizeSQL makes the comparison insensitive to formatting: it drops identifier
|
||||
// backticks, collapses whitespace runs to a single space, and removes spaces directly
|
||||
// inside parentheses. This lets the golden strings be freely indented/wrapped (and
|
||||
// written as Go raw literals, which cannot contain backticks) — only the SQL tokens
|
||||
// and their order matter.
|
||||
func normalizeSQL(s string) string {
|
||||
s = strings.Join(strings.Fields(strings.ReplaceAll(s, "`", "")), " ")
|
||||
s = strings.ReplaceAll(s, "( ", "(")
|
||||
s = strings.ReplaceAll(s, " )", ")")
|
||||
return s
|
||||
}
|
||||
|
||||
func requireSQLEqual(t *testing.T, want string, stmt *qbtypes.Statement) {
|
||||
t.Helper()
|
||||
got := renderSQL(t, stmt)
|
||||
t.Logf("\n%s", got)
|
||||
require.Equal(t, normalizeSQL(want), normalizeSQL(got))
|
||||
}
|
||||
|
||||
// No filter: matched selects only the default order key (last_activity_time), WHERE is
|
||||
// just window + gate mask, no HAVING.
|
||||
func TestBuild_FullSQL_TraceList_NoFilter(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI, Limit: 20,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
requireSQLEqual(t, `
|
||||
WITH matched AS (
|
||||
SELECT trace_id,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND ((mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true))
|
||||
GROUP BY trace_id
|
||||
ORDER BY last_activity_time DESC, trace_id DESC
|
||||
LIMIT 20
|
||||
),
|
||||
ranked AS (
|
||||
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
|
||||
FROM signoz_traces.distributed_trace_summary
|
||||
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
|
||||
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
|
||||
AND start < fromUnixTimestamp64Nano(1747983448000000000)
|
||||
GROUP BY trace_id
|
||||
),
|
||||
buckets AS (
|
||||
SELECT DISTINCT b AS ts_bucket
|
||||
FROM ranked
|
||||
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
|
||||
)
|
||||
SELECT trace_id,
|
||||
min(timestamp) AS start_time,
|
||||
max(timestamp) AS end_time,
|
||||
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS duration_nano,
|
||||
count() AS span_count,
|
||||
anyIf(name, parent_span_id = '') AS root_span_name,
|
||||
any(resource_string_service$$name) AS service.name,
|
||||
countIf(mapContains(attributes_string, 'gen_ai.request.model') = true) AS llm_call_count,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens') = true, toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens') = true, toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
|
||||
GROUP BY trace_id
|
||||
ORDER BY last_activity_time DESC, trace_id DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Span-level AND trace-level filter, order by the aggregate, pagination. matched selects
|
||||
// only output_tokens (the sole aggregate referenced by both ORDER BY and HAVING) — not
|
||||
// input_tokens/llm_call_count/last_activity_time. The span predicate widens the WHERE
|
||||
// prune and becomes a countIf(...) > 0 existence check alongside the gate countIf.
|
||||
func TestBuild_FullSQL_TraceList_SpanAndTraceFilter(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o-mini' AND output_tokens > 1000"},
|
||||
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "output_tokens"}}, Direction: qbtypes.OrderDirectionDesc}},
|
||||
Limit: 10, Offset: 30,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
requireSQLEqual(t, `
|
||||
WITH matched AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens') = true, toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND ((mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)
|
||||
OR (attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model') = true))
|
||||
GROUP BY trace_id
|
||||
HAVING countIf((mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) > 0
|
||||
AND countIf((attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model') = true)) > 0
|
||||
AND output_tokens > 1000
|
||||
ORDER BY output_tokens DESC, trace_id DESC
|
||||
LIMIT 10 OFFSET 30
|
||||
),
|
||||
ranked AS (
|
||||
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
|
||||
FROM signoz_traces.distributed_trace_summary
|
||||
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
|
||||
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
|
||||
AND start < fromUnixTimestamp64Nano(1747983448000000000)
|
||||
GROUP BY trace_id
|
||||
),
|
||||
buckets AS (
|
||||
SELECT DISTINCT b AS ts_bucket
|
||||
FROM ranked
|
||||
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
|
||||
)
|
||||
SELECT trace_id,
|
||||
min(timestamp) AS start_time,
|
||||
max(timestamp) AS end_time,
|
||||
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS duration_nano,
|
||||
count() AS span_count,
|
||||
anyIf(name, parent_span_id = '') AS root_span_name,
|
||||
any(resource_string_service$$name) AS service.name,
|
||||
countIf(mapContains(attributes_string, 'gen_ai.request.model') = true) AS llm_call_count,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens') = true, toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens') = true, toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
|
||||
GROUP BY trace_id
|
||||
ORDER BY output_tokens DESC, trace_id DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Aggregate-only filter (no span filter). WHERE prune is NOT widened, there is no
|
||||
// gate/span countIf, just the aggregate HAVING. `trace.output_tokens` rewrites to the
|
||||
// output_tokens alias. matched selects output_tokens (HAVING) + last_activity_time (default order).
|
||||
func TestBuild_FullSQL_TraceList_AggregateFilterOnly(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > 1000"},
|
||||
Limit: 20,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
requireSQLEqual(t, `
|
||||
WITH matched AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens') = true, toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND ((mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true))
|
||||
GROUP BY trace_id
|
||||
HAVING output_tokens > 1000
|
||||
ORDER BY last_activity_time DESC, trace_id DESC
|
||||
LIMIT 20
|
||||
),
|
||||
ranked AS (
|
||||
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
|
||||
FROM signoz_traces.distributed_trace_summary
|
||||
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
|
||||
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
|
||||
AND start < fromUnixTimestamp64Nano(1747983448000000000)
|
||||
GROUP BY trace_id
|
||||
),
|
||||
buckets AS (
|
||||
SELECT DISTINCT b AS ts_bucket
|
||||
FROM ranked
|
||||
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
|
||||
)
|
||||
SELECT trace_id,
|
||||
min(timestamp) AS start_time,
|
||||
max(timestamp) AS end_time,
|
||||
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS duration_nano,
|
||||
count() AS span_count,
|
||||
anyIf(name, parent_span_id = '') AS root_span_name,
|
||||
any(resource_string_service$$name) AS service.name,
|
||||
countIf(mapContains(attributes_string, 'gen_ai.request.model') = true) AS llm_call_count,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens') = true, toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens') = true, toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
|
||||
GROUP BY trace_id
|
||||
ORDER BY last_activity_time DESC, trace_id DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Span-only filter (no aggregate filter). WHERE is widened; HAVING has the gate + span
|
||||
// countIf pair but no trailing aggregate. `has_error = true` resolves to a
|
||||
// materialized-column predicate (not a map access). matched selects only the default order key.
|
||||
func TestBuild_FullSQL_TraceList_SpanFilterOnly(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: "has_error = true"},
|
||||
Limit: 20,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
requireSQLEqual(t, `
|
||||
WITH matched AS (
|
||||
SELECT trace_id,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND ((mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)
|
||||
OR has_error = true)
|
||||
GROUP BY trace_id
|
||||
HAVING countIf((mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) > 0
|
||||
AND countIf(has_error = true) > 0
|
||||
ORDER BY last_activity_time DESC, trace_id DESC
|
||||
LIMIT 20
|
||||
),
|
||||
ranked AS (
|
||||
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
|
||||
FROM signoz_traces.distributed_trace_summary
|
||||
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
|
||||
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
|
||||
AND start < fromUnixTimestamp64Nano(1747983448000000000)
|
||||
GROUP BY trace_id
|
||||
),
|
||||
buckets AS (
|
||||
SELECT DISTINCT b AS ts_bucket
|
||||
FROM ranked
|
||||
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
|
||||
)
|
||||
SELECT trace_id,
|
||||
min(timestamp) AS start_time,
|
||||
max(timestamp) AS end_time,
|
||||
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS duration_nano,
|
||||
count() AS span_count,
|
||||
anyIf(name, parent_span_id = '') AS root_span_name,
|
||||
any(resource_string_service$$name) AS service.name,
|
||||
countIf(mapContains(attributes_string, 'gen_ai.request.model') = true) AS llm_call_count,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens') = true, toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens') = true, toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
|
||||
GROUP BY trace_id
|
||||
ORDER BY last_activity_time DESC, trace_id DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Mixed filter (two span predicates AND'd into one existence check + an aggregate) with
|
||||
// a two-key order on different aggregates than the filter. matched selects input_tokens
|
||||
// + last_activity_time (ORDER BY) and output_tokens (HAVING) — three of four; llm_call_count is not.
|
||||
func TestBuild_FullSQL_TraceList_MixedFiltersMultiOrder(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o' AND has_error = true AND output_tokens > 500"},
|
||||
Order: []qbtypes.OrderBy{
|
||||
{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "input_tokens"}}, Direction: qbtypes.OrderDirectionDesc},
|
||||
{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "last_activity_time"}}, Direction: qbtypes.OrderDirectionAsc},
|
||||
},
|
||||
Limit: 15,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
requireSQLEqual(t, `
|
||||
WITH matched AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens') = true, toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens') = true, toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND ((mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)
|
||||
OR ((attributes_string['gen_ai.request.model'] = 'gpt-4o' AND mapContains(attributes_string, 'gen_ai.request.model') = true) AND has_error = true))
|
||||
GROUP BY trace_id
|
||||
HAVING countIf((mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) > 0
|
||||
AND countIf(((attributes_string['gen_ai.request.model'] = 'gpt-4o' AND mapContains(attributes_string, 'gen_ai.request.model') = true) AND has_error = true)) > 0
|
||||
AND output_tokens > 500
|
||||
ORDER BY input_tokens DESC, last_activity_time ASC, trace_id DESC
|
||||
LIMIT 15
|
||||
),
|
||||
ranked AS (
|
||||
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
|
||||
FROM signoz_traces.distributed_trace_summary
|
||||
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
|
||||
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
|
||||
AND start < fromUnixTimestamp64Nano(1747983448000000000)
|
||||
GROUP BY trace_id
|
||||
),
|
||||
buckets AS (
|
||||
SELECT DISTINCT b AS ts_bucket
|
||||
FROM ranked
|
||||
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
|
||||
)
|
||||
SELECT trace_id,
|
||||
min(timestamp) AS start_time,
|
||||
max(timestamp) AS end_time,
|
||||
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS duration_nano,
|
||||
count() AS span_count,
|
||||
anyIf(name, parent_span_id = '') AS root_span_name,
|
||||
any(resource_string_service$$name) AS service.name,
|
||||
countIf(mapContains(attributes_string, 'gen_ai.request.model') = true) AS llm_call_count,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens') = true, toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens') = true, toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
|
||||
GROUP BY trace_id
|
||||
ORDER BY input_tokens DESC, last_activity_time ASC, trace_id DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Span list (requestType raw): delegated to the traces builder with the gate ANDed
|
||||
// into the user filter, so only gen_ai spans matching the filter come back. Standard
|
||||
// span columns, single SELECT (no CTE pipeline).
|
||||
func TestBuild_FullSQL_SpanList_Raw(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeRaw,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o-mini'"},
|
||||
Limit: 10,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
requireSQLEqual(t, `
|
||||
SELECT timestamp AS timestamp, trace_id AS trace_id, span_id AS span_id,
|
||||
trace_state AS trace_state, parent_span_id AS parent_span_id, flags AS flags,
|
||||
name AS name, kind AS kind, kind_string AS kind_string, duration_nano AS duration_nano,
|
||||
status_code AS status_code, status_message AS status_message,
|
||||
status_code_string AS status_code_string, events AS events, links AS links,
|
||||
response_status_code AS response_status_code, external_http_url AS external_http_url,
|
||||
http_url AS http_url, external_http_method AS external_http_method,
|
||||
http_method AS http_method, http_host AS http_host, db_name AS db_name,
|
||||
db_operation AS db_operation, has_error AS has_error, is_remote AS is_remote,
|
||||
attributes_string, attributes_number, attributes_bool, resources_string
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE (((mapContains(attributes_string, 'gen_ai.request.model') = true
|
||||
OR mapContains(attributes_string, 'gen_ai.tool.name') = true
|
||||
OR mapContains(attributes_string, 'gen_ai.agent.name') = true))
|
||||
AND ((attributes_string['gen_ai.request.model'] = 'gpt-4o-mini'
|
||||
AND mapContains(attributes_string, 'gen_ai.request.model') = true)))
|
||||
AND timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
LIMIT 10
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Behavior / branch tests not covered by the goldens above
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Trace-level and span-level predicates may not be OR-combined.
|
||||
func TestBuild_TraceList_TraceOrSpanMixRejected(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > 1000 OR gen_ai.request.model = 'x'"},
|
||||
Limit: 10,
|
||||
}
|
||||
_, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace, query, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cannot be combined")
|
||||
}
|
||||
|
||||
// An output-only aggregate (span_count / duration_nano) can be displayed but not used
|
||||
// in the aggregate filter or ORDER BY — it is not computable in the matched pass.
|
||||
func TestBuild_TraceList_OutputOnlyAggregateRejected(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
|
||||
// filter by span_count -> rejected
|
||||
_, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: "span_count > 3"},
|
||||
}, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "span_count")
|
||||
|
||||
// order by duration_nano -> rejected
|
||||
_, err = b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "duration_nano"}}, Direction: qbtypes.OrderDirectionDesc}},
|
||||
}, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unsupported order key")
|
||||
}
|
||||
|
||||
// A HAVING referencing a non-aggregate column is rejected.
|
||||
func TestBuild_TraceList_Having_UnknownColumn(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Source: telemetrytypes.SourceAI,
|
||||
Having: &qbtypes.Having{Expression: "service.name > 1"}, // not an aggregate column
|
||||
Limit: 10,
|
||||
}
|
||||
_, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace, query, nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// Ordering by an unknown key is rejected.
|
||||
func TestBuild_TraceList_UnsupportedOrderKey(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Source: telemetrytypes.SourceAI,
|
||||
Order: []qbtypes.OrderBy{
|
||||
{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "http.request.method"}}, Direction: qbtypes.OrderDirectionDesc},
|
||||
},
|
||||
}
|
||||
_, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace, query, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unsupported order key")
|
||||
}
|
||||
|
||||
// With no limit set, the builder applies the default of 100.
|
||||
func TestBuild_TraceList_DefaultLimit(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Source: telemetrytypes.SourceAI,
|
||||
}
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, stmt.Query, "LIMIT ?")
|
||||
require.Contains(t, stmt.Args, 100)
|
||||
}
|
||||
|
||||
// Only trace list and span list (raw) are supported; distribution is not.
|
||||
func TestBuild_UnsupportedRequestType(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Source: telemetrytypes.SourceAI,
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "count()"},
|
||||
},
|
||||
}
|
||||
_, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeDistribution, query, nil)
|
||||
require.ErrorIs(t, err, ErrUnsupportedRequestType)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter operator resolution
|
||||
//
|
||||
// The goldens pin the CTE structure; these pin how each filter OPERATOR resolves into
|
||||
// SQL (the part that varies with the operator, not the pipeline). Span-level operators
|
||||
// resolve to a predicate that appears both in the widened WHERE prune and as a
|
||||
// countIf(...) > 0 existence check; aggregate operators become a HAVING (values inlined).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuild_TraceList_SpanFilterOperatorResolution(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
build := func(t *testing.T, expr string) string {
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: expr}, Limit: 5,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
return stmt.Query
|
||||
}
|
||||
|
||||
cases := []struct{ name, expr, frag string }{
|
||||
{"not_equal", "gen_ai.request.model != 'gpt-4o'",
|
||||
"attributes_string['gen_ai.request.model'] <> ?"},
|
||||
{"in", "gen_ai.request.model IN ('gpt-4o', 'gpt-4')",
|
||||
"(attributes_string['gen_ai.request.model'] = ? OR attributes_string['gen_ai.request.model'] = ?) AND mapContains(attributes_string, 'gen_ai.request.model') = ?"},
|
||||
{"exists", "gen_ai.user.id EXISTS", // non-gate key, so the fragment is unambiguous
|
||||
"mapContains(attributes_string, 'gen_ai.user.id') = ?"},
|
||||
{"not_exists", "gen_ai.user.id NOT EXISTS",
|
||||
"mapContains(attributes_string, 'gen_ai.user.id') <> ?"},
|
||||
{"contains", "gen_ai.request.model CONTAINS 'gpt'", // case-insensitive
|
||||
"LOWER(attributes_string['gen_ai.request.model']) LIKE LOWER(?)"},
|
||||
{"like", "gen_ai.request.model LIKE 'gpt%'",
|
||||
"attributes_string['gen_ai.request.model'] LIKE ?"},
|
||||
{"numeric_gte", "gen_ai.usage.output_tokens >= 100", // span attr (Float64), distinct from the output_tokens aggregate
|
||||
"toFloat64(attributes_number['gen_ai.usage.output_tokens']) >= ? AND mapContains(attributes_number, 'gen_ai.usage.output_tokens') = ?"},
|
||||
{"not_group", "NOT (gen_ai.request.model = 'gpt-4o')",
|
||||
"NOT (((attributes_string['gen_ai.request.model'] = ? AND mapContains(attributes_string, 'gen_ai.request.model') = ?)))"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
// the resolved predicate appears in the widened WHERE prune and (wrapped) in
|
||||
// the countIf existence check; the goldens pin the countIf structure, here we
|
||||
// pin only the operator's resolution.
|
||||
require.Contains(t, build(t, c.expr), c.frag)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_TraceList_AggregateFilterOperatorResolution(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
build := func(t *testing.T, expr string) (string, error) {
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: expr}, Limit: 5,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return stmt.Query, nil
|
||||
}
|
||||
|
||||
// values are inlined by the HAVING rewriter (not parameterized).
|
||||
cases := []struct{ name, expr, having string }{
|
||||
{"less_than", "output_tokens < 500", "HAVING output_tokens < 500"},
|
||||
{"not_equal", "output_tokens != 0", "HAVING output_tokens != 0"},
|
||||
{"range_and", "output_tokens >= 500 AND output_tokens <= 1000", "HAVING (output_tokens >= 500 AND output_tokens <= 1000)"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
q, err := build(t, c.expr)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, q, c.having)
|
||||
})
|
||||
}
|
||||
|
||||
// BETWEEN is not supported by the HAVING rewriter — surfaced as an error, not silently wrong.
|
||||
t.Run("between_unsupported", func(t *testing.T) {
|
||||
_, err := build(t, "output_tokens BETWEEN 500 AND 1000")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -1190,34 +1190,6 @@ func enrichWithIntrinsicMetricKeys(keys map[string][]*telemetrytypes.TelemetryFi
|
||||
return keys
|
||||
}
|
||||
|
||||
// enrichWithGenAIKeys surfaces the gen_ai semantic-convention span attributes for
|
||||
// trace queries even when they have not been ingested yet, so the AI query builder's
|
||||
// gate and aggregate columns resolve on a fresh install. Only fills a name the store
|
||||
// did not already return (ingested data wins).
|
||||
func enrichWithGenAIKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, selectors []*telemetrytypes.FieldKeySelector) map[string][]*telemetrytypes.TelemetryFieldKey {
|
||||
if len(selectors) == 0 {
|
||||
return keys
|
||||
}
|
||||
|
||||
for _, selector := range selectors {
|
||||
if selector.Signal != telemetrytypes.SignalTraces && selector.Signal != telemetrytypes.SignalUnspecified {
|
||||
continue
|
||||
}
|
||||
for name, def := range telemetrytypes.GenAIFieldDefinitions {
|
||||
if len(keys[name]) > 0 {
|
||||
continue // already resolved from ingested data
|
||||
}
|
||||
if !selectorMatchesIntrinsicField(selector, def) {
|
||||
continue
|
||||
}
|
||||
keyCopy := def
|
||||
keys[name] = []*telemetrytypes.TelemetryFieldKey{&keyCopy}
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func selectorMatchesIntrinsicField(selector *telemetrytypes.FieldKeySelector, definition telemetrytypes.TelemetryFieldKey) bool {
|
||||
if selector.FieldContext != telemetrytypes.FieldContextUnspecified && selector.FieldContext != definition.FieldContext {
|
||||
return false
|
||||
@@ -1303,7 +1275,6 @@ func (t *telemetryMetaStore) GetKeys(ctx context.Context, fieldKeySelector *tele
|
||||
|
||||
applyBackwardCompatibleKeys(mapOfKeys)
|
||||
mapOfKeys = enrichWithIntrinsicMetricKeys(mapOfKeys, selectors)
|
||||
mapOfKeys = enrichWithGenAIKeys(mapOfKeys, selectors)
|
||||
|
||||
return mapOfKeys, complete, nil
|
||||
}
|
||||
@@ -1382,7 +1353,6 @@ func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, fieldKeySelectors
|
||||
|
||||
applyBackwardCompatibleKeys(mapOfKeys)
|
||||
mapOfKeys = enrichWithIntrinsicMetricKeys(mapOfKeys, fieldKeySelectors)
|
||||
mapOfKeys = enrichWithGenAIKeys(mapOfKeys, fieldKeySelectors)
|
||||
|
||||
return mapOfKeys, complete, nil
|
||||
}
|
||||
|
||||
@@ -66,15 +66,20 @@ type Role struct {
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
Name string `bun:"name,type:string" json:"name" required:"true"`
|
||||
Description string `bun:"description,type:string" json:"description" required:"true"`
|
||||
Type valuer.String `bun:"type,type:string" json:"type" required:"true"`
|
||||
OrgID valuer.UUID `bun:"org_id,type:string" json:"orgId" required:"true"`
|
||||
Name string `bun:"name,type:string" json:"name" required:"true"`
|
||||
Description string `bun:"description,type:string" json:"description" required:"true"`
|
||||
Type valuer.String `bun:"type,type:string" json:"type" required:"true"`
|
||||
OrgID valuer.UUID `bun:"org_id,type:string" json:"orgId" required:"true"`
|
||||
TransactionGroups TransactionGroups `bun:"transaction_groups,type:text" json:"transactionGroups" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type RoleWithTransactionGroups struct {
|
||||
*Role
|
||||
TransactionGroups TransactionGroups `json:"transactionGroups" required:"true" nullable:"false"`
|
||||
type GettableRole struct {
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
Name string `json:"name" required:"true"`
|
||||
Description string `json:"description" required:"true"`
|
||||
Type valuer.String `json:"type" required:"true"`
|
||||
OrgID valuer.UUID `json:"orgId" required:"true"`
|
||||
}
|
||||
|
||||
type PostableRole struct {
|
||||
@@ -88,7 +93,7 @@ type UpdatableRole struct {
|
||||
TransactionGroups TransactionGroups `json:"transactionGroups" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
func NewRole(name, description string, roleType valuer.String, orgID valuer.UUID) *Role {
|
||||
func NewRole(name, description string, roleType valuer.String, orgID valuer.UUID, transactionGroups TransactionGroups) *Role {
|
||||
return &Role{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
@@ -97,37 +102,37 @@ func NewRole(name, description string, roleType valuer.String, orgID valuer.UUID
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
Name: name,
|
||||
Description: description,
|
||||
Type: roleType,
|
||||
OrgID: orgID,
|
||||
}
|
||||
}
|
||||
|
||||
func NewRoleWithTransactionGroups(name, description string, roleType valuer.String, orgID valuer.UUID, transactionGroups TransactionGroups) *RoleWithTransactionGroups {
|
||||
role := NewRole(name, description, roleType, orgID)
|
||||
|
||||
return &RoleWithTransactionGroups{
|
||||
Role: role,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Type: roleType,
|
||||
OrgID: orgID,
|
||||
TransactionGroups: transactionGroups,
|
||||
}
|
||||
}
|
||||
|
||||
func MakeRoleWithTransactionGroups(role *Role, transactionGroups TransactionGroups) *RoleWithTransactionGroups {
|
||||
return &RoleWithTransactionGroups{
|
||||
Role: role,
|
||||
TransactionGroups: transactionGroups,
|
||||
func NewGettableRolesFromRoles(roles []*Role) []*GettableRole {
|
||||
gettableRoles := make([]*GettableRole, len(roles))
|
||||
for index, role := range roles {
|
||||
gettableRoles[index] = &GettableRole{
|
||||
Identifiable: role.Identifiable,
|
||||
TimeAuditable: role.TimeAuditable,
|
||||
Name: role.Name,
|
||||
Description: role.Description,
|
||||
Type: role.Type,
|
||||
OrgID: role.OrgID,
|
||||
}
|
||||
}
|
||||
|
||||
return gettableRoles
|
||||
}
|
||||
|
||||
func NewManagedRoles(orgID valuer.UUID) []*Role {
|
||||
return []*Role{
|
||||
NewRole(SigNozAdminRoleName, SigNozAdminRoleDescription, RoleTypeManaged, orgID),
|
||||
NewRole(SigNozEditorRoleName, SigNozEditorRoleDescription, RoleTypeManaged, orgID),
|
||||
NewRole(SigNozViewerRoleName, SigNozViewerRoleDescription, RoleTypeManaged, orgID),
|
||||
NewRole(SigNozAnonymousRoleName, SigNozAnonymousRoleDescription, RoleTypeManaged, orgID),
|
||||
NewRole(SigNozAdminRoleName, SigNozAdminRoleDescription, RoleTypeManaged, orgID, NewTransactionGroupsFromTransactions(coretypes.ManagedRoleToTransactions[SigNozAdminRoleName])),
|
||||
NewRole(SigNozEditorRoleName, SigNozEditorRoleDescription, RoleTypeManaged, orgID, NewTransactionGroupsFromTransactions(coretypes.ManagedRoleToTransactions[SigNozEditorRoleName])),
|
||||
NewRole(SigNozViewerRoleName, SigNozViewerRoleDescription, RoleTypeManaged, orgID, NewTransactionGroupsFromTransactions(coretypes.ManagedRoleToTransactions[SigNozViewerRoleName])),
|
||||
NewRole(SigNozAnonymousRoleName, SigNozAnonymousRoleDescription, RoleTypeManaged, orgID, NewTransactionGroupsFromTransactions(coretypes.ManagedRoleToTransactions[SigNozAnonymousRoleName])),
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func NewStatsFromRoles(roles []*Role) map[string]any {
|
||||
@@ -144,7 +149,7 @@ func NewStatsFromRoles(roles []*Role) map[string]any {
|
||||
return stats
|
||||
}
|
||||
|
||||
func (role *RoleWithTransactionGroups) Update(description string, transactionGroups TransactionGroups) error {
|
||||
func (role *Role) Update(description string, transactionGroups TransactionGroups) error {
|
||||
err := role.ErrIfManaged()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -70,6 +71,28 @@ func NewTransactionGroups(data []byte) (TransactionGroups, error) {
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func NewTransactionGroupsFromTransactions(transactions []coretypes.Transaction) TransactionGroups {
|
||||
objectsByVerb := make(map[string][]*coretypes.Object)
|
||||
for _, transaction := range transactions {
|
||||
object := transaction.Object
|
||||
objectsByVerb[transaction.Verb.StringValue()] = append(objectsByVerb[transaction.Verb.StringValue()], &object)
|
||||
}
|
||||
|
||||
groups := make(TransactionGroups, 0)
|
||||
for _, verb := range coretypes.Verbs {
|
||||
objects := objectsByVerb[verb.StringValue()]
|
||||
if len(objects) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, objectGroup := range coretypes.NewObjectGroupsFromObjects(objects) {
|
||||
groups = append(groups, &TransactionGroup{Relation: Relation{Verb: verb}, ObjectGroup: *objectGroup})
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
func NewGettableTransaction(results []*TransactionWithAuthorization) []*GettableTransaction {
|
||||
gettableTransactions := make([]*GettableTransaction, len(results))
|
||||
for i, result := range results {
|
||||
@@ -87,6 +110,48 @@ func (groups TransactionGroups) Diff(desired TransactionGroups) (additions, dele
|
||||
return desired.subtract(groups), groups.subtract(desired)
|
||||
}
|
||||
|
||||
func (groups TransactionGroups) MarshalJSON() ([]byte, error) {
|
||||
if groups == nil {
|
||||
return []byte("[]"), nil
|
||||
}
|
||||
|
||||
return json.Marshal([]*TransactionGroup(groups))
|
||||
}
|
||||
|
||||
func (groups TransactionGroups) Value() (driver.Value, error) {
|
||||
data, err := json.Marshal(groups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (groups *TransactionGroups) Scan(value any) error {
|
||||
if value == nil {
|
||||
*groups = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
var data []byte
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
data = []byte(typed)
|
||||
case []byte:
|
||||
data = typed
|
||||
default:
|
||||
return errors.Newf(errors.TypeInternal, errors.CodeInternal, "unsupported type %T for transaction groups", value)
|
||||
}
|
||||
|
||||
parsed, err := NewTransactionGroups(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*groups = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
func (transaction *Transaction) UnmarshalJSON(data []byte) error {
|
||||
var shadow = struct {
|
||||
Relation Relation
|
||||
|
||||
@@ -68,7 +68,7 @@ func NewTuplesFromTransactionGroups(name string, orgID valuer.UUID, transactionG
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) []*TransactionGroup {
|
||||
func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) TransactionGroups {
|
||||
objectsByRelation := make(map[string][]*coretypes.Object)
|
||||
|
||||
for _, tuple := range tuples {
|
||||
@@ -81,7 +81,7 @@ func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) []*Transac
|
||||
objectsByRelation[verb.StringValue()] = append(objectsByRelation[verb.StringValue()], object)
|
||||
}
|
||||
|
||||
transactionGroups := make([]*TransactionGroup, 0)
|
||||
transactionGroups := make(TransactionGroups, 0)
|
||||
for _, verb := range coretypes.Verbs {
|
||||
objects := objectsByRelation[verb.StringValue()]
|
||||
if len(objects) == 0 {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
@@ -17,14 +16,12 @@ import (
|
||||
const (
|
||||
LLMCostFeatureType agentConf.AgentFeatureType = "llm_pricing"
|
||||
|
||||
// gen_ai semconv keys live in telemetrytypes (single source); aliased here
|
||||
// for existing pricing consumers.
|
||||
GenAIProviderName = telemetrytypes.GenAIProviderName
|
||||
GenAIRequestModel = telemetrytypes.GenAIRequestModel
|
||||
GenAIUsageInputTokens = telemetrytypes.GenAIUsageInputTokens
|
||||
GenAIUsageOutputTokens = telemetrytypes.GenAIUsageOutputTokens
|
||||
GenAIUsageCacheReadInputTokens = telemetrytypes.GenAIUsageCacheReadInputTokens
|
||||
GenAIUsageCacheCreationInputTokens = telemetrytypes.GenAIUsageCacheCreationInputTokens
|
||||
GenAIRequestModel = "gen_ai.request.model"
|
||||
GenAIProviderName = "gen_ai.provider.name"
|
||||
GenAIUsageInputTokens = "gen_ai.usage.input_tokens"
|
||||
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
|
||||
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
|
||||
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
|
||||
|
||||
SignozGenAICostInput = "_signoz.gen_ai.cost_input"
|
||||
SignozGenAICostOutput = "_signoz.gen_ai.cost_output"
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package telemetrytypes
|
||||
|
||||
// OpenTelemetry gen_ai semantic-convention attribute keys. Single source of truth
|
||||
// shared by the AI query builder and the LLM pricing pipeline.
|
||||
const (
|
||||
GenAIRequestModel = "gen_ai.request.model"
|
||||
GenAIToolName = "gen_ai.tool.name"
|
||||
GenAIAgentName = "gen_ai.agent.name"
|
||||
GenAIProviderName = "gen_ai.provider.name"
|
||||
|
||||
GenAIUsageInputTokens = "gen_ai.usage.input_tokens"
|
||||
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
|
||||
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
|
||||
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
|
||||
)
|
||||
|
||||
// GenAIFieldDefinitions are the gen_ai semantic-convention span attributes the AI
|
||||
// query builder relies on. They are surfaced by the metadata store for trace
|
||||
// queries regardless of whether they have been ingested yet, so the AI gate/columns
|
||||
// resolve on a fresh install (mirrors intrinsic metric keys). String keys are the
|
||||
// gate; the usage keys are numeric.
|
||||
var GenAIFieldDefinitions = map[string]TelemetryFieldKey{
|
||||
GenAIRequestModel: {Name: GenAIRequestModel, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
|
||||
GenAIToolName: {Name: GenAIToolName, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
|
||||
GenAIAgentName: {Name: GenAIAgentName, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
|
||||
|
||||
GenAIUsageInputTokens: {Name: GenAIUsageInputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
|
||||
GenAIUsageOutputTokens: {Name: GenAIUsageOutputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
|
||||
GenAIUsageCacheReadInputTokens: {Name: GenAIUsageCacheReadInputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
|
||||
GenAIUsageCacheCreationInputTokens: {Name: GenAIUsageCacheCreationInputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
|
||||
}
|
||||
@@ -9,7 +9,6 @@ type Source struct {
|
||||
var (
|
||||
SourceAudit = Source{valuer.NewString("audit")}
|
||||
SourceMeter = Source{valuer.NewString("meter")}
|
||||
SourceAI = Source{valuer.NewString("ai")}
|
||||
SourceUnspecified = Source{valuer.NewString("")}
|
||||
)
|
||||
|
||||
@@ -18,6 +17,5 @@ var (
|
||||
func (Source) Enum() []any {
|
||||
return []any{
|
||||
SourceMeter,
|
||||
SourceAI,
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user