Compare commits

..

5 Commits

110 changed files with 3294 additions and 10098 deletions

2
.github/CODEOWNERS vendored
View File

@@ -189,9 +189,7 @@ go.mod @therealpandey
## Infrastructure Monitoring
/frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend
/frontend/src/container/InfraMonitoringHosts/ @SigNoz/pulse-frontend
/frontend/src/container/InfraMonitoringHostsV2/ @SigNoz/pulse-frontend
/frontend/src/container/InfraMonitoringK8s/ @SigNoz/pulse-frontend
/frontend/src/container/InfraMonitoringK8sV2/ @SigNoz/pulse-frontend
## Alerts
/frontend/src/pages/AlertList/ @SigNoz/pulse-frontend

View File

@@ -543,31 +543,6 @@ 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:
@@ -719,6 +694,43 @@ 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
@@ -746,18 +758,6 @@ 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:
@@ -1495,8 +1495,6 @@ components:
- cassandradb
- redis
- cloudsql_postgres
- memorystore_redis
- computeengine
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -12024,7 +12022,7 @@ paths:
properties:
data:
items:
$ref: '#/components/schemas/AuthtypesGettableRole'
$ref: '#/components/schemas/AuthtypesRole'
type: array
status:
type: string
@@ -12208,7 +12206,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesRole'
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
status:
type: string
required:

View File

@@ -119,6 +119,10 @@ 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)
}
@@ -127,6 +131,10 @@ 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)
}
@@ -175,7 +183,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.Role) error {
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.RoleWithTransactionGroups) 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())
@@ -200,31 +208,70 @@ func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *a
return err
}
return provider.store.Create(ctx, role)
if err := provider.store.Create(ctx, role.Role); err != nil {
return err
}
return 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) 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) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.Role) error {
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 {
_, 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.Get(ctx, orgID, updatedRole.ID)
existingRole, err := provider.GetWithTransactionGroups(ctx, orgID, updatedRole.ID)
if err != nil {
return err
}
existingTuples, err := provider.readAllTuplesForRole(ctx, existingRole.Name, orgID)
if err != nil {
return err
}
existingGroups := authtypes.MustNewTransactionGroupsFromTuples(existingTuples)
additions, deletions := existingGroups.Diff(updatedRole.TransactionGroups)
additions, deletions := existingRole.TransactionGroups.Diff(updatedRole.TransactionGroups)
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
if err != nil {
return err
@@ -240,7 +287,7 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
return err
}
return provider.store.Update(ctx, orgID, updatedRole)
return provider.store.Update(ctx, orgID, updatedRole.Role)
}
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
@@ -249,7 +296,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.Get(ctx, orgID, id)
role, err := provider.GetWithTransactionGroups(ctx, orgID, id)
if err != nil {
return err
}
@@ -265,7 +312,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
}
}
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
if err != nil {
return err
}
@@ -277,24 +324,6 @@ 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{}
@@ -346,3 +375,21 @@ 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
}

View File

@@ -105,6 +105,10 @@ 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)
}

View File

@@ -0,0 +1,29 @@
import { PropsWithChildren } from 'react';
type CommonProps = PropsWithChildren<{
className?: string;
minSize?: number;
maxSize?: number;
defaultSize?: number;
direction?: 'horizontal' | 'vertical';
autoSaveId?: string;
withHandle?: boolean;
}>;
export function ResizablePanelGroup({
children,
className,
}: CommonProps): JSX.Element {
return <div className={className}>{children}</div>;
}
export function ResizablePanel({
children,
className,
}: CommonProps): JSX.Element {
return <div className={className}>{children}</div>;
}
export function ResizableHandle({ className }: CommonProps): JSX.Element {
return <div className={className} />;
}

View File

@@ -21,6 +21,7 @@ const config: Config.InitialOptions = {
'\\.md$': '<rootDir>/__mocks__/cssMock.ts',
'^uplot$': '<rootDir>/__mocks__/uplotMock.ts',
'^motion/react$': '<rootDir>/__mocks__/motionMock.tsx',
'^@signozhq/resizable$': '<rootDir>/__mocks__/resizableMock.tsx',
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,

View File

@@ -49,6 +49,7 @@
"@sentry/vite-plugin": "5.3.0",
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.4.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.23",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
@@ -127,7 +128,7 @@
"timestamp-nano": "^1.0.0",
"typescript": "5.9.3",
"uplot": "1.6.31",
"uuid": "14.0.1",
"uuid": "^8.3.2",
"vite": "npm:rolldown-vite@7.3.1",
"vite-plugin-html": "3.2.2",
"zod": "4.3.6",
@@ -221,5 +222,34 @@
"*.(scss|css)": [
"stylelint"
]
},
"resolutions": {
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10",
"debug": "4.3.4",
"semver": "7.5.4",
"xml2js": "0.5.0",
"phin": "^3.7.1",
"body-parser": "1.20.3",
"http-proxy-middleware": "4.1.1",
"cross-spawn": "7.0.5",
"cookie": "^0.7.1",
"serialize-javascript": "6.0.2",
"prismjs": "1.30.0",
"got": "11.8.5",
"form-data": "4.0.6",
"brace-expansion": "^2.0.3",
"on-headers": "^1.1.0",
"js-cookie": "^3.0.7",
"tmp": "0.2.7",
"vite": "npm:rolldown-vite@7.3.1",
"dompurify": "3.4.11",
"js-yaml@3": "3.15.0",
"js-yaml@4": "4.2.0",
"yaml@1": "1.10.3",
"react-router@6": "6.30.4",
"markdown-it": "14.2.0",
"mdast-util-to-hast@13": "13.2.1",
"protocol-buffers-schema": "3.6.1"
}
}

1864
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,53 +1,6 @@
blockExoticSubdeps: true
trustPolicy: no-downgrade
minimumReleaseAge: 2880 # 2d
minimumReleaseAgeStrict: true
minimumReleaseAgeExclude:
- '@signozhq/*'
minimumReleaseAgeStrict: true
# Security floors for vulnerable transitive deps. Where possible, targets are
# capped to avoid crossing breaking versions (major; and minor for 0.x).
# Some entries may still force a breaking bump when no safe release exists
# within the consumers constraint—see per-entry notes.
overrides:
# via: direct devDep @babel/core ^7.22.11 (+ babel plugin peers)
# remove: bump @babel/core in package.json to ^7.29.6
'@babel/core@<=7.29.0': '>=7.29.6 <8'
# via: jest > babel-plugin-istanbul > @istanbuljs/load-nyc-config@1.1.0 (js-yaml ^3.13.1)
# remove: blocked — 1.1.0 is latest and still depends on js-yaml 3.x
'@istanbuljs/load-nyc-config>js-yaml': '>=4.2.0 <5'
# via: msw@1.3.2 (devDep) > cookie ^0.4.2
# remove: upgrade msw to >=2 (ships cookie ^1). Do NOT open the cap: cookie >=1 is
# ESM-only and breaks msw under jest's CJS sandbox (kills every test suite)
cookie@<0.7.0: '>=0.7.1 <1'
# via: direct dep dompurify 3.4.0; @grafana/data@11.6.15 (3.4.0/3.2.4 exact);
# @monaco-editor/react > monaco-editor@0.55.1 (3.2.7 exact)
# remove: bump direct dep to 3.4.11; @grafana/data (latest 13.1.0) and
# monaco-editor (latest 0.55.1) still pin vulnerable versions — blocked
dompurify@<=3.4.10: '>=3.4.11 <4'
# via: rolldown-vite@7.3.1 (esbuild ^0.27.0); orval@8.9.1 (^0.27.4); ts-jest@29.4.9 (~0.27.4)
# remove: blocked on rolldown-vite (7.3.1 is latest, still ^0.27.0);
# orval >=8.20.0 and ts-jest >=29.4.11 already fixed on their side
esbuild@>=0.27.3 <0.28.1: '>=0.28.1 <0.29.0'
# via: react-use@17.5.1 (direct, js-cookie ^2.2.1); @grafana/data > react-use@17.6.0
# remove: bump react-use to >=17.6.1 (js-cookie ^3); @grafana/data side blocked
js-cookie@<=3.0.5: '>=3.0.7 <4'
# via: @orval/core@8.9.1 (devDep, js-yaml 4.1.1 EXACT pin — not deletable);
# json-schema-to-typescript@15 > @apidevtools/json-schema-ref-parser (^4.1.0)
# remove: upgrade orval to >=8.20.0 (drops js-yaml dependency entirely)
js-yaml@>=4.0.0 <=4.1.1: '>=4.2.0 <5'
# via: react-syntax-highlighter@15.5.0 (prismjs ^1.27.0 + refractor@3 ~1.27.0 tilde-pinned)
# remove: bump react-syntax-highlighter to >=16.1.1 (prismjs ^1.30.0, refractor@5)
prismjs@<1.30.0: '>=1.30.0 <2'
# via: direct dep react-router-dom-v5-compat@6.30.3 (react-router 6.30.3 exact)
# remove: bump react-router-dom-v5-compat to 6.30.4. Do NOT open the cap:
# react-router >=7 requires React 19 and breaks the app-wide CompatRouter
react-router@>=6.7.0 <6.30.4: '>=6.30.4 <7'
# via: msw@1.3.2 (devDep) > inquirer@8 > external-editor@3.1.0 (tmp ^0.0.33)
# remove: upgrade msw to >=2 (drops the inquirer/external-editor chain)
tmp@<0.2.6: '>=0.2.6 <0.3.0'
# via: jest > babel-plugin-macros > cosmiconfig@7 (yaml ^1.10.0);
# typescript-plugin-css-modules > postcss-load-config@3 (^1.10.2)
# remove: blocked — babel-plugin-macros 3.1.0 (latest) still uses cosmiconfig@7
yaml@>=1.0.0 <1.10.3: '>=1.10.3 <2'
trustPolicy: no-downgrade
trustPolicyExclude:
- 'semver@6.3.1 || 5.7.2'
blockExoticSubdeps: true

View File

@@ -2082,39 +2082,6 @@ 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
@@ -2358,6 +2325,39 @@ 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
@@ -2814,8 +2814,6 @@ export enum CloudintegrationtypesServiceIDDTO {
cassandradb = 'cassandradb',
redis = 'redis',
cloudsql_postgres = 'cloudsql_postgres',
memorystore_redis = 'memorystore_redis',
computeengine = 'computeengine',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -10616,7 +10614,7 @@ export type ListRoles200 = {
/**
* @type array
*/
data: AuthtypesGettableRoleDTO[];
data: AuthtypesRoleDTO[];
/**
* @type string
*/
@@ -10638,7 +10636,7 @@ export type GetRolePathParameters = {
id: string;
};
export type GetRole200 = {
data: AuthtypesRoleDTO;
data: AuthtypesRoleWithTransactionGroupsDTO;
/**
* @type string
*/

View File

@@ -1,26 +1,50 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { AxiosError, AxiosResponse } from 'axios';
import { ErrorV2Resp } from 'types/api';
import { ExportRawDataProps } from 'types/api/exportRawData/getExportRawData';
export interface FetchExportDataProps extends ExportRawDataProps {
signal?: AbortSignal;
}
async function postExportRawData({
format,
body,
signal,
}: FetchExportDataProps): Promise<AxiosResponse<Blob>> {
return axios.post<Blob>(
`export_raw_data?format=${encodeURIComponent(format)}`,
body,
{
responseType: 'blob',
decompress: true,
headers: {
Accept: 'application/octet-stream',
'Content-Type': 'application/json',
},
timeout: 0,
signal,
},
);
}
/**
* Fetches a single export_raw_data page and returns it as a Blob.
* Callers own retry/cancel/error UX.
*/
export async function fetchExportData(
props: FetchExportDataProps,
): Promise<Blob> {
const response = await postExportRawData(props);
return response.data;
}
export const downloadExportData = async (
props: ExportRawDataProps,
): Promise<void> => {
try {
const response = await axios.post<Blob>(
`export_raw_data?format=${encodeURIComponent(props.format)}`,
props.body,
{
responseType: 'blob',
decompress: true,
headers: {
Accept: 'application/octet-stream',
'Content-Type': 'application/json',
},
timeout: 0,
},
);
const response = await postExportRawData(props);
if (response.status !== 200) {
throw new Error(

View File

@@ -12,4 +12,5 @@
import '@signozhq/design-tokens';
import '@signozhq/icons';
import '@signozhq/resizable';
import '@signozhq/ui';

View File

@@ -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 { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesRoleDTO } 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: AuthtypesGettableRoleDTO[];
roles: AuthtypesRoleDTO[];
isLoading: boolean;
isError: boolean;
error: APIError | undefined;
@@ -33,7 +33,7 @@ export function useRoles(): {
}
export function getRoleOptions(
roles: AuthtypesGettableRoleDTO[],
roles: AuthtypesRoleDTO[],
valueField: 'id' | 'name',
): RoleOption[] {
return roles.map((role) => ({
@@ -79,7 +79,7 @@ interface BaseProps {
placeholder?: string;
className?: string;
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
roles?: AuthtypesGettableRoleDTO[];
roles?: AuthtypesRoleDTO[];
loading?: boolean;
isError?: boolean;
error?: APIError;

View File

@@ -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 { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
import RolesSelect from 'components/RolesSelect';
@@ -28,7 +28,7 @@ interface OverviewTabProps {
localRoles: string[];
onRolesChange: (v: string[]) => void;
isDisabled: boolean;
availableRoles: AuthtypesGettableRoleDTO[];
availableRoles: AuthtypesRoleDTO[];
rolesLoading?: boolean;
rolesError?: boolean;
rolesErrorObj?: APIError | undefined;

View File

@@ -14,28 +14,11 @@
box-sizing: border-box;
}
.entityMetricsTitleContainer {
display: flex;
align-items: center;
gap: 8px;
}
.entityMetricsTitle {
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.metricsExplorerLink {
display: flex;
align-items: center;
color: var(--l2-foreground);
transition: opacity 0.2s;
&:hover {
color: var(--l3-foreground);
}
}
.metricsHeader {
display: flex;
justify-content: flex-end;

View File

@@ -1,8 +1,6 @@
import { useCallback, useMemo, useRef } from 'react';
import { UseQueryResult } from 'react-query';
import { Link } from 'react-router-dom';
import { Compass } from '@signozhq/icons';
import { Skeleton, Tooltip } from 'antd';
import { Skeleton } from 'antd';
import cx from 'classnames';
import { PANEL_TYPES } from 'constants/queryBuilder';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
@@ -21,7 +19,6 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { getMetricsExplorerUrl } from 'utils/explorerUtils';
import { buildEntityMetricsChartConfig } from './configBuilder';
@@ -207,31 +204,9 @@ function EntityMetrics<T>({
key={entityWidgetInfo[idx].title}
className={styles.entityMetricsCol}
>
<div className={styles.entityMetricsTitleContainer}>
<span className={styles.entityMetricsTitle}>
{entityWidgetInfo[idx].title}
</span>
{queryPayloads[idx] &&
queryPayloads[idx].graphType !== PANEL_TYPES.TABLE && (
<Tooltip title="Open in Metrics Explorer">
<Link
to={getMetricsExplorerUrl({
query: queryPayloads[idx].query,
...(selectedInterval && selectedInterval !== 'custom'
? { relativeTime: selectedInterval }
: {
startTimeMs: timeRange.startTime * 1000,
endTimeMs: timeRange.endTime * 1000,
}),
})}
className={styles.metricsExplorerLink}
data-testid={`open-metrics-explorer-${idx}`}
>
<Compass size={14} />
</Link>
</Tooltip>
)}
</div>
<span className={styles.entityMetricsTitle}>
{entityWidgetInfo[idx].title}
</span>
<div className={styles.entityMetricsCard} ref={graphRef}>
{renderCardContent(query, idx)}
</div>

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import * as appContextHooks from 'providers/App/App';
@@ -296,19 +295,17 @@ const renderEntityMetrics = (overrides = {}): any => {
};
return render(
<MemoryRouter>
<EntityMetrics
timeRange={defaultProps.timeRange}
isModalTimeSelection={defaultProps.isModalTimeSelection}
handleTimeChange={defaultProps.handleTimeChange}
selectedInterval={defaultProps.selectedInterval}
entity={defaultProps.entity}
entityWidgetInfo={defaultProps.entityWidgetInfo}
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
queryKey={defaultProps.queryKey}
category={defaultProps.category}
/>
</MemoryRouter>,
<EntityMetrics
timeRange={defaultProps.timeRange}
isModalTimeSelection={defaultProps.isModalTimeSelection}
handleTimeChange={defaultProps.handleTimeChange}
selectedInterval={defaultProps.selectedInterval}
entity={defaultProps.entity}
entityWidgetInfo={defaultProps.entityWidgetInfo}
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
queryKey={defaultProps.queryKey}
category={defaultProps.category}
/>,
);
};
@@ -337,8 +334,8 @@ const mockTableData: (import('../utils').MetricsTableData[] | null)[] = [
];
const mockQueryPayloads = [
{ graphType: 'graph', query: { queryType: 'builder' } }, // time_series
{ graphType: 'table', query: { queryType: 'builder' } }, // table
{ graphType: 'graph' }, // time_series
{ graphType: 'table' }, // table
];
describe('EntityMetrics', () => {
@@ -445,34 +442,6 @@ describe('EntityMetrics', () => {
);
});
it('renders metrics explorer link only for non-table panels', () => {
renderEntityMetrics();
expect(screen.getByTestId('open-metrics-explorer-0')).toBeInTheDocument();
expect(
screen.queryByTestId('open-metrics-explorer-1'),
).not.toBeInTheDocument();
});
it('builds metrics explorer link with relativeTime when a relative interval is selected', () => {
renderEntityMetrics({ selectedInterval: '5m' as Time });
const href = screen
.getByTestId('open-metrics-explorer-0')
.getAttribute('href');
expect(href).toContain('relativeTime=5m');
expect(href).not.toContain('startTime=');
expect(href).not.toContain('endTime=');
});
it('builds metrics explorer link with absolute time range in milliseconds for custom interval', () => {
renderEntityMetrics({ selectedInterval: 'custom' as Time });
const href = screen
.getByTestId('open-metrics-explorer-0')
.getAttribute('href');
expect(href).toContain(`startTime=${mockTimeRange.startTime * 1000}`);
expect(href).toContain(`endTime=${mockTimeRange.endTime * 1000}`);
expect(href).not.toContain('relativeTime=');
});
it('passes correct parameters to useEntityMetrics hook', () => {
renderEntityMetrics();
expect(mockUseEntityMetrics).toHaveBeenCalledWith(

View File

@@ -3,13 +3,3 @@
flex-direction: column;
gap: var(--spacing-8);
}
.tabLabel {
display: inline-flex;
align-items: center;
gap: 8px;
}
.tabBadge {
margin: 0;
}

View File

@@ -1,58 +1,30 @@
import { Badge } from '@signozhq/ui/badge';
import { Tabs } from '@signozhq/ui/tabs';
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
import { parseAsStringEnum, useQueryState } from 'nuqs';
import { MODEL_COSTS_TAB, TAB_KEY, UNPRICED_MODELS_TAB } from './constants';
import styles from './LLMObservabilityModelPricing.module.scss';
import ModelCostTabPanel from './ModelCostTabPanel';
import UnpricedModelsTab from './UnpricedModelsTab';
import styles from './LLMObservabilityModelPricing.module.scss';
function LLMObservabilityModelPricing(): JSX.Element {
const [activeTab, setActiveTab] = useQueryState(
TAB_KEY,
parseAsStringEnum([MODEL_COSTS_TAB, UNPRICED_MODELS_TAB]).withDefault(
MODEL_COSTS_TAB,
),
);
// Count powers the tab badge; deduped with the tab's own fetch by react-query.
const { data } = useListUnmappedLLMModels();
const unpricedCount = data?.data?.items?.length ?? 0;
return (
<div
className={styles.llmObservabilityModelPricing}
data-testid="llm-observability-model-pricing-page"
>
<Tabs
value={activeTab}
onChange={(key): void => {
void setActiveTab(key as typeof activeTab);
}}
// Model costs is the only enabled tab for now, so default to it. When
// the unpriced-models tab lands in a later PR.
defaultValue="model-costs"
items={[
{
key: MODEL_COSTS_TAB,
key: 'model-costs',
label: 'Model costs',
children: <ModelCostTabPanel />,
},
{
key: UNPRICED_MODELS_TAB,
label: (
<span className={styles.tabLabel}>
Unpriced models
{unpricedCount > 0 && (
<Badge
variant="default"
className={styles.tabBadge}
data-testid="unpriced-models-count"
>
{unpricedCount}
</Badge>
)}
</span>
),
children: <UnpricedModelsTab />,
// Unpriced-models tab lands in a later PR.
key: 'unpriced-models',
label: 'Unpriced models',
disabled: true,
children: null,
},
]}
/>

View File

@@ -3,7 +3,6 @@ import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
getListUnmappedLLMModelsQueryKey,
useCreateOrUpdateLLMPricingRules,
} from 'api/generated/services/llmpricingrules';
@@ -11,16 +10,9 @@ import {
EMPTY_DRAFT,
TOAST_MODEL_COST_ADDED,
TOAST_MODEL_COST_UPDATED,
} from 'container/LLMObservability/Settings/ModelPricing/constants';
import type {
DrawerDraft,
DrawerMode,
PricingRule,
} from 'container/LLMObservability/Settings/ModelPricing/types';
import {
buildRulePayload,
draftFromRule,
} from 'container/LLMObservability/Settings/ModelPricing/utils';
} from '../../../../constants';
import type { DrawerDraft, DrawerMode, PricingRule } from '../../../../types';
import { buildRulePayload, draftFromRule } from '../../../../utils';
interface UseModelCostDrawerResult {
isOpen: boolean;
@@ -46,26 +38,17 @@ export function useModelCostDrawer(): UseModelCostDrawerResult {
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
useCreateOrUpdateLLMPricingRules();
// Adding pricing can also resolve a model that was showing up as unpriced, so
// refresh the unmapped list (and its tab-badge count) alongside the rules list.
const invalidateList = useCallback(async (): Promise<void> => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
}),
queryClient.invalidateQueries({
queryKey: getListUnmappedLLMModelsQueryKey(),
}),
]);
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
}, [queryClient]);
// prefillModelName seeds the billing model ID when adding from an unpriced
// model row, so the user only has to fill in pricing.
const openForAdd = useCallback((prefillModelName?: string): void => {
const openForAdd = useCallback((): void => {
setMode('add');
setInitialDraft({
...EMPTY_DRAFT,
modelName: prefillModelName ?? '',
modelName: '',
patterns: [],
});
setSelectedRuleId(null);

View File

@@ -25,6 +25,7 @@
.drawerSurface {
padding: var(--spacing-7);
border-radius: 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
}

View File

@@ -1,27 +0,0 @@
.unpricedModelsTab {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.banner {
display: flex;
align-items: flex-start;
gap: var(--spacing-5);
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 30%, transparent);
background: color-mix(in srgb, var(--bg-amber-400) 8%, transparent);
color: var(--bg-amber-300, var(--bg-amber-400));
}
.bannerIcon {
flex-shrink: 0;
margin-top: var(--spacing-1);
}
.error {
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
}

View File

@@ -1,132 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { TriangleAlert } from '@signozhq/icons';
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import styles from './UnpricedModelsTab.module.scss';
import ModelCostDrawer, {
useModelCostDrawer,
} from '../ModelCostTabPanel/components/ModelCostDrawer';
import type { PricingRule, UnpricedModel } from '../types';
import MapConfirmDialog from './components/MapConfirmDialog';
import type { UnpricedColumnsConfig } from './components/UnpricedModelsTable/TableConfig';
import UnpricedModelsTable from './components/UnpricedModelsTable';
import { useUnpricedModelMapping } from './hooks/useUnpricedModelMapping';
import { usePendingMappingStore } from './usePendingMappingStore';
function UnpricedModelsTab(): JSX.Element {
const { data, isLoading, isError } = useListUnmappedLLMModels();
const { user } = useAppContext();
const [canManagePricing] = useComponentPermission(
['manage_llm_pricing'],
user.role,
);
const models: UnpricedModel[] = useMemo(() => data?.data?.items || [], [data]);
// Picking a billing model stages a single mapping for confirmation; the
// mapping only commits once the user confirms in the dialog. Kept in a store
// (not local state) so the row's memoized select trigger can mirror the pick —
// see usePendingMappingStore.
const pendingMapping = usePendingMappingStore((state) => state.pending);
const setPending = usePendingMappingStore((state) => state.setPending);
const clearPending = usePendingMappingStore((state) => state.clearPending);
const { mapModel, isSaving } = useUnpricedModelMapping();
// Reset any staged mapping when leaving the tab so a stale pick doesn't reopen
// the dialog on remount (the store outlives this component).
useEffect(() => (): void => clearPending(), [clearPending]);
// Reuses the "Model costs" add/edit drawer to define brand-new pricing for a
// model that has no matching billing model to map onto. Saving resolves the
// model, so it drops off this tab (the drawer invalidates the unmapped list).
const drawer = useModelCostDrawer();
const onRequestMap = useCallback(
(model: UnpricedModel, rule: PricingRule): void => {
setPending({ model, rule });
},
[setPending],
);
const onConfirmMap = useCallback(async (): Promise<void> => {
if (!pendingMapping) {
return;
}
const didSave = await mapModel(pendingMapping);
if (didSave) {
clearPending();
}
}, [mapModel, pendingMapping, clearPending]);
// openForAdd is stable (useCallback with no deps); drawer itself is a fresh
// object each render, so depend on the method, not the object.
const { openForAdd } = drawer;
const onCreateNew = useCallback(
(modelName: string): void => openForAdd(modelName),
[openForAdd],
);
const columnsConfig = useMemo<UnpricedColumnsConfig>(
() => ({
canManage: canManagePricing,
onRequestMap,
onCreateNew,
}),
[canManagePricing, onRequestMap, onCreateNew],
);
return (
<div className={styles.unpricedModelsTab}>
<div className={styles.banner}>
<TriangleAlert size="sm" className={styles.bannerIcon} />
<Typography.Text as="span" size="small" color="warning">
Models detected in traces without pricing. Map each to a billing model or
create pricing so estimated cost can be computed.
</Typography.Text>
</div>
{isError && (
<div className={styles.error}>
<Typography.Text as="p" size="small" color="danger" role="alert">
Failed to load unpriced models. Please try again.
</Typography.Text>
</div>
)}
<UnpricedModelsTable
models={models}
isLoading={isLoading}
columnsConfig={columnsConfig}
/>
{pendingMapping && (
<MapConfirmDialog
open
model={pendingMapping.model}
rule={pendingMapping.rule}
isSaving={isSaving}
onConfirm={onConfirmMap}
onCancel={clearPending}
/>
)}
{drawer.isOpen && (
<ModelCostDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
initialDraft={drawer.initialDraft}
onClose={drawer.close}
onSave={drawer.save}
isSaving={drawer.isSaving}
saveError={drawer.saveError}
canManage={canManagePricing}
/>
)}
</div>
);
}
export default UnpricedModelsTab;

View File

@@ -1,181 +0,0 @@
import type { LlmpricingruletypesUpdatableLLMPricingRulesDTO } from 'api/generated/services/sigNoz.schemas';
import {
LLM_PRICING_ENDPOINT,
LLM_UNMAPPED_ENDPOINT,
makeListResponse,
makeUnmappedResponse,
mockRules,
} from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import UnpricedModelsTab from '../UnpricedModelsTab';
const toastSuccess = jest.fn();
const toastError = jest.fn();
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: (...args: unknown[]): void => toastSuccess(...args),
error: (...args: unknown[]): void => toastError(...args),
},
}));
const MODEL = 'gpt-4o-mini-2024-07-18';
function setup(): void {
server.use(
rest.get(LLM_UNMAPPED_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeUnmappedResponse())),
),
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeListResponse(mockRules))),
),
);
}
// Picks a billing model in a row's dropdown: open the combobox, then click the
// option. The dropdown fetches its options from the rules list (mocked above).
async function selectRule(
user: ReturnType<typeof userEvent.setup>,
modelName: string,
ruleId: string,
): Promise<void> {
await user.click(screen.getByTestId(`map-to-select-${modelName}`));
await user.click(await screen.findByTestId(`map-to-option-${ruleId}`));
}
describe('UnpricedModelsTab (integration)', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
setup();
});
afterEach(() => {
server.resetHandlers();
toastSuccess.mockClear();
toastError.mockClear();
});
it('opens the confirm dialog with the target rule pricing when a model is picked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
// Picking a rule stages the mapping in the confirm dialog.
await selectRule(user, MODEL, 'rule-openai');
const confirmItem = await screen.findByTestId(
`unpriced-map-confirm-item-${MODEL}`,
);
expect(confirmItem).toBeInTheDocument();
// Shows the target billing model + its pricing so the user can eyeball it.
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
expect(screen.getByText('$3.00')).toBeInTheDocument();
expect(screen.getByText('$9.00')).toBeInTheDocument();
// While the dialog is open, the row's trigger mirrors the staged pick
// instead of the placeholder.
const trigger = screen.getByTestId(`map-to-select-${MODEL}`);
expect(
within(trigger).getByText('openai:gpt-4o ($3.00/$9.00)'),
).toBeInTheDocument();
});
it('reverts the row trigger to the placeholder when the mapping is cancelled', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
await selectRule(user, MODEL, 'rule-openai');
const trigger = screen.getByTestId(`map-to-select-${MODEL}`);
expect(
within(trigger).getByText('openai:gpt-4o ($3.00/$9.00)'),
).toBeInTheDocument();
await user.click(await screen.findByTestId('unpriced-map-cancel-btn'));
await waitFor(() =>
expect(
within(trigger).queryByText('openai:gpt-4o ($3.00/$9.00)'),
).not.toBeInTheDocument(),
);
expect(
within(trigger).getByText('Select / Create a pricing model'),
).toBeInTheDocument();
});
it('commits the mapping in one request when confirmed', async () => {
const sent: LlmpricingruletypesUpdatableLLMPricingRulesDTO[] = [];
server.use(
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
sent.push(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
await selectRule(user, MODEL, 'rule-openai');
await user.click(await screen.findByTestId('unpriced-map-confirm-btn'));
await waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0].rules).toHaveLength(1);
expect(sent[0].rules?.[0].modelPattern).toContain(MODEL);
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith('Mapped model'),
);
// Dialog closes on success.
await waitFor(() =>
expect(
screen.queryByTestId(`unpriced-map-confirm-item-${MODEL}`),
).not.toBeInTheDocument(),
);
});
it('cancels the mapping without committing', async () => {
const sent: LlmpricingruletypesUpdatableLLMPricingRulesDTO[] = [];
server.use(
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
sent.push(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
await selectRule(user, MODEL, 'rule-openai');
await user.click(await screen.findByTestId('unpriced-map-cancel-btn'));
await waitFor(() =>
expect(
screen.queryByTestId(`unpriced-map-confirm-item-${MODEL}`),
).not.toBeInTheDocument(),
);
expect(sent).toHaveLength(0);
});
it('opens the add-cost drawer prefilled when creating pricing for a model', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
// Open the row's dropdown and take the "Create pricing for …" escape hatch
// instead of mapping onto an existing billing model.
await user.click(screen.getByTestId(`map-to-select-${MODEL}`));
await user.click(await screen.findByTestId(`map-to-create-${MODEL}`));
// The shared add-cost drawer opens with the model name prefilled.
const drawerTitle = await screen.findByText('Add model cost');
expect(drawerTitle).toBeInTheDocument();
expect(screen.getByTestId('drawer-model-id-input')).toHaveValue(MODEL);
});
});

View File

@@ -1,40 +0,0 @@
.body {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.mapping {
display: flex;
align-items: center;
gap: var(--spacing-4);
flex-wrap: wrap;
}
.arrow {
flex-shrink: 0;
opacity: 0.6;
}
.pricing {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-6);
border-radius: var(--radius-2);
border: 1px solid var(--l2-border);
background: var(--l3-background);
}
.pricingRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
}
.footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-4);
}

View File

@@ -1,124 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
import { ArrowRight, Check, Link2, X } from '@signozhq/icons';
import { startCase } from 'lodash-es';
import styles from './MapConfirmDialog.module.scss';
import type {
PricingRule,
UnpricedModel,
} from 'container/LLMObservability/Settings/ModelPricing/types';
import {
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
} from 'container/LLMObservability/Settings/ModelPricing/utils';
interface MapConfirmDialogProps {
open: boolean;
model: UnpricedModel;
rule: PricingRule;
isSaving: boolean;
onConfirm: () => void;
onCancel: () => void;
}
// Per-row confirm step before mapping an unpriced model onto an existing billing
// model. Mapping appends the span's model name as a match pattern on the chosen
// rule, so the model inherits that rule's pricing — shown here so the user can
// eyeball the rates before committing. Dismissible (outside click / close), since
// mapping is reversible (re-map or edit the rule's patterns afterwards).
function MapConfirmDialog({
open,
model,
rule,
isSaving,
onConfirm,
onCancel,
}: MapConfirmDialogProps): JSX.Element {
const extraBuckets = getExtraBuckets(rule);
const pricingRows = [
{ key: 'input', label: 'Input / 1M', value: rule.pricing?.input },
{ key: 'output', label: 'Output / 1M', value: rule.pricing?.output },
...extraBuckets.map((bucket) => ({
key: bucket.key,
label: startCase(bucket.key),
value: bucket.pricePerMillion,
})),
];
const footer = (
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
onClick={onCancel}
disabled={isSaving}
prefix={<X size={12} />}
testId="unpriced-map-cancel-btn"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
loading={isSaving}
onClick={onConfirm}
prefix={<Check size={12} />}
testId="unpriced-map-confirm-btn"
>
Map model
</Button>
</div>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onCancel();
}
}}
width="base"
title="Map to billing model"
titleIcon={<Link2 size={16} />}
footer={footer}
testId="unpriced-map-confirm-dialog"
>
<div className={styles.body}>
<Typography.Text as="p" size="small" color="muted">
Spans from this model will be priced using the selected billing model.
</Typography.Text>
<div className={styles.mapping}>
<Typography.Text
weight="semibold"
testId={`unpriced-map-confirm-item-${model.modelName}`}
>
{model.modelName}
</Typography.Text>
<ArrowRight size={14} className={styles.arrow} />
<Typography.Text weight="semibold">{getCanonicalId(rule)}</Typography.Text>
</div>
<div className={styles.pricing}>
{pricingRows.map((row) => (
<div className={styles.pricingRow} key={row.key}>
<Typography.Text as="span" size="small" color="muted">
{row.label}
</Typography.Text>
<Typography.Text as="span" size="small" weight="semibold">
{formatPricePerMillion(row.value)}
</Typography.Text>
</div>
))}
</div>
</div>
</DialogWrapper>
);
}
export default MapConfirmDialog;

