Compare commits

..

2 Commits

Author SHA1 Message Date
vikrantgupta25
49e9657cae feat(resource): add related resources 2026-06-08 00:17:06 +05:30
vikrantgupta25
d296ce0f3f feat(resource): initial commit 2026-06-06 17:28:50 +05:30
32 changed files with 1279 additions and 2584 deletions

View File

@@ -185,6 +185,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)

View File

@@ -93,7 +93,6 @@ function ValueGraph({
<div
ref={containerRef}
className="value-graph-container"
data-testid="value-graph-container"
style={{
backgroundColor:
threshold.thresholdFormat === 'Background'

View File

@@ -159,8 +159,6 @@ function GridTableComponent({
if (threshold && idx !== -1) {
return (
<div
data-testid="threshold-styled-cell"
data-threshold-format={threshold.thresholdFormat}
style={
threshold.thresholdFormat === 'Background'
? { backgroundColor: threshold.thresholdColor }

View File

@@ -80,23 +80,18 @@ export function ColumnUnitSelector(
{aggregationQueries.map(({ value, label }) => {
const baseQueryName = value.split('.')[0];
return (
<div
<YAxisUnitSelectorV2
value={columnUnits[value] || ''}
onSelect={(unitValue: string): void =>
handleColumnUnitSelect(value, unitValue)
}
fieldLabel={label}
key={value}
className="column-unit-row"
data-testid={`column-unit-row-${baseQueryName}`}
>
<YAxisUnitSelectorV2
value={columnUnits[value] || ''}
onSelect={(unitValue: string): void =>
handleColumnUnitSelect(value, unitValue)
}
fieldLabel={label}
data-testid={props['data-testid']}
selectedQueryName={baseQueryName}
// Update the column unit value automatically only in create mode
shouldUpdateYAxisUnit={isNewDashboard}
/>
</div>
data-testid={props['data-testid']}
selectedQueryName={baseQueryName}
// Update the column unit value automatically only in create mode
shouldUpdateYAxisUnit={isNewDashboard}
/>
);
})}
</div>

View File

@@ -78,10 +78,7 @@ export default function VisualizationSettingsSection({
>
{graphTypes.map((item) => (
<Option key={item.name} value={item.name}>
<div
className="select-option"
data-testid={`panel-type-option-${item.name}`}
>
<div className="select-option">
<div className="icon">{item.icon}</div>
<Typography.Text className="display">{item.display}</Typography.Text>
</div>

View File

@@ -231,14 +231,12 @@ function Threshold({
type="text"
icon={<Pencil size={14} />}
className="edit-btn"
data-testid="threshold-edit-btn"
onClick={editHandler}
/>
<Button
type="text"
icon={<Trash2 size={14} />}
className="delete-btn"
data-testid="threshold-delete-btn"
onClick={deleteHandler}
/>
</div>

View File

@@ -55,6 +55,11 @@ func (handler *healthOpenAPIHandler) AuditDef() *pkghandler.AuditDef {
return nil
}
func (handler *healthOpenAPIHandler) ResourceDefs() []pkghandler.ResourceSpec {
// Health endpoints don't act on resources.
return nil
}
func (provider *provider) addRegistryRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/healthz", newHealthOpenAPIHandler(
provider.authzMiddleware.OpenAccess(provider.factoryHandler.Healthz),

View File

@@ -7,166 +7,190 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (provider *provider) addRoleRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/roles", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Create, authtypes.Relation{Verb: coretypes.VerbCreate}, coretypes.ResourceRole, roleCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "CreateRole",
Tags: []string{"role"},
Summary: "Create role",
Description: "This endpoint creates a role",
Request: new(authtypes.PostableRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbCreate)}),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Create, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateRole",
Tags: []string{"role"},
Summary: "Create role",
Description: "This endpoint creates a role",
Request: new(authtypes.PostableRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.ResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbCreate,
ID: handler.ResponseJSONPath("data.id"),
Selector: handler.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles", handler.New(provider.authzMiddleware.Check(provider.authzHandler.List, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceRole, roleCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "ListRoles",
Tags: []string{"role"},
Summary: "List roles",
Description: "This endpoint lists all roles",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbList)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.List, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListRoles",
Tags: []string{"role"},
Summary: "List roles",
Description: "This endpoint lists all roles",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.ResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbList,
Selector: handler.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Get, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "GetRole",
Tags: []string{"role"},
Summary: "Get role",
Description: "This endpoint gets a role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Get, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetRole",
Tags: []string{"role"},
Summary: "Get role",
Description: "This endpoint gets a role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.ResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbRead,
ID: handler.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(provider.authzMiddleware.Check(provider.authzHandler.GetObjects, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "GetObjects",
Tags: []string{"role"},
Summary: "Get objects for a role by relation",
Description: "Gets all objects connected to the specified role via a given relation type",
Request: nil,
RequestContentType: "",
Response: make([]*coretypes.ObjectGroup, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.GetObjects, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetObjects",
Tags: []string{"role"},
Summary: "Get objects for a role by relation",
Description: "Gets all objects connected to the specified role via a given relation type",
Request: nil,
RequestContentType: "",
Response: make([]*coretypes.ObjectGroup, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.ResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbRead,
ID: handler.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Patch, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "PatchRole",
Tags: []string{"role"},
Summary: "Patch role",
Description: "This endpoint patches a role",
Request: new(authtypes.PatchableRole),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPatch).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Patch, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "PatchRole",
Tags: []string{"role"},
Summary: "Patch role",
Description: "This endpoint patches a role",
Request: new(authtypes.PatchableRole),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.ResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbUpdate,
ID: handler.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(provider.authzMiddleware.Check(provider.authzHandler.PatchObjects, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "PatchObjects",
Tags: []string{"role"},
Summary: "Patch objects for a role by relation",
Description: "Patches the objects connected to the specified role via a given relation type",
Request: new(coretypes.PatchableObjects),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPatch).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.PatchObjects, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "PatchObjects",
Tags: []string{"role"},
Summary: "Patch objects for a role by relation",
Description: "Patches the objects connected to the specified role via a given relation type",
Request: new(coretypes.PatchableObjects),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.ResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbUpdate,
ID: handler.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Delete, authtypes.Relation{Verb: coretypes.VerbDelete}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "DeleteRole",
Tags: []string{"role"},
Summary: "Delete role",
Description: "This endpoint deletes a role",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Delete, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteRole",
Tags: []string{"role"},
Summary: "Delete role",
Description: "This endpoint deletes a role",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.ResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbDelete,
ID: handler.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
func roleCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func (provider *provider) roleInstanceSelectorCallback(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
return nil, err
}
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), roleID)
if err != nil {
return nil, err
}
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(role.Name),
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}

View File

@@ -1,13 +1,10 @@
package signozapiserver
import (
"bytes"
"encoding/json"
"io"
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -17,41 +14,54 @@ import (
)
func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Create, authtypes.Relation{Verb: coretypes.VerbCreate}, coretypes.ResourceServiceAccount, serviceAccountCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "CreateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Create service account",
Description: "This endpoint creates a service account",
Request: new(serviceaccounttypes.PostableServiceAccount),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Create, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Create service account",
Description: "This endpoint creates a service account",
Request: new(serviceaccounttypes.PostableServiceAccount),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.ResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbCreate,
ID: handler.ResponseJSONPath("data.id"),
Selector: handler.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.List, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceServiceAccount, serviceAccountCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "ListServiceAccounts",
Tags: []string{"serviceaccount"},
Summary: "List service accounts",
Description: "This endpoint lists the service accounts for an organisation",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.ServiceAccount, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbList)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.List, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListServiceAccounts",
Tags: []string{"serviceaccount"},
Summary: "List service accounts",
Description: "This endpoint lists the service accounts for an organisation",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.ServiceAccount, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.ResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbList,
Selector: handler.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
@@ -72,89 +82,125 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Get, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "GetServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Gets a service account",
Description: "This endpoint gets an existing service account",
Request: nil,
RequestContentType: "",
Response: new(serviceaccounttypes.ServiceAccountWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Get, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Gets a service account",
Description: "This endpoint gets an existing service account",
Request: nil,
RequestContentType: "",
Response: new(serviceaccounttypes.ServiceAccountWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.ResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbRead,
ID: handler.PathParam("id"),
Selector: handler.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.GetRoles, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "GetServiceAccountRoles",
Tags: []string{"serviceaccount"},
Summary: "Gets service account roles",
Description: "This endpoint gets all the roles for the existing service account",
Request: nil,
RequestContentType: "",
Response: new([]*authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.GetRoles, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetServiceAccountRoles",
Tags: []string{"serviceaccount"},
Summary: "Gets service account roles",
Description: "This endpoint gets all the roles for the existing service account",
Request: nil,
RequestContentType: "",
Response: new([]*authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.ResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbRead,
ID: handler.PathParam("id"),
Selector: handler.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.SetRole, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceRole, SelectorCallback: provider.roleAttachSelectorFromBody, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
ID: "CreateServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
Description: "This endpoint assigns a role to a service account",
Request: new(serviceaccounttypes.PostableServiceAccountRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.SetRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
Description: "This endpoint assigns a role to a service account",
Request: new(serviceaccounttypes.PostableServiceAccountRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(
handler.ResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbAttach,
ID: handler.PathParam("id"),
Selector: handler.IDSelector,
Related: &handler.RelatedResource{Resource: coretypes.ResourceRole, ID: handler.BodyJSONPath("id")},
},
handler.ResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbAttach,
ID: handler.BodyJSONPath("id"),
Selector: provider.roleSelector,
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.DeleteRole, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceRole, SelectorCallback: provider.roleDetachSelectorFromPath, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
ID: "DeleteServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
Description: "This endpoint revokes a role from service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.DeleteRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
Description: "This endpoint revokes a role from service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(
handler.ResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbDetach,
ID: handler.PathParam("id"),
Selector: handler.IDSelector,
Related: &handler.RelatedResource{Resource: coretypes.ResourceRole, ID: handler.PathParam("rid")},
},
handler.ResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbDetach,
ID: handler.PathParam("rid"),
Selector: provider.roleSelector,
},
),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
@@ -175,208 +221,199 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Update, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "UpdateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account",
Description: "This endpoint updates an existing service account",
Request: new(serviceaccounttypes.UpdatableServiceAccount),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Update, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account",
Description: "This endpoint updates an existing service account",
Request: new(serviceaccounttypes.UpdatableServiceAccount),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.ResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbUpdate,
ID: handler.PathParam("id"),
Selector: handler.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Delete, authtypes.Relation{Verb: coretypes.VerbDelete}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "DeleteServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Deletes a service account",
Description: "This endpoint deletes an existing service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDelete)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Delete, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Deletes a service account",
Description: "This endpoint deletes an existing service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.ResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbDelete,
ID: handler.PathParam("id"),
Selector: handler.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.CreateFactorAPIKey, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbCreate}, Resource: coretypes.ResourceMetaResourceFactorAPIKey, SelectorCallback: factorAPIKeyCollectionSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
ID: "CreateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Create a service account key",
Description: "This endpoint creates a service account key",
Request: new(serviceaccounttypes.PostableFactorAPIKey),
RequestContentType: "",
Response: new(serviceaccounttypes.GettableFactorAPIKeyWithKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbCreate), coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach)}),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.CreateFactorAPIKey, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Create a service account key",
Description: "This endpoint creates a service account key",
Request: new(serviceaccounttypes.PostableFactorAPIKey),
RequestContentType: "",
Response: new(serviceaccounttypes.GettableFactorAPIKeyWithKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbCreate), coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(
handler.ResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbCreate,
ID: handler.ResponseJSONPath("data.id"),
Selector: handler.WildcardSelector,
},
handler.ResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbAttach,
ID: handler.PathParam("id"),
Selector: handler.IDSelector,
Related: &handler.RelatedResource{Resource: coretypes.ResourceMetaResourceFactorAPIKey, ID: handler.ResponseJSONPath("data.id")},
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceMetaResourceFactorAPIKey, factorAPIKeyCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "ListServiceAccountKeys",
Tags: []string{"serviceaccount"},
Summary: "List service account keys",
Description: "This endpoint lists the service account keys",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.GettableFactorAPIKey, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbList)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListServiceAccountKeys",
Tags: []string{"serviceaccount"},
Summary: "List service account keys",
Description: "This endpoint lists the service account keys",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.GettableFactorAPIKey, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.ResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbList,
Selector: handler.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceMetaResourceFactorAPIKey, factorAPIKeyInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "UpdateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account key",
Description: "This endpoint updates an existing service account key",
Request: new(serviceaccounttypes.UpdatableFactorAPIKey),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account key",
Description: "This endpoint updates an existing service account key",
Request: new(serviceaccounttypes.UpdatableFactorAPIKey),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.ResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbUpdate,
ID: handler.PathParam("fid"),
Selector: handler.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.RevokeFactorAPIKey, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbDelete}, Resource: coretypes.ResourceMetaResourceFactorAPIKey, SelectorCallback: factorAPIKeyInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
ID: "RevokeServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Revoke a service account key",
Description: "This endpoint revokes an existing service account key",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbDelete), coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.RevokeFactorAPIKey, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "RevokeServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Revoke a service account key",
Description: "This endpoint revokes an existing service account key",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbDelete), coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(
handler.ResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbDelete,
ID: handler.PathParam("fid"),
Selector: handler.IDSelector,
},
handler.ResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbDetach,
ID: handler.PathParam("id"),
Selector: handler.IDSelector,
Related: &handler.RelatedResource{Resource: coretypes.ResourceMetaResourceFactorAPIKey, ID: handler.PathParam("fid")},
},
),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
func (provider *provider) roleDetachSelectorFromPath(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(mux.Vars(req)["rid"])
// roleSelector resolves the FGA selectors for a role from its UUID. The id is
// already extracted by the ResourceDef (path or body); this only does the
// UUID -> name lookup the FGA object string requires. Shared by service account
// and role routes.
func (provider *provider) roleSelector(ctx context.Context, resource coretypes.Resource, id string, claims authtypes.Claims) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(id)
if err != nil {
return nil, err
}
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), roleID)
role, err := provider.authzService.Get(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
if err != nil {
return nil, err
}
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(role.Name),
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func (provider *provider) roleAttachSelectorFromBody(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
req.Body = io.NopCloser(bytes.NewReader(body))
postableRole := new(serviceaccounttypes.PostableServiceAccountRole)
if err := json.Unmarshal(body, postableRole); err != nil {
return nil, err
}
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), postableRole.ID)
if err != nil {
return nil, err
}
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(role.Name),
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func factorAPIKeyCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{
coretypes.TypeMetaResource.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func factorAPIKeyInstanceSelectorCallback(req *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
fid := mux.Vars(req)["fid"]
fidSelector, err := coretypes.TypeMetaResource.Selector(fid)
if err != nil {
return nil, err
}
return []coretypes.Selector{
fidSelector,
coretypes.TypeMetaResource.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func serviceAccountCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{
coretypes.TypeServiceAccount.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func serviceAccountInstanceSelectorCallback(req *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
id := mux.Vars(req)["id"]
idSelector, err := coretypes.TypeServiceAccount.Selector(id)
if err != nil {
return nil, err
}
return []coretypes.Selector{
idSelector,
coretypes.TypeServiceAccount.MustSelector(coretypes.WildCardSelectorString),
resource.Type().MustSelector(role.Name),
resource.Type().MustSelector(coretypes.WildCardSelectorString),
}, nil
}

View File

@@ -16,12 +16,14 @@ type Handler interface {
http.Handler
ServeOpenAPI(openapi.OperationContext)
AuditDef() *AuditDef
ResourceDefs() []ResourceSpec
}
type handler struct {
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
auditDef *AuditDef
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
auditDef *AuditDef
resourceDefs []ResourceSpec
}
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Handler {
@@ -47,6 +49,14 @@ func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Ha
opt(handler)
}
// Resource defs are a developer-authored, registration-time declaration —
// fail fast and loud on misconfiguration, like coretypes' MustSelector.
for _, def := range handler.resourceDefs {
if err := def.validate(); err != nil {
panic(err)
}
}
return handler
}
@@ -133,3 +143,7 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
func (handler *handler) AuditDef() *AuditDef {
return handler.auditDef
}
func (handler *handler) ResourceDefs() []ResourceSpec {
return handler.resourceDefs
}

View File

@@ -23,3 +23,12 @@ func WithAuditDef(def AuditDef) Option {
h.auditDef = &def
}
}
// WithResourceDefs attaches one or more resource specs (ResourceDef /
// ResourcesDef) to the handler. The resource middleware resolves them and the
// authz + audit middlewares read the result.
func WithResourceDefs(defs ...ResourceSpec) Option {
return func(h *handler) {
h.resourceDefs = append(h.resourceDefs, defs...)
}
}

View File

@@ -0,0 +1,299 @@
package handler
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/gorilla/mux"
"github.com/tidwall/gjson"
)
var errCodeInvalidResourceDef = errors.MustNewCode("invalid_resource_def")
// ExtractorContext carries everything an extractor may read. The resource
// middleware fills Request + RequestBody pre-handler; the audit middleware adds
// ResponseBody post-handler. Each extractor is run exactly once, in the phase
// whose data it needs.
type ExtractorContext struct {
Request *http.Request
RequestBody []byte
ResponseBody []byte
}
// extractPhase marks whether an extractor reads request-side data (resolved
// pre-handler by the resource middleware) or response-side data (resolved
// post-handler by the audit middleware).
type extractPhase int
const (
phaseRequest extractPhase = iota
phaseResponse
)
// ResourceIDExtractor resolves a single resource id. Phase-tagged so the
// resolver runs it exactly once in the right phase. The declaration API exposes
// only the constructors below, so the phase is an internal detail.
type ResourceIDExtractor struct {
phase extractPhase
fn func(ExtractorContext) (string, error)
}
// ResourceIDsExtractor resolves multiple resource ids (fan-out). Always
// request-phase — arrays come from the request body.
type ResourceIDsExtractor struct {
phase extractPhase
fn func(ExtractorContext) ([]string, error)
}
// PathParam reads a gorilla/mux path variable. Request-phase.
func PathParam(name string) ResourceIDExtractor {
return ResourceIDExtractor{phase: phaseRequest, fn: func(ec ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
return mux.Vars(ec.Request)[name], nil
}}
}
// BodyJSONPath reads a gjson path from the request body. Request-phase.
func BodyJSONPath(path string) ResourceIDExtractor {
return ResourceIDExtractor{phase: phaseRequest, fn: func(ec ExtractorContext) (string, error) {
return gjson.GetBytes(ec.RequestBody, path).String(), nil
}}
}
// BodyJSONArray reads a JSON array of strings from the request body. Request-phase.
func BodyJSONArray(path string) ResourceIDsExtractor {
return ResourceIDsExtractor{phase: phaseRequest, fn: func(ec ExtractorContext) ([]string, error) {
result := gjson.GetBytes(ec.RequestBody, path)
if !result.Exists() {
return nil, nil
}
array := result.Array()
ids := make([]string, 0, len(array))
for _, r := range array {
ids = append(ids, r.String())
}
return ids, nil
}}
}
// ResponseJSONPath reads a gjson path from the response body. Response-phase —
// yields "" pre-handler and the real value post-handler.
func ResponseJSONPath(path string) ResourceIDExtractor {
return ResourceIDExtractor{phase: phaseResponse, fn: func(ec ExtractorContext) (string, error) {
return gjson.GetBytes(ec.ResponseBody, path).String(), nil
}}
}
// SelectorFunc maps a resolved id (+ its resource) to authz FGA selectors. It is
// the sole source of selectors — there is no default fallback to wildcard. Given
// a missing id it decides for itself whether to return a wildcard or an error. It
// never reads the request/body; ctx + claims are only for an optional DB lookup
// (e.g. role UUID -> name).
type SelectorFunc func(ctx context.Context, resource coretypes.Resource, id string, claims authtypes.Claims) ([]coretypes.Selector, error)
// WildcardSelector ignores the id and returns the resource's wildcard selector.
// Use for create / list / collection routes.
var WildcardSelector SelectorFunc = func(_ context.Context, resource coretypes.Resource, _ string, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{resource.Type().MustSelector(coretypes.WildCardSelectorString)}, nil
}
// IDSelector returns [exact, wildcard] for a present id and errors when the id is
// missing. Use for instance routes whose id is in the path/body.
var IDSelector SelectorFunc = func(_ context.Context, resource coretypes.Resource, id string, _ authtypes.Claims) ([]coretypes.Selector, error) {
if id == "" {
return nil, errors.Newf(errors.TypeInvalidInput, errCodeInvalidResourceDef, "resource id is required for %s", resource.Kind().String())
}
selector, err := resource.Type().Selector(id)
if err != nil {
return nil, err
}
return []coretypes.Selector{selector, resource.Type().MustSelector(coretypes.WildCardSelectorString)}, nil
}
// ResourceSpec is the sealed interface implemented by ResourceDef and
// ResourcesDef. Only these two satisfy WithResourceDefs.
type ResourceSpec interface {
sealResourceSpec()
validate() error
resolveRequest(ec ExtractorContext) []ResolvedResource
}
// ResourceDef declares one resource an operation acts on. For attach/detach,
// Related names the counterpart for audit clarity only — it is never authz-checked.
type ResourceDef struct {
Resource coretypes.Resource
Verb coretypes.Verb
ID ResourceIDExtractor
Selector SelectorFunc
Related *RelatedResource
}
// ResourcesDef declares many resources of one kind (fan-out). One resolved
// entry is produced per id.
type ResourcesDef struct {
Resource coretypes.Resource
Verb coretypes.Verb
IDs ResourceIDsExtractor
Selector SelectorFunc
Related *RelatedResource
}
// RelatedResource is a counterpart named purely for audit clarity. It carries no
// verb and no selector and is not authz-checked.
type RelatedResource struct {
Resource coretypes.Resource
ID ResourceIDExtractor
}
func (ResourceDef) sealResourceSpec() {}
func (ResourcesDef) sealResourceSpec() {}
func (d ResourceDef) validate() error {
if err := coretypes.ErrIfVerbNotValidForResource(d.Verb, *coretypes.NewResourceRef(d.Resource)); err != nil {
return err
}
if d.Related != nil && d.Verb != coretypes.VerbAttach && d.Verb != coretypes.VerbDetach {
return errors.Newf(errors.TypeInvalidInput, errCodeInvalidResourceDef, "Related is only valid with attach/detach, got %s", d.Verb.StringValue())
}
return nil
}
func (d ResourcesDef) validate() error {
if err := coretypes.ErrIfVerbNotValidForResource(d.Verb, *coretypes.NewResourceRef(d.Resource)); err != nil {
return err
}
if d.Related != nil && d.Verb != coretypes.VerbAttach && d.Verb != coretypes.VerbDetach {
return errors.Newf(errors.TypeInvalidInput, errCodeInvalidResourceDef, "Related is only valid with attach/detach, got %s", d.Verb.StringValue())
}
return nil
}
func (d ResourceDef) resolveRequest(ec ExtractorContext) []ResolvedResource {
resolved := ResolvedResource{Resource: d.Resource, Verb: d.Verb, Selector: d.Selector}
resolved.ID, resolved.responseID = resolveID(d.ID, ec)
resolved.Related = resolveRelated(d.Related, ec)
return []ResolvedResource{resolved}
}
func (d ResourcesDef) resolveRequest(ec ExtractorContext) []ResolvedResource {
var ids []string
if d.IDs.fn != nil {
ids, _ = d.IDs.fn(ec)
}
resolved := make([]ResolvedResource, 0, len(ids))
for _, id := range ids {
resolved = append(resolved, ResolvedResource{
Resource: d.Resource,
Verb: d.Verb,
ID: id,
Selector: d.Selector,
Related: resolveRelated(d.Related, ec),
})
}
return resolved
}
// resolveID runs a request-phase extractor immediately and returns its value;
// for a response-phase extractor it returns ("", extractor) so audit can run it
// later.
func resolveID(extractor ResourceIDExtractor, ec ExtractorContext) (string, ResourceIDExtractor) {
if extractor.fn == nil {
return "", ResourceIDExtractor{}
}
if extractor.phase == phaseResponse {
return "", extractor
}
id, _ := extractor.fn(ec)
return id, ResourceIDExtractor{}
}
func resolveRelated(related *RelatedResource, ec ExtractorContext) *ResolvedRelated {
if related == nil {
return nil
}
resolved := &ResolvedRelated{Resource: related.Resource}
resolved.ID, resolved.responseID = resolveID(related.ID, ec)
return resolved
}
// ResolvedResource is the uniform output of resolution (after fan-out). ID is a
// resolved string: request-phase ids are filled by the resource middleware;
// response-phase ids stay "" until FinalizeResponseIDs runs in the audit middleware.
type ResolvedResource struct {
Resource coretypes.Resource
Verb coretypes.Verb
ID string
Selector SelectorFunc
Related *ResolvedRelated
responseID ResourceIDExtractor
}
// ResolvedRelated is the resolved counterpart for audit context.
type ResolvedRelated struct {
Resource coretypes.Resource
ID string
responseID ResourceIDExtractor
}
// ResolveRequest resolves the request-phase ids for all specs (fan-out included)
// against ec. Called by the resource middleware pre-handler.
func ResolveRequest(defs []ResourceSpec, ec ExtractorContext) []ResolvedResource {
var resolved []ResolvedResource
for _, def := range defs {
resolved = append(resolved, def.resolveRequest(ec)...)
}
return resolved
}
// FinalizeResponseIDs runs the carried response-phase extractors against ec to
// fill the ids that were unknown pre-handler. Called by the audit middleware
// post-handler. Mutates the entries in place.
func FinalizeResponseIDs(resolved []ResolvedResource, ec ExtractorContext) {
for idx := range resolved {
if resolved[idx].responseID.fn != nil {
resolved[idx].ID, _ = resolved[idx].responseID.fn(ec)
}
if resolved[idx].Related != nil && resolved[idx].Related.responseID.fn != nil {
resolved[idx].Related.ID, _ = resolved[idx].Related.responseID.fn(ec)
}
}
}
// HasResponseIDs reports whether any resolved entry needs the response body to
// finalize its id. The audit middleware uses this to decide whether to capture
// the success response body.
func HasResponseIDs(resolved []ResolvedResource) bool {
for idx := range resolved {
if resolved[idx].responseID.fn != nil {
return true
}
if resolved[idx].Related != nil && resolved[idx].Related.responseID.fn != nil {
return true
}
}
return false
}

View File

@@ -0,0 +1,100 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResourceDefValidate(t *testing.T) {
// Related on a lifecycle verb is rejected.
err := ResourceDef{Resource: coretypes.ResourceServiceAccount, Verb: coretypes.VerbCreate, Related: &RelatedResource{Resource: coretypes.ResourceRole}}.validate()
require.Error(t, err)
// Attach is valid for service account and may carry Related.
err = ResourceDef{Resource: coretypes.ResourceServiceAccount, Verb: coretypes.VerbAttach, Related: &RelatedResource{Resource: coretypes.ResourceRole}}.validate()
require.NoError(t, err)
// Verb not valid for resource: factor-api-key disallows attach.
err = ResourceDef{Resource: coretypes.ResourceMetaResourceFactorAPIKey, Verb: coretypes.VerbAttach}.validate()
require.Error(t, err)
}
func TestStandardSelectors(t *testing.T) {
ctx := context.Background()
claims := authtypes.Claims{}
wildcard, err := WildcardSelector(ctx, coretypes.ResourceServiceAccount, "ignored", claims)
require.NoError(t, err)
require.Len(t, wildcard, 1)
assert.Equal(t, coretypes.WildCardSelectorString, wildcard[0].String())
// IDSelector errors on a missing id — no silent wildcard fallback.
_, err = IDSelector(ctx, coretypes.ResourceServiceAccount, "", claims)
require.Error(t, err)
id := "0199c47d-f61b-7833-bc5f-c0730f12f046"
selectors, err := IDSelector(ctx, coretypes.ResourceServiceAccount, id, claims)
require.NoError(t, err)
require.Len(t, selectors, 2)
assert.Equal(t, id, selectors[0].String())
assert.Equal(t, coretypes.WildCardSelectorString, selectors[1].String())
}
func TestResolveRequestAndFinalize(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/x", nil)
req = mux.SetURLVars(req, map[string]string{"id": "sa-1"})
body := []byte(`{"id":"role-1","channels":["c1","c2"]}`)
defs := []ResourceSpec{
ResourceDef{
Resource: coretypes.ResourceServiceAccount, Verb: coretypes.VerbAttach,
ID: PathParam("id"), Selector: IDSelector,
Related: &RelatedResource{Resource: coretypes.ResourceRole, ID: BodyJSONPath("id")},
},
ResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey, Verb: coretypes.VerbCreate,
ID: ResponseJSONPath("data.id"), Selector: WildcardSelector,
},
ResourcesDef{
Resource: coretypes.ResourceMetaResourceNotificationChannel, Verb: coretypes.VerbAttach,
IDs: BodyJSONArray("channels"), Selector: IDSelector,
},
}
resolved := ResolveRequest(defs, ExtractorContext{Request: req, RequestBody: body})
// 1 service account + 1 create + 2 channels (fan-out).
require.Len(t, resolved, 4)
assert.Equal(t, "sa-1", resolved[0].ID)
require.NotNil(t, resolved[0].Related)
assert.Equal(t, "role-1", resolved[0].Related.ID)
assert.Equal(t, "", resolved[1].ID, "response-phase id is empty pre-handler")
assert.Equal(t, "c1", resolved[2].ID)
assert.Equal(t, "c2", resolved[3].ID)
assert.True(t, HasResponseIDs(resolved))
// Audit finalizes the response-phase id once the response body is present.
FinalizeResponseIDs(resolved, ExtractorContext{ResponseBody: []byte(`{"data":{"id":"key-9"}}`)})
assert.Equal(t, "key-9", resolved[1].ID)
}
func TestExtractorPhases(t *testing.T) {
assert.Equal(t, phaseRequest, PathParam("id").phase)
assert.Equal(t, phaseRequest, BodyJSONPath("id").phase)
assert.Equal(t, phaseRequest, BodyJSONArray("ids").phase)
assert.Equal(t, phaseResponse, ResponseJSONPath("data.id").phase)
// ResponseJSONPath yields "" when the response body is absent (pre-handler).
id, err := ResponseJSONPath("data.id").fn(ExtractorContext{})
require.NoError(t, err)
assert.Equal(t, "", id)
}

View File

@@ -61,6 +61,13 @@ func (middleware *Audit) Wrap(next http.Handler) http.Handler {
responseBuffer := &byteBuffer{}
writer := newResponseCapture(rw, responseBuffer)
// If any resolved resource derives its id from the response, capture the
// success body (bounded) so the audit event can read it post-handler.
if resolved, ok := ResolvedFromContext(req.Context()); ok && handler.HasResponseIDs(*resolved) {
writer.EnableBodyCapture()
}
next.ServeHTTP(writer, req)
statusCode, writeErr := writer.StatusCode(), writer.WriteError()
@@ -80,7 +87,9 @@ func (middleware *Audit) Wrap(next http.Handler) http.Handler {
fields = append(fields, errors.Attr(writeErr))
middleware.logger.ErrorContext(req.Context(), logMessage, fields...)
} else {
if responseBuffer.Len() != 0 {
// Only log error bodies (status >= 400); a force-captured success
// body is for audit id extraction, not for logging.
if statusCode >= 400 && responseBuffer.Len() != 0 {
fields = append(fields, "response.body", responseBuffer.String())
}
@@ -94,76 +103,44 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
return
}
def := auditDefFromRequest(req)
if def == nil {
return
}
// extract claims
claims, _ := authtypes.ClaimsFromContext(req.Context())
// extract status code
statusCode := writer.StatusCode()
// extract traces.
span := trace.SpanFromContext(req.Context())
// extract error details.
var errorType, errorCode string
if statusCode >= 400 {
errorType = render.ErrorTypeFromStatusCode(statusCode)
errorCode = render.ErrorCodeFromBody(writer.BodyBytes())
}
event := audittypes.NewAuditEventFromHTTPRequest(
req,
routeTemplate,
statusCode,
span.SpanContext().TraceID(),
span.SpanContext().SpanID(),
def.Action,
def.Category,
claims,
resourceIDFromRequest(req, def.ResourceIDParam),
def.ResourceKind,
errorType,
errorCode,
)
// Resources resolved by the Resource middleware — emit one event per entry.
resolved, ok := ResolvedFromContext(req.Context())
if !ok || len(*resolved) == 0 {
return
}
middleware.auditor.Audit(req.Context(), event)
}
func auditDefFromRequest(req *http.Request) *handler.AuditDef {
route := mux.CurrentRoute(req)
if route == nil {
return nil
}
actualHandler := route.GetHandler()
if actualHandler == nil {
return nil
}
// The type assertion is necessary because route.GetHandler() returns
// http.Handler, and not every http.Handler on the mux is a handler.Handler
// (e.g. middleware wrappers, raw http.HandlerFunc registrations).
provider, ok := actualHandler.(handler.Handler)
if !ok {
return nil
}
return provider.AuditDef()
}
func resourceIDFromRequest(req *http.Request, param string) string {
if param == "" {
return ""
}
vars := mux.Vars(req)
if vars == nil {
return ""
}
return vars[param]
handler.FinalizeResponseIDs(*resolved, handler.ExtractorContext{Request: req, ResponseBody: writer.BodyBytes()})
for _, entry := range *resolved {
resourceAttributes := audittypes.NewResourceAttributes(entry.Resource, entry.ID)
if entry.Related != nil {
resourceAttributes = audittypes.NewAttachResourceAttributes(entry.Resource, entry.ID, entry.Related.Resource, entry.Related.ID)
}
event := audittypes.NewAuditEvent(
req,
routeTemplate,
statusCode,
span.SpanContext().TraceID(),
span.SpanContext().SpanID(),
entry.Verb,
audittypes.CategoryFor(entry.Resource),
claims,
resourceAttributes,
errorType,
errorCode,
)
middleware.auditor.Audit(req.Context(), event)
}
}

View File

@@ -285,6 +285,67 @@ func (middleware *AuthZ) CheckAll(next http.HandlerFunc, groups []AuthZCheckGrou
})
}
// CheckResources authorizes every resolved ResourceDef for the route (AND across
// defs). It reads the list placed by the Resource middleware. Each def's Selector
// is the sole source of its FGA selectors; roles are the role names allowed
// (consumed by the OSS role-gate, while the resource selectors drive the EE
// resource check).
func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
resolved, ok := ResolvedFromContext(ctx)
if !ok {
render.Error(rw, errors.New(errors.TypeInternal, errors.CodeInternal, "resource defs not resolved; CheckResources requires WithResourceDefs on the route"))
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
roleSelectors := make([]coretypes.Selector, len(roles))
for idx, role := range roles {
roleSelectors[idx] = coretypes.TypeRole.MustSelector(role)
}
for _, def := range *resolved {
if def.Selector == nil {
render.Error(rw, errors.New(errors.TypeInternal, errors.CodeInternal, "resource def used with CheckResources must declare a Selector"))
return
}
selectors, err := def.Selector(ctx, def.Resource, def.ID, claims)
if err != nil {
render.Error(rw, err)
return
}
err = middleware.authzService.CheckWithTupleCreation(ctx, claims, orgID, authtypes.Relation{Verb: def.Verb}, def.Resource, selectors, roleSelectors)
if err != nil {
if errors.Asc(err, authtypes.ErrCodeAuthZForbidden) {
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
if def.ID != "" {
render.Error(rw, errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "you don't have %s access to %s %s", def.Verb.StringValue(), def.Resource.Kind().String(), def.ID))
return
}
render.Error(rw, errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "you don't have %s access to %s", def.Verb.StringValue(), def.Resource.Kind().String()))
return
}
render.Error(rw, err)
return
}
}
next(rw, req)
})
}
func (middleware *AuthZ) CheckWithoutClaims(next http.HandlerFunc, relation authtypes.Relation, typeable coretypes.Resource, cb selectorCallbackWithoutClaimsFn, roles []string) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()

View File

@@ -0,0 +1,81 @@
package middleware
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/gorilla/mux"
)
// resolvedKey is the context key under which the resolved resource list is stored.
type resolvedKey struct{}
// Resource resolves a route's declared ResourceDefs (request-side) and stashes
// the result in the request context. It is the OUTER of the resource-aware
// middlewares (placed before Audit) and the single point that buffers the
// request body. AuthZ (in the handler) and Audit (inner) read the resolved list.
type Resource struct {
logger *slog.Logger
}
func NewResource(logger *slog.Logger) *Resource {
return &Resource{logger: logger.With(slog.String("pkg", pkgname))}
}
func (middleware *Resource) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
defs := resourceDefsFromRequest(req)
if len(defs) == 0 {
next.ServeHTTP(rw, req)
return
}
// Buffer the request body once so request-side extractors can read it and
// the handler still sees a fresh reader. Single buffering point.
var body []byte
if req.Body != nil {
body, _ = io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewReader(body))
}
resolved := handler.ResolveRequest(defs, handler.ExtractorContext{Request: req, RequestBody: body})
ctx := withResolved(req.Context(), &resolved)
next.ServeHTTP(rw, req.WithContext(ctx))
})
}
func resourceDefsFromRequest(req *http.Request) []handler.ResourceSpec {
route := mux.CurrentRoute(req)
if route == nil {
return nil
}
actualHandler := route.GetHandler()
if actualHandler == nil {
return nil
}
provider, ok := actualHandler.(handler.Handler)
if !ok {
return nil
}
return provider.ResourceDefs()
}
func withResolved(ctx context.Context, resolved *[]handler.ResolvedResource) context.Context {
return context.WithValue(ctx, resolvedKey{}, resolved)
}
// ResolvedFromContext returns the resolved resource list placed by the Resource
// middleware. The pointer lets the audit middleware finalize response-phase ids
// in place.
func ResolvedFromContext(ctx context.Context) (*[]handler.ResolvedResource, bool) {
resolved, ok := ctx.Value(resolvedKey{}).(*[]handler.ResolvedResource)
return resolved, ok
}

