Compare commits

..

27 Commits

Author SHA1 Message Date
Abhishek Kumar Singh
205a78f0e6 feat: added basic html markdown templater 2026-03-17 20:17:57 +05:30
Abhishek Kumar Singh
79518b6823 Merge branch 'chore/am_custom_notifiers' into feat/alert_manager_template 2026-03-17 20:15:00 +05:30
Abhishek Kumar Singh
e6a9f49cec Merge branch 'main' into chore/am_custom_notifiers 2026-03-17 20:14:30 +05:30
Abhishek Kumar Singh
fd5fc40823 chore: updated comments 2026-03-16 18:19:03 +05:30
Abhishek Kumar Singh
db2e2a4617 chore: lint fix 2026-03-16 15:54:22 +05:30
Abhishek Kumar Singh
9368d3f393 refactor: comments and test improvements 2026-03-16 15:47:45 +05:30
Abhishek Kumar Singh
0c97ba36d6 refactor: test case and sb related changed 2026-03-16 15:12:23 +05:30
Abhishek Kumar Singh
2e1bdbc2fd chore: added test for missing function 2026-03-16 14:49:52 +05:30
Abhishek Kumar Singh
330737f779 chore: renamed the interface 2026-03-13 19:20:49 +05:30
Abhishek Kumar Singh
f0c531ae2b chore: lint fix 2026-03-13 19:11:34 +05:30
Abhishek Kumar Singh
54477ee786 feat: added support for and in templating 2026-03-13 19:09:39 +05:30
Abhishek Kumar Singh
d281f7b6a2 test: fix preprocessor test case 2026-03-13 17:31:28 +05:30
Abhishek Kumar Singh
378dc350ef refactor: added extractCommonKV instead of 2 different functions 2026-03-13 17:10:13 +05:30
Abhishek Kumar Singh
89c38ed9bc feat: converted alerttemplater to interface and updated tests 2026-03-13 17:02:26 +05:30
Abhishek Kumar Singh
04c4869b12 chore: added handling for missing variable used in template 2026-03-13 14:10:13 +05:30
Abhishek Kumar Singh
388a1184ca chore: fix lint issues 2026-03-12 21:51:13 +05:30
Abhishek Kumar Singh
03901b353b chore: hooked preProcess function in expandTitle and body, added labels and annotations in alertdata 2026-03-12 21:47:43 +05:30
Abhishek Kumar Singh
74441c74a8 feat: added preprocessor for alert templater 2026-03-12 20:54:28 +05:30
Abhishek Kumar Singh
93d332bef2 chore: exposed templates for alertmanager types 2026-03-12 18:52:40 +05:30
Abhishek Kumar Singh
1e730cae8c chore: added utils for using variables with $ notation 2026-03-12 16:34:43 +05:30
Abhishek Kumar Singh
01a09cf6d2 chore: updated test name + code for timeout errors 2026-03-12 10:22:42 +05:30
Abhishek Kumar Singh
403dddab85 feat: alert manager template to template title and notification body 2026-03-11 21:55:09 +05:30
Abhishek Kumar Singh
d07a833574 chore: added tracing to msteamsv2 notifier 2026-03-11 16:05:00 +05:30
Abhishek Kumar Singh
b39bec7245 Merge branch 'main' into chore/am_custom_notifiers 2026-03-10 22:37:24 +05:30
Abhishek Kumar Singh
6ff55c48be chore: fix email linter 2026-03-10 22:19:05 +05:30
Abhishek Kumar Singh
b15fa0f88f chore: lint fixs 2026-03-10 21:57:53 +05:30
Abhishek Kumar Singh
19fe4f860e chore: custom notifiers in alert manager 2026-03-10 13:20:04 +05:30
121 changed files with 6589 additions and 6145 deletions

View File

@@ -102,3 +102,13 @@ jobs:
run: |
go run cmd/enterprise/*.go generate openapi
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in openapi spec. Run go run cmd/enterprise/*.go generate openapi locally and commit."; exit 1)
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
- name: install-frontend
run: cd frontend && yarn install
- name: generate-api-clients
run: |
cd frontend && yarn generate:api
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run yarn generate:api in frontend/ locally and commit."; exit 1)

View File

@@ -52,16 +52,16 @@ jobs:
with:
PRIMUS_REF: main
JS_SRC: frontend
languages:
md-languages:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
- name: checkout
uses: actions/checkout@v4
- name: run
- name: validate md languages
run: bash frontend/scripts/validate-md-languages.sh
authz:
if: |
@@ -70,55 +70,44 @@ jobs:
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
- name: Checkout code
uses: actions/checkout@v5
- name: node-install
- name: Set up Node.js
uses: actions/setup-node@v5
with:
node-version: "22"
- name: deps-install
- name: Install frontend dependencies
working-directory: ./frontend
run: |
yarn install
- name: uv-install
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: uv-deps
- name: Install Python dependencies
working-directory: ./tests/integration
run: |
uv sync
- name: setup-test
- name: Start test environment
run: |
make py-test-setup
- name: generate
- name: Generate permissions.type.ts
working-directory: ./frontend
run: |
yarn generate:permissions-type
- name: teardown-test
- name: Teardown test environment
if: always()
run: |
make py-test-teardown
- name: validate
- name: Check for changes
run: |
if ! git diff --exit-code frontend/src/hooks/useAuthZ/permissions.type.ts; then
echo "::error::frontend/src/hooks/useAuthZ/permissions.type.ts is out of date. Please run the generator locally and commit the changes: npm run generate:permissions-type (from the frontend directory)"
exit 1
fi
openapi:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
- name: install-frontend
run: cd frontend && yarn install
- name: generate-api-clients
run: |
cd frontend && yarn generate:api
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run yarn generate:api in frontend/ locally and commit."; exit 1)

View File

@@ -220,13 +220,6 @@ components:
- additions
- deletions
type: object
AuthtypesPatchableRole:
properties:
description:
type: string
required:
- description
type: object
AuthtypesPostableAuthDomain:
properties:
config:
@@ -243,15 +236,6 @@ components:
password:
type: string
type: object
AuthtypesPostableRole:
properties:
description:
type: string
name:
type: string
required:
- name
type: object
AuthtypesPostableRotateToken:
properties:
refreshToken:
@@ -267,31 +251,6 @@ components:
- name
- type
type: object
AuthtypesRole:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
name:
type: string
orgId:
type: string
type:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- description
- type
- orgId
type: object
AuthtypesRoleMapping:
properties:
defaultRole:
@@ -1763,6 +1722,47 @@ components:
- status
- error
type: object
RoletypesPatchableRole:
properties:
description:
type: string
required:
- description
type: object
RoletypesPostableRole:
properties:
description:
type: string
name:
type: string
required:
- name
type: object
RoletypesRole:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
name:
type: string
orgId:
type: string
type:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- description
- type
- orgId
type: object
ServiceaccounttypesFactorAPIKey:
properties:
createdAt:
@@ -4234,7 +4234,7 @@ paths:
properties:
data:
items:
$ref: '#/components/schemas/AuthtypesRole'
$ref: '#/components/schemas/RoletypesRole'
type: array
status:
type: string
@@ -4277,7 +4277,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPostableRole'
$ref: '#/components/schemas/RoletypesPostableRole'
responses:
"201":
content:
@@ -4422,7 +4422,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesRole'
$ref: '#/components/schemas/RoletypesRole'
status:
type: string
required:
@@ -4470,7 +4470,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPatchableRole'
$ref: '#/components/schemas/RoletypesPatchableRole'
responses:
"204":
content:

View File

@@ -13,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer"
@@ -22,7 +23,7 @@ type provider struct {
pkgAuthzService authz.AuthZ
openfgaServer *openfgaserver.Server
licensing licensing.Licensing
store authtypes.RoleStore
store roletypes.Store
registry []authz.RegisterTypeable
}
@@ -81,23 +82,23 @@ func (provider *provider) Write(ctx context.Context, additions []*openfgav1.Tupl
return provider.openfgaServer.Write(ctx, additions, deletions)
}
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*roletypes.Role, error) {
return provider.pkgAuthzService.Get(ctx, orgID, id)
}
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*roletypes.Role, error) {
return provider.pkgAuthzService.GetByOrgIDAndName(ctx, orgID, name)
}
func (provider *provider) List(ctx context.Context, orgID valuer.UUID) ([]*authtypes.Role, error) {
func (provider *provider) List(ctx context.Context, orgID valuer.UUID) ([]*roletypes.Role, error) {
return provider.pkgAuthzService.List(ctx, orgID)
}
func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*authtypes.Role, error) {
func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*roletypes.Role, error) {
return provider.pkgAuthzService.ListByOrgIDAndNames(ctx, orgID, names)
}
func (provider *provider) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, ids []valuer.UUID) ([]*authtypes.Role, error) {
func (provider *provider) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, ids []valuer.UUID) ([]*roletypes.Role, error) {
return provider.pkgAuthzService.ListByOrgIDAndIDs(ctx, orgID, ids)
}
@@ -113,7 +114,7 @@ func (provider *provider) Revoke(ctx context.Context, orgID valuer.UUID, names [
return provider.pkgAuthzService.Revoke(ctx, orgID, names, subject)
}
func (provider *provider) CreateManagedRoles(ctx context.Context, orgID valuer.UUID, managedRoles []*authtypes.Role) error {
func (provider *provider) CreateManagedRoles(ctx context.Context, orgID valuer.UUID, managedRoles []*roletypes.Role) error {
return provider.pkgAuthzService.CreateManagedRoles(ctx, orgID, managedRoles)
}
@@ -135,16 +136,16 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
return provider.Write(ctx, tuples, nil)
}
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
return provider.store.Create(ctx, authtypes.NewStorableRoleFromRole(role))
return provider.store.Create(ctx, roletypes.NewStorableRoleFromRole(role))
}
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) (*authtypes.Role, error) {
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) (*roletypes.Role, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -158,10 +159,10 @@ func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, ro
}
if existingRole != nil {
return authtypes.NewRoleFromStorableRole(existingRole), nil
return roletypes.NewRoleFromStorableRole(existingRole), nil
}
err = provider.store.Create(ctx, authtypes.NewStorableRoleFromRole(role))
err = provider.store.Create(ctx, roletypes.NewStorableRoleFromRole(role))
if err != nil {
return nil, err
}
@@ -216,13 +217,13 @@ func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id
return objects, nil
}
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
return provider.store.Update(ctx, orgID, authtypes.NewStorableRoleFromRole(role))
return provider.store.Update(ctx, orgID, roletypes.NewStorableRoleFromRole(role))
}
func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
@@ -231,12 +232,12 @@ func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, n
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
additionTuples, err := authtypes.GetAdditionTuples(name, orgID, relation, additions)
additionTuples, err := roletypes.GetAdditionTuples(name, orgID, relation, additions)
if err != nil {
return err
}
deletionTuples, err := authtypes.GetDeletionTuples(name, orgID, relation, deletions)
deletionTuples, err := roletypes.GetDeletionTuples(name, orgID, relation, deletions)
if err != nil {
return err
}
@@ -260,7 +261,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
return err
}
role := authtypes.NewRoleFromStorableRole(storableRole)
role := roletypes.NewRoleFromStorableRole(storableRole)
err = role.ErrIfManaged()
if err != nil {
return err
@@ -270,7 +271,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
}
func (provider *provider) MustGetTypeables() []authtypes.Typeable {
return []authtypes.Typeable{authtypes.TypeableRole, authtypes.TypeableResourcesRoles}
return []authtypes.Typeable{authtypes.TypeableRole, roletypes.TypeableResourcesRoles}
}
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) ([]*openfgav1.TupleKey, error) {
@@ -282,7 +283,7 @@ func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID va
adminSubject,
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAdminRoleName),
},
orgID,
)
@@ -297,7 +298,7 @@ func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID va
anonymousSubject,
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAnonymousRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAnonymousRoleName),
},
orgID,
)

View File

@@ -19,6 +19,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -223,7 +224,7 @@ func (module *module) MustGetTypeables() []authtypes.Typeable {
func (module *module) MustGetManagedRoleTransactions() map[string][]*authtypes.Transaction {
return map[string][]*authtypes.Transaction{
authtypes.SigNozAnonymousRoleName: {
roletypes.SigNozAnonymousRoleName: {
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationRead,

View File

@@ -80,21 +80,6 @@ func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
alertDataRows := cmock.NewRows(cols, tc.Values)
mock := telemetryStore.Mock()
// Mock metadata queries for FetchTemporalityAndTypeMulti
// First query: fetchMetricsTemporalityAndType (from signoz_metrics time series table)
metadataCols := []cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "temporality", Type: "String"},
{Name: "type", Type: "String"},
{Name: "is_monotonic", Type: "Bool"},
}
metadataRows := cmock.NewRows(metadataCols, [][]any{
{"probe_success", metrictypes.Unspecified, metrictypes.GaugeType, false},
})
mock.ExpectQuery("*distributed_time_series_v4*").WithArgs(nil, nil, nil).WillReturnRows(metadataRows)
// Second query: fetchMeterSourceMetricsTemporalityAndType (from signoz_meter table)
emptyMetadataRows := cmock.NewRows(metadataCols, [][]any{})
mock.ExpectQuery("*meter*").WithArgs(nil).WillReturnRows(emptyMetadataRows)
// Generate query arguments for the metric query
evalTime := time.Now().UTC()

View File

@@ -11,7 +11,6 @@
"prettify": "prettier --write .",
"fmt": "prettier --check .",
"lint": "eslint ./src",
"lint:generated": "eslint ./src/api/generated --fix",
"lint:fix": "eslint ./src --fix",
"jest": "jest",
"jest:coverage": "jest --coverage",
@@ -136,7 +135,6 @@
"react-full-screen": "1.1.1",
"react-grid-layout": "^1.3.4",
"react-helmet-async": "1.3.0",
"react-hook-form": "7.71.2",
"react-i18next": "^11.16.1",
"react-lottie": "1.2.10",
"react-markdown": "8.0.7",
@@ -285,4 +283,4 @@
"tmp": "0.2.4",
"vite": "npm:rolldown-vite@7.3.1"
}
}
}

View File

@@ -15,6 +15,5 @@
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles",
"role_details": "Role Details",
"members": "Members",
"service_accounts": "Service Accounts"
"members": "Members"
}

View File

@@ -50,8 +50,5 @@
"INFRASTRUCTURE_MONITORING_KUBERNETES": "SigNoz | Infra Monitoring",
"METER_EXPLORER": "SigNoz | Meter Explorer",
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members",
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts"
"METER": "SigNoz | Meter"
}

View File

@@ -15,6 +15,5 @@
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles",
"role_details": "Role Details",
"members": "Members",
"service_accounts": "Service Accounts"
"members": "Members"
}

View File

@@ -75,6 +75,5 @@
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members",
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts"
"MEMBERS_SETTINGS": "SigNoz | Members"
}

View File

@@ -25,7 +25,7 @@ echo "\n✅ Prettier formatting successful"
# Fix linting issues
echo "\n\n---\nRunning eslint...\n"
if ! yarn lint:generated; then
if ! yarn lint --fix --quiet src/api/generated; then
echo "ESLint check failed! Please fix linting errors before proceeding."
exit 1
fi

View File

@@ -21,8 +21,6 @@ import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type {
AuthtypesPatchableObjectsDTO,
AuthtypesPatchableRoleDTO,
AuthtypesPostableRoleDTO,
CreateRole201,
DeleteRolePathParameters,
GetObjects200,
@@ -33,6 +31,8 @@ import type {
PatchObjectsPathParameters,
PatchRolePathParameters,
RenderErrorResponseDTO,
RoletypesPatchableRoleDTO,
RoletypesPostableRoleDTO,
} from '../sigNoz.schemas';
/**
@@ -118,14 +118,14 @@ export const invalidateListRoles = async (
* @summary Create role
*/
export const createRole = (
authtypesPostableRoleDTO: BodyType<AuthtypesPostableRoleDTO>,
roletypesPostableRoleDTO: BodyType<RoletypesPostableRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateRole201>({
url: `/api/v1/roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: authtypesPostableRoleDTO,
data: roletypesPostableRoleDTO,
signal,
});
};
@@ -137,13 +137,13 @@ export const getCreateRoleMutationOptions = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRole>>,
TError,
{ data: BodyType<AuthtypesPostableRoleDTO> },
{ data: BodyType<RoletypesPostableRoleDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createRole>>,
TError,
{ data: BodyType<AuthtypesPostableRoleDTO> },
{ data: BodyType<RoletypesPostableRoleDTO> },
TContext
> => {
const mutationKey = ['createRole'];
@@ -157,7 +157,7 @@ export const getCreateRoleMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createRole>>,
{ data: BodyType<AuthtypesPostableRoleDTO> }
{ data: BodyType<RoletypesPostableRoleDTO> }
> = (props) => {
const { data } = props ?? {};
@@ -170,7 +170,7 @@ export const getCreateRoleMutationOptions = <
export type CreateRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof createRole>>
>;
export type CreateRoleMutationBody = BodyType<AuthtypesPostableRoleDTO>;
export type CreateRoleMutationBody = BodyType<RoletypesPostableRoleDTO>;
export type CreateRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -183,13 +183,13 @@ export const useCreateRole = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRole>>,
TError,
{ data: BodyType<AuthtypesPostableRoleDTO> },
{ data: BodyType<RoletypesPostableRoleDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createRole>>,
TError,
{ data: BodyType<AuthtypesPostableRoleDTO> },
{ data: BodyType<RoletypesPostableRoleDTO> },
TContext
> => {
const mutationOptions = getCreateRoleMutationOptions(options);
@@ -370,13 +370,13 @@ export const invalidateGetRole = async (
*/
export const patchRole = (
{ id }: PatchRolePathParameters,
authtypesPatchableRoleDTO: BodyType<AuthtypesPatchableRoleDTO>,
roletypesPatchableRoleDTO: BodyType<RoletypesPatchableRoleDTO>,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v1/roles/${id}`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: authtypesPatchableRoleDTO,
data: roletypesPatchableRoleDTO,
});
};
@@ -389,7 +389,7 @@ export const getPatchRoleMutationOptions = <
TError,
{
pathParams: PatchRolePathParameters;
data: BodyType<AuthtypesPatchableRoleDTO>;
data: BodyType<RoletypesPatchableRoleDTO>;
},
TContext
>;
@@ -398,7 +398,7 @@ export const getPatchRoleMutationOptions = <
TError,
{
pathParams: PatchRolePathParameters;
data: BodyType<AuthtypesPatchableRoleDTO>;
data: BodyType<RoletypesPatchableRoleDTO>;
},
TContext
> => {
@@ -415,7 +415,7 @@ export const getPatchRoleMutationOptions = <
Awaited<ReturnType<typeof patchRole>>,
{
pathParams: PatchRolePathParameters;
data: BodyType<AuthtypesPatchableRoleDTO>;
data: BodyType<RoletypesPatchableRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -429,7 +429,7 @@ export const getPatchRoleMutationOptions = <
export type PatchRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof patchRole>>
>;
export type PatchRoleMutationBody = BodyType<AuthtypesPatchableRoleDTO>;
export type PatchRoleMutationBody = BodyType<RoletypesPatchableRoleDTO>;
export type PatchRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -444,7 +444,7 @@ export const usePatchRole = <
TError,
{
pathParams: PatchRolePathParameters;
data: BodyType<AuthtypesPatchableRoleDTO>;
data: BodyType<RoletypesPatchableRoleDTO>;
},
TContext
>;
@@ -453,7 +453,7 @@ export const usePatchRole = <
TError,
{
pathParams: PatchRolePathParameters;
data: BodyType<AuthtypesPatchableRoleDTO>;
data: BodyType<RoletypesPatchableRoleDTO>;
},
TContext
> => {

View File

@@ -278,13 +278,6 @@ export interface AuthtypesPatchableObjectsDTO {
deletions: AuthtypesGettableObjectsDTO[] | null;
}
export interface AuthtypesPatchableRoleDTO {
/**
* @type string
*/
description: string;
}
export interface AuthtypesPostableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
/**
@@ -308,17 +301,6 @@ export interface AuthtypesPostableEmailPasswordSessionDTO {
password?: string;
}
export interface AuthtypesPostableRoleDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name: string;
}
export interface AuthtypesPostableRotateTokenDTO {
/**
* @type string
@@ -337,39 +319,6 @@ export interface AuthtypesResourceDTO {
type: string;
}
export interface AuthtypesRoleDTO {
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
description: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
*/
type: string;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
}
/**
* @nullable
*/
@@ -2090,6 +2039,57 @@ export interface RenderErrorResponseDTO {
status: string;
}
export interface RoletypesPatchableRoleDTO {
/**
* @type string
*/
description: string;
}
export interface RoletypesPostableRoleDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name: string;
}
export interface RoletypesRoleDTO {
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
description: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
*/
type: string;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
}
export interface ServiceaccounttypesFactorAPIKeyDTO {
/**
* @type string
@@ -3163,7 +3163,7 @@ export type ListRoles200 = {
/**
* @type array
*/
data: AuthtypesRoleDTO[];
data: RoletypesRoleDTO[];
/**
* @type string
*/
@@ -3185,7 +3185,7 @@ export type GetRolePathParameters = {
id: string;
};
export type GetRole200 = {
data: AuthtypesRoleDTO;
data: RoletypesRoleDTO;
/**
* @type string
*/

View File

@@ -1,97 +0,0 @@
.announcement-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--padding-2) var(--padding-4);
height: 40px;
font-family: var(--font-sans), sans-serif;
font-size: var(--label-base-500-font-size);
line-height: var(--label-base-500-line-height);
font-weight: var(--label-base-500-font-weight);
letter-spacing: -0.065px;
&--warning {
background-color: var(--callout-warning-background);
color: var(--callout-warning-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-warning-border);
}
}
&--info {
background-color: var(--callout-primary-background);
color: var(--callout-primary-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-primary-border);
}
}
&--error {
background-color: var(--callout-error-background);
color: var(--callout-error-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-error-border);
}
}
&--success {
background-color: var(--callout-success-background);
color: var(--callout-success-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-success-border);
}
}
&__body {
display: flex;
align-items: center;
gap: var(--spacing-4);
flex: 1;
min-width: 0;
}
&__icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
&__message {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: var(--line-height-normal);
strong {
font-weight: var(--font-weight-semibold);
}
}
&__action {
height: 24px;
font-size: var(--label-small-500-font-size);
color: currentColor;
&:hover {
opacity: 0.8;
}
}
&__dismiss {
width: 24px;
height: 24px;
padding: 0;
color: currentColor;
&:hover {
opacity: 0.8;
}
}
}

View File

@@ -1,89 +0,0 @@
import { render, screen, userEvent } from 'tests/test-utils';
import {
AnnouncementBanner,
AnnouncementBannerProps,
PersistedAnnouncementBanner,
} from './index';
const STORAGE_KEY = 'test-banner-dismissed';
function renderBanner(props: Partial<AnnouncementBannerProps> = {}): void {
render(<AnnouncementBanner message="Test message" {...props} />);
}
afterEach(() => {
localStorage.removeItem(STORAGE_KEY);
});
describe('AnnouncementBanner', () => {
it('renders message and default warning variant', () => {
renderBanner({ message: <strong>Heads up</strong> });
const alert = screen.getByRole('alert');
expect(alert).toHaveClass('announcement-banner--warning');
expect(alert).toHaveTextContent('Heads up');
});
it.each(['warning', 'info', 'success', 'error'] as const)(
'renders %s variant correctly',
(type) => {
renderBanner({ type, message: 'Test message' });
const alert = screen.getByRole('alert');
expect(alert).toHaveClass(`announcement-banner--${type}`);
},
);
it('calls action onClick when action button is clicked', async () => {
const onClick = jest.fn() as jest.MockedFunction<() => void>;
renderBanner({ action: { label: 'Go to Settings', onClick } });
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /go to settings/i }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('hides dismiss button when onClose is not provided and hides icon when icon is null', () => {
renderBanner({ onClose: undefined, icon: null });
expect(
screen.queryByRole('button', { name: /dismiss/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('alert')?.querySelector('.announcement-banner__icon'),
).not.toBeInTheDocument();
});
});
describe('PersistedAnnouncementBanner', () => {
it('dismisses on click, calls onDismiss, and persists to localStorage', async () => {
const onDismiss = jest.fn() as jest.MockedFunction<() => void>;
render(
<PersistedAnnouncementBanner
message="Test message"
storageKey={STORAGE_KEY}
onDismiss={onDismiss}
/>,
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /dismiss/i }));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(STORAGE_KEY)).toBe('true');
});
it('does not render when storageKey is already set in localStorage', () => {
localStorage.setItem(STORAGE_KEY, 'true');
render(
<PersistedAnnouncementBanner
message="Test message"
storageKey={STORAGE_KEY}
/>,
);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});

View File

@@ -1,84 +0,0 @@
import { ReactNode } from 'react';
import { Button } from '@signozhq/button';
import {
CircleAlert,
CircleCheckBig,
Info,
TriangleAlert,
X,
} from '@signozhq/icons';
import cx from 'classnames';
import './AnnouncementBanner.styles.scss';
export type AnnouncementBannerType = 'warning' | 'info' | 'error' | 'success';
export interface AnnouncementBannerAction {
label: string;
onClick: () => void;
}
export interface AnnouncementBannerProps {
message: ReactNode;
type?: AnnouncementBannerType;
icon?: ReactNode | null;
action?: AnnouncementBannerAction;
onClose?: () => void;
className?: string;
}
const DEFAULT_ICONS: Record<AnnouncementBannerType, ReactNode> = {
warning: <TriangleAlert size={14} />,
info: <Info size={14} />,
error: <CircleAlert size={14} />,
success: <CircleCheckBig size={14} />,
};
export default function AnnouncementBanner({
message,
type = 'warning',
icon,
action,
onClose,
className,
}: AnnouncementBannerProps): JSX.Element {
const resolvedIcon = icon === null ? null : icon ?? DEFAULT_ICONS[type];
return (
<div
role="alert"
className={cx(
'announcement-banner',
`announcement-banner--${type}`,
className,
)}
>
<div className="announcement-banner__body">
{resolvedIcon && (
<span className="announcement-banner__icon">{resolvedIcon}</span>
)}
<span className="announcement-banner__message">{message}</span>
{action && (
<Button
type="button"
className="announcement-banner__action"
onClick={action.onClick}
>
{action.label}
</Button>
)}
</div>
{onClose && (
<Button
type="button"
aria-label="Dismiss"
className="announcement-banner__dismiss"
onClick={onClose}
>
<X size={14} />
</Button>
)}
</div>
);
}

View File

@@ -1,34 +0,0 @@
import { useState } from 'react';
import AnnouncementBanner, {
AnnouncementBannerProps,
} from './AnnouncementBanner';
interface PersistedAnnouncementBannerProps extends AnnouncementBannerProps {
storageKey: string;
onDismiss?: () => void;
}
function isDismissed(storageKey: string): boolean {
return localStorage.getItem(storageKey) === 'true';
}
export default function PersistedAnnouncementBanner({
storageKey,
onDismiss,
...props
}: PersistedAnnouncementBannerProps): JSX.Element | null {
const [visible, setVisible] = useState(() => !isDismissed(storageKey));
if (!visible) {
return null;
}
const handleClose = (): void => {
localStorage.setItem(storageKey, 'true');
setVisible(false);
onDismiss?.();
};
return <AnnouncementBanner {...props} onClose={handleClose} />;
}

View File

@@ -1,12 +0,0 @@
import AnnouncementBanner from './AnnouncementBanner';
import PersistedAnnouncementBanner from './PersistedAnnouncementBanner';
export type {
AnnouncementBannerAction,
AnnouncementBannerProps,
AnnouncementBannerType,
} from './AnnouncementBanner';
export { AnnouncementBanner, PersistedAnnouncementBanner };
export default AnnouncementBanner;

View File

@@ -1,106 +0,0 @@
.create-sa-modal {
max-width: 530px;
background: var(--popover);
border: 1px solid var(--secondary);
border-radius: 4px;
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
[data-slot='dialog-header'] {
padding: var(--padding-4);
border-bottom: 1px solid var(--secondary);
flex-shrink: 0;
background: transparent;
margin: 0;
}
[data-slot='dialog-title'] {
font-size: var(--label-base-400-font-size);
font-weight: var(--label-base-400-font-weight);
line-height: var(--label-base-400-line-height);
letter-spacing: -0.065px;
color: var(--bg-base-white);
margin: 0;
}
[data-slot='dialog-description'] {
padding: 0;
.create-sa-modal__content {
padding: var(--padding-4);
}
}
}
.create-sa-form {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
&__item {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
margin-bottom: var(--spacing-4);
> label {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
letter-spacing: -0.07px;
}
}
&__input {
height: 32px;
color: var(--l1-foreground);
background-color: var(--l2-background);
border-color: var(--border);
font-size: var(--paragraph-base-400-font-size);
border-radius: 2px;
width: 100%;
&::placeholder {
color: var(--l3-foreground);
}
&:focus {
border-color: var(--primary);
box-shadow: none;
}
}
&__error {
font-size: var(--paragraph-small-400-font-size);
color: var(--destructive);
line-height: var(--paragraph-small-400-line-height);
margin: 0;
}
&__helper {
font-size: var(--paragraph-small-400-font-size);
color: var(--l3-foreground);
margin: calc(var(--spacing-2) * -1) 0 var(--spacing-4) 0;
line-height: var(--paragraph-small-400-line-height);
}
}
.create-sa-modal__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
padding: 0 var(--padding-4);
height: 56px;
min-height: 56px;
border-top: 1px solid var(--secondary);
gap: var(--spacing-4);
flex-shrink: 0;
}
.lightMode {
.create-sa-modal {
[data-slot='dialog-title'] {
color: var(--bg-base-black);
}
}
}

View File

@@ -1,228 +0,0 @@
import { Controller, useForm } from 'react-hook-form';
import { useQueryClient } from 'react-query';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { X } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
invalidateListServiceAccounts,
useCreateServiceAccount,
} from 'api/generated/services/serviceaccount';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import RolesSelect, { useRoles } from 'components/RolesSelect';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { EMAIL_REGEX } from 'utils/app';
import './CreateServiceAccountModal.styles.scss';
interface FormValues {
name: string;
email: string;
roles: string[];
}
function CreateServiceAccountModal(): JSX.Element {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useQueryState(
'create-sa',
parseAsBoolean.withDefault(false),
);
const {
control,
handleSubmit,
reset,
formState: { isValid, errors },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: {
name: '',
email: '',
roles: [],
},
});
const {
mutate: createServiceAccount,
isLoading: isSubmitting,
} = useCreateServiceAccount({
mutation: {
onSuccess: () => {
toast.success('Service account created successfully', {
richColors: true,
});
reset();
void setIsOpen(null);
void invalidateListServiceAccounts(queryClient);
},
onError: (err) => {
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
toast.error(`Failed to create service account: ${errMessage}`, {
richColors: true,
});
},
},
});
const {
roles,
isLoading: rolesLoading,
isError: rolesError,
error: rolesErrorObj,
refetch: refetchRoles,
} = useRoles();
function handleClose(): void {
reset();
void setIsOpen(null);
}
function handleCreate(values: FormValues): void {
createServiceAccount({
data: {
name: values.name.trim(),
email: values.email.trim(),
roles: values.roles,
},
});
}
return (
<DialogWrapper
title="New Service Account"
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
handleClose();
}
}}
showCloseButton
width="narrow"
className="create-sa-modal"
disableOutsideClick={false}
>
<div className="create-sa-modal__content">
<form className="create-sa-form">
<div className="create-sa-form__item">
<label htmlFor="sa-name">Name</label>
<Controller
name="name"
control={control}
rules={{ required: 'Name is required' }}
render={({ field }): JSX.Element => (
<Input
id="sa-name"
placeholder="Enter a name"
className="create-sa-form__input"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
{errors.name && (
<p className="create-sa-form__error">{errors.name.message}</p>
)}
</div>
<div className="create-sa-form__item">
<label htmlFor="sa-email">Email Address</label>
<Controller
name="email"
control={control}
rules={{
required: 'Email Address is required',
pattern: {
value: EMAIL_REGEX,
message: 'Please enter a valid email address',
},
}}
render={({ field }): JSX.Element => (
<Input
id="sa-email"
type="email"
placeholder="email@example.com"
className="create-sa-form__input"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
{errors.email && (
<p className="create-sa-form__error">{errors.email.message}</p>
)}
</div>
<p className="create-sa-form__helper">
Used only for notifications about this service account. It is not used for
authentication.
</p>
<div className="create-sa-form__item">
<label htmlFor="sa-roles">Roles</label>
<Controller
name="roles"
control={control}
rules={{
validate: (value): string | true =>
value.length > 0 || 'At least one role is required',
}}
render={({ field }): JSX.Element => (
<RolesSelect
id="sa-roles"
mode="multiple"
roles={roles}
loading={rolesLoading}
isError={rolesError}
error={rolesErrorObj}
onRefetch={refetchRoles}
placeholder="Select roles"
value={field.value}
onChange={field.onChange}
getPopupContainer={(triggerNode): HTMLElement =>
(triggerNode?.closest('.create-sa-modal') as HTMLElement) ||
document.body
}
/>
)}
/>
{errors.roles && (
<p className="create-sa-form__error">{errors.roles.message}</p>
)}
</div>
</form>
</div>
<DialogFooter className="create-sa-modal__footer">
<Button
type="button"
variant="solid"
color="secondary"
size="sm"
onClick={handleClose}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
onClick={handleSubmit(handleCreate)}
loading={isSubmitting}
disabled={!isValid}
>
Create Service Account
</Button>
</DialogFooter>
</DialogWrapper>
);
}
export default CreateServiceAccountModal;

View File

@@ -1,179 +0,0 @@
import { toast } from '@signozhq/sonner';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CreateServiceAccountModal from '../CreateServiceAccountModal';
jest.mock('@signozhq/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const mockToast = jest.mocked(toast);
const ROLES_ENDPOINT = '*/api/v1/roles';
const SERVICE_ACCOUNTS_ENDPOINT = '*/api/v1/service_accounts';
function renderModal(): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter searchParams={{ 'create-sa': 'true' }} hasMemory>
<CreateServiceAccountModal />
</NuqsTestingAdapter>,
);
}
describe('CreateServiceAccountModal', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: {} })),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('submit button is disabled when form is empty', () => {
renderModal();
expect(
screen.getByRole('button', { name: /Create Service Account/i }),
).toBeDisabled();
});
it('submit button remains disabled when email is invalid', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await user.type(screen.getByPlaceholderText('Enter a name'), 'My Bot');
await user.type(
screen.getByPlaceholderText('email@example.com'),
'not-an-email',
);
await user.click(screen.getByText('Select roles'));
await user.click(await screen.findByTitle('signoz-admin'));
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Service Account/i }),
).toBeDisabled(),
);
});
it('successful submit shows toast.success and closes modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await user.type(screen.getByPlaceholderText('Enter a name'), 'Deploy Bot');
await user.type(
screen.getByPlaceholderText('email@example.com'),
'deploy@acme.io',
);
await user.click(screen.getByText('Select roles'));
await user.click(await screen.findByTitle('signoz-admin'));
const submitBtn = screen.getByRole('button', {
name: /Create Service Account/i,
});
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.click(submitBtn);
await waitFor(() => {
expect(mockToast.success).toHaveBeenCalledWith(
'Service account created successfully',
expect.anything(),
);
});
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: /New Service Account/i }),
).not.toBeInTheDocument();
});
});
it('shows toast.error on API error and keeps modal open', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(500),
ctx.json({ status: 'error', error: 'Internal Server Error' }),
),
),
);
renderModal();
await user.type(screen.getByPlaceholderText('Enter a name'), 'Dupe Bot');
await user.type(
screen.getByPlaceholderText('email@example.com'),
'dupe@acme.io',
);
await user.click(screen.getByText('Select roles'));
await user.click(await screen.findByTitle('signoz-admin'));
const submitBtn = screen.getByRole('button', {
name: /Create Service Account/i,
});
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.click(submitBtn);
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith(
expect.stringMatching(/Failed to create service account/i),
expect.anything(),
);
});
expect(
screen.getByRole('dialog', { name: /New Service Account/i }),
).toBeInTheDocument();
});
it('Cancel button closes modal without submitting', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await screen.findByRole('dialog', { name: /New Service Account/i });
await user.click(screen.getByRole('button', { name: /Cancel/i }));
expect(
screen.queryByRole('dialog', { name: /New Service Account/i }),
).not.toBeInTheDocument();
});
it('shows "Name is required" after clearing the name field', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const nameInput = screen.getByPlaceholderText('Enter a name');
await user.type(nameInput, 'Bot');
await user.clear(nameInput);
await screen.findByText('Name is required');
});
it('shows "Please enter a valid email address" for a malformed email', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await user.type(
screen.getByPlaceholderText('email@example.com'),
'not-an-email',
);
await screen.findByText('Please enter a valid email address');
});
});