View File

@@ -1,27 +0,0 @@
.mapToCell {
display: flex;
align-items: center;
gap: var(--spacing-1);
min-width: 0;
}
.mapToSelect {
width: 100%;
max-width: 280px;
}
.mapToDropdown {
width: 280px;
}
.skeletonList {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-4);
}
.skeletonRow {
display: block;
width: 100%;
}

View File

@@ -1,138 +0,0 @@
import { useState } from 'react';
import {
Combobox,
ComboboxCommand,
ComboboxContent,
ComboboxCreateItem,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
} from '@signozhq/ui/combobox';
import { Plus } from '@signozhq/icons';
import { Skeleton } from 'antd';
import styles from './MapToBillingModelSelect.module.scss';
import { RULE_OPTIONS_LIMIT } from 'container/LLMObservability/Settings/ModelPricing/constants';
import type { PricingRule } from 'container/LLMObservability/Settings/ModelPricing/types';
import { getRuleOptionLabel } from 'container/LLMObservability/Settings/ModelPricing/utils';
import { usePendingMappingLabel } from 'container/LLMObservability/Settings/ModelPricing/UnpricedModelsTab/usePendingMappingStore';
import { useMapToBillingModelSearch } from './useMapToBillingModelSearch';
// One placeholder row per fetched option, so the skeleton height matches the
// loaded list. Stable keys derived from the fetch limit.
const SKELETON_ROW_KEYS = Array.from(
{ length: RULE_OPTIONS_LIMIT },
(_, index) => `skeleton-${index}`,
);
interface MapToBillingModelSelectProps {
modelName: string;
disabled: boolean;
onSelect: (rule: PricingRule) => void;
onCreateNew: () => void;
}
// Searchable, server-paged dropdown for picking the billing model an unpriced
// model maps onto. Only RULE_OPTIONS_LIMIT rules are fetched at a time; typing
// narrows the set via the rules API rather than client-side filtering, so cmdk's
// own filter is disabled (shouldFilter={false}). The dropdown is a pure picker —
// choosing a rule hands it up to the confirm dialog rather than persisting a
// selection here. The trigger only mirrors the staged pick (read from the
// pending-mapping store) while that dialog is open, reverting on confirm/cancel.
function MapToBillingModelSelect({
modelName,
disabled,
onSelect,
onCreateNew,
}: MapToBillingModelSelectProps): JSX.Element {
const [open, setOpen] = useState(false);
const { searchText, setSearchText, rules, rulesById, isFetching } =
useMapToBillingModelSearch(open);
const selectedLabel = usePendingMappingLabel(modelName);
const handleSelect = (ruleId: string): void => {
const rule = rulesById.get(ruleId);
if (rule) {
onSelect(rule);
}
setOpen(false);
};
const handleCreateNew = (): void => {
setOpen(false);
onCreateNew();
};
return (
<div className={styles.mapToCell}>
<Combobox open={open} onOpenChange={setOpen}>
<ComboboxTrigger
className={styles.mapToSelect}
disabled={disabled}
placeholder="Select / Create a pricing model"
value={selectedLabel}
testId={`map-to-select-${modelName}`}
/>
<ComboboxContent className={styles.mapToDropdown}>
<ComboboxCommand shouldFilter={false}>
<ComboboxInput
value={searchText}
onValueChange={setSearchText}
placeholder="Search billing models"
testId={`map-to-search-${modelName}`}
/>
<ComboboxList>
{rules.map((rule) => (
<ComboboxItem
key={rule.id}
value={rule.id}
onSelect={(): void => handleSelect(rule.id)}
data-testid={`map-to-option-${rule.id}`}
>
{getRuleOptionLabel(rule)}
</ComboboxItem>
))}
{isFetching && (
<div
className={styles.skeletonList}
data-testid={`map-to-loading-${modelName}`}
>
{SKELETON_ROW_KEYS.map((key) => (
<Skeleton.Input
key={key}
active
block
size="small"
className={styles.skeletonRow}
/>
))}
</div>
)}
{!isFetching && rules.length === 0 && (
<ComboboxEmpty>No billing models found</ComboboxEmpty>
)}
</ComboboxList>
{/* Kept outside ComboboxList so it stays pinned as a footer while the
options scroll. Escape hatch when no existing billing model fits:
define this model's own pricing rather than mapping onto another. */}
<ComboboxSeparator alwaysRender />
<ComboboxCreateItem
inputValue={modelName}
value={`create-pricing-${modelName}`}
prefix={<Plus size={14} />}
onSelect={handleCreateNew}
testId={`map-to-create-${modelName}`}
>
Create pricing for &quot;{modelName}&quot;
</ComboboxCreateItem>
</ComboboxCommand>
</ComboboxContent>
</Combobox>
</div>
);
}
export default MapToBillingModelSelect;

View File

@@ -1 +0,0 @@
export { default } from './MapToBillingModelSelect';

View File

@@ -1,44 +0,0 @@
import { useMemo, useState } from 'react';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import useDebounce from 'hooks/useDebounce';
import {
RULE_OPTIONS_LIMIT,
SEARCH_DEBOUNCE_MS,
} from 'container/LLMObservability/Settings/ModelPricing/constants';
import type { PricingRule } from 'container/LLMObservability/Settings/ModelPricing/types';
interface UseMapToBillingModelSearch {
searchText: string;
setSearchText: (value: string) => void;
rules: PricingRule[];
// Fetched rules keyed by id, so a pick can resolve to its full rule object.
rulesById: Map<string, PricingRule>;
isFetching: boolean;
}
// Server-side search for the per-row "Map to billing model" dropdown. Only
// RULE_OPTIONS_LIMIT rules are fetched at a time; typing narrows the set via the
// rules API's `q` param instead of filtering client-side. The fetch is gated on
// `enabled` (the dropdown's open state) so closed rows don't fetch, and
// react-query dedupes identical query keys so rows sharing a term hit the network
// once.
export function useMapToBillingModelSearch(
enabled: boolean,
): UseMapToBillingModelSearch {
const [searchText, setSearchText] = useState('');
const debouncedSearch = useDebounce(searchText, SEARCH_DEBOUNCE_MS);
const { data, isFetching } = useListLLMPricingRules(
{ offset: 0, limit: RULE_OPTIONS_LIMIT, q: debouncedSearch || undefined },
{ query: { enabled } },
);
const rules = useMemo<PricingRule[]>(() => data?.data?.items || [], [data]);
const rulesById = useMemo(
() => new Map(rules.map((rule) => [rule.id, rule])),
[rules],
);
return { searchText, setSearchText, rules, rulesById, isFetching };
}

View File

@@ -1,2 +0,0 @@
export { getUnpricedModelsColumns } from './table.config';
export type { UnpricedColumnsConfig } from './table.config';

View File

@@ -1,88 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import type { TableColumnDef } from 'components/TanStackTableView';
import MapToBillingModelSelect from 'container/LLMObservability/Settings/ModelPricing/UnpricedModelsTab/components/MapToBillingModelSelect';
import type {
PricingRule,
UnpricedModel,
} from 'container/LLMObservability/Settings/ModelPricing/types';
import { formatSpanCount } from 'container/LLMObservability/Settings/ModelPricing/utils';
import styles from './tableConfig.module.scss';
export interface UnpricedColumnsConfig {
canManage: boolean;
// Picking a billing model requests a mapping — the tab opens a confirm dialog
// and commits that single mapping there (no batch/selection state here).
onRequestMap: (model: UnpricedModel, rule: PricingRule) => void;
// Opens the add-cost drawer prefilled with the row's model name.
onCreateNew: (modelName: string) => void;
}
// Column definitions for the unpriced-models TanStackTable. Sorting is off — the
// unmapped-models endpoint returns the full set in one shot with no ordering knob.
// Each row's action is self-contained: pick a billing model to map onto (confirmed
// in a dialog) or create pricing for the model, so there's no bulk-save column.
export function getUnpricedModelsColumns({
canManage,
onRequestMap,
onCreateNew,
}: UnpricedColumnsConfig): TableColumnDef<UnpricedModel>[] {
return [
{
id: 'model',
header: 'Model (from spans)',
accessorFn: (row): string => row.modelName,
width: { min: 240, default: '100%' },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text
weight="semibold"
truncate={1}
testId={`unpriced-model-name-${row.modelName}`}
>
{row.modelName}
</Typography.Text>
),
},
{
id: 'provider',
header: 'Provider',
width: { min: 140 },
enableMove: false,
cell: ({ row }): string => row.provider || 'Unknown',
},
{
id: 'spans',
header: 'Spans',
width: { min: 100 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Badge
color="cherry"
variant="outline"
className={styles.spansBadge}
data-testid={`unpriced-spans-${row.modelName}`}
>
{formatSpanCount(row.spanCount)}
</Badge>
),
},
{
id: 'mapTo',
header: 'Map to billing model',
width: { min: 280, default: '100%' },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<MapToBillingModelSelect
modelName={row.modelName}
disabled={!canManage}
onSelect={(rule): void => onRequestMap(row, rule)}
onCreateNew={(): void => onCreateNew(row.modelName)}
/>
),
},
];
}

View File

@@ -1,4 +0,0 @@
.spansBadge {
margin: 0;
font-family: var(--code-font-family, monospace);
}

View File

@@ -1,22 +0,0 @@
// Capped scroll viewport — mirrors ModelCostsTable so the unpriced tab doesn't
// shift on load, but uses max-height so a short list stays compact instead of
// reserving the full viewport. The extra offset over the model-costs table
// accounts for the banner toolbar above.
.unpricedModelsTable {
--tanstack-table-row-height: 48px;
max-height: calc(100vh - 220px);
overflow-y: auto;
:global(table) tbody tr {
cursor: default;
}
}
.unpricedModelsEmpty {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,56 +0,0 @@
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { SKELETON_ROW_COUNT } from 'container/LLMObservability/Settings/ModelPricing/constants';
import type { UnpricedModel } from 'container/LLMObservability/Settings/ModelPricing/types';
import styles from './UnpricedModelsTable.module.scss';
import {
getUnpricedModelsColumns,
type UnpricedColumnsConfig,
} from './TableConfig';
interface UnpricedModelsTableProps {
models: UnpricedModel[];
isLoading: boolean;
columnsConfig: UnpricedColumnsConfig;
}
// The unmapped-models endpoint returns the full set in one response, so there's
// no pagination here — just a content-height list. Virtual scroll is disabled
// because the set is small and bounded.
function UnpricedModelsTable({
models,
isLoading,
columnsConfig,
}: UnpricedModelsTableProps): JSX.Element {
const columns = useMemo(
() => getUnpricedModelsColumns(columnsConfig),
[columnsConfig],
);
if (!isLoading && models.length === 0) {
return (
<div
className={styles.unpricedModelsEmpty}
data-testid="unpriced-models-empty"
>
All models in your traces are priced.
</div>
);
}
return (
<TanStackTable<UnpricedModel>
className={styles.unpricedModelsTable}
data={models}
columns={columns}
isLoading={isLoading}
skeletonRowCount={SKELETON_ROW_COUNT}
getRowKey={(row): string => row.modelName}
disableVirtualScroll
testId="unpriced-models-table"
/>
);
}
export default UnpricedModelsTable;

View File

@@ -1,70 +0,0 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
getListUnmappedLLMModelsQueryKey,
useCreateOrUpdateLLMPricingRules,
} from 'api/generated/services/llmpricingrules';
import type {
PricingRule,
UnpricedModel,
} from 'container/LLMObservability/Settings/ModelPricing/types';
import { buildPatternMappingPayload } from 'container/LLMObservability/Settings/ModelPricing/utils';
// A single row's choice: map this unpriced model onto this billing rule.
export interface UnpricedModelMapping {
model: UnpricedModel;
rule: PricingRule;
}
interface UseUnpricedModelMappingResult {
// Commits a single mapping in one request. Resolves true on success so the
// caller can close the confirm dialog and clear its staged pick.
mapModel: (mapping: UnpricedModelMapping) => Promise<boolean>;
// True while the save is in flight, so the confirm button can spin.
isSaving: boolean;
}
// Maps an unpriced model onto an existing pricing rule. There's no dedicated
// "edit" endpoint — mapping reuses CreateOrUpdate (PUT), appending the model name
// as a match pattern on the chosen rule so the model inherits its pricing. Both
// the unmapped list and the rules list are invalidated on success so the mapped
// model drops out of this tab immediately.
export function useUnpricedModelMapping(): UseUnpricedModelMappingResult {
const queryClient = useQueryClient();
const [isSaving, setIsSaving] = useState(false);
const { mutateAsync: createOrUpdate } = useCreateOrUpdateLLMPricingRules();
const mapModel = useCallback(
async ({ model, rule }: UnpricedModelMapping): Promise<boolean> => {
const payload = buildPatternMappingPayload(rule, model.modelName);
setIsSaving(true);
try {
await createOrUpdate({ data: { rules: [payload] } });
await Promise.all([
queryClient.invalidateQueries({
queryKey: getListUnmappedLLMModelsQueryKey(),
}),
queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
}),
]);
toast.success('Mapped model');
return true;
} catch (error) {
const message = error instanceof Error ? error.message : 'Mapping failed';
toast.error(message);
return false;
} finally {
setIsSaving(false);
}
},
[createOrUpdate, queryClient],
);
return { mapModel, isSaving };
}

View File

@@ -1 +0,0 @@
export { default } from './UnpricedModelsTab';

View File

@@ -1,39 +0,0 @@
import { create } from 'zustand';
import type { PricingRule, UnpricedModel } from '../types';
import { getRuleOptionLabel } from '../utils';
// A model + the billing rule it's about to be mapped onto, held while the confirm
// dialog is open.
export interface PendingMapping {
model: UnpricedModel;
rule: PricingRule;
}
interface PendingMappingState {
pending: PendingMapping | null;
setPending: (mapping: PendingMapping) => void;
clearPending: () => void;
}
// The staged mapping lives in a store (not component state/props) because the
// row's select trigger, which mirrors the staged pick, sits inside a memoized
// TanStackTable cell whose value is cached per row — a prop/context change would
// not re-render it, but a store subscription does. Picking a billing model stages
// a single mapping for confirmation; it only commits once confirmed in the dialog.
export const usePendingMappingStore = create<PendingMappingState>((set) => ({
pending: null,
setPending: (mapping): void => set({ pending: mapping }),
clearPending: (): void => set({ pending: null }),
}));
// Label to show on the given row's select trigger while its mapping is staged, or
// undefined when nothing is staged for that row (falls back to the placeholder).
export const usePendingMappingLabel = (modelName: string): string | undefined =>
usePendingMappingStore((state) => {
const { pending } = state;
if (!pending || pending.model.modelName !== modelName) {
return undefined;
}
return getRuleOptionLabel(pending.rule);
});

