mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-13 18:10:32 +01:00
Compare commits
17 Commits
issue-4293
...
platform-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
422c9588c4 | ||
|
|
5c6e8b16f9 | ||
|
|
168658b4c3 | ||
|
|
6975c4d90c | ||
|
|
9bfa6e6d59 | ||
|
|
86313ae561 | ||
|
|
f764a8d9af | ||
|
|
3e971a902a | ||
|
|
e0a0f49fb4 | ||
|
|
03abb3ca90 | ||
|
|
e4f9daf7d2 | ||
|
|
9be93a308a | ||
|
|
d1a06a91bf | ||
|
|
042e1fb4f0 | ||
|
|
1798abfc6b | ||
|
|
f155f71051 | ||
|
|
4559a78752 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -189,7 +189,9 @@ 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
|
||||
|
||||
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -58,7 +58,6 @@ jobs:
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
- querier_json_body
|
||||
- promqlparity
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
sqlstore-provider:
|
||||
|
||||
@@ -543,6 +543,31 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
AuthtypesGettableRole:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- description
|
||||
- type
|
||||
- orgId
|
||||
type: object
|
||||
AuthtypesGettableToken:
|
||||
properties:
|
||||
accessToken:
|
||||
@@ -694,43 +719,6 @@ components:
|
||||
- detach
|
||||
type: string
|
||||
AuthtypesRole:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- description
|
||||
- type
|
||||
- orgId
|
||||
type: object
|
||||
AuthtypesRoleMapping:
|
||||
properties:
|
||||
defaultRole:
|
||||
type: string
|
||||
groupMappings:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
useRoleAttribute:
|
||||
type: boolean
|
||||
type: object
|
||||
AuthtypesRoleWithTransactionGroups:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
@@ -758,6 +746,18 @@ components:
|
||||
- orgId
|
||||
- transactionGroups
|
||||
type: object
|
||||
AuthtypesRoleMapping:
|
||||
properties:
|
||||
defaultRole:
|
||||
type: string
|
||||
groupMappings:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
useRoleAttribute:
|
||||
type: boolean
|
||||
type: object
|
||||
AuthtypesSamlConfig:
|
||||
properties:
|
||||
attributeMapping:
|
||||
@@ -1494,7 +1494,7 @@ components:
|
||||
- cosmosdb
|
||||
- cassandradb
|
||||
- redis
|
||||
- cloudsql
|
||||
- cloudsql_postgres
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
@@ -12022,7 +12022,7 @@ paths:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
$ref: '#/components/schemas/AuthtypesGettableRole'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
@@ -12206,7 +12206,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -24405,9 +24405,17 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Query range
|
||||
tags:
|
||||
- querier
|
||||
@@ -24474,9 +24482,17 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Query range preview
|
||||
tags:
|
||||
- querier
|
||||
|
||||
@@ -119,10 +119,6 @@ func (provider *provider) CheckTransactions(ctx context.Context, subject string,
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
|
||||
return provider.openfgaServer.ListObjects(ctx, subject, relation, objectType)
|
||||
}
|
||||
|
||||
func (provider *provider) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
|
||||
return provider.openfgaServer.Write(ctx, additions, deletions)
|
||||
}
|
||||
@@ -131,10 +127,6 @@ func (provider *provider) ReadTuples(ctx context.Context, tupleKey *openfgav1.Re
|
||||
return provider.openfgaServer.ReadTuples(ctx, tupleKey)
|
||||
}
|
||||
|
||||
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
|
||||
return provider.pkgAuthzService.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
|
||||
return provider.pkgAuthzService.GetByOrgIDAndName(ctx, orgID, name)
|
||||
}
|
||||
@@ -183,7 +175,7 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
|
||||
return provider.Write(ctx, tuples, nil)
|
||||
}
|
||||
|
||||
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.RoleWithTransactionGroups) error {
|
||||
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
@@ -208,86 +200,42 @@ func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *a
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.store.Create(ctx, role.Role); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return provider.store.Create(ctx, role)
|
||||
}
|
||||
|
||||
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) (*authtypes.Role, error) {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
existingRole, err := provider.store.GetByOrgIDAndName(ctx, role.OrgID, role.Name)
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if existingRole != nil {
|
||||
return existingRole, nil
|
||||
}
|
||||
|
||||
err = provider.store.Create(ctx, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return role, nil
|
||||
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
|
||||
return provider.store.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
role, err := provider.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transactionGroups := authtypes.MustNewTransactionGroupsFromTuples(tuples)
|
||||
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
|
||||
}
|
||||
|
||||
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
|
||||
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.Role) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
existingRole, err := provider.GetWithTransactionGroups(ctx, orgID, updatedRole.ID)
|
||||
existingRole, err := provider.Get(ctx, orgID, updatedRole.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
additions, deletions := existingRole.TransactionGroups.Diff(updatedRole.TransactionGroups)
|
||||
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
|
||||
existingTuples, err := provider.readAllTuplesForRole(ctx, existingRole.Name, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, deletions)
|
||||
desiredTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, updatedRole.TransactionGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
additionTuples, deletionTuples := authtypes.DiffTuples(existingTuples, desiredTuples)
|
||||
|
||||
err = provider.Write(ctx, additionTuples, deletionTuples)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return provider.store.Update(ctx, orgID, updatedRole.Role)
|
||||
return provider.store.Update(ctx, orgID, updatedRole)
|
||||
}
|
||||
|
||||
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
@@ -296,7 +244,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
role, err := provider.GetWithTransactionGroups(ctx, orgID, id)
|
||||
role, err := provider.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -312,7 +260,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
}
|
||||
}
|
||||
|
||||
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
|
||||
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -324,6 +272,24 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
return provider.store.Delete(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
|
||||
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
|
||||
|
||||
tuples := make([]*openfgav1.TupleKey, 0)
|
||||
for _, objectType := range provider.registry.Types() {
|
||||
typeTuples, err := provider.openfgaServer.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
|
||||
User: subject,
|
||||
Object: objectType.StringValue() + ":",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tuples = append(tuples, typeTuples...)
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) []*openfgav1.TupleKey {
|
||||
tuples := []*openfgav1.TupleKey{}
|
||||
|
||||
@@ -375,21 +341,3 @@ func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*
|
||||
|
||||
return tuples
|
||||
}
|
||||
|
||||
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
|
||||
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
|
||||
|
||||
tuples := make([]*openfgav1.TupleKey, 0)
|
||||
for _, objectType := range provider.registry.Types() {
|
||||
typeTuples, err := provider.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
|
||||
User: subject,
|
||||
Object: objectType.StringValue() + ":",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tuples = append(tuples, typeTuples...)
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
@@ -105,10 +105,6 @@ func (server *Server) BatchCheck(ctx context.Context, tupleReq map[string]*openf
|
||||
return server.pkgAuthzService.BatchCheck(ctx, tupleReq)
|
||||
}
|
||||
|
||||
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
|
||||
return server.pkgAuthzService.ListObjects(ctx, subject, relation, objectType)
|
||||
}
|
||||
|
||||
func (server *Server) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
|
||||
return server.pkgAuthzService.Write(ctx, additions, deletions)
|
||||
}
|
||||
|
||||
88
frontend/__mocks__/motionMock.tsx
Normal file
88
frontend/__mocks__/motionMock.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
import * as React from 'react';
|
||||
|
||||
// In jsdom, AnimatePresence from motion/react keeps children in DOM during exit
|
||||
// animations (awaiting rAF-driven completion that never fully runs in jsdom).
|
||||
// This mock makes AnimatePresence render children immediately and makes motion.*
|
||||
// elements render as their plain HTML equivalents without animation side-effects.
|
||||
//
|
||||
// IMPORTANT: motion component references are cached so React sees a stable
|
||||
// component identity across re-renders and does not enter an infinite remount loop.
|
||||
|
||||
const MOTION_PROPS_TO_STRIP = new Set([
|
||||
'initial',
|
||||
'animate',
|
||||
'exit',
|
||||
'variants',
|
||||
'transition',
|
||||
'whileHover',
|
||||
'whileTap',
|
||||
'whileFocus',
|
||||
'whileInView',
|
||||
'layout',
|
||||
'layoutId',
|
||||
'onAnimationStart',
|
||||
'onAnimationComplete',
|
||||
]);
|
||||
|
||||
const cache = new Map<string, React.ComponentType>();
|
||||
|
||||
function getMotionComponent(tag: string): React.ComponentType {
|
||||
if (!cache.has(tag)) {
|
||||
const Component = React.forwardRef<HTMLElement, Record<string, unknown>>(
|
||||
(props, ref) => {
|
||||
const domProps: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (!MOTION_PROPS_TO_STRIP.has(k)) {
|
||||
domProps[k] = v;
|
||||
}
|
||||
}
|
||||
return React.createElement(tag, { ...domProps, ref });
|
||||
},
|
||||
);
|
||||
Component.displayName = `motion.${tag}`;
|
||||
cache.set(tag, Component as unknown as React.ComponentType);
|
||||
}
|
||||
return cache.get(tag) as React.ComponentType;
|
||||
}
|
||||
|
||||
const motionHandler: ProxyHandler<Record<string, React.ComponentType>> = {
|
||||
get(_target, prop: string) {
|
||||
return getMotionComponent(prop);
|
||||
},
|
||||
};
|
||||
|
||||
export const AnimatePresence: React.FC<{
|
||||
children?: React.ReactNode;
|
||||
mode?: string;
|
||||
}> = ({ children }) => React.createElement(React.Fragment, null, children);
|
||||
|
||||
export const motion = new Proxy(
|
||||
{} as Record<string, React.ComponentType>,
|
||||
motionHandler,
|
||||
);
|
||||
|
||||
export const useAnimation = (): Record<string, unknown> => ({
|
||||
start: (): unknown => Promise.resolve(),
|
||||
stop: (): unknown => undefined,
|
||||
set: (): unknown => undefined,
|
||||
});
|
||||
|
||||
export const useMotionValue = (
|
||||
initial: unknown,
|
||||
): { get: () => unknown; set: () => void } => ({
|
||||
get: (): unknown => initial,
|
||||
set: (): unknown => undefined,
|
||||
});
|
||||
|
||||
export const useTransform = (): { get: () => number } => ({
|
||||
get: (): number => 0,
|
||||
});
|
||||
|
||||
export const useSpring = (v: unknown): unknown => v;
|
||||
|
||||
export const useScroll = (): { scrollY: { get: () => number } } => ({
|
||||
scrollY: { get: (): number => 0 },
|
||||
});
|
||||
|
||||
export default { motion, AnimatePresence };
|
||||
@@ -20,6 +20,7 @@ const config: Config.InitialOptions = {
|
||||
'\\.module\\.mjs$': '<rootDir>/__mocks__/cssMock.ts',
|
||||
'\\.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,
|
||||
|
||||
@@ -2082,6 +2082,39 @@ export interface AuthtypesGettableAuthDomainDTO {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableTokenDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2325,39 +2358,6 @@ export interface AuthtypesPostableUserRoleDTO {
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleWithTransactionGroupsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -2813,7 +2813,7 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
cosmosdb = 'cosmosdb',
|
||||
cassandradb = 'cassandradb',
|
||||
redis = 'redis',
|
||||
cloudsql = 'cloudsql',
|
||||
cloudsql_postgres = 'cloudsql_postgres',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
@@ -10614,7 +10614,7 @@ export type ListRoles200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: AuthtypesRoleDTO[];
|
||||
data: AuthtypesGettableRoleDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -10636,7 +10636,7 @@ export type GetRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetRole200 = {
|
||||
data: AuthtypesRoleWithTransactionGroupsDTO;
|
||||
data: AuthtypesRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -92,6 +92,7 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
width="narrow"
|
||||
className="create-sa-modal"
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
testId="create-service-account-modal"
|
||||
>
|
||||
<div className="create-sa-modal__content">
|
||||
<form
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
waitForElementToBeRemoved,
|
||||
} from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CreateServiceAccountModal from '../CreateServiceAccountModal';
|
||||
|
||||
@@ -89,7 +83,7 @@ describe('CreateServiceAccountModal', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /New Service Account/i }),
|
||||
screen.queryByTestId('create-service-account-modal'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -129,7 +123,7 @@ describe('CreateServiceAccountModal', () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: /New Service Account/i }),
|
||||
screen.getByTestId('create-service-account-modal'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -137,15 +131,14 @@ describe('CreateServiceAccountModal', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: /New Service Account/i,
|
||||
});
|
||||
await screen.findByTestId('create-service-account-modal');
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
await waitForElementToBeRemoved(dialog);
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /New Service Account/i }),
|
||||
).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('create-service-account-modal'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Name is required" after clearing the name field', async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button, Popover, Tooltip } from 'antd';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
|
||||
import { useExportRawData } from 'hooks/useExportData/useServerExport';
|
||||
import { Download, LoaderCircle } from '@signozhq/icons';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useListRoles } from 'api/generated/services/role';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import APIError from 'types/api/error';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
@@ -16,7 +16,7 @@ export interface RoleOption {
|
||||
}
|
||||
|
||||
export function useRoles(): {
|
||||
roles: AuthtypesRoleDTO[];
|
||||
roles: AuthtypesGettableRoleDTO[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: APIError | undefined;
|
||||
@@ -33,7 +33,7 @@ export function useRoles(): {
|
||||
}
|
||||
|
||||
export function getRoleOptions(
|
||||
roles: AuthtypesRoleDTO[],
|
||||
roles: AuthtypesGettableRoleDTO[],
|
||||
valueField: 'id' | 'name',
|
||||
): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
@@ -79,7 +79,7 @@ interface BaseProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
|
||||
roles?: AuthtypesRoleDTO[];
|
||||
roles?: AuthtypesGettableRoleDTO[];
|
||||
loading?: boolean;
|
||||
isError?: boolean;
|
||||
error?: APIError;
|
||||
|
||||
@@ -151,6 +151,7 @@ function AddKeyModal(): JSX.Element {
|
||||
className="add-key-modal"
|
||||
showCloseButton
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
testId="add-key-modal"
|
||||
>
|
||||
{phase === Phase.FORM && (
|
||||
<KeyFormPhase
|
||||
|
||||
@@ -91,6 +91,7 @@ function DeleteAccountModal(): JSX.Element {
|
||||
color="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={handleConfirm}
|
||||
data-testid="confirm-delete-btn"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete
|
||||
@@ -111,6 +112,7 @@ function DeleteAccountModal(): JSX.Element {
|
||||
className="alert-dialog sa-delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
testId="delete-service-account-modal"
|
||||
footer={footer}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -175,6 +175,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
}
|
||||
showCloseButton={!isRevokeConfirmOpen}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
testId="edit-key-modal"
|
||||
footer={
|
||||
isRevokeConfirmOpen ? (
|
||||
<RevokeKeyFooter
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { 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: AuthtypesRoleDTO[];
|
||||
availableRoles: AuthtypesGettableRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
rolesError?: boolean;
|
||||
rolesErrorObj?: APIError | undefined;
|
||||
|
||||
@@ -2,13 +2,7 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
waitForElementToBeRemoved,
|
||||
} from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import AddKeyModal from '../AddKeyModal';
|
||||
|
||||
@@ -97,7 +91,7 @@ describe('AddKeyModal', () => {
|
||||
|
||||
await screen.findByText('snz_abc123xyz456secret');
|
||||
expect(screen.getByText(/Store the key securely/i)).toBeInTheDocument();
|
||||
await screen.findByRole('dialog', { name: /Key Created Successfully/i });
|
||||
expect(screen.getByTestId('add-key-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copy button writes key to clipboard and shows toast.success', async () => {
|
||||
@@ -133,9 +127,11 @@ describe('AddKeyModal', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /Add a New Key/i });
|
||||
await screen.findByTestId('add-key-modal');
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
await waitForElementToBeRemoved(dialog);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('add-key-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,9 +73,7 @@ describe('EditKeyModal (URL-controlled)', () => {
|
||||
it('renders nothing when edit-key param is absent', () => {
|
||||
renderModal(null, { account: 'sa-1' });
|
||||
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('edit-key-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders key data from prop when edit-key param is set', async () => {
|
||||
@@ -102,9 +100,7 @@ describe('EditKeyModal (URL-controlled)', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('edit-key-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,9 +127,7 @@ describe('EditKeyModal (URL-controlled)', () => {
|
||||
expect(latestUrlUpdate.queryString).not.toContain('edit-key=');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('edit-key-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,9 +139,7 @@ describe('EditKeyModal (URL-controlled)', () => {
|
||||
await user.click(screen.getByRole('button', { name: /Revoke Key/i }));
|
||||
|
||||
// Same dialog, now showing revoke confirmation
|
||||
await expect(
|
||||
screen.findByRole('dialog', { name: /Revoke Original Key Name/i }),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByTestId('edit-key-modal')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Revoking this key will permanently invalidate it/i),
|
||||
).toBeInTheDocument();
|
||||
@@ -170,9 +162,7 @@ describe('EditKeyModal (URL-controlled)', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('edit-key-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,21 +222,20 @@ describe('ServiceAccountDrawer', () => {
|
||||
screen.getByRole('button', { name: /Delete Service Account/i }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: /Delete service account CI Bot/i,
|
||||
});
|
||||
expect(dialog).toBeInTheDocument();
|
||||
await screen.findByTestId('delete-service-account-modal');
|
||||
expect(
|
||||
screen.getByTestId('delete-service-account-modal'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /^Delete$/i });
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
await user.click(screen.getByTestId('confirm-delete-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument();
|
||||
});
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(deleteSpy).toHaveBeenCalled();
|
||||
expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('deleted account shows read-only name, no Save button, no Delete button', async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export enum SESSIONSTORAGE {
|
||||
RETRY_LAZY_REFRESHED = 'retry-lazy-refreshed',
|
||||
VIEW_PANEL_HANDOFF = 'view-panel-handoff',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.pageError {
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import styles from './AttributeMappingsTab.module.scss';
|
||||
import MappingsTable from './components/MappingsTable/MappingsTable';
|
||||
import { useAttributeMappingStore } from './hooks/useAttributeMappingStore';
|
||||
|
||||
function AttributeMappingsTab(): JSX.Element {
|
||||
const store = useAttributeMappingStore();
|
||||
|
||||
return (
|
||||
<div data-testid="attribute-mappings-tab">
|
||||
{store.isError ? (
|
||||
<div className={styles.pageError} role="alert">
|
||||
Failed to load mapping groups. Please try again.
|
||||
</div>
|
||||
) : (
|
||||
<MappingsTable store={store} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeMappingsTab;
|
||||
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
SpantypesFieldContextDTO as FieldContext,
|
||||
SpantypesSpanMapperOperationDTO as MapperOperation,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
GROUPS_ENDPOINT,
|
||||
makeGroupsResponse,
|
||||
makeMapper,
|
||||
makeMappersResponse,
|
||||
mappersEndpoint,
|
||||
mockGroups,
|
||||
mockMappers,
|
||||
} from 'container/LLMObservability/AttributeMapping/__tests__/fixtures';
|
||||
import AttributeMappingsTab from '../AttributeMappingsTab';
|
||||
|
||||
function setupGroups(groups = mockGroups): void {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeGroupsResponse(groups))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function setupMappers(mappers = mockMappers, groupId = 'group-1'): void {
|
||||
server.use(
|
||||
rest.get(mappersEndpoint(groupId), (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeMappersResponse(mappers))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function expandGroup(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
groupId = 'group-1',
|
||||
): Promise<void> {
|
||||
await user.click(screen.getByTestId(`group-expand-${groupId}`));
|
||||
}
|
||||
|
||||
describe('AttributeMappingsTab (integration)', () => {
|
||||
beforeEach(() => {
|
||||
// Reset URL state between tests — jsdom shares window.location across a file.
|
||||
window.history.pushState(null, '', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders no error banner on a successful load', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error banner when the groups request fails', async () => {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await expect(screen.findByRole('alert')).resolves.toHaveTextContent(
|
||||
'Failed to load mapping groups. Please try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the empty state when there are no groups', async () => {
|
||||
setupGroups([]);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('mapper-groups-empty'),
|
||||
).resolves.toHaveTextContent('No mapping groups yet.');
|
||||
});
|
||||
|
||||
it('renders each group header row with its name, condition count and status', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
// Condition filters are no longer shown inline as clauses — the header
|
||||
// carries a count instead (the keys surface in the group drawer, later PR).
|
||||
// Group headers are antd Collapse panels, so rows scope to the panel item.
|
||||
// group-1: enabled, with attribute + resource condition keys.
|
||||
const enabledRow = (await screen.findByTestId('group-name-group-1')).closest(
|
||||
'.ant-collapse-item',
|
||||
) as HTMLElement;
|
||||
expect(
|
||||
within(enabledRow).getByTestId('group-name-group-1'),
|
||||
).toHaveTextContent('demo');
|
||||
expect(
|
||||
within(enabledRow).getByTestId('group-condition-count-group-1'),
|
||||
).toHaveTextContent('2 conditions');
|
||||
expect(within(enabledRow).getByTestId('group-enabled-group-1')).toBeChecked();
|
||||
|
||||
// group-2: disabled, with no condition keys.
|
||||
const disabledRow = screen
|
||||
.getByTestId('group-name-group-2')
|
||||
.closest('.ant-collapse-item') as HTMLElement;
|
||||
expect(within(disabledRow).getByText('Tool')).toBeInTheDocument();
|
||||
expect(
|
||||
within(disabledRow).getByTestId('group-condition-count-group-2'),
|
||||
).toHaveTextContent('0 conditions');
|
||||
expect(
|
||||
within(disabledRow).getByTestId('group-enabled-group-2'),
|
||||
).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('renders the group enable state as a read-only switch', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
// The status switch reflects enabled state but is non-interactive in this
|
||||
// read-only listing — editing lands in a later PR.
|
||||
const toggle = await screen.findByTestId('group-enabled-group-1');
|
||||
expect(toggle).toBeChecked();
|
||||
expect(toggle).toBeDisabled();
|
||||
});
|
||||
|
||||
it("reveals a group's mappers on expand and hides them on collapse", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1' })]);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
// The toggle is the antd Collapse header, which owns the expanded state.
|
||||
const header = screen
|
||||
.getByTestId('group-expand-group-1')
|
||||
.closest('.ant-collapse-header') as HTMLElement;
|
||||
expect(header).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
await expandGroup(user);
|
||||
expect(header).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(
|
||||
screen.findByTestId('mapper-target-mapper-1'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await expandGroup(user);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId('mapper-target-mapper-1'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("lazily fetches and renders a group's mappers on first expand", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([
|
||||
makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model', enabled: true }),
|
||||
]);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
// Mappers are not fetched until the row is expanded.
|
||||
expect(
|
||||
screen.queryByTestId('mapper-target-mapper-1'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await expandGroup(user);
|
||||
|
||||
const target = await screen.findByTestId('mapper-target-mapper-1');
|
||||
expect(target).toHaveTextContent('gen_ai.request.model');
|
||||
const mapperRow = target.closest('tr') as HTMLElement;
|
||||
// Sources ordered by priority, highest first (see fixtures).
|
||||
const sources = within(mapperRow).getByTestId('mapper-sources-mapper-1');
|
||||
expect(sources).toHaveTextContent('genai.model');
|
||||
expect(sources).toHaveTextContent('llm.model');
|
||||
// Writes-to field context + enabled status (an inline Switch, not text).
|
||||
expect(within(mapperRow).getByText('attribute')).toBeInTheDocument();
|
||||
expect(
|
||||
within(mapperRow).getByTestId('mapper-enabled-mapper-1'),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
it("renders a mapper's enable state as a read-only switch", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1', enabled: true })]);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
|
||||
// Like the group switch, a mapper's status switch reflects state without
|
||||
// accepting flips in this read-only listing.
|
||||
const toggle = await screen.findByTestId('mapper-enabled-mapper-1');
|
||||
expect(toggle).toBeChecked();
|
||||
expect(toggle).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows the mappers error state when the mappers request fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
server.use(
|
||||
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
|
||||
res(ctx.status(500)),
|
||||
),
|
||||
);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('mappers-error-group-1'),
|
||||
).resolves.toHaveTextContent('Failed to load mappings. Please try again.');
|
||||
});
|
||||
|
||||
it('shows the mappers empty state when a group has no mappers', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([]);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('mappers-empty-group-1'),
|
||||
).resolves.toHaveTextContent('No mappings in this group yet.');
|
||||
});
|
||||
|
||||
it('collapses extra mapper sources into a "+N more" label beyond the visible cap', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([
|
||||
makeMapper({
|
||||
id: 'mapper-1',
|
||||
config: {
|
||||
sources: [1, 2, 3, 4, 5].map((priority) => ({
|
||||
key: `source-${priority}`,
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
priority,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
|
||||
await expect(screen.findByText('+2 more')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a muted placeholder when a mapper has no sources', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1', config: { sources: [] } })]);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('mapper-sources-mapper-1')).toHaveTextContent('—'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
.groupHeaderLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.groupName {
|
||||
color: var(--l1-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.groupCount {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
|
||||
import styles from './GroupHeader.module.scss';
|
||||
|
||||
interface GroupHeaderProps {
|
||||
group: MappingGroup;
|
||||
}
|
||||
|
||||
function GroupHeader({ group }: GroupHeaderProps): JSX.Element {
|
||||
const conditionCount = group.attributes.length + group.resource.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.groupHeaderLabel}
|
||||
data-testid={`group-expand-${group.id}`}
|
||||
>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
className={styles.groupName}
|
||||
testId={`group-name-${group.id}`}
|
||||
>
|
||||
{group.name}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
className={styles.groupCount}
|
||||
testId={`group-condition-count-${group.id}`}
|
||||
>
|
||||
· {conditionCount} {conditionCount === 1 ? 'condition' : 'conditions'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupHeader;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './GroupHeader';
|
||||
@@ -0,0 +1,6 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
|
||||
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import styles from './GroupHeaderActions.module.scss';
|
||||
|
||||
interface GroupHeaderActionsProps {
|
||||
group: MappingGroup;
|
||||
}
|
||||
|
||||
function GroupHeaderActions({ group }: GroupHeaderActionsProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.actions}
|
||||
onClick={(event): void => event.stopPropagation()}
|
||||
>
|
||||
<Switch
|
||||
value={group.enabled}
|
||||
// We don't yet support toggling a group's enabled state in this read-only PR, so disable the switch. A later PR will add the toggle handler and its drawer.
|
||||
disabled
|
||||
testId={`group-enabled-${group.id}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupHeaderActions;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './GroupHeaderActions';
|
||||
@@ -0,0 +1,14 @@
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.mapperStateRow .stateCell {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.stateCell {
|
||||
padding: var(--spacing-4) var(--spacing-6) var(--spacing-4) var(--spacing-12);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useListSpanMappers } from 'api/generated/services/spanmapper';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import {
|
||||
MappingGroup,
|
||||
Mapping,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { buildMappingsFromListResponse } from 'container/LLMObservability/AttributeMapping/utils';
|
||||
import { COLUMN_COUNT } from '../constants';
|
||||
import MapperRow, { MapperRowSkeleton } from '../MapperRow';
|
||||
import MappingsColgroup from '../MappingsColgroup';
|
||||
import styles from './GroupMappers.module.scss';
|
||||
|
||||
const MAPPER_SKELETON_ROWS = 1;
|
||||
|
||||
const STATE_ROW_MOTION = {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
transition: { duration: 0.18, ease: 'easeOut' },
|
||||
} as const;
|
||||
|
||||
interface StateRowProps {
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
function ErrorRow({ groupId }: StateRowProps): JSX.Element {
|
||||
return (
|
||||
<motion.tr className={styles.mapperStateRow} {...STATE_ROW_MOTION}>
|
||||
<td
|
||||
colSpan={COLUMN_COUNT}
|
||||
className={styles.stateCell}
|
||||
data-testid={`mappers-error-${groupId}`}
|
||||
>
|
||||
Failed to load mappings. Please try again.
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyRow({ groupId }: StateRowProps): JSX.Element {
|
||||
return (
|
||||
<motion.tr className={styles.mapperStateRow} {...STATE_ROW_MOTION}>
|
||||
<td
|
||||
colSpan={COLUMN_COUNT}
|
||||
className={styles.stateCell}
|
||||
data-testid={`mappers-empty-${groupId}`}
|
||||
>
|
||||
No mappings in this group yet.
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupMappersProps {
|
||||
group: MappingGroup;
|
||||
}
|
||||
|
||||
function GroupMappers({ group }: GroupMappersProps): JSX.Element {
|
||||
const {
|
||||
data: mappers = [],
|
||||
isLoading,
|
||||
isError,
|
||||
} = useListSpanMappers<Mapping[]>(
|
||||
{
|
||||
groupId: group.id,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
refetchOnMount: false,
|
||||
select: buildMappingsFromListResponse,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let rows: JSX.Element[];
|
||||
if (isError) {
|
||||
rows = [<ErrorRow key="error" groupId={group.id} />];
|
||||
} else if (isLoading) {
|
||||
rows = Array.from({ length: MAPPER_SKELETON_ROWS }).map((_, index) => (
|
||||
<MapperRowSkeleton key={`mapper-skeleton-${index}`} />
|
||||
));
|
||||
} else if (mappers.length === 0) {
|
||||
rows = [<EmptyRow key="empty" groupId={group.id} />];
|
||||
} else {
|
||||
rows = mappers.map((mapper, index) => (
|
||||
<MapperRow key={mapper.id} mapper={mapper} index={index} />
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<table className={styles.table}>
|
||||
<MappingsColgroup />
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupMappers;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './GroupMappers';
|
||||
@@ -0,0 +1,65 @@
|
||||
.mapperRow {
|
||||
&:hover {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.cell {
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
vertical-align: middle;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
// Indent the first cell so mapper rows read as nested under their group.
|
||||
.targetCell {
|
||||
padding-left: var(--spacing-12);
|
||||
}
|
||||
|
||||
// Shorter vertical padding so the loading state reads as a compact placeholder.
|
||||
.skeletonCell {
|
||||
composes: cell;
|
||||
:global(.ant-skeleton-input) {
|
||||
min-height: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
:global(.ant-skeleton-button) {
|
||||
min-height: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.statusCell {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.sources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.sourceChipText {
|
||||
display: block;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sourceMore {
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SpantypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import { Mapping } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import styles from './MapperRow.module.scss';
|
||||
|
||||
const MAX_VISIBLE_SOURCES = 3;
|
||||
|
||||
const ROW_TRANSITION = { duration: 0.18, ease: 'easeOut' } as const;
|
||||
const MAX_STAGGERED_ROWS = 6;
|
||||
const STAGGER_STEP = 0.03;
|
||||
|
||||
interface MapperRowProps {
|
||||
mapper: Mapping;
|
||||
index: number;
|
||||
}
|
||||
|
||||
function MapperRow({ mapper, index }: MapperRowProps): JSX.Element {
|
||||
const sources = mapper.sources ?? [];
|
||||
const visibleSources = sources.slice(0, MAX_VISIBLE_SOURCES);
|
||||
const remainingSources = sources.length - visibleSources.length;
|
||||
|
||||
return (
|
||||
<motion.tr
|
||||
className={styles.mapperRow}
|
||||
data-testid={`mapper-row-${mapper.id}`}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
...ROW_TRANSITION,
|
||||
delay: Math.min(index, MAX_STAGGERED_ROWS) * STAGGER_STEP,
|
||||
}}
|
||||
>
|
||||
<td className={cx(styles.cell, styles.targetCell)}>
|
||||
<Typography.Text
|
||||
truncate={1}
|
||||
title={mapper.name}
|
||||
data-testid={`mapper-target-${mapper.id}`}
|
||||
>
|
||||
{mapper.name}
|
||||
</Typography.Text>
|
||||
</td>
|
||||
<td className={styles.cell}>
|
||||
{sources.length === 0 ? (
|
||||
<span className={styles.muted} data-testid={`mapper-sources-${mapper.id}`}>
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
className={styles.sources}
|
||||
data-testid={`mapper-sources-${mapper.id}`}
|
||||
>
|
||||
{visibleSources.map((source) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
color="vanilla"
|
||||
className={styles.sourceChip}
|
||||
key={`${source.context}:${source.key}`}
|
||||
>
|
||||
<span className={styles.sourceChipText} title={source.key}>
|
||||
{source.key}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
{remainingSources > 0 && (
|
||||
<span className={cx(styles.sourceMore, styles.muted)}>
|
||||
+{remainingSources} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.cell}>
|
||||
<Badge
|
||||
color={
|
||||
mapper.fieldContext === SpantypesFieldContextDTO.resource
|
||||
? 'amber'
|
||||
: 'robin'
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{mapper.fieldContext}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className={cx(styles.cell, styles.statusCell)}>
|
||||
<div className={styles.rowActions}>
|
||||
<Switch
|
||||
value={mapper.enabled}
|
||||
disabled
|
||||
testId={`mapper-enabled-${mapper.id}`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperRow;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './MapperRow.module.scss';
|
||||
|
||||
function MapperRowSkeleton(): JSX.Element {
|
||||
return (
|
||||
<tr className={styles.mapperRow}>
|
||||
<td className={cx(styles.skeletonCell, styles.targetCell)}>
|
||||
<Skeleton.Input active size="small" style={{ width: '55%' }} />
|
||||
</td>
|
||||
<td className={styles.skeletonCell}>
|
||||
<div className={styles.sources}>
|
||||
<Skeleton.Button active size="small" style={{ width: 88 }} />
|
||||
<Skeleton.Button active size="small" style={{ width: 56 }} />
|
||||
</div>
|
||||
</td>
|
||||
<td className={styles.skeletonCell}>
|
||||
<Skeleton.Button active size="small" style={{ width: 72 }} />
|
||||
</td>
|
||||
<td className={cx(styles.skeletonCell, styles.statusCell)}>
|
||||
<div className={styles.rowActions}>
|
||||
<Skeleton.Button active size="small" shape="round" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperRowSkeleton;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './MapperRow';
|
||||
export { default as MapperRowSkeleton } from './MapperRowSkeleton';
|
||||
@@ -0,0 +1,11 @@
|
||||
.colTarget {
|
||||
width: 32%;
|
||||
}
|
||||
|
||||
.colWritesTo {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.colStatus {
|
||||
width: 120px;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import styles from './MappingsColgroup.module.scss';
|
||||
|
||||
function MappingsColgroup(): JSX.Element {
|
||||
return (
|
||||
<colgroup>
|
||||
<col className={styles.colTarget} />
|
||||
<col />
|
||||
<col className={styles.colWritesTo} />
|
||||
<col className={styles.colStatus} />
|
||||
</colgroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default MappingsColgroup;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './MappingsColgroup';
|
||||
@@ -0,0 +1,124 @@
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.headerCell {
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
text-align: left;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--l2-foreground);
|
||||
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.groupsCollapse:global(.ant-collapse) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
|
||||
> :global(.ant-collapse-item) {
|
||||
border-bottom: none;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
border-radius: 0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
> :global(.ant-collapse-header) {
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
background: var(--l2-background);
|
||||
border-radius: 0;
|
||||
padding: var(--spacing-3) var(--spacing-6);
|
||||
color: var(--l3-foreground);
|
||||
|
||||
:global(.ant-collapse-expand-icon) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: auto;
|
||||
padding-inline-end: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header-text) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-extra) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content) {
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
color: inherit;
|
||||
|
||||
> :global(.ant-collapse-content-box) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tableEmpty {
|
||||
padding: var(--spacing-12) var(--spacing-6);
|
||||
text-align: center;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.skeletonList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// Mirrors the Collapse header banner while groups load.
|
||||
.skeletonBanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-3) var(--spacing-6);
|
||||
background: var(--l2-background);
|
||||
border-top: 1px solid var(--l2-border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
:global(.ant-skeleton-input) {
|
||||
min-height: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
:global(.ant-skeleton-button) {
|
||||
min-height: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.skeletonGroupLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skeletonGroupRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { Collapse, type CollapseProps, Skeleton } from 'antd';
|
||||
|
||||
import { AttributeMappingStore } from 'container/LLMObservability/AttributeMapping/AttributeMappingsTab/hooks/useAttributeMappingStore';
|
||||
import GroupHeader from './GroupHeader';
|
||||
import GroupHeaderActions from './GroupHeaderActions';
|
||||
import GroupMappers from './GroupMappers';
|
||||
import MappingsColgroup from './MappingsColgroup';
|
||||
import styles from './MappingsTable.module.scss';
|
||||
|
||||
const SKELETON_ROW_COUNT = 3;
|
||||
|
||||
interface MappingsTableProps {
|
||||
store: AttributeMappingStore;
|
||||
}
|
||||
|
||||
function MappingsTable({ store }: MappingsTableProps): JSX.Element {
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||
|
||||
const isEmpty = !store.isLoading && store.groups.length === 0;
|
||||
|
||||
const items: CollapseProps['items'] = store.groups.map((group) => ({
|
||||
key: group.id,
|
||||
label: <GroupHeader group={group} />,
|
||||
extra: <GroupHeaderActions group={group} />,
|
||||
children: <GroupMappers group={group} />,
|
||||
}));
|
||||
|
||||
const skeletonBanners = (
|
||||
<div className={styles.skeletonList}>
|
||||
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, index) => (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`group-skeleton-${index}`}
|
||||
className={styles.skeletonBanner}
|
||||
>
|
||||
<div className={styles.skeletonGroupLeft}>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
style={{ width: index % 2 === 0 ? 200 : 140 }}
|
||||
/>
|
||||
<Skeleton.Input active size="small" style={{ width: 64 }} />
|
||||
</div>
|
||||
<div className={styles.skeletonGroupRight}>
|
||||
<Skeleton.Button active size="small" shape="round" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className={styles.tableEmpty} data-testid="mapper-groups-empty">
|
||||
No mapping groups yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="mappings-table">
|
||||
<table className={styles.table}>
|
||||
<MappingsColgroup />
|
||||
<thead>
|
||||
<tr className={styles.headerRow}>
|
||||
<th className={styles.headerCell}>Target</th>
|
||||
<th className={styles.headerCell}>Sources</th>
|
||||
<th className={styles.headerCell}>Writes to</th>
|
||||
<th className={styles.headerCell}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
{store.isLoading ? (
|
||||
skeletonBanners
|
||||
) : (
|
||||
<Collapse
|
||||
className={styles.groupsCollapse}
|
||||
activeKey={expandedGroups}
|
||||
onChange={(keys): void =>
|
||||
setExpandedGroups(Array.isArray(keys) ? keys : [keys])
|
||||
}
|
||||
bordered={false}
|
||||
destroyInactivePanel
|
||||
expandIcon={({ isActive }): JSX.Element =>
|
||||
isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
items={items}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MappingsTable;
|
||||
@@ -0,0 +1 @@
|
||||
export const COLUMN_COUNT = 4;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from 'react';
|
||||
import { SpantypesSpanMapperGroupDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useListSpanMapperGroups } from 'api/generated/services/spanmapper';
|
||||
|
||||
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { buildMappingGroup } from 'container/LLMObservability/AttributeMapping/utils';
|
||||
|
||||
export interface AttributeMappingStore {
|
||||
groups: MappingGroup[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
// Read-only store for the listing view: loads the server groups only. Each
|
||||
// group's mappers are fetched lazily when its panel is expanded (see
|
||||
// GroupMappers), so page load is a single request instead of an N+1 fan-out
|
||||
// across every group. Editing (enabled toggles, save/discard) and its drawers
|
||||
// land in a later PR — this PR only lists.
|
||||
export function useAttributeMappingStore(): AttributeMappingStore {
|
||||
const groupsQuery = useListSpanMapperGroups();
|
||||
|
||||
const groups = useMemo<MappingGroup[]>(() => {
|
||||
const serverGroups: SpantypesSpanMapperGroupDTO[] =
|
||||
groupsQuery.data?.data?.items ?? [];
|
||||
return serverGroups.map((group) => buildMappingGroup(group));
|
||||
}, [groupsQuery.data]);
|
||||
|
||||
return {
|
||||
groups,
|
||||
isLoading: groupsQuery.isLoading,
|
||||
isError: groupsQuery.isError,
|
||||
};
|
||||
}
|
||||
@@ -2,12 +2,5 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
}
|
||||
|
||||
.tableEmpty {
|
||||
padding: var(--spacing-12) var(--spacing-6);
|
||||
text-align: center;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
padding: var(--spacing-0);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
|
||||
import AttributeMappingHeader from './components/AttributeMappingHeader';
|
||||
import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
|
||||
const noop = (): void => undefined;
|
||||
|
||||
function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'attribute-mappings',
|
||||
label: 'Attribute mappings',
|
||||
children: <AttributeMappingsTab />,
|
||||
},
|
||||
{
|
||||
key: 'test',
|
||||
label: 'Test',
|
||||
disabled: true,
|
||||
disabledReason: 'Coming soon',
|
||||
children: null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.llmObservabilityAttributeMapping}
|
||||
@@ -16,9 +34,11 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
onSave={noop}
|
||||
/>
|
||||
|
||||
<div className={styles.tableEmpty} data-testid="attribute-mapping-empty">
|
||||
No mapping groups configured yet.
|
||||
</div>
|
||||
<Tabs
|
||||
testId="attribute-mapping-tabs"
|
||||
defaultValue="attribute-mappings"
|
||||
items={tabItems}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
|
||||
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
|
||||
|
||||
function setupGroups(): void {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('LLMObservabilityAttributeMapping', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
setupGroups();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the page shell', () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('llm-observability-attribute-mapping-page'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the attribute-mappings and test sub-tab labels', () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Attribute mappings' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Test' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('activates the attribute-mappings tab by default and renders its content', async () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
const attributeMappingsTab = screen.getByRole('tab', {
|
||||
name: 'Attribute mappings',
|
||||
});
|
||||
expect(attributeMappingsTab).toHaveAttribute('data-state', 'active');
|
||||
await expect(
|
||||
screen.findByTestId('attribute-mappings-tab'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the header with its description and no Save/Discard while pristine', () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Configure source-to-target attribute remapping for LLM traces',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
// The actions only appear once there are staged changes.
|
||||
expect(screen.queryByTestId('save-changes-btn')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('unsaved-changes')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
SpantypesFieldContextDTO as FieldContext,
|
||||
SpantypesSpanMapperDTO as Mapper,
|
||||
SpantypesSpanMapperGroupDTO as MapperGroup,
|
||||
SpantypesSpanMapperOperationDTO as MapperOperation,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// Endpoint globs used by MSW handlers. The generated client hits relative
|
||||
// `/api/v1/span_mapper_groups[...]`, so the `*` prefix matches regardless of
|
||||
// base URL.
|
||||
export const GROUPS_ENDPOINT = '*/api/v1/span_mapper_groups';
|
||||
export function mappersEndpoint(groupId: string): string {
|
||||
return `*/api/v1/span_mapper_groups/${groupId}/span_mappers`;
|
||||
}
|
||||
|
||||
export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
|
||||
return {
|
||||
id: 'group-1',
|
||||
orgId: 'org-1',
|
||||
name: 'demo',
|
||||
enabled: true,
|
||||
condition: {
|
||||
attributes: ['ai.embeddings'],
|
||||
resource: ['cloud.account.id'],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
|
||||
return {
|
||||
id: 'mapper-1',
|
||||
group_id: 'group-1',
|
||||
name: 'gen_ai.request.model',
|
||||
enabled: true,
|
||||
fieldContext: FieldContext.attribute,
|
||||
config: {
|
||||
sources: [
|
||||
{
|
||||
key: 'genai.model',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
priority: 2,
|
||||
},
|
||||
{
|
||||
key: 'llm.model',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.move,
|
||||
priority: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Both list endpoints share the same `{ status, data: { items } }` envelope —
|
||||
// the generated schema mis-types the mappers response with the groups DTO
|
||||
// (see GroupMappers), but the runtime envelope shape is identical.
|
||||
export function makeGroupsResponse(groups: MapperGroup[]): {
|
||||
status: string;
|
||||
data: { items: MapperGroup[] };
|
||||
} {
|
||||
return { status: 'ok', data: { items: groups } };
|
||||
}
|
||||
|
||||
export function makeMappersResponse(mappers: Mapper[]): {
|
||||
status: string;
|
||||
data: { items: Mapper[] };
|
||||
} {
|
||||
return { status: 'ok', data: { items: mappers } };
|
||||
}
|
||||
|
||||
export const mockGroups: MapperGroup[] = [
|
||||
makeGroup({
|
||||
id: 'group-1',
|
||||
name: 'demo',
|
||||
condition: {
|
||||
attributes: ['ai.embeddings'],
|
||||
resource: ['cloud.account.id'],
|
||||
},
|
||||
}),
|
||||
makeGroup({
|
||||
id: 'group-2',
|
||||
name: 'Tool',
|
||||
enabled: false,
|
||||
condition: { attributes: null, resource: null },
|
||||
}),
|
||||
];
|
||||
|
||||
export const mockMappers: Mapper[] = [
|
||||
makeMapper({ id: 'mapper-1', group_id: 'group-1' }),
|
||||
];
|
||||
@@ -5,23 +5,6 @@
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-large);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.pageHeaderActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './AttributeMappingHeader.module.scss';
|
||||
|
||||
@@ -17,38 +18,35 @@ function AttributeMappingHeader({
|
||||
}: AttributeMappingHeaderProps): JSX.Element {
|
||||
return (
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1 className={styles.title}>Attribute Mapping</h1>
|
||||
<p className={styles.description}>
|
||||
Configure source-to-target attribute remapping for LLM traces
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.pageHeaderActions}>
|
||||
{isDirty && (
|
||||
<Typography.Text as="p" size="base" color="muted">
|
||||
Configure source-to-target attribute remapping for LLM traces
|
||||
</Typography.Text>
|
||||
{isDirty && (
|
||||
<div className={styles.pageHeaderActions}>
|
||||
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onDiscard}
|
||||
disabled={!isDirty || isSaving}
|
||||
testId="discard-changes-btn"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
loading={isSaving}
|
||||
disabled={!isDirty || isSaving}
|
||||
testId="save-changes-btn"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onDiscard}
|
||||
disabled={isSaving}
|
||||
testId="discard-changes-btn"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
loading={isSaving}
|
||||
disabled={isSaving}
|
||||
testId="save-changes-btn"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
SpantypesFieldContextDTO,
|
||||
SpantypesSpanMapperOperationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export interface SourceConfig {
|
||||
key: string;
|
||||
context: SpantypesFieldContextDTO;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
}
|
||||
|
||||
export interface Mapping {
|
||||
id: string;
|
||||
name: string;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
sources: SourceConfig[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface MappingGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
ListSpanMappers200,
|
||||
SpantypesSpanMapperDTO,
|
||||
SpantypesSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { MappingGroup, Mapping, SourceConfig } from './types';
|
||||
|
||||
function getMapperSources(mapper: SpantypesSpanMapperDTO): SourceConfig[] {
|
||||
const sources = mapper.config?.sources ?? [];
|
||||
return [...sources]
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map((source) => ({
|
||||
key: source.key,
|
||||
context: source.context,
|
||||
operation: source.operation,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildMapping(mapper: SpantypesSpanMapperDTO): Mapping {
|
||||
return {
|
||||
id: mapper.id,
|
||||
name: mapper.name,
|
||||
fieldContext: mapper.fieldContext,
|
||||
sources: getMapperSources(mapper),
|
||||
enabled: mapper.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMappingsFromListResponse(
|
||||
response: ListSpanMappers200,
|
||||
): Mapping[] {
|
||||
const items = (response.data?.items ??
|
||||
[]) as unknown as SpantypesSpanMapperDTO[];
|
||||
return items.map(buildMapping);
|
||||
}
|
||||
|
||||
export function buildMappingGroup(
|
||||
group: SpantypesSpanMapperGroupDTO,
|
||||
): MappingGroup {
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
attributes: group.condition?.attributes ?? [],
|
||||
resource: group.condition?.resource ?? [],
|
||||
enabled: group.enabled,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom';
|
||||
import cx from 'classnames';
|
||||
import { Pagination, Skeleton } from 'antd';
|
||||
import { useListRoles } from 'api/generated/services/role';
|
||||
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import 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: AuthtypesRoleDTO };
|
||||
| { type: 'role'; role: AuthtypesGettableRoleDTO };
|
||||
|
||||
interface RolesListContentProps {
|
||||
searchQuery: string;
|
||||
@@ -176,7 +176,7 @@ function RolesListContent({ searchQuery }: RolesListContentProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
|
||||
const renderRow = (role: AuthtypesGettableRoleDTO): JSX.Element => (
|
||||
<div
|
||||
key={role.id}
|
||||
className={cx(styles.tableRow, {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from 'react-query';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import type {
|
||||
AuthtypesPostableRoleDTO,
|
||||
AuthtypesRoleWithTransactionGroupsDTO,
|
||||
AuthtypesRoleDTO,
|
||||
AuthtypesTransactionGroupDTO,
|
||||
AuthtypesUpdatableRoleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -133,7 +133,7 @@ export function transformTransactionGroupsToResourcePermissions(
|
||||
}
|
||||
|
||||
export function transformApiToRolePermissions(
|
||||
role: AuthtypesRoleWithTransactionGroupsDTO,
|
||||
role: AuthtypesRoleDTO,
|
||||
): RolePermissionsData {
|
||||
return {
|
||||
roleId: role.id,
|
||||
|
||||
@@ -208,7 +208,7 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /New Service Account/i }));
|
||||
|
||||
await screen.findByRole('dialog', { name: /New Service Account/i });
|
||||
await screen.findByTestId('create-service-account-modal');
|
||||
expect(screen.getByPlaceholderText('Enter a name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
getGetRolesByUserIDQueryKey,
|
||||
useGetRolesByUserID,
|
||||
@@ -21,11 +21,11 @@ export interface MemberRoleUpdateFailure {
|
||||
}
|
||||
|
||||
interface UseMemberRoleManagerResult {
|
||||
currentRoles: AuthtypesRoleDTO[];
|
||||
currentRoles: AuthtypesGettableRoleDTO[];
|
||||
isLoading: boolean;
|
||||
applyDiff: (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
availableRoles: AuthtypesGettableRoleDTO[],
|
||||
) => Promise<MemberRoleUpdateFailure[]>;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useMemberRoleManager(
|
||||
{ query: { enabled: !!userId && enabled } },
|
||||
);
|
||||
|
||||
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
|
||||
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
|
||||
() => data?.data ?? [],
|
||||
[data?.data],
|
||||
);
|
||||
@@ -61,7 +61,7 @@ export function useMemberRoleManager(
|
||||
const applyDiff = useCallback(
|
||||
async (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
availableRoles: AuthtypesGettableRoleDTO[],
|
||||
): Promise<MemberRoleUpdateFailure[]> => {
|
||||
const currentRoleIds = new Set(
|
||||
currentRoles.map((r) => r.id).filter(Boolean),
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useDeleteServiceAccountRoleDeprecated,
|
||||
useGetServiceAccountRoles,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { retryOn429 } from 'utils/errorUtils';
|
||||
|
||||
const enum PromiseStatus {
|
||||
@@ -21,11 +21,11 @@ export interface RoleUpdateFailure {
|
||||
}
|
||||
|
||||
interface UseServiceAccountRoleManagerResult {
|
||||
currentRoles: AuthtypesRoleDTO[];
|
||||
currentRoles: AuthtypesGettableRoleDTO[];
|
||||
isLoading: boolean;
|
||||
applyDiff: (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
availableRoles: AuthtypesGettableRoleDTO[],
|
||||
) => Promise<RoleUpdateFailure[]>;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useServiceAccountRoleManager(
|
||||
{ query: { enabled: options?.enabled ?? true } },
|
||||
);
|
||||
|
||||
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
|
||||
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
|
||||
() => data?.data ?? [],
|
||||
[data?.data],
|
||||
);
|
||||
@@ -64,7 +64,7 @@ export function useServiceAccountRoleManager(
|
||||
const applyDiff = useCallback(
|
||||
async (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
availableRoles: AuthtypesGettableRoleDTO[],
|
||||
): Promise<RoleUpdateFailure[]> => {
|
||||
const currentRoleIds = new Set(
|
||||
currentRoles.map((r) => r.id).filter(Boolean),
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import {
|
||||
downloadFile,
|
||||
getTimestampedFileName,
|
||||
} from 'lib/exportData/downloadFile';
|
||||
import { ExportFormat } from 'lib/exportData/types';
|
||||
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import { useClientExport } from '../useClientExport';
|
||||
|
||||
jest.mock('lib/exportData/downloadFile', () => ({
|
||||
...jest.requireActual('lib/exportData/downloadFile'),
|
||||
downloadFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockMessageError = jest.fn();
|
||||
jest.mock('antd', () => {
|
||||
const actual = jest.requireActual('antd');
|
||||
return {
|
||||
...actual,
|
||||
message: { error: (...args: unknown[]): void => mockMessageError(...args) },
|
||||
};
|
||||
});
|
||||
|
||||
const mockDownloadFile = downloadFile as jest.Mock;
|
||||
|
||||
function timeSeriesResponse(): QueryRangeResponseV5 {
|
||||
return {
|
||||
type: 'time_series',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
aggregations: [
|
||||
{
|
||||
index: 0,
|
||||
alias: '',
|
||||
meta: {},
|
||||
series: [
|
||||
{
|
||||
labels: [{ key: { name: 'service' }, value: 'a' }],
|
||||
values: [{ timestamp: 1000, value: 12 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {},
|
||||
} as unknown as QueryRangeResponseV5;
|
||||
}
|
||||
|
||||
describe('useClientExport', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Freeze the clock so filenames are deterministic — asserted against the
|
||||
// real getTimestampedFileName (the format itself is pinned by an exact
|
||||
// string in downloadFile.test).
|
||||
jest.useFakeTimers().setSystemTime(new Date(2026, 6, 13, 14, 32, 5));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('exports time_series as CSV to a timestamped <fileName>.csv', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useClientExport({
|
||||
response: timeSeriesResponse(),
|
||||
fileName: 'chart',
|
||||
legendMap: { A: '{{service}}' },
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleExport({ format: ExportFormat.Csv });
|
||||
});
|
||||
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
|
||||
const [content, name, mime] = mockDownloadFile.mock.calls[0];
|
||||
// delegation: the hook names files via getTimestampedFileName
|
||||
expect(name).toBe(getTimestampedFileName('chart', 'csv'));
|
||||
expect(mime).toContain('text/csv');
|
||||
expect(content).toContain('service');
|
||||
expect(content).toContain('a');
|
||||
});
|
||||
|
||||
it('exports as JSONL to a timestamped <fileName>.jsonl with the ndjson mime', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useClientExport({ response: timeSeriesResponse() }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleExport({ format: ExportFormat.Jsonl });
|
||||
});
|
||||
|
||||
const [content, name, mime] = mockDownloadFile.mock.calls[0];
|
||||
expect(name).toBe(getTimestampedFileName('export', 'jsonl'));
|
||||
expect(mime).toContain('ndjson');
|
||||
expect(content).toContain('"series"');
|
||||
});
|
||||
|
||||
it('does nothing when there is no response', () => {
|
||||
const { result } = renderHook(() => useClientExport({}));
|
||||
|
||||
act(() => {
|
||||
result.current.handleExport({ format: ExportFormat.Csv });
|
||||
});
|
||||
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockMessageError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error and does not download for unsupported result types', () => {
|
||||
const raw = {
|
||||
type: 'raw',
|
||||
data: { results: [] },
|
||||
meta: {},
|
||||
} as unknown as QueryRangeResponseV5;
|
||||
const { result } = renderHook(() => useClientExport({ response: raw }));
|
||||
|
||||
act(() => {
|
||||
result.current.handleExport({ format: ExportFormat.Csv });
|
||||
});
|
||||
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockMessageError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
93
frontend/src/hooks/useExportData/useClientExport.ts
Normal file
93
frontend/src/hooks/useExportData/useClientExport.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { message } from 'antd';
|
||||
import { REQUEST_TYPES } from 'api/v5/queryRange/constants';
|
||||
import {
|
||||
downloadFile,
|
||||
getTimestampedFileName,
|
||||
} from 'lib/exportData/downloadFile';
|
||||
import { exportTimeseriesData } from 'lib/exportData/exportTimeseriesData';
|
||||
import { toCsv } from 'lib/exportData/toCsv';
|
||||
import { toJsonl } from 'lib/exportData/toJsonl';
|
||||
import { ExportFormat, SerializedTable } from 'lib/exportData/types';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryRangeResponseV5, TimeSeriesData } from 'types/api/v5/queryRange';
|
||||
|
||||
const FORMAT_META: Record<ExportFormat, { mime: string; extension: string }> = {
|
||||
[ExportFormat.Csv]: { mime: 'text/csv;charset=utf-8;', extension: 'csv' },
|
||||
[ExportFormat.Jsonl]: {
|
||||
mime: 'application/x-ndjson;charset=utf-8;',
|
||||
extension: 'jsonl',
|
||||
},
|
||||
};
|
||||
|
||||
// Picks the serializer for the response's request type. Narrows the results
|
||||
// union via the response discriminant. scalar lands with #5591; raw/trace are
|
||||
// server-exported, distribution is never emitted.
|
||||
function serialize(
|
||||
response: QueryRangeResponseV5,
|
||||
yAxisUnit?: string,
|
||||
legendMap?: Record<string, string>,
|
||||
query?: Query,
|
||||
): SerializedTable {
|
||||
if (response.type === REQUEST_TYPES.TIME_SERIES) {
|
||||
return exportTimeseriesData({
|
||||
data: response.data.results as TimeSeriesData[],
|
||||
yAxisUnit,
|
||||
legendMap,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Export is not supported for "${response.type}" results`);
|
||||
}
|
||||
|
||||
interface UseClientExportProps {
|
||||
response?: QueryRangeResponseV5;
|
||||
query?: Query;
|
||||
yAxisUnit?: string;
|
||||
fileName?: string;
|
||||
legendMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ClientExportOptions {
|
||||
format: ExportFormat;
|
||||
}
|
||||
|
||||
interface UseClientExportReturn {
|
||||
isExporting: boolean;
|
||||
handleExport: (options: ClientExportOptions) => void;
|
||||
}
|
||||
|
||||
export function useClientExport({
|
||||
response, // currently supports only qb v5 response. Can extend to support future responses.
|
||||
query,
|
||||
yAxisUnit,
|
||||
fileName = 'export',
|
||||
legendMap,
|
||||
}: UseClientExportProps): UseClientExportReturn {
|
||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||
|
||||
const handleExport = useCallback(
|
||||
({ format }: ClientExportOptions): void => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const table = serialize(response, yAxisUnit, legendMap, query);
|
||||
const content =
|
||||
format === ExportFormat.Jsonl ? toJsonl(table) : toCsv(table);
|
||||
const { mime, extension } = FORMAT_META[format];
|
||||
downloadFile(content, getTimestampedFileName(fileName, extension), mime);
|
||||
} catch {
|
||||
message.error('Failed to export data. Please try again.');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
},
|
||||
[response, query, yAxisUnit, fileName, legendMap],
|
||||
);
|
||||
|
||||
return { isExporting, handleExport };
|
||||
}
|
||||
56
frontend/src/lib/exportData/__tests__/downloadFile.test.ts
Normal file
56
frontend/src/lib/exportData/__tests__/downloadFile.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { downloadFile, getTimestampedFileName } from '../downloadFile';
|
||||
|
||||
// jsdom doesn't implement the object-URL APIs; define stubs so jest.spyOn can wrap them.
|
||||
if (typeof URL.createObjectURL !== 'function') {
|
||||
URL.createObjectURL = (): string => '';
|
||||
}
|
||||
if (typeof URL.revokeObjectURL !== 'function') {
|
||||
URL.revokeObjectURL = (): void => undefined;
|
||||
}
|
||||
|
||||
describe('downloadFile', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('builds a blob anchor, clicks it, and revokes the object URL', () => {
|
||||
const click = jest.fn();
|
||||
const remove = jest.fn();
|
||||
const anchor = {
|
||||
href: '',
|
||||
download: '',
|
||||
click,
|
||||
remove,
|
||||
} as unknown as HTMLAnchorElement;
|
||||
|
||||
(
|
||||
jest.spyOn(document, 'createElement') as unknown as jest.Mock
|
||||
).mockReturnValue(anchor);
|
||||
const createObjectURL = jest
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue('blob:mock');
|
||||
const revokeObjectURL = jest.spyOn(URL, 'revokeObjectURL');
|
||||
|
||||
downloadFile('hello', 'export.csv', 'text/csv');
|
||||
|
||||
expect(anchor.download).toBe('export.csv');
|
||||
expect(anchor.href).toBe('blob:mock');
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
expect(createObjectURL).toHaveBeenCalled();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimestampedFileName', () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('appends a local timestamp between base and extension', () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date(2026, 6, 8, 14, 32, 5));
|
||||
|
||||
expect(getTimestampedFileName('logs-timeseries', 'csv')).toBe(
|
||||
'logs-timeseries-2026-07-08_14-32-05.csv',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
|
||||
|
||||
import { exportTimeseriesData } from '../exportTimeseriesData';
|
||||
|
||||
const iso = (ms: number): string => new Date(ms).toISOString();
|
||||
|
||||
function makeSeries(
|
||||
labels: Record<string, string>,
|
||||
values: [number, number][],
|
||||
): TimeSeries {
|
||||
return {
|
||||
labels: Object.entries(labels).map(([name, value]) => ({
|
||||
key: { name },
|
||||
value,
|
||||
})),
|
||||
values: values.map(([timestamp, value]) => ({ timestamp, value })),
|
||||
};
|
||||
}
|
||||
|
||||
function makeQuery(
|
||||
queryName: string,
|
||||
buckets: { index?: number; alias?: string; series: TimeSeries[] }[],
|
||||
): TimeSeriesData {
|
||||
return {
|
||||
queryName,
|
||||
aggregations: buckets.map((bucket, i) => ({
|
||||
index: bucket.index ?? i,
|
||||
alias: bucket.alias ?? '',
|
||||
meta: {},
|
||||
series: bucket.series,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('exportTimeseriesData', () => {
|
||||
it('one row per point: query column, label columns, unit in value header, legend naming', () => {
|
||||
const data = [
|
||||
makeQuery('A', [
|
||||
{
|
||||
series: [
|
||||
makeSeries({ service_name: 'frontend' }, [
|
||||
[1000, 12],
|
||||
[2000, 15],
|
||||
]),
|
||||
],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const table = exportTimeseriesData({
|
||||
data,
|
||||
yAxisUnit: 'ms',
|
||||
legendMap: { A: '{{service_name}}' },
|
||||
});
|
||||
|
||||
expect(table.headers).toStrictEqual([
|
||||
'timestamp',
|
||||
'query',
|
||||
'series',
|
||||
'service_name',
|
||||
'value (ms)',
|
||||
]);
|
||||
expect(table.rows).toStrictEqual([
|
||||
[iso(1000), 'A', 'frontend', 'frontend', 12],
|
||||
[iso(2000), 'A', 'frontend', 'frontend', 15],
|
||||
]);
|
||||
});
|
||||
|
||||
it('no legend falls back to the label-set name from getLabelName', () => {
|
||||
const data = [
|
||||
makeQuery('A', [
|
||||
{ series: [makeSeries({ service_name: 'frontend' }, [[1000, 12]])] },
|
||||
]),
|
||||
];
|
||||
|
||||
const table = exportTimeseriesData({ data });
|
||||
|
||||
expect(table.rows).toStrictEqual([
|
||||
[iso(1000), 'A', '{service_name="frontend"}', 'frontend', 12],
|
||||
]);
|
||||
});
|
||||
|
||||
it('multi-query: query is its own column; label keys are unioned', () => {
|
||||
const data = [
|
||||
makeQuery('A', [{ series: [makeSeries({ service: 'x' }, [[1000, 1]])] }]),
|
||||
makeQuery('B', [{ series: [makeSeries({ service: 'y' }, [[1000, 2]])] }]),
|
||||
];
|
||||
|
||||
const table = exportTimeseriesData({
|
||||
data,
|
||||
legendMap: { A: '{{service}}', B: '{{service}}' },
|
||||
});
|
||||
|
||||
expect(table.headers).toStrictEqual([
|
||||
'timestamp',
|
||||
'query',
|
||||
'series',
|
||||
'service',
|
||||
'value',
|
||||
]);
|
||||
expect(table.rows).toStrictEqual([
|
||||
[iso(1000), 'A', 'x', 'x', 1],
|
||||
[iso(1000), 'B', 'y', 'y', 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('multi-aggregation with the builder query: names match the chart legend', () => {
|
||||
const data = [
|
||||
makeQuery('A', [
|
||||
{ index: 0, alias: '__result_0', series: [makeSeries({}, [[1000, 5]])] },
|
||||
{
|
||||
index: 1,
|
||||
alias: '__result_1',
|
||||
series: [makeSeries({}, [[1000, 300]])],
|
||||
},
|
||||
]),
|
||||
makeQuery('B', [
|
||||
{
|
||||
index: 0,
|
||||
alias: '__result_0',
|
||||
series: [
|
||||
makeSeries({ 'cloud.account.id': 'signoz-staging' }, [[1000, 7]]),
|
||||
],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const query = {
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
dataSource: 'logs',
|
||||
aggregations: [
|
||||
{ expression: 'count()' },
|
||||
{ expression: 'avg(code.lineno)' },
|
||||
],
|
||||
groupBy: [],
|
||||
},
|
||||
{
|
||||
queryName: 'B',
|
||||
dataSource: 'logs',
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
groupBy: [{ key: 'cloud.account.id' }],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
} as unknown as Query;
|
||||
|
||||
const table = exportTimeseriesData({ data, query });
|
||||
|
||||
expect(table.rows).toStrictEqual([
|
||||
[iso(1000), 'A', 'count()-A', '', 5],
|
||||
[iso(1000), 'A', 'avg(code.lineno)-A', '', 300],
|
||||
[iso(1000), 'B', '{cloud.account.id="signoz-staging"}', 'signoz-staging', 7],
|
||||
]);
|
||||
});
|
||||
|
||||
it('multi-aggregation without the builder query: falls back to base names', () => {
|
||||
const data = [
|
||||
makeQuery('A', [
|
||||
{ index: 0, alias: '__result_0', series: [makeSeries({}, [[1000, 5]])] },
|
||||
{
|
||||
index: 1,
|
||||
alias: '__result_1',
|
||||
series: [makeSeries({}, [[1000, 300]])],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const table = exportTimeseriesData({ data });
|
||||
|
||||
expect(table.rows).toStrictEqual([
|
||||
[iso(1000), 'A', 'A', 5],
|
||||
[iso(1000), 'A', 'A', 300],
|
||||
]);
|
||||
});
|
||||
|
||||
it('empty data: returns a headers-only table', () => {
|
||||
expect(exportTimeseriesData({ data: [] })).toStrictEqual({
|
||||
headers: ['timestamp', 'query', 'series', 'value'],
|
||||
rows: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
42
frontend/src/lib/exportData/__tests__/formatters.test.ts
Normal file
42
frontend/src/lib/exportData/__tests__/formatters.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { toCsv } from '../toCsv';
|
||||
import { toJsonl } from '../toJsonl';
|
||||
import { SerializedTable } from '../types';
|
||||
|
||||
const table: SerializedTable = {
|
||||
headers: ['timestamp', 'value'],
|
||||
rows: [
|
||||
['t1', 12],
|
||||
['t2', 15],
|
||||
],
|
||||
};
|
||||
|
||||
describe('toCsv', () => {
|
||||
it('emits a header row then one row per record, in column order', () => {
|
||||
expect(toCsv(table).split(/\r?\n/)).toStrictEqual([
|
||||
'timestamp,value',
|
||||
't1,12',
|
||||
't2,15',
|
||||
]);
|
||||
});
|
||||
|
||||
it('quotes values containing the delimiter', () => {
|
||||
const csv = toCsv({ headers: ['name', 'value'], rows: [['a,b', 1]] });
|
||||
expect(csv.split(/\r?\n/)).toStrictEqual(['name,value', '"a,b",1']);
|
||||
});
|
||||
|
||||
it('emits only the header row when there are no data rows', () => {
|
||||
expect(toCsv({ headers: ['timestamp'], rows: [] })).toBe('timestamp\r\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toJsonl', () => {
|
||||
it('emits one JSON object per row keyed by header', () => {
|
||||
expect(toJsonl(table)).toBe(
|
||||
'{"timestamp":"t1","value":12}\n{"timestamp":"t2","value":15}',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits an empty string when there are no rows', () => {
|
||||
expect(toJsonl({ headers: ['timestamp'], rows: [] })).toBe('');
|
||||
});
|
||||
});
|
||||
29
frontend/src/lib/exportData/downloadFile.ts
Normal file
29
frontend/src/lib/exportData/downloadFile.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/** Triggers a browser download of in-memory string content as a file. */
|
||||
export function downloadFile(
|
||||
content: string,
|
||||
fileName: string,
|
||||
mime: string,
|
||||
): void {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** `base` + local timestamp + extension, e.g. `logs-timeseries-2026-07-08_14-32-05.csv`.
|
||||
* Keeps repeated exports from colliding and records when the export was taken. */
|
||||
export function getTimestampedFileName(
|
||||
base: string,
|
||||
extension: string,
|
||||
): string {
|
||||
const now = new Date();
|
||||
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
|
||||
now.getDate(),
|
||||
)}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
|
||||
return `${base}-${stamp}.${extension}`;
|
||||
}
|
||||
154
frontend/src/lib/exportData/exportTimeseriesData.ts
Normal file
154
frontend/src/lib/exportData/exportTimeseriesData.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
|
||||
import { SerializedTable } from './types';
|
||||
|
||||
interface ExportTimeseriesDataArgs {
|
||||
data: TimeSeriesData[];
|
||||
yAxisUnit?: string;
|
||||
legendMap?: Record<string, string>;
|
||||
// The builder query that produced the data — lets series names resolve
|
||||
// aggregation aliases/expressions exactly like the chart legend does.
|
||||
query?: Query;
|
||||
}
|
||||
|
||||
// One row of the flattened V5 tree: a single (query, aggregation, label-set) series.
|
||||
interface FlatSeries {
|
||||
queryName: string;
|
||||
labels: Record<string, string>;
|
||||
name: string;
|
||||
values: { timestamp: number; value: number }[];
|
||||
}
|
||||
|
||||
// V5 labels [{key:{name}, value}] → {name: value} (the getLabelName contract).
|
||||
function foldLabels(labels: TimeSeries['labels']): Record<string, string> {
|
||||
const record: Record<string, string> = {};
|
||||
(labels ?? []).forEach((label) => {
|
||||
if (label.key?.name) {
|
||||
record[label.key.name] = String(label.value);
|
||||
}
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
// Series display name, matching the chart legend: getLabelName for the base
|
||||
// (legend template / label-set), then getLegend to resolve the aggregation
|
||||
// alias/expression from the builder query (the response only carries
|
||||
// auto-generated `__result_N` aliases). Same chain the uPlot layer uses.
|
||||
function seriesName(args: {
|
||||
labels: Record<string, string>;
|
||||
queryName: string;
|
||||
legend: string;
|
||||
aggIndex: number;
|
||||
alias: string;
|
||||
query?: Query;
|
||||
}): string {
|
||||
const { labels, queryName, legend, aggIndex, alias, query } = args;
|
||||
const baseName = getLabelName(labels, queryName, legend);
|
||||
|
||||
if (!query) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
const legacySeries = {
|
||||
queryName,
|
||||
metric: labels,
|
||||
values: [],
|
||||
metaData: { alias, index: aggIndex, queryName },
|
||||
} as QueryData;
|
||||
|
||||
return getLegend(legacySeries, query, baseName);
|
||||
}
|
||||
|
||||
// Walk results → aggregations → series into a flat, named list.
|
||||
function flatten(
|
||||
data: TimeSeriesData[],
|
||||
legendMap?: Record<string, string>,
|
||||
query?: Query,
|
||||
): FlatSeries[] {
|
||||
const flat: FlatSeries[] = [];
|
||||
data.forEach((result) => {
|
||||
const queryName = result.queryName ?? '';
|
||||
const legend = legendMap?.[queryName] ?? '';
|
||||
(result.aggregations ?? []).forEach((bucket) => {
|
||||
(bucket.series ?? []).forEach((series) => {
|
||||
const labels = foldLabels(series.labels);
|
||||
flat.push({
|
||||
queryName,
|
||||
labels,
|
||||
name: seriesName({
|
||||
labels,
|
||||
queryName,
|
||||
legend,
|
||||
aggIndex: bucket.index ?? 0,
|
||||
alias: bucket.alias ?? '',
|
||||
query,
|
||||
}),
|
||||
values: (series.values ?? []).map((value) => ({
|
||||
timestamp: value.timestamp,
|
||||
value: value.value,
|
||||
})),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return flat;
|
||||
}
|
||||
|
||||
// Appends the y-axis unit to the value header: `value` → `value (ms)`.
|
||||
function withUnit(header: string, yAxisUnit?: string): string {
|
||||
return yAxisUnit ? `${header} (${yAxisUnit})` : header;
|
||||
}
|
||||
|
||||
function toIso(timestamp: number): string {
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
// Tidy (LONG) layout: one row per (series, timestamp). query is its own column.
|
||||
function buildTable(flat: FlatSeries[], yAxisUnit?: string): SerializedTable {
|
||||
const labelKeySet = new Set<string>();
|
||||
flat.forEach((series) => {
|
||||
Object.keys(series.labels).forEach((key) => labelKeySet.add(key));
|
||||
});
|
||||
const labelKeys = Array.from(labelKeySet).sort();
|
||||
|
||||
const headers = [
|
||||
'timestamp',
|
||||
'query',
|
||||
'series',
|
||||
...labelKeys,
|
||||
withUnit('value', yAxisUnit),
|
||||
];
|
||||
|
||||
const rows: (string | number)[][] = [];
|
||||
flat.forEach((series) => {
|
||||
series.values.forEach(({ timestamp, value }) => {
|
||||
rows.push([
|
||||
toIso(timestamp),
|
||||
series.queryName,
|
||||
series.name,
|
||||
...labelKeys.map((key) => series.labels[key] ?? ''),
|
||||
value,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a V5 time_series result into a format-agnostic tidy table — one
|
||||
* row per (series, timestamp), labels as columns, raw values.
|
||||
* Pure — walks the V5 tree directly; series names match the chart legend.
|
||||
*/
|
||||
export function exportTimeseriesData({
|
||||
data,
|
||||
yAxisUnit,
|
||||
legendMap,
|
||||
query,
|
||||
}: ExportTimeseriesDataArgs): SerializedTable {
|
||||
return buildTable(flatten(data, legendMap, query), yAxisUnit);
|
||||
}
|
||||
8
frontend/src/lib/exportData/toCsv.ts
Normal file
8
frontend/src/lib/exportData/toCsv.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { unparse } from 'papaparse';
|
||||
|
||||
import { SerializedTable } from './types';
|
||||
|
||||
/** Serializes a table to CSV. `fields` pins column order regardless of row keys. */
|
||||
export function toCsv(table: SerializedTable): string {
|
||||
return unparse({ fields: table.headers, data: table.rows });
|
||||
}
|
||||
12
frontend/src/lib/exportData/toJsonl.ts
Normal file
12
frontend/src/lib/exportData/toJsonl.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { SerializedTable } from './types';
|
||||
|
||||
/** Serializes a table to newline-delimited JSON: one object per row, keyed by header. */
|
||||
export function toJsonl(table: SerializedTable): string {
|
||||
return table.rows
|
||||
.map((row) =>
|
||||
JSON.stringify(
|
||||
Object.fromEntries(table.headers.map((header, i) => [header, row[i]])),
|
||||
),
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
13
frontend/src/lib/exportData/types.ts
Normal file
13
frontend/src/lib/exportData/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/** Format-agnostic tabular result produced by every exporter. Consumed by the
|
||||
* CSV/JSONL formatters */
|
||||
export interface SerializedTable {
|
||||
headers: string[];
|
||||
// One entry per header, in header order. Empty string marks a gap.
|
||||
rows: (string | number)[][];
|
||||
}
|
||||
|
||||
/** File formats a client-side export can be downloaded as. */
|
||||
export enum ExportFormat {
|
||||
Csv = 'csv',
|
||||
Jsonl = 'jsonl',
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AuthtypesGettableRoleDTO,
|
||||
AuthtypesRoleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const orgId = '019ba2bb-2fa1-7b24-8159-cfca08617ef9';
|
||||
|
||||
export const managedRoles: AuthtypesRoleDTO[] = [
|
||||
export const managedRoles: AuthtypesGettableRoleDTO[] = [
|
||||
{
|
||||
id: '019c24aa-2248-756f-9833-984f1ab63819',
|
||||
createdAt: '2026-02-03T18:00:55.624356Z',
|
||||
@@ -35,7 +38,7 @@ export const managedRoles: AuthtypesRoleDTO[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const customRoles: AuthtypesRoleDTO[] = [
|
||||
export const customRoles: AuthtypesGettableRoleDTO[] = [
|
||||
{
|
||||
id: '019c24aa-3333-0001-aaaa-111111111111',
|
||||
createdAt: '2026-02-10T10:30:00.000Z',
|
||||
@@ -56,12 +59,24 @@ export const customRoles: AuthtypesRoleDTO[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const allRoles: AuthtypesRoleDTO[] = [...managedRoles, ...customRoles];
|
||||
export const allRoles: AuthtypesGettableRoleDTO[] = [
|
||||
...managedRoles,
|
||||
...customRoles,
|
||||
];
|
||||
|
||||
export const listRolesSuccessResponse = {
|
||||
status: 'success',
|
||||
data: allRoles,
|
||||
};
|
||||
|
||||
export const customRoleResponse = { status: 'success', data: customRoles[0] };
|
||||
export const managedRoleResponse = { status: 'success', data: managedRoles[0] };
|
||||
const customRole: AuthtypesRoleDTO = {
|
||||
...customRoles[0],
|
||||
transactionGroups: [],
|
||||
};
|
||||
const managedRole: AuthtypesRoleDTO = {
|
||||
...managedRoles[0],
|
||||
transactionGroups: [],
|
||||
};
|
||||
|
||||
export const customRoleResponse = { status: 'success', data: customRole };
|
||||
export const managedRoleResponse = { status: 'success', data: managedRole };
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
clearViewPanelHandoff,
|
||||
readViewPanelHandoff,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/ViewPanelModal/viewPanelHandoffStore';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useSwitchToViewMode } from '../useSwitchToViewMode';
|
||||
@@ -18,11 +23,16 @@ jest.mock('hooks/useUrlQuery', () => ({
|
||||
}));
|
||||
|
||||
const query = { queryType: 'builder' } as unknown as Query;
|
||||
const spec = {
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel' },
|
||||
display: { name: 'CPU' },
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
describe('useSwitchToViewMode', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSearch = '';
|
||||
clearViewPanelHandoff();
|
||||
});
|
||||
|
||||
function invoke(): void {
|
||||
@@ -32,6 +42,7 @@ describe('useSwitchToViewMode', () => {
|
||||
panelId: 'panel-1',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
query,
|
||||
spec,
|
||||
}),
|
||||
);
|
||||
result.current();
|
||||
@@ -52,6 +63,21 @@ describe('useSwitchToViewMode', () => {
|
||||
).toStrictEqual(query);
|
||||
});
|
||||
|
||||
it('stashes the live draft spec in the sessionStorage handoff, not the URL', () => {
|
||||
invoke();
|
||||
|
||||
expect(readViewPanelHandoff('dash-1', 'panel-1')).toStrictEqual(spec);
|
||||
// The spec must not bloat the URL — the config-only display name never leaks into it.
|
||||
expect(mockSafeNavigate.mock.calls[0][0]).not.toContain('CPU');
|
||||
});
|
||||
|
||||
it('scopes the handoff to the exact dashboard + panel', () => {
|
||||
invoke();
|
||||
|
||||
expect(readViewPanelHandoff('dash-1', 'other-panel')).toBeNull();
|
||||
expect(readViewPanelHandoff('other-dash', 'panel-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('carries dashboard variables through and drops other editor URL state', () => {
|
||||
mockSearch = 'variables=%7B%22a%22%3A1%7D&compositeQuery=stale';
|
||||
invoke();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -7,27 +8,35 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { writeViewPanelHandoff } from '../../PanelsAndSectionsLayout/Panel/ViewPanelModal/viewPanelHandoffStore';
|
||||
|
||||
interface UseSwitchToViewModeArgs {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panelType: PANEL_TYPES;
|
||||
query: Query;
|
||||
/** Live (un-saved) draft spec — the query rides in the URL, the rest via the handoff. */
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback that leaves the editor for the dashboard with this panel expanded in the
|
||||
* View modal, seeded with the live (un-saved) query — V1's "Switch to View Mode".
|
||||
* Leaves the editor for the dashboard with this panel expanded in the View modal, seeded with
|
||||
* the live (un-saved) query + config — V1's "Switch to View Mode". The query rides in the URL
|
||||
* (`compositeQuery`); the rest of the spec rides in a tab-scoped sessionStorage handoff.
|
||||
*/
|
||||
export function useSwitchToViewMode({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panelType,
|
||||
query,
|
||||
spec,
|
||||
}: UseSwitchToViewModeArgs): () => void {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
return useCallback((): void => {
|
||||
writeViewPanelHandoff({ dashboardId, panelId, spec });
|
||||
|
||||
const params = new URLSearchParams();
|
||||
const variables = urlQuery.get(QueryParams.variables);
|
||||
if (variables) {
|
||||
@@ -42,5 +51,5 @@ export function useSwitchToViewMode({
|
||||
safeNavigate(
|
||||
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}?${params.toString()}`,
|
||||
);
|
||||
}, [safeNavigate, urlQuery, dashboardId, panelId, panelType, query]);
|
||||
}, [safeNavigate, urlQuery, dashboardId, panelId, panelType, query, spec]);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ import { useTableColumns } from './hooks/useTableColumns';
|
||||
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
|
||||
|
||||
import styles from './PanelEditor.module.scss';
|
||||
import logEvent from '@/api/common/logEvent';
|
||||
import { DashboardEvents } from '../../constants/events';
|
||||
|
||||
interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
@@ -204,6 +206,7 @@ function PanelEditorContainer({
|
||||
panelId,
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
|
||||
query: currentQuery,
|
||||
spec: draft.spec,
|
||||
});
|
||||
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
@@ -234,6 +237,13 @@ function PanelEditorContainer({
|
||||
onClose();
|
||||
}, [isNew, panelId, setScrollTargetId, onClose]);
|
||||
|
||||
const switchToViewMode = useCallback((): void => {
|
||||
logEvent(DashboardEvents.SWITCH_TO_VIEW_MODE, {
|
||||
panelId: panelId,
|
||||
});
|
||||
onSwitchToView();
|
||||
}, [onSwitchToView]);
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
<Header
|
||||
@@ -243,7 +253,7 @@ function PanelEditorContainer({
|
||||
readOnly={!isEditable}
|
||||
readOnlyReason={editDisabledReason}
|
||||
onSave={onSave}
|
||||
onSwitchToView={onSwitchToView}
|
||||
onSwitchToView={switchToViewMode}
|
||||
onClose={onCloseEditor}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
|
||||
@@ -16,6 +16,8 @@ import ViewPanelModalHeader from './ViewPanelModalHeader';
|
||||
import { useViewPanelMode } from './useViewPanelMode';
|
||||
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
interface ViewPanelModalContentProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
@@ -97,6 +99,14 @@ function ViewPanelModalContent({
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSwitchToEdit = (): void => {
|
||||
// Carry the drilldown edits so the editor opens on them, not the saved panel.
|
||||
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
|
||||
panelId: panelId,
|
||||
});
|
||||
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.content} data-testid="view-panel-modal-content">
|
||||
<ViewPanelModalHeader
|
||||
@@ -114,10 +124,7 @@ function ViewPanelModalContent({
|
||||
refreshWindow();
|
||||
}
|
||||
}}
|
||||
onSwitchToEdit={(): void =>
|
||||
// Carry the drilldown edits so the editor opens on them, not the saved panel.
|
||||
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) })
|
||||
}
|
||||
onSwitchToEdit={onSwitchToEdit}
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
queryType={queryType}
|
||||
signal={signal}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
|
||||
interface ViewPanelModalHeaderProps {
|
||||
selectedInterval: Time | CustomTimeType;
|
||||
@@ -64,6 +65,10 @@ function ViewPanelModalHeader({
|
||||
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
|
||||
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
|
||||
const panelTypeItems = usePanelTypeSelectItems({ queryType, signal });
|
||||
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
|
||||
const isLocked = useDashboardStore((s) => s.isLocked);
|
||||
|
||||
const canSwitchToEdit = canEditDashboard && !isLocked;
|
||||
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
@@ -75,15 +80,17 @@ function ViewPanelModalHeader({
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
onClick={onSwitchToEdit}
|
||||
data-testid="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
{canSwitchToEdit && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
onClick={onSwitchToEdit}
|
||||
data-testid="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
|
||||
@@ -23,8 +23,11 @@ import {
|
||||
type PanelQueryTimeOverride,
|
||||
type UsePanelQueryResult,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { readViewPanelHandoff } from './viewPanelHandoffStore';
|
||||
|
||||
interface UseViewPanelModeArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
@@ -77,25 +80,33 @@ export function useViewPanelMode({
|
||||
}: UseViewPanelModeArgs): UseViewPanelModeReturn {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
// Seed the draft from the URL (`compositeQuery` + `graphType`) when present, else the saved
|
||||
// panel — mount-only, so a refresh re-seeds from the URL and in-modal edits survive (V1 parity).
|
||||
const urlQuery = useGetCompositeQueryParam();
|
||||
// Config edits from the editor's "Switch to View Mode" arrive via the handoff; the query
|
||||
// still comes from the URL. Falls back to the saved panel for a plain grid "View".
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const baseSpec = useMemo<DashboardtypesPanelSpecDTO>(
|
||||
() => readViewPanelHandoff(dashboardId, panelId) ?? panel.spec,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed
|
||||
[],
|
||||
);
|
||||
|
||||
// Mount-only so a refresh re-seeds and in-modal edits survive (V1 parity).
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const urlGraphType = useUrlQuery().get(
|
||||
QueryParams.graphType,
|
||||
) as PANEL_TYPES | null;
|
||||
const initialPanel = useMemo<DashboardtypesPanelDTO>(
|
||||
() =>
|
||||
urlQuery
|
||||
compositeQuery
|
||||
? {
|
||||
...panel,
|
||||
spec: buildViewPanelSpec({
|
||||
spec: panel.spec,
|
||||
query: urlQuery,
|
||||
spec: baseSpec,
|
||||
query: compositeQuery,
|
||||
panelType:
|
||||
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
|
||||
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[baseSpec.plugin.kind],
|
||||
}),
|
||||
}
|
||||
: panel,
|
||||
: { ...panel, spec: baseSpec },
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed from the URL
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import getSessionStorage from 'api/browser/sessionstorage/get';
|
||||
import removeSessionStorage from 'api/browser/sessionstorage/remove';
|
||||
import setSessionStorage from 'api/browser/sessionstorage/set';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { SESSIONSTORAGE } from 'constants/sessionStorage';
|
||||
|
||||
interface ViewPanelHandoff {
|
||||
/** Correlator: the read returns the spec only for this exact dashboard + panel. */
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab-scoped handoff of the editor's un-saved draft spec to the View modal, so "Switch to View
|
||||
* Mode" carries config edits — not just the query, which stays in the URL. sessionStorage keeps
|
||||
* the link small yet survives a refresh, and clears the edits when the tab closes.
|
||||
*/
|
||||
export function writeViewPanelHandoff(handoff: ViewPanelHandoff): void {
|
||||
setSessionStorage(SESSIONSTORAGE.VIEW_PANEL_HANDOFF, JSON.stringify(handoff));
|
||||
}
|
||||
|
||||
export function readViewPanelHandoff(
|
||||
dashboardId: string,
|
||||
panelId: string,
|
||||
): DashboardtypesPanelSpecDTO | null {
|
||||
const raw = getSessionStorage(SESSIONSTORAGE.VIEW_PANEL_HANDOFF);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const handoff = JSON.parse(raw) as ViewPanelHandoff;
|
||||
return handoff.dashboardId === dashboardId && handoff.panelId === panelId
|
||||
? handoff.spec
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearViewPanelHandoff(): void {
|
||||
removeSessionStorage(SESSIONSTORAGE.VIEW_PANEL_HANDOFF);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { clearViewPanelHandoff } from '../ViewPanelModal/viewPanelHandoffStore';
|
||||
|
||||
export interface UseViewPanelApi {
|
||||
/** Panel id currently expanded in the View modal; null when none is open. */
|
||||
expandedPanelId: string | null;
|
||||
@@ -41,10 +43,11 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
// Copy before mutating: useUrlQuery returns a memoized instance.
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.set(QueryParams.expandedWidgetId, panelId);
|
||||
// Drop any leftover in-modal query/kind so a plain View opens on the saved
|
||||
// panel, not a stale URL query the modal would otherwise hydrate from.
|
||||
// Drop leftover in-modal query/kind + the editor's handoff so a plain View opens
|
||||
// on the saved panel, not stale state the modal would otherwise hydrate from.
|
||||
next.delete(QueryParams.compositeQuery);
|
||||
next.delete(QueryParams.graphType);
|
||||
clearViewPanelHandoff();
|
||||
safeNavigate(`${pathname}?${next.toString()}`);
|
||||
},
|
||||
[pathname, safeNavigate, urlQuery],
|
||||
@@ -55,6 +58,8 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.set(QueryParams.expandedWidgetId, panelId);
|
||||
next.set(QueryParams.graphType, panelType);
|
||||
// A grid drilldown opens on the saved panel, never a stale editor handoff.
|
||||
clearViewPanelHandoff();
|
||||
// Same encoding the query builder uses (see `useGetCompositeQueryParam`): the URL
|
||||
// value is `encodeURIComponent(JSON.stringify(query))`, decoded once on read.
|
||||
next.set(
|
||||
@@ -73,6 +78,7 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
// (the in-modal query builder writes compositeQuery, V1 parity).
|
||||
next.delete(QueryParams.compositeQuery);
|
||||
next.delete(QueryParams.graphType);
|
||||
clearViewPanelHandoff();
|
||||
const search = next.toString();
|
||||
safeNavigate(search ? `${pathname}?${search}` : pathname);
|
||||
}, [pathname, safeNavigate, urlQuery]);
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
} from 'constants/queryCacheTime';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
@@ -48,9 +51,10 @@ function DynamicSelector({
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: DynamicSelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
const existingQuery = useMemo(
|
||||
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
|
||||
@@ -96,8 +100,10 @@ function DynamicSelector({
|
||||
!!variable.dynamicAttribute &&
|
||||
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
|
||||
refetchOnWindowFocus: false,
|
||||
// Each cycle mints a fresh key; a small cacheTime bounds cache churn.
|
||||
cacheTime: DASHBOARD_CACHE_TIME,
|
||||
// Each cycle mints a fresh key; 0 under auto-refresh so entries don't pile up (V1 parity).
|
||||
cacheTime: isAutoRefreshDisabled
|
||||
? DASHBOARD_CACHE_TIME
|
||||
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
} from 'constants/queryCacheTime';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
@@ -44,9 +47,10 @@ function QuerySelector({
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: QuerySelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const payload = useMemo(() => selectionToPayload(selections), [selections]);
|
||||
|
||||
const {
|
||||
@@ -80,8 +84,10 @@ function QuerySelector({
|
||||
{
|
||||
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
|
||||
refetchOnWindowFocus: false,
|
||||
// Each cycle mints a fresh key; a small cacheTime bounds cache churn.
|
||||
cacheTime: DASHBOARD_CACHE_TIME,
|
||||
// Each cycle mints a fresh key; 0 under auto-refresh so entries don't pile up (V1 parity).
|
||||
cacheTime: isAutoRefreshDisabled
|
||||
? DASHBOARD_CACHE_TIME
|
||||
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
} from 'constants/queryCacheTime';
|
||||
|
||||
import { usePanelQuery } from '../usePanelQuery';
|
||||
import { useGetQueryRangeV5 } from '../useGetQueryRangeV5';
|
||||
@@ -432,4 +436,28 @@ describe('usePanelQuery', () => {
|
||||
expect(result.current.pagination?.pageIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheTime (auto-refresh OOM guard)', () => {
|
||||
const withAutoRefreshDisabled = (disabled: boolean): void => {
|
||||
mockUseSelector.mockImplementation((selector: unknown) =>
|
||||
(selector as (state: { globalTime: unknown }) => unknown)({
|
||||
globalTime: { ...DEFAULT_GLOBAL_TIME, isAutoRefreshDisabled: disabled },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
it('caches for DASHBOARD_CACHE_TIME when auto-refresh is disabled', () => {
|
||||
withAutoRefreshDisabled(true);
|
||||
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
|
||||
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME);
|
||||
});
|
||||
|
||||
it('drops cacheTime to 0 when auto-refresh is enabled', () => {
|
||||
withAutoRefreshDisabled(false);
|
||||
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
|
||||
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface UseGetQueryRangeV5Args {
|
||||
enabled: boolean;
|
||||
/** Retain prior data across a key change (list paging) so the table + pager stay mounted. */
|
||||
keepPreviousData?: boolean;
|
||||
/** Unused-entry TTL; callers drop to 0 under auto-refresh to bound cache growth (V1 parity). */
|
||||
cacheTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,6 +48,7 @@ export function useGetQueryRangeV5({
|
||||
queryKey,
|
||||
enabled,
|
||||
keepPreviousData,
|
||||
cacheTime,
|
||||
}: UseGetQueryRangeV5Args): UseQueryResult<QueryRangeV5200, Error> {
|
||||
return useQuery<QueryRangeV5200, Error>({
|
||||
queryKey,
|
||||
@@ -53,5 +56,6 @@ export function useGetQueryRangeV5({
|
||||
enabled,
|
||||
retry: retryUnlessClientError,
|
||||
keepPreviousData,
|
||||
cacheTime,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useQueryClient } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
} from 'constants/queryCacheTime';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
@@ -110,6 +114,7 @@ export function usePanelQuery({
|
||||
selectedTime: globalSelectedInterval,
|
||||
maxTime,
|
||||
minTime,
|
||||
isAutoRefreshDisabled,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
// Resolved variable values for this dashboard, published by useResolvedVariables.
|
||||
@@ -243,6 +248,10 @@ export function usePanelQuery({
|
||||
enabled: enabled && runnable && !isWaitingOnVariable,
|
||||
// Hold the current page while the next loads (offset re-keys) so the pager doesn't flash.
|
||||
keepPreviousData: isPaginated,
|
||||
// 0 under auto-refresh so time-keyed entries don't accumulate and OOM the tab (V1 parity).
|
||||
cacheTime: isAutoRefreshDisabled
|
||||
? DASHBOARD_CACHE_TIME
|
||||
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
4
frontend/src/pages/DashboardPageV2/constants/events.ts
Normal file
4
frontend/src/pages/DashboardPageV2/constants/events.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum DashboardEvents {
|
||||
SWITCH_TO_EDIT_MODE = 'View Panel: Switch to edit mode',
|
||||
SWITCH_TO_VIEW_MODE = 'Edit Panel: Switch to view mode',
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { ArrowUpRight } from '@signozhq/icons';
|
||||
import styles from './MissingSpansBanner.module.scss';
|
||||
|
||||
const MISSING_SPANS_DOCS_URL =
|
||||
'https://signoz.io/docs/userguide/traces/#missing-spans';
|
||||
'https://signoz.io/docs/traces-management/troubleshooting/faqs/#q-why-are-some-spans-missing-from-a-trace';
|
||||
|
||||
function MissingSpansBanner(): JSX.Element | null {
|
||||
// Session-only dismissal — not persisted, so the banner returns on reload.
|
||||
|
||||
@@ -14,7 +14,8 @@ const DOCLINKS = {
|
||||
'https://signoz.io/docs/userguide/logs_clickhouse_queries/',
|
||||
QUERY_CLICKHOUSE_METRICS:
|
||||
'https://signoz.io/docs/userguide/write-a-metrics-clickhouse-query/',
|
||||
AGENT_SKILL_INSTALL: 'https://signoz.io/docs/ai/agent-skills/#installation',
|
||||
AGENT_SKILL_INSTALL:
|
||||
'https://signoz.io/docs/ai/agent-skills/#install-the-plugin',
|
||||
};
|
||||
|
||||
export default DOCLINKS;
|
||||
|
||||
@@ -4,13 +4,26 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func telemetryReadScopes() []string {
|
||||
return []string{
|
||||
coretypes.ResourceTelemetryResourceLogs.Scope(coretypes.VerbRead),
|
||||
coretypes.ResourceTelemetryResourceTraces.Scope(coretypes.VerbRead),
|
||||
coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead),
|
||||
coretypes.ResourceTelemetryResourceAuditLogs.Scope(coretypes.VerbRead),
|
||||
coretypes.ResourceTelemetryResourceMeterMetrics.Scope(coretypes.VerbRead),
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v5/query_range", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.QueryRange), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v5/query_range", handler.New(provider.authzMiddleware.CheckResources(provider.querierHandler.QueryRange, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName), handler.OpenAPIDef{
|
||||
ID: "QueryRangeV5",
|
||||
Tags: []string{"querier"},
|
||||
Summary: "Query range",
|
||||
@@ -446,12 +459,17 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
SecuritySchemes: newScopedSecuritySchemes(telemetryReadScopes()),
|
||||
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.QueryRangeResources,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v5/query_range/preview", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.QueryRangePreview), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v5/query_range/preview", handler.New(provider.authzMiddleware.CheckResources(provider.querierHandler.QueryRangePreview, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName), handler.OpenAPIDef{
|
||||
ID: "QueryRangePreviewV5",
|
||||
Tags: []string{"querier"},
|
||||
Summary: "Query range preview",
|
||||
@@ -463,8 +481,13 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
SecuritySchemes: newScopedSecuritySchemes(telemetryReadScopes()),
|
||||
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.QueryRangeResources,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
|
||||
Description: "This endpoint lists all roles",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*authtypes.Role, 0),
|
||||
Response: make([]*authtypes.GettableRole, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
@@ -73,7 +73,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
|
||||
Description: "This endpoint gets a role",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.RoleWithTransactionGroups),
|
||||
Response: new(authtypes.Role),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
|
||||
@@ -30,17 +30,14 @@ type AuthZ interface {
|
||||
// Write accepts the insertion tuples and the deletion tuples.
|
||||
Write(context.Context, []*openfgav1.TupleKey, []*openfgav1.TupleKey) error
|
||||
|
||||
// Lists the selectors for objects assigned to subject (s) with relation (r) on resource (s)
|
||||
ListObjects(context.Context, string, authtypes.Relation, coretypes.Type) ([]*coretypes.Object, error)
|
||||
// ReadTuples reads tuples from the authorization server matching the given tuple key filter.
|
||||
ReadTuples(context.Context, *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error)
|
||||
|
||||
// Creates the role with its transaction groups.
|
||||
Create(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
|
||||
|
||||
// Gets the role if it exists or creates one.
|
||||
GetOrCreate(context.Context, valuer.UUID, *authtypes.Role) (*authtypes.Role, error)
|
||||
Create(context.Context, valuer.UUID, *authtypes.Role) error
|
||||
|
||||
// Updates the role's metadata and reconciles its transaction groups.
|
||||
Update(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
|
||||
Update(context.Context, valuer.UUID, *authtypes.Role) error
|
||||
|
||||
// Deletes the role and tuples in authorization server.
|
||||
Delete(context.Context, valuer.UUID, valuer.UUID) error
|
||||
@@ -48,9 +45,6 @@ type AuthZ interface {
|
||||
// Gets the role
|
||||
Get(context.Context, valuer.UUID, valuer.UUID) (*authtypes.Role, error)
|
||||
|
||||
// Gets the role with transaction groups
|
||||
GetWithTransactionGroups(context.Context, valuer.UUID, valuer.UUID) (*authtypes.RoleWithTransactionGroups, error)
|
||||
|
||||
// Gets the role by org_id and name
|
||||
GetByOrgIDAndName(context.Context, valuer.UUID, string) (*authtypes.Role, error)
|
||||
|
||||
@@ -80,9 +74,6 @@ type AuthZ interface {
|
||||
|
||||
// Bootstrap managed roles transactions and user assignments
|
||||
CreateManagedUserRoleTransactions(context.Context, valuer.UUID, valuer.UUID) error
|
||||
|
||||
// ReadTuples reads tuples from the authorization server matching the given tuple key filter.
|
||||
ReadTuples(context.Context, *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error)
|
||||
}
|
||||
|
||||
// OnBeforeRoleDelete is a callback invoked before a role is deleted.
|
||||
|
||||
@@ -83,10 +83,6 @@ func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.
|
||||
return provider.store.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
|
||||
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
|
||||
return provider.store.GetByOrgIDAndName(ctx, orgID, name)
|
||||
}
|
||||
@@ -181,15 +177,11 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
|
||||
return provider.Grant(ctx, orgID, []string{authtypes.SigNozAdminRoleName}, authtypes.MustNewSubject(coretypes.NewResourceUser(), userID.String(), orgID, nil))
|
||||
}
|
||||
|
||||
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.RoleWithTransactionGroups) error {
|
||||
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
|
||||
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) GetOrCreate(_ context.Context, _ valuer.UUID, _ *authtypes.Role) (*authtypes.Role, error) {
|
||||
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) Update(_ context.Context, _ valuer.UUID, _ *authtypes.RoleWithTransactionGroups) error {
|
||||
func (provider *provider) Update(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
|
||||
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
|
||||
@@ -36,14 +36,14 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
roleWithTransactionGroups := authtypes.NewRoleWithTransactionGroups(req.Name, req.Description, authtypes.RoleTypeCustom, valuer.MustNewUUID(claims.OrgID), req.TransactionGroups)
|
||||
err = handler.authz.Create(ctx, valuer.MustNewUUID(claims.OrgID), roleWithTransactionGroups)
|
||||
role := authtypes.NewRole(req.Name, req.Description, authtypes.RoleTypeCustom, valuer.MustNewUUID(claims.OrgID), req.TransactionGroups)
|
||||
err = handler.authz.Create(ctx, valuer.MustNewUUID(claims.OrgID), role)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, types.Identifiable{ID: roleWithTransactionGroups.ID})
|
||||
render.Success(rw, http.StatusCreated, types.Identifiable{ID: role.ID})
|
||||
}
|
||||
|
||||
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -65,13 +65,13 @@ func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
roleWithTransactionGroups, err := handler.authz.GetWithTransactionGroups(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
|
||||
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, roleWithTransactionGroups)
|
||||
render.Success(rw, http.StatusOK, role)
|
||||
}
|
||||
|
||||
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -88,7 +88,7 @@ func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, roles)
|
||||
render.Success(rw, http.StatusOK, authtypes.NewGettableRolesFromRoles(roles))
|
||||
}
|
||||
|
||||
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -117,14 +117,13 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
roleWithTransactionGroups := authtypes.MakeRoleWithTransactionGroups(role, nil)
|
||||
err = roleWithTransactionGroups.Update(req.Description, req.TransactionGroups)
|
||||
err = role.Update(req.Description, req.TransactionGroups)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), roleWithTransactionGroups)
|
||||
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), role)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -15,8 +15,6 @@ var (
|
||||
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
|
||||
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
|
||||
FeatureUseInfraMonitoringV2 = featuretypes.MustNewName("use_infra_monitoring_v2")
|
||||
|
||||
FeatureUsePrometheusClickhouseV2 = featuretypes.MustNewName("use_prometheus_clickhouse_v2")
|
||||
)
|
||||
|
||||
func MustNewRegistry() featuretypes.Registry {
|
||||
@@ -117,14 +115,6 @@ func MustNewRegistry() featuretypes.Registry {
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureUsePrometheusClickhouseV2,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
Stage: featuretypes.StageExperimental,
|
||||
Description: "Runs PromQL queries on the clickhousev2 provider alongside the served engine result and logs any difference; serving is unaffected.",
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package handler
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
)
|
||||
|
||||
type ResourceDef interface {
|
||||
// resolveRequest is unexported to seal the interface. It returns a slice so a
|
||||
@@ -97,3 +100,31 @@ func (def AttachDetachParentChildResourceDef) resolveRequest(ec coretypes.Extrac
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type TelemetryResourceDef struct {
|
||||
Verb coretypes.Verb
|
||||
Category coretypes.ActionCategory
|
||||
Selector coretypes.SelectorFunc
|
||||
Resources coretypes.ResourceExtractor
|
||||
}
|
||||
|
||||
func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
refs, err := def.Resources(ec)
|
||||
if err != nil {
|
||||
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(def.Verb, def.Category, err)}
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(
|
||||
def.Verb,
|
||||
def.Category,
|
||||
errors.NewInvalidInputf(errors.CodeInvalidInput, "request resolved to no resources"),
|
||||
)}
|
||||
}
|
||||
|
||||
resolved := make([]coretypes.ResolvedResource, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
resolved = append(resolved, coretypes.NewResolvedResourceWithID(def.Verb, def.Category, ref.Resource, ref.ID, def.Selector))
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -118,6 +118,10 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
|
||||
extractorCtx := coretypes.ExtractorContext{Request: req, ResponseBody: writer.BodyBytes()}
|
||||
|
||||
for _, resource := range resolved {
|
||||
if err := resource.Err(); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
resource.ResolveResponse(extractorCtx)
|
||||
verb, category := resource.Verb(), resource.Category()
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"id": "cloudsql",
|
||||
"title": "GCP Cloud SQL",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [],
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "GCP Cloud SQL Overview",
|
||||
"description": "Overview of GCP Cloud SQL metrics",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
### Monitor GCP Cloud SQL with SigNoz
|
||||
|
||||
Collect key GCP Cloud SQL metrics and view them with an out of the box dashboard.
|
||||
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 933 B After Width: | Height: | Size: 933 B |
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"id": "cloudsql_postgres",
|
||||
"title": "GCP Cloud SQL for PostgreSQL",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/up",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/cpu/utilization",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/memory/utilization",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/memory/usage",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/disk/bytes_used",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/num_backends",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/num_backends_by_state",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/transaction_count",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/deadlock_count",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/vacuum/oldest_transaction_age",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/insights/aggregate/execution_time",
|
||||
"unit": "Microseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/insights/perquery/execution_time",
|
||||
"unit": "Microseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/replication/replica_byte_lag",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "GCP Cloud SQL for PostgreSQL Overview",
|
||||
"description": "Overview of GCP Cloud SQL for PostgreSQL metrics",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
### Monitor GCP Cloud SQL for PostgreSQL with SigNoz
|
||||
|
||||
Collect key GCP Cloud SQL for PostgreSQL metrics and view them with an out of the box dashboard.
|
||||
@@ -16,11 +16,11 @@ const (
|
||||
|
||||
// Documentation links — one per component. User-facing; emitted on missing-entries.
|
||||
const (
|
||||
docLinkHostMetricsReceiver = "https://signoz.io/docs/infrastructure-monitoring/user-guides/hostmetrics/#configure-the-hostmetrics-receiver"
|
||||
docLinkKubeletStatsReceiver = "https://signoz.io/docs/infrastructure-monitoring/user-guides/k8s-metrics/#setup-kubelet-stats-receiver"
|
||||
docLinkK8sClusterReceiver = "https://signoz.io/docs/infrastructure-monitoring/user-guides/k8s-metrics/#setup-k8s-cluster-receiver"
|
||||
docLinkResourceDetectionProcessor = "https://signoz.io/docs/infrastructure-monitoring/user-guides/hostmetrics/#configure-the-resourcedetection-processor"
|
||||
docLinkK8sAttributesProcessor = "https://signoz.io/docs/infrastructure-monitoring/user-guides/k8s-metrics/#3-setup-k8sattributesprocessor-to-enable-kubernetes-metadata"
|
||||
docLinkHostMetricsReceiver = "https://signoz.io/docs/infrastructure-monitoring/hostmetrics/#configure-the-hostmetrics-receiver"
|
||||
docLinkKubeletStatsReceiver = "https://signoz.io/docs/infrastructure-monitoring/k8s-metrics/#2-configure-the-kubelet-stats-receiver"
|
||||
docLinkK8sClusterReceiver = "https://signoz.io/docs/infrastructure-monitoring/k8s-metrics/#1-configure-the-k8s-cluster-receiver"
|
||||
docLinkResourceDetectionProcessor = "https://signoz.io/docs/infrastructure-monitoring/hostmetrics/#configure-the-processors"
|
||||
docLinkK8sAttributesProcessor = "https://signoz.io/docs/infrastructure-monitoring/k8s-metrics/#3-enable-kubernetes-metadata"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user