View File

@@ -254,8 +254,6 @@ function InviteMembersModal({
value={row.email}
onChange={(e): void => updateEmail(row.id, e.target.value)}
className="team-member-email-input"
name={`invite-email-${row.id}`}
autoComplete="email"
/>
{emailValidity[row.id] === false && row.email.trim() !== '' && (
<span className="email-error-message">Invalid email address</span>

View File

@@ -162,7 +162,7 @@
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
margin: 0;
line-height: var(--paragraph-base-400-line-height);
line-height: var(--paragraph-base-400-font-height);
strong {
font-weight: var(--font-weight-medium);

View File

@@ -1,90 +0,0 @@
.roles-select {
width: 100%;
// todo: styles should easeup once upgrade to select from periscope
.ant-select-selector {
min-height: 32px;
background-color: var(--l2-background) !important;
border: 1px solid var(--border) !important;
border-radius: 2px;
padding: 2px var(--padding-2) !important;
display: flex;
align-items: center;
flex-wrap: wrap;
}
.ant-select-selection-overflow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-1);
padding: 2px 0;
}
.ant-select-selection-overflow-item {
display: flex;
align-items: center;
}
.ant-select-selection-item {
display: flex;
align-items: center;
height: 22px;
font-size: var(--font-size-sm);
color: var(--l1-foreground);
background: var(--l3-background);
border: 1px solid var(--border);
border-radius: 2px;
padding: 0 var(--padding-1) 0 6px;
line-height: var(--line-height-20);
letter-spacing: -0.07px;
margin: 0;
}
.ant-select-selection-item-remove {
display: flex;
align-items: center;
color: var(--foreground);
margin-left: 2px;
}
.ant-select-selection-placeholder {
font-size: var(--font-size-sm);
color: var(--l3-foreground);
}
.ant-select-arrow {
color: var(--foreground);
}
&.ant-select-focused .ant-select-selector,
&:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--primary) !important;
}
}
.roles-select-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-3);
padding: var(--padding-1) var(--padding-2);
color: var(--destructive);
font-size: var(--font-size-xs);
&__msg {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
&__retry-btn {
display: flex;
align-items: center;
background: none;
border: none;
cursor: pointer;
padding: 2px;
color: var(--destructive);
}
}

View File

@@ -1,172 +0,0 @@
import { CircleAlert, RefreshCw } from '@signozhq/icons';
import { Checkbox, Select } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRoles } from 'api/generated/services/role';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import APIError from 'types/api/error';
import './RolesSelect.styles.scss';
export interface RoleOption {
label: string;
value: string;
}
export function useRoles(): {
roles: AuthtypesRoleDTO[];
isLoading: boolean;
isError: boolean;
error: APIError | undefined;
refetch: () => void;
} {
const { data, isLoading, isError, error, refetch } = useListRoles();
return {
roles: data?.data ?? [],
isLoading,
isError,
error: convertToApiError(error),
refetch,
};
}
export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] {
return roles.map((role) => ({
label: role.name ?? '',
value: role.name ?? '',
}));
}
function ErrorContent({
error,
onRefetch,
}: {
error?: APIError;
onRefetch?: () => void;
}): JSX.Element {
const errorMessage = error?.message || 'Failed to load roles';
return (
<div className="roles-select-error">
<span className="roles-select-error__msg">
<CircleAlert size={12} />
{errorMessage}
</span>
{onRefetch && (
<button
type="button"
onClick={(e): void => {
e.stopPropagation();
onRefetch();
}}
className="roles-select-error__retry-btn"
title="Retry"
>
<RefreshCw size={12} />
</button>
)}
</div>
);
}
interface BaseProps {
id?: string;
placeholder?: string;
className?: string;
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
roles?: AuthtypesRoleDTO[];
loading?: boolean;
isError?: boolean;
error?: APIError;
onRefetch?: () => void;
}
interface SingleProps extends BaseProps {
mode?: 'single';
value?: string;
onChange?: (role: string) => void;
}
interface MultipleProps extends BaseProps {
mode: 'multiple';
value?: string[];
onChange?: (roles: string[]) => void;
}
export type RolesSelectProps = SingleProps | MultipleProps;
function RolesSelect(props: RolesSelectProps): JSX.Element {
const externalRoles = props.roles;
const {
data,
isLoading: internalLoading,
isError: internalError,
error: internalErrorObj,
refetch: internalRefetch,
} = useListRoles({
query: { enabled: externalRoles === undefined },
});
const roles = externalRoles ?? data?.data ?? [];
const options = getRoleOptions(roles);
const {
mode,
id,
placeholder = 'Select role',
className,
getPopupContainer,
loading = internalLoading,
isError = internalError,
error = convertToApiError(internalErrorObj),
onRefetch = externalRoles === undefined ? internalRefetch : undefined,
} = props;
const notFoundContent = isError ? (
<ErrorContent error={error} onRefetch={onRefetch} />
) : undefined;
if (mode === 'multiple') {
const { value = [], onChange } = props as MultipleProps;
return (
<Select
id={id}
mode="multiple"
value={value}
onChange={onChange}
placeholder={placeholder}
className={cx('roles-select', className)}
loading={loading}
notFoundContent={notFoundContent}
options={options}
optionRender={(option): JSX.Element => (
<Checkbox
checked={value.includes(option.value as string)}
style={{ pointerEvents: 'none' }}
>
{option.label}
</Checkbox>
)}
getPopupContainer={getPopupContainer}
/>
);
}
const { value, onChange } = props as SingleProps;
return (
<Select
id={id}
value={value}
onChange={onChange}
placeholder={placeholder}
className={cx('roles-select', className)}
loading={loading}
notFoundContent={notFoundContent}
options={options}
getPopupContainer={getPopupContainer}
/>
);
}
export default RolesSelect;

View File

@@ -1,2 +0,0 @@
export type { RoleOption, RolesSelectProps } from './RolesSelect';
export { default, getRoleOptions, useRoles } from './RolesSelect';

View File

@@ -1,179 +0,0 @@
.add-key-modal {
[data-slot='dialog-description'] {
padding: 0;
}
&__form {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4);
}
&__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
&__label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
&__expiry-toggle {
width: 60%;
display: flex;
border: 1px solid var(--border);
border-radius: 2px;
overflow: hidden;
padding: 0;
gap: 0;
[data-slot='toggle-group'] {
width: 100%;
display: flex;
}
&-btn {
flex: 1;
height: 32px;
border-radius: 0;
font-size: var(--label-small-400-font-size);
font-weight: var(--label-small-400-font-weight);
line-height: var(--label-small-400-line-height);
justify-content: center;
background: transparent;
border: none;
border-right: 1px solid var(--border);
color: var(--foreground);
&:last-child {
border-right: none;
}
&[data-state='on'] {
background: var(--l2-background);
color: var(--l1-foreground);
}
}
}
&__datepicker {
width: 100%;
height: 32px;
.ant-picker {
background: var(--l2-background);
border-color: var(--border);
border-radius: 2px;
width: 100%;
height: 32px;
input {
color: var(--l1-foreground);
font-size: var(--font-size-sm);
}
.ant-picker-suffix {
color: var(--foreground);
}
}
.add-key-modal-datepicker-popup {
border-radius: 4px;
border: 1px solid var(--secondary);
background: var(--popover);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
}
}
&__key-display {
display: flex;
align-items: center;
height: 32px;
background: var(--l2-background);
border: 1px solid var(--border);
border-radius: 2px;
overflow: hidden;
}
&__key-text {
flex: 1;
min-width: 0;
padding: 0 var(--padding-2);
font-size: var(--font-size-sm);
color: var(--l1-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: monospace;
}
&__copy-btn {
flex-shrink: 0;
height: 32px;
border-radius: 0 2px 2px 0;
border-top: none;
border-right: none;
border-bottom: none;
border-left: 1px solid var(--border);
min-width: 40px;
}
&__expiry-meta {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
&__expiry-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
letter-spacing: 0.48px;
text-transform: uppercase;
}
&__footer {
display: flex;
align-items: center;
justify-content: flex-end;
padding: var(--padding-4);
border-top: 1px solid var(--secondary);
}
&__footer-right {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
&__learn-more {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
color: var(--primary);
font-size: var(--font-size-sm);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -1,302 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useQueryClient } from 'react-query';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
import { Callout } from '@signozhq/callout';
import { DialogWrapper } from '@signozhq/dialog';
import { Check, Copy } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { DatePicker } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
invalidateListServiceAccountKeys,
useCreateServiceAccountKey,
} from 'api/generated/services/serviceaccount';
import type {
RenderErrorResponseDTO,
ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import type { Dayjs } from 'dayjs';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { popupContainer } from 'utils/selectPopupContainer';
import { disabledDate } from './utils';
import './AddKeyModal.styles.scss';
type Phase = 'form' | 'created';
type ExpiryMode = 'none' | 'date';
interface FormValues {
keyName: string;
expiryMode: ExpiryMode;
expiryDate: Dayjs | null;
}
function AddKeyModal(): JSX.Element {
const queryClient = useQueryClient();
const [accountId] = useQueryState('account');
const [isAddKeyOpen, setIsAddKeyOpen] = useQueryState(
'add-key',
parseAsBoolean.withDefault(false),
);
const open = isAddKeyOpen && !!accountId;
const [phase, setPhase] = useState<Phase>('form');
const [
createdKey,
setCreatedKey,
] = useState<ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO | null>(null);
const [hasCopied, setHasCopied] = useState(false);
const {
control,
register,
handleSubmit,
reset,
watch,
formState: { isValid },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: { keyName: '', expiryMode: 'none', expiryDate: null },
});
const expiryMode = watch('expiryMode');
const expiryDate = watch('expiryDate');
useEffect(() => {
if (open) {
setPhase('form');
setCreatedKey(null);
setHasCopied(false);
reset();
}
}, [open, reset]);
const {
mutate: createKey,
isLoading: isSubmitting,
} = useCreateServiceAccountKey({
mutation: {
onSuccess: (response) => {
const keyData = response?.data;
if (keyData) {
setCreatedKey(keyData);
setPhase('created');
if (accountId) {
void invalidateListServiceAccountKeys(queryClient, { id: accountId });
}
}
},
onError: (error) => {
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to create key';
toast.error(errMessage, { richColors: true });
},
},
});
function handleCreate({
keyName,
expiryMode: mode,
expiryDate: date,
}: FormValues): void {
if (!accountId) {
return;
}
const expiresAt = mode === 'date' && date ? date.endOf('day').unix() : 0;
createKey({
pathParams: { id: accountId },
data: { name: keyName.trim(), expiresAt },
});
}
const handleCopy = useCallback(async (): Promise<void> => {
if (!createdKey?.key) {
return;
}
try {
await navigator.clipboard.writeText(createdKey.key);
setHasCopied(true);
setTimeout(() => setHasCopied(false), 2000);
toast.success('Key copied to clipboard', { richColors: true });
} catch {
toast.error('Failed to copy key', { richColors: true });
}
}, [createdKey]);
const handleClose = useCallback((): void => {
setIsAddKeyOpen(null);
}, [setIsAddKeyOpen]);
const expiryLabel = (): string => {
if (expiryMode === 'none' || !expiryDate) {
return 'Never';
}
try {
return expiryDate.format('MMM D, YYYY');
} catch {
return 'Never';
}
};
const title = phase === 'form' ? 'Add a New Key' : 'Key Created Successfully';
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
title={title}
width="base"
className="add-key-modal"
showCloseButton
disableOutsideClick={false}
>
{phase === 'form' && (
<>
<div className="add-key-modal__form">
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="key-name">
Name <span style={{ color: 'var(--destructive)' }}>*</span>
</label>
<Input
id="key-name"
placeholder="Enter key name e.g.: Service Owner"
className="add-key-modal__input"
{...register('keyName', {
required: true,
validate: (v) => !!v.trim(),
})}
/>
</div>
<div className="add-key-modal__field">
<span className="add-key-modal__label">Expiration</span>
<Controller
name="expiryMode"
control={control}
render={({ field }): JSX.Element => (
<ToggleGroup
type="single"
value={field.value}
onValueChange={(val): void => {
if (val) {
field.onChange(val);
}
}}
className="add-key-modal__expiry-toggle"
>
<ToggleGroupItem
value="none"
className="add-key-modal__expiry-toggle-btn"
>
No Expiration
</ToggleGroupItem>
<ToggleGroupItem
value="date"
className="add-key-modal__expiry-toggle-btn"
>
Set Expiration Date
</ToggleGroupItem>
</ToggleGroup>
)}
/>
</div>
{expiryMode === 'date' && (
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="expiry-date">
Expiration Date
</label>
<div className="add-key-modal__datepicker">
<Controller
name="expiryDate"
control={control}
render={({ field }): JSX.Element => (
<DatePicker
id="expiry-date"
value={field.value}
onChange={field.onChange}
popupClassName="add-key-modal-datepicker-popup"
getPopupContainer={popupContainer}
disabledDate={disabledDate}
/>
)}
/>
</div>
</div>
)}
</div>
<div className="add-key-modal__footer">
<div className="add-key-modal__footer-right">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={handleClose}
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
loading={isSubmitting}
disabled={!isValid}
onClick={handleSubmit(handleCreate)}
>
Create Key
</Button>
</div>
</div>
</>
)}
{phase === 'created' && createdKey && (
<>
<div className="add-key-modal__form">
<div className="add-key-modal__field">
<span className="add-key-modal__label">Key</span>
<div className="add-key-modal__key-display">
<span className="add-key-modal__key-text">{createdKey.key}</span>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={handleCopy}
className="add-key-modal__copy-btn"
>
{hasCopied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</div>
</div>
<div className="add-key-modal__expiry-meta">
<span className="add-key-modal__expiry-label">Expiration</span>
<Badge color="vanilla">{expiryLabel()}</Badge>
</div>
<Callout
type="info"
showIcon
message="Store the key securely. This is the only time it will be displayed."
/>
</div>
</>
)}
</DialogWrapper>
);
}
export default AddKeyModal;

View File

@@ -1,108 +0,0 @@
import { useQueryClient } from 'react-query';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { PowerOff, X } from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getGetServiceAccountQueryKey,
invalidateListServiceAccounts,
useUpdateServiceAccountStatus,
} from 'api/generated/services/serviceaccount';
import type {
RenderErrorResponseDTO,
ServiceaccounttypesServiceAccountDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { parseAsBoolean, useQueryState } from 'nuqs';
function DisableAccountModal(): JSX.Element {
const queryClient = useQueryClient();
const [accountId, setAccountId] = useQueryState('account');
const [isDisableOpen, setIsDisableOpen] = useQueryState(
'disable-sa',
parseAsBoolean.withDefault(false),
);
const open = !!isDisableOpen && !!accountId;
const cachedAccount = accountId
? queryClient.getQueryData<{
data: ServiceaccounttypesServiceAccountDTO;
}>(getGetServiceAccountQueryKey({ id: accountId }))
: null;
const accountName = cachedAccount?.data?.name;
const {
mutate: updateStatus,
isLoading: isDisabling,
} = useUpdateServiceAccountStatus({
mutation: {
onSuccess: () => {
toast.success('Service account disabled', { richColors: true });
void setIsDisableOpen(null);
void setAccountId(null);
void invalidateListServiceAccounts(queryClient);
},
onError: (error) => {
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to disable service account';
toast.error(errMessage, { richColors: true });
},
},
});
function handleConfirm(): void {
if (!accountId) {
return;
}
updateStatus({
pathParams: { id: accountId },
data: { status: 'DISABLED' },
});
}
function handleCancel(): void {
void setIsDisableOpen(null);
}
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleCancel();
}
}}
title={`Disable service account ${accountName ?? ''}?`}
width="narrow"
className="alert-dialog sa-disable-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="sa-disable-dialog__body">
Disabling this service account will revoke access for all its keys. Any
systems using this account will lose access immediately.
</p>
<DialogFooter className="sa-disable-dialog__footer">
<Button variant="solid" color="secondary" size="sm" onClick={handleCancel}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
loading={isDisabling}
onClick={handleConfirm}
>
<PowerOff size={12} />
Disable
</Button>
</DialogFooter>
</DialogWrapper>
);
}
export default DisableAccountModal;

View File

@@ -1,188 +0,0 @@
.edit-key-modal {
[data-slot='dialog-description'] {
padding: 0;
}
&__form {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4);
}
&__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
&__label {
font-size: 13px;
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
&__key-display {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--border);
cursor: not-allowed;
opacity: 0.8;
}
&__key-text {
font-size: 13px;
font-family: monospace;
color: var(--foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
letter-spacing: 2px;
}
&__lock-icon {
color: var(--foreground);
flex-shrink: 0;
margin-left: 6px;
opacity: 0.6;
}
&__expiry-toggle {
width: 60%;
display: flex;
border: 1px solid var(--border);
border-radius: 2px;
overflow: hidden;
padding: 0;
gap: 0;
[data-slot='toggle-group'] {
width: 100%;
display: flex;
}
&-btn {
flex: 1;
height: 32px;
border-radius: 0;
font-size: var(--label-small-400-font-size);
font-weight: var(--label-small-400-font-weight);
line-height: var(--label-small-400-line-height);
justify-content: center;
background: transparent;
border: none;
border-right: 1px solid var(--border);
color: var(--foreground);
white-space: nowrap;
&:last-child {
border-right: none;
}
&[data-state='on'] {
background: var(--l2-background);
color: var(--l1-foreground);
}
}
}
&__datepicker {
width: 100%;
height: 32px;
.ant-picker {
background: var(--l2-background);
border-color: var(--border);
border-radius: 2px;
width: 100%;
height: 32px;
input {
color: var(--l1-foreground);
font-size: 13px;
}
.ant-picker-suffix {
color: var(--foreground);
}
}
.edit-key-modal-datepicker-popup {
border-radius: 4px;
border: 1px solid var(--secondary);
background: var(--popover);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
}
}
&__meta {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
&__meta-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
letter-spacing: 0.48px;
text-transform: uppercase;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--padding-4);
border-top: 1px solid var(--secondary);
}
&__footer-right {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
&__footer-danger {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: 0;
background: transparent;
border: none;
cursor: pointer;
color: var(--destructive);
font-size: var(--label-small-400-font-size);
font-weight: var(--label-small-400-font-weight);
transition: opacity 0.15s ease;
&:hover {
opacity: 0.8;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}

View File

@@ -1,314 +0,0 @@
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useQueryClient } from 'react-query';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
import { DialogWrapper } from '@signozhq/dialog';
import { LockKeyhole, Trash2, X } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { DatePicker } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
invalidateListServiceAccountKeys,
useRevokeServiceAccountKey,
useUpdateServiceAccountKey,
} from 'api/generated/services/serviceaccount';
import type {
RenderErrorResponseDTO,
ServiceaccounttypesFactorAPIKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { parseAsString, useQueryState } from 'nuqs';
import { useTimezone } from 'providers/Timezone';
import { popupContainer } from 'utils/selectPopupContainer';
import { RevokeKeyContent } from './RevokeKeyModal';
import { disabledDate, formatLastObservedAt } from './utils';
import './EditKeyModal.styles.scss';
type ExpiryMode = 'none' | 'date';
interface FormValues {
name: string;
expiryMode: ExpiryMode;
expiresAt: Dayjs | null;
}
interface EditKeyModalProps {
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null;
}
function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
const queryClient = useQueryClient();
const [selectedAccountId] = useQueryState('account');
const [editKeyId, setEditKeyId] = useQueryState(
'edit-key',
parseAsString.withDefault(''),
);
const open = !!editKeyId && !!selectedAccountId;
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const [isRevokeConfirmOpen, setIsRevokeConfirmOpen] = useState(false);
const {
register,
control,
reset,
watch,
formState: { isDirty },
handleSubmit,
} = useForm<FormValues>({
defaultValues: { name: '', expiryMode: 'none', expiresAt: null },
});
useEffect(() => {
if (keyItem) {
reset({
name: keyItem.name ?? '',
expiryMode: keyItem.expiresAt === 0 ? 'none' : 'date',
expiresAt: keyItem.expiresAt === 0 ? null : dayjs.unix(keyItem.expiresAt),
});
}
}, [keyItem?.id, reset]); // eslint-disable-line react-hooks/exhaustive-deps
const expiryMode = watch('expiryMode');
const { mutate: updateKey, isLoading: isSaving } = useUpdateServiceAccountKey({
mutation: {
onSuccess: () => {
toast.success('Key updated successfully', { richColors: true });
void setEditKeyId(null);
if (selectedAccountId) {
void invalidateListServiceAccountKeys(queryClient, {
id: selectedAccountId,
});
}
},
onError: (error) => {
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to update key';
toast.error(errMessage, { richColors: true });
},
},
});
const {
mutate: revokeKey,
isLoading: isRevoking,
} = useRevokeServiceAccountKey({
mutation: {
onSuccess: () => {
toast.success('Key revoked successfully', { richColors: true });
setIsRevokeConfirmOpen(false);
void setEditKeyId(null);
if (selectedAccountId) {
void invalidateListServiceAccountKeys(queryClient, {
id: selectedAccountId,
});
}
},
onError: (error) => {
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to revoke key';
toast.error(errMessage, { richColors: true });
},
},
});
function handleClose(): void {
void setEditKeyId(null);
setIsRevokeConfirmOpen(false);
}
const onSubmit = handleSubmit(
({ name, expiryMode: mode, expiresAt }): void => {
if (!keyItem || !selectedAccountId) {
return;
}
const currentExpiresAt =
mode === 'none' || !expiresAt ? 0 : expiresAt.endOf('day').unix();
updateKey({
pathParams: { id: selectedAccountId, fid: keyItem.id },
data: { name, expiresAt: currentExpiresAt },
});
},
);
function handleRevoke(): void {
if (!keyItem || !selectedAccountId) {
return;
}
revokeKey({ pathParams: { id: selectedAccountId, fid: keyItem.id } });
}
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
if (isRevokeConfirmOpen) {
setIsRevokeConfirmOpen(false);
} else {
handleClose();
}
}
}}
title={
isRevokeConfirmOpen
? `Revoke ${keyItem?.name ?? 'key'}?`
: 'Edit Key Details'
}
width={isRevokeConfirmOpen ? 'narrow' : 'base'}
className={
isRevokeConfirmOpen ? 'alert-dialog delete-dialog' : 'edit-key-modal'
}
showCloseButton={!isRevokeConfirmOpen}
disableOutsideClick={false}
>
{isRevokeConfirmOpen ? (
<RevokeKeyContent
isRevoking={isRevoking}
onCancel={(): void => setIsRevokeConfirmOpen(false)}
onConfirm={handleRevoke}
/>
) : (
<>
<div className="edit-key-modal__form">
<div className="edit-key-modal__field">
<label className="edit-key-modal__label" htmlFor="edit-key-name">
Name
</label>
<Input
id="edit-key-name"
className="edit-key-modal__input"
placeholder="Enter key name"
{...register('name')}
/>
</div>
<div className="edit-key-modal__field">
<label className="edit-key-modal__label" htmlFor="edit-key-display">
Key
</label>
<div id="edit-key-display" className="edit-key-modal__key-display">
<span className="edit-key-modal__key-text">********************</span>
<LockKeyhole size={12} className="edit-key-modal__lock-icon" />
</div>
</div>
<div className="edit-key-modal__field">
<span className="edit-key-modal__label">Expiration</span>
<Controller
name="expiryMode"
control={control}
render={({ field }): JSX.Element => (
<ToggleGroup
type="single"
value={field.value}
onValueChange={(val): void => {
if (val) {
field.onChange(val);
}
}}
className="edit-key-modal__expiry-toggle"
>
<ToggleGroupItem
value="none"
className="edit-key-modal__expiry-toggle-btn"
>
No Expiration
</ToggleGroupItem>
<ToggleGroupItem
value="date"
className="edit-key-modal__expiry-toggle-btn"
>
Set Expiration Date
</ToggleGroupItem>
</ToggleGroup>
)}
/>
</div>
{expiryMode === 'date' && (
<div className="edit-key-modal__field">
<label className="edit-key-modal__label" htmlFor="edit-key-datepicker">
Expiration Date
</label>
<div className="edit-key-modal__datepicker">
<Controller
name="expiresAt"
control={control}
render={({ field }): JSX.Element => (
<DatePicker
value={field.value}
id="edit-key-datepicker"
onChange={field.onChange}
popupClassName="edit-key-modal-datepicker-popup"
getPopupContainer={popupContainer}
disabledDate={disabledDate}
/>
)}
/>
</div>
</div>
)}
<div className="edit-key-modal__meta">
<span className="edit-key-modal__meta-label">Last Observed At</span>
<Badge color="vanilla">
{formatLastObservedAt(
keyItem?.lastObservedAt ?? null,
formatTimezoneAdjustedTimestamp,
)}
</Badge>
</div>
</div>
<div className="edit-key-modal__footer">
<Button
type="button"
className="edit-key-modal__footer-danger"
onClick={(): void => setIsRevokeConfirmOpen(true)}
>
<Trash2 size={12} />
Revoke Key
</Button>
<div className="edit-key-modal__footer-right">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={handleClose}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
loading={isSaving}
disabled={!isDirty}
onClick={onSubmit}
>
Save Changes
</Button>
</div>
</div>
</>
)}
</DialogWrapper>
);
}
export default EditKeyModal;

View File

@@ -1,238 +0,0 @@
import { useCallback, useMemo } from 'react';
import { Button } from '@signozhq/button';
import { X } from '@signozhq/icons';
import { Skeleton, Table, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import dayjs from 'dayjs';
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs';
import { useTimezone } from 'providers/Timezone';
import EditKeyModal from './EditKeyModal';
import RevokeKeyModal from './RevokeKeyModal';
import { formatLastObservedAt } from './utils';
interface KeysTabProps {
keys: ServiceaccounttypesFactorAPIKeyDTO[];
isLoading: boolean;
isDisabled?: boolean;
currentPage: number;
pageSize: number;
}
interface BuildColumnsParams {
isDisabled: boolean;
onRevokeClick: (keyId: string) => void;
handleformatLastObservedAt: (
lastObservedAt: Date | null | undefined,
) => string;
}
function formatExpiry(expiresAt: number): JSX.Element {
if (expiresAt === 0) {
return <span className="keys-tab__expiry--never">Never</span>;
}
const expiryDate = dayjs.unix(expiresAt);
if (expiryDate.isBefore(dayjs())) {
return <span className="keys-tab__expiry--expired">Expired</span>;
}
return <span>{expiryDate.format('MMM D, YYYY')}</span>;
}
function buildColumns({
isDisabled,
onRevokeClick,
handleformatLastObservedAt,
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesFactorAPIKeyDTO> {
return [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
className: 'keys-tab__name-column',
sorter: (a, b): number => (a.name ?? '').localeCompare(b.name ?? ''),
render: (_, record): JSX.Element => (
<span className="keys-tab__name-text">{record.name ?? '—'}</span>
),
},
{
title: 'Expiry',
dataIndex: 'expiresAt',
key: 'expiry',
width: 160,
align: 'right' as const,
sorter: (a, b): number => {
const aVal = a.expiresAt === 0 ? Infinity : a.expiresAt;
const bVal = b.expiresAt === 0 ? Infinity : b.expiresAt;
return aVal - bVal;
},
render: (expiresAt: number): JSX.Element => formatExpiry(expiresAt),
},
{
title: 'Last Observed At',
dataIndex: 'lastObservedAt',
key: 'lastObservedAt',
width: 220,
align: 'right' as const,
sorter: (a, b): number => {
const aVal = a.lastObservedAt
? new Date(a.lastObservedAt).getTime()
: -Infinity;
const bVal = b.lastObservedAt
? new Date(b.lastObservedAt).getTime()
: -Infinity;
return aVal - bVal;
},
render: (lastObservedAt: Date | null | undefined): string =>
handleformatLastObservedAt(lastObservedAt),
},
{
title: '',
key: 'action',
width: 48,
align: 'right' as const,
render: (_, record): JSX.Element => (
<Tooltip title={isDisabled ? 'Service account disabled' : 'Revoke Key'}>
<Button
variant="ghost"
size="xs"
color="destructive"
disabled={isDisabled}
onClick={(e): void => {
e.stopPropagation();
onRevokeClick(record.id);
}}
className="keys-tab__revoke-btn"
>
<X size={12} />
</Button>
</Tooltip>
),
},
];
}
function KeysTab({
keys,
isLoading,
isDisabled = false,
currentPage,
pageSize,
}: KeysTabProps): JSX.Element {
const [, setIsAddKeyOpen] = useQueryState(
'add-key',
parseAsBoolean.withDefault(false),
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const [editKeyId, setEditKeyId] = useQueryState(
'edit-key',
parseAsString.withDefault(''),
);
const [, setRevokeKeyId] = useQueryState(
'revoke-key',
parseAsString.withDefault(''),
);
const editKey = keys.find((k) => k.id === editKeyId) ?? null;
const handleformatLastObservedAt = useCallback(
(lastObservedAt: Date | null | undefined): string =>
formatLastObservedAt(lastObservedAt, formatTimezoneAdjustedTimestamp),
[formatTimezoneAdjustedTimestamp],
);
const onRevokeClick = useCallback(
(keyId: string): void => {
setRevokeKeyId(keyId);
},
[setRevokeKeyId],
);
const columns = useMemo(
() => buildColumns({ isDisabled, onRevokeClick, handleformatLastObservedAt }),
[isDisabled, onRevokeClick, handleformatLastObservedAt],
);
if (isLoading) {
return (
<div className="keys-tab__loading">
<Skeleton active paragraph={{ rows: 4 }} />
</div>
);
}
if (keys.length === 0) {
return (
<div className="keys-tab__empty">
<span className="keys-tab__empty-emoji" role="img" aria-label="searching">
🧐
</span>
<p className="keys-tab__empty-text">No keys. Start by creating one.</p>
<Button
type="button"
className="keys-tab__learn-more"
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
disabled={isDisabled}
>
+ Add your first key
</Button>
</div>
);
}
return (
<>
{/* Todo: use new table component from periscope when ready */}
<Table<ServiceaccounttypesFactorAPIKeyDTO>
columns={columns}
dataSource={keys}
rowKey="id"
pagination={{
style: { display: 'none' },
current: currentPage,
pageSize,
}}
showSorterTooltip={false}
className={`keys-tab__table${
isDisabled ? ' keys-tab__table--disabled' : ''
}`}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'keys-tab__table-row--alt' : ''
}
onRow={(
record,
): {
onClick: () => void;
onKeyDown: (e: React.KeyboardEvent) => void;
role: string;
tabIndex: number;
'aria-label': string;
} => ({
onClick: (): void => {
if (!isDisabled) {
void setEditKeyId(record.id);
}
},
onKeyDown: (e: React.KeyboardEvent): void => {
if ((e.key === 'Enter' || e.key === ' ') && !isDisabled) {
if (e.key === ' ') {
e.preventDefault();
}
void setEditKeyId(record.id);
}
},
role: 'button',
tabIndex: 0,
'aria-label': `Edit key ${record.name || 'options'}`,
})}
/>
<EditKeyModal keyItem={editKey} />
<RevokeKeyModal />
</>
);
}
export default KeysTab;

View File

@@ -1,153 +0,0 @@
import { useCallback } from 'react';
import { Badge } from '@signozhq/badge';
import { LockKeyhole } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import RolesSelect from 'components/RolesSelect';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
interface OverviewTabProps {
account: ServiceAccountRow;
localName: string;
onNameChange: (v: string) => void;
localRoles: string[];
onRolesChange: (v: string[]) => void;
isDisabled: boolean;
availableRoles: AuthtypesRoleDTO[];
rolesLoading?: boolean;
rolesError?: boolean;
rolesErrorObj?: APIError | undefined;
onRefetchRoles?: () => void;
}
function OverviewTab({
account,
localName,
onNameChange,
localRoles,
onRolesChange,
isDisabled,
availableRoles,
rolesLoading,
rolesError,
rolesErrorObj,
onRefetchRoles,
}: OverviewTabProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
if (!ts) {
return '—';
}
const d = new Date(ts);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
},
[formatTimezoneAdjustedTimestamp],
);
return (
<>
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-name">
Name
</label>
{isDisabled ? (
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{localName || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
) : (
<Input
id="sa-name"
value={localName}
onChange={(e): void => onNameChange(e.target.value)}
className="sa-drawer__input"
placeholder="Enter name"
/>
)}
</div>
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-email">
Email Address
</label>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{account.email || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</div>
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-roles">
Roles
</label>
{isDisabled ? (
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<div className="sa-drawer__disabled-roles">
{localRoles.length > 0 ? (
localRoles.map((r) => (
<Badge key={r} color="vanilla">
{r}
</Badge>
))
) : (
<span className="sa-drawer__input-text"></span>
)}
</div>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
) : (
<RolesSelect
id="sa-roles"
mode="multiple"
roles={availableRoles}
loading={rolesLoading}
isError={rolesError}
error={rolesErrorObj}
onRefetch={onRefetchRoles}
value={localRoles}
onChange={onRolesChange}
placeholder="Select roles"
getPopupContainer={(triggerNode): HTMLElement =>
(triggerNode?.closest('.sa-drawer') as HTMLElement) || document.body
}
/>
)}
</div>
<div className="sa-drawer__meta">
<div className="sa-drawer__meta-item">
<span className="sa-drawer__meta-label">Status</span>
{account.status?.toUpperCase() === 'ACTIVE' ? (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : (
<Badge color="vanilla" variant="outline" className="sa-status-badge">
DISABLED
</Badge>
)}
</div>
<div className="sa-drawer__meta-item">
<span className="sa-drawer__meta-label">Created At</span>
<Badge color="vanilla">{formatTimestamp(account.createdAt)}</Badge>
</div>
<div className="sa-drawer__meta-item">
<span className="sa-drawer__meta-label">Updated At</span>
<Badge color="vanilla">{formatTimestamp(account.updatedAt)}</Badge>
</div>
</div>
</>
);
}
export default OverviewTab;

View File

@@ -1,128 +0,0 @@
import { useQueryClient } from 'react-query';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { Trash2, X } from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getListServiceAccountKeysQueryKey,
invalidateListServiceAccountKeys,
useRevokeServiceAccountKey,
} from 'api/generated/services/serviceaccount';
import type {
RenderErrorResponseDTO,
ServiceaccounttypesFactorAPIKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { parseAsString, useQueryState } from 'nuqs';
export interface RevokeKeyContentProps {
isRevoking: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export function RevokeKeyContent({
isRevoking,
onCancel,
onConfirm,
}: RevokeKeyContentProps): JSX.Element {
return (
<>
<p className="delete-dialog__body">
Revoking this key will permanently invalidate it. Any systems using this key
will lose access immediately.
</p>
<DialogFooter className="delete-dialog__footer">
<Button variant="solid" color="secondary" size="sm" onClick={onCancel}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
loading={isRevoking}
onClick={onConfirm}
>
<Trash2 size={12} />
Revoke Key
</Button>
</DialogFooter>
</>
);
}
function RevokeKeyModal(): JSX.Element {
const queryClient = useQueryClient();
const [accountId] = useQueryState('account');
const [revokeKeyId, setRevokeKeyId] = useQueryState(
'revoke-key',
parseAsString.withDefault(''),
);
const open = !!revokeKeyId && !!accountId;
const cachedKeys = accountId
? queryClient.getQueryData<{ data: ServiceaccounttypesFactorAPIKeyDTO[] }>(
getListServiceAccountKeysQueryKey({ id: accountId }),
)
: null;
const keyName = cachedKeys?.data?.find((k) => k.id === revokeKeyId)?.name;
const {
mutate: revokeKey,
isLoading: isRevoking,
} = useRevokeServiceAccountKey({
mutation: {
onSuccess: () => {
toast.success('Key revoked successfully', { richColors: true });
void setRevokeKeyId(null);
if (accountId) {
void invalidateListServiceAccountKeys(queryClient, { id: accountId });
}
},
onError: (error) => {
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to revoke key';
toast.error(errMessage, { richColors: true });
},
},
});
function handleConfirm(): void {
if (!revokeKeyId || !accountId) {
return;
}
revokeKey({ pathParams: { id: accountId, fid: revokeKeyId } });
}
function handleCancel(): void {
void setRevokeKeyId(null);
}
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleCancel();
}
}}
title={`Revoke ${keyName ?? 'key'}?`}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<RevokeKeyContent
isRevoking={isRevoking}
onCancel={handleCancel}
onConfirm={handleConfirm}
/>
</DialogWrapper>
);
}
export default RevokeKeyModal;