View File

@@ -2,19 +2,14 @@ import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type ListLLMPricingRules200,
type ListUnmappedLLMModels200,
} from 'api/generated/services/sigNoz.schemas';
import type { PricingRule, UnpricedModel } from '../types';
import type { PricingRule } from '../types';
// Endpoint glob used by MSW handlers. The generated client hits a relative
// `/api/v1/llm_pricing_rules`, so the `*` prefix matches regardless of base URL.
export const LLM_PRICING_ENDPOINT = '*/api/v1/llm_pricing_rules';
export const LLM_PRICING_RULE_ENDPOINT = '*/api/v1/llm_pricing_rules/:id';
// Distinct path (extra segment), so it needs its own handler — the list glob
// above does not match it.
export const LLM_UNMAPPED_ENDPOINT =
'*/api/v1/llm_pricing_rules/unmapped_models';
// Builds a valid pricing rule, with overrides merged shallowly. Pricing is
// replaced wholesale when provided so callers can shape cache buckets freely.
@@ -65,26 +60,6 @@ export const mockRules: PricingRule[] = [
}),
];
// Unpriced models seen in traces with no matching pricing rule.
export const mockUnpricedModels: UnpricedModel[] = [
{ modelName: 'gpt-4o-mini-2024-07-18', provider: 'openai', spanCount: 18400 },
{
modelName: 'claude-3-7-sonnet-20250219',
provider: 'anthropic',
spanCount: 9200,
},
];
// Wraps unpriced models in the envelope the unmapped-models query reads.
export function makeUnmappedResponse(
items: UnpricedModel[] = mockUnpricedModels,
): ListUnmappedLLMModels200 {
return {
status: 'success',
data: { items },
};
}
// Wraps items in the list response envelope the list query reads
// (`data.data.items` / `data.data.total`).
export function makeListResponse(

View File

@@ -34,13 +34,6 @@ export const SOURCE_FILTER_TO_IS_OVERRIDE: Record<
// loaded page renders — otherwise the table height jumps on load.
export const SKELETON_ROW_COUNT = PAGE_SIZE;
export const RULE_OPTIONS_LIMIT = 10;
// URL-backed key for the active tab on the model-pricing page.
export const TAB_KEY = 'tab';
export const MODEL_COSTS_TAB = 'model-costs';
export const UNPRICED_MODELS_TAB = 'unpriced-models';
export const PROVIDER_OPTIONS = [
{ value: 'OpenAI', label: 'OpenAI' },
{ value: 'Anthropic', label: 'Anthropic' },
@@ -69,7 +62,6 @@ export const EMPTY_DRAFT: DrawerDraft = {
provider: 'OpenAI',
patterns: [],
isOverride: true,
enabled: true,
pricing: {
input: null,
output: null,

View File

@@ -1,14 +1,10 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
type LlmpricingruletypesLLMPricingRuleDTO,
type LlmpricingruletypesUnmappedModelDTO,
} from 'api/generated/services/sigNoz.schemas';
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
// A model seen in trace data (gen_ai.request.model) that no pricing rule matches.
export type UnpricedModel = LlmpricingruletypesUnmappedModelDTO;
export interface ExtraBucket {
key: string;
pricePerMillion: number;
@@ -33,7 +29,6 @@ export interface DrawerDraft {
provider: string;
patterns: string[];
isOverride: boolean;
enabled: boolean;
pricing: {
input: number | null;
output: number | null;

View File

@@ -86,7 +86,6 @@ export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
provider: rule.provider,
patterns: rule.modelPattern || [],
isOverride: !!rule.isOverride,
enabled: rule.enabled,
pricing: {
input: rule.pricing?.input ?? 0,
output: rule.pricing?.output ?? 0,
@@ -130,7 +129,7 @@ export const buildRulePayload = (
provider: draft.provider.trim(),
modelPattern: draft.patterns,
isOverride: draft.isOverride,
enabled: draft.enabled,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: buildPricingPayload(draft),
});
@@ -162,38 +161,3 @@ export const validatePricing = (
}
return true;
};
const spanCountFormatter = new Intl.NumberFormat('en', {
notation: 'compact',
maximumFractionDigits: 1,
});
export const formatSpanCount = (count: number): string =>
spanCountFormatter.format(count);
// Label for the "Map to billing model" dropdown, e.g. "openai:gpt-4o ($15.00/$60.00)".
export const getRuleOptionLabel = (rule: PricingRule): string =>
`${getCanonicalId(rule)} (${formatPricePerMillion(
rule.pricing?.input,
)}/${formatPricePerMillion(rule.pricing?.output)})`;
export const buildPatternMappingPayload = (
rule: PricingRule,
modelName: string,
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => {
const existing = rule.modelPattern ?? [];
const modelPattern = existing.includes(modelName)
? existing
: [...existing, modelName];
return {
id: rule.id,
sourceId: rule.sourceId,
modelName: rule.modelName,
provider: rule.provider,
modelPattern,
isOverride: rule.isOverride,
enabled: rule.enabled,
unit: rule.unit,
pricing: rule.pricing,
};
};

View File

@@ -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 { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ROUTES from 'constants/routes';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
@@ -21,7 +21,7 @@ const PAGE_SIZE = 20;
type DisplayItem =
| { type: 'section'; label: string; count?: number }
| { type: 'role'; role: AuthtypesGettableRoleDTO };
| { type: 'role'; role: AuthtypesRoleDTO };
interface RolesListContentProps {
searchQuery: string;
@@ -176,7 +176,7 @@ function RolesListContent({ searchQuery }: RolesListContentProps): JSX.Element {
);
}
const renderRow = (role: AuthtypesGettableRoleDTO): JSX.Element => (
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
<div
key={role.id}
className={cx(styles.tableRow, {

View File

@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from 'react-query';
import { ErrorType } from 'api/generatedAPIInstance';
import type {
AuthtypesPostableRoleDTO,
AuthtypesRoleDTO,
AuthtypesRoleWithTransactionGroupsDTO,
AuthtypesTransactionGroupDTO,
AuthtypesUpdatableRoleDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -133,7 +133,7 @@ export function transformTransactionGroupsToResourcePermissions(
}
export function transformApiToRolePermissions(
role: AuthtypesRoleDTO,
role: AuthtypesRoleWithTransactionGroupsDTO,
): RolePermissionsData {
return {
roleId: role.id,

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import {
getGetRolesByUserIDQueryKey,
useGetRolesByUserID,
@@ -21,11 +21,11 @@ export interface MemberRoleUpdateFailure {
}
interface UseMemberRoleManagerResult {
currentRoles: AuthtypesGettableRoleDTO[];
currentRoles: AuthtypesRoleDTO[];
isLoading: boolean;
applyDiff: (
localRoleIds: string[],
availableRoles: AuthtypesGettableRoleDTO[],
availableRoles: AuthtypesRoleDTO[],
) => Promise<MemberRoleUpdateFailure[]>;
}
@@ -40,7 +40,7 @@ export function useMemberRoleManager(
{ query: { enabled: !!userId && enabled } },
);
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
() => data?.data ?? [],
[data?.data],
);
@@ -61,7 +61,7 @@ export function useMemberRoleManager(
const applyDiff = useCallback(
async (
localRoleIds: string[],
availableRoles: AuthtypesGettableRoleDTO[],
availableRoles: AuthtypesRoleDTO[],
): Promise<MemberRoleUpdateFailure[]> => {
const currentRoleIds = new Set(
currentRoles.map((r) => r.id).filter(Boolean),

View File

@@ -6,7 +6,7 @@ import {
useDeleteServiceAccountRoleDeprecated,
useGetServiceAccountRoles,
} from 'api/generated/services/serviceaccount';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import { retryOn429 } from 'utils/errorUtils';
const enum PromiseStatus {
@@ -21,11 +21,11 @@ export interface RoleUpdateFailure {
}
interface UseServiceAccountRoleManagerResult {
currentRoles: AuthtypesGettableRoleDTO[];
currentRoles: AuthtypesRoleDTO[];
isLoading: boolean;
applyDiff: (
localRoleIds: string[],
availableRoles: AuthtypesGettableRoleDTO[],
availableRoles: AuthtypesRoleDTO[],
) => Promise<RoleUpdateFailure[]>;
}
@@ -40,7 +40,7 @@ export function useServiceAccountRoleManager(
{ query: { enabled: options?.enabled ?? true } },
);
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
() => data?.data ?? [],
[data?.data],
);
@@ -64,7 +64,7 @@ export function useServiceAccountRoleManager(
const applyDiff = useCallback(
async (
localRoleIds: string[],
availableRoles: AuthtypesGettableRoleDTO[],
availableRoles: AuthtypesRoleDTO[],
): Promise<RoleUpdateFailure[]> => {
const currentRoleIds = new Set(
currentRoles.map((r) => r.id).filter(Boolean),

View File

@@ -0,0 +1,183 @@
import 'tests/blob-polyfill';
import { fetchExportData } from 'api/v1/download/downloadExportData';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { downloadFile } from 'lib/exportData/downloadFile';
import { ExportFormat } from 'lib/exportData/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { runChunkedExport } from '../runChunkedExport';
jest.mock('api/v1/download/downloadExportData', () => ({
fetchExportData: jest.fn(),
}));
jest.mock('api/v5/v5', () => ({
prepareQueryRangePayloadV5: jest.fn(),
}));
jest.mock('lib/exportData/downloadFile', () => ({
downloadFile: jest.fn(),
getTimestampedFileName: jest.fn(
(base: string, ext: string) => `${base}.${ext}`,
),
}));
const mockFetch = fetchExportData as jest.Mock;
const mockPreparePayload = prepareQueryRangePayloadV5 as jest.Mock;
const mockDownloadFile = downloadFile as jest.Mock;
// Minimal query shape — the runner only maps over builder.queryData.
const query = {
builder: { queryData: [{}] },
} as unknown as Query;
const baseArgs = {
query,
timeRange: { start: 100, end: 200 },
fileNameBase: 'trace-x',
format: ExportFormat.Csv,
onProgress: jest.fn(),
};
// EXPORT_PAGE_SIZE is 50_000; rows → parts: 120k → 3, 40k → 1, etc.
const rowsForParts = (parts: number): number => parts * 50_000;
function runArgs(
overrides: Partial<Parameters<typeof runChunkedExport>[0]> = {},
): Parameters<typeof runChunkedExport>[0] {
return {
...baseArgs,
totalRows: rowsForParts(1),
signal: new AbortController().signal,
onProgress: jest.fn(),
...overrides,
};
}
async function savedFileText(): Promise<string> {
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
const [blob] = mockDownloadFile.mock.calls[0];
return (blob as Blob).text();
}
describe('runChunkedExport', () => {
beforeEach(() => {
jest.clearAllMocks();
mockPreparePayload.mockImplementation(({ query: pageQuery }) => ({
// Echo the page offset so fetch calls can be asserted per page.
queryPayload: { offset: pageQuery.builder.queryData[0].offset },
}));
});
it('fetches one page for small exports and saves the file', async () => {
mockFetch.mockResolvedValueOnce(new Blob(['h\na,b\n']));
const onProgress = jest.fn();
await runChunkedExport(runArgs({ totalRows: 40_000, onProgress }));
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0][0].body).toStrictEqual({ offset: 0 });
expect(onProgress).toHaveBeenCalledWith(100);
await expect(savedFileText()).resolves.toBe('h\na,b\n');
});
it('fetches every page with the right offsets and stitches in order', async () => {
mockFetch.mockImplementation(({ body }) =>
Promise.resolve(new Blob([`h\nrow-${body.offset}\n`])),
);
await runChunkedExport(runArgs({ totalRows: rowsForParts(3) }));
const offsets = mockFetch.mock.calls.map(([props]) => props.body.offset);
expect(offsets.sort((a, b) => a - b)).toStrictEqual([0, 50_000, 100_000]);
await expect(savedFileText()).resolves.toBe(
'h\nrow-0\nrow-50000\nrow-100000\n',
);
});
it('keeps page order even when later pages resolve first', async () => {
const resolvers: Record<number, (b: Blob) => void> = {};
mockFetch.mockImplementation(
({ body }) =>
new Promise((resolve) => {
resolvers[body.offset] = resolve;
}),
);
const promise = runChunkedExport(runArgs({ totalRows: rowsForParts(3) }));
// All 3 pages are in flight (concurrency 3); resolve in reverse order.
await Promise.resolve();
resolvers[100_000](new Blob(['h\nc\n']));
resolvers[50_000](new Blob(['h\nb\n']));
resolvers[0](new Blob(['h\na\n']));
await promise;
await expect(savedFileText()).resolves.toBe('h\na\nb\nc\n');
});
it('stops claiming pages once the server runs dry', async () => {
// Count says 5 pages but the server only has data for page 0.
mockFetch.mockImplementation(({ body }) =>
Promise.resolve(body.offset === 0 ? new Blob(['h\na\n']) : new Blob([])),
);
await runChunkedExport(runArgs({ totalRows: rowsForParts(5) }));
// Workers stop claiming once an empty part is observed — exact count is
// timing-dependent, but it must never fetch all 5 pages.
expect(mockFetch.mock.calls.length).toBeLessThan(5);
await expect(savedFileText()).resolves.toBe('h\na\n');
});
it('rejects on page failure, aborts in-flight siblings, saves nothing', async () => {
const seenSignals: AbortSignal[] = [];
mockFetch.mockImplementation(({ body, signal }) => {
seenSignals.push(signal);
return body.offset === 50_000
? Promise.reject(new Error('boom'))
: new Promise(() => {}); // siblings hang until aborted
});
await expect(
runChunkedExport(runArgs({ totalRows: rowsForParts(3) })),
).rejects.toThrow('boom');
expect(seenSignals.every((s) => s.aborted)).toBe(true);
expect(mockDownloadFile).not.toHaveBeenCalled();
});
it('propagates a user abort to the page fetches', async () => {
const controller = new AbortController();
mockFetch.mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
);
}),
);
const promise = runChunkedExport(
runArgs({ totalRows: rowsForParts(2), signal: controller.signal }),
);
await Promise.resolve();
controller.abort();
await expect(promise).rejects.toThrow('Aborted');
expect(mockDownloadFile).not.toHaveBeenCalled();
});
it('reports whole-number progress per completed part', async () => {
mockFetch.mockImplementation(({ body }) =>
Promise.resolve(new Blob([`h\nrow-${body.offset}\n`])),
);
const onProgress = jest.fn();
await runChunkedExport(runArgs({ totalRows: rowsForParts(3), onProgress }));
const reported = onProgress.mock.calls.map(([p]) => p);
expect(reported).toHaveLength(3);
expect(reported).toStrictEqual(expect.arrayContaining([33, 67, 100]));
});
});

View File

@@ -0,0 +1,4 @@
export const EXPORT_PAGE_SIZE = 50_000;
/** Pages fetched in parallel.*/
export const EXPORT_CONCURRENCY = 3;

View File

@@ -0,0 +1,143 @@
import { fetchExportData } from 'api/v1/download/downloadExportData';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
downloadFile,
getTimestampedFileName,
} from 'lib/exportData/downloadFile';
import { stitchCsvParts, stitchJsonlParts } from 'lib/exportData/stitchParts';
import { ExportFormat } from 'lib/exportData/types';
import store from 'store';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import { EXPORT_CONCURRENCY, EXPORT_PAGE_SIZE } from './constants';
export interface ChunkedExportArgs {
query: Query;
timeRange: { start: number; end: number };
// Total rows the query is expected to return; drives the page count.
totalRows: number;
fileNameBase: string;
}
interface RunChunkedExportProps extends ChunkedExportArgs {
format: ExportFormat;
signal: AbortSignal;
// 0100 whole number: parts completed over total parts.
onProgress: (progress: number) => void;
}
const MIME_BY_FORMAT: Record<ExportFormat, string> = {
[ExportFormat.Csv]: 'text/csv',
[ExportFormat.Jsonl]: 'application/x-ndjson',
};
/**
* Chunked server export: fetches export_raw_data pages through a bounded
* worker pool (EXPORT_CONCURRENCY wide — pages are independent since offsets
* are precomputable), stitches the parts in page order client-side, and saves
* a single file.
*/
export async function runChunkedExport({
query,
timeRange,
totalRows,
fileNameBase,
format,
signal,
onProgress,
}: RunChunkedExportProps): Promise<void> {
const totalParts = Math.max(1, Math.ceil(totalRows / EXPORT_PAGE_SIZE));
const workerCount = Math.min(Math.max(1, EXPORT_CONCURRENCY), totalParts);
// Internal controller so the first failed page (or a user cancel) stops the
// whole fleet instead of letting siblings run to completion for nothing.
const fleet = new AbortController();
const onExternalAbort = (): void => fleet.abort();
signal.addEventListener('abort', onExternalAbort);
if (signal.aborted) {
fleet.abort();
}
const buildPagePayload = (page: number): QueryRangePayloadV5 => {
const pageQuery: Query = {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((qd) => ({
...qd,
limit: EXPORT_PAGE_SIZE,
pageSize: EXPORT_PAGE_SIZE,
offset: page * EXPORT_PAGE_SIZE,
})),
},
};
return prepareQueryRangePayloadV5({
query: pageQuery,
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
// Read directly (not via hooks) — the absolute bounds below take
// precedence over the global picker in the payload anyway.
globalSelectedInterval: store.getState().globalTime.selectedTime,
start: timeRange.start,
end: timeRange.end,
}).queryPayload;
};
// Indexed by page so out-of-order completion can't scramble the stitch order.
const parts = new Array<Blob | undefined>(totalParts);
let nextPage = 0;
let completedParts = 0;
let serverRanDry = false;
const worker = async (): Promise<void> => {
while (!serverRanDry) {
const page = nextPage;
nextPage += 1;
if (page >= totalParts) {
return;
}
// eslint-disable-next-line no-await-in-loop
const part = await fetchExportData({
format,
body: buildPagePayload(page),
signal: fleet.signal,
});
// Empty part = the row count drifted and the server ran dry early;
// stop claiming further pages (later in-flight pages are empty too).
if (part.size === 0) {
serverRanDry = true;
return;
}
parts[page] = part;
completedParts += 1;
onProgress(Math.round((completedParts / totalParts) * 100));
}
};
try {
await Promise.all(Array.from({ length: workerCount }, () => worker()));
} catch (error) {
fleet.abort();
throw error;
} finally {
signal.removeEventListener('abort', onExternalAbort);
}
const orderedParts = parts.filter((part): part is Blob => Boolean(part));
const stitched =
format === ExportFormat.Csv
? await stitchCsvParts(orderedParts)
: await stitchJsonlParts(orderedParts);
downloadFile(
stitched,
getTimestampedFileName(fileNameBase, format),
MIME_BY_FORMAT[format],
);
}

View File

@@ -0,0 +1,100 @@
import 'tests/blob-polyfill';
import { stitchCsvParts, stitchJsonlParts } from '../stitchParts';
const blob = (content: string): Blob => new Blob([content]);
describe('stitchCsvParts', () => {
it('keeps a single part as-is', async () => {
const out = await stitchCsvParts([blob('h1,h2\na,b\n')]);
await expect(out.text()).resolves.toBe('h1,h2\na,b\n');
expect(out.type).toBe('text/csv');
});
it('strips the header row from parts after the first', async () => {
const out = await stitchCsvParts([
blob('h1,h2\na,b\n'),
blob('h1,h2\nc,d\n'),
blob('h1,h2\ne,f\n'),
]);
await expect(out.text()).resolves.toBe('h1,h2\na,b\nc,d\ne,f\n');
});
it('handles CRLF line endings (the \\r dies with the header)', async () => {
const out = await stitchCsvParts([
blob('h1,h2\r\na,b\r\n'),
blob('h1,h2\r\nc,d\r\n'),
]);
await expect(out.text()).resolves.toBe('h1,h2\r\na,b\r\nc,d\r\n');
});
it('inserts a newline at the seam when a part lacks a trailing one', async () => {
const out = await stitchCsvParts([blob('h1,h2\na,b'), blob('h1,h2\nc,d')]);
await expect(out.text()).resolves.toBe('h1,h2\na,b\nc,d\n');
});
it('preserves multi-byte characters around the header boundary', async () => {
const out = await stitchCsvParts([
blob('höader,h2\naä,b\n'),
blob('höader,h2\ncö,d\n'),
]);
await expect(out.text()).resolves.toBe('höader,h2\naä,b\ncö,d\n');
});
it('strips headers longer than the initial 8KB sniff window', async () => {
// Forces findFirstNewlineEnd through its window-doubling path.
const hugeHeader = Array.from({ length: 1200 }, (_, i) => `column_${i}`).join(
',',
);
expect(hugeHeader.length).toBeGreaterThan(8192);
const out = await stitchCsvParts([
blob(`${hugeHeader}\na,b\n`),
blob(`${hugeHeader}\nc,d\n`),
]);
await expect(out.text()).resolves.toBe(`${hugeHeader}\na,b\nc,d\n`);
});
it('skips empty and header-only parts', async () => {
const out = await stitchCsvParts([
blob('h1,h2\na,b\n'),
blob(''),
blob('h1,h2\n'),
blob('h1,h2'),
blob('h1,h2\nc,d\n'),
]);
await expect(out.text()).resolves.toBe('h1,h2\na,b\nc,d\n');
});
it('returns an empty blob for no parts', async () => {
const out = await stitchCsvParts([]);
expect(out.size).toBe(0);
});
});
describe('stitchJsonlParts', () => {
it('concatenates parts without stripping anything', async () => {
const out = await stitchJsonlParts([
blob('{"a":1}\n{"a":2}\n'),
blob('{"a":3}\n'),
]);
await expect(out.text()).resolves.toBe('{"a":1}\n{"a":2}\n{"a":3}\n');
expect(out.type).toBe('application/x-ndjson');
});
it('guards the seam when a part lacks a trailing newline', async () => {
const out = await stitchJsonlParts([blob('{"a":1}'), blob('{"a":2}')]);
const text = await out.text();
expect(text).toBe('{"a":1}\n{"a":2}\n');
// Every line must stay independently parseable.
const lines = text.trim().split('\n');
expect(lines.map((line) => JSON.parse(line))).toStrictEqual([
{ a: 1 },
{ a: 2 },
]);
});
it('skips empty parts', async () => {
const out = await stitchJsonlParts([blob(''), blob('{"a":1}\n'), blob('')]);
await expect(out.text()).resolves.toBe('{"a":1}\n');
});
});

View File

@@ -1,6 +1,6 @@
/** Triggers a browser download of in-memory string content as a file. */
/** Triggers a browser download of in-memory content (string or Blob) as a file. */
export function downloadFile(
content: string,
content: string | Blob,
fileName: string,
mime: string,
): void {

View File

@@ -0,0 +1,85 @@
/**
* Stitches sequentially-fetched export parts (Blobs) into one downloadable
* Blob. Pure blob surgery — parts are never parsed and `Blob.slice`/`new Blob`
* are zero-copy, so nothing here scales with the data size except the final
* browser-managed blob itself.
*/
const HEADER_SNIFF_BYTES = 8192;
const NEWLINE_BYTE = 0x0a;
/** Byte offset just past the first newline, or -1 when the blob has none. */
async function findFirstNewlineEnd(blob: Blob): Promise<number> {
let sniffBytes = HEADER_SNIFF_BYTES;
for (;;) {
// eslint-disable-next-line no-await-in-loop
const bytes = new Uint8Array(await blob.slice(0, sniffBytes).arrayBuffer());
const newlineIdx = bytes.indexOf(NEWLINE_BYTE);
if (newlineIdx !== -1) {
return newlineIdx + 1;
}
if (sniffBytes >= blob.size) {
return -1;
}
sniffBytes *= 2;
}
}
async function endsWithNewline(blob: Blob): Promise<boolean> {
const lastByte = new Uint8Array(await blob.slice(blob.size - 1).arrayBuffer());
return lastByte[0] === NEWLINE_BYTE;
}
async function stitchParts(
parts: Blob[],
{
stripHeaderAfterFirst,
mime,
}: { stripHeaderAfterFirst: boolean; mime: string },
): Promise<Blob> {
const pieces: (Blob | string)[] = [];
for (let index = 0; index < parts.length; index += 1) {
let piece = parts[index];
if (stripHeaderAfterFirst && index > 0) {
// Every part re-sends the header row; drop it on parts 2..N. Byte-level
// search (not text) so multi-byte characters can't skew the offset.
// ASSUMPTION: the first newline byte ends the header row — i.e. no
// column name contains an embedded (RFC 4180 quoted) newline. Header
// cells are telemetry field names, which can't carry newlines; quoted
// newlines in DATA rows are fine (we only search from byte 0).
// eslint-disable-next-line no-await-in-loop
const headerEnd = await findFirstNewlineEnd(piece);
// A part without any newline is header-only — nothing to keep.
piece = headerEnd === -1 ? piece.slice(piece.size) : piece.slice(headerEnd);
}
if (piece.size === 0) {
continue;
}
pieces.push(piece);
// Guard the seam: without a trailing newline the next part's first row
// would concatenate onto this part's last row.
// eslint-disable-next-line no-await-in-loop
if (!(await endsWithNewline(piece))) {
pieces.push('\n');
}
}
return new Blob(pieces, { type: mime });
}
/** Combines CSV parts: part 1 keeps its header, parts 2..N are decapitated. */
export async function stitchCsvParts(parts: Blob[]): Promise<Blob> {
return stitchParts(parts, { stripHeaderAfterFirst: true, mime: 'text/csv' });
}
/** Combines JSONL parts: plain concatenation with a newline guard per seam. */
export async function stitchJsonlParts(parts: Blob[]): Promise<Blob> {
return stitchParts(parts, {
stripHeaderAfterFirst: false,
mime: 'application/x-ndjson',
});
}

View File

@@ -1,11 +1,8 @@
import {
AuthtypesGettableRoleDTO,
AuthtypesRoleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
const orgId = '019ba2bb-2fa1-7b24-8159-cfca08617ef9';
export const managedRoles: AuthtypesGettableRoleDTO[] = [
export const managedRoles: AuthtypesRoleDTO[] = [
{
id: '019c24aa-2248-756f-9833-984f1ab63819',
createdAt: '2026-02-03T18:00:55.624356Z',
@@ -38,7 +35,7 @@ export const managedRoles: AuthtypesGettableRoleDTO[] = [
},
];
export const customRoles: AuthtypesGettableRoleDTO[] = [
export const customRoles: AuthtypesRoleDTO[] = [
{
id: '019c24aa-3333-0001-aaaa-111111111111',
createdAt: '2026-02-10T10:30:00.000Z',
@@ -59,24 +56,12 @@ export const customRoles: AuthtypesGettableRoleDTO[] = [
},
];
export const allRoles: AuthtypesGettableRoleDTO[] = [
...managedRoles,
...customRoles,
];
export const allRoles: AuthtypesRoleDTO[] = [...managedRoles, ...customRoles];
export const listRolesSuccessResponse = {
status: 'success',
data: allRoles,
};
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 };
export const customRoleResponse = { status: 'success', data: customRoles[0] };
export const managedRoleResponse = { status: 'success', data: managedRoles[0] };

View File

@@ -24,12 +24,6 @@
}
.headerZone {
// Sticks to the top of the scroll area so the heading + filters stay put while
// the results scroll beneath. Its own stacking context sits above the rows; the
// DSL suggestion popup (z-index 1000) still layers above everything.
position: sticky;
top: 0;
z-index: 5;
display: flex;
flex-direction: column;
gap: 14px;
@@ -56,6 +50,7 @@
:global(.ant-table-row:last-child)
:global(.ant-table-cell)
> div {
border-radius: 0 0 6px 6px;
}
:global(.ant-pagination-item) {

View File

@@ -10,10 +10,7 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useAppContext } from 'providers/App/App';
import { toAPIError } from 'utils/errorUtils';
import {
type TagPair,
useAccumulatedTags,
} from '../../hooks/useAccumulatedTags';
import { useAccumulatedTags } from '../../hooks/useAccumulatedTags';
import { useActiveView } from '../../hooks/useActiveView';
import { useCreatorOptions } from '../../hooks/useCreatorOptions';
import { useDashboardFilters } from '../../hooks/useDashboardFilters';
@@ -25,7 +22,7 @@ import {
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
import { useDashboardsListVisibleColumnsStore } from '../../store/useVisibleColumnsStore';
import { BuiltinViewId } from '../../types';
import type { SuggestionSource } from '../../utils/dslSuggestions';
import type { SelectedTag, UpdatedWindow } from '../../types';
import type { DashboardListItem } from '../../utils/helpers';
import { applyClientView } from '../../utils/views';
import FilterZone from '../FilterZone/FilterZone';
@@ -53,7 +50,17 @@ function DashboardsList(): JSX.Element {
);
const canEdit = !!editDashboard;
const { query, isEmpty: filtersEmpty, setQuery } = useDashboardFilters();
const {
filters,
query,
isEmpty: filtersEmpty,
setSearch,
setCreatedBy,
setUpdated,
setTags,
applyFilters,
clearAll,
} = useDashboardFilters();
const [sortColumn, setSortColumn] = useSortColumn();
const [sortOrder, setSortOrder] = useSortOrder();
const [page, setPage] = usePage();
@@ -73,8 +80,8 @@ function DashboardsList(): JSX.Element {
removeView,
renameView,
} = useActiveView({
query,
setQuery,
filters,
applyFilters,
userEmail: user.email,
sortColumn,
sortOrder,
@@ -88,13 +95,38 @@ function DashboardsList(): JSX.Element {
// Any filter change resets to the first page so the user isn't stranded on a
// now-out-of-range offset.
const handleQueryChange = useCallback(
const handleSearchChange = useCallback(
(value: string): void => {
setQuery(value);
setSearch(value);
void setPage(1);
},
[setQuery, setPage],
[setSearch, setPage],
);
const handleCreatedByChange = useCallback(
(emails: string[]): void => {
setCreatedBy(emails);
void setPage(1);
},
[setCreatedBy, setPage],
);
const handleUpdatedChange = useCallback(
(window: UpdatedWindow): void => {
setUpdated(window);
void setPage(1);
},
[setUpdated, setPage],
);
const handleTagsChange = useCallback(
(tags: SelectedTag[]): void => {
setTags(tags);
void setPage(1);
},
[setTags, setPage],
);
const handleClearAll = useCallback((): void => {
clearAll();
void setPage(1);
}, [clearAll, setPage]);
// View actions that change the result set reset pagination too.
const handleSelectView = useCallback(
@@ -163,16 +195,6 @@ function DashboardsList(): JSX.Element {
);
const total = clientView ? dashboards.length : (response?.data?.total ?? 0);
// Step back a page when a delete empties the current one, instead of showing nothing.
useEffect(() => {
if (clientView || isFetching) {
return;
}
if (page > 1 && dashboards.length === 0) {
void setPage(page - 1);
}
}, [clientView, isFetching, dashboards.length, page, setPage]);
// Authors present on the loaded page — a fallback for the creator filter until
// the org-wide user list resolves.
const pageAuthorEmails = useMemo<string[]>(
@@ -188,34 +210,15 @@ function DashboardsList(): JSX.Element {
});
// All key:value tags the API reports for the org's dashboards, powering the
// DSL key/value autocomplete. Accumulated across refetches so previously-seen
// tags stay suggestable even when a filtered page omits them.
const responseTags = useMemo<TagPair[]>(
// Tags filter chip and DSL key suggestions. Accumulated across refetches so
// previously-seen tags stay selectable even when a filtered page omits them.
const responseTags = useMemo<SelectedTag[]>(
() =>
(response?.data?.tags ?? []).map((t) => ({ key: t.key, value: t.value })),
[response],
);
const availableTags = useAccumulatedTags(responseTags);
// Autocomplete data source: reserved keys from the response, tag keys/values
// accumulated across pages, and creator emails for `created_by` values.
const source = useMemo<SuggestionSource>(() => {
const tagValuesByKey: Record<string, string[]> = {};
const tagKeys = new Set<string>();
availableTags.forEach((t) => {
tagKeys.add(t.key);
const lower = t.key.toLowerCase();
(tagValuesByKey[lower] ??= []).push(t.value);
});
return {
reservedKeys: response?.data?.reservedKeywords,
tagKeys: Array.from(tagKeys),
tagValuesByKey,
creatorEmails: creatorOptions.map((o) => o.email),
currentUserEmail: user.email,
};
}, [availableTags, creatorOptions, response, user.email]);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const visibleColumns = useDashboardsListVisibleColumnsStore(
(s) => s.visibleColumns,
@@ -303,10 +306,18 @@ function DashboardsList(): JSX.Element {
onCreate={openCreate}
/>
<FilterZone
query={query}
search={filters.search}
createdBy={filters.createdBy}
updated={filters.updated}
tags={filters.tags}
availableTags={availableTags}
creatorOptions={creatorOptions}
source={source}
onQueryChange={handleQueryChange}
isEmpty={filtersEmpty}
onSearchChange={handleSearchChange}
onCreatedByChange={handleCreatedByChange}
onUpdatedChange={handleUpdatedChange}
onTagsChange={handleTagsChange}
onClearAll={handleClearAll}
/>
</div>
<div className={styles.viewContent}>
@@ -321,7 +332,7 @@ function DashboardsList(): JSX.Element {
errorMessage={errorMessage}
dashboards={dashboards}
activeViewId={activeViewId}
searchValue={query}
searchValue={filters.search}
hasFilters={!filtersEmpty}
sortColumn={sortColumn}
onSortChange={onSortChange}

View File

@@ -1,4 +1,7 @@
import { Checkbox, Select } from 'antd';
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { CalendarClock, ChevronDown, User } from '@signozhq/icons';
import cx from 'classnames';
import type { UpdatedWindow } from '../../types';
@@ -18,10 +21,6 @@ const UPDATED_LABELS: Record<UpdatedWindow, string> = {
};
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
const UPDATED_OPTIONS = UPDATED_WINDOWS.map((w) => ({
value: w,
label: UPDATED_LABELS[w],
}));
interface Props {
createdBy: string[];
@@ -29,10 +28,6 @@ interface Props {
creatorOptions: CreatorOption[];
onCreatedByChange: (emails: string[]) => void;
onUpdatedChange: (window: UpdatedWindow) => void;
// Run the staged draft — fired when a dropdown closes.
onApply: () => void;
// Clear all Created-by selections and run immediately.
onClearCreatedBy: () => void;
}
function FilterChips({
@@ -41,57 +36,92 @@ function FilterChips({
creatorOptions,
onCreatedByChange,
onUpdatedChange,
onApply,
onClearCreatedBy,
}: Props): JSX.Element {
const creatorOptionsData = creatorOptions.map((o) => ({
value: o.email,
label: o.label,
}));
const runOnClose = (open: boolean): void => {
if (!open) {
onApply();
const createdByLabel = useMemo((): string => {
if (createdBy.length === 0) {
return 'Anyone';
}
};
if (createdBy.length === 1) {
const match = creatorOptions.find((o) => o.email === createdBy[0]);
return match?.label ?? createdBy[0];
}
return `${createdBy.length} people`;
}, [createdBy, creatorOptions]);
const createdByItems = useMemo<MenuItem[]>(() => {
const items: MenuItem[] = creatorOptions.map((option) => ({
type: 'checkbox',
key: option.email,
label: option.label,
checked: createdBy.includes(option.email),
onCheckedChange: (checked: boolean): void =>
onCreatedByChange(
checked
? [...createdBy, option.email]
: createdBy.filter((e) => e !== option.email),
),
}));
if (createdBy.length > 0) {
items.push({ type: 'divider', key: 'sep' });
items.push({
key: 'clear',
label: 'Clear selection',
onClick: (): void => onCreatedByChange([]),
});
}
return items;
}, [creatorOptions, createdBy, onCreatedByChange]);
const updatedItems = useMemo<MenuItem[]>(
() => [
{
type: 'radio-group',
value: updated,
onChange: (value: string): void => onUpdatedChange(value as UpdatedWindow),
children: UPDATED_WINDOWS.map((window) => ({
type: 'radio',
key: window,
value: window,
label: UPDATED_LABELS[window],
})),
},
],
[updated, onUpdatedChange],
);
return (
<div className={styles.chips}>
<Select
mode="multiple"
showSearch
allowClear
className={cx(styles.select, styles.selectWide)}
placeholder="Created by"
value={createdBy}
options={creatorOptionsData}
optionFilterProp="label"
maxTagCount={1}
menuItemSelectedIcon={null}
data-testid="dashboards-filter-created-by"
onClear={onClearCreatedBy}
optionRender={(option): JSX.Element => (
<div className={styles.creatorOption}>
<Checkbox
checked={createdBy.includes(option.value as string)}
className={styles.creatorCheck}
/>
<span className={styles.creatorLabel}>{option.label}</span>
</div>
)}
onChange={(value): void => onCreatedByChange(value as string[])}
onDropdownVisibleChange={runOnClose}
/>
<Select
showSearch
className={cx(styles.select, styles.selectNarrow)}
placeholder="Updated"
value={updated}
options={UPDATED_OPTIONS}
optionFilterProp="label"
data-testid="dashboards-filter-updated"
onChange={(value): void => onUpdatedChange(value as UpdatedWindow)}
/>
<DropdownMenuSimple menu={{ items: createdByItems }} align="start">
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<User size={12} />}
suffix={<ChevronDown size={12} />}
className={cx(styles.chip, {
[styles.chipActive]: createdBy.length > 0,
})}
testId="dashboards-filter-created-by"
>
Created by: {createdByLabel}
</Button>
</DropdownMenuSimple>
<DropdownMenuSimple menu={{ items: updatedItems }} align="start">
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<CalendarClock size={12} />}
suffix={<ChevronDown size={12} />}
className={cx(styles.chip, {
[styles.chipActive]: updated !== 'any',
})}
testId="dashboards-filter-updated"
>
Updated: {UPDATED_LABELS[updated]}
</Button>
</DropdownMenuSimple>
</div>
);
}

View File

@@ -37,62 +37,14 @@
display: flex;
align-items: center;
gap: 8px;
// Keep the two dropdowns side by side; each is fixed-width so pills can't
// grow the trigger and wrap them onto separate lines.
flex-wrap: nowrap;
flex-wrap: wrap;
}
.select {
flex: none;
// Match the search bar: near-black background, --l2-border border.
:global(.ant-select-selector) {
background-color: var(--l1-background) !important;
border-color: var(--l2-border) !important;
}
.chip {
font-size: var(--font-size-sm);
}
// The single-select (Updated) is compact; the multi-select (Created-by) is wider
// to fit a truncated pill + "+N" + clear.
.selectNarrow {
width: 140px;
}
.selectWide {
width: 240px;
// Keep the pills on one line and let the first pill shrink + ellipsis to fill
// the available width (antd sets `flex: none` on tags, so allow the pill to
// shrink; keep the "+N" count and the search area intact).
:global(.ant-select-selection-overflow) {
flex-wrap: nowrap;
}
:global(.ant-select-selection-overflow-item) {
flex-shrink: 1;
min-width: 0;
}
:global(.ant-select-selection-overflow-item-rest),
:global(.ant-select-selection-overflow-item-suffix) {
flex-shrink: 0;
}
}
// Created-by option: a presentational checkbox + label (antd handles the toggle).
.creatorOption {
display: flex;
align-items: center;
gap: 8px;
}
.creatorCheck {
flex-shrink: 0;
pointer-events: none;
}
.creatorLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.chipActive {
border-color: var(--primary-background) !important;
color: var(--l1-foreground) !important;
}

View File

@@ -9,108 +9,79 @@ import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import type { SuggestionSource } from '../../utils/dslSuggestions';
import {
createdByClause,
parseReflectedClauses,
spliceClause,
updatedClause,
} from '../../utils/filterQuery';
import type { UpdatedWindow } from '../../types';
import { buildSuggestionKeys } from '../../utils/dslSuggestions';
import type { SelectedTag, UpdatedWindow } from '../../types';
import SearchBar from '../SearchBar/SearchBar';
import FilterChips, { type CreatorOption } from './FilterChips';
import TagsFilterChip from './TagsFilterChip';
import styles from './FilterZone.module.scss';
interface Props {
// The last-run query (source of truth for fetching + the dirty baseline).
query: string;
search: string;
createdBy: string[];
updated: UpdatedWindow;
tags: SelectedTag[];
availableTags: SelectedTag[];
creatorOptions: CreatorOption[];
source: SuggestionSource;
// Commit (run) the draft — the only path that triggers a fetch.
onQueryChange: (value: string) => void;
isEmpty: boolean;
onSearchChange: (value: string) => void;
onCreatedByChange: (emails: string[]) => void;
onUpdatedChange: (window: UpdatedWindow) => void;
onTagsChange: (tags: SelectedTag[]) => void;
onClearAll: () => void;
// Rendered at the end of the search row (e.g. the New Dashboard action).
rightSlot?: ReactNode;
}
// The filter command zone. The query box is a DRAFT: typing and the Created-by /
// Updated dropdowns all edit the draft (dropdowns splice their clause in); nothing
// fetches until the draft is run (Cmd/Ctrl+Enter or the Run button). A dirty dot
// signals unrun changes. The draft re-syncs when the last-run query changes
// externally (view select, back/forward).
// The filter command zone: name search + structured chips (created-by, updated)
// + clear-all. Search is committed on submit/blur (matching the prior bar);
// chips apply immediately.
function FilterZone({
query,
search,
createdBy,
updated,
tags,
availableTags,
creatorOptions,
source,
onQueryChange,
isEmpty,
onSearchChange,
onCreatedByChange,
onUpdatedChange,
onTagsChange,
onClearAll,
rightSlot,
}: Props): JSX.Element {
const [draft, setDraft] = useState(query);
const [searchInput, setSearchInput] = useState(search);
useEffect(() => {
setDraft(query);
}, [query]);
const reflected = useMemo(() => parseReflectedClauses(draft), [draft]);
const dirty = draft.trim() !== query.trim();
const isEmpty = !draft.trim();
const run = useCallback((): void => {
const next = draft.trim();
if (next !== query) {
onQueryChange(next);
}
}, [draft, query, onQueryChange]);
// Created-by (multi-select) only STAGES its clause into the draft; the query runs
// when the dropdown closes (FilterChips fires onApply), not on each pick.
const handleCreatedByChange = useCallback((emails: string[]): void => {
setDraft((d) => spliceClause(d, 'created_by', createdByClause(emails)));
}, []);
// Updated (single-select) is a definitive single choice, so it splices AND runs
// immediately (with the fresh value, avoiding the async draft-state lag).
const handleUpdatedChange = useCallback(
(window: UpdatedWindow): void => {
const next = spliceClause(draft, 'updated_at', updatedClause(window));
setDraft(next);
const trimmed = next.trim();
if (trimmed !== query) {
onQueryChange(trimmed);
}
},
[draft, query, onQueryChange],
const suggestionKeys = useMemo(
() => buildSuggestionKeys(availableTags),
[availableTags],
);
// Clear-all on the Created-by select removes its clause and runs immediately.
const handleClearCreatedBy = useCallback((): void => {
const next = spliceClause(draft, 'created_by', null);
setDraft(next);
const trimmed = next.trim();
if (trimmed !== query) {
onQueryChange(trimmed);
}
}, [draft, query, onQueryChange]);
// Keep the local input in sync with external search changes (applying a view,
// clear-all, back/forward). User typing only mutates the local copy.
useEffect(() => {
setSearchInput(search);
}, [search]);
// Clear resets the draft and runs immediately (empty query).
const handleClear = useCallback((): void => {
setDraft('');
if (query !== '') {
onQueryChange('');
const handleSubmit = useCallback((): void => {
const next = searchInput.trim();
if (next !== search) {
onSearchChange(next);
}
}, [query, onQueryChange]);
}, [searchInput, search, onSearchChange]);
return (
<div className={styles.filterZone}>
<div className={styles.searchRow}>
<div className={styles.searchInput}>
<SearchBar
value={draft}
placeholder="DSL Filter — e.g. name CONTAINS 'api' AND env IN ['prod','staging']"
source={source}
dirty={dirty}
onChange={setDraft}
onSubmit={run}
value={searchInput}
placeholder={`Search with DSL — e.g. name contains "prod" AND env = "staging"`}
suggestionKeys={suggestionKeys}
onChange={setSearchInput}
onSubmit={handleSubmit}
/>
</div>
{rightSlot}
@@ -118,13 +89,16 @@ function FilterZone({
<div className={styles.filtersRow}>
<Typography.Text className={styles.filtersLabel}>Filters</Typography.Text>
<FilterChips
createdBy={reflected.createdBy}
updated={reflected.updated}
createdBy={createdBy}
updated={updated}
creatorOptions={creatorOptions}
onCreatedByChange={handleCreatedByChange}
onUpdatedChange={handleUpdatedChange}
onApply={run}
onClearCreatedBy={handleClearCreatedBy}
onCreatedByChange={onCreatedByChange}
onUpdatedChange={onUpdatedChange}
/>
<TagsFilterChip
availableTags={availableTags}
tags={tags}
onTagsChange={onTagsChange}
/>
{!isEmpty && (
<Button
@@ -132,7 +106,7 @@ function FilterZone({
color="primary"
size="sm"
prefix={<X size={12} />}
onClick={handleClear}
onClick={onClearAll}
testId="dashboards-filter-clear"
>
Clear

View File

@@ -0,0 +1,80 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { ChevronDown, Tag } from '@signozhq/icons';
import cx from 'classnames';
import type { SelectedTag } from '../../types';
import styles from './FilterZone.module.scss';
interface Props {
// All key:value tags the list API reports across the org's dashboards.
availableTags: SelectedTag[];
tags: SelectedTag[];
onTagsChange: (tags: SelectedTag[]) => void;
}
const tagId = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
function TagsFilterChip({
availableTags,
tags,
onTagsChange,
}: Props): JSX.Element {
const selectedIds = useMemo(() => new Set(tags.map(tagId)), [tags]);
const label = useMemo((): string => {
if (tags.length === 0) {
return 'Any';
}
if (tags.length === 1) {
return tagId(tags[0]);
}
return `${tags.length} tags`;
}, [tags]);
const items = useMemo<MenuItem[]>(() => {
const options: MenuItem[] = availableTags.map((tag) => {
const id = tagId(tag);
return {
type: 'checkbox',
key: id,
label: id,
checked: selectedIds.has(id),
onCheckedChange: (checked: boolean): void =>
onTagsChange(
checked ? [...tags, tag] : tags.filter((t) => tagId(t) !== id),
),
};
});
if (tags.length > 0) {
options.push({ type: 'divider', key: 'sep' });
options.push({
key: 'clear',
label: 'Clear selection',
onClick: (): void => onTagsChange([]),
});
}
return options;
}, [availableTags, selectedIds, tags, onTagsChange]);
return (
<DropdownMenuSimple menu={{ items }} align="start">
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<Tag size={12} />}
suffix={<ChevronDown size={12} />}
className={cx(styles.chip, { [styles.chipActive]: tags.length > 0 })}
disabled={availableTags.length === 0}
testId="dashboards-filter-tags"
>
Tags: {label}
</Button>
</DropdownMenuSimple>
);
}
export default TagsFilterChip;

View File

@@ -5,7 +5,8 @@
padding: 16px;
height: 44px;
flex-shrink: 0;
border-bottom: 1px solid var(--l2-border);
border-radius: 6px 6px 0px 0px;
border: 1px solid var(--l2-border);
background: var(--l1-background);
}

View File

@@ -1,203 +1,70 @@
.wrapper {
position: relative;
width: 100%;
// --- CodeMirror internals, scoped to this component ---
:global(.cm-editor) {
background: transparent;
// The completion popup is an absolute child; keep it from being clipped.
overflow: visible;
}
// The query wraps (EditorView.lineWrapping), so no horizontal scrolling.
:global(.cm-scroller) {
overflow-x: hidden;
}
// DSL token colours.
:global(.cm-dsl-key) {
color: var(--bg-robin-400);
}
:global(.cm-dsl-op) {
color: var(--bg-vanilla-400);
}
:global(.cm-dsl-logic) {
color: var(--bg-sakura-400);
font-weight: var(--font-weight-medium);
}
:global(.cm-dsl-string) {
color: var(--bg-forest-400);
}
:global(.cm-dsl-value) {
color: var(--bg-amber-400);
}
// Completion popup — pinned to the input box (absolute, left:0/right:0) like the
// query builder's where-clause editor, not caret-anchored. A "SUGGESTIONS"
// header, a scrolling option list, and a keyboard-hint footer.
:global(.cm-tooltip.cm-tooltip-autocomplete) {
position: absolute !important;
top: calc(100% + 6px) !important;
left: 0 !important;
width: 100% !important;
z-index: 1000;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l1-background);
/* stylelint-disable-next-line local/prefer-css-variables -- dropdown shadow */
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
&::before {
content: 'SUGGESTIONS';
padding: 12px 14px 8px;
font-size: 11px;
font-weight: var(--font-weight-medium);
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
color: var(--muted-foreground);
}
&::after {
content: '↑↓ to navigate · ↵ to apply · esc to dismiss';
padding: 10px 14px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
font-size: var(--font-size-xs);
color: var(--l2-foreground);
}
& > ul {
flex: 1 1 auto;
// min-height:0 lets the list scroll inside the flex column instead of
// overflowing past the footer; width 100% so the scrollbar sits at the
// popup's right edge (CM otherwise caps the list width, leaving a gap).
min-height: 0;
width: 100% !important;
max-width: 100% !important;
max-height: 46vh;
margin: 0;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
border-radius: 10px;
background: var(--l3-background);
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
& > ul > li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 14px;
color: var(--l2-foreground);
line-height: 20px;
&[aria-selected='true'] {
background: var(--l2-background);
color: var(--l1-foreground);
}
}
}
:global(.cm-tooltip-autocomplete .cm-completionLabel) {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Right-aligned "field" / "tag" hint, as a pill (matches the QB type tags).
:global(.cm-tooltip-autocomplete .cm-completionDetail) {
flex-shrink: 0;
padding: 0 8px;
border-radius: 50px;
background: color-mix(in srgb, var(--l1-foreground) 10%, transparent);
font-style: normal;
font-size: var(--font-size-xs);
color: var(--l2-foreground);
text-transform: capitalize;
}
:global(.cm-completionIcon) {
display: none;
}
}
// Input-like shell. The lead icon and Run button overlay the editor (absolute) so
// the editor — and its full-width completion popup — spans the whole box.
.field {
position: relative;
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l1-background);
&:focus-within {
border-color: var(--bg-robin-500);
}
.input::placeholder {
color: var(--l3-foreground);
}
.leadIcon {
// Flatten the input's bottom corners while the dropdown is attached below it.
.inputOpen {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.suggestions {
position: absolute;
top: 11px;
left: 10px;
z-index: 1;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 2px;
max-height: 260px;
overflow-y: auto;
padding: 4px;
border: 1px solid var(--l2-border);
border-top: none;
border-radius: 0 0 6px 6px;
background: var(--l1-background);
/* stylelint-disable-next-line local/prefer-css-variables -- matches the V2 dashboard dropdown shadow */
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
}
.editor {
width: 100%;
.suggestion {
display: flex;
align-items: center;
padding: 8px 10px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--l2-foreground);
font-family: 'Space Mono', monospace;
font-size: var(--font-size-sm);
text-align: left;
cursor: pointer;
transition: background 0.1s;
}
.suggestionActive {
background: var(--l2-background);
color: var(--l1-foreground);
}
.submit {
position: absolute;
top: 5px;
right: 6px;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 6px;
// Bordered light pill so "Run query ⌘↵" reads as an actionable button, not stray
// text.
padding: 3px 8px;
border-radius: 6px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
color: var(--bg-vanilla-400);
}
// Unrun-changes indicator next to "Run query".
.dirtyDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bg-robin-500);
}
// Keycap-styled shortcut so ⌘↵ reads as "press this", not decoration.
.cmdHint {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 5px;
gap: 2px;
padding: 2px 4px;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
color: var(--bg-vanilla-400);
}

View File

@@ -1,24 +1,22 @@
import { type MouseEvent, useMemo, useRef } from 'react';
import {
autocompletion,
closeCompletion,
completionKeymap,
startCompletion,
} from '@codemirror/autocomplete';
ChangeEvent,
KeyboardEvent,
MouseEvent,
useMemo,
useState,
} from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Color } from '@signozhq/design-tokens';
import { ChevronUp, Command, CornerDownLeft, Search } from '@signozhq/icons';
import CodeMirror, {
EditorView,
keymap,
Prec,
type ReactCodeMirrorRef,
} from '@uiw/react-codemirror';
import cx from 'classnames';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import type { SuggestionSource } from '../../utils/dslSuggestions';
import { dslCompletionSource } from './dslCompletions';
import { dslHighlight } from './dslHighlight';
import {
applyKeySuggestion,
getActiveKeyToken,
matchKeys,
} from '../../utils/dslSuggestions';
import styles from './SearchBar.module.scss';
@@ -27,160 +25,139 @@ interface Props {
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
source?: SuggestionSource;
// The draft differs from the last-run query — shows a "run to apply" hint.
dirty?: boolean;
// Keys offered as you type (reserved DSL columns + tag keys from the API).
suggestionKeys?: string[];
}
const EMPTY_SOURCE: SuggestionSource = {
tagKeys: [],
tagValuesByKey: {},
creatorEmails: [],
};
// Token colours come from `.cm-dsl-*` classes (SearchBar.module.scss); this only
// handles layout — transparent background, monospace, single line.
const editorTheme = EditorView.theme({
'&': { fontSize: '13px', backgroundColor: 'transparent' },
'&.cm-focused': { outline: 'none' },
'.cm-content': {
// Leave room for the overlaid lead icon (left) and Run button (right).
padding: '8px 120px 8px 30px',
fontFamily: "'Space Mono', monospace",
color: 'var(--l1-foreground)',
caretColor: 'var(--l1-foreground)',
},
'.cm-line': { padding: '0' },
'.cm-scroller': { fontFamily: 'inherit', lineHeight: '1.5' },
'.cm-placeholder': { color: 'var(--l3-foreground)' },
});
const BASIC_SETUP = {
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightActiveLineGutter: false,
highlightSelectionMatches: false,
bracketMatching: false,
closeBrackets: false,
autocompletion: false,
// Use the browser's native caret so it blinks (a CM-drawn cursor needs extra
// styling and doesn't blink by default).
drawSelection: false,
} as const;
function SearchBar({
value,
onChange,
onSubmit,
placeholder = "Filter with DSL (e.g. name CONTAINS 'foo')",
source = EMPTY_SOURCE,
dirty = false,
placeholder = "Search with DSL (e.g. name CONTAINS 'foo')",
suggestionKeys = [],
}: Props): JSX.Element {
const [focused, setFocused] = useState(false);
// -1 means nothing is highlighted, so Enter submits the typed query rather
// than picking a suggestion (arrow keys engage selection).
const [highlighted, setHighlighted] = useState(-1);
const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS;
const editorRef = useRef<ReactCodeMirrorRef>(null);
// Refs so the (memoised, stable) extensions always see the latest values.
const sourceRef = useRef(source);
sourceRef.current = source;
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
const extensions = useMemo(
() => [
dslHighlight,
editorTheme,
// Wrap a long query onto the next line instead of scrolling horizontally.
EditorView.lineWrapping,
autocompletion({
override: [dslCompletionSource(() => sourceRef.current)],
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 100,
icons: false,
}),
// Open the suggestions on focus/click (not only while typing), so a click
// into the box immediately shows the key list.
EditorView.domEventHandlers({
focus: (_event, view): boolean => {
startCompletion(view);
return false;
},
click: (_event, view): boolean => {
startCompletion(view);
return false;
},
}),
Prec.highest(
keymap.of([
...completionKeymap,
{ key: 'Escape', run: closeCompletion },
// Enter accepts an open suggestion (via completionKeymap above); with
// none open it is a no-op — it never runs the query or inserts a newline.
{ key: 'Enter', preventDefault: true, run: (): boolean => true },
{ key: 'Shift-Enter', preventDefault: true, run: (): boolean => true },
{
key: 'Mod-Enter',
preventDefault: true,
run: (): boolean => {
onSubmitRef.current();
return true;
},
},
]),
),
],
[],
const active = useMemo(() => getActiveKeyToken(value), [value]);
const suggestions = useMemo(
() => (active ? matchKeys(suggestionKeys, active.token) : []),
[active, suggestionKeys],
);
const showSuggestions = focused && suggestions.length > 0;
const pickSuggestion = (key: string): void => {
if (active) {
onChange(applyKeySuggestion(value, active, key));
}
setHighlighted(-1);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
onSubmit();
return;
}
if (showSuggestions && e.key === 'ArrowDown') {
e.preventDefault();
setHighlighted((h) => Math.min(h + 1, suggestions.length - 1));
return;
}
if (showSuggestions && e.key === 'ArrowUp') {
e.preventDefault();
setHighlighted((h) => Math.max(h - 1, 0));
return;
}
if (e.key === 'Enter') {
if (showSuggestions && highlighted >= 0) {
e.preventDefault();
pickSuggestion(suggestions[highlighted]);
} else {
onSubmit();
}
return;
}
if (e.key === 'Escape') {
setFocused(false);
setHighlighted(-1);
}
};
return (
<div className={styles.wrapper}>
<div className={styles.field}>
<Search
size={12}
color={Color.BG_VANILLA_400}
className={styles.leadIcon}
/>
<CodeMirror
ref={editorRef}
className={styles.editor}
value={value}
placeholder={placeholder}
extensions={extensions}
basicSetup={BASIC_SETUP}
indentWithTab={false}
data-testid="dashboards-list-search"
onChange={(next): void => onChange(next.replace(/\n/g, ' '))}
/>
<Button
variant="ghost"
color="secondary"
size="sm"
className={styles.submit}
aria-label="Run search"
testId="dashboards-list-search-submit"
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
e.preventDefault();
}}
onClick={onSubmit}
<Input
className={cx(styles.input, { [styles.inputOpen]: showSuggestions })}
placeholder={placeholder}
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
suffix={
<Button
variant="ghost"
color="secondary"
size="sm"
className={styles.submit}
aria-label="Run search"
testId="dashboards-list-search-submit"
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
// Prevent the input's blur from firing first and double-submitting.
e.preventDefault();
}}
onClick={onSubmit}
>
Run query
<span className={styles.cmdHint}>
{isMac ? (
<Command size={12} color={Color.BG_VANILLA_400} />
) : (
<ChevronUp size={12} color={Color.BG_VANILLA_400} />
)}
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
</span>
</Button>
}
value={value}
testId="dashboards-list-search"
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
onChange(e.target.value);
setHighlighted(-1);
}}
onFocus={(): void => setFocused(true)}
onBlur={(): void => {
setFocused(false);
setHighlighted(-1);
onSubmit();
}}
onKeyDown={handleKeyDown}
/>
{showSuggestions && (
<div
className={styles.suggestions}
data-testid="dashboards-list-search-suggestions"
>
{dirty && (
<span
className={styles.dirtyDot}
data-testid="dashboards-list-search-dirty"
/>
)}
Run query
<span className={styles.cmdHint}>
{isMac ? (
<Command size={12} color={Color.BG_VANILLA_400} />
) : (
<ChevronUp size={12} color={Color.BG_VANILLA_400} />
)}
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
</span>
</Button>
</div>
{suggestions.map((key, index) => (
<button
key={key}
type="button"
className={cx(styles.suggestion, {
[styles.suggestionActive]: index === highlighted,
})}
data-testid={`dashboards-list-search-suggestion-${key}`}
onMouseEnter={(): void => setHighlighted(index)}
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
// Keep focus on the input so blur doesn't submit before we update.
e.preventDefault();
}}
onClick={(): void => pickSuggestion(key)}
>
{key}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -1,50 +0,0 @@
// Bridges our stage-aware DSL suggestion engine into CodeMirror's autocompletion,
// so the query box gets keyboard navigation + a scrollable popup for free.
import {
type CompletionContext,
type CompletionResult,
startCompletion,
} from '@codemirror/autocomplete';
import type { EditorView } from '@codemirror/view';
import {
getSuggestions,
type SuggestionSource,
} from '../../utils/dslSuggestions';
// `getSource` is a getter so the freshest tags/users (which load async) are read
// on every keystroke.
export const dslCompletionSource =
(getSource: () => SuggestionSource) =>
(context: CompletionContext): CompletionResult | null => {
const query = context.state.doc.toString();
const { items, ctx } = getSuggestions(query, context.pos, getSource());
if (items.length === 0) {
return null;
}
return {
from: ctx.replaceStart,
to: ctx.replaceEnd,
// We already filtered/ranked in getSuggestions; keep CodeMirror from
// re-filtering by the typed text.
filter: false,
options: items.map((item) => ({
label: item.label,
type: item.kind,
detail: item.detail,
// Insert the raw text (not the display label) and re-open completion so
// key -> operator -> value chains without extra keystrokes.
apply: (view: EditorView, _c, from: number, to: number): void => {
view.dispatch({
changes: { from, to, insert: item.insertText },
// caretOffset lands the caret inside a `[...]` list (before its
// closing bracket) so multi-select can continue.
selection: {
anchor: from + item.insertText.length - (item.caretOffset ?? 0),
},
});
startCompletion(view);
},
})),
};
};

View File

@@ -1,96 +0,0 @@
// CodeMirror syntax highlighting for the dashboards-list DSL, driven by the same
// tokenizer used for autocomplete (dslTokenizer). Each key / operator / value /
// AND-OR span gets a `cm-dsl-*` class coloured in SearchBar.module.scss.
import { RangeSetBuilder } from '@codemirror/state';
import {
Decoration,
type DecorationSet,
type EditorView,
ViewPlugin,
type ViewUpdate,
} from '@codemirror/view';
import { scanTerm, splitTopLevelTerms } from '../../utils/dslTokenizer';
const KEY = Decoration.mark({ class: 'cm-dsl-key' });
const OP = Decoration.mark({ class: 'cm-dsl-op' });
const STRING = Decoration.mark({ class: 'cm-dsl-string' });
const VALUE = Decoration.mark({ class: 'cm-dsl-value' });
const LOGIC = Decoration.mark({ class: 'cm-dsl-logic' });
interface Marked {
from: number;
to: number;
deco: Decoration;
}
const collectRanges = (query: string): Marked[] => {
const ranges: Marked[] = [];
const terms = splitTopLevelTerms(query);
terms.forEach((term, i) => {
// The AND/OR keyword sits in the gap before this term.
if (i > 0 && term.precedingJoiner) {
const prevEnd = terms[i - 1].end;
const m = /\b(?:AND|OR)\b/i.exec(query.slice(prevEnd, term.start));
if (m) {
ranges.push({
from: prevEnd + m.index,
to: prevEnd + m.index + m[0].length,
deco: LOGIC,
});
}
}
const scan = scanTerm(term.text);
const base = term.start;
if (scan.key) {
ranges.push({
from: base + scan.key.start,
to: base + scan.key.end,
deco: KEY,
});
}
if (scan.operator) {
ranges.push({
from: base + scan.operator.start,
to: base + scan.operator.end,
deco: OP,
});
}
if (scan.value) {
const isString = /^['"]/.test(scan.value.text.trim());
ranges.push({
from: base + scan.value.start,
to: base + scan.value.end,
deco: isString ? STRING : VALUE,
});
}
});
return ranges
.filter((r) => r.to > r.from)
.sort((a, b) => a.from - b.from || a.to - b.to);
};
const buildDecorations = (view: EditorView): DecorationSet => {
const builder = new RangeSetBuilder<Decoration>();
collectRanges(view.state.doc.toString()).forEach((r) => {
builder.add(r.from, r.to, r.deco);
});
return builder.finish();
};
export const dslHighlight = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate): void {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
}
}
},
{ decorations: (plugin): DecorationSet => plugin.decorations },
);

View File

@@ -1,18 +1,14 @@
import { useEffect, useState } from 'react';
// A key:value tag pair the list API reports across the org's dashboards.
export interface TagPair {
key: string;
value: string;
}
import type { SelectedTag } from '../types';
const tagId = (tag: TagPair): string => `${tag.key}:${tag.value}`;
const tagId = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
// The list response only reports the tags present in the current (filtered) page,
// so tags vanish from the suggestions as results narrow. Accumulate every tag
// we've ever seen so previously-surfaced tags stay suggestable across refetches.
export function useAccumulatedTags(responseTags: TagPair[]): TagPair[] {
const [tags, setTags] = useState<TagPair[]>([]);
// so tags vanish from the filter options as results narrow. Accumulate every tag
// we've ever seen so previously-surfaced tags stay selectable across refetches.
export function useAccumulatedTags(responseTags: SelectedTag[]): SelectedTag[] {
const [tags, setTags] = useState<SelectedTag[]>([]);
useEffect(() => {
if (responseTags.length === 0) {

View File

@@ -1,17 +1,21 @@
import { useCallback, useMemo } from 'react';
import { type Options, parseAsString, useQueryState } from 'nuqs';
import { parseAsString, useQueryState, type Options } from 'nuqs';
import type {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import { areQueriesEqual } from '../utils/filterQuery';
import {
areFilterStatesEqual,
DEFAULT_FILTER_STATE,
filterStateToQuery,
} from '../utils/filterQuery';
import { BuiltinViewId } from '../types';
import type { SavedView } from '../types';
import type { DashboardFilterState, SavedView } from '../types';
import {
BUILTIN_VIEWS,
builtinViewSnapshot,
type BuiltinView,
builtinViewQuery,
isClientView,
} from '../utils/views';
import { useSavedViews } from './useSavedViews';
@@ -19,8 +23,8 @@ import { useSavedViews } from './useSavedViews';
const opts: Options = { history: 'push' };
interface UseActiveViewArgs {
query: string;
setQuery: (next: string) => void;
filters: DashboardFilterState;
applyFilters: (next: DashboardFilterState) => void;
userEmail: string;
sortColumn: DashboardtypesListSortDTO;
sortOrder: DashboardtypesListOrderDTO;
@@ -34,7 +38,7 @@ export interface UseActiveViewResult {
customViews: SavedView[];
customViewsLoading: boolean;
isCustomActive: boolean;
// The current query diverges from the active view's canonical query.
// Current filters diverge from the active view's canonical snapshot.
isModified: boolean;
// Whether the active view constrains the list client-side (pinned/recent).
clientView: boolean;
@@ -46,13 +50,19 @@ export interface UseActiveViewResult {
renameView: (id: string, name: string) => void;
}
// The canonical filter snapshot a saved view "is": the backend stores a flat
// query, so a view folds entirely into the search box with empty chips.
const customSnapshot = (view: SavedView): DashboardFilterState => ({
...DEFAULT_FILTER_STATE,
search: view.query,
});
// Orchestrates the active view: which view is selected (URL `view` param),
// merging built-in + org-shared saved views, applying a view's query on select,
// dirty detection, and save/reset/delete via the Views API. A view now simply
// "is" a query string, so dirty detection is a trimmed string compare.
// merging built-in + org-shared saved views, applying a view's snapshot on
// select, dirty detection, and save/reset/delete via the Views API.
export function useActiveView({
query,
setQuery,
filters,
applyFilters,
userEmail,
sortColumn,
sortOrder,
@@ -77,34 +87,35 @@ export function useActiveView({
[customViews, activeViewId],
);
// The query the active view "is" — used to detect divergence.
const canonicalQuery = useMemo<string | null>(
// The filter state the active view "is" — used to detect divergence.
const canonicalSnapshot = useMemo<DashboardFilterState | null>(
() =>
activeCustom
? activeCustom.query
: builtinViewQuery(activeViewId, userEmail),
? customSnapshot(activeCustom)
: builtinViewSnapshot(activeViewId, userEmail),
[activeCustom, activeViewId, userEmail],
);
const isModified =
canonicalQuery !== null && !areQueriesEqual(query, canonicalQuery);
const isModified = canonicalSnapshot
? !areFilterStatesEqual(filters, canonicalSnapshot)
: false;
const selectView = useCallback(
(id: string): void => {
void setActiveViewId(id);
const custom = customViews.find((v) => v.id === id);
if (custom) {
setQuery(custom.query);
applyFilters(customSnapshot(custom));
setSortColumn(custom.sort);
setSortOrder(custom.order);
return;
}
setQuery(builtinViewQuery(id, userEmail) ?? '');
applyFilters(builtinViewSnapshot(id, userEmail) ?? DEFAULT_FILTER_STATE);
},
[
setActiveViewId,
customViews,
setQuery,
applyFilters,
userEmail,
setSortColumn,
setSortOrder,
@@ -113,6 +124,9 @@ export function useActiveView({
const saveView = useCallback(
(name: string): void => {
// The active view's clause already lives in the filter state (e.g. Locked
// seeds `locked = true` into search), so the chips fold into one query.
const query = filterStateToQuery(filters);
void (async (): Promise<void> => {
const created = await createView({
name,
@@ -122,44 +136,55 @@ export function useActiveView({
});
if (created) {
void setActiveViewId(created.id);
// Re-apply the folded representation so the new view isn't
// immediately flagged as modified.
applyFilters(customSnapshot(created));
}
})();
},
[query, createView, sortColumn, sortOrder, setActiveViewId],
[filters, createView, sortColumn, sortOrder, setActiveViewId, applyFilters],
);
const saveActiveView = useCallback((): void => {
if (!activeCustom) {
return;
}
const query = filterStateToQuery(filters);
updateView(activeCustom.id, {
name: activeCustom.name,
query,
sort: sortColumn,
order: sortOrder,
});
}, [activeCustom, query, updateView, sortColumn, sortOrder]);
applyFilters({ ...DEFAULT_FILTER_STATE, search: query });
}, [activeCustom, filters, updateView, sortColumn, sortOrder, applyFilters]);
const resetView = useCallback((): void => {
if (canonicalQuery === null) {
if (!canonicalSnapshot) {
return;
}
setQuery(canonicalQuery);
applyFilters(canonicalSnapshot);
if (activeCustom) {
setSortColumn(activeCustom.sort);
setSortOrder(activeCustom.order);
}
}, [canonicalQuery, setQuery, activeCustom, setSortColumn, setSortOrder]);
}, [
canonicalSnapshot,
applyFilters,
activeCustom,
setSortColumn,
setSortOrder,
]);
const removeView = useCallback(
(id: string): void => {
deleteView(id);
if (activeViewId === id) {
void setActiveViewId(BuiltinViewId.All);
setQuery('');
applyFilters(DEFAULT_FILTER_STATE);
}
},
[deleteView, activeViewId, setActiveViewId, setQuery],
[deleteView, activeViewId, setActiveViewId, applyFilters],
);
// Rename only touches the view's name; its stored query/sort/order are preserved.

View File

@@ -1,33 +1,138 @@
import { useCallback } from 'react';
import { type Options, parseAsString, useQueryState } from 'nuqs';
import { useCallback, useMemo } from 'react';
import {
parseAsArrayOf,
parseAsString,
parseAsStringLiteral,
useQueryState,
type Options,
} from 'nuqs';
import { isQueryEmpty } from '../utils/filterQuery';
import {
DEFAULT_FILTER_STATE,
filterStateToQuery,
isFilterStateEmpty,
} from '../utils/filterQuery';
import type {
DashboardFilterState,
SelectedTag,
UpdatedWindow,
} from '../types';
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
const opts: Options = { history: 'push' };
// Tags are carried in the URL as `key:value` strings; split on the first colon.
const parseTag = (raw: string): SelectedTag | null => {
const idx = raw.indexOf(':');
if (idx <= 0) {
return null;
}
return { key: raw.slice(0, idx), value: raw.slice(idx + 1) };
};
const serializeTag = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
export interface UseDashboardFiltersResult {
// The last-run backend list-filter query string — the source of truth for
// fetching. The editable draft lives in FilterZone; this only changes when a
// query is actually run (or a view is applied).
filters: DashboardFilterState;
// The backend list-filter `query` string derived from the current filters.
query: string;
isEmpty: boolean;
setQuery: (value: string) => void;
setSearch: (value: string) => void;
setCreatedBy: (emails: string[]) => void;
setUpdated: (window: UpdatedWindow) => void;
setTags: (tags: SelectedTag[]) => void;
// Replace the whole filter state at once — used when applying a saved view.
applyFilters: (next: DashboardFilterState) => void;
clearAll: () => void;
}
// Owns the run dashboards-list query, synced to the URL (shareable links,
// back/forward). Sort/order/page live in their own query-param hooks.
// Owns the dashboards-list filter state, synced to the URL (shareable links,
// back/forward) and projected into the backend `query` string. Sort/order/page
// live in their own query-param hooks; this hook is filters-only.
export function useDashboardFilters(): UseDashboardFiltersResult {
const [query, setQueryState] = useQueryState(
'query',
const [search, setSearchState] = useQueryState(
'search',
parseAsString.withDefault('').withOptions(opts),
);
const setQuery = useCallback(
(value: string): void => {
void setQueryState(value.trim() ? value : null);
},
[setQueryState],
const [createdBy, setCreatedByState] = useQueryState(
'createdBy',
parseAsArrayOf(parseAsString).withDefault([]).withOptions(opts),
);
const [updated, setUpdatedState] = useQueryState(
'updated',
parseAsStringLiteral(UPDATED_WINDOWS).withDefault('any').withOptions(opts),
);
const [tagStrings, setTagStringsState] = useQueryState(
'tags',
parseAsArrayOf(parseAsString).withDefault([]).withOptions(opts),
);
return { query, isEmpty: isQueryEmpty(query), setQuery };
const tags = useMemo<SelectedTag[]>(
() => tagStrings.map(parseTag).filter((t): t is SelectedTag => t !== null),
[tagStrings],
);
const filters = useMemo<DashboardFilterState>(
() => ({ search, createdBy, updated, tags }),
[search, createdBy, updated, tags],
);
const query = useMemo(() => filterStateToQuery(filters), [filters]);
const setSearch = useCallback(
(value: string): void => {
void setSearchState(value);
},
[setSearchState],
);
const setCreatedBy = useCallback(
(emails: string[]): void => {
void setCreatedByState(emails.length ? emails : null);
},
[setCreatedByState],
);
const setUpdated = useCallback(
(window: UpdatedWindow): void => {
void setUpdatedState(window);
},
[setUpdatedState],
);
const setTags = useCallback(
(next: SelectedTag[]): void => {
void setTagStringsState(next.length ? next.map(serializeTag) : null);
},
[setTagStringsState],
);
const applyFilters = useCallback(
(next: DashboardFilterState): void => {
void setSearchState(next.search || null);
void setCreatedByState(next.createdBy.length ? next.createdBy : null);
void setUpdatedState(next.updated);
void setTagStringsState(
next.tags.length ? next.tags.map(serializeTag) : null,
);
},
[setSearchState, setCreatedByState, setUpdatedState, setTagStringsState],
);
const clearAll = useCallback((): void => {
applyFilters(DEFAULT_FILTER_STATE);
}, [applyFilters]);
return {
filters,
query,
isEmpty: isFilterStateEmpty(filters),
setSearch,
setCreatedBy,
setUpdated,
setTags,
applyFilters,
clearAll,
};
}

View File

@@ -3,9 +3,27 @@ import type {
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
// Relative "updated within" windows offered by the Updated filter dropdown.
// Relative "updated within" windows offered by the Updated filter chip.
export type UpdatedWindow = 'any' | 'today' | '7d' | '30d';
// A tag selected in the Tags filter chip — a concrete key:value pair drawn from
// the tags the list API reports across the org's dashboards.
export interface SelectedTag {
key: string;
value: string;
}
// The user-controllable filter state a view captures. `search` is a raw filter
// DSL fragment the user types; the structured chips (created-by, updated, tags)
// are AND-ed onto it. Sort/order are handled separately via URL query params and
// are not part of a view snapshot.
export interface DashboardFilterState {
search: string;
createdBy: string[]; // emails (created_by)
updated: UpdatedWindow;
tags: SelectedTag[];
}
// A saved view: a named filter the org shares, persisted via the backend Views
// API. The backend stores a flat `{ query, sort, order }` (no structured chips),
// so a view captures the fully-combined DSL query plus the sort/order to apply.

View File

@@ -1,122 +0,0 @@
// Frontend mirror of the backend dashboards-list filter DSL grammar
// (pkg/types/dashboardtypes/list_filter.go). There is no keys/operators API, so
// the valid keys, per-field operators, and value shapes are encoded here. Keep in
// sync with the backend allow-lists.
import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants';
// Reserved keys the backend recognises as dashboard columns. Any other key is a
// tag key. (`updated_by` and `source` are NOT queryable.)
export const RESERVED_KEYS = [
'name',
'description',
'created_by',
'created_at',
'updated_at',
'locked',
] as const;
export type DslFieldType = 'string' | 'timestamp' | 'bool' | 'tag';
const RESERVED_FIELD_TYPES: Record<string, DslFieldType> = {
name: 'string',
description: 'string',
created_by: 'string',
created_at: 'timestamp',
updated_at: 'timestamp',
locked: 'bool',
};
// A reserved key maps to its column type; anything else is a tag.
export const classifyField = (key: string): DslFieldType =>
RESERVED_FIELD_TYPES[key.toLowerCase()] ?? 'tag';
// String/tag comparison operators, reusing the shared operator constants
// (REGEXP is allow-listed by the backend but unimplemented, so it is omitted).
const STRING_OPS = [
OPERATORS['='],
OPERATORS['!='],
OPERATORS.CONTAINS,
negateOperator(OPERATORS.CONTAINS),
OPERATORS.LIKE,
negateOperator(OPERATORS.LIKE),
OPERATORS.ILIKE,
negateOperator(OPERATORS.ILIKE),
OPERATORS.IN,
negateOperator(OPERATORS.IN),
];
// Valid operators per field type. Tags additionally support existence checks.
export const OPERATOR_MATRIX: Record<DslFieldType, string[]> = {
string: STRING_OPS,
tag: [...STRING_OPS, OPERATORS.EXISTS, negateOperator(OPERATORS.EXISTS)],
timestamp: [
OPERATORS['='],
OPERATORS['!='],
OPERATORS['<'],
OPERATORS['<='],
OPERATORS['>'],
OPERATORS['>='],
OPERATORS.BETWEEN,
negateOperator(OPERATORS.BETWEEN),
],
bool: [OPERATORS['='], OPERATORS['!=']],
};
// Operators that take no value (the clause is complete after the operator).
export const VALUELESS_OPERATORS = new Set([
OPERATORS.EXISTS,
negateOperator(OPERATORS.EXISTS),
]);
// Operators whose value is a bracketed list (`IN ['a', 'b']`) rather than a
// single literal — value suggestions wrap/append inside the `[...]`.
export const LIST_OPERATORS = new Set([
OPERATORS.IN,
negateOperator(OPERATORS.IN),
]);
// Every operator, longest spelling first, so the tokenizer matches `NOT IN`
// before `NOT`/`IN` and `>=` before `>`. Word operators allow flexible internal
// whitespace (`NOT IN`).
export const OPERATOR_PATTERN = new RegExp(
'^(' +
[
'NOT\\s+CONTAINS',
'NOT\\s+LIKE',
'NOT\\s+ILIKE',
'NOT\\s+BETWEEN',
'NOT\\s+EXISTS',
'NOT\\s+IN',
'CONTAINS',
'ILIKE',
'LIKE',
'BETWEEN',
'EXISTS',
'IN',
'>=',
'<=',
'!=',
'<>',
'==',
'=',
'<',
'>',
].join('|') +
')',
'i',
);
// Canonical (uppercase, single-spaced) form of a matched operator spelling.
export const canonicalOperator = (raw: string): string => {
const collapsed = raw.replace(/\s+/g, ' ').trim().toUpperCase();
return collapsed === '==' ? '=' : collapsed === '<>' ? '!=' : collapsed;
};
// Characters that start an operator symbol — used to end a bare key token that
// abuts an operator without whitespace (`name='x'`).
export const isOperatorSymbolStart = (ch: string): boolean =>
ch === '=' || ch === '!' || ch === '<' || ch === '>';
// A single-quoted DSL string literal with embedded single quotes escaped.
export const literal = (value: string): string =>
`'${value.replace(/'/g, "\\'")}'`;

View File

@@ -1,138 +1,57 @@
import { getSuggestions, type SuggestionSource } from './dslSuggestions';
import {
applyKeySuggestion,
buildSuggestionKeys,
getActiveKeyToken,
matchKeys,
RESERVED_DSL_KEYS,
} from './dslSuggestions';
const source: SuggestionSource = {
tagKeys: ['env', 'team'],
tagValuesByKey: { env: ['prod', 'dev'] },
creatorEmails: ['alice@x.io', 'bob@x.io'],
currentUserEmail: 'me@x.io',
};
const labels = (q: string, caret: number): string[] =>
getSuggestions(q, caret, source).items.map((i) => i.label);
const inserts = (q: string, caret: number): string[] =>
getSuggestions(q, caret, source).items.map((i) => i.insertText);
describe('getSuggestions — key stage', () => {
it('suggests reserved + tag keys matching the partial', () => {
expect(labels('en', 2)).toStrictEqual(['env']);
describe('getActiveKeyToken', () => {
it('returns the partial key at the start', () => {
expect(getActiveKeyToken('nam')).toStrictEqual({ token: 'nam', start: 0 });
});
it('inserts the key with a trailing space', () => {
expect(inserts('en', 2)).toStrictEqual(['env ']);
it('returns the partial key after AND', () => {
const value = 'name = "x" AND en';
expect(getActiveKeyToken(value)).toStrictEqual({ token: 'en', start: 15 });
});
it('is null once an operator (space) has been typed', () => {
expect(getActiveKeyToken('name contains')).toBeNull();
});
it('is null for an empty trailing segment', () => {
expect(getActiveKeyToken('name = "x" AND ')).toBeNull();
});
});
describe('getSuggestions — operator stage', () => {
it('offers the full tag operator set as "key op" labels', () => {
const out = labels('env ', 4);
expect(out).toContain('env =');
expect(out).toContain('env CONTAINS');
expect(out).toContain('env NOT IN');
expect(out).toContain('env EXISTS');
expect(out.some((l) => l.includes('REGEXP'))).toBe(false);
});
it('restricts locked to = and !=', () => {
expect(labels('locked ', 7)).toStrictEqual(['locked =', 'locked !=']);
});
it('offers range operators for a timestamp key', () => {
const out = labels('updated_at ', 11);
expect(out).toContain('updated_at >=');
expect(out).toContain('updated_at BETWEEN');
expect(out).not.toContain('updated_at CONTAINS');
});
it('filters operators by the typed partial', () => {
expect(labels('env NOT ', 8)).toHaveLength(0); // "NOT " + space → past partial op, no ops
expect(labels('env NOT', 7)).toStrictEqual([
'env NOT CONTAINS',
'env NOT LIKE',
'env NOT ILIKE',
'env NOT IN',
'env NOT EXISTS',
describe('buildSuggestionKeys', () => {
it('lists reserved keys plus distinct tag keys', () => {
const keys = buildSuggestionKeys([
{ key: 'env', value: 'prod' },
{ key: 'env', value: 'dev' },
{ key: 'team', value: 'core' },
]);
});
it('inserts just the operator with a trailing space', () => {
expect(inserts('env C', 5)).toStrictEqual(['CONTAINS ']);
expect(keys).toStrictEqual([...RESERVED_DSL_KEYS, 'env', 'team']);
});
});
describe('getSuggestions — value stage', () => {
it('suggests creator emails (current user first, labelled) for created_by', () => {
const out = getSuggestions('created_by = ', 13, source);
expect(out.items[0].label).toBe('me@x.io (me)');
expect(out.items.map((i) => i.insertText)).toContain("'alice@x.io' ");
});
it('suggests true/false unquoted for locked', () => {
expect(inserts('locked = ', 9)).toStrictEqual(['true ', 'false ']);
});
it('suggests known tag values (quoted, trailing space) for a tag key', () => {
expect(inserts('env = ', 6)).toStrictEqual(["'prod' ", "'dev' "]);
});
it('filters values by the open-quote partial', () => {
expect(inserts("env = 'pr", 9)).toStrictEqual(["'prod' "]);
});
it('offers no value suggestions for free-text keys', () => {
expect(labels('name = ', 7)).toStrictEqual([]);
describe('matchKeys', () => {
it('matches case-insensitively and excludes exact matches', () => {
expect(matchKeys(['name', 'created_by', 'env'], 'NAM')).toStrictEqual([
'name',
]);
expect(matchKeys(['name'], 'name')).toStrictEqual([]);
});
});
describe('getSuggestions — IN list values', () => {
it('wraps a fresh IN value in a bracketed list, caret before the close', () => {
const q = 'created_by IN ';
const out = getSuggestions(q, q.length, source);
const alice = out.items.find((i) => i.label === 'alice@x.io');
expect(alice?.insertText).toBe("['alice@x.io']");
expect(alice?.caretOffset).toBe(1);
});
it('wraps tag IN values in a bracketed list', () => {
const q = 'env IN ';
expect(inserts(q, q.length)).toStrictEqual(["['prod']", "['dev']"]);
});
it('appends to an open list and excludes already-entered values', () => {
const q = "env IN ['prod', ]";
const caret = q.indexOf(']'); // inside the brackets, after "'prod', "
const out = getSuggestions(q, caret, source);
expect(out.items.map((i) => i.label)).toStrictEqual(['dev']);
const dev = out.items[0];
// No closing bracket (the existing `]` stays); caret lands at the end.
expect(dev.insertText).toBe("['prod', 'dev'");
expect(dev.caretOffset).toBe(0);
});
it('treats a complete last literal (no trailing comma) as entered', () => {
const q = "env IN ['prod'";
const out = getSuggestions(q, q.length, source);
// 'prod' is committed; only 'dev' remains and it appends after it.
expect(out.items.map((i) => i.label)).toStrictEqual(['dev']);
expect(out.items[0].insertText).toBe("['prod', 'dev'");
});
it('filters open-list suggestions by the fragment under the caret', () => {
const q = "env IN ['de";
expect(inserts(q, q.length)).toStrictEqual(["['dev'"]);
});
it('uses a plain literal for a single-value operator (=)', () => {
expect(inserts('env = ', 6)).toStrictEqual(["'prod' ", "'dev' "]);
});
});
describe('getSuggestions — connector stage', () => {
it('chains AND/OR after a valueless operator', () => {
expect(labels('env EXISTS ', 11)).toStrictEqual(['AND', 'OR']);
});
it('chains AND/OR after a complete value', () => {
expect(labels("env = 'prod' ", 13)).toStrictEqual(['AND', 'OR']);
expect(inserts("env = 'prod' ", 13)).toStrictEqual(['AND ', 'OR ']);
describe('applyKeySuggestion', () => {
it('replaces the partial key with the chosen key and a trailing space', () => {
const value = 'name = "x" AND en';
const active = getActiveKeyToken(value);
if (!active) {
throw new Error('expected an active key token');
}
expect(applyKeySuggestion(value, active, 'env')).toBe('name = "x" AND env ');
});
});

View File

@@ -1,255 +1,68 @@
// Stage-aware autocomplete for the dashboards-list DSL: as the caret moves, it
// suggests keys, then operators (valid for that key's field type), then values
// (drawn from the tags/users the API reports). Grammar lives in dslGrammar.ts;
// caret/stage detection in dslTokenizer.ts.
import {
classifyField,
LIST_OPERATORS,
literal,
OPERATOR_MATRIX,
RESERVED_KEYS,
VALUELESS_OPERATORS,
} from './dslGrammar';
import { type CaretContext, getCaretContext } from './dslTokenizer';
// Key-name suggestions for the dashboards-list DSL search box. The reserved keys
// mirror the backend filter DSL (pkg/.../listfilter_visitor.go); any other key is
// treated as a tag key, so we also surface the tag keys the list API reports.
import type { SelectedTag } from '../types';
// Show the full key set (the popup scrolls); values are capped a bit higher too.
const KEY_LIMIT = 100;
const VALUE_LIMIT = 50;
// Reserved DSL keys the backend recognises as dashboard columns.
export const RESERVED_DSL_KEYS: string[] = [
'name',
'description',
'created_at',
'updated_at',
'created_by',
'locked',
'source',
];
export interface SuggestionSource {
// Reserved column keys (from the list response, falling back to RESERVED_KEYS).
reservedKeys?: string[];
tagKeys: string[];
// Known values per tag key (lower-cased key → values).
tagValuesByKey: Record<string, string[]>;
// Creator emails for `created_by` value suggestions.
creatorEmails: string[];
currentUserEmail?: string;
export interface ActiveKeyToken {
token: string;
// Index in the value string where the partial key begins.
start: number;
}
export interface Suggestion {
label: string;
insertText: string;
kind: 'key' | 'operator' | 'value' | 'connector';
// Right-aligned hint in the popup — e.g. 'field' vs 'tag' so reserved columns
// are distinguishable from org tag keys.
detail?: string;
// Chars to move the caret back from the end of `insertText` after applying.
// Used to land the caret inside a `[...]` list so multi-select can continue.
caretOffset?: number;
}
// The partial key the user is currently typing: the trailing segment after the
// last top-level AND/OR (or the start), provided it hasn't yet reached an
// operator (no whitespace). Returns null once the key is complete.
export const getActiveKeyToken = (value: string): ActiveKeyToken | null => {
const boundaryRe = /\b(?:AND|OR)\b/gi;
let lastEnd = 0;
let match = boundaryRe.exec(value);
while (match !== null) {
lastEnd = match.index + match[0].length;
match = boundaryRe.exec(value);
}
const segment = value.slice(lastEnd);
const leading = segment.length - segment.trimStart().length;
const partial = segment.slice(leading);
if (partial.length === 0 || /[\s(]/.test(partial)) {
return null;
}
return { token: partial, start: lastEnd + leading };
};
export interface SuggestionsResult {
items: Suggestion[];
ctx: CaretContext;
}
// Build the de-duplicated, ordered list of keys to offer: reserved columns plus
// distinct tag keys from the list response.
export const buildSuggestionKeys = (availableTags: SelectedTag[]): string[] => {
const tagKeys = availableTags.map((t) => t.key);
return Array.from(new Set([...RESERVED_DSL_KEYS, ...tagKeys]));
};
const includesInsensitive = (haystack: string, needle: string): boolean =>
haystack.toLowerCase().includes(needle.toLowerCase());
const dedupe = (values: string[]): string[] => Array.from(new Set(values));
const keySuggestions = (
partial: string,
source: SuggestionSource,
): Suggestion[] => {
const reserved = source.reservedKeys ?? [...RESERVED_KEYS];
const reservedSet = new Set(reserved.map((k) => k.toLowerCase()));
// Show reserved keys in our defined (purpose) order — name, description, … —
// rather than the API's alphabetical order; any extra API keys follow, then tags.
const known = RESERVED_KEYS as readonly string[];
const orderedReserved = [
...known.filter((k) => reserved.includes(k)),
...reserved.filter((k) => !known.includes(k)),
];
const keys = dedupe([...orderedReserved, ...source.tagKeys]);
const lower = partial.toLowerCase();
// Keys matching the partial token (case-insensitive), excluding an exact match.
export const matchKeys = (
keys: string[],
token: string,
limit = 8,
): string[] => {
const lower = token.toLowerCase();
return keys
.filter((k) => includesInsensitive(k, partial) && k.toLowerCase() !== lower)
.slice(0, KEY_LIMIT)
.map((k) => ({
label: k,
insertText: `${k} `,
kind: 'key',
// Distinguish built-in columns from org tag keys in the popup.
detail: reservedSet.has(k.toLowerCase()) ? 'field' : 'tag',
}));
.filter((k) => k.toLowerCase().includes(lower) && k.toLowerCase() !== lower)
.slice(0, limit);
};
const operatorSuggestions = (
fieldKey: string,
partial: string,
): Suggestion[] => {
const ops = OPERATOR_MATRIX[classifyField(fieldKey)];
const upper = partial.toUpperCase();
return ops
.filter((op) => op.startsWith(upper))
.map((op) => ({
// Echo the key with the operator (matches the query-builder UX) so the
// suggestion reads as the clause being built.
label: `${fieldKey} ${op}`,
insertText: `${op} `,
kind: 'operator',
}));
};
// Strip a leading opening quote from a partially-typed value for matching.
const unquotePartial = (partial: string): string =>
partial.replace(/^['"]/, '');
// A complete quoted literal (`'a'` or `"a"`, escapes allowed) — used to tell an
// already-entered list value from the fragment the user is still typing.
const COMPLETE_LITERAL = /^(['"])(?:\\.|(?!\1).)*\1$/;
interface ListPartial {
// Values already entered in the list (unquoted).
committed: string[];
// The value fragment the caret is currently typing (unquoted).
fragment: string;
}
// Index just past the string literal opening at `start` (mirrors the tokenizer's
// escape handling); runs to end-of-input if unterminated.
const skipStringLiteral = (text: string, start: number): number => {
const quote = text[start];
for (let i = start + 1; i < text.length; i += 1) {
if (text[i] === '\\') {
i += 1;
} else if (text[i] === quote) {
return i;
}
}
return text.length - 1;
};
// Split a top-level, comma-separated `[...]` partial (quote-aware) into its
// already-entered values and the fragment under the caret. A trailing comma or a
// complete quoted last piece means the caret starts a fresh value (fragment '').
const parseListPartial = (partial: string): ListPartial => {
const inner = partial.trim().replace(/^[[(]/, '');
const pieces: string[] = [];
let depth = 0;
let start = 0;
for (let i = 0; i < inner.length; i += 1) {
const c = inner[i];
if (c === "'" || c === '"') {
i = skipStringLiteral(inner, i);
} else if (c === '[' || c === '(') {
depth += 1;
} else if (c === ']' || c === ')') {
depth = Math.max(0, depth - 1);
} else if (c === ',' && depth === 0) {
pieces.push(inner.slice(start, i));
start = i + 1;
}
}
pieces.push(inner.slice(start));
const last = pieces[pieces.length - 1].trim();
const lastIsComplete = last !== '' && COMPLETE_LITERAL.test(last);
const committedPieces = lastIsComplete ? pieces : pieces.slice(0, -1);
return {
committed: committedPieces
.map((p) => unquotePartial(p.trim()).replace(/['"]$/, ''))
.filter((p) => p !== ''),
fragment: lastIsComplete ? '' : unquotePartial(last),
};
};
const valueSuggestions = (
ctx: CaretContext,
source: SuggestionSource,
): Suggestion[] => {
if (VALUELESS_OPERATORS.has(ctx.operator)) {
return [];
}
const key = ctx.fieldKey.toLowerCase();
const type = classifyField(ctx.fieldKey);
// List operators (`IN`/`NOT IN`) take a `[...]` list: parse what's already in
// the brackets so we exclude entered values and append rather than replace.
const isList = LIST_OPERATORS.has(ctx.operator);
const list = isList ? parseListPartial(ctx.partial) : null;
// A fresh list has no `[` yet; once inside one the `]` already sits after the
// caret, so we omit the closing bracket when appending.
const inBracket = isList && /^\s*[[(]/.test(ctx.partial);
const needle = list ? list.fragment : unquotePartial(ctx.partial);
if (type === 'bool') {
return ['true', 'false']
.filter((v) => v.startsWith(needle.toLowerCase()))
.map((v) => ({ label: v, insertText: `${v} `, kind: 'value' }));
}
let raw: string[] = [];
if (key === 'created_by') {
const emails = source.currentUserEmail
? dedupe([source.currentUserEmail, ...source.creatorEmails])
: source.creatorEmails;
raw = emails;
} else if (type === 'tag') {
raw = source.tagValuesByKey[key] ?? [];
} else {
// name / description / timestamps have no known value set — free text.
return [];
}
const committed = new Set(list?.committed ?? []);
return raw
.filter((v) => !committed.has(v) && includesInsensitive(v, needle))
.slice(0, VALUE_LIMIT)
.map((v) => {
const label =
v === source.currentUserEmail && key === 'created_by' ? `${v} (me)` : v;
if (isList) {
const values = [...(list?.committed ?? []), v].map(literal).join(', ');
return {
label,
// Append inside the existing `[...]` (its `]` stays after the caret),
// or open a fresh closed list. Either way the caret lands just before
// the `]` so the next pick continues the list.
insertText: inBracket ? `[${values}` : `[${values}]`,
kind: 'value',
caretOffset: inBracket ? 0 : 1,
};
}
return {
label,
// Trailing space so picking a value lands the caret in the connector slot.
insertText: `${literal(v)} `,
kind: 'value',
};
});
};
// AND / OR chaining after a complete term.
const CONNECTORS = ['AND', 'OR'];
const connectorSuggestions = (partial: string): Suggestion[] => {
const upper = partial.toUpperCase();
return CONNECTORS.filter((c) => c.startsWith(upper)).map((c) => ({
label: c,
insertText: `${c} `,
kind: 'connector',
}));
};
// Compute the suggestions for the caret position, plus the caret context the
// caller uses to splice a chosen suggestion back in.
export const getSuggestions = (
query: string,
caret: number,
source: SuggestionSource,
): SuggestionsResult => {
const ctx = getCaretContext(query, caret);
let items: Suggestion[] = [];
if (ctx.stage === 'key') {
items = keySuggestions(ctx.partial, source);
} else if (ctx.stage === 'operator') {
items = operatorSuggestions(ctx.fieldKey, ctx.partial);
} else if (ctx.stage === 'value') {
items = valueSuggestions(ctx, source);
} else if (ctx.stage === 'connector') {
items = connectorSuggestions(ctx.partial);
}
return { items, ctx };
};
// Replace the active partial key in `value` with the chosen key + a space, ready
// for the user to type an operator.
export const applyKeySuggestion = (
value: string,
active: ActiveKeyToken,
key: string,
): string => `${value.slice(0, active.start)}${key} `;

View File

@@ -1,151 +0,0 @@
import {
getCaretContext,
spliceAtCaret,
splitTopLevelTerms,
} from './dslTokenizer';
describe('splitTopLevelTerms', () => {
it('splits on top-level AND/OR', () => {
const terms = splitTopLevelTerms('a AND b OR c');
expect(terms.map((t) => t.text.trim())).toStrictEqual(['a', 'b', 'c']);
expect(terms.map((t) => t.precedingJoiner)).toStrictEqual([
null,
'AND',
'OR',
]);
});
it('ignores AND inside quotes', () => {
const terms = splitTopLevelTerms("name = 'a AND b' AND env = 'x'");
expect(terms).toHaveLength(2);
expect(terms[0].text.trim()).toBe("name = 'a AND b'");
expect(terms[1].text.trim()).toBe("env = 'x'");
});
it('ignores OR inside parentheses', () => {
const terms = splitTopLevelTerms('(a OR b) AND c');
expect(terms).toHaveLength(2);
expect(terms[1].precedingJoiner).toBe('AND');
});
it('keeps an IN list as a single term', () => {
const terms = splitTopLevelTerms("created_by IN ['a','b']");
expect(terms).toHaveLength(1);
});
it('does not split inside a bare word containing and/or', () => {
expect(splitTopLevelTerms('command = 1')).toHaveLength(1);
});
});
describe('getCaretContext — stage detection', () => {
const stageAt = (q: string, caret: number): string =>
getCaretContext(q, caret).stage;
it('is the key stage while typing the key', () => {
const ctx = getCaretContext('env', 3);
expect(ctx.stage).toBe('key');
expect(ctx.partial).toBe('env');
expect(ctx.replaceStart).toBe(0);
});
it('moves to the operator stage after the key + space', () => {
const ctx = getCaretContext('env ', 4);
expect(ctx.stage).toBe('operator');
expect(ctx.fieldKey).toBe('env');
expect(ctx.partial).toBe('');
});
it('stays on the operator while typing a partial operator', () => {
const ctx = getCaretContext('env NOT', 7);
expect(ctx.stage).toBe('operator');
expect(ctx.partial).toBe('NOT');
expect(ctx.replaceStart).toBe(4);
});
it('moves to the value stage after the operator + space', () => {
const ctx = getCaretContext('env = ', 6);
expect(ctx.stage).toBe('value');
expect(ctx.fieldKey).toBe('env');
expect(ctx.operator).toBe('=');
});
it('recognises a multi-word operator and moves to value', () => {
const ctx = getCaretContext('env NOT IN ', 11);
expect(ctx.stage).toBe('value');
expect(ctx.operator).toBe('NOT IN');
});
it('reports value partial inside an open quote', () => {
const ctx = getCaretContext("env = 'pr", 9);
expect(ctx.stage).toBe('value');
expect(ctx.partial).toBe("'pr");
expect(ctx.replaceStart).toBe(6);
});
it('chains a connector after EXISTS (no value stage)', () => {
expect(stageAt('env EXISTS ', 11)).toBe('connector');
});
it('moves to the connector stage after a complete value + space', () => {
const ctx = getCaretContext("env = 'prod' ", 13);
expect(ctx.stage).toBe('connector');
expect(ctx.partial).toBe('');
});
it('reports a partial connector keyword', () => {
const ctx = getCaretContext("env = 'prod' AN", 15);
expect(ctx.stage).toBe('connector');
expect(ctx.partial).toBe('AN');
expect(ctx.replaceStart).toBe(13);
});
it('handles a key operator with no whitespace', () => {
const ctx = getCaretContext("name='x", 7);
expect(ctx.stage).toBe('value');
expect(ctx.fieldKey).toBe('name');
expect(ctx.operator).toBe('=');
});
it('starts a fresh key after a top-level AND', () => {
const q = "created_by = 'a@x' AND ";
const ctx = getCaretContext(q, q.length);
expect(ctx.stage).toBe('key');
expect(ctx.partial).toBe('');
});
it('detects the stage of the term under a mid-string caret', () => {
const q = "env = AND team = 'core'";
// caret right after the first `env ` (index 4) is the operator stage
expect(stageAt(q, 4)).toBe('operator');
});
});
describe('spliceAtCaret', () => {
it('splices a key suggestion and returns the caret', () => {
const ctx = getCaretContext('env', 3);
const { next, caret } = spliceAtCaret('env', ctx, 'name ');
expect(next).toBe('name ');
expect(caret).toBe(5);
});
it('splices an operator into the gap', () => {
const ctx = getCaretContext('env ', 4);
const { next } = spliceAtCaret('env ', ctx, '= ');
expect(next).toBe('env = ');
});
it('splices a value over an open-quote partial', () => {
const q = "env = 'pr";
const ctx = getCaretContext(q, q.length);
const { next } = spliceAtCaret(q, ctx, "'prod'");
expect(next).toBe("env = 'prod'");
});
it('preserves text after the caret', () => {
const q = "env AND team = 'core'";
const ctx = getCaretContext(q, 4); // operator gap after `env`
const { next } = spliceAtCaret(q, ctx, '= ');
expect(next).toBe("env = AND team = 'core'");
});
});

View File

@@ -1,351 +0,0 @@
// Quote/paren/bracket-aware scanning of the dashboards-list filter DSL, shared by
// the autocomplete engine (dslSuggestions) and the clause splicer (filterQuery).
// The DSL is a boolean composition of `key OP value` terms joined by AND/OR.
import {
canonicalOperator,
isOperatorSymbolStart,
OPERATOR_PATTERN,
VALUELESS_OPERATORS,
} from './dslGrammar';
export interface Term {
// Raw substring of the term (may carry surrounding whitespace); callers trim.
text: string;
start: number; // absolute start index in the query (inclusive)
end: number; // absolute end index in the query (exclusive)
precedingJoiner: 'AND' | 'OR' | null;
}
// A top-level AND/OR keyword starting at index `i` (word-boundary aware), or null.
const joinerAt = (
query: string,
i: number,
): { value: 'AND' | 'OR'; length: number } | null => {
const c = query[i];
if (c !== 'A' && c !== 'a' && c !== 'O' && c !== 'o') {
return null;
}
const prev = i === 0 ? '' : query[i - 1];
if (!(i === 0 || /[^A-Za-z0-9_]/.test(prev))) {
return null;
}
const m = /^(AND|OR)\b/i.exec(query.slice(i));
return m
? { value: m[1].toUpperCase() as 'AND' | 'OR', length: m[0].length }
: null;
};
// Index just past the string literal that opens at `start` (handles `\` escapes;
// runs to end-of-input if unterminated).
const skipString = (query: string, start: number): number => {
const quote = query[start];
let i = start + 1;
while (i < query.length) {
if (query[i] === '\\') {
i += 2;
} else if (query[i] === quote) {
return i + 1;
} else {
i += 1;
}
}
return i;
};
// Split a query into its top-level (depth-0, outside quotes) AND/OR-separated
// terms. `AND`/`OR` inside quotes or brackets/parens are ignored.
export const splitTopLevelTerms = (query: string): Term[] => {
const terms: Term[] = [];
let depth = 0;
let termStart = 0;
let precedingJoiner: 'AND' | 'OR' | null = null;
let i = 0;
const push = (end: number, next: 'AND' | 'OR' | null): void => {
terms.push({
text: query.slice(termStart, end),
start: termStart,
end,
precedingJoiner,
});
precedingJoiner = next;
};
while (i < query.length) {
const c = query[i];
if (c === "'" || c === '"') {
i = skipString(query, i);
continue;
}
if (c === '[' || c === '(') {
depth += 1;
} else if (c === ']' || c === ')') {
depth = Math.max(0, depth - 1);
} else if (depth === 0) {
const joiner = joinerAt(query, i);
if (joiner) {
push(i, joiner.value);
i += joiner.length;
termStart = i;
continue;
}
}
i += 1;
}
push(query.length, null);
return terms;
};
export interface TermScan {
key?: { start: number; end: number; text: string };
// The recognised operator (canonical form), if the second token parses as one.
operator?: { start: number; end: number; canonical: string };
// The second token when it is NOT (yet) a recognised operator — i.e. an
// operator the user is still typing.
operatorPartial?: { start: number; end: number; text: string };
value?: { start: number; end: number; text: string };
}
// Scan a single term (relative coordinates) into key / operator / value tokens.
export const scanTerm = (text: string): TermScan => {
const n = text.length;
let i = 0;
const skipWs = (): void => {
while (i < n && /\s/.test(text[i])) {
i += 1;
}
};
skipWs();
const scan: TermScan = {};
// Key: a bare token up to whitespace or an operator symbol.
const ks = i;
while (i < n && !/\s/.test(text[i]) && !isOperatorSymbolStart(text[i])) {
i += 1;
}
if (i > ks) {
scan.key = { start: ks, end: i, text: text.slice(ks, i) };
}
skipWs();
if (i >= n) {
return scan;
}
// Operator: try to match a known operator at the current position.
const rest = text.slice(i);
const m = OPERATOR_PATTERN.exec(rest);
// A word operator must end on a boundary so `INDIA` isn't read as `IN`.
const wordBoundaryOk =
m &&
(/[^A-Za-z]$/.test(m[0]) ||
i + m[0].length >= n ||
/[^A-Za-z]/.test(text[i + m[0].length]));
if (m && wordBoundaryOk) {
scan.operator = {
start: i,
end: i + m[0].length,
canonical: canonicalOperator(m[0]),
};
i += m[0].length;
skipWs();
if (i < n) {
scan.value = { start: i, end: n, text: text.slice(i, n) };
}
return scan;
}
// Not a recognised operator yet — treat the second token as a partial op.
const os = i;
while (i < n && !/\s/.test(text[i])) {
i += 1;
}
scan.operatorPartial = { start: os, end: i, text: text.slice(os, i) };
return scan;
};
export type CaretStage = 'key' | 'operator' | 'value' | 'connector' | 'none';
export interface CaretContext {
stage: CaretStage;
fieldKey: string; // key of the active term ('' when still typing the key)
operator: string; // canonical operator ('' when not yet parsed)
partial: string; // text typed so far in the active slot (for matching)
replaceStart: number; // absolute index to start replacing at
replaceEnd: number; // absolute index to stop replacing (the caret)
}
interface Slot {
stage: CaretStage;
operator: string;
partial: string;
replaceStartRel: number;
}
// Index where the value CONTENT ends (the value token's `end` is the whole term
// tail, incl. trailing space). A quoted/bracketed value ends after its close; an
// unterminated one runs to the end (still being typed); a bare one ends at space.
const valueContentEnd = (text: string, start: number): number => {
const n = text.length;
const ch = text[start];
if (ch === '"' || ch === "'") {
for (let i = start + 1; i < n; i += 1) {
if (text[i] === ch) {
return i + 1;
}
}
return n;
}
if (ch === '[' || ch === '(') {
const close = ch === '[' ? ']' : ')';
for (let i = start + 1; i < n; i += 1) {
if (text[i] === close) {
return i + 1;
}
}
return n;
}
let i = start;
while (i < n && !/\s/.test(text[i])) {
i += 1;
}
return i;
};
// The connector slot: after a complete term, a (partial) AND/OR keyword sits in
// the trailing whitespace, ready to chain the next term.
const connectorSlot = (
text: string,
from: number,
rel: number,
operator: string,
): Slot => {
const ws = /^\s*/.exec(text.slice(from, rel));
const start = from + (ws ? ws[0].length : 0);
return {
stage: 'connector',
operator,
partial: text.slice(start, rel),
replaceStartRel: start,
};
};
// Slot resolution once the caret is past the key: operator vs value.
const afterKeySlot = (text: string, scan: TermScan, rel: number): Slot => {
const { operator, operatorPartial, value } = scan;
// Editing the operator (a partial second token, or within a recognised op).
if (operatorPartial && rel <= operatorPartial.end) {
return {
stage: 'operator',
operator: '',
partial: text.slice(operatorPartial.start, rel),
replaceStartRel: operatorPartial.start,
};
}
if (operator && rel <= operator.end) {
return {
stage: 'operator',
operator: '',
partial: text.slice(operator.start, rel),
replaceStartRel: operator.start,
};
}
// Whitespace right after the key, before any operator — starting the operator.
if (!operator && !operatorPartial) {
return { stage: 'operator', operator: '', partial: '', replaceStartRel: rel };
}
// Past a recognised operator — the value slot, or (once complete) the connector.
if (operator) {
if (VALUELESS_OPERATORS.has(operator.canonical)) {
// After a valueless op (e.g. EXISTS), trailing space → chain AND/OR.
return rel > operator.end
? connectorSlot(text, operator.end, rel, operator.canonical)
: {
stage: 'none',
operator: operator.canonical,
partial: '',
replaceStartRel: rel,
};
}
if (value) {
// Caret past a complete value (into trailing space) → chain AND/OR.
const contentEnd = valueContentEnd(text, value.start);
if (rel > contentEnd) {
return connectorSlot(text, contentEnd, rel, operator.canonical);
}
return {
stage: 'value',
operator: operator.canonical,
partial: text.slice(value.start, rel),
replaceStartRel: value.start,
};
}
// No value typed yet — the (empty) value slot.
return {
stage: 'value',
operator: operator.canonical,
partial: '',
replaceStartRel: rel,
};
}
// A partial operator the caret has moved past — ambiguous, offer nothing.
return { stage: 'none', operator: '', partial: '', replaceStartRel: rel };
};
// Resolve which slot (key/operator/value) the caret is editing within a scanned
// term, in term-relative coordinates.
const resolveSlot = (text: string, scan: TermScan, rel: number): Slot => {
const { key } = scan;
// Still in/at the key token (or before it).
if (!key || rel <= key.end) {
const start = key ? key.start : rel;
return {
stage: 'key',
operator: '',
partial: key ? text.slice(key.start, rel) : '',
replaceStartRel: Math.min(start, rel),
};
}
return afterKeySlot(text, scan, rel);
};
// Determine what the caret is currently editing (key / operator / value) within
// the top-level term it sits in, plus the range a suggestion should replace.
export const getCaretContext = (query: string, caret: number): CaretContext => {
const pos = Math.max(0, Math.min(caret, query.length));
const terms = splitTopLevelTerms(query);
// The term the caret sits in: the first whose end >= caret (a joiner boundary
// counts as the end of the preceding term).
const term =
terms.find((t) => pos >= t.start && pos <= t.end) ?? terms[terms.length - 1];
const scan = scanTerm(term.text);
const slot = resolveSlot(term.text, scan, pos - term.start);
return {
stage: slot.stage,
fieldKey: scan.key ? scan.key.text : '',
operator: slot.operator,
partial: slot.partial,
replaceStart: term.start + slot.replaceStartRel,
replaceEnd: pos,
};
};
export interface SpliceResult {
next: string;
caret: number;
}
// Replace the active slot (ctx.replaceStart..replaceEnd) with `insertText`,
// preserving everything after the caret, and return the new caret position.
export const spliceAtCaret = (
query: string,
ctx: CaretContext,
insertText: string,
): SpliceResult => {
const next =
query.slice(0, ctx.replaceStart) + insertText + query.slice(ctx.replaceEnd);
return { next, caret: ctx.replaceStart + insertText.length };
};

View File

@@ -1,124 +1,109 @@
import dayjs from 'dayjs';
import {
areQueriesEqual,
createdByClause,
isQueryEmpty,
parseReflectedClauses,
spliceClause,
updatedWindowFromTimestamp,
areFilterStatesEqual,
combineQueries,
DEFAULT_FILTER_STATE,
filterStateToQuery,
isFilterStateEmpty,
} from './filterQuery';
import type { DashboardFilterState } from '../types';
describe('createdByClause', () => {
it('is null for no emails, = for one, IN for many', () => {
expect(createdByClause([])).toBeNull();
expect(createdByClause(['a@x.io'])).toBe("created_by = 'a@x.io'");
expect(createdByClause(['a@x.io', 'b@x.io'])).toBe(
"created_by IN ['a@x.io', 'b@x.io']",
const state = (patch: Partial<DashboardFilterState>): DashboardFilterState => ({
...DEFAULT_FILTER_STATE,
...patch,
});
describe('filterStateToQuery', () => {
it('passes the raw search through, wrapped in parentheses', () => {
expect(filterStateToQuery(state({ search: 'name contains "prod"' }))).toBe(
'(name contains "prod")',
);
});
it('emits an equality clause for a single creator', () => {
expect(filterStateToQuery(state({ createdBy: ['a@b.com'] }))).toBe(
"created_by = 'a@b.com'",
);
});
it('emits an IN clause for multiple creators', () => {
expect(filterStateToQuery(state({ createdBy: ['a@b.com', 'c@d.com'] }))).toBe(
"created_by IN ['a@b.com', 'c@d.com']",
);
});
it('emits an exact equality clause per selected tag', () => {
expect(
filterStateToQuery(
state({
tags: [
{ key: 'env', value: 'prod' },
{ key: 'team', value: 'core' },
],
}),
),
).toBe("env = 'prod' AND team = 'core'");
});
it('ANDs raw search with the structured chips', () => {
expect(
filterStateToQuery(
state({
search: 'name contains "x"',
createdBy: ['a@b.com'],
tags: [{ key: 'env', value: 'prod' }],
}),
),
).toBe("(name contains \"x\") AND created_by = 'a@b.com' AND env = 'prod'");
});
it('returns an empty string for the default state', () => {
expect(filterStateToQuery(DEFAULT_FILTER_STATE)).toBe('');
});
});
describe('isFilterStateEmpty', () => {
it('is true for the default state', () => {
expect(isFilterStateEmpty(DEFAULT_FILTER_STATE)).toBe(true);
});
it('is false when any tag is selected', () => {
expect(
isFilterStateEmpty(state({ tags: [{ key: 'env', value: 'prod' }] })),
).toBe(false);
});
});
describe('areFilterStatesEqual', () => {
it('ignores tag ordering', () => {
const a = state({
tags: [
{ key: 'env', value: 'prod' },
{ key: 'team', value: 'core' },
],
});
const b = state({
tags: [
{ key: 'team', value: 'core' },
{ key: 'env', value: 'prod' },
],
});
expect(areFilterStatesEqual(a, b)).toBe(true);
});
it('distinguishes differing tag selections', () => {
expect(
areFilterStatesEqual(
state({ tags: [{ key: 'env', value: 'prod' }] }),
state({ tags: [{ key: 'env', value: 'dev' }] }),
),
).toBe(false);
});
});
describe('combineQueries', () => {
it('drops empty fragments and ANDs the rest', () => {
expect(combineQueries('locked = true', '', undefined, 'name = "x"')).toBe(
'locked = true AND name = "x"',
);
});
});
describe('parseReflectedClauses', () => {
it('reflects a top-level created_by equality', () => {
expect(
parseReflectedClauses("created_by = 'a@x.io'").createdBy,
).toStrictEqual(['a@x.io']);
});
it('reflects a created_by IN list', () => {
expect(
parseReflectedClauses("created_by IN ['a@x.io','b@x.io']").createdBy,
).toStrictEqual(['a@x.io', 'b@x.io']);
});
it('does not reflect a non =/IN created_by operator', () => {
expect(
parseReflectedClauses("created_by != 'a@x.io'").createdBy,
).toStrictEqual([]);
});
it('does not reflect a nested (parenthesised) clause', () => {
expect(
parseReflectedClauses("(created_by = 'a@x.io') AND name = 'x'").createdBy,
).toStrictEqual([]);
});
it('does not reflect a duplicated key', () => {
expect(
parseReflectedClauses("created_by = 'a@x.io' AND created_by = 'b@x.io'")
.createdBy,
).toStrictEqual([]);
});
it('reflects a recent updated_at >= as the nearest window', () => {
const iso = dayjs().subtract(7, 'day').toISOString();
expect(parseReflectedClauses(`updated_at >= '${iso}'`).updated).toBe('7d');
});
it('falls back to any for an unrecognised updated_at cutoff', () => {
const iso = dayjs().subtract(400, 'day').toISOString();
expect(parseReflectedClauses(`updated_at >= '${iso}'`).updated).toBe('any');
});
});
describe('spliceClause', () => {
it('appends a clause when the key is absent', () => {
expect(
spliceClause("name = 'x'", 'created_by', "created_by = 'a@x.io'"),
).toBe("name = 'x' AND created_by = 'a@x.io'");
});
it('replaces an existing top-level clause for the key', () => {
expect(
spliceClause(
"created_by = 'old' AND name = 'x'",
'created_by',
"created_by = 'new'",
),
).toBe("created_by = 'new' AND name = 'x'");
});
it('removes the clause when passed null', () => {
expect(
spliceClause("created_by = 'a@x.io' AND name = 'x'", 'created_by', null),
).toBe("name = 'x'");
});
it('leaves a nested clause untouched and appends instead', () => {
expect(
spliceClause(
"(created_by = 'a') AND name = 'x'",
'created_by',
"created_by = 'b'",
),
).toBe("(created_by = 'a') AND name = 'x' AND created_by = 'b'");
});
it('is a no-op removing an absent key', () => {
expect(spliceClause("name = 'x'", 'updated_at', null)).toBe("name = 'x'");
});
});
describe('updatedWindowFromTimestamp', () => {
it('maps a ~1 day cutoff to today', () => {
expect(
updatedWindowFromTimestamp(dayjs().subtract(1, 'day').toISOString()),
).toBe('today');
});
it('returns null for an unquoted invalid value', () => {
expect(updatedWindowFromTimestamp('not-a-date')).toBeNull();
});
});
describe('query helpers', () => {
it('isQueryEmpty ignores whitespace', () => {
expect(isQueryEmpty(' ')).toBe(true);
expect(isQueryEmpty("name = 'x'")).toBe(false);
});
it('areQueriesEqual trims before comparing', () => {
expect(areQueriesEqual("name = 'x' ", " name = 'x'")).toBe(true);
});
});

View File

@@ -5,9 +5,9 @@
// - client: constrains by a client-side id set (Favorites, Recently viewed)
import { Clock, Layers, Lock, Pin, User } from '@signozhq/icons';
import { createdByClause } from './filterQuery';
import { DEFAULT_FILTER_STATE } from './filterQuery';
import { BuiltinViewId } from '../types';
import type { ViewSection } from '../types';
import type { DashboardFilterState, ViewSection } from '../types';
import type { DashboardListItem } from './helpers';
// All @signozhq icons share this component type.
@@ -49,25 +49,28 @@ export const BUILTIN_VIEWS: BuiltinView[] = [
export const isClientView = (id: string): boolean =>
id === BuiltinViewId.Pinned || id === BuiltinViewId.Recent;
// DSL the Locked view applies — visible and editable in the query box rather
// than applied invisibly behind the scenes.
// DSL the Locked view seeds into the search box, so the constraint is visible
// (and editable) rather than applied invisibly behind the scenes.
export const LOCKED_QUERY = 'locked = true';
// The canonical query string a built-in view applies when selected. `null` for
// ids that aren't built-in (custom views carry their own query).
export const builtinViewQuery = (
// The canonical filter snapshot a built-in view applies when selected. `null`
// for ids that aren't built-in (custom views carry their own snapshot).
export const builtinViewSnapshot = (
id: string,
userEmail: string,
): string | null => {
): DashboardFilterState | null => {
switch (id) {
case BuiltinViewId.Mine:
return userEmail ? (createdByClause([userEmail]) ?? '') : '';
return {
...DEFAULT_FILTER_STATE,
createdBy: userEmail ? [userEmail] : [],
};
case BuiltinViewId.Locked:
return LOCKED_QUERY;
return { ...DEFAULT_FILTER_STATE, search: LOCKED_QUERY };
case BuiltinViewId.All:
case BuiltinViewId.Pinned:
case BuiltinViewId.Recent:
return '';
return { ...DEFAULT_FILTER_STATE };
default:
return null;
}

View File

@@ -21,6 +21,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceStore } from '../stores/traceStore';
import TraceDownloadPanel from './TraceDownloadPanel';
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
@@ -43,6 +44,7 @@ export interface TraceMetadataForHeader {
rootServiceEntryPoint: string;
rootSpanStatusCode: string;
hasMissingSpans: boolean;
totalSpansCount: number;
}
interface TraceDetailsHeaderProps {
@@ -168,6 +170,10 @@ function TraceDetailsHeader({
showTraceDetails={showTraceDetails}
onToggleTraceDetails={handleToggleTraceDetails}
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
traceId={traceID || ''}
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
totalSpansCount={traceMetadata?.totalSpansCount || 0}
/>
</div>
</TooltipProvider>
@@ -228,6 +234,8 @@ function TraceDetailsHeader({
onClose={(): void => setIsAnalyticsOpen(false)}
onTabChange={handleAnalyticsTabChange}
/>
<TraceDownloadPanel />
</div>
);
}

View File

@@ -0,0 +1,42 @@
.downloadPanel {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-4);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.title {
flex: 1;
display: flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--font-weight-medium);
color: var(--secondary-foreground);
white-space: nowrap;
}
.loader {
color: var(--bg-robin-500);
flex-shrink: 0;
}
.percent {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--font-weight-semibold);
color: var(--l2-foreground);
flex-shrink: 0;
}
.cancelBtn {
height: 24px;
width: 24px;
flex-shrink: 0;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,58 @@
import { useEffect } from 'react';
import { Button } from '@signozhq/ui/button';
import { Progress } from '@signozhq/ui/progress';
import { LoaderCircle, X } from '@signozhq/icons';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import { useTraceDownloadStore } from './traceDownloadStore';
import styles from './TraceDownloadPanel.module.scss';
const PANEL_WIDTH = 356;
const PANEL_HEIGHT = 76;
function TraceDownloadPanel(): JSX.Element {
const isDownloading = useTraceDownloadStore((s) => s.isDownloading);
const progress = useTraceDownloadStore((s) => s.progress);
const cancelDownload = useTraceDownloadStore((s) => s.cancelDownload);
useEffect(() => cancelDownload, [cancelDownload]);
const displayProgress = Math.max(1, progress);
return (
<FloatingPanel
isOpen={isDownloading}
width={PANEL_WIDTH}
height={PANEL_HEIGHT}
minWidth={PANEL_WIDTH}
minHeight={PANEL_HEIGHT}
enableResizing={false}
>
<div className={styles.downloadPanel} data-testid="trace-download-panel">
<div className={`${styles.header} floating-panel__drag-handle`}>
<span className={styles.title}>
Downloading trace
<LoaderCircle size={14} className={`animate-spin ${styles.loader}`} />
</span>
<span className={styles.percent} data-testid="trace-download-percent">
{displayProgress}%
</span>
<Button
variant="ghost"
size="icon"
color="secondary"
className={styles.cancelBtn}
onClick={cancelDownload}
aria-label="Cancel download"
data-testid="trace-download-cancel"
prefix={<X size={16} />}
/>
</div>
<Progress percent={displayProgress} status="active" showInfo={false} />
</div>
</FloatingPanel>
);
}
export default TraceDownloadPanel;

View File

@@ -12,8 +12,10 @@ import {
DropdownMenuTrigger,
} from '@signozhq/ui/dropdown-menu';
import { Settings2 } from '@signozhq/icons';
import { ExportFormat } from 'lib/exportData/types';
import { useTraceStore } from '../stores/traceStore';
import { useDownloadTrace } from './useDownloadTrace';
import styles from './TraceOptionsMenu.module.scss';
@@ -21,6 +23,10 @@ interface TraceOptionsMenuProps {
showTraceDetails: boolean;
onToggleTraceDetails: () => void;
onOpenPreviewFields: () => void;
traceId: string;
startTime: number;
endTime: number;
totalSpansCount: number;
}
// Composed from dropdown-menu primitives (instead of DropdownMenuSimple)
@@ -30,6 +36,10 @@ function TraceOptionsMenu({
showTraceDetails,
onToggleTraceDetails,
onOpenPreviewFields,
traceId,
startTime,
endTime,
totalSpansCount,
}: TraceOptionsMenuProps): JSX.Element {
const colorByField = useTraceStore((s) => s.colorByField);
const setColorByField = useTraceStore((s) => s.setColorByField);
@@ -37,6 +47,13 @@ function TraceOptionsMenu({
(s) => s.availableColorByOptions,
);
const { isDownloading, isExportDisabled, downloadTrace } = useDownloadTrace({
traceId,
startTime,
endTime,
totalSpansCount,
});
const handleColorByChange = (name: string): void => {
const next = availableColorByOptions.find((o) => o.field.name === name);
if (next) {
@@ -81,6 +98,32 @@ function TraceOptionsMenu({
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{!isExportDisabled && (
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={isDownloading}
data-testid="download-trace-submenu"
>
Download trace
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={styles.traceOptionsDropdown}>
<DropdownMenuItem
clickable
onSelect={(): void => downloadTrace(ExportFormat.Csv)}
testId="download-trace-csv"
>
CSV
</DropdownMenuItem>
<DropdownMenuItem
clickable
onSelect={(): void => downloadTrace(ExportFormat.Jsonl)}
testId="download-trace-jsonl"
>
JSONL
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -141,6 +141,7 @@ describe('TraceDetailsHeader trace metadata row', () => {
rootServiceEntryPoint: 'large-trace-root',
rootSpanStatusCode: '404',
hasMissingSpans: false,
totalSpansCount: 42,
};
it('renders the metadata (service, entry point, duration, status) when provided', () => {

View File

@@ -0,0 +1,228 @@
import 'tests/blob-polyfill';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { fetchExportData } from 'api/v1/download/downloadExportData';
import { downloadFile } from 'lib/exportData/downloadFile';
import { render } from 'tests/test-utils';
import TraceDownloadPanel from '../TraceDownloadPanel';
import TraceOptionsMenu from '../TraceOptionsMenu';
import { MAX_EXPORT_SPANS } from '../useDownloadTrace';
// Integration suite: menu → hook → store → runner → stitchers → panel all run
// for real; only the true boundaries are mocked (HTTP + file save).
jest.mock('api/v1/download/downloadExportData', () => ({
fetchExportData: jest.fn(),
}));
jest.mock('lib/exportData/downloadFile', () => ({
downloadFile: jest.fn(),
getTimestampedFileName: jest.fn(
(base: string, ext: string) => `${base}.${ext}`,
),
}));
const mockFetch = fetchExportData as jest.Mock;
const mockDownloadFile = downloadFile as jest.Mock;
const baseProps = {
showTraceDetails: true,
onToggleTraceDetails: jest.fn(),
onOpenPreviewFields: jest.fn(),
traceId: 'trace-123',
startTime: 1_000,
endTime: 2_000,
// 120k spans → 3 export pages of 50k.
totalSpansCount: 120_000,
};
function renderFeature(
props: Partial<typeof baseProps> = {},
): ReturnType<typeof render> {
return render(
<>
<TraceOptionsMenu {...baseProps} {...props} />
<TraceDownloadPanel />
</>,
);
}
// skipHover: simulated pointer travel fires pointerleave on the subtrigger and
// radix's grace-area math (zero rects in jsdom) closes the submenu.
// pointerEventsCheck 0: radix puts pointer-events:none on <body> while open.
function setupUser(): ReturnType<typeof userEvent.setup> {
return userEvent.setup({
delay: null,
skipHover: true,
pointerEventsCheck: 0,
});
}
async function startDownload(
format: 'csv' | 'jsonl' = 'csv',
): Promise<ReturnType<typeof userEvent.setup>> {
const user = setupUser();
await user.click(screen.getByRole('button', { name: /trace options/i }));
await user.click(await screen.findByTestId('download-trace-submenu'));
await user.click(await screen.findByTestId(`download-trace-${format}`));
return user;
}
async function savedFileText(): Promise<string> {
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
const [blob] = mockDownloadFile.mock.calls[0];
return (blob as Blob).text();
}
describe('trace download flow', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('downloads a multi-page trace as one stitched CSV', async () => {
mockFetch.mockImplementation(({ body }) =>
Promise.resolve(
new Blob([`h1,h2\nrow-${body.compositeQuery.queries[0].spec.offset}\n`]),
),
);
renderFeature();
await startDownload('csv');
await waitFor(() => expect(mockDownloadFile).toHaveBeenCalled());
// Real query building: trace scoping, page offsets, ±5min window (seconds→ms).
const bodies = mockFetch.mock.calls.map(([props]) => props.body);
const specs = bodies.map((b) => b.compositeQuery.queries[0].spec);
expect(specs[0].filter.expression).toBe("trace_id = 'trace-123'");
expect(specs.map((s) => s.offset).sort((a, b) => a - b)).toStrictEqual([
0, 50_000, 100_000,
]);
expect(bodies[0].start).toBe((baseProps.startTime - 300) * 1e3);
expect(bodies[0].end).toBe((baseProps.endTime + 300) * 1e3);
// Stitched in page order, headers deduped, saved under the trace's name.
await expect(savedFileText()).resolves.toBe(
'h1,h2\nrow-0\nrow-50000\nrow-100000\n',
);
expect(mockDownloadFile.mock.calls[0][1]).toBe('trace-trace-123.csv');
// Panel dismissed once the download completed.
await waitFor(() =>
expect(screen.queryByTestId('trace-download-panel')).not.toBeInTheDocument(),
);
});
it('downloads JSONL when that format is chosen', async () => {
mockFetch.mockResolvedValue(new Blob(['{"a":1}\n']));
renderFeature({ totalSpansCount: 10 });
await startDownload('jsonl');
await waitFor(() => expect(mockDownloadFile).toHaveBeenCalled());
expect(mockFetch.mock.calls[0][0].format).toBe('jsonl');
expect(mockDownloadFile.mock.calls[0][1]).toBe('trace-trace-123.jsonl');
});
it('shows the progress panel while downloading and cancels from its ✕', async () => {
// Fetches hang until aborted — the download stays in flight.
mockFetch.mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
);
}),
);
renderFeature();
const user = await startDownload();
// Panel appears with the 1% floor (no part finished yet).
const panel = await screen.findByTestId('trace-download-panel');
expect(panel).toBeInTheDocument();
expect(screen.getByTestId('trace-download-percent')).toHaveTextContent('1%');
await user.click(screen.getByTestId('trace-download-cancel'));
await waitFor(() =>
expect(screen.queryByTestId('trace-download-panel')).not.toBeInTheDocument(),
);
expect(mockDownloadFile).not.toHaveBeenCalled();
});
it('disables the submenu trigger while a download is running', async () => {
mockFetch.mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
);
}),
);
renderFeature();
const user = await startDownload();
// Reopen the menu mid-download: trigger is disabled by REAL store state.
await user.click(screen.getByRole('button', { name: /trace options/i }));
const trigger = await screen.findByTestId('download-trace-submenu');
expect(trigger).toHaveAttribute('data-disabled');
// Cleanup: stop the in-flight download so the module-scoped guard resets.
await user.click(screen.getByTestId('trace-download-cancel'));
await waitFor(() =>
expect(screen.queryByTestId('trace-download-panel')).not.toBeInTheDocument(),
);
});
it('hides the download option entirely above the export cap', async () => {
renderFeature({ totalSpansCount: MAX_EXPORT_SPANS + 1 });
const user = setupUser();
await user.click(screen.getByRole('button', { name: /trace options/i }));
await expect(
screen.findByRole('menuitem', { name: /preview fields/i }),
).resolves.toBeInTheDocument();
expect(
screen.queryByTestId('download-trace-submenu'),
).not.toBeInTheDocument();
});
it('saves nothing and dismisses the panel when a page fails', async () => {
mockFetch.mockRejectedValue(new Error('boom'));
renderFeature();
await startDownload();
await waitFor(() =>
expect(screen.queryByTestId('trace-download-panel')).not.toBeInTheDocument(),
);
expect(mockDownloadFile).not.toHaveBeenCalled();
});
it('cancels the in-flight download when the panel unmounts (leaving the page)', async () => {
const seenSignals: AbortSignal[] = [];
mockFetch.mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
seenSignals.push(signal);
signal.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
);
}),
);
const { unmount } = renderFeature();
await startDownload();
await screen.findByTestId('trace-download-panel');
unmount();
await waitFor(() =>
expect(seenSignals.every((signal) => signal.aborted)).toBe(true),
);
expect(mockDownloadFile).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,63 @@
import {
ChunkedExportArgs,
runChunkedExport,
} from 'hooks/useExportData/runChunkedExport';
import { ExportFormat } from 'lib/exportData/types';
import { create } from 'zustand';
/** 'skipped' = a download was already in flight; the call was ignored. */
export type TraceDownloadOutcome = 'completed' | 'cancelled' | 'skipped';
interface TraceDownloadState {
isDownloading: boolean;
// 0100
progress: number;
startDownload: (
args: ChunkedExportArgs & { format: ExportFormat },
) => Promise<TraceDownloadOutcome>;
cancelDownload: () => void;
}
let abortController: AbortController | null = null;
export const useTraceDownloadStore = create<TraceDownloadState>((set) => ({
isDownloading: false,
progress: 0,
startDownload: async ({
format,
...args
}: ChunkedExportArgs & {
format: ExportFormat;
}): Promise<TraceDownloadOutcome> => {
if (abortController) {
return 'skipped';
}
const controller = new AbortController();
abortController = controller;
set({ isDownloading: true, progress: 0 });
try {
await runChunkedExport({
...args,
format,
signal: controller.signal,
onProgress: (progress: number): void => set({ progress }),
});
return 'completed';
} catch (error) {
if (controller.signal.aborted) {
return 'cancelled';
}
throw error;
} finally {
abortController = null;
set({ isDownloading: false });
}
},
cancelDownload: (): void => {
abortController?.abort();
},
}));

View File

@@ -0,0 +1,23 @@
import { listViewInitialTraceQuery } from 'constants/queryBuilder';
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export function getTraceExportQuery(traceId: string): Query {
const [baseQueryData] = listViewInitialTraceQuery.builder.queryData;
return {
...listViewInitialTraceQuery,
builder: {
...listViewInitialTraceQuery.builder,
queryData: [
{
...baseQueryData,
aggregateOperator: LogsAggregatorOperator.NOOP,
filter: { expression: `trace_id = '${traceId}'` },
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
selectColumns: [],
},
],
},
};
}

View File

@@ -0,0 +1,92 @@
import { useCallback, useMemo } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { ExportFormat } from 'lib/exportData/types';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceDownloadStore } from './traceDownloadStore';
import { getTraceExportQuery } from './traceExportQuery';
// Export window pad past the trace bounds so edge spans are never clipped
// (mirrors SpanDetailsPanel).
const FIVE_MINUTES_IN_SECONDS = 5 * 60;
// Span-count cap: traces above it hide their download option entirely.
export const MAX_EXPORT_SPANS = 500_000;
interface UseDownloadTraceProps {
traceId: string;
// Trace start/end in seconds.
startTime: number;
endTime: number;
totalSpansCount: number;
}
interface UseDownloadTraceReturn {
isDownloading: boolean;
isExportDisabled: boolean;
downloadTrace: (format: ExportFormat) => void;
}
export function useDownloadTrace({
traceId,
startTime,
endTime,
totalSpansCount,
}: UseDownloadTraceProps): UseDownloadTraceReturn {
const isDownloading = useTraceDownloadStore((s) => s.isDownloading);
const startDownload = useTraceDownloadStore((s) => s.startDownload);
const logTraceEvent = useTraceDetailLogEvent('v3', traceId);
const query = useMemo(() => getTraceExportQuery(traceId), [traceId]);
const timeRange = useMemo(
() => ({
start: startTime - FIVE_MINUTES_IN_SECONDS,
end: endTime + FIVE_MINUTES_IN_SECONDS,
}),
[startTime, endTime],
);
const downloadTrace = useCallback(
(format: ExportFormat): void => {
logTraceEvent(TraceDetailEvents.DownloadTriggered, {
[TraceDetailEventKeys.Format]: format,
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
});
const run = async (): Promise<void> => {
try {
const outcome = await startDownload({
query,
timeRange,
totalRows: totalSpansCount,
fileNameBase: `trace-${traceId}`,
format,
});
if (outcome === 'completed') {
toast.success('Export completed successfully');
} else if (outcome === 'cancelled') {
logTraceEvent(TraceDetailEvents.DownloadCancelled, {
[TraceDetailEventKeys.Format]: format,
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
});
toast.info('Export cancelled');
}
} catch (error) {
toast.error('Failed to download trace');
console.error(error);
}
};
void run();
},
[query, timeRange, totalSpansCount, traceId, startDownload, logTraceEvent],
);
return {
isDownloading,
isExportDisabled: totalSpansCount > MAX_EXPORT_SPANS,
downloadTrace,
};
}

View File

@@ -6,6 +6,8 @@ export enum TraceDetailEvents {
AnalyticsPanelToggled = 'Trace Detail: Analytics panel toggled',
AnalyticsTabChanged = 'Trace Detail: Analytics tab changed',
SpanPanelTabChanged = 'Trace Detail: Span panel tab changed',
DownloadTriggered = 'Trace Detail: Download triggered',
DownloadCancelled = 'Trace Detail: Download cancelled',
}
export enum TraceDetailEventKeys {
@@ -32,6 +34,8 @@ export enum TraceDetailEventKeys {
Tab = 'tab',
// Span panel tab changed
SpanId = 'spanId',
// Download triggered (reuses TotalSpansCount for trace size)
Format = 'format',
}
export type TraceDetailView = 'v2' | 'v3';

View File

@@ -346,6 +346,7 @@ function TraceDetailsV3(): JSX.Element {
rootServiceEntryPoint: payload.rootServiceEntryPoint,
rootSpanStatusCode: rootSpan?.response_status_code || '',
hasMissingSpans: payload.hasMissingSpans || false,
totalSpansCount: payload.totalSpansCount || 0,
};
}, [traceData?.payload]);

View File

@@ -0,0 +1,34 @@
/**
* jsdom's Blob lacks the modern read methods (arrayBuffer/text). Polyfill via
* FileReader (which jsdom does implement); guarded, so it no-ops once jsdom
* catches up. Side-effect module — import it at the top of any test touching
* Blob contents:
*
* import 'tests/blob-polyfill';
*/
if (typeof Blob.prototype.arrayBuffer === 'undefined') {
Blob.prototype.arrayBuffer = function arrayBuffer(
this: Blob,
): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (): void => resolve(reader.result as ArrayBuffer);
reader.onerror = (): void => reject(reader.error);
reader.readAsArrayBuffer(this);
});
};
}
if (typeof Blob.prototype.text === 'undefined') {
Blob.prototype.text = function text(this: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (): void => resolve(reader.result as string);
reader.onerror = (): void => reject(reader.error);
reader.readAsText(this);
});
};
}
export {};

View File

@@ -1,8 +1,6 @@
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
// Mapping between panel types and explorer views
export const panelTypeToExplorerView: Record<PANEL_TYPES, ExplorerViews> = {
@@ -52,36 +50,3 @@ export const getExplorerViewFromUrl = (
export const getExplorerViewForPanelType = (
panelType: PANEL_TYPES,
): ExplorerViews => panelTypeToExplorerView[panelType];
export interface MetricsExplorerUrlParams {
query: Query;
relativeTime?: string;
startTimeMs?: number;
endTimeMs?: number;
}
export const getMetricsExplorerUrl = ({
query,
relativeTime,
startTimeMs,
endTimeMs,
}: MetricsExplorerUrlParams): string => {
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
if (relativeTime) {
params.set(QueryParams.relativeTime, relativeTime);
} else {
if (startTimeMs !== undefined) {
params.set(QueryParams.startTime, String(startTimeMs));
}
if (endTimeMs !== undefined) {
params.set(QueryParams.endTime, String(endTimeMs));
}
}
return `${ROUTES.METRICS_EXPLORER_EXPLORER}?${params.toString()}`;
};

View File

@@ -47,7 +47,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
Description: "This endpoint lists all roles",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.GettableRole, 0),
Response: make([]*authtypes.Role, 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.Role),
Response: new(authtypes.RoleWithTransactionGroups),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},

View File

@@ -30,14 +30,17 @@ type AuthZ interface {
// Write accepts the insertion tuples and the deletion tuples.
Write(context.Context, []*openfgav1.TupleKey, []*openfgav1.TupleKey) error
// ReadTuples reads tuples from the authorization server matching the given tuple key filter.
ReadTuples(context.Context, *openfgav1.ReadRequestTupleKey) ([]*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)
// Creates the role with its transaction groups.
Create(context.Context, valuer.UUID, *authtypes.Role) error
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)
// Updates the role's metadata and reconciles its transaction groups.
Update(context.Context, valuer.UUID, *authtypes.Role) error
Update(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
// Deletes the role and tuples in authorization server.
Delete(context.Context, valuer.UUID, valuer.UUID) error
@@ -45,6 +48,9 @@ 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)
@@ -74,6 +80,9 @@ 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.

View File

@@ -83,6 +83,10 @@ 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)
}
@@ -177,11 +181,15 @@ 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.Role) error {
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.RoleWithTransactionGroups) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) Update(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
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 {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}

View File

@@ -36,14 +36,14 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
return
}
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)
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)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, types.Identifiable{ID: role.ID})
render.Success(rw, http.StatusCreated, types.Identifiable{ID: roleWithTransactionGroups.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
}
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
roleWithTransactionGroups, err := handler.authz.GetWithTransactionGroups(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, role)
render.Success(rw, http.StatusOK, roleWithTransactionGroups)
}
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, authtypes.NewGettableRolesFromRoles(roles))
render.Success(rw, http.StatusOK, roles)
}
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
@@ -117,13 +117,14 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
return
}
err = role.Update(req.Description, req.TransactionGroups)
roleWithTransactionGroups := authtypes.MakeRoleWithTransactionGroups(role, nil)
err = roleWithTransactionGroups.Update(req.Description, req.TransactionGroups)
if err != nil {
render.Error(rw, err)
return
}
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), role)
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), roleWithTransactionGroups)
if err != nil {
render.Error(rw, err)
return

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}</style></defs><title>Icon_24px_ComputeEngine_Color</title><g data-name="Product Icons"><rect class="cls-1" x="9" y="9" width="6" height="6"/><rect class="cls-2" x="11" y="2" width="2" height="4"/><rect class="cls-2" x="7" y="2" width="2" height="4"/><rect class="cls-2" x="15" y="2" width="2" height="4"/><rect class="cls-3" x="11" y="18" width="2" height="4"/><rect class="cls-3" x="7" y="18" width="2" height="4"/><rect class="cls-3" x="15" y="18" width="2" height="4"/><rect class="cls-3" x="19" y="10" width="2" height="4" transform="translate(8 32) rotate(-90)"/><rect class="cls-3" x="19" y="14" width="2" height="4" transform="translate(4 36) rotate(-90)"/><rect class="cls-3" x="19" y="6" width="2" height="4" transform="translate(12 28) rotate(-90)"/><rect class="cls-2" x="3" y="10" width="2" height="4" transform="translate(-8 16) rotate(-90)"/><rect class="cls-2" x="3" y="14" width="2" height="4" transform="translate(-12 20) rotate(-90)"/><rect class="cls-2" x="3" y="6" width="2" height="4" transform="translate(-4 12) rotate(-90)"/><path class="cls-1" d="M5,5V19H19V5ZM17,17H7V7H17Z"/><polygon class="cls-2" points="9 15 15 15 12 12 9 15"/><polygon class="cls-3" points="12 12 15 15 15 9 12 12"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,124 +0,0 @@
{
"id": "computeengine",
"title": "GCP Compute Engine",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "compute.googleapis.com/instance/uptime_total",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "compute.googleapis.com/instance/cpu/utilization",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "compute.googleapis.com/guest/memory/bytes_used",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "compute.googleapis.com/guest/memory/percent_used",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "agent.googleapis.com/memory/percent_used",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "compute.googleapis.com/instance/disk/average_io_latency",
"unit": "Microseconds",
"type": "Gauge",
"description": ""
},
{
"name": "compute.googleapis.com/instance/disk/read_bytes_count",
"unit": "Bytes",
"type": "Counter",
"description": ""
},
{
"name": "compute.googleapis.com/instance/disk/write_bytes_count",
"unit": "Bytes",
"type": "Counter",
"description": ""
},
{
"name": "compute.googleapis.com/instance/disk/read_ops_count",
"unit": "Count",
"type": "Counter",
"description": ""
},
{
"name": "compute.googleapis.com/instance/disk/write_ops_count",
"unit": "Count",
"type": "Counter",
"description": ""
},
{
"name": "compute.googleapis.com/instance/disk/performance_status",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "compute.googleapis.com/instance/network/received_bytes_count",
"unit": "Bytes",
"type": "Counter",
"description": ""
},
{
"name": "compute.googleapis.com/instance/network/sent_bytes_count",
"unit": "Bytes",
"type": "Counter",
"description": ""
},
{
"name": "compute.googleapis.com/instance/network/received_packets_count",
"unit": "Count",
"type": "Counter",
"description": ""
},
{
"name": "compute.googleapis.com/instance/network/sent_packets_count",
"unit": "Count",
"type": "Counter",
"description": ""
},
{
"name": "compute.googleapis.com/instance/firewall/dropped_packets_count",
"unit": "Count",
"type": "Counter",
"description": ""
}
],
"logs": []
},
"telemetryCollectionStrategy": {
"gcp": {}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "GCP Compute Engine Overview",
"description": "Overview of GCP Compute Engine metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,3 +0,0 @@
### Monitor GCP Memorystore Redis with SigNoz
Collect key GCP Memorystore Redis metrics and view them with an out of the box dashboard.

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