Compare commits

...

4 Commits

Author SHA1 Message Date
Naman Verma
ca96c71146 feat: delete dashboard v2 API and hard delete cron job 2026-05-03 15:01:43 +05:30
Naman Verma
de2909d1d1 feat: lock, unlock, create public, update public v2 dashboard APIs 2026-05-03 14:38:24 +05:30
Naman Verma
f311fcabf7 feat: v2 dashboard update API 2026-04-29 18:39:48 +05:30
Naman Verma
a37c07f881 feat: v2 dashboard GET API 2026-04-29 15:12:47 +05:30
21 changed files with 1118 additions and 0 deletions

View File

@@ -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)
}

View File

@@ -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"},

View File

@@ -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)
}

View 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
}

View 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
}

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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(),

View File

@@ -196,6 +196,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewDropUserDeletedAtFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateAWSAllRegionsFactory(sqlstore),
sqlmigration.NewAddTagsFactory(sqlstore, sqlschema),
sqlmigration.NewAddDashboardSoftDeleteFactory(sqlstore, sqlschema),
)
}

View File

@@ -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

View 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
}

View File

@@ -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"`
}

View File

@@ -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"`

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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{}