View File

@@ -1,460 +0,0 @@
.sa-drawer {
[data-slot='drawer-close'] + div {
border-left: 1px solid var(--l1-border);
padding-left: var(--padding-4);
margin-left: var(--margin-2);
}
&__layout {
display: flex;
flex-direction: column;
height: calc(100vh - 48px);
}
&__tabs {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--padding-3) var(--padding-4) var(--padding-2) var(--padding-4);
flex-shrink: 0;
}
&__tab-group {
[data-slot='toggle-group'] {
height: 32px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
gap: 0;
}
[data-slot='toggle-group-item'] {
height: 32px;
border-radius: 0;
border-left: 1px solid var(--l1-border);
background: transparent;
color: var(--l2-foreground);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-normal);
font-family: Inter, sans-serif;
padding: 0 var(--padding-7);
gap: var(--spacing-3);
box-shadow: none;
&:first-child {
border-left: none;
border-radius: 2px 0 0 2px;
}
&:last-child {
border-radius: 0 2px 2px 0;
}
&:hover {
background: rgba(171, 189, 255, 0.04);
color: var(--l1-foreground);
}
&[data-state='on'] {
background: var(--l1-border);
color: var(--l1-foreground);
}
}
}
&__tab {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-normal);
}
&__tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
padding: 0 6px;
border-radius: 50px;
background: var(--secondary);
font-size: var(--code-small-400-font-size);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
color: var(--foreground);
letter-spacing: -0.06px;
}
&__body {
flex: 1;
overflow-y: auto;
padding: var(--padding-5) var(--padding-4);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
&__footer {
height: 56px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--padding-4);
border-top: 1px solid var(--secondary);
background: var(--card);
}
&__keys-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
width: 100%;
padding: var(--padding-2) 0;
.ant-pagination-total-text {
margin-right: auto;
}
}
&__pagination-range {
font-size: var(--font-size-xs);
color: var(--foreground);
font-weight: var(--font-weight-normal);
}
&__pagination-total {
font-size: var(--font-size-xs);
color: var(--foreground);
opacity: 0.5;
}
&__footer-btn {
padding-left: 0;
padding-right: 0;
}
&__footer-right {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
&__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
&__label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
cursor: default;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
&__input-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--border);
&--disabled {
cursor: not-allowed;
opacity: 0.8;
}
}
&__input-text {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
&__lock-icon {
color: var(--foreground);
flex-shrink: 0;
margin-left: 6px;
opacity: 0.6;
}
&__disabled-roles {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
}
&__meta {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
margin-top: var(--margin-1);
}
&__meta-item {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
[data-slot='badge'] {
padding: var(--padding-1) var(--padding-2);
align-items: center;
font-size: var(--uppercase-small-500-font-size);
font-weight: var(--uppercase-small-500-font-weight);
line-height: 100%;
letter-spacing: 0.44px;
text-transform: uppercase;
}
}
&__meta-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: 0.48px;
text-transform: uppercase;
}
}
.keys-tab {
&__loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--padding-8) var(--padding-4);
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--padding-8) var(--padding-4);
gap: var(--spacing-4);
text-align: center;
height: 80%;
}
&__empty-emoji {
font-size: 32px;
}
&__empty-text {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
margin: 0;
}
&__learn-more {
background: transparent;
border: none;
color: var(--primary);
font-size: var(--font-size-sm);
cursor: pointer;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
// todo: styles should easeup by upgrading the component with table component from persiscope
&__table {
.ant-table {
background: transparent;
border: 1px solid var(--l1-border);
border-radius: 4px;
overflow: hidden;
}
.ant-table-thead > tr > th,
.ant-table-thead > tr > td {
height: 38px;
padding: 0 var(--padding-4);
background: transparent !important;
border-bottom: 1px solid var(--l1-border) !important;
font-size: var(--uppercase-small-500-font-size);
font-weight: var(--uppercase-small-500-font-weight);
color: var(--l2-foreground);
letter-spacing: 0.44px;
text-transform: uppercase;
&::before {
display: none !important;
}
.ant-table-column-sorter {
color: var(--l2-foreground);
opacity: 0.5;
}
&.ant-table-column-sort {
background: transparent !important;
color: var(--l1-foreground);
.ant-table-column-sorter {
color: var(--l1-foreground);
opacity: 1;
}
}
&:hover {
background: transparent !important;
color: var(--l1-foreground);
.ant-table-column-sorter {
opacity: 1;
}
}
}
.ant-table-tbody > tr > td {
height: 38px;
padding: 0 var(--padding-4);
border-bottom: 1px solid var(--l1-border);
font-size: 13px;
font-weight: var(--font-weight-normal);
color: var(--l2-foreground);
letter-spacing: -0.07px;
cursor: pointer;
background: transparent;
transition: none;
font-variant-numeric: lining-nums tabular-nums stacked-fractions ordinal
slashed-zero;
&.ant-table-column-sort {
background: transparent;
}
}
.ant-table-tbody > tr:last-child > td {
border-bottom: none;
}
.ant-table-tbody > tr {
background: transparent;
&:hover > td {
background: rgba(171, 189, 255, 0.06) !important;
}
&.keys-tab__table-row--alt > td {
background: rgba(171, 189, 255, 0.02);
&:hover {
background: rgba(171, 189, 255, 0.06) !important;
}
}
}
&--disabled {
.ant-table-tbody > tr {
cursor: not-allowed;
opacity: 0.6;
&:hover > td {
background: transparent !important;
}
}
}
.ant-table-cell-row-hover {
background: transparent !important;
}
}
&__name-column {
.ant-table-column-sorters {
justify-content: flex-start;
gap: var(--spacing-2);
}
.ant-table-column-title {
flex: none;
}
}
&__name-text {
font-size: 13px;
font-weight: var(--font-weight-normal);
color: var(--l2-foreground);
letter-spacing: -0.07px;
text-transform: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__expiry--never {
color: var(--l2-foreground);
}
&__expiry--expired {
color: var(--l3-foreground);
}
&__revoke-btn {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
}
.sa-disable-dialog {
background: var(--l2-background);
border: 1px solid var(--l2-border);
[data-slot='dialog-title'] {
color: var(--l1-foreground);
}
&__body {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--l2-foreground);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.065px;
margin: 0;
strong {
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
}
&__footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-4);
margin-top: var(--margin-6);
}
}

View File

@@ -1,366 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from '@signozhq/button';
import { DrawerWrapper } from '@signozhq/drawer';
import { Key, LayoutGrid, Plus, PowerOff, X } from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Pagination, Skeleton } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
useGetServiceAccount,
useListServiceAccountKeys,
useUpdateServiceAccount,
} from 'api/generated/services/serviceaccount';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { useRoles } from 'components/RolesSelect';
import {
ServiceAccountRow,
toServiceAccountRow,
} from 'container/ServiceAccountsSettings/utils';
import {
parseAsBoolean,
parseAsInteger,
parseAsString,
parseAsStringEnum,
useQueryState,
} from 'nuqs';
import { toAPIError } from 'utils/errorUtils';
import AddKeyModal from './AddKeyModal';
import DisableAccountModal from './DisableAccountModal';
import KeysTab from './KeysTab';
import OverviewTab from './OverviewTab';
import { ServiceAccountDrawerTab } from './utils';
import './ServiceAccountDrawer.styles.scss';
export interface ServiceAccountDrawerProps {
onSuccess: (options?: { closeDrawer?: boolean }) => void;
}
const PAGE_SIZE = 15;
// eslint-disable-next-line sonarjs/cognitive-complexity
function ServiceAccountDrawer({
onSuccess,
}: ServiceAccountDrawerProps): JSX.Element {
const [selectedAccountId, setSelectedAccountId] = useQueryState('account');
const open = !!selectedAccountId;
const [activeTab, setActiveTab] = useQueryState(
'tab',
parseAsStringEnum<ServiceAccountDrawerTab>(
Object.values(ServiceAccountDrawerTab),
).withDefault(ServiceAccountDrawerTab.Overview),
);
const [keysPage, setKeysPage] = useQueryState(
'keysPage',
parseAsInteger.withDefault(1),
);
const [, setEditKeyId] = useQueryState(
'edit-key',
parseAsString.withDefault(''),
);
const [, setIsAddKeyOpen] = useQueryState(
'add-key',
parseAsBoolean.withDefault(false),
);
const [, setIsDisableOpen] = useQueryState(
'disable-sa',
parseAsBoolean.withDefault(false),
);
const [localName, setLocalName] = useState('');
const [localRoles, setLocalRoles] = useState<string[]>([]);
const {
data: accountData,
isLoading: isAccountLoading,
isError: isAccountError,
error: accountError,
refetch: refetchAccount,
} = useGetServiceAccount(
{ id: selectedAccountId ?? '' },
{ query: { enabled: !!selectedAccountId } },
);
const account = useMemo(
(): ServiceAccountRow | null =>
accountData?.data ? toServiceAccountRow(accountData.data) : null,
[accountData],
);
useEffect(() => {
if (account) {
setLocalName(account.name ?? '');
setLocalRoles(account.roles ?? []);
setKeysPage(1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [account?.id]);
const isDisabled = account?.status?.toUpperCase() !== 'ACTIVE';
const isDirty =
account !== null &&
(localName !== (account.name ?? '') ||
JSON.stringify(localRoles) !== JSON.stringify(account.roles ?? []));
const {
roles: availableRoles,
isLoading: rolesLoading,
isError: rolesError,
error: rolesErrorObj,
refetch: refetchRoles,
} = useRoles();
const { data: keysData, isLoading: keysLoading } = useListServiceAccountKeys(
{ id: selectedAccountId ?? '' },
{ query: { enabled: !!selectedAccountId } },
);
const keys = keysData?.data ?? [];
useEffect(() => {
if (keysLoading) {
return;
}
const maxPage = Math.max(1, Math.ceil(keys.length / PAGE_SIZE));
if (keysPage > maxPage) {
setKeysPage(maxPage);
}
}, [keysLoading, keys.length, keysPage, setKeysPage]);
const { mutate: updateAccount, isLoading: isSaving } = useUpdateServiceAccount(
{
mutation: {
onSuccess: () => {
toast.success('Service account updated successfully', {
richColors: true,
});
refetchAccount();
onSuccess({ closeDrawer: false });
},
onError: (error) => {
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to update service account';
toast.error(errMessage, { richColors: true });
},
},
},
);
function handleSave(): void {
if (!account || !isDirty) {
return;
}
updateAccount({
pathParams: { id: account.id },
data: { name: localName, email: account.email, roles: localRoles },
});
}
const handleClose = useCallback((): void => {
setIsDisableOpen(null);
setIsAddKeyOpen(null);
setSelectedAccountId(null);
setActiveTab(null);
setKeysPage(null);
setEditKeyId(null);
}, [
setSelectedAccountId,
setActiveTab,
setKeysPage,
setEditKeyId,
setIsAddKeyOpen,
setIsDisableOpen,
]);
const drawerContent = (
<div className="sa-drawer__layout">
<div className="sa-drawer__tabs">
<ToggleGroup
type="single"
value={activeTab}
onValueChange={(val): void => {
if (val) {
setActiveTab(val as ServiceAccountDrawerTab);
if (val !== ServiceAccountDrawerTab.Keys) {
setKeysPage(null);
setEditKeyId(null);
}
}
}}
className="sa-drawer__tab-group"
>
<ToggleGroupItem
value={ServiceAccountDrawerTab.Overview}
className="sa-drawer__tab"
>
<LayoutGrid size={14} />
Overview
</ToggleGroupItem>
<ToggleGroupItem
value={ServiceAccountDrawerTab.Keys}
className="sa-drawer__tab"
>
<Key size={14} />
Keys
{keys.length > 0 && (
<span className="sa-drawer__tab-count">{keys.length}</span>
)}
</ToggleGroupItem>
</ToggleGroup>
{activeTab === ServiceAccountDrawerTab.Keys && (
<Button
variant="outlined"
size="sm"
color="secondary"
disabled={isDisabled}
onClick={(): void => {
setIsAddKeyOpen(true);
}}
>
<Plus size={12} />
Add Key
</Button>
)}
</div>
<div
className={`sa-drawer__body${
activeTab === ServiceAccountDrawerTab.Keys ? ' sa-drawer__body--keys' : ''
}`}
>
{isAccountLoading && <Skeleton active paragraph={{ rows: 6 }} />}
{isAccountError && (
<ErrorInPlace
error={toAPIError(
accountError,
'An unexpected error occurred while fetching service account details.',
)}
/>
)}
{!isAccountLoading && !isAccountError && (
<>
{activeTab === ServiceAccountDrawerTab.Overview && account && (
<OverviewTab
account={account}
localName={localName}
onNameChange={setLocalName}
localRoles={localRoles}
onRolesChange={setLocalRoles}
isDisabled={isDisabled}
availableRoles={availableRoles}
rolesLoading={rolesLoading}
rolesError={rolesError}
rolesErrorObj={rolesErrorObj}
onRefetchRoles={refetchRoles}
/>
)}
{activeTab === ServiceAccountDrawerTab.Keys && (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDisabled}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
)}
</>
)}
</div>
<div className="sa-drawer__footer">
{activeTab === ServiceAccountDrawerTab.Keys ? (
<Pagination
current={keysPage}
pageSize={PAGE_SIZE}
total={keys.length}
showTotal={(total: number, range: number[]): JSX.Element => (
<>
<span className="sa-drawer__pagination-range">
{range[0]} &#8212; {range[1]}
</span>
<span className="sa-drawer__pagination-total"> of {total}</span>
</>
)}
showSizeChanger={false}
hideOnSinglePage
onChange={(page): void => {
void setKeysPage(page);
}}
className="sa-drawer__keys-pagination"
/>
) : (
<>
{!isDisabled && (
<Button
variant="ghost"
color="destructive"
className="sa-drawer__footer-btn"
onClick={(): void => {
setIsDisableOpen(true);
}}
>
<PowerOff size={12} />
Disable Service Account
</Button>
)}
{!isDisabled && (
<div className="sa-drawer__footer-right">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={handleClose}
>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
loading={isSaving}
disabled={!isDirty}
onClick={handleSave}
>
Save Changes
</Button>
</div>
)}
</>
)}
</div>
</div>
);
return (
<>
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
direction="right"
type="panel"
showCloseButton
showOverlay={false}
allowOutsideClick
header={{ title: 'Service Account Details' }}
content={drawerContent}
className="sa-drawer"
/>
<DisableAccountModal />
<AddKeyModal />
</>
);
}
export default ServiceAccountDrawer;

View File

@@ -1,139 +0,0 @@
import { toast } from '@signozhq/sonner';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import AddKeyModal from '../AddKeyModal';
jest.mock('@signozhq/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const mockToast = jest.mocked(toast);
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys';
const createdKeyResponse = {
data: {
id: 'key-1',
name: 'Deploy Key',
key: 'snz_abc123xyz456secret',
expiresAt: 0,
lastObservedAt: null,
},
};
function renderModal(): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter
searchParams={{ account: 'sa-1', 'add-key': 'true' }}
hasMemory
>
<AddKeyModal />
</NuqsTestingAdapter>,
);
}
describe('AddKeyModal', () => {
beforeAll(() => {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: jest.fn().mockResolvedValue(undefined) },
configurable: true,
writable: true,
});
});
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.post(SA_KEYS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(201), ctx.json(createdKeyResponse)),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('"Create Key" is disabled when name is empty; enabled after typing a name', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
expect(screen.getByRole('button', { name: /Create Key/i })).toBeDisabled();
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'My Key');
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Key/i }),
).not.toBeDisabled(),
);
});
it('successful creation transitions to phase 2 with key displayed and security callout', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Key/i }),
).not.toBeDisabled(),
);
await user.click(screen.getByRole('button', { name: /Create Key/i }));
await screen.findByText('snz_abc123xyz456secret');
expect(screen.getByText(/Store the key securely/i)).toBeInTheDocument();
await screen.findByRole('dialog', { name: /Key Created Successfully/i });
});
it('copy button writes key to clipboard and shows toast.success', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const writeTextSpy = jest
.spyOn(navigator.clipboard, 'writeText')
.mockResolvedValue(undefined);
renderModal();
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Key/i }),
).not.toBeDisabled(),
);
await user.click(screen.getByRole('button', { name: /Create Key/i }));
await screen.findByText('snz_abc123xyz456secret');
const copyBtn = screen
.getAllByRole('button')
.find((btn) => btn.querySelector('svg'));
if (!copyBtn) {
throw new Error('Copy button not found');
}
await user.click(copyBtn);
await waitFor(() => {
expect(writeTextSpy).toHaveBeenCalledWith('snz_abc123xyz456secret');
expect(mockToast.success).toHaveBeenCalledWith(
'Key copied to clipboard',
expect.anything(),
);
});
writeTextSpy.mockRestore();
});
it('Cancel button closes the modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await screen.findByRole('dialog', { name: /Add a New Key/i });
await user.click(screen.getByRole('button', { name: /Cancel/i }));
expect(
screen.queryByRole('dialog', { name: /Add a New Key/i }),
).not.toBeInTheDocument();
});
});

View File

@@ -1,151 +0,0 @@
import { toast } from '@signozhq/sonner';
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import EditKeyModal from '../EditKeyModal';
jest.mock('@signozhq/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const mockToast = jest.mocked(toast);
const SA_KEY_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys/key-1';
const mockKey: ServiceaccounttypesFactorAPIKeyDTO = {
id: 'key-1',
name: 'Original Key Name',
expiresAt: 0,
lastObservedAt: null as any,
key: 'snz_abc123',
serviceAccountId: 'sa-1',
};
function renderModal(
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null = mockKey,
searchParams: Record<string, string> = {
account: 'sa-1',
'edit-key': 'key-1',
},
): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
<EditKeyModal keyItem={keyItem} />
</NuqsTestingAdapter>,
);
}
describe('EditKeyModal (URL-controlled)', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.put(SA_KEY_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_KEY_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders nothing when edit-key param is absent', () => {
renderModal(null, { account: 'sa-1' });
expect(
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
).not.toBeInTheDocument();
});
it('renders key data from prop when edit-key param is set', async () => {
renderModal();
expect(
await screen.findByDisplayValue('Original Key Name'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save Changes/i })).toBeDisabled();
});
it('save calls update API, shows toast, and closes modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const nameInput = await screen.findByPlaceholderText(/Enter key name/i);
await user.clear(nameInput);
await user.type(nameInput, 'Updated Key Name');
await user.click(screen.getByRole('button', { name: /Save Changes/i }));
await waitFor(() => {
expect(mockToast.success).toHaveBeenCalledWith(
'Key updated successfully',
expect.anything(),
);
});
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
).not.toBeInTheDocument();
});
});
it('cancel clears edit-key param and closes modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await screen.findByDisplayValue('Original Key Name');
await user.click(screen.getByRole('button', { name: /Cancel/i }));
expect(
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
).not.toBeInTheDocument();
});
it('revoke flow: clicking Revoke Key shows confirmation inside same dialog', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await screen.findByDisplayValue('Original Key Name');
await user.click(screen.getByRole('button', { name: /Revoke Key/i }));
// Same dialog, now showing revoke confirmation
expect(
await screen.findByRole('dialog', { name: /Revoke Original Key Name/i }),
).toBeInTheDocument();
expect(
screen.getByText(/Revoking this key will permanently invalidate it/i),
).toBeInTheDocument();
});
it('revoke flow: confirming revoke shows toast and closes modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await screen.findByDisplayValue('Original Key Name');
await user.click(screen.getByRole('button', { name: /Revoke Key/i }));
const confirmBtn = await screen.findByRole('button', {
name: /^Revoke Key$/i,
});
await user.click(confirmBtn);
await waitFor(() => {
expect(mockToast.success).toHaveBeenCalledWith(
'Key revoked successfully',
expect.anything(),
);
});
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
).not.toBeInTheDocument();
});
});
});

View File

@@ -1,183 +0,0 @@
import { toast } from '@signozhq/sonner';
import { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import KeysTab from '../KeysTab';
jest.mock('@signozhq/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const mockToast = jest.mocked(toast);
const SA_KEY_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys/:fid';
const keys: ServiceaccounttypesFactorAPIKeyDTO[] = [
{
id: 'key-1',
name: 'Production Key',
expiresAt: 0,
lastObservedAt: null as any,
key: 'snz_prod_123',
serviceAccountId: 'sa-1',
},
{
id: 'key-2',
name: 'Staging Key',
expiresAt: 1924905600, // 2030-12-31
lastObservedAt: new Date('2026-03-10T10:00:00Z'),
key: 'snz_stag_456',
serviceAccountId: 'sa-1',
},
];
const defaultProps = {
keys,
isLoading: false,
isDisabled: false,
currentPage: 1,
pageSize: 10,
};
function renderKeysTab(
props: Partial<typeof defaultProps> = {},
searchParams: Record<string, string> = { account: 'sa-1' },
): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
<KeysTab {...defaultProps} {...props} />
</NuqsTestingAdapter>,
);
}
describe('KeysTab', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.delete(SA_KEY_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders loading state', () => {
renderKeysTab({ isLoading: true });
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('renders empty state when no keys and clicking add sets add-key param', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onUrlUpdate = jest.fn();
render(
<NuqsTestingAdapter
searchParams={{ account: 'sa-1' }}
onUrlUpdate={onUrlUpdate}
>
<KeysTab {...defaultProps} keys={[]} />
</NuqsTestingAdapter>,
);
expect(
screen.getByText(/No keys. Start by creating one./i),
).toBeInTheDocument();
const addBtn = screen.getByRole('button', { name: /\+ Add your first key/i });
await user.click(addBtn);
expect(onUrlUpdate).toHaveBeenCalledWith(
expect.objectContaining({
queryString: expect.stringContaining('add-key=true'),
}),
);
});
it('renders table with keys', () => {
renderKeysTab();
expect(screen.getByText('Production Key')).toBeInTheDocument();
expect(screen.getByText('Staging Key')).toBeInTheDocument();
expect(screen.getByText('Never')).toBeInTheDocument();
expect(screen.getByText('Dec 31, 2030')).toBeInTheDocument();
});
it('clicking a row sets the edit-key URL param', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onUrlUpdate = jest.fn();
render(
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate}>
<KeysTab {...defaultProps} />
</NuqsTestingAdapter>,
);
const row = screen.getByText('Production Key').closest('tr');
if (!row) {
throw new Error('Row not found');
}
await user.click(row);
expect(onUrlUpdate).toHaveBeenCalledWith(
expect.objectContaining({
queryString: expect.stringContaining('edit-key=key-1'),
}),
);
});
it('clicking revoke icon sets revoke-key URL param', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onUrlUpdate = jest.fn();
render(
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate}>
<KeysTab {...defaultProps} />
</NuqsTestingAdapter>,
);
const revokeBtns = screen
.getAllByRole('button')
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
await user.click(revokeBtns[0]);
expect(onUrlUpdate).toHaveBeenCalledWith(
expect.objectContaining({
queryString: expect.stringContaining('revoke-key=key-1'),
}),
);
});
it('handles successful key revocation via RevokeKeyModal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderKeysTab();
// Seed the keys cache so RevokeKeyModal can read the key name
const revokeBtns = screen
.getAllByRole('button')
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
await user.click(revokeBtns[0]);
const confirmBtn = await screen.findByRole('button', { name: /Revoke Key/i });
await user.click(confirmBtn);
await waitFor(() => {
expect(mockToast.success).toHaveBeenCalledWith(
'Key revoked successfully',
expect.anything(),
);
});
});
it('disables actions when isDisabled is true', () => {
renderKeysTab({ isDisabled: true });
const revokeBtns = screen
.getAllByRole('button')
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
revokeBtns.forEach((btn) => expect(btn).toBeDisabled());
});
});

View File

@@ -1,246 +0,0 @@
import type { ReactNode } from 'react';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import ServiceAccountDrawer from '../ServiceAccountDrawer';
jest.mock('@signozhq/drawer', () => ({
DrawerWrapper: ({
content,
open,
}: {
content?: ReactNode;
open: boolean;
}): JSX.Element | null => (open ? <div>{content}</div> : null),
}));
jest.mock('@signozhq/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const ROLES_ENDPOINT = '*/api/v1/roles';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_STATUS_ENDPOINT = '*/api/v1/service_accounts/sa-1/status';
const activeAccountResponse = {
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
};
const disabledAccountResponse = {
...activeAccountResponse,
id: 'sa-2',
status: 'DISABLED',
};
function renderDrawer(
searchParams: Record<string, string> = { account: 'sa-1' },
): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
<ServiceAccountDrawer onSuccess={jest.fn()} />
</NuqsTestingAdapter>,
);
}
describe('ServiceAccountDrawer', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: activeAccountResponse })),
),
rest.put(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.put(SA_STATUS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders Overview tab by default: editable name input, locked email, Save disabled when not dirty', async () => {
renderDrawer();
expect(await screen.findByDisplayValue('CI Bot')).toBeInTheDocument();
expect(screen.getByText('ci-bot@signoz.io')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save Changes/i })).toBeDisabled();
});
it('editing name enables Save; clicking Save sends correct payload and calls onSuccess', async () => {
const onSuccess = jest.fn();
const updateSpy = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.put(SA_ENDPOINT, async (req, res, ctx) => {
updateSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
render(
<NuqsTestingAdapter searchParams={{ account: 'sa-1' }} hasMemory>
<ServiceAccountDrawer onSuccess={onSuccess} />
</NuqsTestingAdapter>,
);
const nameInput = await screen.findByDisplayValue('CI Bot');
await user.clear(nameInput);
await user.type(nameInput, 'CI Bot Updated');
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(updateSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'CI Bot Updated',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
}),
);
expect(onSuccess).toHaveBeenCalledWith({ closeDrawer: false });
});
});
it('changing roles enables Save; clicking Save sends updated roles in payload', async () => {
const updateSpy = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.put(SA_ENDPOINT, async (req, res, ctx) => {
updateSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
renderDrawer();
await screen.findByDisplayValue('CI Bot');
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-viewer'));
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(updateSpy).toHaveBeenCalledWith(
expect.objectContaining({
roles: expect.arrayContaining(['signoz-admin', 'signoz-viewer']),
}),
);
});
});
it('"Disable Service Account" opens confirm dialog; confirming sends correct status payload', async () => {
const statusSpy = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.put(SA_STATUS_ENDPOINT, async (req, res, ctx) => {
statusSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
renderDrawer();
await screen.findByDisplayValue('CI Bot');
await user.click(
screen.getByRole('button', { name: /Disable Service Account/i }),
);
const dialog = await screen.findByRole('dialog', {
name: /Disable service account CI Bot/i,
});
expect(dialog).toBeInTheDocument();
const confirmBtns = screen.getAllByRole('button', { name: /^Disable$/i });
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(statusSpy).toHaveBeenCalledWith({ status: 'DISABLED' });
});
});
it('disabled account shows read-only name, no Save button, no Disable button', async () => {
server.use(
rest.get('*/api/v1/service_accounts/sa-2', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: disabledAccountResponse })),
),
rest.get('*/api/v1/service_accounts/sa-2/keys', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
);
renderDrawer({ account: 'sa-2' });
await screen.findByText('CI Bot');
expect(
screen.queryByRole('button', { name: /Save Changes/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /Disable Service Account/i }),
).not.toBeInTheDocument();
expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument();
});
it('switching to Keys tab shows "No keys" empty state', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer();
await screen.findByDisplayValue('CI Bot');
await user.click(screen.getByRole('radio', { name: /Keys/i }));
await screen.findByText(/No keys/i);
});
it('shows skeleton while loading account data', () => {
renderDrawer();
// Skeleton renders while the fetch is in-flight
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('shows error state when account fetch fails', async () => {
server.use(
rest.get(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(500), ctx.json({ message: 'Server error' })),
),
);
renderDrawer();
expect(
await screen.findByText(
/An unexpected error occurred while fetching service account details/i,
),
).toBeInTheDocument();
});
});

View File

@@ -1,33 +0,0 @@
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
export enum ServiceAccountDrawerTab {
Overview = 'overview',
Keys = 'keys',
}
export function formatLastObservedAt(
lastObservedAt: string | Date | null | undefined,
formatTimezoneAdjustedTimestamp: (ts: string, format: string) => string,
): string {
if (!lastObservedAt) {
return '—';
}
const str =
typeof lastObservedAt === 'string'
? lastObservedAt
: lastObservedAt.toISOString();
// Go zero time means the key has never been used
if (str.startsWith('0001-01-01')) {
return '—';
}
const d = new Date(str);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(str, DATE_TIME_FORMATS.DASH_DATETIME);
}
export const disabledDate = (current: Dayjs): boolean =>
!!current && current < dayjs().startOf('day');

View File

@@ -1,218 +0,0 @@
.sa-table-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: 4px;
}
.sa-table {
.ant-table {
background: transparent;
font-size: 13px;
}
.ant-table-container {
border-radius: 0 !important;
border: none !important;
}
.ant-table-thead {
> tr > th,
> tr > td {
background: var(--background);
font-size: var(--paragraph-small-600-font-size);
font-weight: var(--paragraph-small-600-font-weight);
line-height: var(--paragraph-small-600-line-height);
letter-spacing: 0.44px;
text-transform: uppercase;
color: var(--foreground);
padding: var(--padding-2) var(--padding-4);
border-bottom: none !important;
border-top: none !important;
&::before {
display: none !important;
}
}
}
.ant-table-tbody {
> tr > td {
border-bottom: none !important;
padding: var(--padding-2) var(--padding-4);
background: transparent;
transition: none;
}
> tr.sa-table-row--tinted > td {
background: rgba(171, 189, 255, 0.02);
}
> tr:hover > td {
background: rgba(171, 189, 255, 0.04) !important;
}
}
.ant-table-wrapper,
.ant-table-container,
.ant-spin-nested-loading,
.ant-spin-container {
border: none !important;
box-shadow: none !important;
}
.sa-name-column {
.ant-table-column-sorters {
justify-content: flex-start;
gap: var(--spacing-2);
}
.ant-table-column-title {
flex: none;
}
}
.sa-status-cell {
[data-slot='badge'] {
padding: var(--padding-1) var(--padding-2);
align-items: center;
font-size: var(--uppercase-small-500-font-size);
font-weight: var(--uppercase-small-500-font-weight);
line-height: 100%;
letter-spacing: 0.44px;
text-transform: uppercase;
}
}
}
.sa-name-email-cell {
display: flex;
align-items: center;
gap: var(--spacing-2);
height: 22px;
overflow: hidden;
.sa-name {
font-size: var(--paragraph-base-500-font-size);
font-weight: var(--paragraph-base-500-font-weight);
color: var(--foreground);
line-height: var(--paragraph-base-500-line-height);
letter-spacing: -0.07px;
white-space: nowrap;
flex-shrink: 0;
}
.sa-email {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--l3-foreground-hover);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.07px;
flex: 1 0 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.sa-roles-cell {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.sa-dash {
font-size: var(--paragraph-base-400-font-size);
color: var(--l3-foreground-hover);
}
.sa-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--padding-12) var(--padding-4);
gap: var(--spacing-4);
color: var(--foreground);
&__emoji {
font-size: var(--font-size-2xl);
line-height: 1;
}
&__text {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
margin: 0;
line-height: var(--paragraph-base-400-line-height);
strong {
font-weight: var(--font-weight-medium);
color: var(--bg-base-white);
}
}
}
.sa-table-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
padding: var(--padding-2) var(--padding-4);
.ant-pagination-total-text {
margin-right: auto;
}
.sa-pagination-range {
font-size: var(--font-size-xs);
color: var(--foreground);
}
.sa-pagination-total {
font-size: var(--font-size-xs);
color: var(--foreground);
opacity: 0.5;
}
}
.sa-tooltip {
.ant-tooltip-inner {
background-color: var(--bg-slate-500);
color: var(--foreground);
font-size: var(--font-size-xs);
line-height: normal;
padding: var(--padding-2) var(--padding-3);
border-radius: 4px;
text-align: left;
}
.ant-tooltip-arrow-content {
background-color: var(--bg-slate-500);
}
}
.lightMode {
.sa-table {
.ant-table-tbody {
> tr.sa-table-row--tinted > td {
background: rgba(0, 0, 0, 0.015);
}
> tr:hover > td {
background: rgba(0, 0, 0, 0.03) !important;
}
}
}
.sa-empty-state {
&__text {
strong {
color: var(--bg-base-black);
}
}
}
}

View File

@@ -1,95 +0,0 @@
import React from 'react';
import { Table } from 'antd';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { parseAsInteger, parseAsString, useQueryState } from 'nuqs';
import {
columns,
ServiceAccountsEmptyState,
showPaginationTotal,
} from './utils';
import './ServiceAccountsTable.styles.scss';
export const PAGE_SIZE = 20;
interface ServiceAccountsTableProps {
data: ServiceAccountRow[];
loading: boolean;
onRowClick?: (row: ServiceAccountRow) => void;
}
function ServiceAccountsTable({
data,
loading,
onRowClick,
}: ServiceAccountsTableProps): JSX.Element {
const [currentPage, setPage] = useQueryState(
'page',
parseAsInteger.withDefault(1),
);
const [searchQuery] = useQueryState('search', parseAsString.withDefault(''));
return (
<div className="sa-table-wrapper">
{/* Todo: use new table component from periscope when ready */}
<Table<ServiceAccountRow>
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={
data.length > PAGE_SIZE
? {
current: currentPage,
pageSize: PAGE_SIZE,
total: data.length,
showTotal: showPaginationTotal,
showSizeChanger: false,
onChange: (page: number): void => void setPage(page),
className: 'sa-table-pagination',
}
: false
}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'sa-table-row--tinted' : ''
}
showSorterTooltip={false}
locale={{
emptyText: <ServiceAccountsEmptyState searchQuery={searchQuery} />,
}}
className="sa-table"
onRow={(
record,
): {
onClick?: () => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLElement>) => void;
style?: React.CSSProperties;
tabIndex?: number;
role?: string;
'aria-label'?: string;
} => {
if (!onRowClick) {
return {};
}
return {
onClick: (): void => onRowClick(record),
onKeyDown: (e: React.KeyboardEvent<HTMLElement>): void => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onRowClick(record);
}
},
style: { cursor: 'pointer' },
tabIndex: 0,
role: 'button',
'aria-label': `View service account ${record.name || record.email}`,
};
}}
/>
</div>
);
}
export default ServiceAccountsTable;

View File