View File

@@ -23,9 +23,14 @@ type responseCapture interface {
// WriteError returns the error (if any) from the downstream Write call.
WriteError() error
// BodyBytes returns the captured response body bytes. Only populated
// for error responses (status >= 400).
// BodyBytes returns the captured response body bytes. Populated for error
// responses (status >= 400), or for any response once EnableBodyCapture is called.
BodyBytes() []byte
// EnableBodyCapture forces capture of the response body regardless of status
// code (still bounded by maxResponseBodyCapture). Must be called before the
// handler writes the response.
EnableBodyCapture()
}
func newResponseCapture(rw http.ResponseWriter, buffer *byteBuffer) responseCapture {
@@ -72,12 +77,13 @@ func (b *byteBuffer) String() string {
}
type nonFlushingResponseCapture struct {
rw http.ResponseWriter
buffer *byteBuffer
captureBody bool
bodyBytesLeft int
statusCode int
writeError error
rw http.ResponseWriter
buffer *byteBuffer
captureBody bool
forceCaptureBody bool
bodyBytesLeft int
statusCode int
writeError error
}
type flushingResponseCapture struct {
@@ -98,13 +104,17 @@ func (writer *nonFlushingResponseCapture) Header() http.Header {
// WriteHeader writes the HTTP response header.
func (writer *nonFlushingResponseCapture) WriteHeader(statusCode int) {
writer.statusCode = statusCode
if statusCode >= 400 {
if statusCode >= 400 || writer.forceCaptureBody {
writer.captureBody = true
}
writer.rw.WriteHeader(statusCode)
}
func (writer *nonFlushingResponseCapture) EnableBodyCapture() {
writer.forceCaptureBody = true
}
// Write writes HTTP response data.
func (writer *nonFlushingResponseCapture) Write(data []byte) (int, error) {
if writer.statusCode == 0 {

View File

@@ -168,6 +168,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)

View File

@@ -71,23 +71,65 @@ func (attributes PrincipalAttributes) Put(dest pcommon.Map) {
// Audit attributes — Resource (On What).
// These are OTel resource attributes (placed on the Resource, not event attributes).
type ResourceAttributes struct {
ResourceID string
ResourceKind coretypes.Kind // guaranteed to be present
Resource coretypes.Resource // guaranteed to be present
ResourceID string
// TargetResource names the counterpart of an attach/detach event (audit
// context only). nil when there is no relationship.
TargetResource coretypes.Resource
TargetResourceID string
}
func NewResourceAttributes(resourceID string, resourceKind coretypes.Kind) ResourceAttributes {
func NewResourceAttributes(resource coretypes.Resource, resourceID string) ResourceAttributes {
return ResourceAttributes{
ResourceID: resourceID,
ResourceKind: resourceKind,
Resource: resource,
ResourceID: resourceID,
}
}
// NewAttachResourceAttributes builds resource attributes that additionally name
// the target counterpart (used for attach/detach audit events).
func NewAttachResourceAttributes(resource coretypes.Resource, resourceID string, targetResource coretypes.Resource, targetResourceID string) ResourceAttributes {
return ResourceAttributes{
Resource: resource,
ResourceID: resourceID,
TargetResource: targetResource,
TargetResourceID: targetResourceID,
}
}
// CategoryFor derives the audit ActionCategory from a resource's kind. Audit owns
// this mapping so ResourceDef stays consumer-agnostic.
func CategoryFor(resource coretypes.Resource) ActionCategory {
switch resource.Kind().String() {
case "role", "serviceaccount", "user", "auth-domain", "session", "factor-password", "factor-api-key":
return ActionCategoryAccessControl
default:
return ActionCategoryConfigurationChange
}
}
// PutResource writes the resource attributes to an OTel Resource's attribute map.
// These are resource-level attributes (stored in the resource JSON column),
// not event-level attributes (stored in attributes_string).
func (attributes ResourceAttributes) PutResource(dest pcommon.Map) {
putStrIfNotEmpty(dest, "signoz.audit.resource.kind", attributes.ResourceKind.String())
putStrIfNotEmpty(dest, "signoz.audit.resource.id", attributes.ResourceID)
func (attributes ResourceAttributes) PutResource(orgID valuer.UUID, dest pcommon.Map) {
if attributes.Resource != nil {
putStrIfNotEmpty(dest, "signoz.audit.resource.kind", attributes.Resource.Kind().String())
putStrIfNotEmpty(dest, "signoz.audit.resource.id", attributes.ResourceID)
if attributes.ResourceID != "" {
// The FGA object string — correlates with the authz Check() object on
// the same resource (id-keyed resources match exactly).
putStrIfNotEmpty(dest, "signoz.audit.resource.object", attributes.Resource.Object(orgID, attributes.ResourceID))
}
}
if attributes.TargetResource != nil {
putStrIfNotEmpty(dest, "signoz.audit.resource.target.kind", attributes.TargetResource.Kind().String())
putStrIfNotEmpty(dest, "signoz.audit.resource.target.id", attributes.TargetResourceID)
if attributes.TargetResourceID != "" {
putStrIfNotEmpty(dest, "signoz.audit.resource.target.object", attributes.TargetResource.Object(orgID, attributes.TargetResourceID))
}
}
}
// Audit attributes — Error (When outcome is failure)
@@ -192,12 +234,25 @@ func newBody(auditAttributes AuditAttributes, principalAttributes PrincipalAttri
}
// Resource: " kind (id)" or " kind".
b.WriteString(" ")
b.WriteString(resourceAttributes.ResourceKind.String())
if resourceAttributes.ResourceID != "" {
b.WriteString(" (")
b.WriteString(resourceAttributes.ResourceID)
b.WriteString(")")
if resourceAttributes.Resource != nil {
b.WriteString(" ")
b.WriteString(resourceAttributes.Resource.Kind().String())
if resourceAttributes.ResourceID != "" {
b.WriteString(" (")
b.WriteString(resourceAttributes.ResourceID)
b.WriteString(")")
}
}
// Target (attach/detach context): " · target kind (id)" or " · target kind".
if resourceAttributes.TargetResource != nil {
b.WriteString(" · target ")
b.WriteString(resourceAttributes.TargetResource.Kind().String())
if resourceAttributes.TargetResourceID != "" {
b.WriteString(" (")
b.WriteString(resourceAttributes.TargetResourceID)
b.WriteString(")")
}
}
// Error suffix (failure only): ": type (code)" or ": type" or ": (code)" or omitted.

View File

@@ -63,8 +63,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "",
ResourceKind: coretypes.MustNewKind("dashboard"),
ResourceID: "",
Resource: coretypes.ResourceMetaResourceDashboard,
},
errorAttributes: ErrorAttributes{},
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) deleted dashboard",
@@ -81,8 +81,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.Email{},
},
resourceAttributes: ResourceAttributes{
ResourceID: "abd",
ResourceKind: coretypes.MustNewKind("dashboard"),
ResourceID: "abd",
Resource: coretypes.ResourceMetaResourceDashboard,
},
errorAttributes: ErrorAttributes{},
expectedBody: "019a1234-abcd-7000-8000-567800000001 deleted dashboard (abd)",
@@ -99,8 +99,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.Email{},
},
resourceAttributes: ResourceAttributes{
ResourceID: "abd",
ResourceKind: coretypes.MustNewKind("dashboard"),
ResourceID: "abd",
Resource: coretypes.ResourceMetaResourceDashboard,
},
errorAttributes: ErrorAttributes{},
expectedBody: "deleted dashboard (abd)",
@@ -117,8 +117,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("alice@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "019b-5678",
ResourceKind: coretypes.MustNewKind("dashboard"),
ResourceID: "019b-5678",
Resource: coretypes.ResourceMetaResourceDashboard,
},
errorAttributes: ErrorAttributes{},
expectedBody: "alice@acme.com (019a1234-abcd-7000-8000-567800000001) created dashboard (019b-5678)",
@@ -132,10 +132,10 @@ func TestNewBody(t *testing.T) {
},
principalAttributes: PrincipalAttributes{},
resourceAttributes: ResourceAttributes{
ResourceKind: coretypes.MustNewKind("alert-rule"),
Resource: coretypes.ResourceMetaResourceRule,
},
errorAttributes: ErrorAttributes{},
expectedBody: "updated alert-rule",
expectedBody: "updated rule",
},
{
name: "Failure_AllPresent",
@@ -149,8 +149,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("viewer@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "019b-5678",
ResourceKind: coretypes.MustNewKind("dashboard"),
ResourceID: "019b-5678",
Resource: coretypes.ResourceMetaResourceDashboard,
},
errorAttributes: ErrorAttributes{
ErrorType: "forbidden",
@@ -169,7 +169,7 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceKind: coretypes.MustNewKind("user"),
Resource: coretypes.ResourceUser,
},
errorAttributes: ErrorAttributes{
ErrorType: "not-found",
@@ -187,8 +187,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "019b-5678",
ResourceKind: coretypes.MustNewKind("dashboard"),
ResourceID: "019b-5678",
Resource: coretypes.ResourceMetaResourceDashboard,
},
errorAttributes: ErrorAttributes{},
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) failed to create dashboard (019b-5678)",

View File

@@ -0,0 +1,26 @@
package audittypes
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/stretchr/testify/assert"
)
func TestCategoryFor(t *testing.T) {
assert.Equal(t, ActionCategoryAccessControl, CategoryFor(coretypes.ResourceServiceAccount))
assert.Equal(t, ActionCategoryAccessControl, CategoryFor(coretypes.ResourceRole))
assert.Equal(t, ActionCategoryAccessControl, CategoryFor(coretypes.ResourceMetaResourceFactorAPIKey))
assert.Equal(t, ActionCategoryConfigurationChange, CategoryFor(coretypes.ResourceMetaResourceDashboard))
assert.Equal(t, ActionCategoryConfigurationChange, CategoryFor(coretypes.ResourceMetaResourceNotificationChannel))
}
func TestNewBodyWithTarget(t *testing.T) {
auditAttributes := AuditAttributes{Action: coretypes.VerbAttach, Outcome: OutcomeSuccess}
principalAttributes := PrincipalAttributes{}
resourceAttributes := NewAttachResourceAttributes(coretypes.ResourceServiceAccount, "sa-1", coretypes.ResourceMetaResourceFactorAPIKey, "key-9")
body := newBody(auditAttributes, principalAttributes, resourceAttributes, ErrorAttributes{})
assert.Contains(t, body, "serviceaccount (sa-1)")
assert.Contains(t, body, "target factor-api-key (key-9)")
}

View File

@@ -44,7 +44,9 @@ type AuditEvent struct {
TransportAttributes TransportAttributes
}
func NewAuditEventFromHTTPRequest(
// NewAuditEvent builds an audit event from pre-built resource attributes (which
// may carry attach/target context).
func NewAuditEvent(
req *http.Request,
route string,
statusCode int,
@@ -53,14 +55,12 @@ func NewAuditEventFromHTTPRequest(
action coretypes.Verb,
actionCategory ActionCategory,
claims authtypes.Claims,
resourceID string,
resourceKind coretypes.Kind,
resourceAttributes ResourceAttributes,
errorType string,
errorCode string,
) AuditEvent {
auditAttributes := NewAuditAttributesFromHTTP(statusCode, action, actionCategory, claims)
principalAttributes := NewPrincipalAttributesFromClaims(claims)
resourceAttributes := NewResourceAttributes(resourceID, resourceKind)
errorAttributes := NewErrorAttributes(errorType, errorCode)
transportAttributes := NewTransportAttributesFromHTTP(req, route, statusCode)
@@ -69,7 +69,7 @@ func NewAuditEventFromHTTPRequest(
TraceID: traceID,
SpanID: spanID,
Body: newBody(auditAttributes, principalAttributes, resourceAttributes, errorAttributes),
EventName: NewEventName(resourceAttributes.ResourceKind, auditAttributes.Action),
EventName: NewEventName(resourceAttributes.Resource.Kind(), auditAttributes.Action),
AuditAttributes: auditAttributes,
PrincipalAttributes: principalAttributes,
ResourceAttributes: resourceAttributes,
@@ -89,7 +89,11 @@ func NewPLogsFromAuditEvents(events []AuditEvent, name string, version string, s
groups := make(map[resourceKey][]int)
order := make([]resourceKey, 0)
for i, event := range events {
key := resourceKey{kind: event.ResourceAttributes.ResourceKind.String(), id: event.ResourceAttributes.ResourceID}
kind := ""
if event.ResourceAttributes.Resource != nil {
kind = event.ResourceAttributes.Resource.Kind().String()
}
key := resourceKey{kind: kind, id: event.ResourceAttributes.ResourceID}
if _, exists := groups[key]; !exists {
order = append(order, key)
}
@@ -101,7 +105,8 @@ func NewPLogsFromAuditEvents(events []AuditEvent, name string, version string, s
resourceAttrs := resourceLogs.Resource().Attributes()
resourceAttrs.PutStr(string(semconv.ServiceNameKey), name)
resourceAttrs.PutStr(string(semconv.ServiceVersionKey), version)
events[groups[key][0]].ResourceAttributes.PutResource(resourceAttrs)
first := events[groups[key][0]]
first.ResourceAttributes.PutResource(first.PrincipalAttributes.PrincipalOrgID, resourceAttrs)
scopeLogs := resourceLogs.ScopeLogs().AppendEmpty()
scopeLogs.Scope().SetName(scope)

View File

@@ -12,10 +12,10 @@ import (
)
var (
testDashboardKind = coretypes.MustNewKind("dashboard")
testDashboardResource = coretypes.ResourceMetaResourceDashboard
)
func TestNewAuditEventFromHTTPRequest(t *testing.T) {
func TestNewAuditEvent(t *testing.T) {
traceID := oteltrace.TraceID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
spanID := oteltrace.SpanID{1, 2, 3, 4, 5, 6, 7, 8}
@@ -28,8 +28,8 @@ func TestNewAuditEventFromHTTPRequest(t *testing.T) {
action coretypes.Verb
category ActionCategory
claims authtypes.Claims
resource coretypes.Resource
resourceID string
resourceKind coretypes.Kind
errorType string
errorCode string
expectedOutcome Outcome
@@ -44,8 +44,8 @@ func TestNewAuditEventFromHTTPRequest(t *testing.T) {
action: coretypes.VerbCreate,
category: ActionCategoryConfigurationChange,
claims: authtypes.Claims{UserID: "019a1234-abcd-7000-8000-567800000001", Email: "alice@acme.com", OrgID: "019a-0000-0000-0001", IdentNProvider: authtypes.IdentNProviderTokenizer},
resource: testDashboardResource,
resourceID: "019b-5678-efgh-9012",
resourceKind: testDashboardKind,
expectedOutcome: OutcomeSuccess,
expectedBody: "alice@acme.com (019a1234-abcd-7000-8000-567800000001) created dashboard (019b-5678-efgh-9012)",
},
@@ -58,8 +58,8 @@ func TestNewAuditEventFromHTTPRequest(t *testing.T) {
action: coretypes.VerbUpdate,
category: ActionCategoryConfigurationChange,
claims: authtypes.Claims{UserID: "019aaaaa-bbbb-7000-8000-cccc00000002", Email: "viewer@acme.com", OrgID: "019a-0000-0000-0001", IdentNProvider: authtypes.IdentNProviderTokenizer},
resource: testDashboardResource,
resourceID: "019b-5678-efgh-9012",
resourceKind: testDashboardKind,
errorType: "forbidden",
errorCode: "authz_forbidden",
expectedOutcome: OutcomeFailure,
@@ -71,7 +71,7 @@ func TestNewAuditEventFromHTTPRequest(t *testing.T) {
t.Run(testCase.name, func(t *testing.T) {
req := httptest.NewRequest(testCase.method, testCase.path, nil)
event := NewAuditEventFromHTTPRequest(
event := NewAuditEvent(
req,
testCase.route,
testCase.statusCode,
@@ -80,15 +80,14 @@ func TestNewAuditEventFromHTTPRequest(t *testing.T) {
testCase.action,
testCase.category,
testCase.claims,
testCase.resourceID,
testCase.resourceKind,
NewResourceAttributes(testCase.resource, testCase.resourceID),
testCase.errorType,
testCase.errorCode,
)
assert.Equal(t, testCase.expectedOutcome, event.AuditAttributes.Outcome)
assert.Equal(t, testCase.expectedBody, event.Body)
assert.Equal(t, testCase.resourceKind, event.ResourceAttributes.ResourceKind)
assert.Equal(t, testCase.resource.Kind(), event.ResourceAttributes.Resource.Kind())
assert.Equal(t, testCase.resourceID, event.ResourceAttributes.ResourceID)
assert.Equal(t, testCase.action, event.AuditAttributes.Action)
assert.Equal(t, testCase.category, event.AuditAttributes.ActionCategory)
@@ -103,18 +102,18 @@ func TestNewAuditEventFromHTTPRequest(t *testing.T) {
}
}
func newTestEvent(resourceKind coretypes.Kind, resourceID string, action coretypes.Verb) AuditEvent {
func newTestEvent(resource coretypes.Resource, resourceID string, action coretypes.Verb) AuditEvent {
return AuditEvent{
Body: resourceKind.String() + "." + action.PastTense(),
EventName: NewEventName(resourceKind, action),
Body: resource.Kind().String() + "." + action.PastTense(),
EventName: NewEventName(resource.Kind(), action),
AuditAttributes: AuditAttributes{
Action: action,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
ResourceAttributes: ResourceAttributes{
ResourceKind: resourceKind,
ResourceID: resourceID,
Resource: resource,
ResourceID: resourceID,
},
}
}
@@ -136,7 +135,7 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "SingleEvent",
events: []AuditEvent{
newTestEvent(testDashboardKind, "d-001", coretypes.VerbCreate),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbCreate),
},
expectedResourceLogs: 1,
expectedResourceKinds: []string{"dashboard"},
@@ -146,9 +145,9 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "SameResource_MultipleEvents",
events: []AuditEvent{
newTestEvent(testDashboardKind, "d-001", coretypes.VerbCreate),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbDelete),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbCreate),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbDelete),
},
expectedResourceLogs: 1,
expectedResourceKinds: []string{"dashboard"},
@@ -158,8 +157,8 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "DifferentResources_SeparateGroups",
events: []AuditEvent{
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
newTestEvent(coretypes.MustNewKind("user"), "u-001", coretypes.VerbDelete),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
newTestEvent(coretypes.ResourceUser, "u-001", coretypes.VerbDelete),
},
expectedResourceLogs: 2,
expectedResourceKinds: []string{"dashboard", "user"},
@@ -169,8 +168,8 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "SameKind_DifferentIDs_SeparateGroups",
events: []AuditEvent{
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
newTestEvent(testDashboardKind, "d-002", coretypes.VerbDelete),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
newTestEvent(testDashboardResource, "d-002", coretypes.VerbDelete),
},
expectedResourceLogs: 2,
expectedResourceKinds: []string{"dashboard", "dashboard"},
@@ -180,11 +179,11 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "InterleavedResources_GroupedCorrectly",
events: []AuditEvent{
newTestEvent(testDashboardKind, "d-001", coretypes.VerbCreate),
newTestEvent(coretypes.MustNewKind("user"), "u-001", coretypes.VerbUpdate),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
newTestEvent(coretypes.MustNewKind("user"), "u-001", coretypes.VerbDelete),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbDelete),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbCreate),
newTestEvent(coretypes.ResourceUser, "u-001", coretypes.VerbUpdate),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
newTestEvent(coretypes.ResourceUser, "u-001", coretypes.VerbDelete),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbDelete),
},
expectedResourceLogs: 2,
expectedResourceKinds: []string{"dashboard", "user"},
@@ -203,7 +202,6 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
resourceLogs := logs.ResourceLogs().At(i)
resourceAttrs := resourceLogs.Resource().Attributes()
// Verify service resource attributes
serviceName, exists := resourceAttrs.Get("service.name")
assert.True(t, exists)
assert.Equal(t, "signoz", serviceName.Str())
@@ -212,7 +210,6 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
assert.True(t, exists)
assert.Equal(t, "0.90.0", serviceVersion.Str())
// Verify audit resource attributes on Resource (not event attributes)
kind, exists := resourceAttrs.Get("signoz.audit.resource.kind")
assert.True(t, exists)
assert.Equal(t, testCase.expectedResourceKinds[i], kind.Str())
@@ -221,14 +218,11 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
assert.True(t, exists)
assert.Equal(t, testCase.expectedResourceIDs[i], id.Str())
// Verify scope
assert.Equal(t, 1, resourceLogs.ScopeLogs().Len())
assert.Equal(t, "signoz.audit", resourceLogs.ScopeLogs().At(0).Scope().Name())
// Verify log record count per group
assert.Equal(t, testCase.expectedLogRecordCounts[i], resourceLogs.ScopeLogs().At(0).LogRecords().Len())
// Verify resource attrs are NOT in log record event attributes
for j := 0; j < resourceLogs.ScopeLogs().At(0).LogRecords().Len(); j++ {
recordAttrs := resourceLogs.ScopeLogs().At(0).LogRecords().At(j).Attributes()
_, hasKind := recordAttrs.Get("signoz.audit.resource.kind")

View File

@@ -8,10 +8,6 @@ import {
} from '@playwright/test';
import apmMetricsTemplate from '../testdata/apm-metrics.json';
import queriesData from '../testdata/queries.json';
export type SignalType = 'metrics' | 'logs' | 'traces';
export type QueriesData = typeof queriesData;
import chartDataTemplate from '../testdata/chart-data-dashboard.json';
import variablesTemplate from '../testdata/variables-dashboard.json';
@@ -370,56 +366,6 @@ export async function findDashboardIdByTitle(
return body.data.find((d) => d.data.title === title)?.id;
}
/** Shape of a single persisted widget — only the fields these specs assert on. */
export interface PersistedWidget {
id?: string;
title?: string;
description?: string;
panelTypes?: string;
timePreferance?: string;
yAxisUnit?: string;
decimalPrecision?: number;
thresholds?: Array<{
thresholdFormat?: string;
thresholdOperator?: string;
thresholdValue?: number;
thresholdColor?: string;
thresholdTableOptions?: string;
}>;
columnUnits?: Record<string, string>;
[key: string]: unknown;
}
/** Shape of the persisted dashboard payload returned by GET /api/v1/dashboards/<id>. */
export interface DashboardData {
title: string;
description?: string;
tags?: string[];
widgets?: PersistedWidget[];
variables?: Record<string, Record<string, unknown>>;
layout?: unknown[];
}
/**
* Fetch the persisted dashboard payload via API. Use this for "did the save
* actually land on the server?" assertions — UI-only checks can pass on
* optimistic-update bugs.
*/
export async function fetchDashboardData(
page: Page,
id: string,
): Promise<DashboardData> {
const token = await authToken(page);
const res = await page.request.get(`/api/v1/dashboards/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(`GET /dashboards/${id} ${res.status()}: ${await res.text()}`);
}
const body = (await res.json()) as { data: { data: DashboardData } };
return body.data.data;
}
// ─── List page UI helpers ────────────────────────────────────────────────
/**
@@ -441,245 +387,3 @@ export async function openDashboardActionMenu(
await icon.click();
return page.getByRole('tooltip');
}
// ─── Dashboard detail page helpers ──────────────────────────────────────────
/**
* Click the Configure button (`data-testid="show-drawer"`) on a dashboard
* detail page and wait for the settings drawer (`.settings-container-root`) to
* be visible. Works from both the empty-state view and the populated toolbar —
* both render the same testid.
*
* Returns the drawer locator so callers can scope further assertions to it.
*/
export async function openDashboardSettingsDrawer(
page: Page,
): Promise<Locator> {
await page.getByTestId('show-drawer').first().click();
const drawer = page.locator('.settings-container-root');
await drawer.waitFor({ state: 'visible' });
return drawer;
}
/**
* Click `data-testid="save-dashboard-config"` and wait for the resulting
* `PUT /api/v1/dashboards/<id>` response. The Save button is only rendered
* when there is at least one unsaved change — callers must ensure the drawer
* has been dirtied before calling this.
*/
export async function saveDashboardSettings(page: Page): Promise<void> {
const patchResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByTestId('save-dashboard-config').click();
await patchResponse;
}
/**
* Rename a dashboard via the toolbar options popover:
* opens the popover (`data-testid="options"`), clicks "Rename", fills the
* input, clicks "Rename Dashboard", and waits for the PUT response.
*
* Pre-condition: the caller must be on the dashboard detail page.
*/
export async function renameDashboardViaToolbar(
page: Page,
newTitle: string,
): Promise<void> {
await page.getByTestId('options').click();
await page.getByRole('button', { name: 'Rename' }).click();
const modal = page.getByRole('dialog');
await modal.waitFor({ state: 'visible' });
const input = modal.getByTestId('dashboard-name');
await input.clear();
await input.fill(newTitle);
const patchResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByRole('button', { name: 'Rename Dashboard' }).click();
await patchResponse;
await modal.waitFor({ state: 'hidden' });
}
// ─── Add panel flow ─────────────────────────────────────────────────────────
/**
* From the dashboard detail page (must already be loaded), drive the full
* "Add Panel" flow for the given signal type:
* 1. Click the empty-state `add-panel` CTA to open the New Panel modal.
* 2. Pick the Time Series panel type.
* 3. Fill the panel name in the right pane (drives the post-save assertion).
* 4. For metrics: type the metric name from `queries.json` into the metric
* AutoComplete and select it from the dropdown. For logs/traces: switch
* the data-source selector to LOGS / TRACES; default Query Builder state
* is sufficient (queries.json query strings are empty by design).
* 5. Click Save Changes and wait for the PUT /api/v1/dashboards/<id> response.
*
* Throws if the PUT response is not 2xx. After return, the page is back on
* the dashboard detail page; the caller asserts the panel rendered.
*/
export async function configureAndSavePanel(
page: Page,
signal: SignalType,
panelTitle: string,
): Promise<void> {
await page.getByTestId('add-panel').click();
const newPanelModal = page
.getByRole('dialog')
.filter({ hasText: 'New Panel' });
await newPanelModal.waitFor({ state: 'visible' });
await newPanelModal.getByTestId('panel-type-graph').click();
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
await page.getByTestId('panel-name-input').fill(panelTitle);
if (signal === 'metrics') {
const metricName = queriesData.metrics.metricName;
// The testid is on the Ant Select wrapper <div>; the editable input
// lives inside it. Target the descendant input for fill().
const metricInput = page
.getByTestId('metric-name-selector-0')
.locator('input');
await metricInput.click();
await metricInput.fill(metricName);
// AutoComplete debounces and fetches; wait for the option then click.
await page
.locator('.ant-select-item-option-content', { hasText: metricName })
.first()
.click();
} else {
// logs / traces — switch the data source. Default query is sufficient.
await page.getByTestId('query-data-source-selector-0').click();
await page
.locator('.ant-select-item-option-content', {
hasText: signal.toUpperCase(),
})
.click();
}
const putResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByTestId('new-widget-save').click();
const res = await putResponse;
if (!res.ok()) {
throw new Error(
`PUT /api/v1/dashboards failed ${res.status()}: ${await res.text()}`,
);
}
// Save navigates back to /dashboard/<id> (no /new suffix).
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
}
// ─── Widget editor (re-open existing panel) ────────────────────────────────
/**
* Display labels surfaced in the `panel-change-select` Ant Select inside the
* widget editor. Mirrors the `PanelDisplay` enum in
* `frontend/src/constants/queryBuilder.ts` — keep in sync if a label is added,
* removed, or renamed there.
*/
export type PanelDisplayLabel =
| 'Time Series'
| 'Number'
| 'Table'
| 'List'
| 'Bar'
| 'Pie'
| 'Histogram';
/**
* Maps each display label to the URL `graphType` value. The right-hand strings
* mirror the `PANEL_TYPES` enum in
* `frontend/src/constants/queryBuilder.ts` (TIME_SERIES='graph',
* VALUE='value', and so on) — keep in sync with the enum.
*/
const PANEL_DISPLAY_TO_GRAPH_TYPE: Record<PanelDisplayLabel, string> = {
'Time Series': 'graph',
Number: 'value',
Table: 'table',
List: 'list',
Bar: 'bar',
Pie: 'pie',
Histogram: 'histogram',
};
/**
* Open the widget editor for an existing panel by driving the panel header
* options menu (the three-dot Ant `Dropdown` next to the title).
*
* The widget-header-options button is `visibility: hidden` until the panel is
* hovered (see `GridCardLayout.styles.scss`) — except on TABLE panels, where
* `globalSearchAvailable` keeps it permanently visible. Hovering the title
* testid first works for both states.
*/
export async function openWidgetEditor(
page: Page,
panelTitle: string,
): Promise<void> {
await page.getByTestId(panelTitle).first().hover();
await page.getByTestId('widget-header-options').first().click();
await page
.getByRole('menuitem', { name: /^edit$/i })
.first()
.click();
await page.waitForURL(/widgetId=/);
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
}
/**
* Click "Save Changes" in the widget editor, await the dashboard PUT response,
* and wait for navigation back to `/dashboard/<id>`. Throws if the PUT
* response is not 2xx. NewWidget's save handler calls the mutation and
* navigates on success — there is no confirmation modal in this flow.
*/
export async function saveWidgetEdit(page: Page): Promise<void> {
const putResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByTestId('new-widget-save').click();
const res = await putResponse;
if (!res.ok()) {
throw new Error(
`PUT /api/v1/dashboards failed ${res.status()}: ${await res.text()}`,
);
}
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
}
/**
* Switch the editor's panel display type via the Ant `Select` exposed as
* `data-testid="panel-change-select"`. The select options carry the display
* label as visible text (matches `PanelDisplay` enum values). After the
* change, this helper waits for the URL `graphType` param to reflect the new
* panel type and for the Save Changes button to re-render — the editor
* re-routes mid-flow via `redirectWithQueryBuilderData`.
*
* Note: the "List" option is filtered out of the dropdown when the current
* query contains a metrics data source (see VisualizationSettingsSection).
*/
export async function changePanelType(
page: Page,
displayLabel: PanelDisplayLabel,
): Promise<void> {
const expectedGraphType = PANEL_DISPLAY_TO_GRAPH_TYPE[displayLabel];
await page.getByTestId('panel-change-select').click();
// Each option renders a `data-testid="panel-type-option-<graphType>"` hook
// on its inner `.select-option` wrapper (see VisualizationSettingsSection).
// Targeting that is more stable than Ant's hidden-select option role —
// whose accessible name is the URL `graphType` value, not the display label.
await page.getByTestId(`panel-type-option-${expectedGraphType}`).click();
await page.waitForURL(new RegExp(`graphType=${expectedGraphType}`));
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
}

View File

@@ -1,12 +0,0 @@
{
"logs": {
"query": ""
},
"metrics": {
"metricName": "signoz_calls_total",
"query": ""
},
"traces": {
"query": ""
}
}

View File

@@ -2,7 +2,6 @@ import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
@@ -144,59 +143,4 @@ test.describe('Dashboard Detail — Add Panel (entry-point + persistence)', () =
page.getByText(panelName, { exact: true }).first(),
).toBeVisible();
});
// ─── Per-signal panel creation ───────────────────────────────────────────
//
// Configure a query for each signal using values from testdata/queries.json,
// save the panel, return to the dashboard, and verify the panel card renders
// and survives a reload.
test('TC-03 add metrics Time Series panel using signoz_calls_total from queries.json', async ({
authedPage: page,
}) => {
const id = await createDashboardViaApi(page, 'add-panel-metrics');
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'metrics', 'metrics-timeseries');
await expect(page.getByTestId('metrics-timeseries')).toBeVisible();
// Reload — proves the panel persists, not just optimistic UI from the save.
await page.reload();
await expect(page.getByTestId('metrics-timeseries')).toBeVisible();
});
test('TC-04 add logs Time Series panel with default query from queries.json', async ({
authedPage: page,
}) => {
const id = await createDashboardViaApi(page, 'add-panel-logs');
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'logs', 'logs-timeseries');
await expect(page.getByTestId('logs-timeseries')).toBeVisible();
await page.reload();
await expect(page.getByTestId('logs-timeseries')).toBeVisible();
});
test('TC-05 add traces Time Series panel with default query from queries.json', async ({
authedPage: page,
}) => {
const id = await createDashboardViaApi(page, 'add-panel-traces');
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'traces', 'traces-timeseries');
await expect(page.getByTestId('traces-timeseries')).toBeVisible();
await page.reload();
await expect(page.getByTestId('traces-timeseries')).toBeVisible();
});
});

View File

@@ -7,10 +7,6 @@ import {
awaitVariablesResolved,
createDashboardViaApi,
deleteDashboardViaApi,
fetchDashboardData,
gotoDashboardsList,
renameDashboardViaToolbar,
SEARCH_PLACEHOLDER,
} from '../../../helpers/dashboards';
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
@@ -688,38 +684,4 @@ test.describe('Dashboard Detail — Configure drawer', () => {
.first(),
).toBeVisible();
});
// ─── Toolbar rename ──────────────────────────────────────────────────────
// The toolbar options popover uses a separate PUT path from the Configure
// drawer (TC-02 above). Both paths must persist server-side and surface in
// the list view — this catches optimistic-update regressions specific to
// the toolbar entry point.
test('TC-22 rename via toolbar options popover persists to the toolbar title', async ({
authedPage: page,
}) => {
const id = await seed(page, 'cfg-toolbar-rename');
await page.goto(`/dashboard/${id}`);
// DashboardDescription toolbar always renders — even on blank dashboards.
await expect(page.getByTestId('options')).toBeVisible();
await renameDashboardViaToolbar(page, 'cfg-toolbar-rename-renamed');
await expect(page.getByTestId('dashboard-title')).toHaveText(
'cfg-toolbar-rename-renamed',
);
const persisted = await fetchDashboardData(page, id);
expect(persisted.title).toBe('cfg-toolbar-rename-renamed');
// List view reflects the rename after navigating back.
await gotoDashboardsList(page);
await page
.getByPlaceholder(SEARCH_PLACEHOLDER)
.fill('cfg-toolbar-rename-renamed');
await expect(
page.getByText('cfg-toolbar-rename-renamed').first(),
).toBeVisible();
});
});

View File

@@ -9,7 +9,6 @@ import {
createDashboardViaApi,
createVariablesDashboardViaApi,
deleteDashboardViaApi,
openDashboardSettingsDrawer,
} from '../../../helpers/dashboards';
const seedIds = new Set<string>();
@@ -241,25 +240,4 @@ test.describe('Dashboard Detail Page — Edge Cases', () => {
// document.title is set from the dashboard name — confirm it is intact.
await expect(page).toHaveTitle(new RegExp('Spec & Chars'));
});
test('TC-09 navigating away with the settings drawer open does not crash', async ({
authedPage: page,
}) => {
const id = await createDashboardViaApi(page, 'edge-drawer-nav-away');
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await openDashboardSettingsDrawer(page);
// Navigate away without closing the drawer.
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/dashboard($|\?)/);
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
// No error overlay should be present.
await expect(
page.getByRole('alert').filter({ hasText: /error/i }),
).toHaveCount(0);
});
});

View File

@@ -1,4 +1,3 @@
import path from 'path';
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
@@ -9,7 +8,6 @@ import {
createDashboardViaApi,
DEFAULT_DASHBOARD_TITLE,
deleteDashboardViaApi,
fetchDashboardData,
findDashboardIdByTitle,
gotoDashboardsList,
importApmMetricsDashboardViaUI,
@@ -17,11 +15,6 @@ import {
SEARCH_PLACEHOLDER,
} from '../../helpers/dashboards';
const APM_METRICS_TESTDATA_PATH = path.resolve(
__dirname,
'../../testdata/apm-metrics.json',
);
// Tests in this file mutate the dashboard list (create / delete). Run them
// serially within the worker so state from one test does not leak into
// another's assertions. Files still run in parallel via the project-level
@@ -429,140 +422,12 @@ test.describe('Dashboards List Page', () => {
await expect(page).toHaveURL(/\/dashboard($|\?)/);
});
test('TC-19 import via file upload creates dashboard, navigates to detail, and surfaces metadata in list', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page
.getByRole('dialog')
.filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
const postResponse = page.waitForResponse(
(r) =>
r.request().method() === 'POST' && /\/api\/v1\/dashboards/.test(r.url()),
);
await dialog
.locator('input[type="file"]')
.setInputFiles(APM_METRICS_TESTDATA_PATH);
await dialog.getByRole('button', { name: 'Import and Next' }).click();
const res = await postResponse;
expect(res.status()).toBeGreaterThanOrEqual(200);
expect(res.status()).toBeLessThan(300);
await page.waitForURL(/\/dashboard\/[0-9a-f-]+/);
// Register for cleanup.
const urlMatch = page.url().match(/\/dashboard\/([0-9a-f-]+)/);
expect(urlMatch, 'URL must contain dashboard ID').not.toBeNull();
seedIds.add(urlMatch![1]);
await expect(page.getByTestId('dashboard-title')).toHaveText(
APM_METRICS_TITLE,
);
// Server-side check: every widget + tag from the fixture must be persisted.
// A partial import (e.g. silently dropped widgets) would pass the UI title
// check but fail here. The apm-metrics fixture has 16 widgets and 4 tags.
const persisted = await fetchDashboardData(page, urlMatch![1]);
expect(persisted.widgets?.length).toBe(16);
expect(persisted.tags).toEqual(
expect.arrayContaining(['apm', 'latency', 'error rate', 'throughput']),
);
// Navigate back and confirm the imported dashboard surfaces in the list
// with at least one tag chip.
await gotoDashboardsList(page);
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(APM_METRICS_TITLE);
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
// The apm-metrics fixture has tags ['apm', 'latency', 'error rate', 'throughput'].
await expect(page.getByText('apm').first()).toBeVisible();
});
// The Monaco paste path is intentionally not covered — the file-upload
// path (TC-19) exercises the same populate-editor-then-import code path.
// Keyboard-typing large JSON into Monaco is unreliable in headless CI.
test('TC-20 invalid JSON via file upload shows "Invalid JSON" error', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page
.getByRole('dialog')
.filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
// Track POST attempts: invalid JSON must never reach the create endpoint.
let postFired = false;
await page.route(/\/api\/v1\/dashboards(\?|$)/, (route) => {
if (route.request().method() === 'POST') {
postFired = true;
}
void route.continue();
});
await dialog.locator('input[type="file"]').setInputFiles({
name: 'bad.json',
mimeType: 'application/json',
buffer: Buffer.from('not valid json {'),
});
await expect(dialog.getByText('Invalid JSON')).toBeVisible();
await expect(dialog).toBeVisible();
// Clicking "Import and Next" with invalid content should surface an error
// and keep the dialog open.
await dialog.getByRole('button', { name: 'Import and Next' }).click();
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await expect(dialog).toBeVisible();
await page.waitForLoadState('networkidle');
expect(postFired, 'invalid JSON must not trigger POST').toBe(false);
});
test('TC-21 import with empty editor clicking Import and Next shows error', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page
.getByRole('dialog')
.filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
let postFired = false;
await page.route(/\/api\/v1\/dashboards(\?|$)/, (route) => {
if (route.request().method() === 'POST') {
postFired = true;
}
void route.continue();
});
await dialog.getByRole('button', { name: 'Import and Next' }).click();
await expect(dialog.getByText('Error loading JSON file')).toBeVisible();
await expect(dialog).toBeVisible();
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await page.waitForLoadState('networkidle');
expect(postFired, 'empty editor must not trigger POST').toBe(false);
});
// ─── Deleting dashboards ─────────────────────────────────────────────────
//
// Known behaviour: clicking Cancel in the confirmation dialog navigates to
// the dashboard detail page rather than staying on the list.
test('TC-22 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
test('TC-19 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-confirm';
@@ -591,7 +456,7 @@ test.describe('Dashboards List Page', () => {
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible();
});
test('TC-23 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
test('TC-20 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-cancel';
@@ -606,7 +471,7 @@ test.describe('Dashboards List Page', () => {
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-24 confirming delete removes the dashboard from the list', async ({
test('TC-21 confirming delete removes the dashboard from the list', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-confirmed';
@@ -641,7 +506,7 @@ test.describe('Dashboards List Page', () => {
// ─── Row click navigation ────────────────────────────────────────────────
test('TC-25 clicking a dashboard row navigates to the detail page', async ({
test('TC-22 clicking a dashboard row navigates to the detail page', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-row-click';
@@ -655,7 +520,7 @@ test.describe('Dashboards List Page', () => {
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-26 sidebar Dashboards link navigates to the list page', async ({
test('TC-23 sidebar Dashboards link navigates to the list page', async ({
authedPage: page,
}) => {
await page.goto('/home');
@@ -673,7 +538,7 @@ test.describe('Dashboards List Page', () => {
// ─── URL state and deep linking ──────────────────────────────────────────
test('TC-27 browser Back after navigating to a dashboard restores search state', async ({
test('TC-24 browser Back after navigating to a dashboard restores search state', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-back-search';
@@ -692,7 +557,7 @@ test.describe('Dashboards List Page', () => {
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toHaveValue(name);
});
test('TC-28 direct navigation with sort params honours them on load', async ({
test('TC-25 direct navigation with sort params honours them on load', async ({
authedPage: page,
}) => {
await page.goto('/dashboard?columnKey=updatedAt&order=descend');

View File

@@ -1,255 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
changePanelType,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
fetchDashboardData,
findDashboardIdByTitle,
openWidgetEditor,
saveWidgetEdit,
} from '../../../helpers/dashboards';
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'list-controls-fixture';
const FIXTURE_PANEL_TITLE = 'list-controls-panel';
const seedIds = new Set<string>();
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
// LIST panels require a logs (or traces) data source — metrics queries
// hide the LIST option from panel-change-select.
await configureAndSavePanel(page, 'logs', FIXTURE_PANEL_TITLE);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'List');
await saveWidgetEdit(page);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoFixtureDashboard(page: Page): Promise<void> {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
await page.goto(`/dashboard/${id}`);
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
}
/** Fetch the persisted fixture dashboard's first widget. */
async function fetchFixtureWidget(page: Page) {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
const dashboard = await fetchDashboardData(page, id!);
const widget = dashboard.widgets?.[0];
expect(widget, 'fixture dashboard must have at least one widget').toBeTruthy();
return widget!;
}
test.describe('List Panel Controls', () => {
test('TC-01 panel name persists and is reflected in the widget header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('list-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('list-controls-renamed').first()).toBeVisible();
// Server-side check.
expect((await fetchFixtureWidget(page)).title).toBe('list-controls-renamed');
await openWidgetEditor(page, 'list-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'list-controls-renamed',
);
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 description persists and shows info icon on header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E list description');
await saveWidgetEdit(page);
await expect(
page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE })
.locator('.info-tooltip')
.first(),
).toBeVisible();
expect((await fetchFixtureWidget(page)).description).toBe('E2E list description');
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E list description',
);
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
});
test('TC-03 panel type switch from List to Table persists and re-renders', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Table');
// Table re-renders Decimal Precision + Column Units in the right pane.
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
await saveWidgetEdit(page);
// Panel card should now render an Ant table head.
await expect(
page
.locator('[data-testid="' + FIXTURE_PANEL_TITLE + '"]')
.first(),
).toBeVisible();
await expect(page.locator('.ant-table-thead').first()).toBeVisible();
// Server-side: panelTypes is 'table'.
expect((await fetchFixtureWidget(page)).panelTypes).toBe('table');
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=table/);
// Reset back to List.
await changePanelType(page, 'List');
await saveWidgetEdit(page);
});
test('TC-04 sections hidden for LIST are not rendered in the right pane', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.locator('section.panel-time-preference')).toHaveCount(0);
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await expect(page.locator('section.stack-chart')).toHaveCount(0);
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
await expect(page.locator('section.log-scale')).toHaveCount(0);
await expect(page.locator('section.legend-position')).toHaveCount(0);
await expect(page.locator('.decimal-precision-selector')).toHaveCount(0);
await expect(page.locator('.column-unit-selector')).toHaveCount(0);
await expect(page.locator('.y-axis-unit-selector-v2')).toHaveCount(0);
await expect(page.getByTestId('add-threshold-cta')).toHaveCount(0);
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-description-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
});
test('TC-05 discarding right-pane changes does not persist', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('discard-list-test');
let putFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
putFired = true;
}
route.continue();
});
await page.getByTestId('discard-button').click();
await page
.getByRole('dialog')
.last()
.getByRole('button', { name: /^OK$/i })
.click({ timeout: 1000 })
.catch(() => {
// no modal — direct navigation
});
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
// Settle before asserting no PUT.
await page.waitForLoadState('networkidle');
expect(putFired).toBe(false);
// Server-side double-check: persisted title is still the fixture name.
expect((await fetchFixtureWidget(page)).title).toBe(FIXTURE_PANEL_TITLE);
});
// ─── Reload persistence ──────────────────────────────────────────────────
test('TC-06 panel state survives a hard dashboard reload', async ({
authedPage: page,
}) => {
// Save description + a non-default panel type, then hard-reload and
// re-verify the panel card rehydrates with the right state.
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('reload persistence description');
await saveWidgetEdit(page);
await page.reload();
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
// Description info icon must render after rehydration.
await expect(
page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE })
.locator('.info-tooltip')
.first(),
).toBeVisible();
// Server-side check post-reload — confirms the load path read the same JSON.
const persisted = await fetchFixtureWidget(page);
expect(persisted.description).toBe('reload persistence description');
expect(persisted.panelTypes).toBe('list');
// Reset.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
});
});

View File

@@ -1,588 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
changePanelType,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
fetchDashboardData,
findDashboardIdByTitle,
openWidgetEditor,
saveWidgetEdit,
} from '../../../helpers/dashboards';
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'table-controls-fixture';
const FIXTURE_PANEL_TITLE = 'table-controls-panel';
const seedIds = new Set<string>();
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_TITLE);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Table');
await saveWidgetEdit(page);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoFixtureDashboard(page: Page): Promise<void> {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
await page.goto(`/dashboard/${id}`);
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
}
/**
* Fetch the persisted fixture dashboard JSON and return the first widget.
* Use this after a save to confirm the PUT actually landed the expected
* shape on the backend — UI-only round-trips pass on optimistic-update bugs.
*/
async function fetchFixtureWidget(page: Page) {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
const dashboard = await fetchDashboardData(page, id!);
const widget = dashboard.widgets?.[0];
expect(widget, 'fixture dashboard must have at least one widget').toBeTruthy();
return widget!;
}
/**
* Return the last <td> in the first data row of the panel's Ant Design table.
* Ant Design applies .ant-table-row to actual data rows only (not header rows),
* so this correctly skips the fixed/sticky header tbody rows.
*
* For the metrics panel the row has: td[0] = label column, td[last] = value
* column (the aggregation query "A"). The last td is thus the value cell.
* However, depending on the panel query there may only be ONE td per row. Use
* the cell that contains a non-empty value: any td that is not purely the
* label placeholder.
*
* NOTE: the value cell wraps its text in a <button> element (from the
* QueryTable open-traces render path) so textContent picks it up correctly.
*/
async function getFirstDataCell(page: Page) {
// .ant-table-row targets Ant Design data rows only (not header/fixed rows).
const firstRow = page.locator('tr.ant-table-row').first();
await firstRow.waitFor({ state: 'visible' });
// Return the last <td> — for a metrics table with columns [label, A] this
// is the value column. For a single-column table it is the only column.
return firstRow.locator('td').last();
}
/**
* Ensure a SettingsSection accordion in the widget editor right pane is
* expanded. If it is already open (content div has the `open` class), this is
* a no-op. Otherwise it clicks the header button and waits for the content to
* become visible.
*/
async function expandSection(page: Page, title: string): Promise<void> {
const section = page
.locator('.settings-section')
.filter({ has: page.locator('button.settings-section-header', { hasText: title }) });
const contentDiv = section.locator('.settings-section-content');
const isOpen = await contentDiv.evaluate((el) => el.classList.contains('open'));
if (!isOpen) {
await section.locator('button.settings-section-header').click();
await contentDiv.waitFor({ state: 'visible' });
}
}
/**
* Select a unit from the column-unit selector dropdown by typing a search
* term, then clicking the filtered option. Scoped to .column-unit-selector to
* avoid matching the Y-axis unit selectors on other panel types.
*
* The selector has `showSearch` enabled and renders a long virtualised option
* list — typing first avoids instability from the list re-rendering when the
* target option is off-screen.
*/
async function selectColumnUnit(
page: Page,
searchTerm: string,
optionText: string,
): Promise<void> {
const row = page.getByTestId(/^column-unit-row-/).first();
await row.click();
// `showSearch` is enabled; the visible text input is rendered by Ant
// inside the row but outside its testid wrapper post-focus, so reach in
// via the remaining ant-select internals on the focused row.
await row.locator('.ant-select input').fill(searchTerm);
await page
.locator('.ant-select-item-option-content', { hasText: optionText })
.first()
.click();
}
test.describe('Table Panel Controls', () => {
test('TC-01 panel name persists and is reflected in the widget header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('table-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('table-controls-renamed').first()).toBeVisible();
// Server-side check — the PUT must carry the new title.
expect((await fetchFixtureWidget(page)).title).toBe('table-controls-renamed');
await openWidgetEditor(page, 'table-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'table-controls-renamed',
);
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 description persists and shows info icon on header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E table description');
await saveWidgetEdit(page);
await expect(
page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE })
.locator('.info-tooltip')
.first(),
).toBeVisible();
expect((await fetchFixtureWidget(page)).description).toBe(
'E2E table description',
);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E table description',
);
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
});
test('TC-03 panel time preference switches to Last 15 min and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.locator('section.panel-time-preference')
.getByRole('button', { name: /global time/i })
.click();
await page.getByRole('menuitem', { name: /Last 15 min/i }).click();
await saveWidgetEdit(page);
// Server-side: persisted timePreferance enum, not just visible label.
expect((await fetchFixtureWidget(page)).timePreferance).toBe('LAST_15_MIN');
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 15 min/i);
await page
.locator('section.panel-time-preference')
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: /Global Time/i }).click();
await saveWidgetEdit(page);
});
test('TC-04 column unit formats the matching column cells and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// Use selectColumnUnit to avoid virtualised-list detached-DOM failures.
await selectColumnUnit(page, 'Milliseconds', 'Milliseconds (ms)');
// Wait for the dropdown selection to settle in the editor state
// before saving — otherwise the PUT can race the React state update.
await expect(
page.getByTestId(/^column-unit-row-/).first(),
).toContainText('Milliseconds');
await saveWidgetEdit(page);
// Render-side: the cell text should carry the `ms` suffix, confirming
// the unit selection reached the formatter. Asserting the *shape* of
// the number (digit pattern) belongs to the formatter's unit tests.
const cell = await getFirstDataCell(page);
await expect(cell).toContainText('ms');
// Server-side: columnUnits must record the unit code, not just the
// label. UI display can use a fancy label while the persisted enum drifts.
const persistedAfterUnit = await fetchFixtureWidget(page);
const columnUnitValues = Object.values(persistedAfterUnit.columnUnits ?? {});
expect(columnUnitValues, 'columnUnits must include the chosen unit').toContain(
'ms',
);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Section starts collapsed again on re-open — expand before asserting.
await expandSection(page, 'Formatting & Units');
await expect(
page.getByTestId(/^column-unit-row-/).first(),
).toContainText('Milliseconds');
// Reset — clear the unit via the Ant Select allowClear X button.
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2')
.first()
.hover();
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-clear')
.first()
.click();
await saveWidgetEdit(page);
});
test('TC-05 decimal precision changes the number of decimals when a column unit is set', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// Set a column unit so decimal precision has a visible effect.
await selectColumnUnit(page, 'Seconds', 'Seconds (s)');
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
.first()
.click();
// Wait for both selections to settle in the editor before saving.
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
'0 decimals',
);
await expect(
page.getByTestId(/^column-unit-row-/).first(),
).toContainText('Seconds');
await saveWidgetEdit(page);
// Render-side: the cell should carry the `s` unit suffix. The exact
// numeric formatting (integer vs decimal) is covered by the
// formatter's unit tests — keep e2e on the persistence contract.
const cell = await getFirstDataCell(page);
await expect(cell).toContainText('s');
// Server-side: decimalPrecision must be 0 in the persisted widget.
expect((await fetchFixtureWidget(page)).decimalPrecision).toBe(0);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Section starts collapsed again on re-open — expand before asserting.
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
/0 decimals/,
);
// Reset: decimal precision back to 2, clear column unit.
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
.first()
.click();
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2')
.first()
.hover();
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-clear')
.first()
.click();
await saveWidgetEdit(page);
});
test('TC-06 column-targeted Background threshold paints only the targeted column', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Thresholds" section starts collapsed when there are no thresholds.
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const card = page.locator('.threshold-container').first();
// For TABLE thresholds the column selector (table-operator-input-selector)
// defaults to the first aggregation query column (typically `A`). Operator
// defaults to '>'; switch to '>=' so it reliably matches non-negative values.
await card.getByTestId('operator-input-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '>=' })
.first()
.click();
await card.getByTestId('threshold-color-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: 'Background' })
.first()
.click();
// Save the threshold row (commits it to the thresholds state array).
await card.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// Inspect the threshold-styled cell directly. The testid host carries
// `data-threshold-format="Background"` so we can confirm the format too.
const row = page.locator('tr.ant-table-row').first();
await row.waitFor({ state: 'visible' });
const styledCell = row.getByTestId('threshold-styled-cell').first();
await expect(styledCell).toBeVisible();
await expect(styledCell).toHaveAttribute('data-threshold-format', 'Background');
const dataStyle = (await styledCell.getAttribute('style')) ?? '';
expect(dataStyle).toMatch(/background-color:/);
// Server-side: thresholds[] must be persisted with format=Background.
const persistedThresholds = (await fetchFixtureWidget(page)).thresholds ?? [];
expect(persistedThresholds.length).toBe(1);
expect(persistedThresholds[0].thresholdFormat).toBe('Background');
expect(persistedThresholds[0].thresholdOperator).toBe('>=');
// Reset — delete the threshold via its testid.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// ThresholdsSection defaultOpen is based on threshold count at mount; may
// start collapsed due to async state loading — always expand before interacting.
await expandSection(page, 'Thresholds');
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
await firstCard.getByTestId('threshold-delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-07 column-targeted Text threshold colors only the targeted column text', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Thresholds" section starts collapsed when there are no thresholds.
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const card = page.locator('.threshold-container').first();
await card.getByTestId('operator-input-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '>=' })
.first()
.click();
// Format defaults to 'Text' — no change needed.
await card.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
const row = page.locator('tr.ant-table-row').first();
await row.waitFor({ state: 'visible' });
const styledCell = row.getByTestId('threshold-styled-cell').first();
await expect(styledCell).toBeVisible();
await expect(styledCell).toHaveAttribute('data-threshold-format', 'Text');
const dataStyle = (await styledCell.getAttribute('style')) ?? '';
expect(dataStyle).toMatch(/color:/);
expect(dataStyle).not.toMatch(/background-color:/);
// Server-side: thresholds[] must be persisted with format=Text.
const persistedThresholds = (await fetchFixtureWidget(page)).thresholds ?? [];
expect(persistedThresholds.length).toBe(1);
expect(persistedThresholds[0].thresholdFormat).toBe('Text');
// Reset
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Thresholds');
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
await firstCard.getByTestId('threshold-delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-08 sections hidden for TABLE are not rendered', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await expect(page.locator('section.stack-chart')).toHaveCount(0);
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
await expect(page.locator('section.log-scale')).toHaveCount(0);
await expect(page.locator('section.legend-position')).toHaveCount(0);
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
// decimal-precision-selector and column-unit-selector are inside the
// "Formatting & Units" section which starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
await expect(page.locator('.column-unit-selector').first()).toBeVisible();
// add-threshold-cta is inside "Thresholds" which is also collapsed.
await expandSection(page, 'Thresholds');
await expect(page.getByTestId('add-threshold-cta')).toBeVisible();
});
test('TC-09 panel type switch from Table to Number persists and re-renders as a number', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Number');
// Number panel exposes the Y-axis unit selector in the Formatting & Units section.
await expect(page.locator('.y-axis-unit-selector-v2').first()).toBeVisible();
await saveWidgetEdit(page);
await expect(page.getByTestId('value-graph-text').first()).toBeVisible();
// Server-side: persisted panelTypes is the PANEL_TYPES enum value 'value'.
expect((await fetchFixtureWidget(page)).panelTypes).toBe('value');
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=value/);
// Reset: switch back to Table.
await changePanelType(page, 'Table');
await saveWidgetEdit(page);
});
test('TC-10 discarding right-pane changes does not persist', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('discard-table-test');
let putFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
putFired = true;
}
route.continue();
});
await page.getByTestId('discard-button').click();
await page
.getByRole('dialog')
.last()
.getByRole('button', { name: /^OK$/i })
.click({ timeout: 1000 })
.catch(() => {
// no modal — direct navigation
});
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
// Settle before asserting — a delayed PUT could otherwise sneak past.
await page.waitForLoadState('networkidle');
expect(putFired).toBe(false);
// Server-side double-check: persisted title is still the fixture name.
expect((await fetchFixtureWidget(page)).title).toBe(FIXTURE_PANEL_TITLE);
});
// ─── Reload persistence ──────────────────────────────────────────────────
test('TC-11 panel state survives a hard dashboard reload', async ({
authedPage: page,
}) => {
// Apply a combination of edits, save, then hard-reload the page and
// re-verify everything renders from the persisted JSON. Catches backend
// → frontend rehydration regressions that round-trips via close+reopen
// editor miss (re-opening the editor reuses the in-memory query state).
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('reload persistence description');
await expandSection(page, 'Formatting & Units');
await selectColumnUnit(page, 'Milliseconds', 'Milliseconds (ms)');
// Wait for the column-unit dropdown to settle before saving so the
// PUT carries the new unit (Ant Select onChange is async via React).
await expect(
page.getByTestId(/^column-unit-row-/).first(),
).toContainText('Milliseconds');
await saveWidgetEdit(page);
// Hard reload — purges in-memory state, forces a fresh fetch.
await page.reload();
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
// Cell value must still carry the unit after reload (proves the
// columnUnits + decimalPrecision + panelType rehydrated correctly).
// Numeric-shape validation lives in the formatter's unit tests.
const cell = await getFirstDataCell(page);
await expect(cell).toContainText('ms');
// Description info icon (the only header surface for description) must
// still render after rehydration.
await expect(
page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE })
.locator('.info-tooltip')
.first(),
).toBeVisible();
// Reset: clear unit + description.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-description-input').fill('');
await expandSection(page, 'Formatting & Units');
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2')
.first()
.hover();
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-clear')
.first()
.click();
await saveWidgetEdit(page);
});
});

View File

@@ -1,589 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
changePanelType,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
fetchDashboardData,
findDashboardIdByTitle,
openWidgetEditor,
saveWidgetEdit,
} from '../../../helpers/dashboards';
// All TCs operate on the same fixture panel and toggle its state — they MUST
// run serially within the worker. Project-level fullyParallel still runs this
// file in parallel with other files.
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'value-controls-fixture';
const FIXTURE_PANEL_TITLE = 'value-controls-panel';
const seedIds = new Set<string>();
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_TITLE);
// configureAndSavePanel creates a Time Series panel. Switch it to the
// Number (VALUE) type before the per-TC bodies run.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Number');
await saveWidgetEdit(page);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoFixtureDashboard(page: Page): Promise<void> {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
await page.goto(`/dashboard/${id}`);
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
}
/** Fetch the persisted fixture dashboard's first widget. */
async function fetchFixtureWidget(page: Page) {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
const dashboard = await fetchDashboardData(page, id!);
const widget = dashboard.widgets?.[0];
expect(widget, 'fixture dashboard must have at least one widget').toBeTruthy();
return widget!;
}
/**
* Ensure a SettingsSection accordion in the widget editor right pane is
* expanded. If it is already open (content div has the `open` class), this is
* a no-op. Otherwise it clicks the header button and waits for the CSS
* transition to complete. This handles both the common case (collapsed on
* mount) and the defensive case (already open).
*/
async function expandSection(page: Page, title: string): Promise<void> {
// Find the settings-section that contains this title in its header.
const section = page
.locator('.settings-section')
.filter({ has: page.locator('button.settings-section-header', { hasText: title }) });
// Check if the content div already has the `open` class.
const contentDiv = section.locator('.settings-section-content');
const isOpen = await contentDiv.evaluate((el) =>
el.classList.contains('open'),
);
if (!isOpen) {
// Click the header button to open the section.
await section.locator('button.settings-section-header').click();
// Wait for the CSS transition to complete (opacity 0→1, max-height 0→1000px).
await contentDiv.waitFor({ state: 'visible' });
}
}
/**
* Select a unit from the Y-axis unit selector dropdown by typing a search
* term, then clicking the filtered option. The selector has `showSearch`
* enabled and renders a long virtualised option list — typing first avoids
* instability from the virtualised list re-rendering when the target option
* is off-screen.
*/
async function selectYAxisUnit(
page: Page,
searchTerm: string,
optionText: string,
): Promise<void> {
// Click the outer wrapper to open the dropdown.
const unitSelect = page.locator('.y-axis-unit-selector-v2 .ant-select').first();
await unitSelect.click();
// The Ant Select input is now focused — type to filter the virtual list.
await page.locator('.y-axis-unit-selector-v2 .ant-select input').first().fill(searchTerm);
// Wait for the dropdown to show the filtered option, then click it.
await page
.locator('.ant-select-item-option-content', { hasText: optionText })
.first()
.click();
}
test.describe('Value Panel Controls', () => {
test('TC-01 panel name persists and is reflected in the widget header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('value-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('value-controls-renamed').first()).toBeVisible();
// Server-side check.
expect((await fetchFixtureWidget(page)).title).toBe('value-controls-renamed');
await openWidgetEditor(page, 'value-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'value-controls-renamed',
);
// Reset back to fixture title so subsequent TCs locate the panel.
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 panel description persists and renders the info icon on the header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E test description');
await saveWidgetEdit(page);
await expect(
page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE })
.locator('.info-tooltip')
.first(),
).toBeVisible();
expect((await fetchFixtureWidget(page)).description).toBe(
'E2E test description',
);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E test description',
);
// Reset
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
});
test('TC-03 panel time preference switches from Global Time to Last 15 min and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
const timeButton = page
.locator('section.panel-time-preference')
.getByRole('button', { name: /global time/i });
await timeButton.click();
await page.getByRole('menuitem', { name: /Last 15 min/i }).click();
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 15 min/i);
await saveWidgetEdit(page);
// Server-side: persisted timePreferance enum.
expect((await fetchFixtureWidget(page)).timePreferance).toBe('LAST_15_MIN');
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 15 min/i);
// Reset
await page
.locator('section.panel-time-preference')
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: /Global Time/i }).click();
await saveWidgetEdit(page);
});
test('TC-04 Y-axis unit applies a suffix to the rendered value and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// The Y-Axis Unit selector has showSearch enabled and a long virtualised
// option list. Type "Seconds" to filter before clicking.
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
// Live preview should now render a suffix unit `s`.
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
await saveWidgetEdit(page);
// Back on the dashboard the panel card should also render the suffix.
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
// Server-side: yAxisUnit must hold the unit code (catches a label-only
// regression where the UI shows "Seconds" but persists nothing).
expect((await fetchFixtureWidget(page)).yAxisUnit).toBe('s');
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(
page.locator('.y-axis-unit-selector-v2 .ant-select-selection-item').first(),
).toContainText(/Seconds/);
// Reset — clear the unit via allowClear (X button on the Ant Select).
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
});
test('TC-05 decimal precision reformats the rendered value when a unit is set', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// Setting a unit is required for decimal precision to have a visible
// effect — see Known Limitations #3 in the test plan.
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
.first()
.click();
// Wait for the dropdown to reflect the chosen precision before saving
// so the editor's state has actually applied the change.
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
'0 decimals',
);
// Reformatting check: with decimalPrecision=0 the rendered value must
// not contain a decimal separator. The exact numeric value depends on
// query/seed alignment and lives in the formatter's unit tests; this
// asserts only that the precision setting reaches the render layer.
const valueText = page.getByTestId('value-graph-text').first();
await expect(valueText).toBeVisible();
await expect(valueText).not.toContainText('.');
await saveWidgetEdit(page);
// Same assertion post-save: the dashboard render must respect the
// persisted precision, not just the editor's live preview.
await expect(page.getByTestId('value-graph-text').first()).not.toContainText(
'.',
);
// Server-side: decimalPrecision is 0 and yAxisUnit is 's'.
const persistedDecimals = await fetchFixtureWidget(page);
expect(persistedDecimals.decimalPrecision).toBe(0);
expect(persistedDecimals.yAxisUnit).toBe('s');
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
/0 decimals/,
);
// Reset: restore default 2 decimals and clear the unit.
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
.first()
.click();
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
});
test('TC-06 Text-format threshold colors the rendered value text and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Thresholds" section starts collapsed when there are no thresholds
// (defaultOpen={!!thresholds.length}) — expand it first.
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
// VALUE panels do not render a threshold label input — only operator,
// value, unit, format (Text/Background), and color. Defaults: operator
// '>', format 'Text', value 0, color 'Red'. We force operator to '>=' so
// the threshold reliably matches non-negative values.
const thresholdCard = page.locator('.threshold-container').first();
await thresholdCard
.getByTestId('operator-input-selector')
.click();
await page
.locator('.ant-select-item-option-content', { hasText: '>=' })
.first()
.click();
// Save the threshold row (commits it to the thresholds state array). The
// dashboard PUT still needs `saveWidgetEdit` after this.
await thresholdCard.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// Dashboard render: value text should now carry an inline color style.
const valueText = page.getByTestId('value-graph-text').first();
await expect(valueText).toBeVisible();
const inlineStyle = await valueText.getAttribute('style');
expect(inlineStyle).toMatch(/color:/);
// Server-side: thresholds[] persisted with format=Text.
const persistedThresholds = (await fetchFixtureWidget(page)).thresholds ?? [];
expect(persistedThresholds.length).toBe(1);
expect(persistedThresholds[0].thresholdFormat).toBe('Text');
expect(persistedThresholds[0].thresholdOperator).toBe('>=');
// Re-open editor and verify the threshold round-tripped.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The ThresholdsSection defaultOpen is based on threshold count at mount
// time; due to async state loading it may start collapsed. Expand it.
await expandSection(page, 'Thresholds');
await expect(
page.locator('.threshold-container').first(),
).toBeVisible();
// Reset — delete the threshold via testid.
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
await firstCard.getByTestId('threshold-delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-07 Background-format threshold paints the value container background', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Thresholds" section starts collapsed when there are no thresholds.
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const thresholdCard = page.locator('.threshold-container').first();
// Set operator >= and switch format from Text to Background.
await thresholdCard.getByTestId('operator-input-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '>=' })
.first()
.click();
await thresholdCard.getByTestId('threshold-color-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: 'Background' })
.first()
.click();
await thresholdCard.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// Dashboard render: value-graph-container must carry an inline background.
const container = page.getByTestId('value-graph-container').first();
await expect(container).toBeVisible();
const inlineStyle = await container.getAttribute('style');
expect(inlineStyle).toMatch(/background-color:/);
// Server-side: thresholds[] persisted with format=Background.
const persistedThresholds = (await fetchFixtureWidget(page)).thresholds ?? [];
expect(persistedThresholds.length).toBe(1);
expect(persistedThresholds[0].thresholdFormat).toBe('Background');
// Reset
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// ThresholdsSection may start collapsed even with thresholds — always
// expand before interacting with threshold cards.
await expandSection(page, 'Thresholds');
// Edit/delete buttons are display:none by default, revealed on :hover.
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
await firstCard.getByTestId('threshold-delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-08 clearing the Y-axis unit removes the suffix from the rendered value', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// Apply a unit first.
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
await saveWidgetEdit(page);
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
// Clear it.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
// Suffix should be gone from the rendered panel.
await expect(page.getByTestId('value-graph-suffix-unit')).toHaveCount(0);
// Server-side: yAxisUnit must be cleared (empty / undefined).
const cleared = await fetchFixtureWidget(page);
expect(cleared.yAxisUnit ?? '').toBe('');
});
test('TC-09 panel type switch from Number to Time Series persists and re-renders', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Time Series');
// Time Series exposes Fill gaps — confirm the right pane re-rendered.
await expect(page.locator('section.fill-gaps')).toBeVisible();
await saveWidgetEdit(page);
// Server-side: panelTypes is 'graph' (PANEL_TYPES.TIME_SERIES).
expect((await fetchFixtureWidget(page)).panelTypes).toBe('graph');
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=graph/);
// Reset: switch back to Number for downstream TCs.
await changePanelType(page, 'Number');
await saveWidgetEdit(page);
});
test('TC-10 sections hidden for VALUE are not rendered in the right pane', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Hidden by the panel-type matrix for VALUE — these sections are not
// rendered in the DOM at all (conditionally excluded by RightContainer).
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
await expect(page.locator('section.log-scale')).toHaveCount(0);
await expect(page.locator('section.legend-position')).toHaveCount(0);
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await expect(page.locator('section.stack-chart')).toHaveCount(0);
// Expected to be present in the always-open General and Visualization
// sections.
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
// The "Formatting & Units" section is collapsed on open — expand it to
// verify the controls are rendered for VALUE.
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
// The "Thresholds" section is collapsed when there are no thresholds —
// expand it to verify the Add Threshold CTA is rendered for VALUE.
await expandSection(page, 'Thresholds');
await expect(page.getByTestId('add-threshold-cta')).toBeVisible();
});
test('TC-11 discarding right-pane changes does not persist or visually update', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('discard-value-test');
let putFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
putFired = true;
}
route.continue();
});
await page.getByTestId('discard-button').click();
// If a discard confirmation appears, OK it. Right-pane-only changes
// usually don't trigger one.
const confirmDialog = page.getByRole('dialog').last();
await confirmDialog
.getByRole('button', { name: /^OK$/i })
.click({ timeout: 1000 })
.catch(() => {
// no modal — the editor navigated away immediately
});
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
// Settle before asserting no PUT.
await page.waitForLoadState('networkidle');
expect(putFired).toBe(false);
// Server-side double-check: persisted title is still the fixture name.
expect((await fetchFixtureWidget(page)).title).toBe(FIXTURE_PANEL_TITLE);
});
// ─── Reload persistence ──────────────────────────────────────────────────
test('TC-12 panel state survives a hard dashboard reload', async ({
authedPage: page,
}) => {
// Apply unit + description + decimal precision, save, hard-reload, and
// re-verify the panel renders correctly from the persisted JSON.
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('reload persistence description');
await expandSection(page, 'Formatting & Units');
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
await saveWidgetEdit(page);
await page.reload();
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
// Suffix unit must render after rehydration.
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
// Description info icon must render after rehydration.
await expect(
page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE })
.locator('.info-tooltip')
.first(),
).toBeVisible();
// Reset.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-description-input').fill('');
await expandSection(page, 'Formatting & Units');
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
});
});