mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-10 19:00:34 +01:00
Compare commits
11 Commits
feat/auth-
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
048db465c0 | ||
|
|
fd25056373 | ||
|
|
d882d4e775 | ||
|
|
1fe46f4bb6 | ||
|
|
9fa5f87249 | ||
|
|
b8f5835c2b | ||
|
|
f38fa7f3ac | ||
|
|
0b632b6765 | ||
|
|
9622c867a2 | ||
|
|
a2e75cf5ba | ||
|
|
b2cba2aa2c |
@@ -470,6 +470,25 @@ components:
|
||||
role:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesAuthDomainConfig:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AuthtypesSamlConfig'
|
||||
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
|
||||
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
|
||||
properties:
|
||||
googleAuthConfig:
|
||||
$ref: '#/components/schemas/AuthtypesGoogleConfig'
|
||||
oidcConfig:
|
||||
$ref: '#/components/schemas/AuthtypesOIDCConfig'
|
||||
roleMapping:
|
||||
$ref: '#/components/schemas/AuthtypesRoleMapping'
|
||||
samlConfig:
|
||||
$ref: '#/components/schemas/AuthtypesSamlConfig'
|
||||
ssoEnabled:
|
||||
type: boolean
|
||||
ssoType:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
type: object
|
||||
AuthtypesAuthNProvider:
|
||||
enum:
|
||||
- google_auth
|
||||
@@ -496,48 +515,6 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
AuthtypesAuthProviderEnvelope:
|
||||
discriminator:
|
||||
mapping:
|
||||
google_auth: '#/components/schemas/AuthtypesAuthProviderGoogle'
|
||||
oidc: '#/components/schemas/AuthtypesAuthProviderOIDC'
|
||||
saml: '#/components/schemas/AuthtypesAuthProviderSAML'
|
||||
propertyName: type
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AuthtypesAuthProviderSAML'
|
||||
- $ref: '#/components/schemas/AuthtypesAuthProviderOIDC'
|
||||
- $ref: '#/components/schemas/AuthtypesAuthProviderGoogle'
|
||||
type: object
|
||||
AuthtypesAuthProviderGoogle:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesGoogleConfig'
|
||||
type:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
required:
|
||||
- type
|
||||
- config
|
||||
type: object
|
||||
AuthtypesAuthProviderOIDC:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesOIDCConfig'
|
||||
type:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
required:
|
||||
- type
|
||||
- config
|
||||
type: object
|
||||
AuthtypesAuthProviderSAML:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesSAMLConfig'
|
||||
type:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
required:
|
||||
- type
|
||||
- config
|
||||
type: object
|
||||
AuthtypesCallbackAuthNSupport:
|
||||
properties:
|
||||
provider:
|
||||
@@ -549,6 +526,8 @@ components:
|
||||
properties:
|
||||
authNProviderInfo:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProviderInfo'
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -558,12 +537,6 @@ components:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
provider:
|
||||
$ref: '#/components/schemas/AuthtypesAuthProviderEnvelope'
|
||||
roleMapping:
|
||||
$ref: '#/components/schemas/AuthtypesRoleMapping'
|
||||
ssoEnabled:
|
||||
type: boolean
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -661,14 +634,10 @@ components:
|
||||
type: object
|
||||
AuthtypesPostableAuthDomain:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
|
||||
name:
|
||||
type: string
|
||||
provider:
|
||||
$ref: '#/components/schemas/AuthtypesAuthProviderEnvelope'
|
||||
roleMapping:
|
||||
$ref: '#/components/schemas/AuthtypesRoleMapping'
|
||||
ssoEnabled:
|
||||
type: boolean
|
||||
type: object
|
||||
AuthtypesPostableEmailPasswordSession:
|
||||
properties:
|
||||
@@ -741,7 +710,7 @@ components:
|
||||
useRoleAttribute:
|
||||
type: boolean
|
||||
type: object
|
||||
AuthtypesSAMLConfig:
|
||||
AuthtypesSamlConfig:
|
||||
properties:
|
||||
attributeMapping:
|
||||
$ref: '#/components/schemas/AuthtypesAttributeMapping'
|
||||
@@ -776,12 +745,8 @@ components:
|
||||
type: object
|
||||
AuthtypesUpdatableAuthDomain:
|
||||
properties:
|
||||
provider:
|
||||
$ref: '#/components/schemas/AuthtypesAuthProviderEnvelope'
|
||||
roleMapping:
|
||||
$ref: '#/components/schemas/AuthtypesRoleMapping'
|
||||
ssoEnabled:
|
||||
type: boolean
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
|
||||
type: object
|
||||
AuthtypesUserRole:
|
||||
properties:
|
||||
|
||||
@@ -53,7 +53,7 @@ func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSett
|
||||
}
|
||||
|
||||
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
|
||||
if authDomain.AuthDomainConfig().Provider.Type != authtypes.AuthNProviderOIDC {
|
||||
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderOIDC {
|
||||
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not oidc")
|
||||
}
|
||||
|
||||
@@ -106,14 +106,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims == nil && authDomain.AuthDomainConfig().Oidc().GetUserInfo {
|
||||
if claims == nil && authDomain.AuthDomainConfig().OIDC.GetUserInfo {
|
||||
claims, err = a.claimsFromUserInfo(ctx, oidcProvider, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
emailClaim, ok := claims[authDomain.AuthDomainConfig().Oidc().ClaimMapping.Email].(string)
|
||||
emailClaim, ok := claims[authDomain.AuthDomainConfig().OIDC.ClaimMapping.Email].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email in claims")
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to parse email").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
if !authDomain.AuthDomainConfig().Oidc().InsecureSkipEmailVerified {
|
||||
if !authDomain.AuthDomainConfig().OIDC.InsecureSkipEmailVerified {
|
||||
emailVerifiedClaim, ok := claims["email_verified"].(bool)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email_verified in claims")
|
||||
@@ -135,14 +135,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
}
|
||||
|
||||
name := ""
|
||||
if nameClaim := authDomain.AuthDomainConfig().Oidc().ClaimMapping.Name; nameClaim != "" {
|
||||
if nameClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Name; nameClaim != "" {
|
||||
if n, ok := claims[nameClaim].(string); ok {
|
||||
name = n
|
||||
}
|
||||
}
|
||||
|
||||
var groups []string
|
||||
if groupsClaim := authDomain.AuthDomainConfig().Oidc().ClaimMapping.Groups; groupsClaim != "" {
|
||||
if groupsClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Groups; groupsClaim != "" {
|
||||
if claimValue, exists := claims[groupsClaim]; exists {
|
||||
switch g := claimValue.(type) {
|
||||
case []any:
|
||||
@@ -161,7 +161,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
}
|
||||
|
||||
role := ""
|
||||
if roleClaim := authDomain.AuthDomainConfig().Oidc().ClaimMapping.Role; roleClaim != "" {
|
||||
if roleClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Role; roleClaim != "" {
|
||||
if r, ok := claims[roleClaim].(string); ok {
|
||||
role = r
|
||||
}
|
||||
@@ -177,11 +177,11 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
|
||||
}
|
||||
|
||||
func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (*oidc.Provider, *oauth2.Config, error) {
|
||||
if authDomain.AuthDomainConfig().Oidc().IssuerAlias != "" {
|
||||
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().Oidc().IssuerAlias)
|
||||
if authDomain.AuthDomainConfig().OIDC.IssuerAlias != "" {
|
||||
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().OIDC.IssuerAlias)
|
||||
}
|
||||
|
||||
oidcProvider, err := oidc.NewProvider(ctx, authDomain.AuthDomainConfig().Oidc().Issuer)
|
||||
oidcProvider, err := oidc.NewProvider(ctx, authDomain.AuthDomainConfig().OIDC.Issuer)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -194,8 +194,8 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
|
||||
}
|
||||
|
||||
return oidcProvider, &oauth2.Config{
|
||||
ClientID: authDomain.AuthDomainConfig().Oidc().ClientID,
|
||||
ClientSecret: authDomain.AuthDomainConfig().Oidc().ClientSecret,
|
||||
ClientID: authDomain.AuthDomainConfig().OIDC.ClientID,
|
||||
ClientSecret: authDomain.AuthDomainConfig().OIDC.ClientSecret,
|
||||
Endpoint: oidcProvider.Endpoint(),
|
||||
Scopes: scopes,
|
||||
RedirectURL: (&url.URL{
|
||||
@@ -212,7 +212,7 @@ func (a *AuthN) claimsFromIDToken(ctx context.Context, authDomain *authtypes.Aut
|
||||
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "oidc: no id_token in token response")
|
||||
}
|
||||
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().Oidc().ClientID})
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().OIDC.ClientID})
|
||||
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "oidc: failed to verify token").WithAdditional(err.Error())
|
||||
|
||||
@@ -40,7 +40,7 @@ func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Li
|
||||
}
|
||||
|
||||
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
|
||||
if authDomain.AuthDomainConfig().Provider.Type != authtypes.AuthNProviderSAML {
|
||||
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderSAML {
|
||||
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "saml: domain type is not saml")
|
||||
}
|
||||
|
||||
@@ -101,19 +101,19 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
|
||||
}
|
||||
|
||||
name := ""
|
||||
if nameAttribute := authDomain.AuthDomainConfig().Saml().AttributeMapping.Name; nameAttribute != "" {
|
||||
if nameAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Name; nameAttribute != "" {
|
||||
if val := assertionInfo.Values.Get(nameAttribute); val != "" {
|
||||
name = val
|
||||
}
|
||||
}
|
||||
|
||||
var groups []string
|
||||
if groupAttribute := authDomain.AuthDomainConfig().Saml().AttributeMapping.Groups; groupAttribute != "" {
|
||||
if groupAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Groups; groupAttribute != "" {
|
||||
groups = assertionInfo.Values.GetAll(groupAttribute)
|
||||
}
|
||||
|
||||
role := ""
|
||||
if roleAttribute := authDomain.AuthDomainConfig().Saml().AttributeMapping.Role; roleAttribute != "" {
|
||||
if roleAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Role; roleAttribute != "" {
|
||||
if val := assertionInfo.Values.Get(roleAttribute); val != "" {
|
||||
role = val
|
||||
}
|
||||
@@ -142,11 +142,11 @@ func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDoma
|
||||
// The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak.
|
||||
// For AWSSSO, this is the value of Application SAML audience.
|
||||
return &saml2.SAMLServiceProvider{
|
||||
IdentityProviderSSOURL: authDomain.AuthDomainConfig().Saml().SamlIdp,
|
||||
IdentityProviderIssuer: authDomain.AuthDomainConfig().Saml().SamlEntity,
|
||||
IdentityProviderSSOURL: authDomain.AuthDomainConfig().SAML.SamlIdp,
|
||||
IdentityProviderIssuer: authDomain.AuthDomainConfig().SAML.SamlEntity,
|
||||
ServiceProviderIssuer: siteURL.Host,
|
||||
AssertionConsumerServiceURL: acsURL.String(),
|
||||
SignAuthnRequests: !authDomain.AuthDomainConfig().Saml().InsecureSkipAuthNRequestsSigned,
|
||||
SignAuthnRequests: !authDomain.AuthDomainConfig().SAML.InsecureSkipAuthNRequestsSigned,
|
||||
AllowMissingAttributes: true,
|
||||
IDPCertificateStore: certStore,
|
||||
SPKeyStore: dsig.RandomKeyStoreForTest(),
|
||||
@@ -159,15 +159,15 @@ func (a *AuthN) getCertificateStore(authDomain *authtypes.AuthDomain) (dsig.X509
|
||||
}
|
||||
|
||||
var certBytes []byte
|
||||
if strings.Contains(authDomain.AuthDomainConfig().Saml().SamlCert, "-----BEGIN CERTIFICATE-----") {
|
||||
block, _ := pem.Decode([]byte(authDomain.AuthDomainConfig().Saml().SamlCert))
|
||||
if strings.Contains(authDomain.AuthDomainConfig().SAML.SamlCert, "-----BEGIN CERTIFICATE-----") {
|
||||
block, _ := pem.Decode([]byte(authDomain.AuthDomainConfig().SAML.SamlCert))
|
||||
if block == nil {
|
||||
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no valid pem cert found")
|
||||
}
|
||||
|
||||
certBytes = block.Bytes
|
||||
} else {
|
||||
certData, err := base64.StdEncoding.DecodeString(authDomain.AuthDomainConfig().Saml().SamlCert)
|
||||
certData, err := base64.StdEncoding.DecodeString(authDomain.AuthDomainConfig().SAML.SamlCert)
|
||||
if err != nil {
|
||||
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to read certificate: %s", err.Error())
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ function FieldsSelector({
|
||||
() =>
|
||||
fields.map((f) => ({
|
||||
...f,
|
||||
key: f.key ?? buildCompositeKey(f.name, f.fieldContext),
|
||||
key: f.key ?? buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
})),
|
||||
[fields],
|
||||
);
|
||||
|
||||
@@ -52,14 +52,20 @@ function OtherFields({
|
||||
const normalizedSuggestions: TelemetryFieldKey[] = suggestions.map(
|
||||
(attr) => ({
|
||||
...attr,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext as string),
|
||||
key: buildCompositeKey(
|
||||
attr.name,
|
||||
attr.fieldContext as string,
|
||||
attr.fieldDataType as string | undefined,
|
||||
),
|
||||
signal: attr.signal as SignalType,
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType as FieldDataType,
|
||||
}),
|
||||
);
|
||||
const addedIds = new Set(
|
||||
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
|
||||
addedFields.map(
|
||||
(f) => f.key ?? buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
),
|
||||
);
|
||||
return normalizedSuggestions.filter(
|
||||
(attr) => !addedIds.has(attr.key as string),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
font-family: var(--traces-table-font, inherit);
|
||||
|
||||
--row-hover-bg: var(--l1-border);
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
113
frontend/src/components/Traces/TableView/TracesTable.tsx
Normal file
113
frontend/src/components/Traces/TableView/TracesTable.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import styles from './TracesTable.module.scss';
|
||||
|
||||
export type TracesTablePanelType = 'LIST' | 'TRACE';
|
||||
|
||||
export type TracesTableProps<TRow> = {
|
||||
data: TRow[];
|
||||
columns: TableColumnDef<TRow>[];
|
||||
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: APIError | Error | null;
|
||||
isFilterApplied: boolean;
|
||||
panelType: TracesTablePanelType;
|
||||
|
||||
columnStorageKey: string;
|
||||
respectColumnOrder?: boolean;
|
||||
cellTypographySize?: 'small' | 'medium' | 'large';
|
||||
|
||||
onColumnOrderChange?: (cols: TableColumnDef<TRow>[]) => void;
|
||||
onColumnRemove?: (id: string) => void;
|
||||
|
||||
/** Build the href for a row. Wrapper handles same-tab navigation + cmd-click new-tab dispatch. */
|
||||
getRowHref: (row: TRow) => string;
|
||||
|
||||
onEndReached: () => void;
|
||||
};
|
||||
|
||||
export function TracesTable<TRow>({
|
||||
data,
|
||||
columns,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
isFilterApplied,
|
||||
panelType,
|
||||
columnStorageKey,
|
||||
respectColumnOrder,
|
||||
cellTypographySize = 'medium',
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
getRowHref,
|
||||
onEndReached,
|
||||
}: TracesTableProps<TRow>): JSX.Element {
|
||||
const history = useHistory();
|
||||
const isEmpty = data.length === 0;
|
||||
const isInitialLoading = (isLoading || isFetching) && isEmpty;
|
||||
|
||||
const onRowClick = useCallback(
|
||||
(row: TRow): void => {
|
||||
history.push(getRowHref(row));
|
||||
},
|
||||
[history, getRowHref],
|
||||
);
|
||||
|
||||
const onRowClickNewTab = useCallback(
|
||||
(row: TRow): void => {
|
||||
window.open(
|
||||
getAbsoluteUrl(getRowHref(row)),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
},
|
||||
[getRowHref],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{isInitialLoading && <TracesLoading />}
|
||||
|
||||
{!isLoading && !isFetching && !isError && !isFilterApplied && isEmpty && (
|
||||
<NoLogs dataSource={DataSource.TRACES} />
|
||||
)}
|
||||
|
||||
{!isLoading && !isFetching && isEmpty && !isError && isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
|
||||
)}
|
||||
|
||||
{!isEmpty && (
|
||||
<div className={styles.tableWrapper}>
|
||||
<TanStackTable<TRow>
|
||||
data={data}
|
||||
columns={columns}
|
||||
columnStorageKey={columnStorageKey}
|
||||
respectColumnOrder={respectColumnOrder}
|
||||
cellTypographySize={cellTypographySize}
|
||||
isLoading={isLoading || isFetching}
|
||||
onEndReached={onEndReached}
|
||||
onColumnOrderChange={onColumnOrderChange}
|
||||
onColumnRemove={onColumnRemove}
|
||||
onRowClick={onRowClick}
|
||||
onRowClickNewTab={onRowClickNewTab}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import type { Pagination } from 'hooks/queryPagination';
|
||||
|
||||
import { useTraceInfiniteQuery } from '../useTraceInfiniteQuery';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useGetQueryRange', () => ({
|
||||
useGetQueryRange: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseGetQueryRange = useGetQueryRange as jest.MockedFunction<
|
||||
typeof useGetQueryRange
|
||||
>;
|
||||
|
||||
type Row = { id: string; name: string };
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Builds a fake `useGetQueryRange` return shape with a payload that
|
||||
* transforms into N rows.
|
||||
*/
|
||||
const makeQueryResult = (
|
||||
rowsForPage: Row[],
|
||||
overrides: Partial<{
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
}> = {},
|
||||
): any => ({
|
||||
data: {
|
||||
payload: { rows: rowsForPage },
|
||||
warning: undefined,
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
params: {} as any,
|
||||
warnings: [],
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const emptyQueryResult = (
|
||||
overrides: Partial<{
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
}> = {},
|
||||
): any => ({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Generates N rows for a given page. */
|
||||
const makePage = (offset: number, count: number): Row[] =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `row-${offset + i}`,
|
||||
name: `name-${offset + i}`,
|
||||
}));
|
||||
|
||||
const baseProps = (queryDeps: unknown[] = ['Q1']): any => ({
|
||||
queryDeps,
|
||||
buildRequest: jest.fn((pagination: Pagination) => ({
|
||||
query: {} as any,
|
||||
graphType: 'LIST' as any,
|
||||
selectedTime: 'GLOBAL_TIME' as any,
|
||||
globalSelectedInterval: '5m' as any,
|
||||
params: { dataSource: 'traces' },
|
||||
tableParams: { pagination },
|
||||
})),
|
||||
transformResponse: jest.fn(
|
||||
(payload: any): Row[] => (payload?.rows as Row[]) ?? [],
|
||||
),
|
||||
enabled: true,
|
||||
entityVersion: 'v5',
|
||||
panelType: 'LIST',
|
||||
});
|
||||
|
||||
describe('useTraceInfiniteQuery', () => {
|
||||
beforeEach(() => {
|
||||
mockedUseGetQueryRange.mockReset();
|
||||
});
|
||||
|
||||
it('starts with empty rows and hasMore=true; appends first page when data arrives', () => {
|
||||
mockedUseGetQueryRange.mockReturnValue(emptyQueryResult());
|
||||
|
||||
const props = baseProps();
|
||||
const { result, rerender } = renderHook(() => useTraceInfiniteQuery(props));
|
||||
|
||||
expect(result.current.rows).toStrictEqual([]);
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
// First page returns 50 rows (full page → hasMore stays true).
|
||||
const page1 = makePage(0, PAGE_SIZE);
|
||||
mockedUseGetQueryRange.mockReturnValue(makeQueryResult(page1));
|
||||
|
||||
rerender();
|
||||
expect(result.current.rows).toStrictEqual(page1);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('appends the next page when handleEndReached is called (no replace)', () => {
|
||||
const page1 = makePage(0, PAGE_SIZE);
|
||||
mockedUseGetQueryRange.mockReturnValue(makeQueryResult(page1));
|
||||
|
||||
const props = baseProps();
|
||||
const { result, rerender } = renderHook(() => useTraceInfiniteQuery(props));
|
||||
|
||||
expect(result.current.rows).toHaveLength(PAGE_SIZE);
|
||||
|
||||
// Trigger next page — pagination state bumps offset.
|
||||
act(() => {
|
||||
result.current.handleEndReached();
|
||||
});
|
||||
|
||||
// buildRequest is called again with the new offset; the hook would now ask
|
||||
// the mocked useGetQueryRange for the next slice. Simulate that by swapping
|
||||
// the returned payload to page 2 and rerendering.
|
||||
const page2 = makePage(PAGE_SIZE, PAGE_SIZE);
|
||||
mockedUseGetQueryRange.mockReturnValue(makeQueryResult(page2));
|
||||
rerender();
|
||||
|
||||
// Accumulator keeps page 1 + page 2.
|
||||
expect(result.current.rows).toHaveLength(PAGE_SIZE * 2);
|
||||
expect(result.current.rows[0]).toStrictEqual(page1[0]);
|
||||
expect(result.current.rows[PAGE_SIZE]).toStrictEqual(page2[0]);
|
||||
|
||||
// buildRequest was called with offset 0 on first render and offset PAGE_SIZE
|
||||
// after handleEndReached.
|
||||
const offsets = props.buildRequest.mock.calls.map(
|
||||
(call: any) => call[0].offset,
|
||||
);
|
||||
expect(offsets).toContain(0);
|
||||
expect(offsets).toContain(PAGE_SIZE);
|
||||
});
|
||||
|
||||
it('sets hasMore=false when fewer than PAGE_SIZE rows are returned; handleEndReached is a no-op afterwards', () => {
|
||||
const partialPage = makePage(0, 12);
|
||||
mockedUseGetQueryRange.mockReturnValue(makeQueryResult(partialPage));
|
||||
|
||||
const props = baseProps();
|
||||
const { result } = renderHook(() => useTraceInfiniteQuery(props));
|
||||
|
||||
expect(result.current.rows).toStrictEqual(partialPage);
|
||||
|
||||
// Capture buildRequest calls before EOF trigger.
|
||||
const callsBefore = props.buildRequest.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
result.current.handleEndReached();
|
||||
});
|
||||
|
||||
// hasMore should be false → handleEndReached short-circuits, no extra
|
||||
// buildRequest call (pagination state unchanged → no fetch trigger).
|
||||
expect(props.buildRequest.mock.calls).toHaveLength(callsBefore);
|
||||
});
|
||||
|
||||
it('resets accumulator + pagination when queryDeps change', () => {
|
||||
const page1 = makePage(0, PAGE_SIZE);
|
||||
mockedUseGetQueryRange.mockReturnValue(makeQueryResult(page1));
|
||||
|
||||
const props = baseProps(['Q1']);
|
||||
const { result, rerender } = renderHook(
|
||||
(p: any) => useTraceInfiniteQuery(p),
|
||||
{ initialProps: props },
|
||||
);
|
||||
|
||||
expect(result.current.rows).toHaveLength(PAGE_SIZE);
|
||||
|
||||
// Scroll past page 1.
|
||||
act(() => {
|
||||
result.current.handleEndReached();
|
||||
});
|
||||
const page2 = makePage(PAGE_SIZE, PAGE_SIZE);
|
||||
mockedUseGetQueryRange.mockReturnValue(makeQueryResult(page2));
|
||||
rerender(props);
|
||||
expect(result.current.rows).toHaveLength(PAGE_SIZE * 2);
|
||||
|
||||
// queryDeps change → reset should clear the accumulator.
|
||||
const newPage = makePage(0, 5);
|
||||
mockedUseGetQueryRange.mockReturnValue(makeQueryResult(newPage));
|
||||
const nextProps = { ...props, queryDeps: ['Q2'] };
|
||||
rerender(nextProps);
|
||||
|
||||
expect(result.current.rows).toStrictEqual(newPage);
|
||||
});
|
||||
|
||||
it('propagates isError and error from useGetQueryRange', () => {
|
||||
const err = new Error('boom');
|
||||
mockedUseGetQueryRange.mockReturnValue(
|
||||
emptyQueryResult({ isLoading: false, isError: true, error: err }),
|
||||
);
|
||||
|
||||
const props = baseProps();
|
||||
const { result } = renderHook(() => useTraceInfiniteQuery(props));
|
||||
|
||||
expect(result.current.isError).toBe(true);
|
||||
expect(result.current.error).toBe(err);
|
||||
expect(result.current.rows).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('calls setIsLoadingQueries when isLoading/isFetching changes', () => {
|
||||
const setIsLoadingQueries = jest.fn();
|
||||
mockedUseGetQueryRange.mockReturnValue(
|
||||
emptyQueryResult({ isLoading: true, isFetching: false }),
|
||||
);
|
||||
|
||||
const props = { ...baseProps(), setIsLoadingQueries };
|
||||
const { rerender } = renderHook(() => useTraceInfiniteQuery(props));
|
||||
|
||||
expect(setIsLoadingQueries).toHaveBeenCalledWith(true);
|
||||
|
||||
mockedUseGetQueryRange.mockReturnValue(
|
||||
makeQueryResult([], { isLoading: false, isFetching: false }),
|
||||
);
|
||||
rerender();
|
||||
|
||||
expect(setIsLoadingQueries).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('publishes the constructed queryKey via queryKeyRef', () => {
|
||||
mockedUseGetQueryRange.mockReturnValue(emptyQueryResult());
|
||||
|
||||
const queryKeyRef = { current: null as unknown };
|
||||
const props = { ...baseProps(['orderBy-asc', 'time-1h']), queryKeyRef };
|
||||
|
||||
renderHook(() => useTraceInfiniteQuery(props));
|
||||
|
||||
expect(Array.isArray(queryKeyRef.current)).toBe(true);
|
||||
// First element is the GET_QUERY_RANGE tag, then pagination, then the
|
||||
// caller's queryDeps spread.
|
||||
expect(queryKeyRef.current as unknown[]).toStrictEqual(
|
||||
expect.arrayContaining(['orderBy-asc', 'time-1h']),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards data.warning to setWarning when payload is present', () => {
|
||||
const setWarning = jest.fn();
|
||||
mockedUseGetQueryRange.mockReturnValue({
|
||||
data: {
|
||||
payload: { rows: makePage(0, 3) },
|
||||
warning: { message: 'partial' } as any,
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
params: {} as any,
|
||||
warnings: [],
|
||||
} as any,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const props = { ...baseProps(), setWarning };
|
||||
renderHook(() => useTraceInfiniteQuery(props));
|
||||
|
||||
expect(setWarning).toHaveBeenCalledWith({ message: 'partial' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import type { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { useTracesTableColumns } from '../useTracesTableColumns';
|
||||
|
||||
type Row = { trace_id: string; span_id: string };
|
||||
|
||||
const cellStub = (): JSX.Element => <span />;
|
||||
|
||||
const baseColumns: TableColumnDef<Row>[] = [
|
||||
{
|
||||
id: 'span.timestamp',
|
||||
header: 'Timestamp',
|
||||
accessorFn: (r): unknown => r.span_id,
|
||||
width: { default: 170, min: 170 },
|
||||
cell: cellStub,
|
||||
},
|
||||
{
|
||||
id: 'span.trace_id',
|
||||
header: 'Trace ID',
|
||||
accessorFn: (r): unknown => r.trace_id,
|
||||
width: { min: 200 },
|
||||
cell: cellStub,
|
||||
},
|
||||
];
|
||||
|
||||
describe('useTracesTableColumns', () => {
|
||||
it('returns baseColumns as-is when no fields are provided', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTracesTableColumns<Row>({ baseColumns }),
|
||||
);
|
||||
expect(result.current).toHaveLength(baseColumns.length);
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'span.timestamp',
|
||||
'span.trace_id',
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends dynamic field columns after baseColumns', () => {
|
||||
const fields: TelemetryFieldKey[] = [
|
||||
{
|
||||
name: 'http.method',
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'string',
|
||||
signal: 'traces',
|
||||
} as TelemetryFieldKey,
|
||||
{
|
||||
name: 'duration_nano',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: 'int64',
|
||||
signal: 'traces',
|
||||
} as TelemetryFieldKey,
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTracesTableColumns<Row>({ baseColumns, fields }),
|
||||
);
|
||||
|
||||
expect(result.current).toHaveLength(baseColumns.length + fields.length);
|
||||
// baseColumns first.
|
||||
expect(
|
||||
result.current.slice(0, baseColumns.length).map((c) => c.id),
|
||||
).toStrictEqual(['span.timestamp', 'span.trace_id']);
|
||||
// Then dynamic fields with 3-arg composite IDs (context.name.dataType).
|
||||
expect(
|
||||
result.current.slice(baseColumns.length).map((c) => c.id),
|
||||
).toStrictEqual(['attribute.http.method.string', 'span.duration_nano.int64']);
|
||||
});
|
||||
|
||||
it('preserves the same array reference when inputs are stable (memoization)', () => {
|
||||
const fields: TelemetryFieldKey[] = [];
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useTracesTableColumns<Row>({ baseColumns, fields }),
|
||||
);
|
||||
const first = result.current;
|
||||
rerender();
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
|
||||
it('returns a new array when baseColumns reference changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { baseColumns: TableColumnDef<Row>[] }) =>
|
||||
useTracesTableColumns<Row>({ baseColumns: props.baseColumns }),
|
||||
{ initialProps: { baseColumns } },
|
||||
);
|
||||
const first = result.current;
|
||||
rerender({ baseColumns: [...baseColumns] });
|
||||
expect(result.current).not.toBe(first);
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual(first.map((c) => c.id));
|
||||
});
|
||||
|
||||
it('uses 2-arg composite ID when fieldDataType is empty', () => {
|
||||
const fields: TelemetryFieldKey[] = [
|
||||
{
|
||||
name: 'service.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: '',
|
||||
signal: 'traces',
|
||||
} as TelemetryFieldKey,
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useTracesTableColumns<Row>({ baseColumns: [], fields }),
|
||||
);
|
||||
expect(result.current[0].id).toBe('resource.service.name');
|
||||
});
|
||||
});
|
||||
25
frontend/src/components/Traces/TableView/getTraceLink.ts
Normal file
25
frontend/src/components/Traces/TableView/getTraceLink.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { formUrlParams } from 'container/TraceDetail/utils';
|
||||
|
||||
// Reads camelCase OR snake_case at runtime — accepts any row shape that ships
|
||||
// trace_id (and optionally span_id). Both the TanStack ListView's SpanRow and
|
||||
// the legacy antd `RowData` (TracesTableComponent, EntityTraces) satisfy this.
|
||||
export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
const traceId = readId(record.traceID) || readId(record.trace_id);
|
||||
const spanId = readId(record.spanID) || readId(record.span_id);
|
||||
return `${ROUTES.TRACE}/${traceId}${formUrlParams({
|
||||
spanId,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
})}`;
|
||||
};
|
||||
|
||||
function readId(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
} from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import type { Pagination } from 'hooks/queryPagination';
|
||||
import type { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import type { Warning } from 'types/api';
|
||||
import type APIError from 'types/api/error';
|
||||
import type { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export type UseTraceInfiniteQueryOptions<TRow> = {
|
||||
queryDeps: unknown[];
|
||||
buildRequest: (pagination: Pagination) => GetQueryResultsProps;
|
||||
transformResponse: (
|
||||
payload: MetricQueryRangeSuccessResponse['payload'] | undefined,
|
||||
) => TRow[];
|
||||
enabled: boolean;
|
||||
entityVersion: string;
|
||||
queryKeyRef?: MutableRefObject<unknown>;
|
||||
setIsLoadingQueries?: (loading: boolean) => void;
|
||||
setWarning?: (warning: Warning | undefined) => void;
|
||||
panelType: string;
|
||||
};
|
||||
|
||||
export type UseTraceInfiniteQueryResult<TRow> = {
|
||||
rows: TRow[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: APIError | Error | null;
|
||||
handleEndReached: () => void;
|
||||
};
|
||||
|
||||
export function useTraceInfiniteQuery<TRow>({
|
||||
queryDeps,
|
||||
buildRequest,
|
||||
transformResponse,
|
||||
enabled,
|
||||
entityVersion,
|
||||
queryKeyRef,
|
||||
setIsLoadingQueries,
|
||||
setWarning,
|
||||
panelType,
|
||||
}: UseTraceInfiniteQueryOptions<TRow>): UseTraceInfiniteQueryResult<TRow> {
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
offset: 0,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
const [accumulatedRows, setAccumulatedRows] = useState<TRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination({ offset: 0, limit: PAGE_SIZE });
|
||||
setAccumulatedRows([]);
|
||||
setHasMore(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, queryDeps);
|
||||
|
||||
const requestParams = useMemo(
|
||||
() => buildRequest(pagination),
|
||||
[buildRequest, pagination],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [REACT_QUERY_KEY.GET_QUERY_RANGE, pagination, ...queryDeps],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[pagination, ...queryDeps],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
requestParams,
|
||||
entityVersion,
|
||||
{ queryKey, enabled, keepPreviousData: true },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload && setWarning) {
|
||||
setWarning(data.warning);
|
||||
}
|
||||
}, [data?.payload, data?.warning, setWarning]);
|
||||
|
||||
// Append-only. Fires solely on new data arriving (pagination is not a dep —
|
||||
// pagination state changes drive the queryKey, which drives a new fetch,
|
||||
// which lands here as a fresh data.payload). Functional updater so the new
|
||||
// rows always pile onto the latest queued accumulator (which is [] right
|
||||
// after reset).
|
||||
useEffect(() => {
|
||||
if (!data?.payload) {
|
||||
return;
|
||||
}
|
||||
const newRows = transformResponse(data.payload);
|
||||
setAccumulatedRows((prev) => [...prev, ...newRows]);
|
||||
setHasMore(newRows.length >= PAGE_SIZE);
|
||||
}, [data?.payload, transformResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoadingQueries?.(isLoading || isFetching);
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && accumulatedRows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', { panelType });
|
||||
}
|
||||
}, [isLoading, isFetching, isError, accumulatedRows.length, panelType]);
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (!hasMore) {
|
||||
return;
|
||||
}
|
||||
setPagination((p) => ({ ...p, offset: p.offset + p.limit }));
|
||||
}, [hasMore]);
|
||||
|
||||
return {
|
||||
rows: accumulatedRows,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
handleEndReached,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useMemo, type ReactElement } from 'react';
|
||||
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import type { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
type UseTracesTableColumnsProps<TRow> = {
|
||||
/** Pinned / always-on columns owned by the consumer (e.g. timestamp for List view, the 5 static columns for Traces grouped view). */
|
||||
baseColumns: TableColumnDef<TRow>[];
|
||||
/** Dynamic columns sourced from `selectColumns` (List view). Omit or pass [] for views without a picker (Traces grouped). */
|
||||
fields?: TelemetryFieldKey[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared column builder for the trace list view and the trace (group-by-trace) view.
|
||||
*
|
||||
* Composition: `[...baseColumns, ...fields.map(makeUserFieldCol)]`. Each view owns its
|
||||
* `baseColumns` inline so view-specific changes (timestamp formatting on list, static-column
|
||||
* cell renderers on grouped) stay localized. The shared piece is `makeUserFieldCol` — the
|
||||
* dynamic-field factory that consumes `selectColumns` for the list view.
|
||||
*/
|
||||
export function useTracesTableColumns<TRow>({
|
||||
baseColumns,
|
||||
fields = [],
|
||||
}: UseTracesTableColumnsProps<TRow>): TableColumnDef<TRow>[] {
|
||||
return useMemo<TableColumnDef<TRow>[]>(
|
||||
() => [...baseColumns, ...fields.map((f) => makeUserFieldCol<TRow>(f))],
|
||||
[baseColumns, fields],
|
||||
);
|
||||
}
|
||||
|
||||
function makeUserFieldCol<TRow>(f: TelemetryFieldKey): TableColumnDef<TRow> {
|
||||
const col: TableColumnDef<Record<string, unknown>> = {
|
||||
id: buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
header: f.name,
|
||||
accessorFn: (row): unknown => row[f.name],
|
||||
enableRemove: true,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{stringifyCellValue(value)}</TanStackTable.Text>
|
||||
),
|
||||
};
|
||||
return col as TableColumnDef<TRow>;
|
||||
}
|
||||
|
||||
function stringifyCellValue(value: unknown): string {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export enum LOCALSTORAGE {
|
||||
TRACES_LIST_OPTIONS = 'TRACES_LIST_OPTIONS',
|
||||
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
|
||||
|
||||
@@ -3,10 +3,8 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import {
|
||||
BlockLink,
|
||||
getTraceLink,
|
||||
} from 'container/TracesExplorer/ListView/utils';
|
||||
import { getTraceLink } from 'components/Traces/TableView/getTraceLink';
|
||||
import { BlockLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
@@ -56,7 +56,7 @@ export function dedupeColumnsByCompositeKey(
|
||||
const seen = new Set<string>();
|
||||
let hasDuplicate = false;
|
||||
const deduped = columns.filter((c) => {
|
||||
const key = buildCompositeKey(c.name, c.fieldContext);
|
||||
const key = buildCompositeKey(c.name, c.fieldContext, c.fieldDataType);
|
||||
if (seen.has(key)) {
|
||||
hasDuplicate = true;
|
||||
return false;
|
||||
|
||||
@@ -278,10 +278,20 @@ const useOptionsMenu = ({
|
||||
[searchedAttributeKeys, selectedColumnKeys, preferences, updateColumns],
|
||||
);
|
||||
|
||||
// Logs emits 2-part IDs (no `fieldDataType`); traces emits 3-part for
|
||||
// `http.status_code`-style disambig. Tech debt — migrate logs to 3-part too
|
||||
// and drop this gate.
|
||||
const includeDataType = dataSource !== DataSource.LOGS;
|
||||
|
||||
const handleRemoveSelectedColumn = useCallback(
|
||||
(columnKey: string) => {
|
||||
const newSelectedColumns = preferences?.columns?.filter(
|
||||
(f) => buildCompositeKey(f.name, f.fieldContext) !== columnKey,
|
||||
(f) =>
|
||||
buildCompositeKey(
|
||||
f.name,
|
||||
f.fieldContext,
|
||||
includeDataType ? f.fieldDataType : undefined,
|
||||
) !== columnKey,
|
||||
);
|
||||
|
||||
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
|
||||
@@ -364,14 +374,21 @@ const useOptionsMenu = ({
|
||||
(orderedIds: string[]): void => {
|
||||
const current = preferences?.columns ?? [];
|
||||
const byCompositeKey = new Map(
|
||||
current.map((f) => [buildCompositeKey(f.name, f.fieldContext), f]),
|
||||
current.map((f) => [
|
||||
buildCompositeKey(
|
||||
f.name,
|
||||
f.fieldContext,
|
||||
includeDataType ? f.fieldDataType : undefined,
|
||||
),
|
||||
f,
|
||||
]),
|
||||
);
|
||||
const reordered = orderedIds
|
||||
.map((id) => byCompositeKey.get(id))
|
||||
.filter((f): f is TelemetryFieldKey => f !== undefined);
|
||||
updateColumns(reordered);
|
||||
},
|
||||
[preferences, updateColumns],
|
||||
[preferences, updateColumns, includeDataType],
|
||||
);
|
||||
|
||||
const handleFocus = (): void => {
|
||||
|
||||
@@ -15,8 +15,15 @@ export const getOptionsFromKeys = (
|
||||
);
|
||||
};
|
||||
|
||||
// Composite identity for a column. Disambiguates same-name fields across
|
||||
// different fieldContexts (e.g. resource.service.name vs attribute.service.name).
|
||||
// Falls back to bare name when context is missing.
|
||||
export const buildCompositeKey = (name: string, context?: string): string =>
|
||||
context ? `${context}.${name}` : name;
|
||||
// Composite column id. Disambiguates same-name fields by `context` and `dataType`
|
||||
// (e.g. attribute.http.status_code ships as both number and string). Each arg
|
||||
// is appended only when truthy. `dataType` is optional — logs callers stay on
|
||||
// the 2-arg form until parity lands.
|
||||
export const buildCompositeKey = (
|
||||
name: string,
|
||||
context?: string,
|
||||
dataType?: string,
|
||||
): string => {
|
||||
const withContext = context ? `${context}.${name}` : name;
|
||||
return dataType ? `${withContext}.${dataType}` : withContext;
|
||||
};
|
||||
|
||||
@@ -2,62 +2,37 @@ import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import Controls, { ControlsProps } from 'container/Controls';
|
||||
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import styles from './Controls.module.scss';
|
||||
|
||||
function TraceExplorerControls({
|
||||
isLoading,
|
||||
totalCount,
|
||||
perPageOptions,
|
||||
config,
|
||||
showSizeChanger = true,
|
||||
}: TraceExplorerControlsProps): JSX.Element | null {
|
||||
const { t } = useTranslation(['trace']);
|
||||
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
|
||||
|
||||
const {
|
||||
pagination,
|
||||
handleCountItemsPerPageChange,
|
||||
handleNavigateNext,
|
||||
handleNavigatePrevious,
|
||||
} = useQueryPagination(totalCount, perPageOptions);
|
||||
if (!config?.fieldsSelector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{config?.fieldsSelector && (
|
||||
<>
|
||||
<div
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
>
|
||||
{t('options_menu.options')}
|
||||
<Settings size="md" />
|
||||
</div>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
fields={config.fieldsSelector.value}
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Controls
|
||||
isLoading={isLoading}
|
||||
totalCount={totalCount}
|
||||
offset={pagination.offset}
|
||||
countPerPage={pagination.limit}
|
||||
perPageOptions={perPageOptions}
|
||||
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
|
||||
handleNavigateNext={handleNavigateNext}
|
||||
handleNavigatePrevious={handleNavigatePrevious}
|
||||
showSizeChanger={showSizeChanger}
|
||||
<div
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
>
|
||||
{t('options_menu.options')}
|
||||
<Settings size="md" />
|
||||
</div>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
fields={config.fieldsSelector.value}
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -67,16 +42,8 @@ TraceExplorerControls.defaultProps = {
|
||||
config: null,
|
||||
};
|
||||
|
||||
type TraceExplorerControlsProps = Pick<
|
||||
ControlsProps,
|
||||
'isLoading' | 'totalCount' | 'perPageOptions'
|
||||
> & {
|
||||
type TraceExplorerControlsProps = {
|
||||
config?: OptionsMenuConfig | null;
|
||||
showSizeChanger?: boolean;
|
||||
};
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
showSizeChanger: true,
|
||||
};
|
||||
|
||||
export default memo(TraceExplorerControls);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
// Page chain (.trace-explorer-page → .trace-explorer → .traces-explorer-views)
|
||||
// isn't a flex column, so anchor against the viewport.
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
|
||||
--tanstack-cell-padding-left-first-column: 5px;
|
||||
--tanstack-plain-body-line-clamp: 3;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
gap: 8px;
|
||||
|
||||
.order-by-container {
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const defaultSelectedColumns: string[] = [
|
||||
'service.name',
|
||||
'name',
|
||||
'duration_nano',
|
||||
'http_method',
|
||||
'response_status_code',
|
||||
'timestamp',
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
@@ -54,18 +54,17 @@ const renderListView = (
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to verify all controls are visible
|
||||
// Helper to verify all controls are visible.
|
||||
// Pagination controls were removed in the TanStack-table migration (infinite
|
||||
// scroll replaces page-by-page navigation), so only the order-by combobox +
|
||||
// options trigger remain in the top toolbar.
|
||||
const verifyControlsVisibility = (): void => {
|
||||
// Order by controls
|
||||
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
|
||||
|
||||
// Pagination controls
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
|
||||
|
||||
// Items per page selector (there are multiple comboboxes, so we check for at least 2)
|
||||
// At least one combobox (order-by); page-size selector is gone post-migration.
|
||||
const comboboxes = screen.getAllByRole('combobox');
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Options menu (settings button) - check for translation key or actual text
|
||||
expect(screen.getByText(/options_menu.options|options/i)).toBeInTheDocument();
|
||||
@@ -152,15 +151,10 @@ describe('Traces ListView - Error and Empty States', () => {
|
||||
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Order by controls should be interactive
|
||||
// Order-by combobox should be interactive (pagination buttons removed
|
||||
// after the TanStack migration switched List view to infinite scroll).
|
||||
const comboboxes = screen.getAllByRole('combobox');
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Pagination controls should be present
|
||||
const previousButton = screen.getByRole('button', { name: /previous/i });
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
expect(previousButton).toBeInTheDocument();
|
||||
expect(nextButton).toBeInTheDocument();
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Options menu should be clickable
|
||||
const optionsButton = screen.getByText(/options_menu.options|options/i);
|
||||
@@ -175,9 +169,9 @@ describe('Traces ListView - Error and Empty States', () => {
|
||||
expect(screen.getByText(/No traces yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// All controls should be interactive
|
||||
// At least the order-by combobox should be interactive.
|
||||
const comboboxes = screen.getAllByRole('combobox');
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Options menu should be clickable
|
||||
const optionsButton = screen.getByText(/options_menu.options|options/i);
|
||||
|
||||
@@ -4,48 +4,46 @@ import {
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { TracesTable } from 'components/Traces/TableView/TracesTable';
|
||||
import { useTraceInfiniteQuery } from 'components/Traces/TableView/useTraceInfiniteQuery';
|
||||
import { useTracesTableColumns } from 'components/Traces/TableView/useTracesTableColumns';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import type { Pagination } from 'hooks/queryPagination';
|
||||
import type { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import type { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import { defaultSelectedColumns, PER_PAGE_OPTIONS } from './configs';
|
||||
import { Container, tableStyles } from './styles';
|
||||
import { getListColumns, transformDataWithDate } from './utils';
|
||||
import { getTraceLink } from 'components/Traces/TableView/getTraceLink';
|
||||
import {
|
||||
makeListFieldCol,
|
||||
makeTimestampCol,
|
||||
SpanRow,
|
||||
transformSpanRows,
|
||||
} from './utils';
|
||||
|
||||
import './ListView.styles.scss';
|
||||
|
||||
import styles from './ListView.module.scss';
|
||||
|
||||
interface ListViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
@@ -77,25 +75,11 @@ function ListView({
|
||||
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
const paginationConfig =
|
||||
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// TEMP — remove after traces moves to TanStack table.
|
||||
// Stable sorted-name signature for the queryKey + reset trigger.
|
||||
// - Drag updates selectColumns; raw queryKey would churn on reorder.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch from scratch.
|
||||
// - Sorted-name signature: stable on reorder, changes on add/remove.
|
||||
const selectColumnsSignature = useMemo(
|
||||
() =>
|
||||
@@ -106,140 +90,92 @@ function ListView({
|
||||
[options?.selectColumns],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
stagedQuery,
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
maxTime,
|
||||
minTime,
|
||||
orderBy,
|
||||
],
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isFetching, isLoading, isError, error } = useGetQueryRange(
|
||||
{
|
||||
const buildRequest = useCallback(
|
||||
(pagination: Pagination): GetQueryResultsProps => ({
|
||||
query: requestQuery,
|
||||
graphType: panelType,
|
||||
selectedTime: 'GLOBAL_TIME' as const,
|
||||
globalSelectedInterval: globalSelectedTime as CustomTimeType,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
params: { dataSource: 'traces' },
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
pagination,
|
||||
selectColumns: options?.selectColumns,
|
||||
},
|
||||
}),
|
||||
[requestQuery, panelType, globalSelectedTime, options?.selectColumns],
|
||||
);
|
||||
|
||||
const transformResponse = useCallback(
|
||||
(
|
||||
payload: MetricQueryRangeSuccessResponse['payload'] | undefined,
|
||||
): SpanRow[] => {
|
||||
const result = payload?.data?.newResult?.data?.result;
|
||||
return result ? transformSpanRows(result) : [];
|
||||
},
|
||||
// ENTITY_VERSION_V4,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled:
|
||||
// don't make api call while the time range state in redux is loading
|
||||
!timeRangeUpdateLoading &&
|
||||
!!stagedQuery &&
|
||||
panelType === PANEL_TYPES.LIST &&
|
||||
!!options?.selectColumns?.length,
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
const dataLength =
|
||||
data?.payload?.data?.newResult?.data?.result[0]?.list?.length;
|
||||
const totalCount = useMemo(() => dataLength || 0, [dataLength]);
|
||||
|
||||
const queryTableDataResult = data?.payload?.data?.newResult?.data?.result;
|
||||
const queryTableData = useMemo(
|
||||
() => queryTableDataResult || [],
|
||||
[queryTableDataResult],
|
||||
);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getListColumns(
|
||||
options?.selectColumns || [],
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
),
|
||||
[options?.selectColumns, formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const transformedQueryTableData = useMemo(
|
||||
() => transformDataWithDate(queryTableData) || [],
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const handleDragColumn = useCallback(
|
||||
(fromIndex: number, toIndex: number): void => {
|
||||
const reordered = [...columns];
|
||||
const [moved] = reordered.splice(fromIndex, 1);
|
||||
reordered.splice(toIndex, 0, moved);
|
||||
// `key` is the composite (fieldContext.name) — disambiguates same-name fields.
|
||||
const orderedIds = reordered
|
||||
.map((c) => String(c.key || ('dataIndex' in c && c.dataIndex) || ''))
|
||||
.filter(Boolean);
|
||||
config?.addColumn?.onReorder(orderedIds);
|
||||
},
|
||||
[columns, config],
|
||||
);
|
||||
const {
|
||||
rows: accumulatedRows,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
handleEndReached,
|
||||
} = useTraceInfiniteQuery<SpanRow>({
|
||||
queryDeps: [
|
||||
stagedQuery,
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
orderBy,
|
||||
selectColumnsSignature,
|
||||
],
|
||||
buildRequest,
|
||||
transformResponse,
|
||||
enabled:
|
||||
!timeRangeUpdateLoading &&
|
||||
!!stagedQuery &&
|
||||
panelType === PANEL_TYPES.LIST &&
|
||||
!!options?.selectColumns?.length,
|
||||
entityVersion: ENTITY_VERSION_V5,
|
||||
queryKeyRef,
|
||||
setIsLoadingQueries,
|
||||
setWarning,
|
||||
panelType,
|
||||
});
|
||||
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
const isDataAbsent =
|
||||
!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
transformedQueryTableData.length === 0;
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const baseColumns = useMemo(
|
||||
() => [
|
||||
makeTimestampCol(formatTimezoneAdjustedTimestamp),
|
||||
...(options?.selectColumns ?? []).map(makeListFieldCol),
|
||||
],
|
||||
[formatTimezoneAdjustedTimestamp, options?.selectColumns],
|
||||
);
|
||||
|
||||
const tableColumns = useTracesTableColumns<SpanRow>({ baseColumns });
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(cols: { id: string }[]): void => {
|
||||
config?.addColumn?.onReorder(cols.map((c) => c.id));
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
transformedQueryTableData.length !== 0
|
||||
) {
|
||||
logEvent('Traces Explorer: Data present', {
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, transformedQueryTableData, panelType]);
|
||||
return (
|
||||
<Container>
|
||||
<div className={styles.container}>
|
||||
<div className="trace-explorer-controls">
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
@@ -258,41 +194,26 @@ function ListView({
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isFetching}
|
||||
totalCount={totalCount}
|
||||
config={config}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
<TraceExplorerControls config={config} />
|
||||
</div>
|
||||
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && transformedQueryTableData.length === 0)) && (
|
||||
<TracesLoading />
|
||||
)}
|
||||
|
||||
{isDataAbsent && !isFilterApplied && (
|
||||
<NoLogs dataSource={DataSource.TRACES} />
|
||||
)}
|
||||
|
||||
{isDataAbsent && isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
|
||||
)}
|
||||
|
||||
{!isError && transformedQueryTableData.length !== 0 && (
|
||||
<ResizeTable
|
||||
tableLayout="fixed"
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
loading={isFetching}
|
||||
style={tableStyles}
|
||||
dataSource={transformedQueryTableData}
|
||||
columns={columns}
|
||||
onDragColumn={handleDragColumn}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
<TracesTable<SpanRow>
|
||||
data={accumulatedRows}
|
||||
columns={tableColumns}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
panelType="LIST"
|
||||
columnStorageKey={LOCALSTORAGE.TRACES_LIST_COLUMNS}
|
||||
respectColumnOrder={false}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
getRowHref={getTraceLink}
|
||||
onEndReached={handleEndReached}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
// Kept for legacy antd consumers (TracesTableComponent, LogsPanelComponent).
|
||||
export const tableStyles: CSSProperties = {
|
||||
cursor: 'unset',
|
||||
};
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
--typography-color: var(--l1-foreground);
|
||||
`;
|
||||
|
||||
export const ErrorText = styled(Typography)`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export const DateText = styled(Typography)`
|
||||
min-width: 145px;
|
||||
`;
|
||||
|
||||
@@ -3,17 +3,26 @@ import type { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { getTraceLink } from 'components/Traces/TableView/getTraceLink';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { formUrlParams } from 'container/TraceDetail/utils';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
// `BlockLink`, `getListColumns`, `transformDataWithDate` are kept for legacy
|
||||
// antd consumers. `getTraceLink` is shared with the TanStack ListView, which
|
||||
// otherwise uses `make*Col` / `SpanRow` / `transformSpanRows`.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy antd consumers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function BlockLink({
|
||||
children,
|
||||
to,
|
||||
@@ -41,12 +50,22 @@ export const transformDataWithDate = (
|
||||
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
|
||||
[];
|
||||
|
||||
export const getTraceLink = (record: RowData): string =>
|
||||
`${ROUTES.TRACE}/${record.traceID || record.trace_id}${formUrlParams({
|
||||
spanId: record.spanID || record.span_id,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
})}`;
|
||||
// Re-export for legacy antd consumers (TracesTableComponent, EntityTraces) that
|
||||
// import from this path. New code should import from
|
||||
// `components/Traces/TableView/getTraceLink`.
|
||||
|
||||
function stringifyCellValue(value: unknown): string {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export const getListColumns = (
|
||||
selectedColumns: TelemetryFieldKey[],
|
||||
@@ -136,3 +155,111 @@ export const getListColumns = (
|
||||
|
||||
return [...initialColumns, ...columns];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TanStack ListView (current)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Span row shape for the trace list view. Known intrinsic fields explicit; the
|
||||
// rest of the row comes from user-selected dynamic columns (selectColumns), hence
|
||||
// the Record intersection. `timestamp` is added by transformSpanRows from the
|
||||
// API's wrapping ListItem.timestamp (data itself omits it).
|
||||
export type SpanRow = {
|
||||
trace_id: string;
|
||||
span_id: string;
|
||||
timestamp: string;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
export const transformSpanRows = (data: QueryDataV3[]): SpanRow[] => {
|
||||
const list = data[0]?.list;
|
||||
if (!list) {
|
||||
return [];
|
||||
}
|
||||
return list.map((item) => {
|
||||
const row = item.data as Record<string, unknown>;
|
||||
return {
|
||||
...row,
|
||||
timestamp: item.timestamp,
|
||||
id: row.span_id,
|
||||
};
|
||||
}) as unknown as SpanRow[];
|
||||
};
|
||||
|
||||
// Field-name allowlists that drive signal-specific cell rendering (kept from the
|
||||
// pre-TanStack getListColumns). Both legacy camelCase + snake_case variants are
|
||||
// listed because the API has shipped both over time.
|
||||
const STATUS_FIELD_NAMES = new Set([
|
||||
'httpMethod',
|
||||
'http_method',
|
||||
'responseStatusCode',
|
||||
'response_status_code',
|
||||
]);
|
||||
const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
|
||||
|
||||
type TimestampFormatter = (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
) => string | number;
|
||||
|
||||
export function makeTimestampCol(
|
||||
formatTimezoneAdjustedTimestamp: TimestampFormatter,
|
||||
): TableColumnDef<SpanRow> {
|
||||
return {
|
||||
id: buildCompositeKey('timestamp', 'span'),
|
||||
header: 'Timestamp',
|
||||
accessorFn: (row): unknown => row.timestamp,
|
||||
// Pinned left as a visual anchor during horizontal scroll. Trade-off: the
|
||||
// sticky-positioning + cell `overflow: hidden` in TanStackTable.module.scss
|
||||
// makes the right-edge resize handle effectively unhittable for pinned
|
||||
// columns — accepted.
|
||||
pin: 'left',
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
width: { default: 170, min: 170 },
|
||||
cell: ({ value }): JSX.Element => {
|
||||
const ts = value as string | number;
|
||||
const formatted =
|
||||
typeof ts === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
ts / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
return <TanStackTable.Text>{String(formatted)}</TanStackTable.Text>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function makeListFieldCol(
|
||||
f: TelemetryFieldKey,
|
||||
): TableColumnDef<SpanRow> {
|
||||
return {
|
||||
id: buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
header: f.name,
|
||||
accessorFn: (row): unknown => row[f.name],
|
||||
enableRemove: true,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): JSX.Element => {
|
||||
if (value === '' || value == null) {
|
||||
return <TanStackTable.Text data-testid={f.name}>N/A</TanStackTable.Text>;
|
||||
}
|
||||
const text = stringifyCellValue(value);
|
||||
if (STATUS_FIELD_NAMES.has(f.name)) {
|
||||
return (
|
||||
<Badge data-testid={f.name} color="sakura" variant="outline">
|
||||
{text}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (DURATION_FIELD_NAMES.has(f.name)) {
|
||||
return (
|
||||
<TanStackTable.Text data-testid={f.name}>
|
||||
{getMs(text)}
|
||||
ms
|
||||
</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
return <TanStackTable.Text data-testid={f.name}>{text}</TanStackTable.Text>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
// Page chain isn't a flex column; anchor against the viewport.
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
|
||||
--tanstack-cell-padding-left-first-column: 12px;
|
||||
}
|
||||
|
||||
.actionsContainer {
|
||||
display: flex;
|
||||
padding-bottom: 8px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,50 +1,72 @@
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import type { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ROUTES from 'constants/routes';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
import { ListItem } from 'types/api/widgets/getQuery';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
export const columns: ColumnsType<ListItem['data']> = [
|
||||
// Trace-grouped (group-by-trace) row shape. Distinct from logs' `ListItem.data`
|
||||
// (which is `Omit<ILog, 'timestamp' | 'span_id'>` — the legacy logs shape).
|
||||
// Trace rows ship trace-summary fields; runtime keys often contain dots (e.g.
|
||||
// `service.name`), so the row indexes via string keys, not nested-property access.
|
||||
export type TraceRow = {
|
||||
'service.name': string;
|
||||
name: string;
|
||||
duration_nano: number | string;
|
||||
span_count: number | string;
|
||||
trace_id: string;
|
||||
// Mirror of trace_id used by TanStack's getRowId. Injected during response
|
||||
// transform — without it, rows fall back to positional index.
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const columns: TableColumnDef<TraceRow>[] = [
|
||||
{
|
||||
title: 'Root Service Name',
|
||||
dataIndex: 'service.name',
|
||||
key: 'serviceName',
|
||||
},
|
||||
{
|
||||
title: 'Root Operation Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Root Duration (in ms)',
|
||||
dataIndex: 'duration_nano',
|
||||
key: 'durationNano',
|
||||
render: (duration: number): JSX.Element => (
|
||||
<Typography>{getMs(String(duration))}ms</Typography>
|
||||
id: buildCompositeKey('service.name', 'resource'),
|
||||
header: 'Root Service Name',
|
||||
accessorFn: (row): unknown => row['service.name'],
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
),
|
||||
width: { min: 192 },
|
||||
},
|
||||
{
|
||||
title: 'No of Spans',
|
||||
dataIndex: 'span_count',
|
||||
key: 'span_count',
|
||||
},
|
||||
{
|
||||
title: 'TraceID',
|
||||
dataIndex: 'trace_id',
|
||||
key: 'traceID',
|
||||
render: (traceID: string): JSX.Element => (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, {
|
||||
id: traceID,
|
||||
})}
|
||||
data-testid="trace-id"
|
||||
>
|
||||
{traceID}
|
||||
</Link>
|
||||
id: 'name',
|
||||
header: 'Root Operation Name',
|
||||
accessorFn: (row): unknown => row.name,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text data-testid="trace-id">
|
||||
{String(value ?? '')}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
width: { min: 200 },
|
||||
},
|
||||
{
|
||||
id: 'duration_nano',
|
||||
header: 'Root Duration (in ms)',
|
||||
accessorFn: (row): unknown => row.duration_nano,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{getMs(String(value))}ms</TanStackTable.Text>
|
||||
),
|
||||
width: { min: 180 },
|
||||
},
|
||||
{
|
||||
id: 'span_count',
|
||||
header: 'No of Spans',
|
||||
accessorFn: (row): unknown => row.span_count,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
),
|
||||
width: { min: 120 },
|
||||
},
|
||||
{
|
||||
id: 'trace_id',
|
||||
header: 'TraceID',
|
||||
accessorFn: (row): unknown => row.trace_id,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
),
|
||||
width: { min: 250 },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,40 +1,34 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { getTraceLink } from 'components/Traces/TableView/getTraceLink';
|
||||
import { TracesTable } from 'components/Traces/TableView/TracesTable';
|
||||
import { useTraceInfiniteQuery } from 'components/Traces/TableView/useTraceInfiniteQuery';
|
||||
import { useTracesTableColumns } from 'components/Traces/TableView/useTracesTableColumns';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import type { Pagination } from 'hooks/queryPagination';
|
||||
import type { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import type { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
import { ActionsContainer, Container } from './styles';
|
||||
import { columns as baseColumns, TraceRow } from './configs';
|
||||
|
||||
import styles from './TracesView.module.scss';
|
||||
|
||||
interface TracesViewProps {
|
||||
isFilterApplied: boolean;
|
||||
@@ -57,92 +51,66 @@ function TracesView({
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
],
|
||||
[
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
const buildRequest = useCallback(
|
||||
(pagination: Pagination): GetQueryResultsProps => ({
|
||||
query: transformedQuery,
|
||||
graphType: panelType || PANEL_TYPES.TRACE,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationQueryData,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
|
||||
},
|
||||
params: { dataSource: 'traces' },
|
||||
tableParams: { pagination },
|
||||
}),
|
||||
[transformedQuery, panelType, globalSelectedTime],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
|
||||
const tableData = useMemo(
|
||||
() => responseData?.map((listItem) => listItem.data),
|
||||
[responseData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && (tableData || []).length !== 0) {
|
||||
logEvent('Traces Explorer: Data present', {
|
||||
panelType: 'TRACE',
|
||||
const transformResponse = useCallback(
|
||||
(
|
||||
payload: MetricQueryRangeSuccessResponse['payload'] | undefined,
|
||||
): TraceRow[] => {
|
||||
const list = payload?.data?.newResult?.data?.result?.[0]?.list;
|
||||
if (!list) {
|
||||
return [];
|
||||
}
|
||||
// API returns trace-summary rows; the `ListItem.data` static type is the
|
||||
// legacy logs shape, so route through `unknown` to land on `TraceRow`.
|
||||
return list.map((li) => {
|
||||
const row = li.data as unknown as TraceRow;
|
||||
return { ...row, id: row.trace_id };
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, panelType, tableData]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const {
|
||||
rows: accumulatedRows,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
handleEndReached,
|
||||
} = useTraceInfiniteQuery<TraceRow>({
|
||||
queryDeps: [stagedQuery, panelType, globalSelectedTime, maxTime, minTime],
|
||||
buildRequest,
|
||||
transformResponse,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
|
||||
entityVersion: ENTITY_VERSION_V5,
|
||||
queryKeyRef,
|
||||
setIsLoadingQueries,
|
||||
setWarning,
|
||||
panelType: 'TRACE',
|
||||
});
|
||||
|
||||
const tableColumns = useTracesTableColumns<TraceRow>({ baseColumns });
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{(tableData || []).length !== 0 && (
|
||||
<ActionsContainer>
|
||||
<div className={styles.container}>
|
||||
{accumulatedRows.length !== 0 && (
|
||||
<div className={styles.actionsContainer}>
|
||||
<Typography>
|
||||
This tab only shows Root Spans. More details
|
||||
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
|
||||
@@ -150,48 +118,23 @@ function TracesView({
|
||||
here
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={responseData?.length || 0}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</ActionsContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && (tableData || []).length === 0)) && (
|
||||
<TracesLoading />
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
!isFilterApplied &&
|
||||
(tableData || []).length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
|
||||
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
(tableData || []).length === 0 &&
|
||||
!isError &&
|
||||
isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="TRACE" />
|
||||
)}
|
||||
|
||||
{(tableData || []).length !== 0 && (
|
||||
<ResizeTable
|
||||
loading={isLoading}
|
||||
columns={columns}
|
||||
tableLayout="fixed"
|
||||
dataSource={tableData}
|
||||
scroll={{ x: true }}
|
||||
pagination={false}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
<TracesTable<TraceRow>
|
||||
data={accumulatedRows}
|
||||
columns={tableColumns}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
panelType="TRACE"
|
||||
columnStorageKey={LOCALSTORAGE.TRACES_VIEW_COLUMNS}
|
||||
getRowHref={getTraceLink}
|
||||
onEndReached={handleEndReached}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const ActionsContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
@@ -11,12 +11,12 @@ import { UseQueryResult } from 'react-query';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { getTraceLink } from 'components/Traces/TableView/getTraceLink';
|
||||
import Controls from 'container/Controls';
|
||||
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
|
||||
import { tableStyles } from 'container/TracesExplorer/ListView/styles';
|
||||
import {
|
||||
getListColumns,
|
||||
getTraceLink,
|
||||
transformDataWithDate,
|
||||
} from 'container/TracesExplorer/ListView/utils';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
|
||||
@@ -66,24 +66,28 @@
|
||||
|
||||
.trace-explorer-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.filter {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
border-right: 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
@@ -24,7 +23,6 @@ import {
|
||||
getQueryByPanelType,
|
||||
} from 'container/TracesExplorer/explorerUtils';
|
||||
import ListView from 'container/TracesExplorer/ListView';
|
||||
import { defaultSelectedColumns } from 'container/TracesExplorer/ListView/configs';
|
||||
import QuerySection from 'container/TracesExplorer/QuerySection';
|
||||
import TableView from 'container/TracesExplorer/TableView';
|
||||
import TracesView from 'container/TracesExplorer/TracesView';
|
||||
@@ -80,9 +78,6 @@ function TracesExplorer(): JSX.Element {
|
||||
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -250,16 +245,18 @@ function TracesExplorer(): JSX.Element {
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="trace-explorer-page">
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
{isOpen && (
|
||||
<section className="filter">
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
<div
|
||||
className={cx('trace-explorer', {
|
||||
'filters-expanded': isOpen,
|
||||
|
||||
@@ -1,13 +1,48 @@
|
||||
.traces-module-container {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
> .ant-tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding: 0 16px;
|
||||
margin-bottom: 0px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::before {
|
||||
border-bottom: 1px solid var(--l1-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ant-tabs-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ant-tabs-tabpane-active {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useEffect, useState } from 'react';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { defaultSelectedColumns as defaultTracesSelectedColumns } from 'container/TracesExplorer/ListView/configs';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -69,7 +69,7 @@ export function usePreferenceSync({
|
||||
};
|
||||
}
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
columns = parsedExtraData?.selectColumns || defaultTracesSelectedColumns;
|
||||
columns = parsedExtraData?.selectColumns || defaultTraceSelectedColumns;
|
||||
}
|
||||
setSavedViewPreferences({ columns, formatting });
|
||||
}, [viewsData, dataSource, savedViewId, mode]);
|
||||
|
||||
@@ -59,7 +59,7 @@ func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *auth
|
||||
return "", err
|
||||
}
|
||||
|
||||
if authDomain.AuthDomainConfig().Provider.Type != authtypes.AuthNProviderGoogleAuth {
|
||||
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderGoogleAuth {
|
||||
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not google")
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google: no id_token in token response")
|
||||
}
|
||||
|
||||
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().Google().ClientID})
|
||||
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().Google.ClientID})
|
||||
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: failed to verify token", errors.Attr(err))
|
||||
@@ -135,7 +135,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: unexpected hd claim")
|
||||
}
|
||||
|
||||
if !authDomain.AuthDomainConfig().Google().InsecureSkipEmailVerified {
|
||||
if !authDomain.AuthDomainConfig().Google.InsecureSkipEmailVerified {
|
||||
if !claims.EmailVerified {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: email is not verified", slog.String("email", claims.Email))
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: email is not verified")
|
||||
@@ -148,14 +148,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
}
|
||||
|
||||
var groups []string
|
||||
if authDomain.AuthDomainConfig().Google().FetchGroups {
|
||||
groups, err = a.fetchGoogleWorkspaceGroups(ctx, claims.Email, authDomain.AuthDomainConfig().Google())
|
||||
if authDomain.AuthDomainConfig().Google.FetchGroups {
|
||||
groups, err = a.fetchGoogleWorkspaceGroups(ctx, claims.Email, authDomain.AuthDomainConfig().Google)
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: could not fetch groups", errors.Attr(err))
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "google: could not fetch groups").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
allowedGroups := authDomain.AuthDomainConfig().Google().AllowedGroups
|
||||
allowedGroups := authDomain.AuthDomainConfig().Google.AllowedGroups
|
||||
if len(allowedGroups) > 0 {
|
||||
groups = filterGroups(groups, allowedGroups)
|
||||
if len(groups) == 0 {
|
||||
@@ -175,8 +175,8 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
|
||||
|
||||
func (a *AuthN) oauth2Config(siteURL *url.URL, authDomain *authtypes.AuthDomain, provider *oidc.Provider) *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: authDomain.AuthDomainConfig().Google().ClientID,
|
||||
ClientSecret: authDomain.AuthDomainConfig().Google().ClientSecret,
|
||||
ClientID: authDomain.AuthDomainConfig().Google.ClientID,
|
||||
ClientSecret: authDomain.AuthDomainConfig().Google.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: scopes,
|
||||
RedirectURL: (&url.URL{
|
||||
|
||||
@@ -38,7 +38,7 @@ func (handler *handler) Create(rw http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
authDomain, err := authtypes.NewAuthDomainFromConfig(body.Name, &body.AuthDomainConfig, valuer.MustNewUUID(claims.OrgID))
|
||||
authDomain, err := authtypes.NewAuthDomainFromConfig(body.Name, &body.Config, valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -154,7 +154,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err = authDomain.Update(&body.AuthDomainConfig)
|
||||
err = authDomain.Update(&body.Config)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -27,7 +27,7 @@ func (module *module) Get(ctx context.Context, id valuer.UUID) (*authtypes.AuthD
|
||||
}
|
||||
|
||||
func (module *module) GetAuthNProviderInfo(ctx context.Context, domain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
|
||||
if callbackAuthN, ok := module.authNs[domain.AuthDomainConfig().Provider.Type].(authn.CallbackAuthN); ok {
|
||||
if callbackAuthN, ok := module.authNs[domain.AuthDomainConfig().AuthNProvider].(authn.CallbackAuthN); ok {
|
||||
return callbackAuthN.ProviderInfo(ctx, domain)
|
||||
}
|
||||
return &authtypes.AuthNProviderInfo{}
|
||||
@@ -62,7 +62,7 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
|
||||
stats := make(map[string]any)
|
||||
|
||||
for _, domain := range domains {
|
||||
key := "authdomain." + domain.AuthDomainConfig().Provider.Type.StringValue() + ".count"
|
||||
key := "authdomain." + domain.AuthDomainConfig().AuthNProvider.StringValue() + ".count"
|
||||
if value, ok := stats[key]; ok {
|
||||
stats[key] = value.(int64) + 1
|
||||
} else {
|
||||
|
||||
@@ -201,7 +201,7 @@ func (module *module) getOrgSessionContext(ctx context.Context, org *types.Organ
|
||||
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddPasswordAuthNSupport(authtypes.AuthNProviderEmailPassword), nil
|
||||
}
|
||||
|
||||
provider, err := getProvider[authn.CallbackAuthN](authDomain.AuthDomainConfig().Provider.Type, module.authNs)
|
||||
provider, err := getProvider[authn.CallbackAuthN](authDomain.AuthDomainConfig().AuthNProvider, module.authNs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -211,7 +211,7 @@ func (module *module) getOrgSessionContext(ctx context.Context, org *types.Organ
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddCallbackAuthNSupport(authDomain.AuthDomainConfig().Provider.Type, loginURL), nil
|
||||
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddCallbackAuthNSupport(authDomain.AuthDomainConfig().AuthNProvider, loginURL), nil
|
||||
}
|
||||
|
||||
func getProvider[T authn.AuthN](authNProvider authtypes.AuthNProvider, authNs map[authtypes.AuthNProvider]authn.AuthN) (T, error) {
|
||||
|
||||
@@ -211,7 +211,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddDashboardNameFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewFixChangelogOperationTypeFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema),
|
||||
sqlmigration.NewMigrateAuthDomainPayloadFactory(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type migrateAuthDomainPayload struct{}
|
||||
|
||||
type authDomainPayloadRaw struct {
|
||||
bun.BaseModel `bun:"table:auth_domain"`
|
||||
|
||||
ID string `bun:"id"`
|
||||
Data string `bun:"data"`
|
||||
}
|
||||
|
||||
// auth config type -> old sso type.
|
||||
var legacyConfigKeyByType = map[string]string{
|
||||
"saml": "samlConfig",
|
||||
"oidc": "oidcConfig",
|
||||
"google_auth": "googleAuthConfig",
|
||||
}
|
||||
|
||||
func NewMigrateAuthDomainPayloadFactory() factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("migrate_auth_domain_payload"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &migrateAuthDomainPayload{}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *migrateAuthDomainPayload) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *migrateAuthDomainPayload) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
var rows []*authDomainPayloadRaw
|
||||
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
var oldData map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(row.Data), &oldData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// idempotency - we skip the ones which already migrated.
|
||||
if _, hasProvider := oldData["provider"]; hasProvider {
|
||||
continue
|
||||
}
|
||||
if _, hasSSOType := oldData["ssoType"]; !hasSSOType {
|
||||
continue
|
||||
}
|
||||
|
||||
var ssoType string
|
||||
if err := json.Unmarshal(oldData["ssoType"], &ssoType); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
provider := map[string]json.RawMessage{
|
||||
"type": oldData["ssoType"],
|
||||
}
|
||||
|
||||
// get from old data and set config in provider.
|
||||
if configKey, ok := legacyConfigKeyByType[ssoType]; ok {
|
||||
if cfg, ok := oldData[configKey]; ok {
|
||||
provider["config"] = cfg
|
||||
}
|
||||
}
|
||||
|
||||
providerRaw, err := json.Marshal(provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updatedData := map[string]json.RawMessage{
|
||||
"provider": providerRaw,
|
||||
}
|
||||
if v, ok := oldData["ssoEnabled"]; ok {
|
||||
updatedData["ssoEnabled"] = v
|
||||
}
|
||||
if v, ok := oldData["roleMapping"]; ok {
|
||||
updatedData["roleMapping"] = v
|
||||
}
|
||||
|
||||
updatedDataRaw, err := json.Marshal(updatedData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
row.Data = string(updatedDataRaw)
|
||||
|
||||
if _, err := tx.NewUpdate().Model(row).Column("data").Where("id = ?", row.ID).Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *migrateAuthDomainPayload) Down(ctx context.Context, db *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
@@ -31,7 +30,7 @@ var (
|
||||
|
||||
type GettableAuthDomain struct {
|
||||
StorableAuthDomain
|
||||
AuthDomainConfig
|
||||
Config AuthDomainConfig `json:"config"`
|
||||
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
|
||||
}
|
||||
|
||||
@@ -40,12 +39,12 @@ type AuthNProviderInfo struct {
|
||||
}
|
||||
|
||||
type PostableAuthDomain struct {
|
||||
Name string `json:"name"`
|
||||
AuthDomainConfig
|
||||
Config AuthDomainConfig `json:"config"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type UpdatableAuthDomain struct {
|
||||
AuthDomainConfig
|
||||
Config AuthDomainConfig `json:"config"`
|
||||
}
|
||||
|
||||
type StorableAuthDomain struct {
|
||||
@@ -58,114 +57,22 @@ type StorableAuthDomain struct {
|
||||
types.TimeAuditable
|
||||
}
|
||||
|
||||
// TODO: the oneOf emitted by JSONSchemaOneOf is not the shape OpenAPI wants
|
||||
// for a discriminated union. OpenAPI's discriminator requires every oneOf
|
||||
// branch to be a $ref to a named component and a sibling property whose value
|
||||
// selects the variant. ssoType is already discriminator-shaped, but the
|
||||
// variant payload lives in a sibling field (samlConfig / googleAuthConfig /
|
||||
// oidcConfig) instead of being the payload itself, so no discriminator can
|
||||
// be attached. Refactor AuthDomainConfig into an envelope (see
|
||||
// ruletypes.RuleThresholdData for the pattern) where the chosen config is
|
||||
// the payload and ssoType is the discriminator.
|
||||
type AuthDomainConfig struct {
|
||||
SSOEnabled bool `json:"ssoEnabled"`
|
||||
RoleMapping *RoleMapping `json:"roleMapping,omitempty"`
|
||||
Provider AuthProviderEnvelope `json:"provider"`
|
||||
}
|
||||
|
||||
func (config AuthDomainConfig) Saml() *SAMLConfig {
|
||||
cfg, _ := config.Provider.Config.(*SAMLConfig)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (config AuthDomainConfig) Google() *GoogleConfig {
|
||||
cfg, _ := config.Provider.Config.(*GoogleConfig)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (config AuthDomainConfig) Oidc() *OIDCConfig {
|
||||
cfg, _ := config.Provider.Config.(*OIDCConfig)
|
||||
return cfg
|
||||
}
|
||||
|
||||
type AuthProviderEnvelope struct {
|
||||
Type AuthNProvider `json:"type" required:"true"`
|
||||
Config any `json:"config" required:"true"` // this can be either of SamlConfig, OIDCConfig and GoogleConfig
|
||||
}
|
||||
|
||||
// internal - drives the oneOf thing in open api spec.
|
||||
type authProviderSAML struct {
|
||||
Type AuthNProvider `json:"type" required:"true"`
|
||||
Config SAMLConfig `json:"config" required:"true"`
|
||||
}
|
||||
|
||||
type authProviderOIDC struct {
|
||||
Type AuthNProvider `json:"type" required:"true"`
|
||||
Config OIDCConfig `json:"config" required:"true"`
|
||||
}
|
||||
|
||||
type authProviderGoogle struct {
|
||||
Type AuthNProvider `json:"type" required:"true"`
|
||||
Config GoogleConfig `json:"config" required:"true"`
|
||||
}
|
||||
|
||||
var (
|
||||
_ jsonschema.OneOfExposer = AuthProviderEnvelope{}
|
||||
_ jsonschema.Preparer = AuthProviderEnvelope{}
|
||||
)
|
||||
|
||||
func (AuthProviderEnvelope) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
authProviderSAML{},
|
||||
authProviderOIDC{},
|
||||
authProviderGoogle{},
|
||||
}
|
||||
}
|
||||
|
||||
func (AuthProviderEnvelope) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
if schema.ExtraProperties == nil {
|
||||
schema.ExtraProperties = map[string]any{}
|
||||
}
|
||||
|
||||
schema.ExtraProperties["x-signoz-discriminator"] = map[string]any{
|
||||
"propertyName": "type",
|
||||
"mapping": map[string]string{
|
||||
"saml": "#/components/schemas/AuthtypesAuthProviderSAML",
|
||||
"oidc": "#/components/schemas/AuthtypesAuthProviderOIDC",
|
||||
"google_auth": "#/components/schemas/AuthtypesAuthProviderGoogle",
|
||||
},
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (envelop *AuthProviderEnvelope) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Type AuthNProvider `json:"type"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to unmarshal auth provider: %v", err)
|
||||
}
|
||||
|
||||
envelop.Type = raw.Type
|
||||
|
||||
switch raw.Type {
|
||||
case AuthNProviderSAML:
|
||||
cfg := new(SAMLConfig)
|
||||
if err := json.Unmarshal(raw.Config, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
envelop.Config = cfg
|
||||
case AuthNProviderOIDC:
|
||||
cfg := new(OIDCConfig)
|
||||
if err := json.Unmarshal(raw.Config, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
envelop.Config = cfg
|
||||
case AuthNProviderGoogleAuth:
|
||||
cfg := new(GoogleConfig)
|
||||
if err := json.Unmarshal(raw.Config, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
envelop.Config = cfg
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown auth provider type: %s", raw.Type.StringValue())
|
||||
}
|
||||
|
||||
return nil
|
||||
SSOEnabled bool `json:"ssoEnabled"`
|
||||
AuthNProvider AuthNProvider `json:"ssoType"`
|
||||
SAML *SamlConfig `json:"samlConfig"`
|
||||
Google *GoogleConfig `json:"googleAuthConfig"`
|
||||
OIDC *OIDCConfig `json:"oidcConfig"`
|
||||
RoleMapping *RoleMapping `json:"roleMapping"`
|
||||
}
|
||||
|
||||
type AuthDomain struct {
|
||||
@@ -214,7 +121,7 @@ func NewAuthDomainFromStorableAuthDomain(storableAuthDomain *StorableAuthDomain)
|
||||
func NewGettableAuthDomainFromAuthDomain(authDomain *AuthDomain, authNProviderInfo *AuthNProviderInfo) *GettableAuthDomain {
|
||||
return &GettableAuthDomain{
|
||||
StorableAuthDomain: *authDomain.StorableAuthDomain(),
|
||||
AuthDomainConfig: *authDomain.AuthDomainConfig(),
|
||||
Config: *authDomain.AuthDomainConfig(),
|
||||
AuthNProviderInfo: authNProviderInfo,
|
||||
}
|
||||
}
|
||||
@@ -251,14 +158,51 @@ func (typ *PostableAuthDomain) UnmarshalJSON(data []byte) error {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidName, "invalid domain name %s", temp.Name)
|
||||
}
|
||||
|
||||
if temp.Provider.Config == nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "provider config is required")
|
||||
}
|
||||
|
||||
*typ = PostableAuthDomain(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (typ *AuthDomainConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias AuthDomainConfig
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch temp.AuthNProvider {
|
||||
case AuthNProviderGoogleAuth:
|
||||
if temp.Google == nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "google auth config is required")
|
||||
}
|
||||
|
||||
case AuthNProviderSAML:
|
||||
if temp.SAML == nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "saml config is required")
|
||||
}
|
||||
|
||||
case AuthNProviderOIDC:
|
||||
if temp.OIDC == nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "oidc config is required")
|
||||
}
|
||||
|
||||
default:
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "invalid authn provider %q", temp.AuthNProvider.StringValue())
|
||||
}
|
||||
|
||||
*typ = AuthDomainConfig(temp)
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func (AuthDomainConfig) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
SamlConfig{},
|
||||
GoogleConfig{},
|
||||
OIDCConfig{},
|
||||
}
|
||||
}
|
||||
|
||||
type AuthDomainStore interface {
|
||||
// Get by id.
|
||||
Get(context.Context, valuer.UUID) (*AuthDomain, error)
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Verifies the new flat wire shape: ssoType/provider configs collapse into a
|
||||
// single discriminated `provider:{type,config}`, and the typed payload survives
|
||||
// a marshal -> unmarshal round-trip. This fails if UnmarshalJSON forgets to
|
||||
// assign envelop.Config (the decoded config would be lost).
|
||||
func TestAuthDomainConfigWireRoundTrip(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
provider AuthNProvider
|
||||
config any
|
||||
wantType string
|
||||
assertConfig func(t *testing.T, c AuthDomainConfig)
|
||||
}{
|
||||
{
|
||||
name: "saml",
|
||||
provider: AuthNProviderSAML,
|
||||
config: &SAMLConfig{
|
||||
SamlEntity: "https://idp.example.com",
|
||||
SamlIdp: "https://idp.example.com/sso",
|
||||
SamlCert: "cert-bytes",
|
||||
},
|
||||
wantType: "saml",
|
||||
assertConfig: func(t *testing.T, c AuthDomainConfig) {
|
||||
require.NotNil(t, c.Saml())
|
||||
require.Equal(t, "https://idp.example.com", c.Saml().SamlEntity)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "google",
|
||||
provider: AuthNProviderGoogleAuth,
|
||||
config: &GoogleConfig{ClientID: "cid", ClientSecret: "secret"},
|
||||
wantType: "google_auth",
|
||||
assertConfig: func(t *testing.T, c AuthDomainConfig) {
|
||||
require.NotNil(t, c.Google())
|
||||
require.Equal(t, "cid", c.Google().ClientID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "oidc",
|
||||
provider: AuthNProviderOIDC,
|
||||
config: &OIDCConfig{Issuer: "https://issuer", ClientID: "cid", ClientSecret: "secret"},
|
||||
wantType: "oidc",
|
||||
assertConfig: func(t *testing.T, c AuthDomainConfig) {
|
||||
require.NotNil(t, c.Oidc())
|
||||
require.Equal(t, "https://issuer", c.Oidc().Issuer)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
in := AuthDomainConfig{
|
||||
SSOEnabled: true,
|
||||
Provider: AuthProviderEnvelope{Type: tt.provider, Config: tt.config},
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(in)
|
||||
require.NoError(t, err)
|
||||
|
||||
js := string(raw)
|
||||
require.Contains(t, js, `"provider"`)
|
||||
require.Contains(t, js, `"type":"`+tt.wantType+`"`)
|
||||
// legacy keys must be gone
|
||||
require.NotContains(t, js, "ssoType")
|
||||
require.NotContains(t, js, "samlConfig")
|
||||
require.NotContains(t, js, "googleAuthConfig")
|
||||
require.NotContains(t, js, "oidcConfig")
|
||||
|
||||
var out AuthDomainConfig
|
||||
require.NoError(t, json.Unmarshal(raw, &out))
|
||||
require.True(t, out.SSOEnabled)
|
||||
require.Equal(t, tt.provider, out.Provider.Type)
|
||||
tt.assertConfig(t, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown discriminator values are rejected, and the nested provider config's
|
||||
// own validators still run through the envelope.
|
||||
func TestAuthDomainConfigUnmarshalRejects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
}{
|
||||
{
|
||||
name: "unknown provider type",
|
||||
json: `{"ssoEnabled":true,"provider":{"type":"ldap","config":{}}}`,
|
||||
},
|
||||
{
|
||||
name: "oidc config missing clientId",
|
||||
json: `{"ssoEnabled":true,"provider":{"type":"oidc","config":{"issuer":"https://issuer","clientSecret":"secret"}}}`,
|
||||
},
|
||||
{
|
||||
name: "saml config missing samlEntity",
|
||||
json: `{"ssoEnabled":true,"provider":{"type":"saml","config":{"samlIdp":"https://idp/sso","samlCert":"abc"}}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var c AuthDomainConfig
|
||||
require.Error(t, json.Unmarshal([]byte(tt.json), &c))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The config is marshaled into the `data` column and unmarshaled back when the
|
||||
// domain is loaded, so the typed provider config must survive that round-trip.
|
||||
func TestAuthDomainStorageRoundTrip(t *testing.T) {
|
||||
cfg := AuthDomainConfig{
|
||||
SSOEnabled: true,
|
||||
Provider: AuthProviderEnvelope{
|
||||
Type: AuthNProviderSAML,
|
||||
Config: &SAMLConfig{SamlEntity: "https://idp", SamlIdp: "https://idp/sso", SamlCert: "abc"},
|
||||
},
|
||||
}
|
||||
|
||||
domain, err := NewAuthDomainFromConfig("example.com", &cfg, valuer.GenerateUUID())
|
||||
require.NoError(t, err)
|
||||
|
||||
got := domain.AuthDomainConfig()
|
||||
require.Equal(t, AuthNProviderSAML, got.Provider.Type)
|
||||
require.NotNil(t, got.Saml())
|
||||
require.Equal(t, "https://idp", got.Saml().SamlEntity)
|
||||
}
|
||||
|
||||
// Postable keeps name-regex validation and still decodes the embedded provider.
|
||||
func TestPostableAuthDomainUnmarshal(t *testing.T) {
|
||||
valid := `{
|
||||
"name":"example.com",
|
||||
"ssoEnabled":true,
|
||||
"provider":{"type":"saml","config":{"samlEntity":"https://idp","samlIdp":"https://idp/sso","samlCert":"abc"}}
|
||||
}`
|
||||
|
||||
var p PostableAuthDomain
|
||||
require.NoError(t, json.Unmarshal([]byte(valid), &p))
|
||||
require.Equal(t, "example.com", p.Name)
|
||||
require.True(t, p.SSOEnabled)
|
||||
require.NotNil(t, p.Saml())
|
||||
require.Equal(t, "https://idp", p.Saml().SamlEntity)
|
||||
|
||||
invalid := `{"name":"not a domain!","provider":{"type":"saml","config":{"samlEntity":"https://idp","samlIdp":"https://idp/sso","samlCert":"abc"}}}`
|
||||
var bad PostableAuthDomain
|
||||
require.Error(t, json.Unmarshal([]byte(invalid), &bad))
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
type SAMLConfig struct {
|
||||
type SamlConfig struct {
|
||||
// The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{samlEntity}">
|
||||
SamlEntity string `json:"samlEntity"`
|
||||
|
||||
@@ -25,8 +25,8 @@ type SAMLConfig struct {
|
||||
AttributeMapping AttributeMapping `json:"attributeMapping"`
|
||||
}
|
||||
|
||||
func (config *SAMLConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias SAMLConfig
|
||||
func (config *SamlConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias SamlConfig
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
@@ -51,6 +51,6 @@ func (config *SAMLConfig) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
*config = SAMLConfig(temp)
|
||||
*config = SamlConfig(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
5
tests/fixtures/idp.py
vendored
5
tests/fixtures/idp.py
vendored
@@ -371,11 +371,10 @@ def idp_login(driver: webdriver.Chrome) -> Callable[[str, str], None]:
|
||||
|
||||
# Click the login button
|
||||
login_button = wait.until(EC.element_to_be_clickable((By.ID, "kc-login")))
|
||||
current_url = driver.current_url
|
||||
login_button.click()
|
||||
|
||||
# Wait till the page redirects away from the login form. We poll the URL.
|
||||
wait.until(EC.url_changes(current_url))
|
||||
# Wait till kc-login element has vanished from the page, which means that a redirection is taking place.
|
||||
wait.until(EC.invisibility_of_element((By.ID, "kc-login")))
|
||||
|
||||
return _idp_login
|
||||
|
||||
|
||||
@@ -53,10 +53,10 @@ def test_create_auth_domain(
|
||||
signoz.self.host_configs["8080"].get("/signoz/api/v1/domains"),
|
||||
json={
|
||||
"name": "oidc.basepath.test",
|
||||
"ssoEnabled": True,
|
||||
"provider": {
|
||||
"type": "oidc",
|
||||
"config": {
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "oidc",
|
||||
"oidcConfig": {
|
||||
"clientId": settings["client_id"],
|
||||
"clientSecret": settings["client_secret"],
|
||||
# Change the hostname of the issuer to the internal resolvable hostname of the idp
|
||||
|
||||
@@ -51,10 +51,10 @@ def test_create_auth_domain(
|
||||
signoz.self.host_configs["8080"].get("/signoz/api/v1/domains"),
|
||||
json={
|
||||
"name": "saml.basepath.test",
|
||||
"ssoEnabled": True,
|
||||
"provider": {
|
||||
"type": "saml",
|
||||
"config": {
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": settings["entityID"],
|
||||
"samlIdp": settings["singleSignOnServiceLocation"],
|
||||
"samlCert": settings["certificate"],
|
||||
|
||||
@@ -31,10 +31,10 @@ def test_create_and_get_domain(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
json={
|
||||
"name": "domain-google.integration.test",
|
||||
"ssoEnabled": True,
|
||||
"provider": {
|
||||
"type": "google_auth",
|
||||
"config": {
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "google_auth",
|
||||
"googleAuthConfig": {
|
||||
"clientId": "client-id",
|
||||
"clientSecret": "client-secret",
|
||||
"redirectURI": "redirect-uri",
|
||||
@@ -52,10 +52,10 @@ def test_create_and_get_domain(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
json={
|
||||
"name": "domain-saml.integration.test",
|
||||
"ssoEnabled": True,
|
||||
"provider": {
|
||||
"type": "saml",
|
||||
"config": {
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": "saml-entity",
|
||||
"samlIdp": "saml-idp",
|
||||
"samlCert": "saml-cert",
|
||||
@@ -86,7 +86,7 @@ def test_create_and_get_domain(
|
||||
"domain-google.integration.test",
|
||||
"domain-saml.integration.test",
|
||||
]
|
||||
assert domain["provider"]["type"] in ["google_auth", "saml"]
|
||||
assert domain["config"]["ssoType"] in ["google_auth", "saml"]
|
||||
|
||||
|
||||
def test_create_invalid(
|
||||
@@ -96,16 +96,15 @@ def test_create_invalid(
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Create a domain with type saml but an oidc-shaped config; this should fail
|
||||
# because the config is decoded as SAML and fails SAML validation.
|
||||
# Create a domain with type saml and body for oidc, this should fail because oidcConfig is not allowed for saml
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
json={
|
||||
"name": "domain.integration.test",
|
||||
"ssoEnabled": True,
|
||||
"provider": {
|
||||
"type": "saml",
|
||||
"config": {
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"oidcConfig": {
|
||||
"clientId": "client-id",
|
||||
"clientSecret": "client-secret",
|
||||
"issuer": "issuer",
|
||||
@@ -123,10 +122,10 @@ def test_create_invalid(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
json={
|
||||
"name": "$%^invalid",
|
||||
"ssoEnabled": True,
|
||||
"provider": {
|
||||
"type": "saml",
|
||||
"config": {
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": "saml-entity",
|
||||
"samlIdp": "saml-idp",
|
||||
"samlCert": "saml-cert",
|
||||
@@ -143,15 +142,15 @@ def test_create_invalid(
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
json={
|
||||
"ssoEnabled": True,
|
||||
"provider": {
|
||||
"type": "saml",
|
||||
"config": {
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": "saml-entity",
|
||||
"samlIdp": "saml-idp",
|
||||
"samlCert": "saml-cert",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
@@ -159,7 +158,7 @@ def test_create_invalid(
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
# Create a domain with no provider
|
||||
# Create a domain with no config
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
json={
|
||||
@@ -185,17 +184,17 @@ def test_create_invalid_role_mapping(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
json={
|
||||
"name": "invalid-role-test.integration.test",
|
||||
"ssoEnabled": True,
|
||||
"provider": {
|
||||
"type": "saml",
|
||||
"config": {
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": "saml-entity",
|
||||
"samlIdp": "saml-idp",
|
||||
"samlCert": "saml-cert",
|
||||
},
|
||||
},
|
||||
"roleMapping": {
|
||||
"defaultRole": "SUPERADMIN", # Invalid role
|
||||
"roleMapping": {
|
||||
"defaultRole": "SUPERADMIN", # Invalid role
|
||||
},
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
@@ -209,19 +208,19 @@ def test_create_invalid_role_mapping(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
json={
|
||||
"name": "invalid-group-role.integration.test",
|
||||
"ssoEnabled": True,
|
||||
"provider": {
|
||||
"type": "saml",
|
||||
"config": {
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": "saml-entity",
|
||||
"samlIdp": "saml-idp",
|
||||
"samlCert": "saml-cert",
|
||||
},
|
||||
},
|
||||
"roleMapping": {
|
||||
"defaultRole": "VIEWER",
|
||||
"groupMappings": {
|
||||
"admins": "SUPERUSER", # Invalid role
|
||||
"roleMapping": {
|
||||
"defaultRole": "VIEWER",
|
||||
"groupMappings": {
|
||||
"admins": "SUPERUSER", # Invalid role
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -236,20 +235,20 @@ def test_create_invalid_role_mapping(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
json={
|
||||
"name": "valid-role-mapping.integration.test",
|
||||
"ssoEnabled": True,
|
||||
"provider": {
|
||||
"type": "saml",
|
||||
"config": {
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": "saml-entity",
|
||||
"samlIdp": "saml-idp",
|
||||
"samlCert": "saml-cert",
|
||||
},
|
||||
},
|
||||
"roleMapping": {
|
||||
"defaultRole": "VIEWER",
|
||||
"groupMappings": {
|
||||
"signoz-admins": "ADMIN",
|
||||
"signoz-editors": "EDITOR",
|
||||
"roleMapping": {
|
||||
"defaultRole": "VIEWER",
|
||||
"groupMappings": {
|
||||
"signoz-admins": "ADMIN",
|
||||
"signoz-editors": "EDITOR",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -53,10 +53,10 @@ def test_create_auth_domain(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
json={
|
||||
"name": "saml.integration.test",
|
||||
"ssoEnabled": True,
|
||||
"provider": {
|
||||
"type": "saml",
|
||||
"config": {
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": settings["entityID"],
|
||||
"samlIdp": settings["singleSignOnServiceLocation"],
|
||||
"samlCert": settings["certificate"],
|
||||
@@ -176,10 +176,10 @@ def test_saml_update_domain_with_group_mappings(
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
|
||||
json={
|
||||
"ssoEnabled": True,
|
||||
"provider": {
|
||||
"type": "saml",
|
||||
"config": {
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": settings["entityID"],
|
||||
"samlIdp": settings["singleSignOnServiceLocation"],
|
||||
"samlCert": settings["certificate"],
|
||||
@@ -189,15 +189,15 @@ def test_saml_update_domain_with_group_mappings(
|
||||
"role": "signoz_role",
|
||||
},
|
||||
},
|
||||
},
|
||||
"roleMapping": {
|
||||
"defaultRole": "VIEWER",
|
||||
"groupMappings": {
|
||||
"signoz-admins": "ADMIN",
|
||||
"signoz-editors": "EDITOR",
|
||||
"signoz-viewers": "VIEWER",
|
||||
"roleMapping": {
|
||||
"defaultRole": "VIEWER",
|
||||
"groupMappings": {
|
||||
"signoz-admins": "ADMIN",
|
||||
"signoz-editors": "EDITOR",
|
||||
"signoz-viewers": "VIEWER",
|
||||
},
|
||||
"useRoleAttribute": False,
|
||||
},
|
||||
"useRoleAttribute": False,
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
@@ -340,10 +340,10 @@ def test_saml_update_domain_with_use_role_claim(
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
|
||||
json={
|
||||
"ssoEnabled": True,
|
||||
"provider": {
|
||||
"type": "saml",
|
||||
"config": {
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": settings["entityID"],
|
||||
"samlIdp": settings["singleSignOnServiceLocation"],
|
||||
"samlCert": settings["certificate"],
|
||||
@@ -353,14 +353,14 @@ def test_saml_update_domain_with_use_role_claim(
|
||||
"role": "signoz_role",
|
||||
},
|
||||
},
|
||||
},
|
||||
"roleMapping": {
|
||||
"defaultRole": "VIEWER",
|
||||
"groupMappings": {
|
||||
"signoz-admins": "ADMIN",
|
||||
"signoz-editors": "EDITOR",
|
||||
"roleMapping": {
|
||||
"defaultRole": "VIEWER",
|
||||
"groupMappings": {
|
||||
"signoz-admins": "ADMIN",
|
||||
"signoz-editors": "EDITOR",
|
||||
},
|
||||
"useRoleAttribute": True,
|
||||
},
|
||||
"useRoleAttribute": True,
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
|
||||
@@ -57,10 +57,10 @@ def test_create_auth_domain(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
json={
|
||||
"name": "oidc.integration.test",
|
||||
"ssoEnabled": True,
|
||||
"provider": {
|
||||
"type": "oidc",
|
||||
"config": {
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "oidc",
|
||||
"oidcConfig": {
|
||||
"clientId": settings["client_id"],
|
||||
"clientSecret": settings["client_secret"],
|
||||
# Change the hostname of the issuer to the internal resolvable hostname of the idp
|
||||
@@ -132,10 +132,10 @@ def test_oidc_update_domain_with_group_mappings(
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
|
||||
json={
|
||||
"ssoEnabled": True,
|
||||
"provider": {
|
||||
"type": "oidc",
|
||||
"config": {
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "oidc",
|
||||
"oidcConfig": {
|
||||
"clientId": settings["client_id"],
|
||||
"clientSecret": settings["client_secret"],
|
||||
"issuer": f"{idp.container.container_configs['6060'].get(urlparse(settings['issuer']).path)}",
|
||||
@@ -148,15 +148,15 @@ def test_oidc_update_domain_with_group_mappings(
|
||||
"role": "signoz_role",
|
||||
},
|
||||
},
|
||||
},
|
||||
"roleMapping": {
|
||||
"defaultRole": "VIEWER",
|
||||
"groupMappings": {
|
||||
"signoz-admins": "ADMIN",
|
||||
"signoz-editors": "EDITOR",
|
||||
"signoz-viewers": "VIEWER",
|
||||
"roleMapping": {
|
||||
"defaultRole": "VIEWER",
|
||||
"groupMappings": {
|
||||
"signoz-admins": "ADMIN",
|
||||
"signoz-editors": "EDITOR",
|
||||
"signoz-viewers": "VIEWER",
|
||||
},
|
||||
"useRoleAttribute": False,
|
||||
},
|
||||
"useRoleAttribute": False,
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
@@ -301,10 +301,10 @@ def test_oidc_update_domain_with_use_role_claim(
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
|
||||
json={
|
||||
"ssoEnabled": True,
|
||||
"provider": {
|
||||
"type": "oidc",
|
||||
"config": {
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "oidc",
|
||||
"oidcConfig": {
|
||||
"clientId": settings["client_id"],
|
||||
"clientSecret": settings["client_secret"],
|
||||
"issuer": f"{idp.container.container_configs['6060'].get(urlparse(settings['issuer']).path)}",
|
||||
@@ -317,14 +317,14 @@ def test_oidc_update_domain_with_use_role_claim(
|
||||
"role": "signoz_role",
|
||||
},
|
||||
},
|
||||
},
|
||||
"roleMapping": {
|
||||
"defaultRole": "VIEWER",
|
||||
"groupMappings": {
|
||||
"signoz-admins": "ADMIN",
|
||||
"signoz-editors": "EDITOR",
|
||||
"roleMapping": {
|
||||
"defaultRole": "VIEWER",
|
||||
"groupMappings": {
|
||||
"signoz-admins": "ADMIN",
|
||||
"signoz-editors": "EDITOR",
|
||||
},
|
||||
"useRoleAttribute": True,
|
||||
},
|
||||
"useRoleAttribute": True,
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
|
||||
Reference in New Issue
Block a user