@@ -1,96 +0,0 @@
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent } from 'tests/test-utils';
import ServiceAccountsTable from '../ServiceAccountsTable';
const mockActiveAccount: ServiceAccountRow = {
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
};
const mockDisabledAccount: ServiceAccountRow = {
id: 'sa-2',
name: 'Legacy Bot',
email: 'legacy@signoz.io',
roles: ['signoz-viewer', 'signoz-editor', 'billing-manager'],
status: 'DISABLED',
createdAt: '2025-06-01T00:00:00Z',
updatedAt: '2025-12-01T00:00:00Z',
};
const defaultProps = {
loading: false,
onRowClick: jest.fn(),
};
describe('ServiceAccountsTable', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders name, email, role badge, and ACTIVE status badge', () => {
render(<ServiceAccountsTable {...defaultProps} data={[mockActiveAccount]} />);
expect(screen.getByText('CI Bot')).toBeInTheDocument();
expect(screen.getByText('ci-bot@signoz.io')).toBeInTheDocument();
expect(screen.getByText('signoz-admin')).toBeInTheDocument();
expect(screen.getByText('ACTIVE')).toBeInTheDocument();
});
it('shows DISABLED badge and +2 overflow badge for multi-role accounts', () => {
render(
<ServiceAccountsTable {...defaultProps} data={[mockDisabledAccount]} />,
);
expect(screen.getByText('DISABLED')).toBeInTheDocument();
expect(screen.getByText('signoz-viewer')).toBeInTheDocument();
expect(screen.getByText('+2')).toBeInTheDocument();
});
it('calls onRowClick with the correct account when a row is clicked', async () => {
const onRowClick = jest.fn() as jest.MockedFunction<
(row: ServiceAccountRow) => void
>;
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ServiceAccountsTable
{...defaultProps}
data={[mockActiveAccount]}
onRowClick={onRowClick}
/>,
);
await user.click(
screen.getByRole('button', { name: /View service account CI Bot/i }),
);
expect(onRowClick).toHaveBeenCalledTimes(1);
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: 'sa-1', email: 'ci-bot@signoz.io' }),
);
});
it('shows "No service accounts" empty state when data is empty and no search query', () => {
render(<ServiceAccountsTable {...defaultProps} data={[]} />);
expect(screen.getByText(/No service accounts/i)).toBeInTheDocument();
});
it('shows "No results for {query}" empty state when search is active', () => {
render(
<NuqsTestingAdapter searchParams={{ search: 'ghost' }}>
<ServiceAccountsTable {...defaultProps} data={[]} />
</NuqsTestingAdapter>,
);
expect(screen.getByText(/No results for/i)).toBeInTheDocument();
expect(screen.getByText('ghost')).toBeInTheDocument();
});
});

View File

