mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-11 19:30:31 +01:00
Compare commits
1 Commits
feat/auth-
...
feat/llm-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dd887f7fd |
@@ -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())
|
||||
}
|
||||
|
||||
@@ -323,3 +323,10 @@ export const AIAssistantPage = Loadable(
|
||||
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityModelPricingPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
IntegrationsDetailsPage,
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityModelPricingPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
@@ -507,6 +508,13 @@ const routes: AppRoutes[] = [
|
||||
key: 'AI_ASSISTANT',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
exact: true,
|
||||
component: LLMObservabilityModelPricingPage,
|
||||
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORT_ROUTE: AppRoutes = {
|
||||
|
||||
@@ -91,6 +91,7 @@ const ROUTES = {
|
||||
AI_ASSISTANT_BASE: '/ai-assistant',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/model-pricing',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -40,31 +40,13 @@ type SpeechRecognitionConstructor = new () => ISpeechRecognition;
|
||||
|
||||
// ── Vendor-prefix shim for Safari / older browsers ────────────────────────────
|
||||
|
||||
// Some hardened/enterprise browsers install a getter
|
||||
// on window.SpeechRecognition that THROWS on access ("Web Speech API is disabled
|
||||
// due to your security policy") instead of leaving the property undefined.
|
||||
// Because this resolves at module-evaluation time, an uncaught throw here aborts
|
||||
// the entire bundle and the app renders a blank page. Read defensively so a
|
||||
// throwing getter degrades to "unsupported" rather than crashing the app.
|
||||
function resolveSpeechRecognitionAPI(): SpeechRecognitionConstructor | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).SpeechRecognition ??
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).webkitSpeechRecognition ??
|
||||
null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const SpeechRecognitionAPI: SpeechRecognitionConstructor | null =
|
||||
resolveSpeechRecognitionAPI();
|
||||
typeof window !== 'undefined'
|
||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
((window as any).SpeechRecognition ??
|
||||
(window as any).webkitSpeechRecognition ??
|
||||
null)
|
||||
: null;
|
||||
|
||||
export type SpeechRecognitionError =
|
||||
| 'not-supported'
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
.llm-observability-model-pricing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px 32px;
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
&__title {
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-tabs {
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filters-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
|
||||
&__search {
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
&__source,
|
||||
&__currency {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
&__add {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.page-error {
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 90, 90, 0.08);
|
||||
color: var(--bg-cherry-400);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.model-costs-table {
|
||||
.model-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__canonical-id {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.price-cell {
|
||||
font-family: var(--code-font-family, monospace);
|
||||
}
|
||||
|
||||
.extra-buckets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
&__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__key {
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__price {
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
margin: 0;
|
||||
|
||||
&--auto {
|
||||
background: rgba(78, 116, 248, 0.12);
|
||||
color: var(--bg-robin-400);
|
||||
border-color: rgba(78, 116, 248, 0.24);
|
||||
}
|
||||
|
||||
&--override {
|
||||
background: rgba(245, 175, 25, 0.12);
|
||||
color: var(--bg-amber-400);
|
||||
border-color: rgba(245, 175, 25, 0.24);
|
||||
}
|
||||
}
|
||||
|
||||
&__row--selected {
|
||||
background: rgba(78, 116, 248, 0.06);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button, Input, Select, Tabs } from 'antd';
|
||||
import { Plus, Search } from '@signozhq/icons';
|
||||
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import ModelCostDrawer from './ModelCostDrawer';
|
||||
import ModelCostsTable from './ModelCostsTable';
|
||||
import { useModelCostDrawer } from './useModelCostDrawer';
|
||||
import { filterRules, type PricingRule, type SourceFilter } from './utils';
|
||||
|
||||
import './LLMObservabilityModelPricing.styles.scss';
|
||||
|
||||
const SOURCE_OPTIONS: { value: SourceFilter; label: string }[] = [
|
||||
{ value: 'all', label: 'Source: All' },
|
||||
{ value: 'auto', label: 'Auto-populated' },
|
||||
{ value: 'override', label: 'User override' },
|
||||
];
|
||||
|
||||
const CURRENCY_OPTIONS = [
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'EUR', label: 'EUR', disabled: true },
|
||||
{ value: 'INR', label: 'INR', disabled: true },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
function LLMObservabilityModelPricing(): JSX.Element {
|
||||
const [search, setSearch] = useState<string>('');
|
||||
const [source, setSource] = useState<SourceFilter>('all');
|
||||
const [currency, setCurrency] = useState<string>('USD');
|
||||
|
||||
const { data, isLoading, isError } = useListLLMPricingRules({
|
||||
offset: 0,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const rules: PricingRule[] = useMemo(() => data?.data?.items || [], [data]);
|
||||
|
||||
const filteredRules = useMemo(
|
||||
() => filterRules(rules, search, source),
|
||||
[rules, search, source],
|
||||
);
|
||||
|
||||
const drawer = useModelCostDrawer();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="llm-observability-model-pricing"
|
||||
data-testid="llm-observability-model-pricing-page"
|
||||
>
|
||||
<header className="page-header">
|
||||
<div className="page-header__title">
|
||||
<h1>Configuration</h1>
|
||||
<p>Model pricing and cost estimation settings</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
className="page-tabs"
|
||||
defaultActiveKey="model-costs"
|
||||
items={[
|
||||
{ key: 'model-costs', label: 'Model costs' },
|
||||
{
|
||||
key: 'unpriced-models',
|
||||
label: 'Unpriced models',
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="filters-bar">
|
||||
<Input
|
||||
className="filters-bar__search"
|
||||
placeholder="Search by model or provider…"
|
||||
prefix={<Search size={14} />}
|
||||
value={search}
|
||||
onChange={(event): void => setSearch(event.target.value)}
|
||||
data-testid="search-input"
|
||||
allowClear
|
||||
/>
|
||||
<Select<SourceFilter>
|
||||
className="filters-bar__source"
|
||||
value={source}
|
||||
onChange={(value): void => setSource(value)}
|
||||
options={SOURCE_OPTIONS}
|
||||
data-testid="source-select"
|
||||
/>
|
||||
<Select<string>
|
||||
className="filters-bar__currency"
|
||||
value={currency}
|
||||
onChange={(value): void => setCurrency(value)}
|
||||
options={CURRENCY_OPTIONS}
|
||||
data-testid="currency-select"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
className="filters-bar__add"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void => drawer.openForAdd()}
|
||||
data-testid="add-model-cost-btn"
|
||||
>
|
||||
Add model cost
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className="page-error" role="alert">
|
||||
Failed to load pricing rules. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelCostsTable
|
||||
rules={filteredRules}
|
||||
isLoading={isLoading}
|
||||
selectedRuleId={drawer.selectedRuleId}
|
||||
onEdit={drawer.openForEdit}
|
||||
/>
|
||||
|
||||
<footer className="page-footer">
|
||||
Showing {filteredRules.length} model{filteredRules.length === 1 ? '' : 's'}
|
||||
{' · '}All prices per 1M tokens (USD)
|
||||
</footer>
|
||||
|
||||
<ModelCostDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
mode={drawer.mode}
|
||||
draft={drawer.draft}
|
||||
setDraft={drawer.setDraft}
|
||||
onClose={drawer.close}
|
||||
onSave={drawer.save}
|
||||
onDelete={drawer.deleteRule}
|
||||
isSaving={drawer.isSaving}
|
||||
isDeleting={drawer.isDeleting}
|
||||
saveError={drawer.saveError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LLMObservabilityModelPricing;
|
||||
@@ -0,0 +1,256 @@
|
||||
.model-cost-drawer {
|
||||
.ant-drawer-body {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--bg-slate-300);
|
||||
|
||||
&-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
label,
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--bg-vanilla-200);
|
||||
}
|
||||
|
||||
.help {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.drawer-surface {
|
||||
padding: 14px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.managed-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.pattern-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.pattern-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&__remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-left: 2px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pattern-add {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pattern-test {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
|
||||
&__result {
|
||||
font-size: 12px;
|
||||
|
||||
&--match {
|
||||
color: var(--bg-forest-400);
|
||||
}
|
||||
|
||||
&--no-match {
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.source-radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.source-radio {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
|
||||
&__title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
&.ant-radio-wrapper-checked.source-radio--auto {
|
||||
background: rgba(78, 116, 248, 0.1);
|
||||
border-color: rgba(78, 116, 248, 0.3);
|
||||
}
|
||||
|
||||
&.ant-radio-wrapper-checked.source-radio--override {
|
||||
background: rgba(245, 175, 25, 0.1);
|
||||
border-color: rgba(245, 175, 25, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.reset-confirm {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(78, 116, 248, 0.06);
|
||||
border: 1px solid rgba(78, 116, 248, 0.2);
|
||||
|
||||
p {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.pricing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pricing-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.ant-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.cache-mode-field {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.extras-divider {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.cost-preview {
|
||||
&__line {
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-size: 12px;
|
||||
|
||||
strong {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-error {
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 90, 90, 0.08);
|
||||
color: var(--bg-cherry-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button, Drawer, Input, InputNumber, Select, Tooltip } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Lock, Trash2, X } from '@signozhq/icons';
|
||||
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
CACHE_MODE_OPTIONS,
|
||||
computeCostPreview,
|
||||
matchesAnyPattern,
|
||||
PROVIDER_OPTIONS,
|
||||
validateDraft,
|
||||
type DrawerDraft,
|
||||
type DrawerMode,
|
||||
} from './drawerUtils';
|
||||
import './ModelCostDrawer.styles.scss';
|
||||
|
||||
interface ModelCostDrawerProps {
|
||||
isOpen: boolean;
|
||||
mode: DrawerMode;
|
||||
draft: DrawerDraft;
|
||||
setDraft: (next: DrawerDraft) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
saveError: string | null;
|
||||
}
|
||||
|
||||
function ModelCostDrawer({
|
||||
isOpen,
|
||||
mode,
|
||||
draft,
|
||||
setDraft,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
saveError,
|
||||
}: ModelCostDrawerProps): JSX.Element {
|
||||
const [patternInput, setPatternInput] = useState<string>('');
|
||||
const [testInput, setTestInput] = useState<string>('');
|
||||
const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
|
||||
const isReadOnly = !draft.isOverride;
|
||||
|
||||
const validation = validateDraft(draft, mode);
|
||||
const preview = useMemo(() => computeCostPreview(draft), [draft]);
|
||||
const testMatch = useMemo(
|
||||
() => (testInput ? matchesAnyPattern(testInput, draft.patterns) : null),
|
||||
[testInput, draft.patterns],
|
||||
);
|
||||
|
||||
const update = (patch: Partial<DrawerDraft>): void => {
|
||||
setDraft({ ...draft, ...patch });
|
||||
};
|
||||
|
||||
const updatePricing = (patch: Partial<DrawerDraft['pricing']>): void => {
|
||||
setDraft({ ...draft, pricing: { ...draft.pricing, ...patch } });
|
||||
};
|
||||
|
||||
const addPattern = (): void => {
|
||||
const next = patternInput.trim();
|
||||
if (!next || draft.patterns.includes(next)) {
|
||||
setPatternInput('');
|
||||
return;
|
||||
}
|
||||
update({ patterns: [...draft.patterns, next] });
|
||||
setPatternInput('');
|
||||
};
|
||||
|
||||
const removePattern = (pattern: string): void => {
|
||||
update({ patterns: draft.patterns.filter((p) => p !== pattern) });
|
||||
};
|
||||
|
||||
const handleSourceChange = (value: 'auto' | 'override'): void => {
|
||||
if (value === 'auto' && draft.isOverride) {
|
||||
setShowResetConfirm(true);
|
||||
return;
|
||||
}
|
||||
if (value === 'override' && !draft.isOverride) {
|
||||
update({ isOverride: true });
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReset = (): void => {
|
||||
update({ isOverride: false });
|
||||
setShowResetConfirm(false);
|
||||
};
|
||||
|
||||
const hasCacheBucket =
|
||||
draft.pricing.cacheRead !== null || draft.pricing.cacheWrite !== null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width={520}
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
placement="right"
|
||||
className="model-cost-drawer"
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} />}
|
||||
title={
|
||||
<div className="model-cost-drawer__title">
|
||||
<h3>{mode === 'edit' ? 'Edit model cost' : 'Add model cost'}</h3>
|
||||
<p>Pricing computes gen_ai.estimated_total_cost at ingest.</p>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<div className="model-cost-drawer__footer">
|
||||
{mode === 'edit' && (
|
||||
<Button
|
||||
danger
|
||||
type="text"
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={onDelete}
|
||||
loading={isDeleting}
|
||||
data-testid="drawer-delete-btn"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<div className="model-cost-drawer__footer-right">
|
||||
<Button onClick={onClose} data-testid="drawer-cancel-btn">
|
||||
Cancel
|
||||
</Button>
|
||||
<Tooltip title={validation.ok ? '' : validation.message}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onSave}
|
||||
loading={isSaving}
|
||||
disabled={!validation.ok}
|
||||
data-testid="drawer-save-btn"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="drawer-section">
|
||||
<label htmlFor="billing-model-id">Billing model ID</label>
|
||||
<Input
|
||||
id="billing-model-id"
|
||||
placeholder="e.g. openai:gpt-4o"
|
||||
value={draft.modelName}
|
||||
disabled={mode === 'edit'}
|
||||
onChange={(e): void => update({ modelName: e.target.value })}
|
||||
data-testid="drawer-model-id-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="drawer-section">
|
||||
<label htmlFor="provider-select">Provider</label>
|
||||
<Select
|
||||
id="provider-select"
|
||||
value={draft.provider}
|
||||
onChange={(value): void => update({ provider: value })}
|
||||
options={PROVIDER_OPTIONS}
|
||||
disabled={isReadOnly}
|
||||
className="full-width"
|
||||
data-testid="drawer-provider-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="drawer-section">
|
||||
<span className="field-label">
|
||||
Model name patterns <span className="muted">(prefix match)</span>
|
||||
</span>
|
||||
<div className="pattern-chips">
|
||||
{draft.patterns.map((pattern) => (
|
||||
<Badge
|
||||
key={pattern}
|
||||
color="forest"
|
||||
variant="outline"
|
||||
className="pattern-chip"
|
||||
>
|
||||
{pattern}*
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove pattern ${pattern}`}
|
||||
className="pattern-chip__remove"
|
||||
onClick={(): void => removePattern(pattern)}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<div className="pattern-add">
|
||||
<Input
|
||||
placeholder="Add pattern…"
|
||||
value={patternInput}
|
||||
onChange={(e): void => setPatternInput(e.target.value)}
|
||||
onPressEnter={addPattern}
|
||||
data-testid="drawer-pattern-input"
|
||||
/>
|
||||
<Button onClick={addPattern} data-testid="drawer-pattern-add-btn">
|
||||
+ Add
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<p className="muted help">
|
||||
Each pattern uses prefix matching against gen_ai.request.model.
|
||||
</p>
|
||||
{!isReadOnly && (
|
||||
<div className="pattern-test">
|
||||
<Input
|
||||
placeholder="Test: type a model name…"
|
||||
value={testInput}
|
||||
onChange={(e): void => setTestInput(e.target.value)}
|
||||
data-testid="drawer-pattern-test-input"
|
||||
/>
|
||||
{testInput && (
|
||||
<span
|
||||
className={`pattern-test__result ${
|
||||
testMatch
|
||||
? 'pattern-test__result--match'
|
||||
: 'pattern-test__result--no-match'
|
||||
}`}
|
||||
data-testid="drawer-pattern-test-result"
|
||||
>
|
||||
{testMatch ? `Matched: ${testMatch}*` : 'No matching pattern'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="drawer-section drawer-surface">
|
||||
<div className="drawer-surface__head">
|
||||
<h4>Source</h4>
|
||||
{isReadOnly && (
|
||||
<span className="managed-label">
|
||||
<Lock size={12} />
|
||||
Managed by SigNoz
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={draft.isOverride ? 'override' : 'auto'}
|
||||
onChange={(value): void =>
|
||||
handleSourceChange(value as 'auto' | 'override')
|
||||
}
|
||||
className="source-radio-group"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="auto"
|
||||
className="source-radio source-radio--auto"
|
||||
testId="drawer-source-auto"
|
||||
>
|
||||
<div className="source-radio__title">Auto-populated</div>
|
||||
<div className="source-radio__desc">
|
||||
Default pricing from SigNoz. Updated automatically.
|
||||
</div>
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
value="override"
|
||||
className="source-radio source-radio--override"
|
||||
testId="drawer-source-override"
|
||||
>
|
||||
<div className="source-radio__title">User override</div>
|
||||
<div className="source-radio__desc">
|
||||
Custom pricing. Takes precedence.
|
||||
</div>
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
{showResetConfirm && (
|
||||
<div
|
||||
className="reset-confirm"
|
||||
role="dialog"
|
||||
aria-label="Reset to default pricing"
|
||||
>
|
||||
<p>Reset to default pricing? Custom values will be discarded.</p>
|
||||
<div className="reset-confirm__actions">
|
||||
<Button
|
||||
onClick={(): void => setShowResetConfirm(false)}
|
||||
data-testid="drawer-reset-keep-btn"
|
||||
>
|
||||
Keep
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={confirmReset}
|
||||
data-testid="drawer-reset-confirm-btn"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="drawer-section drawer-surface">
|
||||
<div className="drawer-surface__head">
|
||||
<h4>Pricing (per 1M tokens, USD)</h4>
|
||||
{isReadOnly && (
|
||||
<span className="managed-label">
|
||||
<Lock size={12} />
|
||||
Read-only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pricing-grid">
|
||||
<div className="pricing-field">
|
||||
<label htmlFor="input-cost">
|
||||
Input cost <span className="required">*</span>
|
||||
</label>
|
||||
<InputNumber
|
||||
id="input-cost"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={draft.pricing.input}
|
||||
onChange={(v): void => updatePricing({ input: Number(v) || 0 })}
|
||||
disabled={isReadOnly}
|
||||
data-testid="drawer-input-cost"
|
||||
/>
|
||||
</div>
|
||||
<div className="pricing-field">
|
||||
<label htmlFor="output-cost">
|
||||
Output cost <span className="required">*</span>
|
||||
</label>
|
||||
<InputNumber
|
||||
id="output-cost"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={draft.pricing.output}
|
||||
onChange={(v): void => updatePricing({ output: Number(v) || 0 })}
|
||||
disabled={isReadOnly}
|
||||
data-testid="drawer-output-cost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="extras-divider">Extra pricing buckets</div>
|
||||
<div className="pricing-grid">
|
||||
<div className="pricing-field">
|
||||
<label htmlFor="cache-read">cache_read</label>
|
||||
<InputNumber
|
||||
id="cache-read"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={draft.pricing.cacheRead ?? undefined}
|
||||
placeholder="—"
|
||||
onChange={(v): void =>
|
||||
updatePricing({ cacheRead: v === null ? null : Number(v) })
|
||||
}
|
||||
disabled={isReadOnly}
|
||||
data-testid="drawer-cache-read-cost"
|
||||
/>
|
||||
</div>
|
||||
<div className="pricing-field">
|
||||
<label htmlFor="cache-write">cache_write</label>
|
||||
<InputNumber
|
||||
id="cache-write"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={draft.pricing.cacheWrite ?? undefined}
|
||||
placeholder="—"
|
||||
onChange={(v): void =>
|
||||
updatePricing({ cacheWrite: v === null ? null : Number(v) })
|
||||
}
|
||||
disabled={isReadOnly}
|
||||
data-testid="drawer-cache-write-cost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasCacheBucket && (
|
||||
<div className="pricing-field cache-mode-field">
|
||||
<label htmlFor="cache-mode">Cache mode</label>
|
||||
<Select
|
||||
id="cache-mode"
|
||||
value={draft.pricing.cacheMode}
|
||||
options={CACHE_MODE_OPTIONS}
|
||||
onChange={(v): void => updatePricing({ cacheMode: v as CacheModeDTO })}
|
||||
disabled={isReadOnly}
|
||||
className="full-width"
|
||||
data-testid="drawer-cache-mode"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="muted help">Image tokens may be priced differently (v2).</p>
|
||||
</div>
|
||||
|
||||
<div className="drawer-section drawer-surface cost-preview">
|
||||
<div className="drawer-surface__head">
|
||||
<h4>Cost preview</h4>
|
||||
</div>
|
||||
<div className="cost-preview__line">
|
||||
{preview.breakdown.map((part) => part.label).join(' + ')} ={' '}
|
||||
<strong>≈ ${preview.total.toFixed(4)}</strong>
|
||||
</div>
|
||||
<p className="muted help">
|
||||
Write-time attribution. Changes only affect new spans.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="drawer-error" role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCostDrawer;
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Button, Table, type TableColumnsType } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
formatPricePerMillion,
|
||||
getCanonicalId,
|
||||
getExtraBuckets,
|
||||
getRelativeLastSeen,
|
||||
getSourceLabel,
|
||||
type PricingRule,
|
||||
} from './utils';
|
||||
|
||||
interface ModelCostsTableProps {
|
||||
rules: PricingRule[];
|
||||
isLoading: boolean;
|
||||
selectedRuleId: string | null;
|
||||
onEdit: (rule: PricingRule) => void;
|
||||
}
|
||||
|
||||
function ModelCostsTable({
|
||||
rules,
|
||||
isLoading,
|
||||
selectedRuleId,
|
||||
onEdit,
|
||||
}: ModelCostsTableProps): JSX.Element {
|
||||
const columns: TableColumnsType<PricingRule> = [
|
||||
{
|
||||
title: 'Model',
|
||||
dataIndex: 'modelName',
|
||||
key: 'model',
|
||||
render: (_value, rule): JSX.Element => (
|
||||
<div className="model-cell">
|
||||
<div className="model-cell__name">{rule.modelName}</div>
|
||||
<div className="model-cell__canonical-id">{getCanonicalId(rule)}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Provider',
|
||||
dataIndex: 'provider',
|
||||
key: 'provider',
|
||||
},
|
||||
{
|
||||
title: 'Input / 1M',
|
||||
key: 'input',
|
||||
render: (_value, rule): JSX.Element => (
|
||||
<span className="price-cell">
|
||||
{formatPricePerMillion(rule.pricing?.input)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Output / 1M',
|
||||
key: 'output',
|
||||
render: (_value, rule): JSX.Element => (
|
||||
<span className="price-cell">
|
||||
{formatPricePerMillion(rule.pricing?.output)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Extra buckets',
|
||||
key: 'extra-buckets',
|
||||
render: (_value, rule): JSX.Element => {
|
||||
const buckets = getExtraBuckets(rule);
|
||||
if (buckets.length === 0) {
|
||||
return <span className="muted">—</span>;
|
||||
}
|
||||
return (
|
||||
<div className="extra-buckets">
|
||||
{buckets.map((bucket) => (
|
||||
<Badge
|
||||
key={bucket.key}
|
||||
color="vanilla"
|
||||
variant="outline"
|
||||
className="extra-buckets__chip"
|
||||
>
|
||||
<span className="extra-buckets__key">{bucket.key}</span>
|
||||
<span className="extra-buckets__price">
|
||||
{formatPricePerMillion(bucket.pricePerMillion)}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Source',
|
||||
dataIndex: 'isOverride',
|
||||
key: 'source',
|
||||
render: (_value, rule): JSX.Element => {
|
||||
const label = getSourceLabel(rule);
|
||||
return (
|
||||
<Badge
|
||||
color={rule.isOverride ? 'amber' : 'robin'}
|
||||
variant="outline"
|
||||
className="source-badge"
|
||||
data-testid={`source-badge-${rule.id}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Last seen',
|
||||
key: 'last-seen',
|
||||
render: (_value, rule): string => getRelativeLastSeen(rule),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 80,
|
||||
render: (_value, rule): JSX.Element => (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
data-testid={`edit-rule-${rule.id}`}
|
||||
onClick={(): void => onEdit(rule)}
|
||||
>
|
||||
Edit
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table<PricingRule>
|
||||
className="model-costs-table"
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={rules}
|
||||
loading={isLoading}
|
||||
pagination={false}
|
||||
rowClassName={(row): string =>
|
||||
row.id === selectedRuleId ? 'model-costs-table__row--selected' : ''
|
||||
}
|
||||
data-testid="model-costs-table"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCostsTable;
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
type LlmpricingruletypesLLMPricingRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import LLMObservabilityModelPricing from '../LLMObservabilityModelPricing';
|
||||
|
||||
const ENDPOINT = '*/api/v1/llm_pricing_rules';
|
||||
|
||||
const mockRules: LlmpricingruletypesLLMPricingRuleDTO[] = [
|
||||
{
|
||||
id: 'rule-gpt4o',
|
||||
orgId: 'org-1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
modelPattern: ['gpt-4o'],
|
||||
isOverride: false,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 15, output: 60 },
|
||||
},
|
||||
{
|
||||
id: 'rule-llama',
|
||||
orgId: 'org-1',
|
||||
modelName: 'llama-3.1-70b',
|
||||
provider: 'Self-hosted',
|
||||
modelPattern: ['llama-3.1'],
|
||||
isOverride: true,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 0, output: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
describe('LLMObservabilityModelPricing', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
items: mockRules,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
total: mockRules.length,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the page header and both rules', async () => {
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
await screen.findByText('gpt-4o');
|
||||
expect(screen.getByText('Configuration')).toBeInTheDocument();
|
||||
expect(screen.getByText('llama-3.1-70b')).toBeInTheDocument();
|
||||
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters rules by the search input', async () => {
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
await screen.findByText('gpt-4o');
|
||||
|
||||
fireEvent.change(screen.getByTestId('search-input'), {
|
||||
target: { value: 'llama' },
|
||||
});
|
||||
|
||||
expect(screen.queryByText('gpt-4o')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('llama-3.1-70b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the drawer in Add mode when the Add button is clicked', async () => {
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
await screen.findByText('gpt-4o');
|
||||
fireEvent.click(screen.getByTestId('add-model-cost-btn'));
|
||||
|
||||
const input = (await screen.findByTestId(
|
||||
'drawer-model-id-input',
|
||||
)) as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('opens the drawer in Edit mode with prefilled values when a row Edit is clicked', async () => {
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
await screen.findByText('gpt-4o');
|
||||
fireEvent.click(screen.getByTestId('edit-rule-rule-gpt4o'));
|
||||
|
||||
const input = (await screen.findByTestId(
|
||||
'drawer-model-id-input',
|
||||
)) as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.value).toBe('gpt-4o');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import { EMPTY_DRAFT, type DrawerDraft } from '../drawerUtils';
|
||||
import ModelCostDrawer from '../ModelCostDrawer';
|
||||
|
||||
interface HarnessProps {
|
||||
initialDraft?: DrawerDraft;
|
||||
mode?: 'add' | 'edit';
|
||||
onSave?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function Harness({
|
||||
initialDraft = { ...EMPTY_DRAFT, modelName: 'gpt-4o' },
|
||||
mode = 'add',
|
||||
onSave = jest.fn(),
|
||||
onDelete = jest.fn(),
|
||||
}: HarnessProps): JSX.Element {
|
||||
const [draft, setDraft] = useState<DrawerDraft>(initialDraft);
|
||||
return (
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode={mode}
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
isSaving={false}
|
||||
isDeleting={false}
|
||||
saveError={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ModelCostDrawer', () => {
|
||||
it('adds a pattern chip when the user types and presses Enter', () => {
|
||||
render(<Harness />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('drawer-pattern-input'), {
|
||||
target: { value: 'gpt-4o-mini' },
|
||||
});
|
||||
fireEvent.keyDown(screen.getByTestId('drawer-pattern-input'), {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
});
|
||||
|
||||
expect(screen.getByText('gpt-4o-mini*')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a match result when the test input matches an existing pattern', () => {
|
||||
render(
|
||||
<Harness
|
||||
initialDraft={{
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
patterns: ['gpt-4o'],
|
||||
isOverride: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('drawer-pattern-test-input'), {
|
||||
target: { value: 'gpt-4o-2024-08-06' },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('drawer-pattern-test-result')).toHaveTextContent(
|
||||
/matched: gpt-4o\*/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a no-match result when nothing matches', () => {
|
||||
render(
|
||||
<Harness
|
||||
initialDraft={{
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
patterns: ['gpt-4o'],
|
||||
isOverride: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('drawer-pattern-test-input'), {
|
||||
target: { value: 'claude' },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('drawer-pattern-test-result')).toHaveTextContent(
|
||||
/no matching pattern/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a reset confirmation when switching from Override to Auto', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<Harness
|
||||
initialDraft={{
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
isOverride: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('drawer-source-auto'));
|
||||
|
||||
expect(screen.getByTestId('drawer-reset-confirm-btn')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-reset-keep-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the Delete action in Add mode', () => {
|
||||
render(<Harness mode="add" />);
|
||||
expect(screen.queryByTestId('drawer-delete-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the Delete action in Edit mode', () => {
|
||||
render(
|
||||
<Harness
|
||||
mode="edit"
|
||||
initialDraft={{
|
||||
...EMPTY_DRAFT,
|
||||
id: 'rule-1',
|
||||
modelName: 'gpt-4o',
|
||||
isOverride: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('drawer-delete-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSave when the Save button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(<Harness onSave={onSave} />);
|
||||
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
buildPricingPayload,
|
||||
buildRulePayload,
|
||||
computeCostPreview,
|
||||
draftFromRule,
|
||||
EMPTY_DRAFT,
|
||||
matchesAnyPattern,
|
||||
validateDraft,
|
||||
type DrawerDraft,
|
||||
} from '../drawerUtils';
|
||||
import type { PricingRule } from '../utils';
|
||||
|
||||
const makeRule = (overrides: Partial<PricingRule> = {}): PricingRule => ({
|
||||
id: 'rule-1',
|
||||
orgId: 'org-1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
modelPattern: ['gpt-4o'],
|
||||
isOverride: false,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 15, output: 60 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('drawerUtils', () => {
|
||||
describe('draftFromRule', () => {
|
||||
it('maps a rule to a draft with cache values when present', () => {
|
||||
const rule = makeRule({
|
||||
pricing: {
|
||||
input: 3,
|
||||
output: 15,
|
||||
cache: {
|
||||
mode: CacheModeDTO.additive,
|
||||
read: 0.3,
|
||||
write: 3.75,
|
||||
},
|
||||
},
|
||||
});
|
||||
const draft = draftFromRule(rule);
|
||||
expect(draft.modelName).toBe('gpt-4o');
|
||||
expect(draft.pricing.input).toBe(3);
|
||||
expect(draft.pricing.cacheRead).toBe(0.3);
|
||||
expect(draft.pricing.cacheWrite).toBe(3.75);
|
||||
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.additive);
|
||||
});
|
||||
|
||||
it('falls back to defaults when cache is missing', () => {
|
||||
const draft = draftFromRule(makeRule());
|
||||
expect(draft.pricing.cacheRead).toBeNull();
|
||||
expect(draft.pricing.cacheWrite).toBeNull();
|
||||
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.unknown);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPricingPayload', () => {
|
||||
it('omits the cache block when no cache values are set', () => {
|
||||
const payload = buildPricingPayload(EMPTY_DRAFT);
|
||||
expect(payload.cache).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes only the cache values that are > 0', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
pricing: {
|
||||
...EMPTY_DRAFT.pricing,
|
||||
cacheRead: 1.5,
|
||||
cacheWrite: 0,
|
||||
cacheMode: CacheModeDTO.subtract,
|
||||
},
|
||||
};
|
||||
const payload = buildPricingPayload(draft);
|
||||
expect(payload.cache?.read).toBe(1.5);
|
||||
expect(payload.cache?.write).toBeUndefined();
|
||||
expect(payload.cache?.mode).toBe(CacheModeDTO.subtract);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRulePayload', () => {
|
||||
it('uses the modelName as a default pattern when no patterns are supplied', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
patterns: [],
|
||||
provider: 'OpenAI',
|
||||
};
|
||||
const payload = buildRulePayload(draft);
|
||||
expect(payload.modelPattern).toStrictEqual(['gpt-4o']);
|
||||
expect(payload.unit).toBe(UnitDTO.per_million_tokens);
|
||||
expect(payload.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('omits id and sourceId for an Add draft', () => {
|
||||
const payload = buildRulePayload(EMPTY_DRAFT);
|
||||
expect(payload.id).toBeUndefined();
|
||||
expect(payload.sourceId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateDraft', () => {
|
||||
it('requires a model name in Add mode', () => {
|
||||
const result = validateDraft(EMPTY_DRAFT, 'add');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toMatch(/billing model id/i);
|
||||
});
|
||||
|
||||
it('rejects negative pricing', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
pricing: { ...EMPTY_DRAFT.pricing, input: -1 },
|
||||
};
|
||||
expect(validateDraft(draft, 'add').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a valid Add draft', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 2 },
|
||||
};
|
||||
expect(validateDraft(draft, 'add').ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesAnyPattern', () => {
|
||||
it('returns the matching prefix pattern, case-insensitive', () => {
|
||||
expect(matchesAnyPattern('GPT-4o-2024', ['gpt-4o'])).toBe('gpt-4o');
|
||||
});
|
||||
|
||||
it('returns null when nothing matches', () => {
|
||||
expect(matchesAnyPattern('claude', ['gpt-4o'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeCostPreview', () => {
|
||||
it('adds cache buckets when they are set', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
pricing: {
|
||||
...EMPTY_DRAFT.pricing,
|
||||
input: 10,
|
||||
output: 30,
|
||||
cacheRead: 5,
|
||||
},
|
||||
};
|
||||
const preview = computeCostPreview(draft);
|
||||
const labels = preview.breakdown.map((part) => part.label);
|
||||
expect(labels).toContain('2000 input');
|
||||
expect(labels).toContain('500 output');
|
||||
expect(labels).toContain('1000 cache_read');
|
||||
expect(preview.total).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
filterRules,
|
||||
formatPricePerMillion,
|
||||
getCanonicalId,
|
||||
getExtraBuckets,
|
||||
getRelativeLastSeen,
|
||||
getSourceLabel,
|
||||
type PricingRule,
|
||||
} from '../utils';
|
||||
|
||||
const makeRule = (overrides: Partial<PricingRule> = {}): PricingRule => ({
|
||||
id: 'rule-1',
|
||||
orgId: 'org-1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
modelPattern: ['gpt-4o'],
|
||||
isOverride: false,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 15, output: 60 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('utils', () => {
|
||||
describe('formatPricePerMillion', () => {
|
||||
it('formats numbers with 2 decimals and dollar prefix', () => {
|
||||
expect(formatPricePerMillion(15)).toBe('$15.00');
|
||||
expect(formatPricePerMillion(0.15)).toBe('$0.15');
|
||||
});
|
||||
|
||||
it('returns em-dash for nullish or NaN', () => {
|
||||
expect(formatPricePerMillion(undefined)).toBe('—');
|
||||
expect(formatPricePerMillion(Number.NaN)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExtraBuckets', () => {
|
||||
it('returns an empty array when there is no cache pricing', () => {
|
||||
expect(getExtraBuckets(makeRule())).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('returns only buckets with values > 0', () => {
|
||||
const rule = makeRule({
|
||||
pricing: {
|
||||
input: 3,
|
||||
output: 15,
|
||||
cache: {
|
||||
mode: CacheModeDTO.additive,
|
||||
read: 0.3,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
const buckets = getExtraBuckets(rule);
|
||||
expect(buckets).toStrictEqual([{ key: 'cache_read', pricePerMillion: 0.3 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSourceLabel', () => {
|
||||
it('returns "Auto" for non-override and "User override" otherwise', () => {
|
||||
expect(getSourceLabel(makeRule({ isOverride: false }))).toBe('Auto');
|
||||
expect(getSourceLabel(makeRule({ isOverride: true }))).toBe('User override');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCanonicalId', () => {
|
||||
it('lowercases the provider and joins with the model name', () => {
|
||||
expect(getCanonicalId(makeRule({ provider: 'OpenAI' }))).toBe(
|
||||
'openai:gpt-4o',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRelativeLastSeen', () => {
|
||||
it('returns em-dash when no timestamp is present', () => {
|
||||
expect(getRelativeLastSeen(makeRule())).toBe('—');
|
||||
});
|
||||
|
||||
it('formats minutes-old timestamps', () => {
|
||||
const recent = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||
expect(getRelativeLastSeen(makeRule({ updatedAt: recent }))).toMatch(
|
||||
/min ago/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterRules', () => {
|
||||
const auto = makeRule({ id: 'r1', modelName: 'gpt-4o', isOverride: false });
|
||||
const override = makeRule({
|
||||
id: 'r2',
|
||||
modelName: 'llama-3',
|
||||
provider: 'Self-hosted',
|
||||
modelPattern: ['llama-3'],
|
||||
isOverride: true,
|
||||
});
|
||||
|
||||
it('returns everything when no filters are applied', () => {
|
||||
expect(filterRules([auto, override], '', 'all')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('narrows by source = override', () => {
|
||||
expect(filterRules([auto, override], '', 'override')).toStrictEqual([
|
||||
override,
|
||||
]);
|
||||
});
|
||||
|
||||
it('narrows by free-text search across model and provider', () => {
|
||||
expect(filterRules([auto, override], 'self', 'all')).toStrictEqual([
|
||||
override,
|
||||
]);
|
||||
expect(filterRules([auto, override], 'gpt-4', 'all')).toStrictEqual([auto]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
type LlmpricingruletypesLLMPricingCacheCostsDTO,
|
||||
type LlmpricingruletypesLLMRulePricingDTO,
|
||||
type LlmpricingruletypesUpdatableLLMPricingRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PricingRule } from './utils';
|
||||
|
||||
export const PROVIDER_OPTIONS = [
|
||||
{ value: 'OpenAI', label: 'OpenAI' },
|
||||
{ value: 'Anthropic', label: 'Anthropic' },
|
||||
{ value: 'Azure OpenAI', label: 'Azure OpenAI' },
|
||||
{ value: 'Google', label: 'Google' },
|
||||
{ value: 'Self-hosted', label: 'Self-hosted' },
|
||||
{ value: 'Other', label: 'Other' },
|
||||
];
|
||||
|
||||
export const CACHE_MODE_OPTIONS = [
|
||||
{
|
||||
value: CacheModeDTO.subtract,
|
||||
label: 'Subtract (OpenAI style)',
|
||||
},
|
||||
{
|
||||
value: CacheModeDTO.additive,
|
||||
label: 'Additive (Anthropic style)',
|
||||
},
|
||||
{
|
||||
value: CacheModeDTO.unknown,
|
||||
label: 'Unknown',
|
||||
},
|
||||
];
|
||||
|
||||
export type DrawerMode = 'add' | 'edit';
|
||||
|
||||
export interface DrawerDraft {
|
||||
id: string | null;
|
||||
sourceId: string | null;
|
||||
modelName: string;
|
||||
provider: string;
|
||||
patterns: string[];
|
||||
isOverride: boolean;
|
||||
pricing: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheMode: CacheModeDTO;
|
||||
cacheRead: number | null;
|
||||
cacheWrite: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const EMPTY_DRAFT: DrawerDraft = {
|
||||
id: null,
|
||||
sourceId: null,
|
||||
modelName: '',
|
||||
provider: 'OpenAI',
|
||||
patterns: [],
|
||||
isOverride: true,
|
||||
pricing: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheMode: CacheModeDTO.unknown,
|
||||
cacheRead: null,
|
||||
cacheWrite: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
|
||||
id: rule.id,
|
||||
sourceId: rule.sourceId ?? null,
|
||||
modelName: rule.modelName,
|
||||
provider: rule.provider || 'OpenAI',
|
||||
patterns: rule.modelPattern || [],
|
||||
isOverride: !!rule.isOverride,
|
||||
pricing: {
|
||||
input: rule.pricing?.input ?? 0,
|
||||
output: rule.pricing?.output ?? 0,
|
||||
cacheMode: rule.pricing?.cache?.mode ?? CacheModeDTO.unknown,
|
||||
cacheRead: rule.pricing?.cache?.read ?? null,
|
||||
cacheWrite: rule.pricing?.cache?.write ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const hasCacheValue = (value: number | null): boolean =>
|
||||
typeof value === 'number' && value > 0;
|
||||
|
||||
export const buildPricingPayload = (
|
||||
draft: DrawerDraft,
|
||||
): LlmpricingruletypesLLMRulePricingDTO => {
|
||||
const pricing: LlmpricingruletypesLLMRulePricingDTO = {
|
||||
input: draft.pricing.input,
|
||||
output: draft.pricing.output,
|
||||
};
|
||||
if (
|
||||
hasCacheValue(draft.pricing.cacheRead) ||
|
||||
hasCacheValue(draft.pricing.cacheWrite)
|
||||
) {
|
||||
const cache: LlmpricingruletypesLLMPricingCacheCostsDTO = {
|
||||
mode: draft.pricing.cacheMode,
|
||||
};
|
||||
if (hasCacheValue(draft.pricing.cacheRead)) {
|
||||
cache.read = draft.pricing.cacheRead as number;
|
||||
}
|
||||
if (hasCacheValue(draft.pricing.cacheWrite)) {
|
||||
cache.write = draft.pricing.cacheWrite as number;
|
||||
}
|
||||
pricing.cache = cache;
|
||||
}
|
||||
return pricing;
|
||||
};
|
||||
|
||||
export const buildRulePayload = (
|
||||
draft: DrawerDraft,
|
||||
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => ({
|
||||
id: draft.id || undefined,
|
||||
sourceId: draft.sourceId || undefined,
|
||||
modelName: draft.modelName.trim(),
|
||||
provider: draft.provider.trim(),
|
||||
modelPattern:
|
||||
draft.patterns.length > 0 ? draft.patterns : [draft.modelName.trim()],
|
||||
isOverride: draft.isOverride,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: buildPricingPayload(draft),
|
||||
});
|
||||
|
||||
export interface ValidationResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const validateDraft = (
|
||||
draft: DrawerDraft,
|
||||
mode: DrawerMode,
|
||||
): ValidationResult => {
|
||||
if (mode === 'add' && !draft.modelName.trim()) {
|
||||
return { ok: false, message: 'Billing model ID is required.' };
|
||||
}
|
||||
if (!draft.provider.trim()) {
|
||||
return { ok: false, message: 'Provider is required.' };
|
||||
}
|
||||
if (draft.pricing.input < 0 || draft.pricing.output < 0) {
|
||||
return { ok: false, message: 'Pricing values must be non-negative.' };
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
export const matchesAnyPattern = (
|
||||
candidate: string,
|
||||
patterns: string[],
|
||||
): string | null => {
|
||||
const lowered = candidate.toLowerCase();
|
||||
const match = patterns.find((pattern) =>
|
||||
lowered.startsWith(pattern.toLowerCase()),
|
||||
);
|
||||
return match || null;
|
||||
};
|
||||
|
||||
const EXAMPLE_INPUT_TOKENS = 2000;
|
||||
const EXAMPLE_OUTPUT_TOKENS = 500;
|
||||
const EXAMPLE_CACHE_TOKENS = 1000;
|
||||
const PER_MILLION = 1_000_000;
|
||||
|
||||
export interface CostPreviewParts {
|
||||
total: number;
|
||||
breakdown: { label: string; cost: number }[];
|
||||
}
|
||||
|
||||
export const computeCostPreview = (draft: DrawerDraft): CostPreviewParts => {
|
||||
const breakdown: { label: string; cost: number }[] = [];
|
||||
const inputCost = (EXAMPLE_INPUT_TOKENS / PER_MILLION) * draft.pricing.input;
|
||||
const outputCost =
|
||||
(EXAMPLE_OUTPUT_TOKENS / PER_MILLION) * draft.pricing.output;
|
||||
breakdown.push({ label: `${EXAMPLE_INPUT_TOKENS} input`, cost: inputCost });
|
||||
breakdown.push({ label: `${EXAMPLE_OUTPUT_TOKENS} output`, cost: outputCost });
|
||||
let total = inputCost + outputCost;
|
||||
if (hasCacheValue(draft.pricing.cacheRead)) {
|
||||
const cost =
|
||||
(EXAMPLE_CACHE_TOKENS / PER_MILLION) * (draft.pricing.cacheRead as number);
|
||||
breakdown.push({ label: `${EXAMPLE_CACHE_TOKENS} cache_read`, cost });
|
||||
total += cost;
|
||||
}
|
||||
if (hasCacheValue(draft.pricing.cacheWrite)) {
|
||||
const cost =
|
||||
(EXAMPLE_CACHE_TOKENS / PER_MILLION) * (draft.pricing.cacheWrite as number);
|
||||
breakdown.push({ label: `${EXAMPLE_CACHE_TOKENS} cache_write`, cost });
|
||||
total += cost;
|
||||
}
|
||||
return { total, breakdown };
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getListLLMPricingRulesQueryKey,
|
||||
useCreateOrUpdateLLMPricingRules,
|
||||
useDeleteLLMPricingRule,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import {
|
||||
buildRulePayload,
|
||||
draftFromRule,
|
||||
EMPTY_DRAFT,
|
||||
type DrawerDraft,
|
||||
type DrawerMode,
|
||||
} from './drawerUtils';
|
||||
import type { PricingRule } from './utils';
|
||||
|
||||
interface UseModelCostDrawerResult {
|
||||
isOpen: boolean;
|
||||
mode: DrawerMode;
|
||||
draft: DrawerDraft;
|
||||
setDraft: (next: DrawerDraft) => void;
|
||||
openForAdd: (prefillModelName?: string) => void;
|
||||
openForEdit: (rule: PricingRule) => void;
|
||||
close: () => void;
|
||||
save: () => Promise<void>;
|
||||
deleteRule: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
saveError: string | null;
|
||||
selectedRuleId: string | null;
|
||||
}
|
||||
|
||||
export function useModelCostDrawer(): UseModelCostDrawerResult {
|
||||
const queryClient = useQueryClient();
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [mode, setMode] = useState<DrawerMode>('add');
|
||||
const [draft, setDraft] = useState<DrawerDraft>(EMPTY_DRAFT);
|
||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
|
||||
useCreateOrUpdateLLMPricingRules();
|
||||
const { mutateAsync: deleteRuleApi, isLoading: isDeleting } =
|
||||
useDeleteLLMPricingRule();
|
||||
|
||||
const invalidateList = useCallback(async (): Promise<void> => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getListLLMPricingRulesQueryKey(),
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
const openForAdd = useCallback((prefillModelName?: string): void => {
|
||||
setMode('add');
|
||||
setDraft({
|
||||
...EMPTY_DRAFT,
|
||||
modelName: prefillModelName || '',
|
||||
patterns: prefillModelName ? [prefillModelName] : [],
|
||||
});
|
||||
setSelectedRuleId(null);
|
||||
setSaveError(null);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const openForEdit = useCallback((rule: PricingRule): void => {
|
||||
setMode('edit');
|
||||
setDraft(draftFromRule(rule));
|
||||
setSelectedRuleId(rule.id);
|
||||
setSaveError(null);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback((): void => {
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
setSaveError(null);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async (): Promise<void> => {
|
||||
setSaveError(null);
|
||||
try {
|
||||
await createOrUpdate({
|
||||
data: { rules: [buildRulePayload(draft)] },
|
||||
});
|
||||
await invalidateList();
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Save failed';
|
||||
setSaveError(message);
|
||||
}
|
||||
}, [createOrUpdate, draft, invalidateList]);
|
||||
|
||||
const deleteRule = useCallback(async (): Promise<void> => {
|
||||
if (!draft.id) {
|
||||
return;
|
||||
}
|
||||
setSaveError(null);
|
||||
try {
|
||||
await deleteRuleApi({ pathParams: { id: draft.id } });
|
||||
await invalidateList();
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Delete failed';
|
||||
setSaveError(message);
|
||||
}
|
||||
}, [deleteRuleApi, draft.id, invalidateList]);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
mode,
|
||||
draft,
|
||||
setDraft,
|
||||
openForAdd,
|
||||
openForEdit,
|
||||
close,
|
||||
save,
|
||||
deleteRule,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
saveError,
|
||||
selectedRuleId,
|
||||
};
|
||||
}
|
||||
101
frontend/src/container/LLMObservabilityModelPricing/utils.ts
Normal file
101
frontend/src/container/LLMObservabilityModelPricing/utils.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
|
||||
|
||||
export type SourceFilter = 'all' | 'auto' | 'override';
|
||||
|
||||
export interface ExtraBucket {
|
||||
key: string;
|
||||
pricePerMillion: number;
|
||||
}
|
||||
|
||||
export const formatPricePerMillion = (value: number | undefined): string => {
|
||||
if (value === undefined || value === null || Number.isNaN(value)) {
|
||||
return '—';
|
||||
}
|
||||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
export const getExtraBuckets = (rule: PricingRule): ExtraBucket[] => {
|
||||
const cache = rule.pricing?.cache;
|
||||
if (!cache) {
|
||||
return [];
|
||||
}
|
||||
const buckets: ExtraBucket[] = [];
|
||||
if (typeof cache.read === 'number' && cache.read > 0) {
|
||||
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
|
||||
}
|
||||
if (typeof cache.write === 'number' && cache.write > 0) {
|
||||
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
|
||||
}
|
||||
return buckets;
|
||||
};
|
||||
|
||||
export const getSourceLabel = (rule: PricingRule): 'Auto' | 'User override' =>
|
||||
rule.isOverride ? 'User override' : 'Auto';
|
||||
|
||||
const MINUTE = 60;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const MONTH = 30 * DAY;
|
||||
const YEAR = 365 * DAY;
|
||||
|
||||
export const getRelativeLastSeen = (rule: PricingRule): string => {
|
||||
const ts = rule.updatedAt || rule.syncedAt || rule.createdAt;
|
||||
if (!ts) {
|
||||
return '—';
|
||||
}
|
||||
const now = Date.now();
|
||||
const target = new Date(ts).getTime();
|
||||
if (Number.isNaN(target)) {
|
||||
return '—';
|
||||
}
|
||||
const seconds = Math.max(0, Math.round((now - target) / 1000));
|
||||
if (seconds < MINUTE) {
|
||||
return 'just now';
|
||||
}
|
||||
if (seconds < HOUR) {
|
||||
return `${Math.floor(seconds / MINUTE)} min ago`;
|
||||
}
|
||||
if (seconds < DAY) {
|
||||
return `${Math.floor(seconds / HOUR)} hr ago`;
|
||||
}
|
||||
if (seconds < MONTH) {
|
||||
return `${Math.floor(seconds / DAY)} days ago`;
|
||||
}
|
||||
if (seconds < YEAR) {
|
||||
return `${Math.floor(seconds / MONTH)} mo ago`;
|
||||
}
|
||||
return `${Math.floor(seconds / YEAR)} yr ago`;
|
||||
};
|
||||
|
||||
const lc = (value: string): string => value.toLowerCase();
|
||||
|
||||
export const filterRules = (
|
||||
rules: PricingRule[],
|
||||
search: string,
|
||||
source: SourceFilter,
|
||||
): PricingRule[] => {
|
||||
const normalized = lc(search.trim());
|
||||
return rules.filter((rule) => {
|
||||
if (source === 'auto' && rule.isOverride) {
|
||||
return false;
|
||||
}
|
||||
if (source === 'override' && !rule.isOverride) {
|
||||
return false;
|
||||
}
|
||||
if (!normalized) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
lc(rule.modelName).includes(normalized) ||
|
||||
lc(rule.provider).includes(normalized) ||
|
||||
(rule.modelPattern || []).some((pattern) => lc(pattern).includes(normalized))
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const getCanonicalId = (rule: PricingRule): string => {
|
||||
const provider = rule.provider?.trim() || 'unknown';
|
||||
return `${lc(provider)}:${rule.modelName}`;
|
||||
};
|
||||
@@ -142,15 +142,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.reset-password-back-action {
|
||||
margin-top: var(--spacing-12);
|
||||
width: 100%;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { ArrowLeft, CircleAlert } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { CircleAlert } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import './ResetPassword.styles.scss';
|
||||
@@ -62,16 +59,6 @@ function TokenError({ error }: TokenErrorProps): JSX.Element {
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{error && <AuthError error={error} />}
|
||||
<div className="reset-password-back-action">
|
||||
<Button
|
||||
variant="solid"
|
||||
data-testid="back-to-login"
|
||||
prefix={<ArrowLeft size={12} />}
|
||||
onClick={(): void => history.push(ROUTES.LOGIN)}
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AuthPageContainer>
|
||||
);
|
||||
|
||||
@@ -119,10 +119,6 @@
|
||||
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
background: var(--l3-background);
|
||||
|
||||
&.version-container-standalone {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.version {
|
||||
|
||||
@@ -1010,7 +1010,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
<img src={signozBrandLogoUrl} alt="SigNoz" />
|
||||
</div>
|
||||
|
||||
{(licenseTag || currentVersion) && (
|
||||
{licenseTag && (
|
||||
<div
|
||||
className={cx(
|
||||
'brand-title-section',
|
||||
@@ -1021,7 +1021,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
'version-update-notification',
|
||||
)}
|
||||
>
|
||||
{licenseTag && <span className="license-type"> {licenseTag} </span>}
|
||||
<span className="license-type"> {licenseTag} </span>
|
||||
|
||||
{currentVersion && (
|
||||
<Tooltip
|
||||
@@ -1043,12 +1043,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
'version-container',
|
||||
!licenseTag && 'version-container-standalone',
|
||||
)}
|
||||
>
|
||||
<div className="version-container">
|
||||
<span
|
||||
className={cx('version', changelog && 'version-clickable')}
|
||||
onClick={onClickVersionHandler}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Building2,
|
||||
ChartArea,
|
||||
Cloudy,
|
||||
Coins,
|
||||
DraftingCompass,
|
||||
FileKey2,
|
||||
Github,
|
||||
@@ -365,6 +366,13 @@ export const settingsNavSections: SettingsNavSection[] = [
|
||||
isEnabled: false,
|
||||
itemKey: 'mcp-server',
|
||||
},
|
||||
{
|
||||
key: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
label: 'Model Pricing',
|
||||
icon: <Coins size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'llm-observability-model-pricing',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import NotFound from 'components/NotFound';
|
||||
import Spinner from 'components/Spinner';
|
||||
import DashboardContainer from 'container/DashboardContainer';
|
||||
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { ErrorType } from 'types/common';
|
||||
|
||||
function DashboardPage(): JSX.Element {
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
const [onModal, Content] = Modal.useModal();
|
||||
|
||||
const { isLoading, isError, isFetching, error } = useDashboardBootstrap(
|
||||
dashboardId,
|
||||
{ confirm: onModal.confirm },
|
||||
);
|
||||
|
||||
const dashboardTitle = useDashboardStore((s) => s.dashboardData?.data.title);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = dashboardTitle || document.title;
|
||||
}, [dashboardTitle]);
|
||||
|
||||
const errorMessage = isError
|
||||
? (error as AxiosError<{ errorType: string }>)?.response?.data?.errorType
|
||||
: 'Something went wrong';
|
||||
|
||||
if (isError && !isFetching && errorMessage === ErrorType.NotFound) {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
if (isError && errorMessage) {
|
||||
return <Typography>{errorMessage}</Typography>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading.." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Content}
|
||||
<DashboardContainer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -1,15 +1,53 @@
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import DashboardPageV2 from 'pages/DashboardPageV2';
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import NotFound from 'components/NotFound';
|
||||
import Spinner from 'components/Spinner';
|
||||
import DashboardContainer from 'container/DashboardContainer';
|
||||
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { ErrorType } from 'types/common';
|
||||
|
||||
import DashboardPage from './DashboardPage';
|
||||
function DashboardPage(): JSX.Element {
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
// Serves the V2 dashboard detail page when the `use_dashboard_v2` flag is active;
|
||||
// otherwise the existing V1 page. Lets V2 dark-ship behind the flag without
|
||||
// changing route definitions.
|
||||
function DashboardPageEntry(): JSX.Element {
|
||||
const isDashboardV2 = useIsDashboardV2();
|
||||
const [onModal, Content] = Modal.useModal();
|
||||
|
||||
return isDashboardV2 ? <DashboardPageV2 /> : <DashboardPage />;
|
||||
const { isLoading, isError, isFetching, error } = useDashboardBootstrap(
|
||||
dashboardId,
|
||||
{ confirm: onModal.confirm },
|
||||
);
|
||||
|
||||
const dashboardTitle = useDashboardStore((s) => s.dashboardData?.data.title);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = dashboardTitle || document.title;
|
||||
}, [dashboardTitle]);
|
||||
|
||||
const errorMessage = isError
|
||||
? (error as AxiosError<{ errorType: string }>)?.response?.data?.errorType
|
||||
: 'Something went wrong';
|
||||
|
||||
if (isError && !isFetching && errorMessage === ErrorType.NotFound) {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
if (isError && errorMessage) {
|
||||
return <Typography>{errorMessage}</Typography>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading.." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Content}
|
||||
<DashboardContainer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPageEntry;
|
||||
export default DashboardPage;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
DashboardtypesLayoutDTO,
|
||||
DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesJSONPatchOperationDTOOp } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { GridItem } from './utils';
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { GridItem } from './utils';
|
||||
* patches in DashboardSettings/General and DashboardDescription).
|
||||
*/
|
||||
|
||||
const { add, replace, remove } = DashboardtypesPatchOpDTO;
|
||||
const { add, replace, remove } = DashboardtypesJSONPatchOperationDTOOp;
|
||||
|
||||
const PANEL_REF_PREFIX = '#/spec/panels/';
|
||||
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import DashboardsListPageV2 from 'pages/DashboardsListPageV2';
|
||||
|
||||
import DashboardsListPage from './DashboardsListPage';
|
||||
|
||||
// Serves the V2 dashboards list when the `use_dashboard_v2` flag is active;
|
||||
// otherwise the existing V1 list. Lets V2 dark-ship behind the flag without
|
||||
// changing route definitions.
|
||||
function DashboardsListPageEntry(): JSX.Element {
|
||||
const isDashboardV2 = useIsDashboardV2();
|
||||
|
||||
return isDashboardV2 ? <DashboardsListPageV2 /> : <DashboardsListPage />;
|
||||
}
|
||||
|
||||
export default DashboardsListPageEntry;
|
||||
export default DashboardsListPage;
|
||||
|
||||
@@ -8,10 +8,6 @@ import {
|
||||
createDashboardV2,
|
||||
useListDashboardsV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { RequestDashboardBtn } from 'container/ListOfDashboard/RequestDashboardBtn';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
@@ -28,6 +24,8 @@ import {
|
||||
useSearch,
|
||||
useSortColumn,
|
||||
useSortOrder,
|
||||
type SortColumn,
|
||||
type SortOrder,
|
||||
} from '../../hooks/useDashboardsListQueryParams';
|
||||
import type { DashboardListItem } from '../../utils';
|
||||
import ConfigureMetadataModal from '../ConfigureMetadataModal/ConfigureMetadataModal';
|
||||
@@ -152,7 +150,7 @@ function DashboardsList(): JSX.Element {
|
||||
}, []);
|
||||
|
||||
const onSortChange = useCallback(
|
||||
(column: DashboardtypesListSortDTO): void => {
|
||||
(column: SortColumn): void => {
|
||||
void setSortColumn(column);
|
||||
void setPage(1);
|
||||
},
|
||||
@@ -160,7 +158,7 @@ function DashboardsList(): JSX.Element {
|
||||
);
|
||||
|
||||
const onOrderChange = useCallback(
|
||||
(order: DashboardtypesListOrderDTO): void => {
|
||||
(order: SortOrder): void => {
|
||||
void setSortOrder(order);
|
||||
void setPage(1);
|
||||
},
|
||||
|
||||
@@ -7,18 +7,18 @@ import {
|
||||
HdmiPort,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SortColumn,
|
||||
SortOrder,
|
||||
} from '../../hooks/useDashboardsListQueryParams';
|
||||
|
||||
import styles from './ListHeader.module.scss';
|
||||
|
||||
interface Props {
|
||||
sortColumn: DashboardtypesListSortDTO;
|
||||
onSortChange: (column: DashboardtypesListSortDTO) => void;
|
||||
sortOrder: DashboardtypesListOrderDTO;
|
||||
onOrderChange: (order: DashboardtypesListOrderDTO) => void;
|
||||
sortColumn: SortColumn;
|
||||
onSortChange: (column: SortColumn) => void;
|
||||
sortOrder: SortOrder;
|
||||
onOrderChange: (order: SortOrder) => void;
|
||||
onConfigureMetadata: () => void;
|
||||
}
|
||||
|
||||
@@ -44,57 +44,49 @@ function ListHeader({
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onSortChange(DashboardtypesListSortDTO.name)}
|
||||
onClick={(): void => onSortChange('name')}
|
||||
data-testid="sort-by-name"
|
||||
>
|
||||
Name
|
||||
{sortColumn === DashboardtypesListSortDTO.name && <Check size={14} />}
|
||||
{sortColumn === 'name' && <Check size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void =>
|
||||
onSortChange(DashboardtypesListSortDTO.created_at)
|
||||
}
|
||||
onClick={(): void => onSortChange('created_at')}
|
||||
data-testid="sort-by-last-created"
|
||||
>
|
||||
Last created
|
||||
{sortColumn === DashboardtypesListSortDTO.created_at && (
|
||||
<Check size={14} />
|
||||
)}
|
||||
{sortColumn === 'created_at' && <Check size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void =>
|
||||
onSortChange(DashboardtypesListSortDTO.updated_at)
|
||||
}
|
||||
onClick={(): void => onSortChange('updated_at')}
|
||||
data-testid="sort-by-last-updated"
|
||||
>
|
||||
Last updated
|
||||
{sortColumn === DashboardtypesListSortDTO.updated_at && (
|
||||
<Check size={14} />
|
||||
)}
|
||||
{sortColumn === 'updated_at' && <Check size={14} />}
|
||||
</Button>
|
||||
<div className={styles.sortDivider} />
|
||||
<Typography.Text className={styles.sortHeading}>Order</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.asc)}
|
||||
onClick={(): void => onOrderChange('asc')}
|
||||
data-testid="sort-order-asc"
|
||||
>
|
||||
Ascending
|
||||
{sortOrder === DashboardtypesListOrderDTO.asc && <Check size={14} />}
|
||||
{sortOrder === 'asc' && <Check size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.desc)}
|
||||
onClick={(): void => onOrderChange('desc')}
|
||||
data-testid="sort-order-desc"
|
||||
>
|
||||
Descending
|
||||
{sortOrder === DashboardtypesListOrderDTO.desc && <Check size={14} />}
|
||||
{sortOrder === 'desc' && <Check size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Shared building blocks for the dashboards-list view states. */
|
||||
/* Composed via CSS-modules `composes:` from each state's own SCSS. */
|
||||
// Shared building blocks for the dashboards-list view states.
|
||||
// Composed via CSS-modules `composes:` from each state's own SCSS.
|
||||
|
||||
.cardWrapper {
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
@@ -11,31 +7,26 @@ import {
|
||||
type UseQueryStateReturn,
|
||||
} from 'nuqs';
|
||||
|
||||
export const SORT_COLUMNS = Object.values(DashboardtypesListSortDTO);
|
||||
export const SORT_ORDERS = Object.values(DashboardtypesListOrderDTO);
|
||||
export const SORT_COLUMNS = ['updated_at', 'created_at', 'name'] as const;
|
||||
export type SortColumn = (typeof SORT_COLUMNS)[number];
|
||||
|
||||
export const SORT_ORDERS = ['asc', 'desc'] as const;
|
||||
export type SortOrder = (typeof SORT_ORDERS)[number];
|
||||
|
||||
const opts: Options = { history: 'push' };
|
||||
|
||||
export const useSortColumn = (): UseQueryStateReturn<
|
||||
DashboardtypesListSortDTO,
|
||||
DashboardtypesListSortDTO
|
||||
> =>
|
||||
export const useSortColumn = (): UseQueryStateReturn<SortColumn, SortColumn> =>
|
||||
useQueryState(
|
||||
'sort',
|
||||
parseAsStringLiteral(SORT_COLUMNS)
|
||||
.withDefault(DashboardtypesListSortDTO.updated_at)
|
||||
.withDefault('updated_at')
|
||||
.withOptions(opts),
|
||||
);
|
||||
|
||||
export const useSortOrder = (): UseQueryStateReturn<
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListOrderDTO
|
||||
> =>
|
||||
export const useSortOrder = (): UseQueryStateReturn<SortOrder, SortOrder> =>
|
||||
useQueryState(
|
||||
'order',
|
||||
parseAsStringLiteral(SORT_ORDERS)
|
||||
.withDefault(DashboardtypesListOrderDTO.desc)
|
||||
.withOptions(opts),
|
||||
parseAsStringLiteral(SORT_ORDERS).withDefault('desc').withOptions(opts),
|
||||
);
|
||||
|
||||
export const usePage = (): UseQueryStateReturn<number, number> =>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import type { DashboardtypesListedDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesGettableDashboardWithPinDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type DashboardListItem = DashboardtypesListedDashboardV2DTO;
|
||||
export type DashboardListItem = DashboardtypesGettableDashboardWithPinDTO;
|
||||
|
||||
export const tagsToStrings = (
|
||||
tags: { key: string; value: string }[] | null | undefined,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import LLMObservabilityModelPricing from 'container/LLMObservabilityModelPricing/LLMObservabilityModelPricing';
|
||||
|
||||
function LLMObservabilityModelPricingPage(): JSX.Element {
|
||||
return <LLMObservabilityModelPricing />;
|
||||
}
|
||||
|
||||
export default LLMObservabilityModelPricingPage;
|
||||
@@ -2,7 +2,7 @@ import { Logout } from 'api/utils';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { createErrorResponse, rest, server } from 'mocks-server/server';
|
||||
import { render, screen, waitFor, fireEvent } from 'tests/test-utils';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ResetPassword from '../index';
|
||||
|
||||
@@ -103,7 +103,6 @@ describe('ResetPassword Page', () => {
|
||||
expect(
|
||||
screen.getByText(/reset password token does not exist/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('back-to-login')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "token is expired" when token is expired (401) without redirecting to login', async () => {
|
||||
@@ -138,32 +137,6 @@ describe('ResetPassword Page', () => {
|
||||
// 401 from this endpoint must NOT trigger logout/redirect
|
||||
expect(mockHistoryPush).not.toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
expect(Logout).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('back-to-login')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to login when "Back to login" is clicked on error screen', async () => {
|
||||
server.use(
|
||||
rest.post(
|
||||
VERIFY_TOKEN_ENDPOINT,
|
||||
createErrorResponse(
|
||||
404,
|
||||
'reset_password_token_not_found',
|
||||
'reset password token does not exist',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
window.history.pushState({}, '', '/password-reset?token=invalid-token');
|
||||
render(<ResetPassword />, undefined, {
|
||||
initialRoute: '/password-reset?token=invalid-token',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('back-to-login')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('back-to-login'));
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
});
|
||||
|
||||
it('redirects to login when no token is in the URL', async () => {
|
||||
|
||||
@@ -136,4 +136,5 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
AI_ASSISTANT_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_MODEL_PRICING: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
};
|
||||
|
||||
@@ -48,7 +48,9 @@
|
||||
"node_modules",
|
||||
"src/parser/*.ts",
|
||||
"src/parser/TraceOperatorParser/*.ts",
|
||||
"orval.config.ts"
|
||||
"orval.config.ts",
|
||||
"src/pages/DashboardsListPageV2/**/*",
|
||||
"src/pages/DashboardPageV2/**/*"
|
||||
],
|
||||
"include": [
|
||||
"./src",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -212,7 +212,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewFixChangelogOperationTypeFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema),
|
||||
sqlmigration.NewAddUserDashboardPreferenceFactory(sqlstore, 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
|
||||
}
|
||||
|
||||
7
tests/fixtures/idp.py
vendored
7
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
|
||||
|
||||
@@ -687,8 +686,6 @@ def perform_saml_login(
|
||||
url = session_context["orgs"][0]["authNSupport"]["callback"][0]["url"]
|
||||
driver.get(url)
|
||||
idp_login(email, password)
|
||||
# wait until the browser lands back on SigNoz
|
||||
WebDriverWait(driver, 15).until(lambda d: d.current_url.startswith(signoz.self.host_configs["8080"].base()))
|
||||
|
||||
|
||||
def delete_keycloak_client(idp: types.TestContainerIDP, client_id: str) -> None:
|
||||
|
||||
@@ -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