mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-08 01:50:23 +01:00
Compare commits
2 Commits
e2e/table-
...
platform-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49e9657cae | ||
|
|
d296ce0f3f |
@@ -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)
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ function ValueGraph({
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="value-graph-container"
|
||||
data-testid="value-graph-container"
|
||||
style={{
|
||||
backgroundColor:
|
||||
threshold.thresholdFormat === 'Background'
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
|
||||
299
pkg/http/handler/resourcedef.go
Normal file
299
pkg/http/handler/resourcedef.go
Normal 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
|
||||
}
|
||||
100
pkg/http/handler/resourcedef_test.go
Normal file
100
pkg/http/handler/resourcedef_test.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
81
pkg/http/middleware/resource.go
Normal file
81
pkg/http/middleware/resource.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)",
|
||||
|
||||
26
pkg/types/audittypes/category_test.go
Normal file
26
pkg/types/audittypes/category_test.go
Normal 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)")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
12
tests/e2e/testdata/queries.json
vendored
12
tests/e2e/testdata/queries.json
vendored
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"logs": {
|
||||
"query": ""
|
||||
},
|
||||
"metrics": {
|
||||
"metricName": "signoz_calls_total",
|
||||
"query": ""
|
||||
},
|
||||
"traces": {
|
||||
"query": ""
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user