@@ -1,133 +0,0 @@
import { Badge } from '@signozhq/badge';
import { Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
export function NameEmailCell({
name,
email,
}: {
name: string;
email: string;
}): JSX.Element {
return (
<div className="sa-name-email-cell">
{name && (
<span className="sa-name" title={name}>
{name}
</span>
)}
<Tooltip title={email} overlayClassName="sa-tooltip">
<span className="sa-email">{email}</span>
</Tooltip>
</div>
);
}
export function RolesCell({ roles }: { roles: string[] }): JSX.Element {
if (!roles || roles.length === 0) {
return <span className="sa-dash"></span>;
}
const first = roles[0];
const overflow = roles.length - 1;
const tooltipContent = roles.slice(1).join(', ');
return (
<div className="sa-roles-cell">
<Badge color="vanilla">{first}</Badge>
{overflow > 0 && (
<Tooltip
title={tooltipContent}
overlayClassName="sa-tooltip"
overlayStyle={{ maxWidth: '600px' }}
>
<Badge color="vanilla" variant="outline" className="sa-status-badge">
+{overflow}
</Badge>
</Tooltip>
)}
</div>
);
}
export function StatusBadge({ status }: { status: string }): JSX.Element {
if (status?.toUpperCase() === 'ACTIVE') {
return (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
);
}
return (
<Badge color="vanilla" variant="outline" className="sa-status-badge">
DISABLED
</Badge>
);
}
export function ServiceAccountsEmptyState({
searchQuery,
}: {
searchQuery: string;
}): JSX.Element {
return (
<div className="sa-empty-state">
<span className="sa-empty-state__emoji" role="img" aria-label="monocle face">
🧐
</span>
{searchQuery ? (
<p className="sa-empty-state__text">
No results for <strong>{searchQuery}</strong>
</p>
) : (
<p className="sa-empty-state__text">
No service accounts. Start by creating one to manage keys.
</p>
)}
</div>
);
}
export const columns: ColumnsType<ServiceAccountRow> = [
{
title: 'Name / Email',
dataIndex: 'name',
key: 'name',
className: 'sa-name-column',
sorter: (a, b): number => a.email.localeCompare(b.email),
render: (_, record): JSX.Element => (
<NameEmailCell name={record.name} email={record.email} />
),
},
{
title: 'Roles',
dataIndex: 'roles',
key: 'roles',
width: 420,
render: (roles: string[]): JSX.Element => <RolesCell roles={roles} />,
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 120,
align: 'right' as const,
className: 'sa-status-cell',
sorter: (a, b): number =>
(a.status?.toUpperCase() === 'ACTIVE' ? 0 : 1) -
(b.status?.toUpperCase() === 'ACTIVE' ? 0 : 1),
render: (status: string): JSX.Element => <StatusBadge status={status} />,
},
];
export const showPaginationTotal = (
_total: number,
range: number[],
): JSX.Element => (
<>
<span className="sa-pagination-range">
{range[0]} &#8212; {range[1]}
</span>
<span className="sa-pagination-total"> of {_total}</span>
</>
);

View File

@@ -35,5 +35,4 @@ export enum LOCALSTORAGE {
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
}

View File

@@ -86,7 +86,6 @@ const ROUTES = {
METER_EXPLORER_VIEWS: '/meter/explorer/views',
HOME_PAGE: '/',
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
} as const;
export default ROUTES;

View File

@@ -6,16 +6,13 @@ import { Button, Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import listUserPreferences from 'api/v1/user/preferences/list';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { PersistedAnnouncementBanner } from 'components/AnnouncementBanner';
import Header from 'components/Header/Header';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import { getMetricsListQuery } from 'container/MetricsExplorer/Summary/utils';
import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config';
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -294,24 +291,6 @@ export default function Home(): JSX.Element {
return (
<div className="home-container">
{IS_SERVICE_ACCOUNTS_ENABLED && (
<PersistedAnnouncementBanner
type="warning"
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
message={
<>
<strong>API Keys</strong> have been deprecated and replaced by{' '}
<strong>Service Accounts</strong>. Please migrate to Service Accounts for
programmatic API access.
</>
}
action={{
label: 'Go to Service Accounts',
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
}}
/>
)}
<div className="sticky-header">
<Header
leftComponent={

View File

@@ -13,7 +13,6 @@ import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
import useUrlQuery from 'hooks/useUrlQuery';
import { useAppContext } from 'providers/App/App';
import { toISOString } from 'utils/app';
import { FilterMode, INVITE_PREFIX, MemberStatus } from './utils';
@@ -62,8 +61,8 @@ function MembersSettings(): JSX.Element {
email: user.email,
role: user.role,
status: MemberStatus.Active,
joinedOn: toISOString(user.createdAt),
updatedAt: toISOString(user?.updatedAt),
joinedOn: user.createdAt ? String(user.createdAt) : null,
updatedAt: user?.updatedAt ? String(user.updatedAt) : null,
}));
const pendingInvites: MemberRow[] = (invitesData?.data ?? []).map(
@@ -73,7 +72,7 @@ function MembersSettings(): JSX.Element {
email: invite.email,
role: invite.role,
status: MemberStatus.Invited,
joinedOn: toISOString(invite.createdAt),
joinedOn: invite.createdAt ? String(invite.createdAt) : null,
token: invite.token ?? null,
}),
);
@@ -123,9 +122,6 @@ function MembersSettings(): JSX.Element {
if (currentPage > maxPage) {
setPage(maxPage);
}
if (currentPage < 1) {
setPage(1);
}
}, [filteredMembers.length, currentPage, setPage]);
const pendingCount = invitesData?.data?.length ?? 0;
@@ -213,7 +209,6 @@ function MembersSettings(): JSX.Element {
<div className="members-settings__search">
<Input
type="search"
placeholder="Search by name, email, or role..."
value={searchQuery}
onChange={(e): void => {
@@ -222,7 +217,6 @@ function MembersSettings(): JSX.Element {
}}
className="members-search-input"
color="secondary"
name="members-search"
/>
</div>

View File

@@ -13,8 +13,8 @@ import {
usePatchRole,
} from 'api/generated/services/role';
import {
AuthtypesPostableRoleDTO,
RenderErrorResponseDTO,
RoletypesPostableRoleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
@@ -114,7 +114,7 @@ function CreateRoleModal({
data: { description: values.description || '' },
});
} else {
const data: AuthtypesPostableRoleDTO = {
const data: RoletypesPostableRoleDTO = {
name: values.name,
...(values.description ? { description: values.description } : {}),
};

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import { Pagination, Skeleton } from 'antd';
import { useListRoles } from 'api/generated/services/role';
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
@@ -20,7 +20,7 @@ const PAGE_SIZE = 20;
type DisplayItem =
| { type: 'section'; label: string; count?: number }
| { type: 'role'; role: AuthtypesRoleDTO };
| { type: 'role'; role: RoletypesRoleDTO };
interface RolesListingTableProps {
searchQuery: string;
@@ -187,7 +187,7 @@ function RolesListingTable({
};
// todo: use table from periscope when its available for consumption
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
const renderRow = (role: RoletypesRoleDTO): JSX.Element => (
<div
key={role.id}
className={`roles-table-row ${

View File

@@ -1,134 +0,0 @@
.sa-settings {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
height: 100%;
&__header {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
&__title {
font-size: var(--label-large-500-font-size);
font-weight: var(--label-large-500-font-weight);
color: var(--text-base-white);
letter-spacing: -0.09px;
line-height: var(--line-height-normal);
margin: 0;
}
&__subtitle {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
letter-spacing: -0.07px;
line-height: var(--paragraph-base-400-line-height);
margin: 0;
}
&__learn-more {
color: var(--primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
&__controls {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
&__search {
flex: 1;
min-width: 0;
}
}
.sa-status-badge {
color: var(--l3-foreground);
border-color: var(--border);
}
.sa-settings-filter-trigger {
display: flex;
align-items: center;
gap: var(--spacing-2);
border: 1px solid var(--border);
border-radius: 2px;
background-color: var(--l2-background);
> span {
color: var(--foreground);
}
&__chevron {
flex-shrink: 0;
color: var(--foreground);
}
}
.sa-settings-filter-dropdown {
.ant-dropdown-menu {
padding: var(--padding-3) 14px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--l2-background);
backdrop-filter: blur(20px);
}
.ant-dropdown-menu-item {
background: transparent !important;
padding: var(--padding-1) 0 !important;
&:hover {
background: transparent !important;
}
}
}
.sa-settings-filter-option {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
letter-spacing: 0.14px;
min-width: 170px;
&:hover {
color: var(--card-foreground);
background: transparent;
}
}
.sa-settings-search-input {
height: 32px;
color: var(--l1-foreground);
background-color: var(--l2-background);
border-color: var(--border);
&::placeholder {
color: var(--l3-foreground);
}
}
.lightMode {
.sa-settings {
&__title {
color: var(--text-base-black);
}
}
.sa-settings-filter-option {
&:hover {
color: var(--bg-neutral-light-100);
}
}
}

View File

@@ -1,306 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import { Button } from '@signozhq/button';
import { Check, ChevronDown, Plus } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import type { MenuProps } from 'antd';
import { Dropdown } from 'antd';
import {
getGetServiceAccountQueryKey,
useListServiceAccounts,
} from 'api/generated/services/serviceaccount';
import type {
GetServiceAccount200,
ListServiceAccounts200,
} from 'api/generated/services/sigNoz.schemas';
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
import ServiceAccountsTable, {
PAGE_SIZE,
} from 'components/ServiceAccountsTable/ServiceAccountsTable';
import {
parseAsBoolean,
parseAsInteger,
parseAsString,
parseAsStringEnum,
useQueryState,
} from 'nuqs';
import { toAPIError } from 'utils/errorUtils';
import {
FilterMode,
ServiceAccountRow,
ServiceAccountStatus,
toServiceAccountRow,
} from './utils';
import './ServiceAccountsSettings.styles.scss';
function ServiceAccountsSettings(): JSX.Element {
const [currentPage, setPage] = useQueryState(
'page',
parseAsInteger.withDefault(1),
);
const [searchQuery, setSearchQuery] = useQueryState(
'search',
parseAsString.withDefault(''),
);
const [filterMode, setFilterMode] = useQueryState(
'filter',
parseAsStringEnum<FilterMode>(Object.values(FilterMode)).withDefault(
FilterMode.All,
),
);
const [, setSelectedAccountId] = useQueryState('account');
const [, setIsCreateModalOpen] = useQueryState(
'create-sa',
parseAsBoolean.withDefault(false),
);
const queryClient = useQueryClient();
const seedAccountCache = useCallback(
(data: ListServiceAccounts200) => {
data.data.forEach((account) => {
queryClient.setQueryData<GetServiceAccount200>(
getGetServiceAccountQueryKey({ id: account.id }),
(old) => old ?? { data: account, status: data.status },
);
});
},
[queryClient],
);
const {
data: serviceAccountsData,
isLoading,
isError,
error,
refetch: handleCreateSuccess,
} = useListServiceAccounts({
query: { onSuccess: seedAccountCache },
});
const allAccounts = useMemo(
(): ServiceAccountRow[] =>
(serviceAccountsData?.data ?? []).map(toServiceAccountRow),
[serviceAccountsData],
);
const activeCount = useMemo(
() =>
allAccounts.filter(
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Active,
).length,
[allAccounts],
);
const disabledCount = useMemo(
() =>
allAccounts.filter(
(a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active,
).length,
[allAccounts],
);
const filteredAccounts = useMemo((): ServiceAccountRow[] => {
let result = allAccounts;
if (filterMode === FilterMode.Active) {
result = result.filter(
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Active,
);
} else if (filterMode === FilterMode.Disabled) {
result = result.filter(
(a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active,
);
}
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase();
result = result.filter(
(a) =>
a.name?.toLowerCase().includes(q) ||
a.email?.toLowerCase().includes(q) ||
a.roles?.some((role: string) => role.toLowerCase().includes(q)),
);
}
return result;
}, [allAccounts, filterMode, searchQuery]);
useEffect(() => {
if (filteredAccounts.length === 0) {
return;
}
const maxPage = Math.max(1, Math.ceil(filteredAccounts.length / PAGE_SIZE));
if (currentPage > maxPage) {
setPage(maxPage);
} else if (currentPage < 1) {
setPage(1);
}
}, [filteredAccounts.length, currentPage, setPage]);
const totalCount = allAccounts.length;
const filterMenuItems: MenuProps['items'] = [
{
key: FilterMode.All,
label: (
<div className="sa-settings-filter-option">
<span>All accounts {totalCount}</span>
{filterMode === FilterMode.All && <Check size={14} />}
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.All);
setPage(1);
},
},
{
key: FilterMode.Active,
label: (
<div className="sa-settings-filter-option">
<span>Active {activeCount}</span>
{filterMode === FilterMode.Active && <Check size={14} />}
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.Active);
setPage(1);
},
},
{
key: FilterMode.Disabled,
label: (
<div className="sa-settings-filter-option">
<span>Disabled {disabledCount}</span>
{filterMode === FilterMode.Disabled && <Check size={14} />}
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.Disabled);
setPage(1);
},
},
];
function getFilterLabel(): string {
switch (filterMode) {
case FilterMode.Active:
return `Active ⎯ ${activeCount}`;
case FilterMode.Disabled:
return `Disabled ⎯ ${disabledCount}`;
default:
return `All accounts ⎯ ${totalCount}`;
}
}
const filterLabel = getFilterLabel();
const handleRowClick = useCallback(
(row: ServiceAccountRow): void => {
setSelectedAccountId(row.id);
},
[setSelectedAccountId],
);
const handleDrawerSuccess = useCallback(
(options?: { closeDrawer?: boolean }): void => {
if (options?.closeDrawer) {
setSelectedAccountId(null);
}
handleCreateSuccess();
},
[handleCreateSuccess, setSelectedAccountId],
);
return (
<>
<div className="sa-settings">
<div className="sa-settings__header">
<h1 className="sa-settings__title">Service Accounts</h1>
<p className="sa-settings__subtitle">
Overview of service accounts added to this workspace.{' '}
{/* Todo: to add doc links */}
{/* <a
href="https://signoz.io/docs/service-accounts"
target="_blank"
rel="noopener noreferrer"
className="sa-settings__learn-more"
>
Learn more
</a> */}
</p>
</div>
<div className="sa-settings__controls">
<Dropdown
menu={{ items: filterMenuItems }}
trigger={['click']}
overlayClassName="sa-settings-filter-dropdown"
>
<Button
variant="solid"
size="sm"
color="secondary"
className="sa-settings-filter-trigger"
>
<span>{filterLabel}</span>
<ChevronDown size={12} className="sa-settings-filter-trigger__chevron" />
</Button>
</Dropdown>
<div className="sa-settings__search">
<Input
type="search"
name="service-accounts-search"
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e): void => {
setSearchQuery(e.target.value);
setPage(1);
}}
className="sa-settings-search-input"
color="secondary"
/>
</div>
<Button
variant="solid"
size="sm"
color="primary"
onClick={(): void => {
void setIsCreateModalOpen(true);
}}
>
<Plus size={12} />
New Service Account
</Button>
</div>
</div>
{isError ? (
<ErrorInPlace
error={toAPIError(
error,
'An unexpected error occurred while fetching service accounts.',
)}
/>
) : (
<ServiceAccountsTable
data={filteredAccounts}
loading={isLoading}
onRowClick={handleRowClick}
/>
)}
<CreateServiceAccountModal />
<ServiceAccountDrawer onSuccess={handleDrawerSuccess} />
</>
);
}
export default ServiceAccountsSettings;

View File

@@ -1,231 +0,0 @@
import type { ReactNode } from 'react';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent } from 'tests/test-utils';
import ServiceAccountsSettings from '../ServiceAccountsSettings';
const SA_LIST_ENDPOINT = '*/api/v1/service_accounts';
const SA_ENDPOINT = '*/api/v1/service_accounts/:id';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const ROLES_ENDPOINT = '*/api/v1/roles';
jest.mock('@signozhq/drawer', () => ({
DrawerWrapper: ({
content,
open,
}: {
content?: ReactNode;
open: boolean;
}): JSX.Element | null => (open ? <div>{content}</div> : null),
}));
const mockServiceAccountsAPI = [
{
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: 1700000000,
updatedAt: 1700000001,
},
{
id: 'sa-2',
name: 'Monitoring Agent',
email: 'monitor@signoz.io',
roles: ['signoz-viewer'],
status: 'ACTIVE',
createdAt: 1700000002,
updatedAt: 1700000003,
},
{
id: 'sa-3',
name: 'Legacy Bot',
email: 'legacy@signoz.io',
roles: ['signoz-editor'],
status: 'DISABLED',
createdAt: 1700000004,
updatedAt: 1700000005,
},
];
describe('ServiceAccountsSettings (integration)', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(SA_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockServiceAccountsAPI })),
),
rest.get(SA_ENDPOINT, (req, res, ctx) => {
const { id } = req.params as { id: string };
const account = mockServiceAccountsAPI.find((a) => a.id === id);
return account
? res(ctx.status(200), ctx.json({ data: account }))
: res(ctx.status(404), ctx.json({ message: 'Not found' }));
}),
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('loads and displays all accounts with correct ACTIVE and DISABLED badges', async () => {
render(
<NuqsTestingAdapter>
<ServiceAccountsSettings />
</NuqsTestingAdapter>,
);
await screen.findByText('CI Bot');
expect(screen.getByText('Monitoring Agent')).toBeInTheDocument();
expect(screen.getByText('legacy@signoz.io')).toBeInTheDocument();
expect(screen.getAllByText('ACTIVE')).toHaveLength(2);
expect(screen.getByText('DISABLED')).toBeInTheDocument();
});
it('filter dropdown to "Active" hides DISABLED accounts', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<NuqsTestingAdapter>
<ServiceAccountsSettings />
</NuqsTestingAdapter>,
);
await screen.findByText('CI Bot');
await user.click(screen.getByRole('button', { name: /All accounts/i }));
const activeOption = await screen.findByText(/Active ⎯/i);
await user.click(activeOption);
await screen.findByText('CI Bot');
expect(screen.queryByText('Legacy Bot')).not.toBeInTheDocument();
});
it('search by name filters accounts in real-time', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<NuqsTestingAdapter>
<ServiceAccountsSettings />
</NuqsTestingAdapter>,
);
await screen.findByText('CI Bot');
await user.type(
screen.getByPlaceholderText(/Search by name or email/i),
'legacy',
);
await screen.findByText('Legacy Bot');
expect(screen.queryByText('CI Bot')).not.toBeInTheDocument();
expect(screen.queryByText('Monitoring Agent')).not.toBeInTheDocument();
});
it('clicking a row opens the drawer with account details visible', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<NuqsTestingAdapter hasMemory>
<ServiceAccountsSettings />
</NuqsTestingAdapter>,
);
await user.click(
await screen.findByRole('button', {
name: /View service account CI Bot/i,
}),
);
expect(
await screen.findByRole('button', { name: /Disable Service Account/i }),
).toBeInTheDocument();
});
it('saving changes in the drawer refetches the list', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const listRefetchSpy = jest.fn();
server.use(
rest.get(SA_LIST_ENDPOINT, (_, res, ctx) => {
listRefetchSpy();
return res(ctx.status(200), ctx.json({ data: mockServiceAccountsAPI }));
}),
rest.put(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
);
render(
<NuqsTestingAdapter hasMemory>
<ServiceAccountsSettings />
</NuqsTestingAdapter>,
);
await screen.findByText('CI Bot');
listRefetchSpy.mockClear();
await user.click(
await screen.findByRole('button', { name: /View service account CI Bot/i }),
);
const nameInput = await screen.findByDisplayValue('CI Bot');
await user.clear(nameInput);
await user.type(nameInput, 'CI Bot Updated');
await user.click(screen.getByRole('button', { name: /Save Changes/i }));
await screen.findByDisplayValue('CI Bot Updated');
expect(listRefetchSpy).toHaveBeenCalled();
});
it('"New Service Account" button opens the Create Service Account modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<NuqsTestingAdapter>
<ServiceAccountsSettings />
</NuqsTestingAdapter>,
);
await screen.findByText('CI Bot');
await user.click(
screen.getByRole('button', { name: /New Service Account/i }),
);
await screen.findByRole('dialog', { name: /New Service Account/i });
expect(screen.getByPlaceholderText('Enter a name')).toBeInTheDocument();
});
it('shows error state when API fails', async () => {
server.use(
rest.get(SA_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(500), ctx.json({ message: 'Internal Server Error' })),
),
);
render(
<NuqsTestingAdapter>
<ServiceAccountsSettings />
</NuqsTestingAdapter>,
);
expect(
await screen.findByText(
/An unexpected error occurred while fetching service accounts/i,
),
).toBeInTheDocument();
});
});

View File

@@ -1 +0,0 @@
export const IS_SERVICE_ACCOUNTS_ENABLED = false;

View File

@@ -1,37 +0,0 @@
import type { ServiceaccounttypesServiceAccountDTO } from 'api/generated/services/sigNoz.schemas';
import { toISOString } from 'utils/app';
export function toServiceAccountRow(
sa: ServiceaccounttypesServiceAccountDTO,
): ServiceAccountRow {
return {
id: sa.id,
name: sa.name,
email: sa.email,
roles: sa.roles,
status: sa.status,
createdAt: toISOString(sa.createdAt),
updatedAt: toISOString(sa.updatedAt),
};
}
export enum FilterMode {
All = 'all',
Active = 'active',
Disabled = 'disabled',
}
export enum ServiceAccountStatus {
Active = 'ACTIVE',
Disabled = 'DISABLED',
}
export interface ServiceAccountRow {
id: string;
name: string;
email: string;
roles: string[];
status: string;
createdAt: string | null;
updatedAt: string | null;
}

View File

@@ -8,7 +8,6 @@ import {
BellDot,
Binoculars,
Book,
Bot,
Boxes,
BugIcon,
Building2,
@@ -359,13 +358,6 @@ export const settingsNavSections: SettingsNavSection[] = [
isEnabled: false,
itemKey: 'members',
},
{
key: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
label: 'Service Accounts',
icon: <Bot size={16} />,
isEnabled: false,
itemKey: 'service-accounts',
},
{
key: ROUTES.API_KEYS,
label: 'API Keys',

View File

@@ -154,7 +154,6 @@ export const routesToSkip = [
ROUTES.ALL_DASHBOARD,
ROUTES.ORG_SETTINGS,
ROUTES.MEMBERS_SETTINGS,
ROUTES.SERVICE_ACCOUNTS_SETTINGS,
ROUTES.INGESTION_SETTINGS,
ROUTES.API_KEYS,
ROUTES.ERROR_DETAIL,

View File

@@ -1,8 +1,8 @@
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
const orgId = '019ba2bb-2fa1-7b24-8159-cfca08617ef9';
export const managedRoles: AuthtypesRoleDTO[] = [
export const managedRoles: RoletypesRoleDTO[] = [
{
id: '019c24aa-2248-756f-9833-984f1ab63819',
createdAt: new Date('2026-02-03T18:00:55.624356Z'),
@@ -35,7 +35,7 @@ export const managedRoles: AuthtypesRoleDTO[] = [
},
];
export const customRoles: AuthtypesRoleDTO[] = [
export const customRoles: RoletypesRoleDTO[] = [
{
id: '019c24aa-3333-0001-aaaa-111111111111',
createdAt: new Date('2026-02-10T10:30:00.000Z'),
@@ -56,7 +56,7 @@ export const customRoles: AuthtypesRoleDTO[] = [
},
];
export const allRoles: AuthtypesRoleDTO[] = [...managedRoles, ...customRoles];
export const allRoles: RoletypesRoleDTO[] = [...managedRoles, ...customRoles];
export const listRolesSuccessResponse = {
status: 'success',

View File

@@ -1 +0,0 @@
export { default } from 'container/ServiceAccountsSettings/ServiceAccountsSettings';

View File

@@ -5,7 +5,6 @@ import logEvent from 'api/common/logEvent';
import RouteTab from 'components/RouteTab';
import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes';
import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config';
import { routeConfig } from 'container/SideNav/config';
import { getQueryString } from 'container/SideNav/helper';
import { settingsNavSections } from 'container/SideNav/menuItems';
@@ -86,8 +85,6 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.INGESTION_SETTINGS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
(IS_SERVICE_ACCOUNTS_ENABLED &&
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS) ||
item.key === ROUTES.SHORTCUTS
? true
: item.isEnabled,
@@ -119,8 +116,6 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
(IS_SERVICE_ACCOUNTS_ENABLED &&
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS) ||
item.key === ROUTES.INGESTION_SETTINGS
? true
: item.isEnabled,
@@ -146,9 +141,7 @@ function SettingsPage(): JSX.Element {
isEnabled:
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
(IS_SERVICE_ACCOUNTS_ENABLED &&
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS)
item.key === ROUTES.MEMBERS_SETTINGS
? true
: item.isEnabled,
}));

View File

@@ -17,7 +17,6 @@ import { TFunction } from 'i18next';
import {
Backpack,
BellDot,
Bot,
Building,
Cpu,
CreditCard,
@@ -31,7 +30,6 @@ import {
} from 'lucide-react';
import ChannelsEdit from 'pages/ChannelsEdit';
import MembersSettings from 'pages/MembersSettings';
import ServiceAccountsSettings from 'pages/ServiceAccountsSettings';
import Shortcuts from 'pages/Shortcuts';
export const organizationSettings = (t: TFunction): RouteTabProps['routes'] => [
@@ -205,21 +203,6 @@ export const mySettings = (t: TFunction): RouteTabProps['routes'] => [
},
];
export const serviceAccountsSettings = (
t: TFunction,
): RouteTabProps['routes'] => [
{
Component: ServiceAccountsSettings,
name: (
<div className="periscope-tab">
<Bot size={16} /> {t('routes:service_accounts').toString()}
</div>
),
route: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
key: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
},
];
export const createAlertChannels = (t: TFunction): RouteTabProps['routes'] => [
{
Component: (): JSX.Element => (

View File

@@ -1,5 +1,4 @@
import { RouteTabProps } from 'components/RouteTab/types';
import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config';
import { TFunction } from 'i18next';
import { ROLES, USER_ROLES } from 'types/roles';
@@ -18,7 +17,6 @@ import {
organizationSettings,
roleDetails,
rolesSettings,
serviceAccountsSettings,
} from './config';
export const getRoutes = (
@@ -65,10 +63,6 @@ export const getRoutes = (
if (isAdmin) {
settings.push(...apiKeys(t), ...membersSettings(t));
if (IS_SERVICE_ACCOUNTS_ENABLED) {
settings.push(...serviceAccountsSettings(t));
}
}
// todo: Sagar - check the condition for role list and details page, to whom we want to serve

View File

@@ -1,7 +1,6 @@
import getLocalStorage from 'api/browser/localstorage/get';
import { FeatureKeys } from 'constants/features';
import { SKIP_ONBOARDING } from 'constants/onboarding';
import dayjs from 'dayjs';
import { get } from 'lodash-es';
import { getLocation } from 'utils/getLocation';
@@ -74,19 +73,3 @@ export function buildAbsolutePath({
}
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function toISOString(
date: Date | string | number | null | undefined,
): string | null {
if (date == null) {
return null;
}
const d = dayjs(date);
if (!d.isValid()) {
return null;
}
return d.toISOString();
}

View File

@@ -100,7 +100,6 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
ROLES_SETTINGS: ['ADMIN'],
ROLE_DETAILS: ['ADMIN'],
MEMBERS_SETTINGS: ['ADMIN'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN'],
BILLING: ['ADMIN'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@@ -16824,11 +16824,6 @@ react-helmet-async@*, react-helmet-async@1.3.0:
react-fast-compare "^3.2.0"
shallowequal "^1.1.0"
react-hook-form@7.71.2:
version "7.71.2"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.71.2.tgz#a5f1d2b855be9ecf1af6e74df9b80f54beae7e35"
integrity sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==
react-hooks-testing-library@0.6.0:
version "0.6.0"
resolved "https://registry.npmjs.org/react-hooks-testing-library/-/react-hooks-testing-library-0.6.0.tgz"

5
go.mod
View File

@@ -16,6 +16,7 @@ require (
github.com/coreos/go-oidc/v3 v3.17.0
github.com/dgraph-io/ristretto/v2 v2.3.0
github.com/dustin/go-humanize v1.0.1
github.com/emersion/go-smtp v0.24.0
github.com/gin-gonic/gin v1.11.0
github.com/go-co-op/gocron v1.30.1
github.com/go-openapi/runtime v0.29.2
@@ -64,6 +65,7 @@ require (
github.com/uptrace/bun/dialect/pgdialect v1.2.9
github.com/uptrace/bun/dialect/sqlitedialect v1.2.9
github.com/uptrace/bun/extra/bunotel v1.2.9
github.com/yuin/goldmark v1.7.16
go.opentelemetry.io/collector/confmap v1.51.0
go.opentelemetry.io/collector/otelcol v0.144.0
go.opentelemetry.io/collector/pdata v1.51.0
@@ -108,6 +110,7 @@ require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
@@ -161,7 +164,7 @@ require (
github.com/ClickHouse/ch-go v0.67.0 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/Yiling-J/theine-go v0.6.2 // indirect
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/beevik/etree v1.1.0 // indirect

2
go.sum
View File

@@ -1142,6 +1142,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A=

View File

@@ -0,0 +1,430 @@
package email
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"log/slog"
"math/rand"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
"net/mail"
"net/smtp"
"net/textproto"
"os"
"strings"
"sync"
"time"
"github.com/SigNoz/signoz/pkg/errors"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
const (
Integration = "email"
)
// Email implements a Notifier for email notifications.
type Email struct {
conf *config.EmailConfig
tmpl *template.Template
logger *slog.Logger
hostname string
}
var errNoAuthUserNameConfigured = errors.NewInternalf(errors.CodeInternal, "no auth username configured")
// New returns a new Email notifier.
func New(c *config.EmailConfig, t *template.Template, l *slog.Logger) *Email {
if _, ok := c.Headers["Subject"]; !ok {
c.Headers["Subject"] = config.DefaultEmailSubject
}
if _, ok := c.Headers["To"]; !ok {
c.Headers["To"] = c.To
}
if _, ok := c.Headers["From"]; !ok {
c.Headers["From"] = c.From
}
h, err := os.Hostname()
// If we can't get the hostname, we'll use localhost
if err != nil {
h = "localhost.localdomain"
}
return &Email{conf: c, tmpl: t, logger: l, hostname: h}
}
// auth resolves a string of authentication mechanisms.
func (n *Email) auth(mechs string) (smtp.Auth, error) {
username := n.conf.AuthUsername
// If no username is set, return custom error which can be ignored if needed.
if n.conf.AuthUsername == "" {
return nil, errNoAuthUserNameConfigured
}
err := &types.MultiError{}
for mech := range strings.SplitSeq(mechs, " ") {
switch mech {
case "CRAM-MD5":
secret, secretErr := n.getAuthSecret()
if secretErr != nil {
err.Add(secretErr)
continue
}
if secret == "" {
err.Add(errors.NewInternalf(errors.CodeInternal, "missing secret for CRAM-MD5 auth mechanism"))
continue
}
return smtp.CRAMMD5Auth(username, secret), nil
case "PLAIN":
password, passwordErr := n.getPassword()
if passwordErr != nil {
err.Add(passwordErr)
continue
}
if password == "" {
err.Add(errors.NewInternalf(errors.CodeInternal, "missing password for PLAIN auth mechanism"))
continue
}
identity := n.conf.AuthIdentity
return smtp.PlainAuth(identity, username, password, n.conf.Smarthost.Host), nil
case "LOGIN":
password, passwordErr := n.getPassword()
if passwordErr != nil {
err.Add(passwordErr)
continue
}
if password == "" {
err.Add(errors.NewInternalf(errors.CodeInternal, "missing password for LOGIN auth mechanism"))
continue
}
return LoginAuth(username, password), nil
}
}
if err.Len() == 0 {
err.Add(errors.NewInternalf(errors.CodeInternal, "unknown auth mechanism: %s", mechs))
}
return nil, err
}
// Notify implements the Notifier interface.
func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
var (
c *smtp.Client
conn net.Conn
err error
success = false
)
// Determine whether to use Implicit TLS
var useImplicitTLS bool
if n.conf.ForceImplicitTLS != nil {
useImplicitTLS = *n.conf.ForceImplicitTLS
} else {
// Default logic: port 465 uses implicit TLS (backward compatibility)
useImplicitTLS = n.conf.Smarthost.Port == "465"
}
if useImplicitTLS {
tlsConfig, err := commoncfg.NewTLSConfig(n.conf.TLSConfig)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse TLS configuration")
}
if tlsConfig.ServerName == "" {
tlsConfig.ServerName = n.conf.Smarthost.Host
}
conn, err = tls.Dial("tcp", n.conf.Smarthost.String(), tlsConfig)
if err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "establish TLS connection to server")
}
} else {
var (
d = net.Dialer{}
err error
)
conn, err = d.DialContext(ctx, "tcp", n.conf.Smarthost.String())
if err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "establish connection to server")
}
}
c, err = smtp.NewClient(conn, n.conf.Smarthost.Host)
if err != nil {
conn.Close()
return true, errors.WrapInternalf(err, errors.CodeInternal, "create SMTP client")
}
defer func() {
// Try to clean up after ourselves but don't log anything if something has failed.
if err := c.Quit(); success && err != nil {
n.logger.WarnContext(ctx, "failed to close SMTP connection", "err", err)
}
}()
if n.conf.Hello != "" {
err = c.Hello(n.conf.Hello)
if err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "send EHLO command")
}
}
// Global Config guarantees RequireTLS is not nil.
if *n.conf.RequireTLS && !useImplicitTLS {
if ok, _ := c.Extension("STARTTLS"); !ok {
return true, errors.WrapInternalf(err, errors.CodeInternal, "'require_tls' is true (default) but %q does not advertise the STARTTLS extension", n.conf.Smarthost)
}
tlsConf, err := commoncfg.NewTLSConfig(n.conf.TLSConfig)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse TLS configuration")
}
if tlsConf.ServerName == "" {
tlsConf.ServerName = n.conf.Smarthost.Host
}
if err := c.StartTLS(tlsConf); err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "send STARTTLS command")
}
}
if ok, mech := c.Extension("AUTH"); ok {
auth, err := n.auth(mech)
if err != nil && err != errNoAuthUserNameConfigured {
return true, errors.WrapInternalf(err, errors.CodeInternal, "find auth mechanism")
} else if err == errNoAuthUserNameConfigured {
n.logger.DebugContext(ctx, "no auth username configured. Attempting to send email without authenticating")
}
if auth != nil {
if err := c.Auth(auth); err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "%T auth", auth)
}
}
}
var (
tmplErr error
data = notify.GetTemplateData(ctx, n.tmpl, as, n.logger)
tmpl = notify.TmplText(n.tmpl, data, &tmplErr)
)
from := tmpl(n.conf.From)
if tmplErr != nil {
return false, errors.WrapInternalf(tmplErr, errors.CodeInternal, "execute 'from' template")
}
to := tmpl(n.conf.To)
if tmplErr != nil {
return false, errors.WrapInternalf(tmplErr, errors.CodeInternal, "execute 'to' template")
}
addrs, err := mail.ParseAddressList(from)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse 'from' addresses")
}
if len(addrs) != 1 {
return false, errors.NewInternalf(errors.CodeInternal, "must be exactly one 'from' address (got: %d)", len(addrs))
}
if err = c.Mail(addrs[0].Address); err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "send MAIL command")
}
addrs, err = mail.ParseAddressList(to)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse 'to' addresses")
}
for _, addr := range addrs {
if err = c.Rcpt(addr.Address); err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "send RCPT command")
}
}
// Send the email headers and body.
message, err := c.Data()
if err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "send DATA command")
}
closeOnce := sync.OnceValue(func() error {
return message.Close()
})
// Close the message when this method exits in order to not leak resources. Even though we're calling this explicitly
// further down, the method may exit before then.
defer func() {
// If we try close an already-closed writer, it'll send a subsequent request to the server which is invalid.
_ = closeOnce()
}()
buffer := &bytes.Buffer{}
for header, t := range n.conf.Headers {
value, err := n.tmpl.ExecuteTextString(t, data)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute %q header template", header)
}
fmt.Fprintf(buffer, "%s: %s\r\n", header, mime.QEncoding.Encode("utf-8", value))
}
if _, ok := n.conf.Headers["Message-Id"]; !ok {
fmt.Fprintf(buffer, "Message-Id: %s\r\n", fmt.Sprintf("<%d.%d@%s>", time.Now().UnixNano(), rand.Uint64(), n.hostname))
}
if n.conf.Threading.Enabled {
key, err := notify.ExtractGroupKey(ctx)
if err != nil {
return false, err
}
// Add threading headers. All notifications for the same alert group
// (identified by key hash) are threaded together.
threadBy := ""
if n.conf.Threading.ThreadByDate != "none" {
// ThreadByDate is 'daily':
// Use current date so all mails for this alert today thread together.
threadBy = time.Now().Format("2006-01-02")
}
keyHash := key.Hash()
if len(keyHash) > 16 {
keyHash = keyHash[:16]
}
// The thread root ID is a Message-ID that doesn't correspond to
// any actual email. Email clients following the (commonly used) JWZ
// algorithm will create a dummy container to group these messages.
threadRootID := fmt.Sprintf("<alert-%s-%s@alertmanager>", keyHash, threadBy)
fmt.Fprintf(buffer, "References: %s\r\n", threadRootID)
fmt.Fprintf(buffer, "In-Reply-To: %s\r\n", threadRootID)
}
multipartBuffer := &bytes.Buffer{}
multipartWriter := multipart.NewWriter(multipartBuffer)
fmt.Fprintf(buffer, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
fmt.Fprintf(buffer, "Content-Type: multipart/alternative; boundary=%s\r\n", multipartWriter.Boundary())
fmt.Fprintf(buffer, "MIME-Version: 1.0\r\n\r\n")
// TODO: Add some useful headers here, such as URL of the alertmanager
// and active/resolved.
_, err = message.Write(buffer.Bytes())
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "write headers")
}
if len(n.conf.Text) > 0 {
// Text template
w, err := multipartWriter.CreatePart(textproto.MIMEHeader{
"Content-Transfer-Encoding": {"quoted-printable"},
"Content-Type": {"text/plain; charset=UTF-8"},
})
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "create part for text template")
}
body, err := n.tmpl.ExecuteTextString(n.conf.Text, data)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute text template")
}
qw := quotedprintable.NewWriter(w)
_, err = qw.Write([]byte(body))
if err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "write text part")
}
err = qw.Close()
if err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "close text part")
}
}
if len(n.conf.HTML) > 0 {
// Html template
// Preferred alternative placed last per section 5.1.4 of RFC 2046
// https://www.ietf.org/rfc/rfc2046.txt
w, err := multipartWriter.CreatePart(textproto.MIMEHeader{
"Content-Transfer-Encoding": {"quoted-printable"},
"Content-Type": {"text/html; charset=UTF-8"},
})
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "create part for html template")
}
body, err := n.tmpl.ExecuteHTMLString(n.conf.HTML, data)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute html template")
}
qw := quotedprintable.NewWriter(w)
_, err = qw.Write([]byte(body))
if err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "write HTML part")
}
err = qw.Close()
if err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "close HTML part")
}
}
err = multipartWriter.Close()
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "close multipartWriter")
}
_, err = message.Write(multipartBuffer.Bytes())
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "write body buffer")
}
// Complete the message and await response.
if err = closeOnce(); err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "delivery failure")
}
success = true
return false, nil
}
type loginAuth struct {
username, password string
}
func LoginAuth(username, password string) smtp.Auth {
return &loginAuth{username, password}
}
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", []byte{}, nil
}
// Used for AUTH LOGIN. (Maybe password should be encrypted).
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch strings.ToLower(string(fromServer)) {
case "username:":
return []byte(a.username), nil
case "password:":
return []byte(a.password), nil
default:
return nil, errors.NewInternalf(errors.CodeInternal, "unexpected server challenge")
}
}
return nil, nil
}
func (n *Email) getPassword() (string, error) {
if len(n.conf.AuthPasswordFile) > 0 {
content, err := os.ReadFile(n.conf.AuthPasswordFile)
if err != nil {
return "", errors.NewInternalf(errors.CodeInternal, "could not read %s: %v", n.conf.AuthPasswordFile, err)
}
return strings.TrimSpace(string(content)), nil
}
return string(n.conf.AuthPassword), nil
}
func (n *Email) getAuthSecret() (string, error) {
if len(n.conf.AuthSecretFile) > 0 {
content, err := os.ReadFile(n.conf.AuthSecretFile)
if err != nil {
return "", errors.NewInternalf(errors.CodeInternal, "could not read %s: %v", n.conf.AuthSecretFile, err)
}
return string(content), nil
}
return string(n.conf.AuthSecret), nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
smarthost: 127.0.0.1:1026
server: http://127.0.0.1:1081/
username: user
password: pass

View File

@@ -0,0 +1,4 @@
smarthost: maildev-auth:1025
server: http://maildev-auth:1080/
username: user
password: pass

View File

@@ -0,0 +1,2 @@
smarthost: 127.0.0.1:1025
server: http://127.0.0.1:1080/

View File

@@ -0,0 +1,2 @@
smarthost: maildev-noauth:1025
server: http://maildev-noauth:1080/

View File

@@ -27,6 +27,10 @@ const (
colorGrey = "Warning"
)
const (
Integration = "msteamsv2"
)
type Notifier struct {
conf *config.MSTeamsV2Config
titleLink string
@@ -87,7 +91,7 @@ type teamsMessage struct {
// New returns a new notifier that uses the Microsoft Teams Power Platform connector.
func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := commoncfg.NewClientFromConfig(*c.HTTPConfig, "msteamsv2", httpOpts...)
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,2 @@
my_secret_api_key

View File

@@ -0,0 +1,290 @@
package opsgenie
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"maps"
"net/http"
"os"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
const (
Integration = "opsgenie"
)
// https://docs.opsgenie.com/docs/alert-api - 130 characters meaning runes.
const maxMessageLenRunes = 130
// Notifier implements a Notifier for OpsGenie notifications.
type Notifier struct {
conf *config.OpsGenieConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
}
// New returns a new OpsGenie notifier.
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}
return &Notifier{
conf: c,
tmpl: t,
logger: l,
client: client,
retrier: &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
}, nil
}
type opsGenieCreateMessage struct {
Alias string `json:"alias"`
Message string `json:"message"`
Description string `json:"description,omitempty"`
Details map[string]string `json:"details"`
Source string `json:"source"`
Responders []opsGenieCreateMessageResponder `json:"responders,omitempty"`
Tags []string `json:"tags,omitempty"`
Note string `json:"note,omitempty"`
Priority string `json:"priority,omitempty"`
Entity string `json:"entity,omitempty"`
Actions []string `json:"actions,omitempty"`
}
type opsGenieCreateMessageResponder struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Username string `json:"username,omitempty"`
Type string `json:"type"` // team, user, escalation, schedule etc.
}
type opsGenieCloseMessage struct {
Source string `json:"source"`
}
type opsGenieUpdateMessageMessage struct {
Message string `json:"message,omitempty"`
}
type opsGenieUpdateDescriptionMessage struct {
Description string `json:"description,omitempty"`
}
// Notify implements the Notifier interface.
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
requests, retry, err := n.createRequests(ctx, as...)
if err != nil {
return retry, err
}
for _, req := range requests {
req.Header.Set("User-Agent", notify.UserAgentHeader)
resp, err := n.client.Do(req) //nolint:bodyclose
if err != nil {
return true, err
}
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
notify.Drain(resp)
if err != nil {
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
}
}
return true, nil
}
// Like Split but filter out empty strings.
func safeSplit(s, sep string) []string {
a := strings.Split(strings.TrimSpace(s), sep)
b := a[:0]
for _, x := range a {
if x != "" {
b = append(b, x)
}
}
return b
}
// Create requests for a list of alerts.
func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*http.Request, bool, error) {
key, err := notify.ExtractGroupKey(ctx)
if err != nil {
return nil, false, err
}
logger := n.logger.With("group_key", key)
logger.DebugContext(ctx, "extracted group key")
data := notify.GetTemplateData(ctx, n.tmpl, as, logger)
tmpl := notify.TmplText(n.tmpl, data, &err)
details := make(map[string]string)
maps.Copy(details, data.CommonLabels)
for k, v := range n.conf.Details {
details[k] = tmpl(v)
}
requests := []*http.Request{}
var (
alias = key.Hash()
alerts = types.Alerts(as...)
)
switch alerts.Status() {
case model.AlertResolved:
resolvedEndpointURL := n.conf.APIURL.Copy()
resolvedEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/close", alias)
q := resolvedEndpointURL.Query()
q.Set("identifierType", "alias")
resolvedEndpointURL.RawQuery = q.Encode()
msg := &opsGenieCloseMessage{Source: tmpl(n.conf.Source)}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
return nil, false, err
}
req, err := http.NewRequest("POST", resolvedEndpointURL.String(), &buf)
if err != nil {
return nil, true, err
}
requests = append(requests, req.WithContext(ctx))
default:
message, truncated := notify.TruncateInRunes(tmpl(n.conf.Message), maxMessageLenRunes)
if truncated {
logger.WarnContext(ctx, "Truncated message", "alert", key, "max_runes", maxMessageLenRunes)
}
createEndpointURL := n.conf.APIURL.Copy()
createEndpointURL.Path += "v2/alerts"
var responders []opsGenieCreateMessageResponder
for _, r := range n.conf.Responders {
responder := opsGenieCreateMessageResponder{
ID: tmpl(r.ID),
Name: tmpl(r.Name),
Username: tmpl(r.Username),
Type: tmpl(r.Type),
}
if responder == (opsGenieCreateMessageResponder{}) {
// Filter out empty responders. This is useful if you want to fill
// responders dynamically from alert's common labels.
continue
}
if responder.Type == "teams" {
teams := safeSplit(responder.Name, ",")
for _, team := range teams {
newResponder := opsGenieCreateMessageResponder{
Name: tmpl(team),
Type: tmpl("team"),
}
responders = append(responders, newResponder)
}
continue
}
responders = append(responders, responder)
}
msg := &opsGenieCreateMessage{
Alias: alias,
Message: message,
Description: tmpl(n.conf.Description),
Details: details,
Source: tmpl(n.conf.Source),
Responders: responders,
Tags: safeSplit(tmpl(n.conf.Tags), ","),
Note: tmpl(n.conf.Note),
Priority: tmpl(n.conf.Priority),
Entity: tmpl(n.conf.Entity),
Actions: safeSplit(tmpl(n.conf.Actions), ","),
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
return nil, false, err
}
req, err := http.NewRequest("POST", createEndpointURL.String(), &buf)
if err != nil {
return nil, true, err
}
requests = append(requests, req.WithContext(ctx))
if n.conf.UpdateAlerts {
updateMessageEndpointURL := n.conf.APIURL.Copy()
updateMessageEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/message", alias)
q := updateMessageEndpointURL.Query()
q.Set("identifierType", "alias")
updateMessageEndpointURL.RawQuery = q.Encode()
updateMsgMsg := &opsGenieUpdateMessageMessage{
Message: msg.Message,
}
var updateMessageBuf bytes.Buffer
if err := json.NewEncoder(&updateMessageBuf).Encode(updateMsgMsg); err != nil {
return nil, false, err
}
req, err := http.NewRequest("PUT", updateMessageEndpointURL.String(), &updateMessageBuf)
if err != nil {
return nil, true, err
}
requests = append(requests, req)
updateDescriptionEndpointURL := n.conf.APIURL.Copy()
updateDescriptionEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/description", alias)
q = updateDescriptionEndpointURL.Query()
q.Set("identifierType", "alias")
updateDescriptionEndpointURL.RawQuery = q.Encode()
updateDescMsg := &opsGenieUpdateDescriptionMessage{
Description: msg.Description,
}
var updateDescriptionBuf bytes.Buffer
if err := json.NewEncoder(&updateDescriptionBuf).Encode(updateDescMsg); err != nil {
return nil, false, err
}
req, err = http.NewRequest("PUT", updateDescriptionEndpointURL.String(), &updateDescriptionBuf)
if err != nil {
return nil, true, err
}
requests = append(requests, req.WithContext(ctx))
}
}
var apiKey string
if n.conf.APIKey != "" {
apiKey = tmpl(string(n.conf.APIKey))
} else {
content, err := os.ReadFile(n.conf.APIKeyFile)
if err != nil {
return nil, false, errors.WrapInternalf(err, errors.CodeInternal, "read key_file error")
}
apiKey = tmpl(string(content))
apiKey = strings.TrimSpace(string(apiKey))
}
if err != nil {
return nil, false, errors.WrapInternalf(err, errors.CodeInternal, "templating error")
}
for _, req := range requests {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("GenieKey %s", apiKey))
}
return requests, true, nil
}

View File

@@ -0,0 +1,333 @@
package opsgenie
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"testing"
"time"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/require"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/notify/test"
"github.com/prometheus/alertmanager/types"
)
func TestOpsGenieRetry(t *testing.T) {
notifier, err := New(
&config.OpsGenieConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
retryCodes := append(test.DefaultRetryCodes(), http.StatusTooManyRequests)
for statusCode, expected := range test.RetryTests(retryCodes) {
actual, _ := notifier.retrier.Check(statusCode, nil)
require.Equal(t, expected, actual, "error on status %d", statusCode)
}
}
func TestOpsGenieRedactedURL(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
key := "key"
notifier, err := New(
&config.OpsGenieConfig{
APIURL: &config.URL{URL: u},
APIKey: config.Secret(key),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
}
func TestGettingOpsGegineApikeyFromFile(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
key := "key"
f, err := os.CreateTemp(t.TempDir(), "opsgenie_test")
require.NoError(t, err, "creating temp file failed")
_, err = f.WriteString(key)
require.NoError(t, err, "writing to temp file failed")
notifier, err := New(
&config.OpsGenieConfig{
APIURL: &config.URL{URL: u},
APIKeyFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
}
func TestOpsGenie(t *testing.T) {
u, err := url.Parse("https://opsgenie/api")
if err != nil {
t.Fatalf("failed to parse URL: %v", err)
}
logger := promslog.NewNopLogger()
tmpl := test.CreateTmpl(t)
for _, tc := range []struct {
title string
cfg *config.OpsGenieConfig
expectedEmptyAlertBody string
expectedBody string
}{
{
title: "config without details",
cfg: &config.OpsGenieConfig{
NotifierConfig: config.NotifierConfig{
VSendResolved: true,
},
Message: `{{ .CommonLabels.Message }}`,
Description: `{{ .CommonLabels.Description }}`,
Source: `{{ .CommonLabels.Source }}`,
Responders: []config.OpsGenieConfigResponder{
{
Name: `{{ .CommonLabels.ResponderName1 }}`,
Type: `{{ .CommonLabels.ResponderType1 }}`,
},
{
Name: `{{ .CommonLabels.ResponderName2 }}`,
Type: `{{ .CommonLabels.ResponderType2 }}`,
},
},
Tags: `{{ .CommonLabels.Tags }}`,
Note: `{{ .CommonLabels.Note }}`,
Priority: `{{ .CommonLabels.Priority }}`,
Entity: `{{ .CommonLabels.Entity }}`,
Actions: `{{ .CommonLabels.Actions }}`,
APIKey: `{{ .ExternalURL }}`,
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
expectedEmptyAlertBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"","details":{},"source":""}
`,
expectedBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"message","description":"description","details":{"Actions":"doThis,doThat","Description":"description","Entity":"test-domain","Message":"message","Note":"this is a note","Priority":"P1","ResponderName1":"TeamA","ResponderName2":"EscalationA","ResponderName3":"TeamA,TeamB","ResponderType1":"team","ResponderType2":"escalation","ResponderType3":"teams","Source":"http://prometheus","Tags":"tag1,tag2"},"source":"http://prometheus","responders":[{"name":"TeamA","type":"team"},{"name":"EscalationA","type":"escalation"}],"tags":["tag1","tag2"],"note":"this is a note","priority":"P1","entity":"test-domain","actions":["doThis","doThat"]}
`,
},
{
title: "config with details",
cfg: &config.OpsGenieConfig{
NotifierConfig: config.NotifierConfig{
VSendResolved: true,
},
Message: `{{ .CommonLabels.Message }}`,
Description: `{{ .CommonLabels.Description }}`,
Source: `{{ .CommonLabels.Source }}`,
Details: map[string]string{
"Description": `adjusted {{ .CommonLabels.Description }}`,
},
Responders: []config.OpsGenieConfigResponder{
{
Name: `{{ .CommonLabels.ResponderName1 }}`,
Type: `{{ .CommonLabels.ResponderType1 }}`,
},
{
Name: `{{ .CommonLabels.ResponderName2 }}`,
Type: `{{ .CommonLabels.ResponderType2 }}`,
},
},
Tags: `{{ .CommonLabels.Tags }}`,
Note: `{{ .CommonLabels.Note }}`,
Priority: `{{ .CommonLabels.Priority }}`,
Entity: `{{ .CommonLabels.Entity }}`,
Actions: `{{ .CommonLabels.Actions }}`,
APIKey: `{{ .ExternalURL }}`,
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
expectedEmptyAlertBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"","details":{"Description":"adjusted "},"source":""}
`,
expectedBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"message","description":"description","details":{"Actions":"doThis,doThat","Description":"adjusted description","Entity":"test-domain","Message":"message","Note":"this is a note","Priority":"P1","ResponderName1":"TeamA","ResponderName2":"EscalationA","ResponderName3":"TeamA,TeamB","ResponderType1":"team","ResponderType2":"escalation","ResponderType3":"teams","Source":"http://prometheus","Tags":"tag1,tag2"},"source":"http://prometheus","responders":[{"name":"TeamA","type":"team"},{"name":"EscalationA","type":"escalation"}],"tags":["tag1","tag2"],"note":"this is a note","priority":"P1","entity":"test-domain","actions":["doThis","doThat"]}
`,
},
{
title: "config with multiple teams",
cfg: &config.OpsGenieConfig{
NotifierConfig: config.NotifierConfig{
VSendResolved: true,
},
Message: `{{ .CommonLabels.Message }}`,
Description: `{{ .CommonLabels.Description }}`,
Source: `{{ .CommonLabels.Source }}`,
Details: map[string]string{
"Description": `adjusted {{ .CommonLabels.Description }}`,
},
Responders: []config.OpsGenieConfigResponder{
{
Name: `{{ .CommonLabels.ResponderName3 }}`,
Type: `{{ .CommonLabels.ResponderType3 }}`,
},
},
Tags: `{{ .CommonLabels.Tags }}`,
Note: `{{ .CommonLabels.Note }}`,
Priority: `{{ .CommonLabels.Priority }}`,
APIKey: `{{ .ExternalURL }}`,
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
expectedEmptyAlertBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"","details":{"Description":"adjusted "},"source":""}
`,
expectedBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"message","description":"description","details":{"Actions":"doThis,doThat","Description":"adjusted description","Entity":"test-domain","Message":"message","Note":"this is a note","Priority":"P1","ResponderName1":"TeamA","ResponderName2":"EscalationA","ResponderName3":"TeamA,TeamB","ResponderType1":"team","ResponderType2":"escalation","ResponderType3":"teams","Source":"http://prometheus","Tags":"tag1,tag2"},"source":"http://prometheus","responders":[{"name":"TeamA","type":"team"},{"name":"TeamB","type":"team"}],"tags":["tag1","tag2"],"note":"this is a note","priority":"P1"}
`,
},
} {
t.Run(tc.title, func(t *testing.T) {
notifier, err := New(tc.cfg, tmpl, logger)
require.NoError(t, err)
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
expectedURL, _ := url.Parse("https://opsgenie/apiv2/alerts")
// Empty alert.
alert1 := &types.Alert{
Alert: model.Alert{
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
req, retry, err := notifier.createRequests(ctx, alert1)
require.NoError(t, err)
require.Len(t, req, 1)
require.True(t, retry)
require.Equal(t, expectedURL, req[0].URL)
require.Equal(t, "GenieKey http://am", req[0].Header.Get("Authorization"))
require.Equal(t, tc.expectedEmptyAlertBody, readBody(t, req[0]))
// Fully defined alert.
alert2 := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
"Message": "message",
"Description": "description",
"Source": "http://prometheus",
"ResponderName1": "TeamA",
"ResponderType1": "team",
"ResponderName2": "EscalationA",
"ResponderType2": "escalation",
"ResponderName3": "TeamA,TeamB",
"ResponderType3": "teams",
"Tags": "tag1,tag2",
"Note": "this is a note",
"Priority": "P1",
"Entity": "test-domain",
"Actions": "doThis,doThat",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
req, retry, err = notifier.createRequests(ctx, alert2)
require.NoError(t, err)
require.True(t, retry)
require.Len(t, req, 1)
require.Equal(t, tc.expectedBody, readBody(t, req[0]))
// Broken API Key Template.
tc.cfg.APIKey = "{{ kaput "
_, _, err = notifier.createRequests(ctx, alert2)
require.Error(t, err)
require.Equal(t, "template: :1: function \"kaput\" not defined", err.Error())
})
}
}
func TestOpsGenieWithUpdate(t *testing.T) {
u, err := url.Parse("https://test-opsgenie-url")
require.NoError(t, err)
tmpl := test.CreateTmpl(t)
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
opsGenieConfigWithUpdate := config.OpsGenieConfig{
Message: `{{ .CommonLabels.Message }}`,
Description: `{{ .CommonLabels.Description }}`,
UpdateAlerts: true,
APIKey: "test-api-key",
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger())
alert := &types.Alert{
Alert: model.Alert{
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
Labels: model.LabelSet{
"Message": "new message",
"Description": "new description",
},
},
}
require.NoError(t, err)
requests, retry, err := notifierWithUpdate.createRequests(ctx, alert)
require.NoError(t, err)
require.True(t, retry)
require.Len(t, requests, 3)
body0 := readBody(t, requests[0])
body1 := readBody(t, requests[1])
body2 := readBody(t, requests[2])
key, _ := notify.ExtractGroupKey(ctx)
alias := key.Hash()
require.Equal(t, "https://test-opsgenie-url/v2/alerts", requests[0].URL.String())
require.NotEmpty(t, body0)
require.Equal(t, requests[1].URL.String(), fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/message?identifierType=alias", alias))
require.JSONEq(t, `{"message":"new message"}`, body1)
require.Equal(t, requests[2].URL.String(), fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/description?identifierType=alias", alias))
require.JSONEq(t, `{"description":"new description"}`, body2)
}
func TestOpsGenieApiKeyFile(t *testing.T) {
u, err := url.Parse("https://test-opsgenie-url")
require.NoError(t, err)
tmpl := test.CreateTmpl(t)
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
opsGenieConfigWithUpdate := config.OpsGenieConfig{
APIKeyFile: `./api_key_file`,
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger())
require.NoError(t, err)
requests, _, err := notifierWithUpdate.createRequests(ctx)
require.NoError(t, err)
require.Equal(t, "GenieKey my_secret_api_key", requests[0].Header.Get("Authorization"))
}
func readBody(t *testing.T, r *http.Request) string {
t.Helper()
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
return string(body)
}

View File

@@ -0,0 +1,374 @@
package pagerduty
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/alecthomas/units"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
const (
Integration = "pagerduty"
)
const (
maxEventSize int = 512000
// https://developer.pagerduty.com/docs/ZG9jOjExMDI5NTc4-send-a-v1-event - 1024 characters or runes.
maxV1DescriptionLenRunes = 1024
// https://developer.pagerduty.com/docs/ZG9jOjExMDI5NTgx-send-an-alert-event - 1024 characters or runes.
maxV2SummaryLenRunes = 1024
)
// Notifier implements a Notifier for PagerDuty notifications.
type Notifier struct {
conf *config.PagerdutyConfig
tmpl *template.Template
logger *slog.Logger
apiV1 string // for tests.
client *http.Client
retrier *notify.Retrier
}
// New returns a new PagerDuty notifier.
func New(c *config.PagerdutyConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}
n := &Notifier{conf: c, tmpl: t, logger: l, client: client}
if c.ServiceKey != "" || c.ServiceKeyFile != "" {
n.apiV1 = "https://events.pagerduty.com/generic/2010-04-15/create_event.json"
// Retrying can solve the issue on 403 (rate limiting) and 5xx response codes.
// https://developer.pagerduty.com/docs/events-api-v1-overview#api-response-codes--retry-logic
n.retrier = &notify.Retrier{RetryCodes: []int{http.StatusForbidden}, CustomDetailsFunc: errDetails}
} else {
// Retrying can solve the issue on 429 (rate limiting) and 5xx response codes.
// https://developer.pagerduty.com/docs/events-api-v2-overview#response-codes--retry-logic
n.retrier = &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}, CustomDetailsFunc: errDetails}
}
return n, nil
}
const (
pagerDutyEventTrigger = "trigger"
pagerDutyEventResolve = "resolve"
)
type pagerDutyMessage struct {
RoutingKey string `json:"routing_key,omitempty"`
ServiceKey string `json:"service_key,omitempty"`
DedupKey string `json:"dedup_key,omitempty"`
IncidentKey string `json:"incident_key,omitempty"`
EventType string `json:"event_type,omitempty"`
Description string `json:"description,omitempty"`
EventAction string `json:"event_action"`
Payload *pagerDutyPayload `json:"payload"`
Client string `json:"client,omitempty"`
ClientURL string `json:"client_url,omitempty"`
Details map[string]any `json:"details,omitempty"`
Images []pagerDutyImage `json:"images,omitempty"`
Links []pagerDutyLink `json:"links,omitempty"`
}
type pagerDutyLink struct {
HRef string `json:"href"`
Text string `json:"text"`
}
type pagerDutyImage struct {
Src string `json:"src"`
Alt string `json:"alt"`
Href string `json:"href"`
}
type pagerDutyPayload struct {
Summary string `json:"summary"`
Source string `json:"source"`
Severity string `json:"severity"`
Timestamp string `json:"timestamp,omitempty"`
Class string `json:"class,omitempty"`
Component string `json:"component,omitempty"`
Group string `json:"group,omitempty"`
CustomDetails map[string]any `json:"custom_details,omitempty"`
}
func (n *Notifier) encodeMessage(ctx context.Context, msg *pagerDutyMessage) (bytes.Buffer, error) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
return buf, errors.WrapInternalf(err, errors.CodeInternal, "failed to encode PagerDuty message")
}
if buf.Len() > maxEventSize {
truncatedMsg := fmt.Sprintf("Custom details have been removed because the original event exceeds the maximum size of %s", units.MetricBytes(maxEventSize).String())
if n.apiV1 != "" {
msg.Details = map[string]any{"error": truncatedMsg}
} else {
msg.Payload.CustomDetails = map[string]any{"error": truncatedMsg}
}
n.logger.WarnContext(ctx, "Truncated Details because message of size exceeds limit", "message_size", units.MetricBytes(buf.Len()).String(), "max_size", units.MetricBytes(maxEventSize).String())
buf.Reset()
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
return buf, errors.WrapInternalf(err, errors.CodeInternal, "failed to encode PagerDuty message")
}
}
return buf, nil
}
func (n *Notifier) notifyV1(
ctx context.Context,
eventType string,
key notify.Key,
data *template.Data,
details map[string]any,
) (bool, error) {
var tmplErr error
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
description, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV1DescriptionLenRunes)
if truncated {
n.logger.WarnContext(ctx, "Truncated description", "key", key, "max_runes", maxV1DescriptionLenRunes)
}
serviceKey := string(n.conf.ServiceKey)
if serviceKey == "" {
content, fileErr := os.ReadFile(n.conf.ServiceKeyFile)
if fileErr != nil {
return false, errors.WrapInternalf(fileErr, errors.CodeInternal, "failed to read service key from file")
}
serviceKey = strings.TrimSpace(string(content))
}
msg := &pagerDutyMessage{
ServiceKey: tmpl(serviceKey),
EventType: eventType,
IncidentKey: key.Hash(),
Description: description,
Details: details,
}
if eventType == pagerDutyEventTrigger {
msg.Client = tmpl(n.conf.Client)
msg.ClientURL = tmpl(n.conf.ClientURL)
}
if tmplErr != nil {
return false, errors.WrapInternalf(tmplErr, errors.CodeInternal, "failed to template PagerDuty v1 message")
}
// Ensure that the service key isn't empty after templating.
if msg.ServiceKey == "" {
return false, errors.NewInternalf(errors.CodeInternal, "service key cannot be empty")
}
encodedMsg, err := n.encodeMessage(ctx, msg)
if err != nil {
return false, err
}
resp, err := notify.PostJSON(ctx, n.client, n.apiV1, &encodedMsg) //nolint:bodyclose
if err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "failed to post message to PagerDuty v1")
}
defer notify.Drain(resp)
return n.retrier.Check(resp.StatusCode, resp.Body)
}
func (n *Notifier) notifyV2(
ctx context.Context,
eventType string,
key notify.Key,
data *template.Data,
details map[string]any,
) (bool, error) {
var tmplErr error
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
if n.conf.Severity == "" {
n.conf.Severity = "error"
}
summary, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV2SummaryLenRunes)
if truncated {
n.logger.WarnContext(ctx, "Truncated summary", "key", key, "max_runes", maxV2SummaryLenRunes)
}
routingKey := string(n.conf.RoutingKey)
if routingKey == "" {
content, fileErr := os.ReadFile(n.conf.RoutingKeyFile)
if fileErr != nil {
return false, errors.WrapInternalf(fileErr, errors.CodeInternal, "failed to read routing key from file")
}
routingKey = strings.TrimSpace(string(content))
}
msg := &pagerDutyMessage{
Client: tmpl(n.conf.Client),
ClientURL: tmpl(n.conf.ClientURL),
RoutingKey: tmpl(routingKey),
EventAction: eventType,
DedupKey: key.Hash(),
Images: make([]pagerDutyImage, 0, len(n.conf.Images)),
Links: make([]pagerDutyLink, 0, len(n.conf.Links)),
Payload: &pagerDutyPayload{
Summary: summary,
Source: tmpl(n.conf.Source),
Severity: tmpl(n.conf.Severity),
CustomDetails: details,
Class: tmpl(n.conf.Class),
Component: tmpl(n.conf.Component),
Group: tmpl(n.conf.Group),
},
}
for _, item := range n.conf.Images {
image := pagerDutyImage{
Src: tmpl(item.Src),
Alt: tmpl(item.Alt),
Href: tmpl(item.Href),
}
if image.Src != "" {
msg.Images = append(msg.Images, image)
}
}
for _, item := range n.conf.Links {
link := pagerDutyLink{
HRef: tmpl(item.Href),
Text: tmpl(item.Text),
}
if link.HRef != "" {
msg.Links = append(msg.Links, link)
}
}
if tmplErr != nil {
return false, errors.WrapInternalf(tmplErr, errors.CodeInternal, "failed to template PagerDuty v2 message")
}
// Ensure that the routing key isn't empty after templating.
if msg.RoutingKey == "" {
return false, errors.NewInternalf(errors.CodeInternal, "routing key cannot be empty")
}
encodedMsg, err := n.encodeMessage(ctx, msg)
if err != nil {
return false, err
}
resp, err := notify.PostJSON(ctx, n.client, n.conf.URL.String(), &encodedMsg) //nolint:bodyclose
if err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "failed to post message to PagerDuty")
}
defer notify.Drain(resp)
retry, err := n.retrier.Check(resp.StatusCode, resp.Body)
if err != nil {
return retry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
}
return retry, err
}
// Notify implements the Notifier interface.
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
key, err := notify.ExtractGroupKey(ctx)
if err != nil {
return false, err
}
logger := n.logger.With("group_key", key)
var (
alerts = types.Alerts(as...)
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
eventType = pagerDutyEventTrigger
)
if alerts.Status() == model.AlertResolved {
eventType = pagerDutyEventResolve
}
logger.DebugContext(ctx, "extracted group key", "event_type", eventType)
details, err := n.renderDetails(data)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "failed to render details: %v", err)
}
if n.conf.Timeout > 0 {
nfCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, errors.NewInternalf(errors.CodeTimeout, "configured pagerduty timeout reached (%s)", n.conf.Timeout))
defer cancel()
ctx = nfCtx
}
nf := n.notifyV2
if n.apiV1 != "" {
nf = n.notifyV1
}
retry, err := nf(ctx, eventType, key, data, details)
if err != nil {
if ctx.Err() != nil {
err = errors.WrapInternalf(err, errors.CodeInternal, "failed to notify PagerDuty: %v", context.Cause(ctx))
}
return retry, err
}
return retry, nil
}
func errDetails(status int, body io.Reader) string {
// See https://v2.developer.pagerduty.com/docs/trigger-events for the v1 events API.
// See https://v2.developer.pagerduty.com/docs/send-an-event-events-api-v2 for the v2 events API.
if status != http.StatusBadRequest || body == nil {
return ""
}
var pgr struct {
Status string `json:"status"`
Message string `json:"message"`
Errors []string `json:"errors"`
}
if err := json.NewDecoder(body).Decode(&pgr); err != nil {
return ""
}
return fmt.Sprintf("%s: %s", pgr.Message, strings.Join(pgr.Errors, ","))
}
func (n *Notifier) renderDetails(
data *template.Data,
) (map[string]any, error) {
var (
tmplTextErr error
tmplText = notify.TmplText(n.tmpl, data, &tmplTextErr)
tmplTextFunc = func(tmpl string) (string, error) {
return tmplText(tmpl), tmplTextErr
}
)
var err error
rendered := make(map[string]any, len(n.conf.Details))
for k, v := range n.conf.Details {
rendered[k], err = template.DeepCopyWithTemplate(v, tmplTextFunc)
if err != nil {
return nil, err
}
}
return rendered, nil
}

View File

@@ -0,0 +1,879 @@
package pagerduty
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/errors"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/require"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/notify/test"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
func TestPagerDutyRetryV1(t *testing.T) {
notifier, err := New(
&config.PagerdutyConfig{
ServiceKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
retryCodes := append(test.DefaultRetryCodes(), http.StatusForbidden)
for statusCode, expected := range test.RetryTests(retryCodes) {
actual, _ := notifier.retrier.Check(statusCode, nil)
require.Equal(t, expected, actual, "retryv1 - error on status %d", statusCode)
}
}
func TestPagerDutyRetryV2(t *testing.T) {
notifier, err := New(
&config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
retryCodes := append(test.DefaultRetryCodes(), http.StatusTooManyRequests)
for statusCode, expected := range test.RetryTests(retryCodes) {
actual, _ := notifier.retrier.Check(statusCode, nil)
require.Equal(t, expected, actual, "retryv2 - error on status %d", statusCode)
}
}
func TestPagerDutyRedactedURLV1(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
key := "01234567890123456789012345678901"
notifier, err := New(
&config.PagerdutyConfig{
ServiceKey: config.Secret(key),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
notifier.apiV1 = u.String()
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
}
func TestPagerDutyRedactedURLV2(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
key := "01234567890123456789012345678901"
notifier, err := New(
&config.PagerdutyConfig{
URL: &config.URL{URL: u},
RoutingKey: config.Secret(key),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
}
func TestPagerDutyV1ServiceKeyFromFile(t *testing.T) {
key := "01234567890123456789012345678901"
f, err := os.CreateTemp(t.TempDir(), "pagerduty_test")
require.NoError(t, err, "creating temp file failed")
_, err = f.WriteString(key)
require.NoError(t, err, "writing to temp file failed")
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
notifier, err := New(
&config.PagerdutyConfig{
ServiceKeyFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
notifier.apiV1 = u.String()
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
}
func TestPagerDutyV2RoutingKeyFromFile(t *testing.T) {
key := "01234567890123456789012345678901"
f, err := os.CreateTemp(t.TempDir(), "pagerduty_test")
require.NoError(t, err, "creating temp file failed")
_, err = f.WriteString(key)
require.NoError(t, err, "writing to temp file failed")
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
notifier, err := New(
&config.PagerdutyConfig{
URL: &config.URL{URL: u},
RoutingKeyFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
}
func TestPagerDutyTemplating(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dec := json.NewDecoder(r.Body)
out := make(map[string]any)
err := dec.Decode(&out)
if err != nil {
panic(err)
}
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
for _, tc := range []struct {
title string
cfg *config.PagerdutyConfig
retry bool
errMsg string
}{
{
title: "full-blown legacy message",
cfg: &config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
Images: []config.PagerdutyImage{
{
Src: "{{ .Status }}",
Alt: "{{ .Status }}",
Href: "{{ .Status }}",
},
},
Links: []config.PagerdutyLink{
{
Href: "{{ .Status }}",
Text: "{{ .Status }}",
},
},
Details: map[string]any{
"firing": `{{ .Alerts.Firing | toJson }}`,
"resolved": `{{ .Alerts.Resolved | toJson }}`,
"num_firing": `{{ .Alerts.Firing | len }}`,
"num_resolved": `{{ .Alerts.Resolved | len }}`,
},
},
},
{
title: "full-blown legacy message",
cfg: &config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
Images: []config.PagerdutyImage{
{
Src: "{{ .Status }}",
Alt: "{{ .Status }}",
Href: "{{ .Status }}",
},
},
Links: []config.PagerdutyLink{
{
Href: "{{ .Status }}",
Text: "{{ .Status }}",
},
},
Details: map[string]any{
"firing": `{{ template "pagerduty.default.instances" .Alerts.Firing }}`,
"resolved": `{{ template "pagerduty.default.instances" .Alerts.Resolved }}`,
"num_firing": `{{ .Alerts.Firing | len }}`,
"num_resolved": `{{ .Alerts.Resolved | len }}`,
},
},
},
{
title: "nested details",
cfg: &config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
Details: map[string]any{
"a": map[string]any{
"b": map[string]any{
"c": map[string]any{
"firing": `{{ .Alerts.Firing | toJson }}`,
"resolved": `{{ .Alerts.Resolved | toJson }}`,
"num_firing": `{{ .Alerts.Firing | len }}`,
"num_resolved": `{{ .Alerts.Resolved | len }}`,
},
},
},
},
},
},
{
title: "nested details with template error",
cfg: &config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
Details: map[string]any{
"a": map[string]any{
"b": map[string]any{
"c": map[string]any{
"firing": `{{ template "pagerduty.default.instances" .Alerts.Firing`,
},
},
},
},
},
errMsg: "failed to render details: template: :1: unclosed action",
},
{
title: "details with templating errors",
cfg: &config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
Details: map[string]any{
"firing": `{{ .Alerts.Firing | toJson`,
"resolved": `{{ .Alerts.Resolved | toJson }}`,
"num_firing": `{{ .Alerts.Firing | len }}`,
"num_resolved": `{{ .Alerts.Resolved | len }}`,
},
},
errMsg: "failed to render details: template: :1: unclosed action",
},
{
title: "v2 message with templating errors",
cfg: &config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
Severity: "{{ ",
},
errMsg: "failed to template",
},
{
title: "v1 message with templating errors",
cfg: &config.PagerdutyConfig{
ServiceKey: config.Secret("01234567890123456789012345678901"),
Client: "{{ ",
},
errMsg: "failed to template",
},
{
title: "routing key cannot be empty",
cfg: &config.PagerdutyConfig{
RoutingKey: config.Secret(`{{ "" }}`),
},
errMsg: "routing key cannot be empty",
},
{
title: "service_key cannot be empty",
cfg: &config.PagerdutyConfig{
ServiceKey: config.Secret(`{{ "" }}`),
},
errMsg: "service key cannot be empty",
},
} {
t.Run(tc.title, func(t *testing.T) {
tc.cfg.URL = &config.URL{URL: u}
tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger())
require.NoError(t, err)
if pd.apiV1 != "" {
pd.apiV1 = u.String()
}
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
ok, err := pd.Notify(ctx, []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{
"lbl1": "val1",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
}...)
if tc.errMsg == "" {
require.NoError(t, err)
} else {
require.Error(t, err)
if errors.Asc(err, errors.CodeInternal) {
_, _, errMsg, _, _, _ := errors.Unwrapb(err)
require.Contains(t, errMsg, tc.errMsg)
} else {
require.Contains(t, err.Error(), tc.errMsg)
}
}
require.Equal(t, tc.retry, ok)
})
}
}
func TestErrDetails(t *testing.T) {
for _, tc := range []struct {
status int
body io.Reader
exp string
}{
{
status: http.StatusBadRequest,
body: bytes.NewBuffer([]byte(
`{"status":"invalid event","message":"Event object is invalid","errors":["Length of 'routing_key' is incorrect (should be 32 characters)"]}`,
)),
exp: "Length of 'routing_key' is incorrect",
},
{
status: http.StatusBadRequest,
body: bytes.NewBuffer([]byte(`{"status"}`)),
exp: "",
},
{
status: http.StatusBadRequest,
exp: "",
},
{
status: http.StatusTooManyRequests,
exp: "",
},
} {
t.Run("", func(t *testing.T) {
err := errDetails(tc.status, tc.body)
require.Contains(t, err, tc.exp)
})
}
}
func TestEventSizeEnforcement(t *testing.T) {
bigDetailsV1 := map[string]any{
"firing": strings.Repeat("a", 513000),
}
bigDetailsV2 := map[string]any{
"firing": strings.Repeat("a", 513000),
}
// V1 Messages
msgV1 := &pagerDutyMessage{
ServiceKey: "01234567890123456789012345678901",
EventType: "trigger",
Details: bigDetailsV1,
}
notifierV1, err := New(
&config.PagerdutyConfig{
ServiceKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
encodedV1, err := notifierV1.encodeMessage(context.Background(), msgV1)
require.NoError(t, err)
require.Contains(t, encodedV1.String(), `"details":{"error":"Custom details have been removed because the original event exceeds the maximum size of 512KB"}`)
// V2 Messages
msgV2 := &pagerDutyMessage{
RoutingKey: "01234567890123456789012345678901",
EventAction: "trigger",
Payload: &pagerDutyPayload{
CustomDetails: bigDetailsV2,
},
}
notifierV2, err := New(
&config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
encodedV2, err := notifierV2.encodeMessage(context.Background(), msgV2)
require.NoError(t, err)
require.Contains(t, encodedV2.String(), `"custom_details":{"error":"Custom details have been removed because the original event exceeds the maximum size of 512KB"}`)
}
func TestPagerDutyEmptySrcHref(t *testing.T) {
type pagerDutyEvent struct {
RoutingKey string `json:"routing_key"`
EventAction string `json:"event_action"`
DedupKey string `json:"dedup_key"`
Payload pagerDutyPayload `json:"payload"`
Images []pagerDutyImage
Links []pagerDutyLink
}
images := []config.PagerdutyImage{
{
Src: "",
Alt: "Empty src",
Href: "https://example.com/",
},
{
Src: "https://example.com/cat.jpg",
Alt: "Empty href",
Href: "",
},
{
Src: "https://example.com/cat.jpg",
Alt: "",
Href: "https://example.com/",
},
}
links := []config.PagerdutyLink{
{
Href: "",
Text: "Empty href",
},
{
Href: "https://example.com/",
Text: "",
},
}
expectedImages := make([]pagerDutyImage, 0, len(images))
for _, image := range images {
if image.Src == "" {
continue
}
expectedImages = append(expectedImages, pagerDutyImage{
Src: image.Src,
Alt: image.Alt,
Href: image.Href,
})
}
expectedLinks := make([]pagerDutyLink, 0, len(links))
for _, link := range links {
if link.Href == "" {
continue
}
expectedLinks = append(expectedLinks, pagerDutyLink{
HRef: link.Href,
Text: link.Text,
})
}
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var event pagerDutyEvent
if err := decoder.Decode(&event); err != nil {
panic(err)
}
if event.RoutingKey == "" || event.EventAction == "" {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
for _, image := range event.Images {
if image.Src == "" {
http.Error(w, "Event object is invalid: 'image src' is missing or blank", http.StatusBadRequest)
return
}
}
for _, link := range event.Links {
if link.HRef == "" {
http.Error(w, "Event object is invalid: 'link href' is missing or blank", http.StatusBadRequest)
return
}
}
require.Equal(t, expectedImages, event.Images)
require.Equal(t, expectedLinks, event.Links)
},
))
defer server.Close()
url, err := url.Parse(server.URL)
require.NoError(t, err)
pagerDutyConfig := config.PagerdutyConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
RoutingKey: config.Secret("01234567890123456789012345678901"),
URL: &config.URL{URL: url},
Images: images,
Links: links,
}
pagerDuty, err := New(&pagerDutyConfig, test.CreateTmpl(t), promslog.NewNopLogger())
require.NoError(t, err)
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
_, err = pagerDuty.Notify(ctx, []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{
"lbl1": "val1",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
}...)
require.NoError(t, err)
}
func TestPagerDutyTimeout(t *testing.T) {
type pagerDutyEvent struct {
RoutingKey string `json:"routing_key"`
EventAction string `json:"event_action"`
DedupKey string `json:"dedup_key"`
Payload pagerDutyPayload `json:"payload"`
Images []pagerDutyImage
Links []pagerDutyLink
}
tests := map[string]struct {
latency time.Duration
timeout time.Duration
wantErr bool
}{
"success": {latency: 100 * time.Millisecond, timeout: 120 * time.Millisecond, wantErr: false},
"error": {latency: 100 * time.Millisecond, timeout: 80 * time.Millisecond, wantErr: true},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var event pagerDutyEvent
if err := decoder.Decode(&event); err != nil {
panic(err)
}
if event.RoutingKey == "" || event.EventAction == "" {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
time.Sleep(tt.latency)
},
))
defer srv.Close()
u, err := url.Parse(srv.URL)
require.NoError(t, err)
cfg := config.PagerdutyConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
RoutingKey: config.Secret("01234567890123456789012345678901"),
URL: &config.URL{URL: u},
Timeout: tt.timeout,
}
pd, err := New(&cfg, test.CreateTmpl(t), promslog.NewNopLogger())
require.NoError(t, err)
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
alert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
"lbl1": "val1",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
_, err = pd.Notify(ctx, alert)
require.Equal(t, tt.wantErr, err != nil)
})
}
}
func TestRenderDetails(t *testing.T) {
type args struct {
details map[string]any
data *template.Data
}
tests := []struct {
name string
args args
want map[string]any
wantErr bool
}{
{
name: "flat",
args: args{
details: map[string]any{
"a": "{{ .Status }}",
"b": "String",
},
data: &template.Data{
Status: "Flat",
},
},
want: map[string]any{
"a": "Flat",
"b": "String",
},
wantErr: false,
},
{
name: "flat error",
args: args{
details: map[string]any{
"a": "{{ .Status",
},
data: &template.Data{
Status: "Error",
},
},
want: nil,
wantErr: true,
},
{
name: "nested",
args: args{
details: map[string]any{
"a": map[string]any{
"b": map[string]any{
"c": "{{ .Status }}",
"d": "String",
},
},
},
data: &template.Data{
Status: "Nested",
},
},
want: map[string]any{
"a": map[string]any{
"b": map[string]any{
"c": "Nested",
"d": "String",
},
},
},
wantErr: false,
},
{
name: "nested error",
args: args{
details: map[string]any{
"a": map[string]any{
"b": map[string]any{
"c": "{{ .Status",
},
},
},
data: &template.Data{
Status: "Error",
},
},
want: nil,
wantErr: true,
},
{
name: "alerts",
args: args{
details: map[string]any{
"alerts": map[string]any{
"firing": "{{ .Alerts.Firing | toJson }}",
"resolved": "{{ .Alerts.Resolved | toJson }}",
"num_firing": "{{ len .Alerts.Firing }}",
"num_resolved": "{{ len .Alerts.Resolved }}",
},
},
data: &template.Data{
Alerts: template.Alerts{
{
Status: "firing",
Annotations: template.KV{
"annotation1": "value1",
"annotation2": "value2",
},
Labels: template.KV{
"alertname": "Firing1",
"label1": "value1",
"label2": "value2",
},
Fingerprint: "fingerprint1",
GeneratorURL: "http://generator1",
StartsAt: time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC),
EndsAt: time.Date(2001, time.January, 1, 1, 0, 0, 0, time.UTC),
},
{
Status: "firing",
Annotations: template.KV{
"annotation1": "value1",
"annotation2": "value2",
},
Labels: template.KV{
"alertname": "Firing2",
"label1": "value1",
"label2": "value2",
},
Fingerprint: "fingerprint2",
GeneratorURL: "http://generator2",
StartsAt: time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC),
EndsAt: time.Date(2002, time.January, 1, 1, 0, 0, 0, time.UTC),
},
{
Status: "resolved",
Annotations: template.KV{
"annotation1": "value1",
"annotation2": "value2",
},
Labels: template.KV{
"alertname": "Resolved1",
"label1": "value1",
"label2": "value2",
},
Fingerprint: "fingerprint3",
GeneratorURL: "http://generator3",
StartsAt: time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC),
EndsAt: time.Date(2001, time.January, 1, 1, 0, 0, 0, time.UTC),
},
{
Status: "resolved",
Annotations: template.KV{
"annotation1": "value1",
"annotation2": "value2",
},
Labels: template.KV{
"alertname": "Resolved2",
"label1": "value1",
"label2": "value2",
},
Fingerprint: "fingerprint4",
GeneratorURL: "http://generator4",
StartsAt: time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC),
EndsAt: time.Date(2002, time.January, 1, 1, 0, 0, 0, time.UTC),
},
},
},
},
want: map[string]any{
"alerts": map[string]any{
"firing": []any{
map[string]any{
"status": "firing",
"labels": map[string]any{
"alertname": "Firing1",
"label1": "value1",
"label2": "value2",
},
"annotations": map[string]any{
"annotation1": "value1",
"annotation2": "value2",
},
"startsAt": time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
"endsAt": time.Date(2001, time.January, 1, 1, 0, 0, 0, time.UTC).Format(time.RFC3339),
"fingerprint": "fingerprint1",
"generatorURL": "http://generator1",
},
map[string]any{
"status": "firing",
"labels": map[string]any{
"alertname": "Firing2",
"label1": "value1",
"label2": "value2",
},
"annotations": map[string]any{
"annotation1": "value1",
"annotation2": "value2",
},
"startsAt": time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
"endsAt": time.Date(2002, time.January, 1, 1, 0, 0, 0, time.UTC).Format(time.RFC3339),
"fingerprint": "fingerprint2",
"generatorURL": "http://generator2",
},
},
"resolved": []any{
map[string]any{
"status": "resolved",
"labels": map[string]any{
"alertname": "Resolved1",
"label1": "value1",
"label2": "value2",
},
"annotations": map[string]any{
"annotation1": "value1",
"annotation2": "value2",
},
"startsAt": time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
"endsAt": time.Date(2001, time.January, 1, 1, 0, 0, 0, time.UTC).Format(time.RFC3339),
"fingerprint": "fingerprint3",
"generatorURL": "http://generator3",
},
map[string]any{
"status": "resolved",
"labels": map[string]any{
"alertname": "Resolved2",
"label1": "value1",
"label2": "value2",
},
"annotations": map[string]any{
"annotation1": "value1",
"annotation2": "value2",
},
"startsAt": time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
"endsAt": time.Date(2002, time.January, 1, 1, 0, 0, 0, time.UTC).Format(time.RFC3339),
"fingerprint": "fingerprint4",
"generatorURL": "http://generator4",
},
},
"num_firing": 2,
"num_resolved": 2,
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
n := &Notifier{
conf: &config.PagerdutyConfig{
Details: tt.args.details,
},
tmpl: test.CreateTmpl(t),
}
got, err := n.renderDetails(tt.args.data)
if (err != nil) != tt.wantErr {
t.Errorf("renderDetails() error = %v, wantErr %v", err, tt.wantErr)
return
}
require.Equal(t, tt.want, got)
})
}
}

View File

@@ -2,8 +2,14 @@ package alertmanagernotify
import (
"log/slog"
"slices"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/email"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/msteamsv2"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/slack"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/webhook"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/config/receiver"
"github.com/prometheus/alertmanager/notify"
@@ -11,6 +17,15 @@ import (
"github.com/prometheus/alertmanager/types"
)
var customNotifierIntegrations = []string{
webhook.Integration,
email.Integration,
pagerduty.Integration,
opsgenie.Integration,
slack.Integration,
msteamsv2.Integration,
}
func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger) ([]notify.Integration, error) {
upstreamIntegrations, err := receiver.BuildReceiverIntegrations(nc, tmpl, logger)
if err != nil {
@@ -31,14 +46,29 @@ func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Templ
)
for _, integration := range upstreamIntegrations {
// skip upstream msteamsv2 integration
if integration.Name() != "msteamsv2" {
// skip upstream integration if we support custom integration for it
if !slices.Contains(customNotifierIntegrations, integration.Name()) {
integrations = append(integrations, integration)
}
}
for i, c := range nc.WebhookConfigs {
add(webhook.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l) })
}
for i, c := range nc.EmailConfigs {
add(email.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return email.New(c, tmpl, l), nil })
}
for i, c := range nc.PagerdutyConfigs {
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l) })
}
for i, c := range nc.OpsGenieConfigs {
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l) })
}
for i, c := range nc.SlackConfigs {
add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l) })
}
for i, c := range nc.MSTeamsV2Configs {
add("msteamsv2", i, c, func(l *slog.Logger) (notify.Notifier, error) {
add(msteamsv2.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
return msteamsv2.New(c, tmpl, `{{ template "msteamsv2.default.titleLink" . }}`, l)
})
}

View File

@@ -0,0 +1,278 @@
package slack
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"os"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
const (
Integration = "slack"
)
// https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters.
const maxTitleLenRunes = 1024
// Notifier implements a Notifier for Slack notifications.
type Notifier struct {
conf *config.SlackConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error)
}
// New returns a new Slack notification handler.
func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}
return &Notifier{
conf: c,
tmpl: t,
logger: l,
client: client,
retrier: &notify.Retrier{},
postJSONFunc: notify.PostJSON,
}, nil
}
// request is the request for sending a slack notification.
type request struct {
Channel string `json:"channel,omitempty"`
Username string `json:"username,omitempty"`
IconEmoji string `json:"icon_emoji,omitempty"`
IconURL string `json:"icon_url,omitempty"`
LinkNames bool `json:"link_names,omitempty"`
Text string `json:"text,omitempty"`
Attachments []attachment `json:"attachments"`
}
// attachment is used to display a richly-formatted message block.
type attachment struct {
Title string `json:"title,omitempty"`
TitleLink string `json:"title_link,omitempty"`
Pretext string `json:"pretext,omitempty"`
Text string `json:"text"`
Fallback string `json:"fallback"`
CallbackID string `json:"callback_id"`
Fields []config.SlackField `json:"fields,omitempty"`
Actions []config.SlackAction `json:"actions,omitempty"`
ImageURL string `json:"image_url,omitempty"`
ThumbURL string `json:"thumb_url,omitempty"`
Footer string `json:"footer"`
Color string `json:"color,omitempty"`
MrkdwnIn []string `json:"mrkdwn_in,omitempty"`
}
// Notify implements the Notifier interface.
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
var err error
key, err := notify.ExtractGroupKey(ctx)
if err != nil {
return false, err
}
logger := n.logger.With("group_key", key)
logger.DebugContext(ctx, "extracted group key")
var (
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
tmplText = notify.TmplText(n.tmpl, data, &err)
)
var markdownIn []string
if len(n.conf.MrkdwnIn) == 0 {
markdownIn = []string{"fallback", "pretext", "text"}
} else {
markdownIn = n.conf.MrkdwnIn
}
title, truncated := notify.TruncateInRunes(tmplText(n.conf.Title), maxTitleLenRunes)
if truncated {
logger.WarnContext(ctx, "Truncated title", "max_runes", maxTitleLenRunes)
}
att := &attachment{
Title: title,
TitleLink: tmplText(n.conf.TitleLink),
Pretext: tmplText(n.conf.Pretext),
Text: tmplText(n.conf.Text),
Fallback: tmplText(n.conf.Fallback),
CallbackID: tmplText(n.conf.CallbackID),
ImageURL: tmplText(n.conf.ImageURL),
ThumbURL: tmplText(n.conf.ThumbURL),
Footer: tmplText(n.conf.Footer),
Color: tmplText(n.conf.Color),
MrkdwnIn: markdownIn,
}
numFields := len(n.conf.Fields)
if numFields > 0 {
fields := make([]config.SlackField, numFields)
for index, field := range n.conf.Fields {
// Check if short was defined for the field otherwise fallback to the global setting
var short bool
if field.Short != nil {
short = *field.Short
} else {
short = n.conf.ShortFields
}
// Rebuild the field by executing any templates and setting the new value for short
fields[index] = config.SlackField{
Title: tmplText(field.Title),
Value: tmplText(field.Value),
Short: &short,
}
}
att.Fields = fields
}
numActions := len(n.conf.Actions)
if numActions > 0 {
actions := make([]config.SlackAction, numActions)
for index, action := range n.conf.Actions {
slackAction := config.SlackAction{
Type: tmplText(action.Type),
Text: tmplText(action.Text),
URL: tmplText(action.URL),
Style: tmplText(action.Style),
Name: tmplText(action.Name),
Value: tmplText(action.Value),
}
if action.ConfirmField != nil {
slackAction.ConfirmField = &config.SlackConfirmationField{
Title: tmplText(action.ConfirmField.Title),
Text: tmplText(action.ConfirmField.Text),
OkText: tmplText(action.ConfirmField.OkText),
DismissText: tmplText(action.ConfirmField.DismissText),
}
}
actions[index] = slackAction
}
att.Actions = actions
}
req := &request{
Channel: tmplText(n.conf.Channel),
Username: tmplText(n.conf.Username),
IconEmoji: tmplText(n.conf.IconEmoji),
IconURL: tmplText(n.conf.IconURL),
LinkNames: n.conf.LinkNames,
Text: tmplText(n.conf.MessageText),
Attachments: []attachment{*att},
}
if err != nil {
return false, err
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(req); err != nil {
return false, err
}
var u string
if n.conf.APIURL != nil {
u = n.conf.APIURL.String()
} else {
content, err := os.ReadFile(n.conf.APIURLFile)
if err != nil {
return false, err
}
u = strings.TrimSpace(string(content))
}
if n.conf.Timeout > 0 {
postCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, errors.NewInternalf(errors.CodeTimeout, "configured slack timeout reached (%s)", n.conf.Timeout))
defer cancel()
ctx = postCtx
}
resp, err := n.postJSONFunc(ctx, n.client, u, &buf) //nolint:bodyclose
if err != nil {
if ctx.Err() != nil {
err = errors.NewInternalf(errors.CodeInternal, "failed to post JSON to slack: %v", context.Cause(ctx))
}
return true, notify.RedactURL(err)
}
defer notify.Drain(resp)
// Use a retrier to generate an error message for non-200 responses and
// classify them as retriable or not.
retry, err := n.retrier.Check(resp.StatusCode, resp.Body)
if err != nil {
err = errors.NewInternalf(errors.CodeInternal, "channel %q: %v", req.Channel, err)
return retry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
}
// Slack web API might return errors with a 200 response code.
// https://slack.dev/node-slack-sdk/web-api#handle-errors
retry, err = checkResponseError(resp)
if err != nil {
err = errors.NewInternalf(errors.CodeInternal, "channel %q: %v", req.Channel, err)
return retry, notify.NewErrorWithReason(notify.ClientErrorReason, err)
}
return retry, nil
}
// checkResponseError parses out the error message from Slack API response.
func checkResponseError(resp *http.Response) (bool, error) {
body, err := io.ReadAll(resp.Body)
if err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "could not read response body")
}
if strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
return checkJSONResponseError(body)
}
return checkTextResponseError(body)
}
// checkTextResponseError classifies plaintext responses from Slack.
// A plaintext (non-JSON) response is successful if it's a string "ok".
// This is typically a response for an Incoming Webhook
// (https://api.slack.com/messaging/webhooks#handling_errors)
func checkTextResponseError(body []byte) (bool, error) {
if !bytes.Equal(body, []byte("ok")) {
return false, errors.NewInternalf(errors.CodeInternal, "received an error response from Slack: %s", string(body))
}
return false, nil
}
// checkJSONResponseError classifies JSON responses from Slack.
func checkJSONResponseError(body []byte) (bool, error) {
// response is for parsing out errors from the JSON response.
type response struct {
OK bool `json:"ok"`
Error string `json:"error"`
}
var data response
if err := json.Unmarshal(body, &data); err != nil {
return true, errors.NewInternalf(errors.CodeInternal, "could not unmarshal JSON response %q: %v", string(body), err)
}
if !data.OK {
return false, errors.NewInternalf(errors.CodeInternal, "error response from Slack: %s", data.Error)
}
return false, nil
}

View File

@@ -0,0 +1,339 @@
package slack
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/require"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/notify/test"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
func TestSlackRetry(t *testing.T) {
notifier, err := New(
&config.SlackConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) {
actual, _ := notifier.retrier.Check(statusCode, nil)
require.Equal(t, expected, actual, "error on status %d", statusCode)
}
}
func TestSlackRedactedURL(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
notifier, err := New(
&config.SlackConfig{
APIURL: &config.SecretURL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String())
}
func TestGettingSlackURLFromFile(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
f, err := os.CreateTemp(t.TempDir(), "slack_test")
require.NoError(t, err, "creating temp file failed")
_, err = f.WriteString(u.String())
require.NoError(t, err, "writing to temp file failed")
notifier, err := New(
&config.SlackConfig{
APIURLFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String())
}
func TestTrimmingSlackURLFromFile(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
f, err := os.CreateTemp(t.TempDir(), "slack_test_newline")
require.NoError(t, err, "creating temp file failed")
_, err = f.WriteString(u.String() + "\n\n")
require.NoError(t, err, "writing to temp file failed")
notifier, err := New(
&config.SlackConfig{
APIURLFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String())
}
func TestNotifier_Notify_WithReason(t *testing.T) {
tests := []struct {
name string
statusCode int
responseBody string
expectedReason notify.Reason
expectedErr string
expectedRetry bool
noError bool
}{
{
name: "with a 4xx status code",
statusCode: http.StatusUnauthorized,
expectedReason: notify.ClientErrorReason,
expectedRetry: false,
expectedErr: "unexpected status code 401",
},
{
name: "with a 5xx status code",
statusCode: http.StatusInternalServerError,
expectedReason: notify.ServerErrorReason,
expectedRetry: true,
expectedErr: "unexpected status code 500",
},
{
name: "with a 3xx status code",
statusCode: http.StatusTemporaryRedirect,
expectedReason: notify.DefaultReason,
expectedRetry: false,
expectedErr: "unexpected status code 307",
},
{
name: "with a 1xx status code",
statusCode: http.StatusSwitchingProtocols,
expectedReason: notify.DefaultReason,
expectedRetry: false,
expectedErr: "unexpected status code 101",
},
{
name: "2xx response with invalid JSON",
statusCode: http.StatusOK,
responseBody: `{"not valid json"}`,
expectedReason: notify.ClientErrorReason,
expectedRetry: true,
expectedErr: "could not unmarshal",
},
{
name: "2xx response with a JSON error",
statusCode: http.StatusOK,
responseBody: `{"ok":false,"error":"error_message"}`,
expectedReason: notify.ClientErrorReason,
expectedRetry: false,
expectedErr: "error response from Slack: error_message",
},
{
name: "2xx response with a plaintext error",
statusCode: http.StatusOK,
responseBody: "no_channel",
expectedReason: notify.ClientErrorReason,
expectedRetry: false,
expectedErr: "error response from Slack: no_channel",
},
{
name: "successful JSON response",
statusCode: http.StatusOK,
responseBody: `{"ok":true}`,
noError: true,
},
{
name: "successful plaintext response",
statusCode: http.StatusOK,
responseBody: "ok",
noError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
apiurl, _ := url.Parse("https://slack.com/post.Message")
notifier, err := New(
&config.SlackConfig{
NotifierConfig: config.NotifierConfig{},
HTTPConfig: &commoncfg.HTTPClientConfig{},
APIURL: &config.SecretURL{URL: apiurl},
Channel: "channelname",
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
notifier.postJSONFunc = func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) {
resp := httptest.NewRecorder()
if strings.HasPrefix(tt.responseBody, "{") {
resp.Header().Add("Content-Type", "application/json; charset=utf-8")
}
resp.WriteHeader(tt.statusCode)
_, _ = resp.WriteString(tt.responseBody)
return resp.Result(), nil
}
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
alert1 := &types.Alert{
Alert: model.Alert{
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
retry, err := notifier.Notify(ctx, alert1)
require.Equal(t, tt.expectedRetry, retry)
if tt.noError {
require.NoError(t, err)
} else {
var reasonError *notify.ErrorWithReason
require.ErrorAs(t, err, &reasonError)
require.Equal(t, tt.expectedReason, reasonError.Reason)
require.Contains(t, err.Error(), tt.expectedErr)
require.Contains(t, err.Error(), "channelname")
}
})
}
}
func TestSlackTimeout(t *testing.T) {
tests := map[string]struct {
latency time.Duration
timeout time.Duration
wantErr bool
}{
"success": {latency: 100 * time.Millisecond, timeout: 120 * time.Millisecond, wantErr: false},
"error": {latency: 100 * time.Millisecond, timeout: 80 * time.Millisecond, wantErr: true},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
u, _ := url.Parse("https://slack.com/post.Message")
notifier, err := New(
&config.SlackConfig{
NotifierConfig: config.NotifierConfig{},
HTTPConfig: &commoncfg.HTTPClientConfig{},
APIURL: &config.SecretURL{URL: u},
Channel: "channelname",
Timeout: tt.timeout,
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
notifier.postJSONFunc = func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(tt.latency):
resp := httptest.NewRecorder()
resp.Header().Set("Content-Type", "application/json; charset=utf-8")
resp.WriteHeader(http.StatusOK)
_, _ = resp.WriteString(`{"ok":true}`)
return resp.Result(), nil
}
}
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
alert := &types.Alert{
Alert: model.Alert{
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
_, err = notifier.Notify(ctx, alert)
require.Equal(t, tt.wantErr, err != nil)
})
}
}
func TestSlackMessageField(t *testing.T) {
// 1. Setup a fake Slack server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
// 2. VERIFY: Top-level text exists
if body["text"] != "My Top Level Message" {
t.Errorf("Expected top-level 'text' to be 'My Top Level Message', got %v", body["text"])
}
// 3. VERIFY: Old attachments still exist
attachments, ok := body["attachments"].([]any)
if !ok || len(attachments) == 0 {
t.Errorf("Expected attachments to exist")
} else {
first := attachments[0].(map[string]any)
if first["title"] != "Old Attachment Title" {
t.Errorf("Expected attachment title 'Old Attachment Title', got %v", first["title"])
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok": true}`))
}))
defer server.Close()
// 4. Configure Notifier with BOTH new and old fields
u, _ := url.Parse(server.URL)
conf := &config.SlackConfig{
APIURL: &config.SecretURL{URL: u},
MessageText: "My Top Level Message", // Your NEW field
Title: "Old Attachment Title", // An OLD field
Channel: "#test-channel",
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
tmpl, err := template.FromGlobs([]string{})
if err != nil {
t.Fatal(err)
}
tmpl.ExternalURL = u
logger := slog.New(slog.DiscardHandler)
notifier, err := New(conf, tmpl, logger)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "test-group-key")
if _, err := notifier.Notify(ctx); err != nil {
t.Fatal("Notify failed:", err)
}
}

View File

@@ -0,0 +1,136 @@
package webhook
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"os"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
const (
Integration = "webhook"
)
// Notifier implements a Notifier for generic webhooks.
type Notifier struct {
conf *config.WebhookConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
}
// New returns a new Webhook.
func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}
return &Notifier{
conf: conf,
tmpl: t,
logger: l,
client: client,
// Webhooks are assumed to respond with 2xx response codes on a successful
// request and 5xx response codes are assumed to be recoverable.
retrier: &notify.Retrier{},
}, nil
}
// Message defines the JSON object send to webhook endpoints.
type Message struct {
*template.Data
// The protocol version.
Version string `json:"version"`
GroupKey string `json:"groupKey"`
TruncatedAlerts uint64 `json:"truncatedAlerts"`
}
func truncateAlerts(maxAlerts uint64, alerts []*types.Alert) ([]*types.Alert, uint64) {
if maxAlerts != 0 && uint64(len(alerts)) > maxAlerts {
return alerts[:maxAlerts], uint64(len(alerts)) - maxAlerts
}
return alerts, 0
}
// Notify implements the Notifier interface.
func (n *Notifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) {
alerts, numTruncated := truncateAlerts(n.conf.MaxAlerts, alerts)
data := notify.GetTemplateData(ctx, n.tmpl, alerts, n.logger)
groupKey, err := notify.ExtractGroupKey(ctx)
if err != nil {
return false, err
}
logger := n.logger.With("group_key", groupKey)
logger.DebugContext(ctx, "extracted group key")
msg := &Message{
Version: "4",
Data: data,
GroupKey: groupKey.String(),
TruncatedAlerts: numTruncated,
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
return false, err
}
var url string
var tmplErr error
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
if n.conf.URL != "" {
url = tmpl(string(n.conf.URL))
} else {
content, err := os.ReadFile(n.conf.URLFile)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "read url_file")
}
url = tmpl(strings.TrimSpace(string(content)))
}
if tmplErr != nil {
return false, errors.NewInternalf(errors.CodeInternal, "failed to template webhook URL: %v", tmplErr)
}
if url == "" {
return false, errors.NewInternalf(errors.CodeInternal, "webhook URL is empty after templating")
}
if n.conf.Timeout > 0 {
postCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, errors.NewInternalf(errors.CodeTimeout, "configured webhook timeout reached (%s)", n.conf.Timeout))
defer cancel()
ctx = postCtx
}
resp, err := notify.PostJSON(ctx, n.client, url, &buf) //nolint:bodyclose
if err != nil {
if ctx.Err() != nil {
err = errors.NewInternalf(errors.CodeInternal, "failed to post JSON to webhook: %v", context.Cause(ctx))
}
return true, notify.RedactURL(err)
}
defer notify.Drain(resp)
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
if err != nil {
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
}
return shouldRetry, err
}

