mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-04 09:20:34 +01:00
Compare commits
4 Commits
nv/v2-dash
...
nv/delete-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca96c71146 | ||
|
|
de2909d1d1 | ||
|
|
f311fcabf7 | ||
|
|
a37c07f881 |
@@ -204,6 +204,64 @@ func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy
|
||||
return module.pkgDashboardModule.CreateV2(ctx, orgID, createdBy, creator, postable)
|
||||
}
|
||||
|
||||
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypesv2.Dashboard, error) {
|
||||
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypesv2.UpdateableDashboard) (*dashboardtypesv2.Dashboard, error) {
|
||||
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updateable)
|
||||
}
|
||||
|
||||
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
|
||||
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
|
||||
}
|
||||
|
||||
func (module *module) CreatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, postable dashboardtypes.PostablePublicDashboard) (*dashboardtypesv2.Dashboard, error) {
|
||||
if _, err := module.licensing.GetActive(ctx, orgID); err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
existing, err := module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing.PublicConfig != nil {
|
||||
return nil, errors.Newf(errors.TypeAlreadyExists, dashboardtypes.ErrCodePublicDashboardAlreadyExists, "dashboard with id %s is already public", id)
|
||||
}
|
||||
|
||||
publicDashboard := dashboardtypes.NewPublicDashboard(postable.TimeRangeEnabled, postable.DefaultTimeRange, id)
|
||||
if err := module.store.CreatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(publicDashboard)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing.PublicConfig = publicDashboard
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, deletedBy string) error {
|
||||
return module.pkgDashboardModule.DeleteV2(ctx, orgID, id, deletedBy)
|
||||
}
|
||||
|
||||
func (module *module) UpdatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable dashboardtypes.UpdatablePublicDashboard) (*dashboardtypesv2.Dashboard, error) {
|
||||
if _, err := module.licensing.GetActive(ctx, orgID); err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
existing, err := module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing.PublicConfig == nil {
|
||||
return nil, errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodePublicDashboardNotFound, "dashboard with id %s isn't public", id)
|
||||
}
|
||||
|
||||
existing.PublicConfig.Update(updatable.TimeRangeEnabled, updatable.DefaultTimeRange)
|
||||
if err := module.store.UpdatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(existing.PublicConfig)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
|
||||
return module.pkgDashboardModule.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,125 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authZ.ViewAccess(provider.dashboardHandler.GetV2), handler.OpenAPIDef{
|
||||
ID: "GetDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get dashboard (v2)",
|
||||
Description: "This endpoint returns a v2-shape dashboard with its tags and public sharing config (if any).",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypesv2.GettableDashboard),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authZ.EditAccess(provider.dashboardHandler.UpdateV2), handler.OpenAPIDef{
|
||||
ID: "UpdateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update dashboard (v2)",
|
||||
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
|
||||
Request: new(dashboardtypesv2.UpdateableDashboard),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypesv2.GettableDashboard),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authZ.EditAccess(provider.dashboardHandler.LockV2), handler.OpenAPIDef{
|
||||
ID: "LockDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Lock dashboard (v2)",
|
||||
Description: "This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authZ.EditAccess(provider.dashboardHandler.UnlockV2), handler.OpenAPIDef{
|
||||
ID: "UnlockDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Unlock dashboard (v2)",
|
||||
Description: "This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/public", handler.New(provider.authZ.AdminAccess(provider.dashboardHandler.CreatePublicV2), handler.OpenAPIDef{
|
||||
ID: "CreatePublicDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Make a dashboard v2 public",
|
||||
Description: "This endpoint creates the public sharing config for a v2 dashboard and returns the dashboard with the new public config attached. Lock state does not gate this endpoint.",
|
||||
Request: new(dashboardtypes.PostablePublicDashboard),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypesv2.GettableDashboard),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authZ.EditAccess(provider.dashboardHandler.DeleteV2), handler.OpenAPIDef{
|
||||
ID: "DeleteDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Delete dashboard (v2)",
|
||||
Description: "This endpoint soft-deletes a v2-shape dashboard. Locked dashboards are rejected. Hard deletion happens later via the purge cron.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/public", handler.New(provider.authZ.AdminAccess(provider.dashboardHandler.UpdatePublicV2), handler.OpenAPIDef{
|
||||
ID: "UpdatePublicDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update public sharing config for a dashboard v2",
|
||||
Description: "This endpoint updates the public sharing config (time range settings) of an already-public v2 dashboard. Lock state does not gate this endpoint.",
|
||||
Request: new(dashboardtypes.UpdatablePublicDashboard),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypesv2.GettableDashboard),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authZ.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
|
||||
ID: "CreatePublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
|
||||
@@ -58,6 +58,18 @@ type Module interface {
|
||||
// v2 dashboard methods
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypesv2.PostableDashboard) (*dashboardtypesv2.Dashboard, error)
|
||||
|
||||
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypesv2.Dashboard, error)
|
||||
|
||||
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypesv2.UpdateableDashboard) (*dashboardtypesv2.Dashboard, error)
|
||||
|
||||
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
|
||||
|
||||
CreatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, postable dashboardtypes.PostablePublicDashboard) (*dashboardtypesv2.Dashboard, error)
|
||||
|
||||
UpdatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable dashboardtypes.UpdatablePublicDashboard) (*dashboardtypesv2.Dashboard, error)
|
||||
|
||||
DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, deletedBy string) error
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
@@ -85,4 +97,18 @@ type Handler interface {
|
||||
// v2 dashboard methods
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
CreateV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdateV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
LockV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
UnlockV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
CreatePublicV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdatePublicV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
DeleteV2(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
46
pkg/modules/dashboard/dashboardpurger/config.go
Normal file
46
pkg/modules/dashboard/dashboardpurger/config.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package dashboardpurger
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// Interval between successive purge passes.
|
||||
Interval time.Duration `mapstructure:"interval"`
|
||||
|
||||
// BatchSize is the maximum number of dashboards hard-deleted per pass.
|
||||
// Caps the size of the IN(...) clause and bounds tx duration.
|
||||
BatchSize int `mapstructure:"batch_size"`
|
||||
|
||||
// Retention is how long a soft-deleted dashboard sticks around before
|
||||
// becoming eligible for hard deletion.
|
||||
Retention time.Duration `mapstructure:"retention"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
return factory.NewConfigFactory(factory.MustNewName("dashboardpurger"), newConfig)
|
||||
}
|
||||
|
||||
func newConfig() factory.Config {
|
||||
return &Config{
|
||||
Interval: 1 * time.Hour,
|
||||
BatchSize: 100,
|
||||
Retention: 7 * 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.Interval <= 0 {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "interval must be positive")
|
||||
}
|
||||
if c.BatchSize <= 0 {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "batch_size must be positive")
|
||||
}
|
||||
if c.Retention < 0 {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "retention must not be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
79
pkg/modules/dashboard/dashboardpurger/purger.go
Normal file
79
pkg/modules/dashboard/dashboardpurger/purger.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package dashboardpurger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
)
|
||||
|
||||
type Purger interface {
|
||||
factory.Service
|
||||
}
|
||||
|
||||
type purger struct {
|
||||
config Config
|
||||
settings factory.ScopedProviderSettings
|
||||
store dashboardtypes.Store
|
||||
stopC chan struct{}
|
||||
}
|
||||
|
||||
func NewFactory(store dashboardtypes.Store) factory.ProviderFactory[Purger, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("dashboardpurger"), func(ctx context.Context, providerSettings factory.ProviderSettings, config Config) (Purger, error) {
|
||||
return New(ctx, providerSettings, config, store)
|
||||
})
|
||||
}
|
||||
|
||||
func New(_ context.Context, providerSettings factory.ProviderSettings, config Config, store dashboardtypes.Store) (Purger, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/dashboard/dashboardpurger")
|
||||
return &purger{
|
||||
config: config,
|
||||
settings: settings,
|
||||
store: store,
|
||||
stopC: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *purger) Start(ctx context.Context) error {
|
||||
ticker := time.NewTicker(p.config.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.stopC:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
ctx, span := p.settings.Tracer().Start(ctx, "dashboardpurger.Sweep")
|
||||
if err := p.sweep(ctx); err != nil {
|
||||
span.RecordError(err)
|
||||
p.settings.Logger().ErrorContext(ctx, "dashboard purge sweep failed", errors.Attr(err))
|
||||
}
|
||||
span.End()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *purger) Stop(_ context.Context) error {
|
||||
close(p.stopC)
|
||||
return nil
|
||||
}
|
||||
|
||||
// sweep does at most one batch per call. The ticker drives further passes; if
|
||||
// purge volume is bursty the next tick will pick up the rest.
|
||||
func (p *purger) sweep(ctx context.Context) error {
|
||||
ids, err := p.store.ListPurgeable(ctx, p.config.Retention, p.config.BatchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := p.store.HardDelete(ctx, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
p.settings.Logger().InfoContext(ctx, "hard-deleted soft-deleted dashboards", slog.Int("count", len(ids)))
|
||||
return nil
|
||||
}
|
||||
@@ -2,10 +2,13 @@ package impldashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
@@ -63,6 +66,195 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
|
||||
return storableDashboard, nil
|
||||
}
|
||||
|
||||
func (store *store) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.StorableDashboard, *dashboardtypes.StorablePublicDashboard, error) {
|
||||
type joinedRow struct {
|
||||
bun.BaseModel `bun:"table:dashboard,alias:d"`
|
||||
|
||||
ID valuer.UUID `bun:"id"`
|
||||
OrgID valuer.UUID `bun:"org_id"`
|
||||
Data dashboardtypes.StorableDashboardData `bun:"data"`
|
||||
Locked bool `bun:"locked"`
|
||||
CreatedAt time.Time `bun:"created_at"`
|
||||
CreatedBy string `bun:"created_by"`
|
||||
UpdatedAt time.Time `bun:"updated_at"`
|
||||
UpdatedBy string `bun:"updated_by"`
|
||||
|
||||
PublicID *valuer.UUID `bun:"public_id"`
|
||||
PublicCreatedAt *time.Time `bun:"public_created_at"`
|
||||
PublicUpdatedAt *time.Time `bun:"public_updated_at"`
|
||||
PublicTimeRangeEnabled *bool `bun:"public_time_range_enabled"`
|
||||
PublicDefaultTimeRange *string `bun:"public_default_time_range"`
|
||||
}
|
||||
|
||||
row := new(joinedRow)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(row).
|
||||
ColumnExpr("d.id, d.org_id, d.data, d.locked, d.created_at, d.created_by, d.updated_at, d.updated_by").
|
||||
ColumnExpr("pd.id AS public_id, pd.created_at AS public_created_at, pd.updated_at AS public_updated_at, pd.time_range_enabled AS public_time_range_enabled, pd.default_time_range AS public_default_time_range").
|
||||
Join("LEFT JOIN public_dashboard AS pd ON pd.dashboard_id = d.id").
|
||||
Where("d.id = ?", id).
|
||||
Where("d.org_id = ?", orgID).
|
||||
Where("d.deleted_at IS NULL").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodeDashboardNotFound, "dashboard with id %s doesn't exist", id)
|
||||
}
|
||||
|
||||
storable := &dashboardtypes.StorableDashboard{
|
||||
Identifiable: types.Identifiable{ID: row.ID},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt},
|
||||
UserAuditable: types.UserAuditable{CreatedBy: row.CreatedBy, UpdatedBy: row.UpdatedBy},
|
||||
OrgID: row.OrgID,
|
||||
Data: row.Data,
|
||||
Locked: row.Locked,
|
||||
}
|
||||
|
||||
if row.PublicID == nil {
|
||||
return storable, nil, nil
|
||||
}
|
||||
public := &dashboardtypes.StorablePublicDashboard{
|
||||
Identifiable: types.Identifiable{ID: *row.PublicID},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: *row.PublicCreatedAt, UpdatedAt: *row.PublicUpdatedAt},
|
||||
TimeRangeEnabled: *row.PublicTimeRangeEnabled,
|
||||
DefaultTimeRange: *row.PublicDefaultTimeRange,
|
||||
DashboardID: row.ID.StringValue(),
|
||||
}
|
||||
return storable, public, nil
|
||||
}
|
||||
|
||||
func (store *store) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, data dashboardtypes.StorableDashboardData) error {
|
||||
res, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model((*dashboardtypes.StorableDashboard)(nil)).
|
||||
Set("data = ?", data).
|
||||
Set("updated_by = ?", updatedBy).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("id = ?", id).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("deleted_at IS NULL").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Defends against the race where a soft-delete lands between the caller's
|
||||
// pre-update GetV2 and this update.
|
||||
if rows == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodeDashboardNotFound, "dashboard with id %s doesn't exist", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) ListPurgeable(ctx context.Context, retention time.Duration, limit int) ([]valuer.UUID, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
cutoff := time.Now().Add(-retention)
|
||||
ids := make([]valuer.UUID, 0, limit)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model((*dashboardtypes.StorableDashboard)(nil)).
|
||||
Column("id").
|
||||
Where("deleted_at IS NOT NULL").
|
||||
Where("deleted_at < ?", cutoff).
|
||||
Limit(limit).
|
||||
Scan(ctx, &ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// HardDelete cascades to tag_relations and public_dashboard inside one
|
||||
// transaction so a partial failure leaves no orphans.
|
||||
func (store *store) HardDelete(ctx context.Context, ids []valuer.UUID) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return store.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
|
||||
if _, err := store.sqlstore.BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*tagtypes.TagRelation)(nil)).
|
||||
Where("entity_id IN (?)", bun.In(ids)).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := store.sqlstore.BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*dashboardtypes.StorablePublicDashboard)(nil)).
|
||||
Where("dashboard_id IN (?)", bun.In(ids)).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := store.sqlstore.BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*dashboardtypes.StorableDashboard)(nil)).
|
||||
Where("id IN (?)", bun.In(ids)).
|
||||
Exec(ctx)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (store *store) SoftDeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, deletedBy string) error {
|
||||
res, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model((*dashboardtypes.StorableDashboard)(nil)).
|
||||
Set("deleted_at = ?", time.Now()).
|
||||
Set("deleted_by = ?", deletedBy).
|
||||
Where("id = ?", id).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("deleted_at IS NULL").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodeDashboardNotFound, "dashboard with id %s doesn't exist", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, locked bool, updatedBy string) error {
|
||||
res, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model((*dashboardtypes.StorableDashboard)(nil)).
|
||||
Set("locked = ?", locked).
|
||||
Set("updated_by = ?", updatedBy).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("id = ?", id).
|
||||
Where("deleted_at IS NULL").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodeDashboardNotFound, "dashboard with id %s doesn't exist", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) GetPublic(ctx context.Context, dashboardID string) (*dashboardtypes.StorablePublicDashboard, error) {
|
||||
storable := new(dashboardtypes.StorablePublicDashboard)
|
||||
err := store.
|
||||
|
||||
@@ -6,10 +6,13 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes/dashboardtypesv2"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (handler *handler) CreateV2(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -42,3 +45,259 @@ func (handler *handler) CreateV2(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusCreated, dashboardtypesv2.NewGettableDashboardFromDashboard(dashboard))
|
||||
}
|
||||
|
||||
func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.GetV2(ctx, orgID, dashboardID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboardtypesv2.NewGettableDashboardFromDashboard(dashboard))
|
||||
}
|
||||
|
||||
func (handler *handler) LockV2(rw http.ResponseWriter, r *http.Request) {
|
||||
handler.lockUnlockV2(rw, r, true)
|
||||
}
|
||||
|
||||
func (handler *handler) UnlockV2(rw http.ResponseWriter, r *http.Request) {
|
||||
handler.lockUnlockV2(rw, r, false)
|
||||
}
|
||||
|
||||
func (handler *handler) lockUnlockV2(rw http.ResponseWriter, r *http.Request, lock bool) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin := false
|
||||
selectors := []authtypes.Selector{
|
||||
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
|
||||
}
|
||||
if err := handler.authz.CheckWithTupleCreation(
|
||||
ctx,
|
||||
claims,
|
||||
valuer.MustNewUUID(claims.OrgID),
|
||||
authtypes.RelationAssignee,
|
||||
authtypes.TypeableRole,
|
||||
selectors,
|
||||
selectors,
|
||||
); err == nil {
|
||||
isAdmin = true
|
||||
}
|
||||
|
||||
if err := handler.module.LockUnlockV2(ctx, orgID, dashboardID, claims.Email, isAdmin, lock); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := dashboardtypesv2.UpdateableDashboard{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.UpdateV2(ctx, orgID, dashboardID, claims.Email, req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboardtypesv2.NewGettableDashboardFromDashboard(dashboard))
|
||||
}
|
||||
|
||||
func (handler *handler) CreatePublicV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := dashboardtypes.PostablePublicDashboard{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.CreatePublicV2(ctx, orgID, dashboardID, req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboardtypesv2.NewGettableDashboardFromDashboard(dashboard))
|
||||
}
|
||||
|
||||
func (handler *handler) UpdatePublicV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := dashboardtypes.UpdatablePublicDashboard{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.UpdatePublicV2(ctx, orgID, dashboardID, req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboardtypesv2.NewGettableDashboardFromDashboard(dashboard))
|
||||
}
|
||||
|
||||
func (handler *handler) DeleteV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := handler.module.DeleteV2(ctx, orgID, dashboardID, claims.Email); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package impldashboard
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes/dashboardtypesv2"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -46,3 +47,99 @@ func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy
|
||||
module.analytics.TrackUser(ctx, orgID.String(), creator.String(), "Dashboard Created", dashboardtypes.NewStatsFromStorableDashboards([]*dashboardtypes.StorableDashboard{storableDashboard}))
|
||||
return dashboard, nil
|
||||
}
|
||||
|
||||
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypesv2.Dashboard, error) {
|
||||
storable, public, err := module.store.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := module.tagModule.ListForEntity(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dashboardtypesv2.NewDashboardFromStorable(storable, public, tags)
|
||||
}
|
||||
|
||||
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypesv2.UpdateableDashboard) (*dashboardtypesv2.Dashboard, error) {
|
||||
if err := updateable.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing, err := module.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// safety check before upserting tags. existing.Update also has this checks, but
|
||||
// because existing.Update needs the resolved tags, that method can only be called
|
||||
// after the tags have been resolved.
|
||||
if err := existing.CanUpdate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Tag upserts run outside the update transaction for the same reason as
|
||||
// Create: a successful upsert that loses the outer transaction just leaves
|
||||
// resolved tag rows around for the next attempt.
|
||||
resolvedTags, err := module.tagModule.CreateMany(ctx, orgID, updateable.Tags, updatedBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagIDs := make([]valuer.UUID, len(resolvedTags))
|
||||
for i, t := range resolvedTags {
|
||||
tagIDs[i] = t.ID
|
||||
}
|
||||
|
||||
if err := existing.Update(updateable, updatedBy, resolvedTags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storable, err := existing.ToStorableDashboard()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
|
||||
if err := module.tagModule.SyncLinksForEntity(ctx, orgID, dashboardtypes.EntityTypeDashboard, id, tagIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
return module.store.UpdateV2(ctx, orgID, id, updatedBy, storable.Data)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// CreatePublicV2 is not supported in the community build.
|
||||
func (module *module) CreatePublicV2(_ context.Context, _ valuer.UUID, _ valuer.UUID, _ dashboardtypes.PostablePublicDashboard) (*dashboardtypesv2.Dashboard, error) {
|
||||
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
// UpdatePublicV2 is not supported in the community build.
|
||||
func (module *module) UpdatePublicV2(_ context.Context, _ valuer.UUID, _ valuer.UUID, _ dashboardtypes.UpdatablePublicDashboard) (*dashboardtypesv2.Dashboard, error) {
|
||||
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, deletedBy string) error {
|
||||
existing, err := module.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := existing.CanDelete(); err != nil {
|
||||
return err
|
||||
}
|
||||
return module.store.SoftDeleteV2(ctx, orgID, id, deletedBy)
|
||||
}
|
||||
|
||||
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
|
||||
existing, err := module.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := existing.LockUnlock(lock, isAdmin, updatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
return module.store.LockUnlockV2(ctx, orgID, id, lock, updatedBy)
|
||||
}
|
||||
|
||||
@@ -40,3 +40,14 @@ func (m *module) LinkToEntity(ctx context.Context, orgID valuer.UUID, entityType
|
||||
}
|
||||
return m.store.CreateRelations(ctx, tagtypes.NewTagRelations(orgID, entityType, entityID, tagIDs))
|
||||
}
|
||||
|
||||
func (m *module) SyncLinksForEntity(ctx context.Context, orgID valuer.UUID, entityType tagtypes.EntityType, entityID valuer.UUID, tagIDs []valuer.UUID) error {
|
||||
if err := m.store.CreateRelations(ctx, tagtypes.NewTagRelations(orgID, entityType, entityID, tagIDs)); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.store.DeleteRelationsExcept(ctx, entityID, tagIDs)
|
||||
}
|
||||
|
||||
func (m *module) ListForEntity(ctx context.Context, entityID valuer.UUID) ([]*tagtypes.Tag, error) {
|
||||
return m.store.ListByEntity(ctx, entityID)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
@@ -30,6 +31,21 @@ func (s *store) List(ctx context.Context, orgID valuer.UUID) ([]*tagtypes.Tag, e
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (s *store) ListByEntity(ctx context.Context, entityID valuer.UUID) ([]*tagtypes.Tag, error) {
|
||||
tags := make([]*tagtypes.Tag, 0)
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&tags).
|
||||
Join("JOIN tag_relations AS tr ON tr.tag_id = tag.id").
|
||||
Where("tr.entity_id = ?", entityID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (s *store) Create(ctx context.Context, tags []*tagtypes.Tag) ([]*tagtypes.Tag, error) {
|
||||
if len(tags) == 0 {
|
||||
return tags, nil
|
||||
@@ -64,3 +80,16 @@ func (s *store) CreateRelations(ctx context.Context, relations []*tagtypes.TagRe
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *store) DeleteRelationsExcept(ctx context.Context, entityID valuer.UUID, keepTagIDs []valuer.UUID) error {
|
||||
q := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*tagtypes.TagRelation)(nil)).
|
||||
Where("entity_id = ?", entityID)
|
||||
if len(keepTagIDs) > 0 {
|
||||
q = q.Where("tag_id NOT IN (?)", bun.In(keepTagIDs))
|
||||
}
|
||||
_, err := q.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -20,4 +20,9 @@ type Module interface {
|
||||
// are left untouched. Uses the caller's transaction context if any so that
|
||||
// it can be made atomic with the entity row insert.
|
||||
LinkToEntity(ctx context.Context, orgID valuer.UUID, entityType tagtypes.EntityType, entityID valuer.UUID, tagIDs []valuer.UUID) error
|
||||
|
||||
// missing links are inserted, obsolete ones removed.
|
||||
SyncLinksForEntity(ctx context.Context, orgID valuer.UUID, entityType tagtypes.EntityType, entityID valuer.UUID, tagIDs []valuer.UUID) error
|
||||
|
||||
ListForEntity(ctx context.Context, entityID valuer.UUID) ([]*tagtypes.Tag, error)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/identn"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/dashboardpurger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
@@ -143,6 +144,9 @@ type Config struct {
|
||||
|
||||
// Authz config
|
||||
Authz authz.Config `mapstructure:"authz"`
|
||||
|
||||
// DashboardPurger config (hard-deletes soft-deleted dashboards after a TTL).
|
||||
DashboardPurger dashboardpurger.Config `mapstructure:"dashboardpurger"`
|
||||
}
|
||||
|
||||
func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.ResolverConfig) (Config, error) {
|
||||
@@ -168,6 +172,7 @@ func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.R
|
||||
statsreporter.NewConfigFactory(),
|
||||
gateway.NewConfigFactory(),
|
||||
tokenizer.NewConfigFactory(),
|
||||
dashboardpurger.NewConfigFactory(),
|
||||
metricsexplorer.NewConfigFactory(),
|
||||
inframonitoring.NewConfigFactory(),
|
||||
flagger.NewConfigFactory(),
|
||||
|
||||
@@ -196,6 +196,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewDropUserDeletedAtFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateAWSAllRegionsFactory(sqlstore),
|
||||
sqlmigration.NewAddTagsFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddDashboardSoftDeleteFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/dashboardpurger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
@@ -495,6 +497,12 @@ func New(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize dashboard purger — periodic hard-delete of soft-deleted rows.
|
||||
dashboardPurger, err := dashboardpurger.NewFactory(impldashboard.NewStore(sqlstore)).New(ctx, providerSettings, config.DashboardPurger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
registry, err := factory.NewRegistry(
|
||||
ctx,
|
||||
instrumentation.Logger(),
|
||||
@@ -509,6 +517,7 @@ func New(
|
||||
factory.NewNamedService(factory.MustNewName("user"), userService, factory.MustNewName("authz")),
|
||||
factory.NewNamedService(factory.MustNewName("auditor"), auditor),
|
||||
factory.NewNamedService(factory.MustNewName("ruler"), rulerInstance),
|
||||
factory.NewNamedService(factory.MustNewName("dashboardpurger"), dashboardPurger),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
59
pkg/sqlmigration/079_add_dashboard_soft_delete.go
Normal file
59
pkg/sqlmigration/079_add_dashboard_soft_delete.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addDashboardSoftDelete struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
func NewAddDashboardSoftDeleteFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_dashboard_soft_delete"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addDashboardSoftDelete{
|
||||
sqlstore: sqlstore,
|
||||
sqlschema: sqlschema,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addDashboardSoftDelete) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addDashboardSoftDelete) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
dashboardTable := &sqlschema.Table{Name: "dashboard"}
|
||||
sqls := [][]byte{}
|
||||
sqls = append(sqls, migration.sqlschema.Operator().AddColumn(
|
||||
dashboardTable, nil,
|
||||
&sqlschema.Column{Name: "deleted_at", DataType: sqlschema.DataTypeTimestamp, Nullable: true}, nil,
|
||||
)...)
|
||||
sqls = append(sqls, migration.sqlschema.Operator().AddColumn(
|
||||
dashboardTable, nil,
|
||||
&sqlschema.Column{Name: "deleted_by", DataType: sqlschema.DataTypeText, Nullable: true}, nil,
|
||||
)...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addDashboardSoftDelete) Down(_ context.Context, _ *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -11,3 +11,8 @@ type UserAuditable struct {
|
||||
CreatedBy string `bun:"created_by,type:text" json:"createdBy"`
|
||||
UpdatedBy string `bun:"updated_by,type:text" json:"updatedBy"`
|
||||
}
|
||||
|
||||
type DeleteAuditable struct {
|
||||
DeletedBy string `bun:"deleted_by,type:text,nullzero" json:"deletedBy,omitempty"`
|
||||
DeletedAt time.Time `bun:"deleted_at,nullzero" json:"deletedAt,omitzero"`
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ type StorableDashboard struct {
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
types.DeleteAuditable
|
||||
Data StorableDashboardData `bun:"data,type:text,notnull"`
|
||||
Locked bool `bun:"locked,notnull,default:false"`
|
||||
OrgID valuer.UUID `bun:"org_id,notnull"`
|
||||
|
||||
@@ -51,6 +51,10 @@ type PostableDashboard struct {
|
||||
Tags []tagtypes.PostableTag `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateableDashboard is the request shape for PUT /api/v2/dashboards/{id}.
|
||||
// Identical to PostableDashboard today; aliased so the surface reads cleanly.
|
||||
type UpdateableDashboard = PostableDashboard
|
||||
|
||||
func (p *PostableDashboard) UnmarshalJSON(data []byte) error {
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
@@ -129,6 +133,87 @@ func NewDashboard(orgID valuer.UUID, createdBy string, postable PostableDashboar
|
||||
}
|
||||
}
|
||||
|
||||
// rejects rows that don't carry a v2-shape blob — those are pre-migration v1 dashboards that the v2 API can't render.
|
||||
func NewDashboardFromStorable(storable *dashboardtypes.StorableDashboard, public *dashboardtypes.StorablePublicDashboard, tags []*tagtypes.Tag) (*Dashboard, error) {
|
||||
metadata, _ := storable.Data["metadata"].(map[string]any)
|
||||
if metadata == nil || metadata["schemaVersion"] != SchemaVersion {
|
||||
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodeDashboardInvalidData, "dashboard %s is not in %s schema", storable.ID, SchemaVersion)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(storable.Data)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal stored v2 dashboard data")
|
||||
}
|
||||
var stored StoredDashboardInfo
|
||||
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal stored v2 dashboard data")
|
||||
}
|
||||
|
||||
var publicConfig *dashboardtypes.PublicDashboard
|
||||
if public != nil {
|
||||
publicConfig = dashboardtypes.NewPublicDashboardFromStorablePublicDashboard(public)
|
||||
}
|
||||
|
||||
return &Dashboard{
|
||||
Identifiable: storable.Identifiable,
|
||||
TimeAuditable: storable.TimeAuditable,
|
||||
UserAuditable: storable.UserAuditable,
|
||||
OrgID: storable.OrgID,
|
||||
Locked: storable.Locked,
|
||||
Info: DashboardInfo{
|
||||
StoredDashboardInfo: stored,
|
||||
Tags: tags,
|
||||
},
|
||||
PublicConfig: publicConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Dashboard) CanLockUnlock(lock bool, isAdmin bool, updatedBy string) error {
|
||||
if d.CreatedBy != updatedBy && !isAdmin {
|
||||
return errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "you are not authorized to lock/unlock this dashboard")
|
||||
}
|
||||
if d.Locked == lock {
|
||||
if lock {
|
||||
return errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "dashboard is already locked")
|
||||
}
|
||||
return errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "dashboard is already unlocked")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Dashboard) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
|
||||
if err := d.CanLockUnlock(lock, isAdmin, updatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
d.Locked = lock
|
||||
d.UpdatedBy = updatedBy
|
||||
d.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Dashboard) CanUpdate() error {
|
||||
if d.Locked {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Dashboard) CanDelete() error {
|
||||
return d.CanUpdate()
|
||||
}
|
||||
|
||||
func (d *Dashboard) Update(updateable UpdateableDashboard, updatedBy string, resolvedTags []*tagtypes.Tag) error {
|
||||
if err := d.CanUpdate(); err != nil {
|
||||
return err
|
||||
}
|
||||
d.Info.Metadata = updateable.Metadata
|
||||
d.Info.Data = updateable.Data
|
||||
d.Info.Tags = resolvedTags
|
||||
d.UpdatedBy = updatedBy
|
||||
d.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToStorableDashboard packages a Dashboard into the bun row that goes into
|
||||
// the dashboard table. Tags are intentionally omitted — they live in
|
||||
// tag_relations and are inserted separately by the caller.
|
||||
|
||||
@@ -2,6 +2,7 @@ package dashboardtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -32,4 +33,23 @@ type Store interface {
|
||||
DeletePublic(context.Context, string) error
|
||||
|
||||
RunInTx(context.Context, func(context.Context) error) error
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// v2 dashboard methods
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
GetV2(context.Context, valuer.UUID, valuer.UUID) (*StorableDashboard, *StorablePublicDashboard, error)
|
||||
|
||||
// UpdateV2 updates the dashboard's data, updated_at and updated_by columns
|
||||
// only, scoped by org and excluding soft-deleted rows. Uses the caller's
|
||||
// transaction context so it can be made atomic with tag relation changes.
|
||||
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, data StorableDashboardData) error
|
||||
|
||||
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, locked bool, updatedBy string) error
|
||||
|
||||
//re-deleting a soft-deleted row returns 0 rows → NotFound.
|
||||
SoftDeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, deletedBy string) error
|
||||
|
||||
ListPurgeable(ctx context.Context, retention time.Duration, limit int) ([]valuer.UUID, error)
|
||||
|
||||
HardDelete(ctx context.Context, ids []valuer.UUID) error
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
type Store interface {
|
||||
List(ctx context.Context, orgID valuer.UUID) ([]*Tag, error)
|
||||
|
||||
ListByEntity(ctx context.Context, entityID valuer.UUID) ([]*Tag, error)
|
||||
|
||||
// Create upserts the given tags and returns them with authoritative IDs.
|
||||
// On conflict on (org_id, internal_name) — which happens only when a
|
||||
// concurrent insert raced ours — the returned entry carries the existing
|
||||
@@ -17,4 +19,6 @@ type Store interface {
|
||||
|
||||
// CreateRelations inserts tag-entity relations. Conflicts on the composite primary key are ignored.
|
||||
CreateRelations(ctx context.Context, relations []*TagRelation) error
|
||||
|
||||
DeleteRelationsExcept(ctx context.Context, entityID valuer.UUID, keepTagIDs []valuer.UUID) error
|
||||
}
|
||||
|
||||
@@ -162,6 +162,14 @@ func (f *fakeStore) CreateRelations(_ context.Context, _ []*TagRelation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListByEntity(_ context.Context, _ valuer.UUID) ([]*Tag, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteRelationsExcept(_ context.Context, _ valuer.UUID, _ []valuer.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResolve(t *testing.T) {
|
||||
t.Run("empty input does not hit store", func(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
|
||||
Reference in New Issue
Block a user