View File

@@ -0,0 +1,214 @@
package webhook
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/require"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/notify/test"
"github.com/prometheus/alertmanager/types"
)
func TestWebhookRetry(t *testing.T) {
notifier, err := New(
&config.WebhookConfig{
URL: config.SecretTemplateURL("http://example.com"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
if err != nil {
require.NoError(t, err)
}
t.Run("test retry status code", func(t *testing.T) {
for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) {
actual, _ := notifier.retrier.Check(statusCode, nil)
require.Equal(t, expected, actual, "error on status %d", statusCode)
}
})
t.Run("test retry error details", func(t *testing.T) {
for _, tc := range []struct {
status int
body io.Reader
exp string
}{
{
status: http.StatusBadRequest,
body: bytes.NewBuffer([]byte(
`{"status":"invalid event"}`,
)),
exp: fmt.Sprintf(`unexpected status code %d: {"status":"invalid event"}`, http.StatusBadRequest),
},
{
status: http.StatusBadRequest,
exp: fmt.Sprintf(`unexpected status code %d`, http.StatusBadRequest),
},
} {
t.Run("", func(t *testing.T) {
_, err = notifier.retrier.Check(tc.status, tc.body)
require.Equal(t, tc.exp, err.Error())
})
}
})
}
func TestWebhookTruncateAlerts(t *testing.T) {
alerts := make([]*types.Alert, 10)
truncatedAlerts, numTruncated := truncateAlerts(0, alerts)
require.Len(t, truncatedAlerts, 10)
require.EqualValues(t, 0, numTruncated)
truncatedAlerts, numTruncated = truncateAlerts(4, alerts)
require.Len(t, truncatedAlerts, 4)
require.EqualValues(t, 6, numTruncated)
truncatedAlerts, numTruncated = truncateAlerts(100, alerts)
require.Len(t, truncatedAlerts, 10)
require.EqualValues(t, 0, numTruncated)
}
func TestWebhookRedactedURL(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
secret := "secret"
notifier, err := New(
&config.WebhookConfig{
URL: config.SecretTemplateURL(u.String()),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret)
}
func TestWebhookReadingURLFromFile(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
f, err := os.CreateTemp(t.TempDir(), "webhook_url")
require.NoError(t, err, "creating temp file failed")
_, err = f.WriteString(u.String() + "\n")
require.NoError(t, err, "writing to temp file failed")
notifier, err := New(
&config.WebhookConfig{
URLFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String())
}
func TestWebhookURLTemplating(t *testing.T) {
var calledURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledURL = r.URL.Path
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
tests := []struct {
name string
url string
groupLabels model.LabelSet
alertLabels model.LabelSet
expectError bool
expectedErrMsg string
expectedPath string
}{
{
name: "templating with alert labels",
url: srv.URL + "/{{ .GroupLabels.alertname }}/{{ .CommonLabels.severity }}",
groupLabels: model.LabelSet{"alertname": "TestAlert"},
alertLabels: model.LabelSet{"alertname": "TestAlert", "severity": "critical"},
expectError: false,
expectedPath: "/TestAlert/critical",
},
{
name: "invalid template field",
url: srv.URL + "/{{ .InvalidField }}",
groupLabels: model.LabelSet{"alertname": "TestAlert"},
alertLabels: model.LabelSet{"alertname": "TestAlert"},
expectError: true,
expectedErrMsg: "failed to template webhook URL",
},
{
name: "template renders to empty string",
url: "{{ if .CommonLabels.nonexistent }}http://example.com{{ end }}",
groupLabels: model.LabelSet{"alertname": "TestAlert"},
alertLabels: model.LabelSet{"alertname": "TestAlert"},
expectError: true,
expectedErrMsg: "webhook URL is empty after templating",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
calledURL = "" // Reset for each test
notifier, err := New(
&config.WebhookConfig{
URL: config.SecretTemplateURL(tc.url),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
promslog.NewNopLogger(),
)
require.NoError(t, err)
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "test-group")
if tc.groupLabels != nil {
ctx = notify.WithGroupLabels(ctx, tc.groupLabels)
}
alerts := []*types.Alert{
{
Alert: model.Alert{
Labels: tc.alertLabels,
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
}
_, err = notifier.Notify(ctx, alerts...)
if tc.expectError {
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectedErrMsg)
} else {
require.NoError(t, err)
require.Equal(t, tc.expectedPath, calledURL)
}
})
}
}

View File

@@ -0,0 +1,254 @@
package alertmanagertemplate
import (
"context"
"log/slog"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
)
// AlertManagerTemplater processes alert notification templates.
type AlertManagerTemplater interface {
// ProcessTemplates expands the title and body templates from input
// against the provided alerts and returns the expanded templates.
ProcessTemplates(ctx context.Context, input TemplateInput, alerts []*types.Alert) (*ExpandedTemplates, error)
}
type alertManagerTemplater struct {
tmpl *template.Template
logger *slog.Logger
}
func New(tmpl *template.Template, logger *slog.Logger) AlertManagerTemplater {
return &alertManagerTemplater{tmpl: tmpl, logger: logger}
}
// ProcessTemplates expands the title and body templates from input
// against the provided alerts and returns the expanded templates.
func (at *alertManagerTemplater) ProcessTemplates(
ctx context.Context,
input TemplateInput,
alerts []*types.Alert,
) (*ExpandedTemplates, error) {
ntd := at.buildNotificationTemplateData(ctx, alerts)
title, titleMissingVars, err := at.expandTitle(ctx, input, alerts, ntd)
if err != nil {
return nil, err
}
body, bodyMissingVars, err := at.expandBody(ctx, input, alerts, ntd)
if err != nil {
return nil, err
}
missingVars := make(map[string]bool)
for k := range titleMissingVars {
missingVars[k] = true
}
for k := range bodyMissingVars {
missingVars[k] = true
}
return &ExpandedTemplates{Title: title, Body: body, MissingVars: missingVars}, nil
}
// expandTitle expands the title template. Falls back to the default if the custom template
// result in empty string.
func (at *alertManagerTemplater) expandTitle(
ctx context.Context,
input TemplateInput,
alerts []*types.Alert,
ntd *NotificationTemplateData,
) (string, map[string]bool, error) {
if input.TitleTemplate != "" {
processRes, err := PreProcessTemplateAndData(input.TitleTemplate, ntd)
if err != nil {
return "", nil, err
}
result, err := at.tmpl.ExecuteTextString(processRes.Template, processRes.Data)
if err != nil {
return "", nil, errors.NewInternalf(errors.CodeInvalidInput, "failed to execute template: %s", err.Error())
}
if strings.TrimSpace(result) != "" {
return result, processRes.UnknownVars, nil
}
}
if input.DefaultTitleTemplate == "" {
return "", nil, nil
}
// Fall back to the default title template if present in the input
data := notify.GetTemplateData(ctx, at.tmpl, alerts, at.logger)
result, err := at.tmpl.ExecuteTextString(input.DefaultTitleTemplate, data)
return result, nil, err
}
// expandBody expands the body template once per alert and concatenates the results to return resulting body template
// it falls back to the default templates if body template is empty or result in empty string.
func (at *alertManagerTemplater) expandBody(
ctx context.Context,
input TemplateInput,
alerts []*types.Alert,
ntd *NotificationTemplateData,
) (string, map[string]bool, error) {
if input.BodyTemplate != "" {
var sb strings.Builder
missingVars := make(map[string]bool)
for i := range ntd.Alerts {
processRes, err := PreProcessTemplateAndData(input.BodyTemplate, &ntd.Alerts[i])
if err != nil {
return "", nil, err
}
for k := range processRes.UnknownVars {
missingVars[k] = true
}
part, err := at.tmpl.ExecuteTextString(processRes.Template, processRes.Data)
if err != nil {
return "", nil, errors.NewInternalf(errors.CodeInvalidInput, "failed to execute template: %s", err.Error())
}
sb.WriteString(part)
// Add separator if not last alert
if i < len(ntd.Alerts)-1 {
sb.WriteString("<br><br>")
}
}
result := sb.String()
if strings.TrimSpace(result) != "" {
return result, missingVars, nil
}
}
if input.DefaultBodyTemplate == "" {
return "", nil, nil
}
// Fall back to the default body template if present in the input
data := notify.GetTemplateData(ctx, at.tmpl, alerts, at.logger)
result, err := at.tmpl.ExecuteTextString(input.DefaultBodyTemplate, data)
return result, nil, err
}
// buildNotificationTemplateData creates the NotificationTemplateData using
// info from context and the raw alerts.
func (at *alertManagerTemplater) buildNotificationTemplateData(
ctx context.Context,
alerts []*types.Alert,
) *NotificationTemplateData {
// extract the required data from the context
receiver, ok := notify.ReceiverName(ctx)
if !ok {
at.logger.WarnContext(ctx, "missing receiver name in context")
}
groupLabels, ok := notify.GroupLabels(ctx)
if !ok {
at.logger.WarnContext(ctx, "missing group labels in context")
}
// extract the external URL from the template
externalURL := ""
if at.tmpl.ExternalURL != nil {
externalURL = at.tmpl.ExternalURL.String()
}
commonAnnotations := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
commonLabels := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Labels })
// aggregate labels and annotations from all alerts
labels := aggregateKV(alerts, func(a *types.Alert) model.LabelSet { return a.Labels })
annotations := aggregateKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
// build the alert data slice
alertDataSlice := make([]AlertData, 0, len(alerts))
for _, a := range alerts {
ad := buildAlertData(a, receiver)
alertDataSlice = append(alertDataSlice, ad)
}
// count the number of firing and resolved alerts
var firing, resolved int
for _, ad := range alertDataSlice {
if ad.IsFiring {
firing++
} else if ad.IsResolved {
resolved++
}
}
// extract the rule-level convenience fields from common labels
alertName := commonLabels[ruletypes.LabelAlertName]
ruleID := commonLabels[ruletypes.LabelRuleId]
ruleLink := commonLabels[ruletypes.LabelRuleSource]
// build the group labels
gl := make(template.KV, len(groupLabels))
for k, v := range groupLabels {
gl[string(k)] = string(v)
}
// build the notification template data
return &NotificationTemplateData{
Receiver: receiver,
Status: string(types.Alerts(alerts...).Status()),
AlertName: alertName,
RuleID: ruleID,
RuleLink: ruleLink,
TotalFiring: firing,
TotalResolved: resolved,
Alerts: alertDataSlice,
GroupLabels: gl,
CommonLabels: commonLabels,
CommonAnnotations: commonAnnotations,
ExternalURL: externalURL,
Labels: labels,
Annotations: annotations,
}
}
// buildAlertData converts a single *types.Alert into an AlertData.
func buildAlertData(a *types.Alert, receiver string) AlertData {
labels := make(template.KV, len(a.Labels))
for k, v := range a.Labels {
labels[string(k)] = string(v)
}
annotations := make(template.KV, len(a.Annotations))
for k, v := range a.Annotations {
annotations[string(k)] = string(v)
}
status := string(a.Status())
isFiring := a.Status() == model.AlertFiring
isResolved := a.Status() == model.AlertResolved
isMissingData := labels[ruletypes.LabelNoData] == "true"
return AlertData{
Receiver: receiver,
Status: status,
Labels: labels,
Annotations: annotations,
StartsAt: a.StartsAt,
EndsAt: a.EndsAt,
GeneratorURL: a.GeneratorURL,
Fingerprint: a.Fingerprint().String(),
AlertName: labels[ruletypes.LabelAlertName],
RuleID: labels[ruletypes.LabelRuleId],
RuleLink: labels[ruletypes.LabelRuleSource],
Severity: labels[ruletypes.LabelSeverityName],
LogLink: annotations[ruletypes.AnnotationRelatedLogs],
TraceLink: annotations[ruletypes.AnnotationRelatedTraces],
Value: annotations[ruletypes.AnnotationValue],
Threshold: annotations[ruletypes.AnnotationThreshold],
CompareOp: annotations[ruletypes.AnnotationCompareOp],
MatchType: annotations[ruletypes.AnnotationMatchType],
IsFiring: isFiring,
IsResolved: isResolved,
IsMissingData: isMissingData,
}
}

View File

@@ -0,0 +1,272 @@
package alertmanagertemplate
import (
"context"
"log/slog"
"testing"
"time"
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/types"
)
// testSetup returns an AlertTemplater and a context pre-populated with group key,
// receiver name, and group labels for use in tests.
func testSetup(t *testing.T) (AlertManagerTemplater, context.Context) {
t.Helper()
tmpl := test.CreateTmpl(t)
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "test-group")
ctx = notify.WithReceiverName(ctx, "slack")
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": "TestAlert", "severity": "critical"})
logger := slog.New(slog.DiscardHandler)
return New(tmpl, logger), ctx
}
func createAlert(labels, annotations map[string]string, isFiring bool) *types.Alert {
ls := model.LabelSet{}
for k, v := range labels {
ls[model.LabelName(k)] = model.LabelValue(v)
}
ann := model.LabelSet{}
for k, v := range annotations {
ann[model.LabelName(k)] = model.LabelValue(v)
}
startsAt := time.Now()
var endsAt time.Time
if isFiring {
endsAt = startsAt.Add(time.Hour)
} else {
startsAt = startsAt.Add(-2 * time.Hour)
endsAt = startsAt.Add(-time.Hour)
}
return &types.Alert{Alert: model.Alert{Labels: ls, Annotations: ann, StartsAt: startsAt, EndsAt: endsAt}}
}
func TestExpandTemplates(t *testing.T) {
at, ctx := testSetup(t)
tests := []struct {
name string
alerts []*types.Alert
input TemplateInput
wantTitle string
wantBody string
wantMissingVars []string
errorContains string
}{
{
// High request throughput on a service — service is a custom label.
// $labels.service extracts the label value; $annotations.description pulls the annotation.
name: "new template: high request throughput for a service",
alerts: []*types.Alert{
createAlert(
map[string]string{
ruletypes.LabelAlertName: "HighRequestThroughput",
ruletypes.LabelSeverityName: "warning",
"service": "payment-service",
},
map[string]string{"description": "Request rate exceeded 10k/s"},
true,
),
},
input: TemplateInput{
TitleTemplate: "High request throughput for $service",
BodyTemplate: `The service $service is getting high request. Please investigate.
Severity: $severity
Status: $status
Service: $service
Description: $description`,
},
wantTitle: "High request throughput for payment-service",
wantBody: `The service payment-service is getting high request. Please investigate.
Severity: warning
Status: firing
Service: payment-service
Description: Request rate exceeded 10k/s`,
},
{
// Disk usage alert using old Go template syntax throughout.
// No custom templates — both title and body use the default fallback path.
name: "old template: disk usage high on database host",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "DiskUsageHigh",
ruletypes.LabelSeverityName: "critical",
"instance": "db-primary-01",
},
map[string]string{
"summary": "Disk usage high on database host",
"description": "Disk usage is high on the database host",
"related_logs": "https://logs.example.com/search?q=DiskUsageHigh",
"related_traces": "https://traces.example.com/search?q=DiskUsageHigh",
},
true,
),
},
input: TemplateInput{
DefaultTitleTemplate: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
{{" "}}(
{{- with .CommonLabels.Remove .GroupLabels.Names }}
{{- range $index, $label := .SortedPairs -}}
{{ if $index }}, {{ end }}
{{- $label.Name }}="{{ $label.Value -}}"
{{- end }}
{{- end -}}
)
{{- end }}`,
DefaultBodyTemplate: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
*Summary:* {{ .Annotations.summary }}
*Description:* {{ .Annotations.description }}
*RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}
*RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}
*Details:*
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
{{ end }}
{{ end }}`,
},
wantTitle: "[FIRING:1] DiskUsageHigh for (instance=\"db-primary-01\")",
wantBody: `*Alert:* DiskUsageHigh - critical
*Summary:* Disk usage high on database host
*Description:* Disk usage is high on the database host
*RelatedLogs:* View in <https://logs.example.com/search?q=DiskUsageHigh|logs explorer>
*RelatedTraces:* View in <https://traces.example.com/search?q=DiskUsageHigh|traces explorer>
*Details:*
• *alertname:* DiskUsageHigh
• *instance:* db-primary-01
• *severity:* critical
`,
},
{
// Pod crash loop on multiple pods — body is expanded once per alert
// and joined with "<br><br>", with the pod name pulled from labels.
name: "new template: pod crash loop on multiple pods, body per-alert",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "PodCrashLoop", "pod": "api-worker-1"}, nil, true),
createAlert(map[string]string{ruletypes.LabelAlertName: "PodCrashLoop", "pod": "api-worker-2"}, nil, true),
createAlert(map[string]string{ruletypes.LabelAlertName: "PodCrashLoop", "pod": "api-worker-3"}, nil, true),
},
input: TemplateInput{
TitleTemplate: "$rule_name: $total_firing pods affected",
BodyTemplate: "$labels.pod is crash looping",
},
wantTitle: "PodCrashLoop: 3 pods affected",
wantBody: "api-worker-1 is crash looping<br><br>api-worker-2 is crash looping<br><br>api-worker-3 is crash looping",
},
{
// Incident partially resolved — one service still down, one recovered.
// Title shows the aggregate counts; body shows per-service status.
name: "new template: service degradation with mixed firing and resolved alerts",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "ServiceDown", "service": "auth-service"}, nil, true),
createAlert(map[string]string{ruletypes.LabelAlertName: "ServiceDown", "service": "payment-service"}, nil, false),
},
input: TemplateInput{
TitleTemplate: "$total_firing firing, $total_resolved resolved",
BodyTemplate: "$labels.service ($status)",
},
wantTitle: "1 firing, 1 resolved",
wantBody: "auth-service (firing)<br><br>payment-service (resolved)",
},
{
// $environment is not a known AlertData or NotificationTemplateData field,
// so it lands in MissingVars and renders as "<no value>" in the output.
name: "missing vars: unknown $environment variable in title",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"}, nil, true),
},
input: TemplateInput{
TitleTemplate: "[$environment] $rule_name",
},
wantTitle: "[<no value>] HighCPU",
wantMissingVars: []string{"environment"},
},
{
// $runbook_url is not a known field — someone tried to embed a runbook link
// directly as a variable instead of via annotations.
name: "missing vars: unknown $runbook_url variable in body",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "PodOOMKilled", ruletypes.LabelSeverityName: "warning"}, nil, true),
},
input: TemplateInput{
BodyTemplate: "$rule_name: see runbook at $runbook_url",
},
wantBody: "PodOOMKilled: see runbook at <no value>",
wantMissingVars: []string{"runbook_url"},
},
{
// Both title and body use unknown variables; MissingVars is the union of both.
name: "missing vars: unknown variables in both title and body",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "HighMemory", ruletypes.LabelSeverityName: "critical"}, nil, true),
},
input: TemplateInput{
TitleTemplate: "[$environment] $rule_name and [{{ $service }}]",
BodyTemplate: "$rule_name: see runbook at $runbook_url",
},
wantTitle: "[<no value>] HighMemory and [<no value>]",
wantBody: "HighMemory: see runbook at <no value>",
wantMissingVars: []string{"environment", "runbook_url", "service"},
},
{
// Custom title template that expands to only whitespace triggers the fallback,
// so the default title template is used instead.
name: "fallback: whitespace-only custom title falls back to default",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"}, nil, false),
},
input: TemplateInput{
TitleTemplate: " ",
DefaultTitleTemplate: "{{ .CommonLabels.alertname }} ({{ .Status | toUpper }})",
BodyTemplate: "$rule_name ($severity) for $alertname",
},
wantTitle: "HighCPU (RESOLVED)",
wantBody: "HighCPU (critical) for HighCPU",
},
{
name: "using non-existing function in template",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"}, nil, true),
},
input: TemplateInput{
TitleTemplate: "$rule_name ({{$severity | toUpperAndTrim}}) for $alertname",
},
errorContains: "function \"toUpperAndTrim\" not defined",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := at.ProcessTemplates(ctx, tc.input, tc.alerts)
if tc.errorContains != "" {
require.ErrorContains(t, err, tc.errorContains)
return
}
require.NoError(t, err)
if tc.wantTitle != "" {
require.Equal(t, tc.wantTitle, got.Title)
}
if tc.wantBody != "" {
require.Equal(t, tc.wantBody, got.Body)
}
require.Len(t, got.MissingVars, len(tc.wantMissingVars))
for _, v := range tc.wantMissingVars {
require.True(t, got.MissingVars[v], "expected %q in MissingVars", v)
}
})
}
}

View File

@@ -0,0 +1,242 @@
package alertmanagertemplate
import (
"fmt"
"reflect"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/go-viper/mapstructure/v2"
)
// fieldMapping represents a mapping from a JSON tag name to its struct field name.
type fieldMapping struct {
VarName string // JSON tag name (e.g., "receiver", "rule_name")
FieldName string // Struct field name (e.g., "Receiver", "AlertName")
}
// extractFieldMappings uses reflection to extract field mappings from a struct.
func extractFieldMappings(data any) []fieldMapping {
val := reflect.ValueOf(data)
// Handle pointer types
if val.Kind() == reflect.Ptr {
if val.IsNil() {
return nil
}
val = val.Elem()
}
// return nil if the given data is not a struct
if val.Kind() != reflect.Struct {
return nil
}
typ := val.Type()
var mappings []fieldMapping
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
// Skip unexported fields
if !field.IsExported() {
continue
}
// Get JSON tag name
jsonTag := field.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
// Extract the name part (before any comma options like omitempty)
varName := strings.Split(jsonTag, ",")[0]
if varName == "" {
continue
}
varFieldName := field.Tag.Get("mapstructure")
if varFieldName == "" {
varFieldName = field.Name
}
// Skip complex types: slices and interfaces
kind := field.Type.Kind()
if kind == reflect.Slice || kind == reflect.Interface {
continue
}
// For struct types, we skip all but with few exceptions like time.Time
if kind == reflect.Struct {
// Allow time.Time which is commonly used
if field.Type.String() != "time.Time" {
continue
}
}
mappings = append(mappings, fieldMapping{
VarName: varName,
FieldName: varFieldName,
})
}
return mappings
}
// extractNestedFieldsDefinitions adds the labels and annotations keys from the data struct to the template variable definitions
// it takes the known data struct and extracts the labels and annotations maps and adds their keys to template variable definitions to be used in the template
func extractNestedFieldsDefinitions(data any) map[string]string {
variables := make(map[string]string)
addLabelsAndAnnotations := func(labels, annotations map[string]string) {
for k := range annotations {
variables[k] = fmt.Sprintf("index .annotations \"%s\"", k)
}
for k := range labels {
variables[k] = fmt.Sprintf("index .labels \"%s\"", k)
}
}
switch data := data.(type) {
case *NotificationTemplateData:
addLabelsAndAnnotations(data.Labels, data.Annotations)
case *AlertData:
addLabelsAndAnnotations(data.Labels, data.Annotations)
default:
return variables
}
return variables
}
// prepareDataForTemplating prepares the data for templating by adding the labels and annotations values to the resulting map
// so they can be accessed directly from root level, the predefined values takes precedence over the labels and annotations values
// for example, if labels has a value called rule_name, which collides with the rule_name field in the data struct, the value from the data struct will take precedence
func prepareDataForTemplating(data any) (map[string]interface{}, error) {
var result map[string]interface{}
if err := mapstructure.Decode(data, &result); err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to prepare data for templating")
}
addLabelsAndAnnotationsValues := func(labels, annotations map[string]string) {
for k, v := range labels {
if _, ok := result[k]; !ok {
result[k] = v
}
}
for k, v := range annotations {
if _, ok := result[k]; !ok {
result[k] = v
}
}
}
switch data := data.(type) {
case *NotificationTemplateData:
addLabelsAndAnnotationsValues(data.Labels, data.Annotations)
case *AlertData:
addLabelsAndAnnotationsValues(data.Labels, data.Annotations)
default:
return result, nil
}
return result, nil
}
// generateVariableDefinitions creates `{{ $varname := "" }}` declarations for each variable name.
func generateVariableDefinitions(varNames map[string]string) string {
if len(varNames) == 0 {
return ""
}
var sb strings.Builder
for name := range varNames {
fmt.Fprintf(&sb, `{{ $%s := %s }}`, name, varNames[name])
}
return sb.String()
}
// buildVariableDefinitions constructs the full variable definition preamble for a template.
// containing all known and unknown variables, the reason to include unknown variables is to
// populate them with "<no value>" in template so go-text-template don't throw errors
// when these variables are used in the template.
func buildVariableDefinitions(tmpl string, data any) (string, map[string]bool, error) {
// Extract the initial fields from the data struct and add to the definitions
mappings := extractFieldMappings(data)
// Add variables from struct root level fields to the definitions
variables := make(map[string]string)
for _, m := range mappings {
variables[m.VarName] = fmt.Sprintf(".%s", m.FieldName)
}
// Extract the nested fields definitions from the data struct, like labels, annotations, etc.
// once extracted we add them to the variables map along with the field address
nestedVariables := extractNestedFieldsDefinitions(data)
for k, v := range nestedVariables {
variables[k] = v
}
// variables that are used throughout the template
usedVars, err := ExtractUsedVariables(tmpl)
if err != nil {
return "", nil, err
}
// Compute unknown variables: used in template but not covered by a field mapping
probableUnknownVars := make(map[string]bool)
for name := range usedVars {
_, ok := variables[name]
if !ok {
probableUnknownVars[name] = true
}
}
// Add missing variables to the definitions with "<no value>"
// missingkey=zero is used to replace the missing value with "<no value>"
// but it only works when getting map values like {{ .keyfrommap }} from map and in struct this breaks
// with missing variable errors, we add missing variables in map so when directly variables
// are accessed directly in template block like {{ $variable }} it's handled and doesn't throw errors.
for name := range probableUnknownVars {
variables[name] = `"<no value>"`
}
return generateVariableDefinitions(variables), probableUnknownVars, nil
}
type ProcessingResult struct {
Template string
Data map[string]interface{}
// UnknownVars is the set of possible unknown variables exptracted using regex
UnknownVars map[string]bool
}
// PreProcessTemplateAndData prepares a template string and struct data for Go template execution.
//
// Input: "$receiver has $rule_name in $status state"
// Output: "{{ $receiver := .Receiver }}...{{ $receiver }} has {{ $rule_name }} in {{ $status }} state"
func PreProcessTemplateAndData(tmpl string, data any) (*ProcessingResult, error) {
// Handle empty template
unknownVars := make(map[string]bool)
if tmpl == "" {
result, err := prepareDataForTemplating(data)
if err != nil {
return nil, err
}
return &ProcessingResult{Data: result, UnknownVars: unknownVars}, nil
}
// Build variable definitions: known struct fields + fallback empty-string declarations
definitions, unknownVars, err := buildVariableDefinitions(tmpl, data)
if err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to build template definitions")
}
// Attach definitions prefix so WrapDollarVariables can parse the AST without "undefined variable" errors.
finalTmpl := definitions + tmpl
// Call WrapDollarVariables to transform bare $variable references to go-text-template format
// with {{ $variable }} syntax from $variable syntax
wrappedTmpl, err := WrapDollarVariables(finalTmpl)
if err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to prepare template for templating")
}
// Convert struct to map using mapstructure to be used for template execution
result, err := prepareDataForTemplating(data)
if err != nil {
return nil, err
}
return &ProcessingResult{Template: wrappedTmpl, Data: result, UnknownVars: unknownVars}, nil
}

View File

@@ -0,0 +1,316 @@
package alertmanagertemplate
import (
"testing"
"time"
"github.com/prometheus/alertmanager/template"
"github.com/stretchr/testify/require"
)
func TestExtractFieldMappings(t *testing.T) {
// Struct with various field types to test extraction logic
type TestStruct struct {
Name string `json:"name"`
Status string `json:"status"`
ActiveUserCount int `json:"user_count" mapstructure:"active_user_count"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"` // time.Time allowed
Items []string `json:"items"` // slice skipped
unexported string // unexported skipped (no tag needed)
NoTag string // no json tag skipped
SkippedTag string `json:"-"` // json:"-" skipped
}
testCases := []struct {
name string
data any
expected []fieldMapping
}{
{
name: "struct with mixed field types",
data: TestStruct{Name: "test", ActiveUserCount: 5, unexported: ""},
expected: []fieldMapping{
{VarName: "name", FieldName: "Name"},
{VarName: "status", FieldName: "Status"},
{VarName: "user_count", FieldName: "active_user_count"},
{VarName: "is_active", FieldName: "IsActive"},
{VarName: "created_at", FieldName: "CreatedAt"},
},
},
{
name: "nil data",
data: nil,
expected: nil,
},
{
name: "non-struct type",
data: "string",
expected: nil,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := extractFieldMappings(tc.data)
require.Equal(t, tc.expected, result)
})
}
}
func TestBuildVariableDefinitions(t *testing.T) {
testCases := []struct {
name string
tmpl string
data any
expectedVars []string // substrings that must appear in result
expectError bool
}{
{
name: "empty template still returns struct field definitions",
tmpl: "",
data: &NotificationTemplateData{Receiver: "test"},
expectedVars: []string{
"{{ $receiver := .receiver }}",
"{{ $status := .status }}",
},
},
{
name: "mix of known and unknown vars",
tmpl: "$rule_name: $custom_label",
data: &AlertData{AlertName: "test", Status: "ok", Severity: "critical"},
expectedVars: []string{
"{{ $rule_name := .rule_name }}",
"{{ $status := .status }}",
"{{ $severity := .severity }}",
`{{ $custom_label := "<no value>" }}`,
},
},
{
name: "nested fields definitions coming from NotificationTemplateData",
tmpl: "$severity for $service",
data: &NotificationTemplateData{Labels: template.KV{
"severity": "critical",
"service": "test",
}},
expectedVars: []string{
"{{ $severity := index .labels \"severity\" }}",
"{{ $service := index .labels \"service\" }}",
},
},
{
name: "nested fields definitions coming from AlertData",
tmpl: "$severity for $service",
data: &AlertData{Labels: template.KV{
"severity": "critical",
"service": "test",
}},
expectedVars: []string{
"{{ $severity := index .labels \"severity\" }}",
"{{ $service := index .labels \"service\" }}",
},
},
{
name: "invalid template syntax returns error",
tmpl: "{{invalid",
data: &NotificationTemplateData{},
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, _, err := buildVariableDefinitions(tc.tmpl, tc.data)
if tc.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
if len(tc.expectedVars) == 0 {
require.Empty(t, result)
return
}
for _, expected := range tc.expectedVars {
require.Contains(t, result, expected)
}
})
}
}
func TestPreProcessTemplateAndData(t *testing.T) {
testCases := []struct {
name string
tmpl string
data any
expectedTemplateContains []string
expectedData map[string]any
expectedUnknownVars map[string]bool
expectError bool
}{
{
name: "NotificationTemplateData with dollar variables",
tmpl: "[$status] $rule_name (ID: $rule_id) - Firing: $total_firing, Resolved: $total_resolved, Severity: $severity",
data: &NotificationTemplateData{
Receiver: "pagerduty",
Status: "firing",
AlertName: "HighMemory",
RuleID: "rule-123",
Labels: template.KV{
"severity": "critical",
},
TotalFiring: 3,
TotalResolved: 1,
},
expectedTemplateContains: []string{
"{{$status := .status}}",
"{{$rule_name := .rule_name}}",
"{{$rule_id := .rule_id}}",
"{{$total_firing := .total_firing}}",
"{{$total_resolved := .total_resolved}}",
"{{$severity := index .labels \"severity\"}}",
"[{{ .status }}] {{ .rule_name }} (ID: {{ .rule_id }}) - Firing: {{ .total_firing }}, Resolved: {{ .total_resolved }}",
},
expectedData: map[string]any{
"status": "firing",
"rule_name": "HighMemory",
"rule_id": "rule-123",
"total_firing": 3,
"total_resolved": 1,
"severity": "critical",
},
expectedUnknownVars: map[string]bool{},
},
{
name: "AlertData with dollar variables",
tmpl: "$rule_name: Value $value exceeded $threshold (Status: $status, Severity: $severity, Description: $description)",
data: &AlertData{
Receiver: "webhook",
Status: "resolved",
AlertName: "DiskFull",
RuleID: "disk-001",
Severity: "warning",
Annotations: template.KV{
"description": "Disk full and cannot be written to",
},
Value: "85%",
Threshold: "80%",
IsFiring: false,
IsResolved: true,
},
expectedTemplateContains: []string{
"{{$rule_name := .rule_name}}",
"{{$value := .value}}",
"{{$threshold := .threshold}}",
"{{$status := .status}}",
"{{$severity := .severity}}",
"{{$description := index .annotations \"description\"}}",
"{{ .rule_name }}: Value {{ .value }} exceeded {{ .threshold }} (Status: {{ .status }}, Severity: {{ .severity }}, Description: {{ .description }})",
},
expectedData: map[string]any{
"status": "resolved",
"rule_name": "DiskFull",
"rule_id": "disk-001",
"severity": "warning",
"value": "85%",
"threshold": "80%",
"description": "Disk full and cannot be written to",
},
expectedUnknownVars: map[string]bool{},
},
{
name: "mixed dollar and dot notation with both labels and annotations",
tmpl: "Alert $rule_name has {{.total_firing}} firing alerts",
data: &NotificationTemplateData{
AlertName: "HighCPU",
TotalFiring: 5,
Labels: template.KV{
"value": "<MASKED VALUE>",
},
Annotations: template.KV{
"value": "85%",
},
},
expectedTemplateContains: []string{
"{{$rule_name := .rule_name}}",
"{{$value := index .labels \"value\"}}",
"Alert {{ .rule_name }} has {{.total_firing}} firing alerts",
},
expectedData: map[string]any{
"rule_name": "HighCPU",
"total_firing": 5,
"value": "<MASKED VALUE>",
},
expectedUnknownVars: map[string]bool{},
},
{
name: "empty template",
tmpl: "",
data: &NotificationTemplateData{Receiver: "slack"},
},
{
name: "invalid template syntax",
tmpl: "{{invalid",
data: &NotificationTemplateData{},
expectError: true,
},
{
name: "unknown dollar var in text renders empty",
tmpl: "alert $custom_note fired",
data: &NotificationTemplateData{AlertName: "HighCPU"},
expectedTemplateContains: []string{
`{{$custom_note := "<no value>"}}`,
"alert {{ .custom_note }} fired",
},
expectedUnknownVars: map[string]bool{"custom_note": true},
},
{
name: "unknown dollar var in action block renders empty",
tmpl: "alert {{ $custom_note }} fired",
data: &NotificationTemplateData{AlertName: "HighCPU"},
expectedTemplateContains: []string{
`{{$custom_note := "<no value>"}}`,
`alert {{$custom_note}} fired`,
},
expectedUnknownVars: map[string]bool{"custom_note": true},
},
{
name: "mix of known and unknown vars",
tmpl: "$rule_name: $custom_label",
data: &NotificationTemplateData{AlertName: "HighCPU"},
expectedTemplateContains: []string{
"{{$rule_name := .rule_name}}",
`{{$custom_label := "<no value>"}}`,
"{{ .rule_name }}: {{ .custom_label }}",
},
expectedData: map[string]any{"rule_name": "HighCPU"},
expectedUnknownVars: map[string]bool{"custom_label": true},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := PreProcessTemplateAndData(tc.tmpl, tc.data)
if tc.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
if tc.tmpl == "" {
require.Equal(t, "", result.Template)
return
}
for _, substr := range tc.expectedTemplateContains {
require.Contains(t, result.Template, substr)
}
for k, v := range tc.expectedData {
require.Equal(t, v, result.Data[k])
}
if tc.expectedUnknownVars != nil {
require.Equal(t, tc.expectedUnknownVars, result.UnknownVars)
}
})
}
}

View File

@@ -0,0 +1,86 @@
package alertmanagertemplate
import (
"time"
"github.com/prometheus/alertmanager/template"
)
// TemplateInput carries the title/body templates
// and their defaults to apply in case the custom templates
// are result in empty strings.
type TemplateInput struct {
TitleTemplate string
BodyTemplate string
DefaultTitleTemplate string
DefaultBodyTemplate string
}
// ExpandedTemplates is the result of ExpandTemplates.
type ExpandedTemplates struct {
Title string
Body string
MissingVars map[string]bool // union of unknown vars from title + body templates
}
// AlertData holds per-alert data used when expanding body templates
type AlertData struct {
Receiver string `json:"receiver" mapstructure:"receiver"`
Status string `json:"status" mapstructure:"status"`
Labels template.KV `json:"labels" mapstructure:"labels"`
Annotations template.KV `json:"annotations" mapstructure:"annotations"`
StartsAt time.Time `json:"starts_at" mapstructure:"starts_at"`
EndsAt time.Time `json:"ends_at" mapstructure:"ends_at"`
GeneratorURL string `json:"generator_url" mapstructure:"generator_url"`
Fingerprint string `json:"fingerprint" mapstructure:"fingerprint"`
// Convenience fields extracted from well-known labels/annotations.
AlertName string `json:"rule_name" mapstructure:"rule_name"`
RuleID string `json:"rule_id" mapstructure:"rule_id"`
RuleLink string `json:"rule_link" mapstructure:"rule_link"`
Severity string `json:"severity" mapstructure:"severity"`
// Alert internal data fields
Value string `json:"value" mapstructure:"value"`
Threshold string `json:"threshold" mapstructure:"threshold"`
CompareOp string `json:"compare_op" mapstructure:"compare_op"`
MatchType string `json:"match_type" mapstructure:"match_type"`
// Link annotations added by the rule evaluator.
LogLink string `json:"log_link" mapstructure:"log_link"`
TraceLink string `json:"trace_link" mapstructure:"trace_link"`
// Status booleans for easy conditional templating.
IsFiring bool `json:"is_firing" mapstructure:"is_firing"`
IsResolved bool `json:"is_resolved" mapstructure:"is_resolved"`
IsMissingData bool `json:"is_missing_data" mapstructure:"is_missing_data"`
IsRecovering bool `json:"is_recovering" mapstructure:"is_recovering"`
}
// NotificationTemplateData is the top-level data struct provided to custom templates.
type NotificationTemplateData struct {
Receiver string `json:"receiver" mapstructure:"receiver"`
Status string `json:"status" mapstructure:"status"`
// Convenience fields for title templates.
AlertName string `json:"rule_name" mapstructure:"rule_name"`
RuleID string `json:"rule_id" mapstructure:"rule_id"`
RuleLink string `json:"rule_link" mapstructure:"rule_link"`
TotalFiring int `json:"total_firing" mapstructure:"total_firing"`
TotalResolved int `json:"total_resolved" mapstructure:"total_resolved"`
// Per-alert data, also available as filtered sub-slices.
Alerts []AlertData `json:"-" mapstructure:"-"`
// Cross-alert aggregates, computed as intersection across all alerts.
GroupLabels template.KV `json:"group_labels" mapstructure:"group_labels"`
CommonLabels template.KV `json:"common_labels" mapstructure:"common_labels"`
CommonAnnotations template.KV `json:"common_annotations" mapstructure:"common_annotations"`
ExternalURL string `json:"external_url" mapstructure:"external_url"`
// Labels and Annotations that are collection of labels
// and annotations from all alerts, it includes only the common labels and annotations
// and for non-common labels and annotations, it picks some first few labels/annotations
// and joins them with ", " to avoid blank values in the template
Labels template.KV `json:"labels" mapstructure:"labels"`
Annotations template.KV `json:"annotations" mapstructure:"annotations"`
}

View File

@@ -0,0 +1,234 @@
package alertmanagertemplate
import (
"fmt"
"reflect"
"regexp"
"strings"
"text/template/parse"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
)
// maxAggregatedValues is the maximum number of unique values to include
// when aggregating non-common label/annotation values across alerts.
const maxAggregatedValues = 5
// bareVariableRegex matches bare $variable references including dotted paths like $service.name.
var bareVariableRegex = regexp.MustCompile(`\$(\w+(?:\.\w+)*)`)
// bareVariableRegexFirstSeg matches only the base $variable name, stopping before any dotted path.
// e.g. "$labels.severity" matches "$labels", "$name" matches "$name".
var bareVariableRegexFirstSeg = regexp.MustCompile(`\$\w+`)
// ExtractTemplatesFromAnnotations computes the common annotations across all alerts
// and returns the values for the title_template and body_template annotation keys as title and body templates.
func ExtractTemplatesFromAnnotations(alerts []*types.Alert) (titleTemplate, bodyTemplate string) {
if len(alerts) == 0 {
return "", ""
}
commonAnnotations := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
return commonAnnotations[ruletypes.AnnotationTitleTemplate], commonAnnotations[ruletypes.AnnotationBodyTemplate]
}
// WrapDollarVariables wraps bare $variable references in Go template syntax.
// Example transformations:
// - "$name is $status" -> "{{ $name }} is {{ $status }}"
// - "$labels.severity" -> "{{ index .labels \"severity\" }}"
// - "$labels.http.status" -> "{{ index .labels \"http.status\" }}"
// - "$annotations.summary" -> "{{ index .annotations \"summary\" }}"
// - "$service.name" -> "{{ index . \"service.name\" }}"
// - "$name is {{ .Status }}" -> "{{ $name }} is {{ .Status }}"
func WrapDollarVariables(src string) (string, error) {
if src == "" {
return src, nil
}
funcMap := alertmanagertypes.AdditionalFuncMap()
// Create a new parse.Tree directly
tree := parse.New("template")
tree.Mode = parse.SkipFuncCheck
// Parse the template
_, err := tree.Parse(src, "{{", "}}", make(map[string]*parse.Tree), funcMap)
if err != nil {
return "", err
}
// Walk the AST and transform TextNodes
walkAndWrapTextNodes(tree.Root)
// Return the reassembled template
return tree.Root.String(), nil
}
// walkAndWrapTextNodes recursively walks the parse tree trying to find a text node
// once text node is found it wraps the bare $variable and changes it to index based
// element access form datamap like .key or .key.subkey
func walkAndWrapTextNodes(node parse.Node) {
if reflect.ValueOf(node).IsNil() {
return
}
switch n := node.(type) {
// `$name is {{.Status}}` is a list node with one text and one action node
case *parse.ListNode:
// Recurse into all child nodes
if n.Nodes != nil {
for _, child := range n.Nodes {
walkAndWrapTextNodes(child)
}
}
// `$name is ` is a text node with plain text in root
// we try to find the $name variable and wrap it with template block
// like `{{ .name }}`, for labels and annotations we use the index to access the value
// so `$labels.service` becomes `{{ index .labels "service" }}`
case *parse.TextNode:
// Transform $variable based on its pattern
n.Text = bareVariableRegex.ReplaceAllFunc(n.Text, func(match []byte) []byte {
// Extract variable name without the $
varName := string(match[1:])
// Check if variable contains dots
if strings.Contains(varName, ".") {
// Check for reserved prefixes: labels.* or annotations.*
if strings.HasPrefix(varName, "labels.") {
key := strings.TrimPrefix(varName, "labels.")
return []byte(fmt.Sprintf(`{{ index .labels "%s" }}`, key))
}
if strings.HasPrefix(varName, "annotations.") {
key := strings.TrimPrefix(varName, "annotations.")
return []byte(fmt.Sprintf(`{{ index .annotations "%s" }}`, key))
}
// Other dotted variables: index into root context
return []byte(fmt.Sprintf(`{{ index . "%s" }}`, varName))
}
// Simple variables: use dot notation to directly access the field
// without raising any error due to missing variables
return []byte(fmt.Sprintf("{{ .%s }}", varName))
})
// `{{if pipeline}} T1 {{else}} T0 {{end}}` is a if node with T1 part of List and T0 part of ElseList
case *parse.IfNode:
// Recurse into both branches
walkAndWrapTextNodes(n.List)
walkAndWrapTextNodes(n.ElseList)
// `{{range pipeline}} T1 {{else}} T0 {{end}}` is a range node with T1 part of List and T0 part of ElseList
case *parse.RangeNode:
// Recurse into both branches
walkAndWrapTextNodes(n.List)
walkAndWrapTextNodes(n.ElseList)
// All other node types (ActionNode, PipeNode, VariableNode, etc.) are already
// inside {{ }} action blocks and don't need transformation
// Support for `with` can be added later when we start supporting it in editor block
}
}
// ExtractUsedVariables returns the set of all $variable referenced in template
// — text nodes, action blocks, branch conditions, and loop declarations — regardless of scope.
// After finding all variables we find the ones which are not part of our alert data and handle them so
// Go-text-template parser doesn't rejects undefined $variables
func ExtractUsedVariables(src string) (map[string]bool, error) {
if src == "" {
return map[string]bool{}, nil
}
// Regex-scan raw template string to collect all $var base names.
// bareVariableRegexFirstSeg stops before dots, so "$labels.severity" yields "$labels".
used := make(map[string]bool)
for _, m := range bareVariableRegexFirstSeg.FindAll([]byte(src), -1) {
used[string(m[1:])] = true // strip leading "$"
}
// Build a preamble that pre-declares every found variable.
// This prevents "undefined variable" parse errors for $vars used in action
// blocks while still letting genuine syntax errors propagate.
var preamble strings.Builder
for name := range used {
fmt.Fprintf(&preamble, `{{$%s := ""}}`, name)
}
// Validate template syntax.
funcMap := alertmanagertypes.AdditionalFuncMap()
tree := parse.New("template")
tree.Mode = parse.SkipFuncCheck
if _, err := tree.Parse(preamble.String()+src, "{{", "}}", make(map[string]*parse.Tree), funcMap); err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInternal, "failed to extract used variables")
}
return used, nil
}
// aggregateKV aggregates key-value pairs (labels or annotations) from all alerts into a single template.KV
// the result is used to populate the labels and annotations in the notification template data.
// this is done to avoid blank values in the template when labels and annotations used are not common throughout the alerts
func aggregateKV(alerts []*types.Alert, extractFn func(*types.Alert) model.LabelSet) template.KV {
// track unique values per key in order of first appearance
valuesPerKey := make(map[string][]string)
// track which values have been seen for deduplication
seenValues := make(map[string]map[string]bool)
for _, alert := range alerts {
kvPairs := extractFn(alert)
for k, v := range kvPairs {
key := string(k)
value := string(v)
if seenValues[key] == nil {
seenValues[key] = make(map[string]bool)
}
// only add if not already seen and under the limit of maxAggregatedValues
if !seenValues[key][value] && len(valuesPerKey[key]) < maxAggregatedValues {
seenValues[key][value] = true
valuesPerKey[key] = append(valuesPerKey[key], value)
}
}
}
// build the result by joining values
result := make(template.KV, len(valuesPerKey))
for key, values := range valuesPerKey {
result[key] = strings.Join(values, ", ")
}
return result
}
// extractCommonKV returns the intersection of key-value pairs across all alerts.
// A key/value pair is included only if it appears identically on every alert.
func extractCommonKV(alerts []*types.Alert, extractFn func(*types.Alert) model.LabelSet) template.KV {
if len(alerts) == 0 {
return template.KV{}
}
common := make(template.KV, len(extractFn(alerts[0])))
for k, v := range extractFn(alerts[0]) {
common[string(k)] = string(v)
}
for _, a := range alerts[1:] {
kv := extractFn(a)
for k := range common {
if string(kv[model.LabelName(k)]) != common[k] {
delete(common, k)
}
}
if len(common) == 0 {
break
}
}
return common
}

View File

@@ -0,0 +1,348 @@
package alertmanagertemplate
import (
"testing"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
)
func TestWrapBareVars(t *testing.T) {
testCases := []struct {
name string
input string
expected string
expectError bool
}{
{
name: "mixed variables with actions",
input: "$name is {{.Status}}",
expected: "{{ .name }} is {{.Status}}",
},
{
name: "nested variables in range",
input: `{{range .items}}
$title
{{end}}`,
expected: `{{range .items}}
{{ .title }}
{{end}}`,
},
{
name: "nested variables in if else",
input: "{{if .ok}}$a{{else}}$b{{end}}",
expected: "{{if .ok}}{{ .a }}{{else}}{{ .b }}{{end}}",
},
// Labels prefix: index into .labels map
{
name: "labels variables prefix simple",
input: "$labels.service",
expected: `{{ index .labels "service" }}`,
},
{
name: "labels variables prefix nested with multiple dots",
input: "$labels.http.status",
expected: `{{ index .labels "http.status" }}`,
},
{
name: "multiple labels variables simple and nested",
input: "$labels.service and $labels.instance.id",
expected: `{{ index .labels "service" }} and {{ index .labels "instance.id" }}`,
},
// Annotations prefix: index into .annotations map
{
name: "annotations variables prefix simple",
input: "$annotations.summary",
expected: `{{ index .annotations "summary" }}`,
},
{
name: "annotations variables prefix nested with multiple dots",
input: "$annotations.alert.url",
expected: `{{ index .annotations "alert.url" }}`,
},
// Other dotted paths: index into root context
{
name: "other variables with multiple dots",
input: "$service.name",
expected: `{{ index . "service.name" }}`,
},
{
name: "other variables with multiple dots nested",
input: "$http.status.code",
expected: `{{ index . "http.status.code" }}`,
},
// Hybrid: all types combined
{
name: "hybrid - all variables types",
input: "Alert: $alert_name Labels: $labels.severity Annotations: $annotations.desc Service: $service.name Count: $error_count",
expected: `Alert: {{ .alert_name }} Labels: {{ index .labels "severity" }} Annotations: {{ index .annotations "desc" }} Service: {{ index . "service.name" }} Count: {{ .error_count }}`,
},
{
name: "already wrapped should not be changed",
input: "{{$status := .status}}{{.name}} is {{$status | toUpper}}",
expected: "{{$status := .status}}{{.name}} is {{$status | toUpper}}",
},
{
name: "no variables should not be changed",
input: "Hello world",
expected: "Hello world",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "deeply nested",
input: "{{range .items}}{{if .ok}}$deep{{end}}{{end}}",
expected: "{{range .items}}{{if .ok}}{{ .deep }}{{end}}{{end}}",
},
{
name: "complex example",
input: `Hello $name, your score is $score.
{{if .isAdmin}}
Welcome back $name, you have {{.unreadCount}} messages.
{{end}}`,
expected: `Hello {{ .name }}, your score is {{ .score }}.
{{if .isAdmin}}
Welcome back {{ .name }}, you have {{.unreadCount}} messages.
{{end}}`,
},
{
name: "with custom function",
input: "$name triggered at {{urlescape .url}}",
expected: "{{ .name }} triggered at {{urlescape .url}}",
},
{
name: "invalid template",
input: "{{invalid",
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := WrapDollarVariables(tc.input)
if tc.expectError {
require.Error(t, err, "should error on invalid template syntax")
} else {
require.NoError(t, err)
require.Equal(t, tc.expected, result)
}
})
}
}
func TestExtractUsedVariables(t *testing.T) {
testCases := []struct {
name string
input string
expected map[string]bool
expectError bool
}{
{
name: "simple usage in text",
input: "$name is $status",
expected: map[string]bool{"name": true, "status": true},
},
{
name: "declared in action block",
input: "{{ $name := .name }}",
expected: map[string]bool{"name": true},
},
{
name: "range loop vars",
input: "{{ range $i, $v := .items }}{{ end }}",
expected: map[string]bool{"i": true, "v": true},
},
{
name: "mixed text and action",
input: "$x and {{ $y }}",
expected: map[string]bool{"x": true, "y": true},
},
{
name: "dotted path in text extracts base only",
input: "$labels.severity",
expected: map[string]bool{"labels": true},
},
{
name: "nested if else",
input: "{{ if .ok }}{{ $a }}{{ else }}{{ $b }}{{ end }}",
expected: map[string]bool{"a": true, "b": true},
},
{
name: "empty string",
input: "",
expected: map[string]bool{},
},
{
name: "no variables",
input: "Hello world",
expected: map[string]bool{},
},
{
name: "invalid template returns error",
input: "{{invalid",
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := ExtractUsedVariables(tc.input)
if tc.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.expected, result)
}
})
}
}
func TestAggregateKV(t *testing.T) {
extractLabels := func(a *types.Alert) model.LabelSet { return a.Labels }
testCases := []struct {
name string
alerts []*types.Alert
extractFn func(*types.Alert) model.LabelSet
expected template.KV
}{
{
name: "empty alerts slice",
alerts: []*types.Alert{},
extractFn: extractLabels,
expected: template.KV{},
},
{
name: "single alert",
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{
"env": "production",
"service": "backend",
},
},
},
},
extractFn: extractLabels,
expected: template.KV{
"env": "production",
"service": "backend",
},
},
{
name: "varying values with duplicates deduped",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"env": "production", "service": "backend"}}},
{Alert: model.Alert{Labels: model.LabelSet{"env": "production", "service": "api"}}},
{Alert: model.Alert{Labels: model.LabelSet{"env": "production", "service": "frontend"}}},
{Alert: model.Alert{Labels: model.LabelSet{"env": "production", "service": "api"}}},
},
extractFn: extractLabels,
expected: template.KV{
"env": "production",
"service": "backend, api, frontend",
},
},
{
name: "more than 5 unique values truncates to 5",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc1"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc2"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc3"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc4"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc5"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc6"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc7"}}},
},
extractFn: extractLabels,
expected: template.KV{
"service": "svc1, svc2, svc3, svc4, svc5",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := aggregateKV(tc.alerts, tc.extractFn)
require.Equal(t, tc.expected, result)
})
}
}
func TestExtractCommonKV(t *testing.T) {
extractLabels := func(a *types.Alert) model.LabelSet { return a.Labels }
extractAnnotations := func(a *types.Alert) model.LabelSet { return a.Annotations }
testCases := []struct {
name string
alerts []*types.Alert
extractFn func(*types.Alert) model.LabelSet
expected template.KV
}{
{
name: "empty alerts slice",
alerts: []*types.Alert{},
extractFn: extractLabels,
expected: template.KV{},
},
{
name: "single alert returns all labels",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "service": "api"}}},
},
extractFn: extractLabels,
expected: template.KV{"env": "prod", "service": "api"},
},
{
name: "multiple alerts with fully common labels",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "region": "us-east"}}},
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "region": "us-east"}}},
},
extractFn: extractLabels,
expected: template.KV{"env": "prod", "region": "us-east"},
},
{
name: "multiple alerts with partially common labels",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "service": "api"}}},
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "service": "worker"}}},
},
extractFn: extractLabels,
expected: template.KV{"env": "prod"},
},
{
name: "multiple alerts with no common labels",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"service": "api"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "worker"}}},
},
extractFn: extractLabels,
expected: template.KV{},
},
{
name: "annotations extract common annotations",
alerts: []*types.Alert{
{Alert: model.Alert{Annotations: model.LabelSet{"summary": "high cpu", "runbook": "http://x"}}},
{Alert: model.Alert{Annotations: model.LabelSet{"summary": "high cpu", "runbook": "http://y"}}},
},
extractFn: extractAnnotations,
expected: template.KV{"summary": "high cpu"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := extractCommonKV(tc.alerts, tc.extractFn)
require.Equal(t, tc.expected, result)
})
}
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/gorilla/mux"
)
@@ -15,7 +16,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
Tags: []string{"role"},
Summary: "Create role",
Description: "This endpoint creates a role",
Request: new(authtypes.PostableRole),
Request: new(roletypes.PostableRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
@@ -34,7 +35,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
Description: "This endpoint lists all roles",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
Response: make([]*roletypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -51,7 +52,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
Description: "This endpoint gets a role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.Role),
Response: new(roletypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -83,7 +84,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
Tags: []string{"role"},
Summary: "Patch role",
Description: "This endpoint patches a role",
Request: new(authtypes.PatchableRole),
Request: new(roletypes.PatchableRole),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",

View File

@@ -6,6 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
@@ -29,10 +30,10 @@ type AuthZ interface {
ListObjects(context.Context, string, authtypes.Relation, authtypes.Typeable) ([]*authtypes.Object, error)
// Creates the role.
Create(context.Context, valuer.UUID, *authtypes.Role) error
Create(context.Context, valuer.UUID, *roletypes.Role) error
// Gets the role if it exists or creates one.
GetOrCreate(context.Context, valuer.UUID, *authtypes.Role) (*authtypes.Role, error)
GetOrCreate(context.Context, valuer.UUID, *roletypes.Role) (*roletypes.Role, error)
// Gets the objects associated with the given role and relation.
GetObjects(context.Context, valuer.UUID, valuer.UUID, authtypes.Relation) ([]*authtypes.Object, error)
@@ -41,7 +42,7 @@ type AuthZ interface {
GetResources(context.Context) []*authtypes.Resource
// Patches the role.
Patch(context.Context, valuer.UUID, *authtypes.Role) error
Patch(context.Context, valuer.UUID, *roletypes.Role) error
// Patches the objects in authorization server associated with the given role and relation
PatchObjects(context.Context, valuer.UUID, string, authtypes.Relation, []*authtypes.Object, []*authtypes.Object) error
@@ -50,19 +51,19 @@ type AuthZ interface {
Delete(context.Context, valuer.UUID, valuer.UUID) error
// Gets the role
Get(context.Context, valuer.UUID, valuer.UUID) (*authtypes.Role, error)
Get(context.Context, valuer.UUID, valuer.UUID) (*roletypes.Role, error)
// Gets the role by org_id and name
GetByOrgIDAndName(context.Context, valuer.UUID, string) (*authtypes.Role, error)
GetByOrgIDAndName(context.Context, valuer.UUID, string) (*roletypes.Role, error)
// Lists all the roles for the organization.
List(context.Context, valuer.UUID) ([]*authtypes.Role, error)
List(context.Context, valuer.UUID) ([]*roletypes.Role, error)
// Lists all the roles for the organization filtered by name
ListByOrgIDAndNames(context.Context, valuer.UUID, []string) ([]*authtypes.Role, error)
ListByOrgIDAndNames(context.Context, valuer.UUID, []string) ([]*roletypes.Role, error)
// Lists all the roles for the organization filtered by ids
ListByOrgIDAndIDs(context.Context, valuer.UUID, []valuer.UUID) ([]*authtypes.Role, error)
ListByOrgIDAndIDs(context.Context, valuer.UUID, []valuer.UUID) ([]*roletypes.Role, error)
// Grants a role to the subject based on role name.
Grant(context.Context, valuer.UUID, []string, string) error
@@ -74,7 +75,7 @@ type AuthZ interface {
ModifyGrant(context.Context, valuer.UUID, []string, []string, string) error
// Bootstrap the managed roles.
CreateManagedRoles(context.Context, valuer.UUID, []*authtypes.Role) error
CreateManagedRoles(context.Context, valuer.UUID, []*roletypes.Role) error
// Bootstrap managed roles transactions and user assignments
CreateManagedUserRoleTransactions(context.Context, valuer.UUID, valuer.UUID) error

View File

@@ -5,7 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
@@ -14,11 +14,11 @@ type store struct {
sqlstore sqlstore.SQLStore
}
func NewSqlAuthzStore(sqlstore sqlstore.SQLStore) authtypes.RoleStore {
func NewSqlAuthzStore(sqlstore sqlstore.SQLStore) roletypes.Store {
return &store{sqlstore: sqlstore}
}
func (store *store) Create(ctx context.Context, role *authtypes.StorableRole) error {
func (store *store) Create(ctx context.Context, role *roletypes.StorableRole) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
@@ -32,8 +32,8 @@ func (store *store) Create(ctx context.Context, role *authtypes.StorableRole) er
return nil
}
func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.StorableRole, error) {
role := new(authtypes.StorableRole)
func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*roletypes.StorableRole, error) {
role := new(roletypes.StorableRole)
err := store.
sqlstore.
BunDBCtx(ctx).
@@ -43,14 +43,14 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
Where("id = ?", id).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, authtypes.ErrCodeRoleNotFound, "role with id: %s doesn't exist", id)
return nil, store.sqlstore.WrapNotFoundErrf(err, roletypes.ErrCodeRoleNotFound, "role with id: %s doesn't exist", id)
}
return role, nil
}
func (store *store) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.StorableRole, error) {
role := new(authtypes.StorableRole)
func (store *store) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*roletypes.StorableRole, error) {
role := new(roletypes.StorableRole)
err := store.
sqlstore.
BunDBCtx(ctx).
@@ -60,14 +60,14 @@ func (store *store) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, na
Where("name = ?", name).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, authtypes.ErrCodeRoleNotFound, "role with name: %s doesn't exist", name)
return nil, store.sqlstore.WrapNotFoundErrf(err, roletypes.ErrCodeRoleNotFound, "role with name: %s doesn't exist", name)
}
return role, nil
}
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*authtypes.StorableRole, error) {
roles := make([]*authtypes.StorableRole, 0)
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*roletypes.StorableRole, error) {
roles := make([]*roletypes.StorableRole, 0)
err := store.
sqlstore.
BunDBCtx(ctx).
@@ -82,8 +82,8 @@ func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*authtypes.S
return roles, nil
}
func (store *store) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*authtypes.StorableRole, error) {
roles := make([]*authtypes.StorableRole, 0)
func (store *store) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*roletypes.StorableRole, error) {
roles := make([]*roletypes.StorableRole, 0)
err := store.
sqlstore.
BunDBCtx(ctx).
@@ -99,7 +99,7 @@ func (store *store) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID,
if len(roles) != len(names) {
return nil, store.sqlstore.WrapNotFoundErrf(
nil,
authtypes.ErrCodeRoleNotFound,
roletypes.ErrCodeRoleNotFound,
"not all roles found for the provided names: %v", names,
)
}
@@ -107,8 +107,8 @@ func (store *store) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID,
return roles, nil
}
func (store *store) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, ids []valuer.UUID) ([]*authtypes.StorableRole, error) {
roles := make([]*authtypes.StorableRole, 0)
func (store *store) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, ids []valuer.UUID) ([]*roletypes.StorableRole, error) {
roles := make([]*roletypes.StorableRole, 0)
err := store.
sqlstore.
BunDBCtx(ctx).
@@ -124,7 +124,7 @@ func (store *store) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, id
if len(roles) != len(ids) {
return nil, store.sqlstore.WrapNotFoundErrf(
nil,
authtypes.ErrCodeRoleNotFound,
roletypes.ErrCodeRoleNotFound,
"not all roles found for the provided ids: %v", ids,
)
}
@@ -132,7 +132,7 @@ func (store *store) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, id
return roles, nil
}
func (store *store) Update(ctx context.Context, orgID valuer.UUID, role *authtypes.StorableRole) error {
func (store *store) Update(ctx context.Context, orgID valuer.UUID, role *roletypes.StorableRole) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
@@ -153,12 +153,12 @@ func (store *store) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUI
sqlstore.
BunDBCtx(ctx).
NewDelete().
Model(new(authtypes.StorableRole)).
Model(new(roletypes.StorableRole)).
Where("org_id = ?", orgID).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, authtypes.ErrCodeRoleNotFound, "role with id %s doesn't exist", id)
return store.sqlstore.WrapNotFoundErrf(err, roletypes.ErrCodeRoleNotFound, "role with id %s doesn't exist", id)
}
return nil

View File

@@ -8,6 +8,7 @@ import (
"github.com/SigNoz/signoz/pkg/authz/openfgaserver"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/factory"
@@ -18,7 +19,7 @@ import (
type provider struct {
server *openfgaserver.Server
store authtypes.RoleStore
store roletypes.Store
}
func NewProviderFactory(sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile) factory.ProviderFactory[authz.AuthZ, authz.Config] {
@@ -67,61 +68,61 @@ func (provider *provider) ListObjects(ctx context.Context, subject string, relat
return provider.server.ListObjects(ctx, subject, relation, typeable)
}
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*roletypes.Role, error) {
storableRole, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
return authtypes.NewRoleFromStorableRole(storableRole), nil
return roletypes.NewRoleFromStorableRole(storableRole), nil
}
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*roletypes.Role, error) {
storableRole, err := provider.store.GetByOrgIDAndName(ctx, orgID, name)
if err != nil {
return nil, err
}
return authtypes.NewRoleFromStorableRole(storableRole), nil
return roletypes.NewRoleFromStorableRole(storableRole), nil
}
func (provider *provider) List(ctx context.Context, orgID valuer.UUID) ([]*authtypes.Role, error) {
func (provider *provider) List(ctx context.Context, orgID valuer.UUID) ([]*roletypes.Role, error) {
storableRoles, err := provider.store.List(ctx, orgID)
if err != nil {
return nil, err
}
roles := make([]*authtypes.Role, len(storableRoles))
roles := make([]*roletypes.Role, len(storableRoles))
for idx, storableRole := range storableRoles {
roles[idx] = authtypes.NewRoleFromStorableRole(storableRole)
roles[idx] = roletypes.NewRoleFromStorableRole(storableRole)
}
return roles, nil
}
func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*authtypes.Role, error) {
func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*roletypes.Role, error) {
storableRoles, err := provider.store.ListByOrgIDAndNames(ctx, orgID, names)
if err != nil {
return nil, err
}
roles := make([]*authtypes.Role, len(storableRoles))
roles := make([]*roletypes.Role, len(storableRoles))
for idx, storable := range storableRoles {
roles[idx] = authtypes.NewRoleFromStorableRole(storable)
roles[idx] = roletypes.NewRoleFromStorableRole(storable)
}
return roles, nil
}
func (provider *provider) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, ids []valuer.UUID) ([]*authtypes.Role, error) {
func (provider *provider) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, ids []valuer.UUID) ([]*roletypes.Role, error) {
storableRoles, err := provider.store.ListByOrgIDAndIDs(ctx, orgID, ids)
if err != nil {
return nil, err
}
roles := make([]*authtypes.Role, len(storableRoles))
roles := make([]*roletypes.Role, len(storableRoles))
for idx, storable := range storableRoles {
roles[idx] = authtypes.NewRoleFromStorableRole(storable)
roles[idx] = roletypes.NewRoleFromStorableRole(storable)
}
return roles, nil
@@ -178,10 +179,10 @@ func (provider *provider) Revoke(ctx context.Context, orgID valuer.UUID, names [
return provider.Write(ctx, nil, tuples)
}
func (provider *provider) CreateManagedRoles(ctx context.Context, _ valuer.UUID, managedRoles []*authtypes.Role) error {
func (provider *provider) CreateManagedRoles(ctx context.Context, _ valuer.UUID, managedRoles []*roletypes.Role) error {
err := provider.store.RunInTx(ctx, func(ctx context.Context) error {
for _, role := range managedRoles {
err := provider.store.Create(ctx, authtypes.NewStorableRoleFromRole(role))
err := provider.store.Create(ctx, roletypes.NewStorableRoleFromRole(role))
if err != nil {
return err
}
@@ -198,15 +199,15 @@ func (provider *provider) CreateManagedRoles(ctx context.Context, _ valuer.UUID,
}
func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
return provider.Grant(ctx, orgID, []string{authtypes.SigNozAdminRoleName}, authtypes.MustNewSubject(authtypes.TypeableUser, userID.String(), orgID, nil))
return provider.Grant(ctx, orgID, []string{roletypes.SigNozAdminRoleName}, authtypes.MustNewSubject(authtypes.TypeableUser, userID.String(), orgID, nil))
}
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *roletypes.Role) error {
return errors.Newf(errors.TypeUnsupported, roletypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) GetOrCreate(_ context.Context, _ valuer.UUID, _ *authtypes.Role) (*authtypes.Role, error) {
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
func (provider *provider) GetOrCreate(_ context.Context, _ valuer.UUID, _ *roletypes.Role) (*roletypes.Role, error) {
return nil, errors.Newf(errors.TypeUnsupported, roletypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) GetResources(_ context.Context) []*authtypes.Resource {
@@ -214,19 +215,19 @@ func (provider *provider) GetResources(_ context.Context) []*authtypes.Resource
}
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*authtypes.Object, error) {
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
return nil, errors.Newf(errors.TypeUnsupported, roletypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) Patch(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
func (provider *provider) Patch(_ context.Context, _ valuer.UUID, _ *roletypes.Role) error {
return errors.Newf(errors.TypeUnsupported, roletypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) PatchObjects(_ context.Context, _ valuer.UUID, _ string, _ authtypes.Relation, _, _ []*authtypes.Object) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
return errors.Newf(errors.TypeUnsupported, roletypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) Delete(_ context.Context, _ valuer.UUID, _ valuer.UUID) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
return errors.Newf(errors.TypeUnsupported, roletypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) MustGetTypeables() []authtypes.Typeable {

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -29,13 +30,13 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
return
}
req := new(authtypes.PostableRole)
req := new(roletypes.PostableRole)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
role := authtypes.NewRole(req.Name, req.Description, authtypes.RoleTypeCustom, valuer.MustNewUUID(claims.OrgID))
role := roletypes.NewRole(req.Name, req.Description, roletypes.RoleTypeCustom, valuer.MustNewUUID(claims.OrgID))
err = handler.authz.Create(ctx, valuer.MustNewUUID(claims.OrgID), role)
if err != nil {
render.Error(rw, err)
@@ -55,7 +56,7 @@ func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
id, ok := mux.Vars(r)["id"]
if !ok {
render.Error(rw, errors.New(errors.TypeInvalidInput, authtypes.ErrCodeRoleInvalidInput, "id is missing from the request"))
render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "id is missing from the request"))
return
}
roleID, err := valuer.NewUUID(id)
@@ -83,7 +84,7 @@ func (handler *handler) GetObjects(rw http.ResponseWriter, r *http.Request) {
id, ok := mux.Vars(r)["id"]
if !ok {
render.Error(rw, errors.New(errors.TypeInvalidInput, authtypes.ErrCodeRoleInvalidInput, "id is missing from the request"))
render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "id is missing from the request"))
return
}
roleID, err := valuer.NewUUID(id)
@@ -94,7 +95,7 @@ func (handler *handler) GetObjects(rw http.ResponseWriter, r *http.Request) {
relationStr, ok := mux.Vars(r)["relation"]
if !ok {
render.Error(rw, errors.New(errors.TypeInvalidInput, authtypes.ErrCodeRoleInvalidInput, "relation is missing from the request"))
render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "relation is missing from the request"))
return
}
relation, err := authtypes.NewRelation(relationStr)
@@ -149,7 +150,7 @@ func (handler *handler) Patch(rw http.ResponseWriter, r *http.Request) {
return
}
req := new(authtypes.PatchableRole)
req := new(roletypes.PatchableRole)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return

View File

@@ -147,7 +147,7 @@ func Ast(cause error, typ typ) bool {
return t == typ
}
// Ast checks if the provided error matches the specified custom error code.
// Asc checks if the provided error matches the specified custom error code.
func Asc(cause error, code Code) bool {
_, c, _, _, _, _ := Unwrapb(cause)

View File

@@ -10,6 +10,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -55,9 +56,9 @@ func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozEditorRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozViewerRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAdminRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozEditorRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozViewerRoleName),
}
err = middleware.authzService.CheckWithTupleCreation(
@@ -107,8 +108,8 @@ func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozEditorRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAdminRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozEditorRoleName),
}
err = middleware.authzService.CheckWithTupleCreation(
@@ -158,7 +159,7 @@ func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAdminRoleName),
}
err = middleware.authzService.CheckWithTupleCreation(

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