mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-15 09:20:34 +01:00
## Summary
- Handle malformed selectedFields in the extradata in the migration and
new migration to fix in the already migrated cases.
- Restructure saved-view create/update/get payloads so
`schemaVersion`/`spec` are top-level (unwrapping the old `data`
nesting), matching how dashboards and rules shape their wire types.
- Publish `schemaVersion` as an `enum: [v2]`
- Make `display` and `selectedFields` optional in the OpenAPI schema
- Declare `409` on `CreateSavedView`
- Require `minItems: 1` on `queries`
New API contract in [below
comment](https://github.com/SigNoz/signoz/pull/12477#issuecomment-5230041074),
follow up on https://github.com/SigNoz/signoz/pull/12342
Closes https://github.com/SigNoz/engineering-pod/issues/4651
Notes to reviewer:
- Please pay attention to the last case in above linked comment for
partial display field updates.
- Still assuming that [migration
046](6372af75a6/pkg/sqlmigration/046_update_dashboard_alert_and_saved_view_v5.go (L233))
has already migrated all the views to v5 QB format and don't need to do
that now.
- Breaking change: queries are not validated in the v1 APIs as well, so
any incorrect query will be rejected
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
306 lines
8.9 KiB
Go
306 lines
8.9 KiB
Go
package implsavedview
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/SigNoz/signoz/pkg/errors"
|
|
"github.com/SigNoz/signoz/pkg/http/render"
|
|
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
|
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
|
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
|
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
|
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
|
"github.com/SigNoz/signoz/pkg/valuer"
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
type handler struct {
|
|
module savedview.Module
|
|
}
|
|
|
|
func NewHandler(module savedview.Module) savedview.Handler {
|
|
return &handler{module: module}
|
|
}
|
|
|
|
// legacyExtraData mirrors the frontend's extraData JSON shape so /api/v1
|
|
// responses can synthesize the same shape back for the legacy frontend.
|
|
type legacyExtraData struct {
|
|
Color string `json:"color,omitempty"`
|
|
SelectColumns []telemetrytypes.TelemetryFieldKey `json:"selectColumns,omitempty"`
|
|
Format string `json:"format,omitempty"`
|
|
MaxLines int `json:"maxLines,omitempty"`
|
|
FontSize string `json:"fontSize,omitempty"`
|
|
}
|
|
|
|
// newPostableSavedViewFromLegacyView builds a create payload for a v1 request.
|
|
func newPostableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.PostableSavedView {
|
|
var legacy legacyExtraData
|
|
if v.ExtraData != "" {
|
|
// Best-effort: malformed/older extraData shapes never fail the request.
|
|
_ = json.Unmarshal([]byte(v.ExtraData), &legacy)
|
|
}
|
|
|
|
panelType := savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))}
|
|
|
|
return savedviewtypes.PostableSavedView{
|
|
GenerateName: true,
|
|
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
|
|
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
|
Spec: savedviewtypes.SavedViewSpec{
|
|
DisplayName: v.Name,
|
|
PanelType: panelType,
|
|
// v1 has no requestType concept of its own -- fall back to the panelType-derived guess.
|
|
RequestType: savedviewtypes.LegacyRequestTypeForPanelType(panelType),
|
|
Queries: v.CompositeQuery.Queries,
|
|
SelectedFields: legacy.SelectColumns,
|
|
Display: savedviewtypes.Display{
|
|
MaxLines: legacy.MaxLines,
|
|
FontSize: legacy.FontSize,
|
|
Format: legacy.Format,
|
|
Color: legacy.Color,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// newUpdatableSavedViewFromLegacyView builds an update payload for a v1 request.
|
|
func newUpdatableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.UpdatableSavedView {
|
|
var legacy legacyExtraData
|
|
if v.ExtraData != "" {
|
|
// Best-effort: malformed/older extraData shapes never fail the request.
|
|
_ = json.Unmarshal([]byte(v.ExtraData), &legacy)
|
|
}
|
|
|
|
panelType := savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))}
|
|
|
|
return savedviewtypes.UpdatableSavedView{
|
|
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
|
|
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
|
Spec: savedviewtypes.SavedViewSpec{
|
|
DisplayName: v.Name,
|
|
PanelType: panelType,
|
|
// v1 has no requestType concept of its own -- fall back to the panelType-derived guess.
|
|
RequestType: savedviewtypes.LegacyRequestTypeForPanelType(panelType),
|
|
Queries: v.CompositeQuery.Queries,
|
|
SelectedFields: legacy.SelectColumns,
|
|
Display: savedviewtypes.Display{
|
|
MaxLines: legacy.MaxLines,
|
|
FontSize: legacy.FontSize,
|
|
Format: legacy.Format,
|
|
Color: legacy.Color,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// newLegacyViewFromSavedView renders a v2 SavedView back into the v1 shape.
|
|
func newLegacyViewFromSavedView(v *savedviewtypes.SavedView) (*v3.SavedView, error) {
|
|
extraData, err := json.Marshal(legacyExtraData{
|
|
Color: v.Spec.Display.Color,
|
|
SelectColumns: v.Spec.SelectedFields,
|
|
Format: v.Spec.Display.Format,
|
|
MaxLines: v.Spec.Display.MaxLines,
|
|
FontSize: v.Spec.Display.FontSize,
|
|
})
|
|
if err != nil {
|
|
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling extra data")
|
|
}
|
|
|
|
return &v3.SavedView{
|
|
ID: v.ID,
|
|
Name: v.Spec.DisplayName,
|
|
CreatedAt: v.CreatedAt,
|
|
CreatedBy: v.CreatedBy,
|
|
UpdatedAt: v.UpdatedAt,
|
|
UpdatedBy: v.UpdatedBy,
|
|
SourcePage: v.Source.StringValue(),
|
|
CompositeQuery: &v3.CompositeQuery{
|
|
PanelType: v3.PanelType(v.Spec.PanelType.StringValue()),
|
|
// Saved views are only ever created from the explorer's builder mode.
|
|
QueryType: v3.QueryTypeBuilder,
|
|
Queries: v.Spec.Queries,
|
|
},
|
|
ExtraData: string(extraData),
|
|
}, nil
|
|
}
|
|
|
|
func newLegacyViewsFromSavedViews(views []*savedviewtypes.SavedView) ([]*v3.SavedView, error) {
|
|
out := make([]*v3.SavedView, 0, len(views))
|
|
for _, view := range views {
|
|
legacyView, err := newLegacyViewFromSavedView(view)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, legacyView)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (handler *handler) Create(w 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(w, err)
|
|
return
|
|
}
|
|
|
|
var view v3.SavedView
|
|
if err := json.NewDecoder(r.Body).Decode(&view); err != nil {
|
|
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode request body"))
|
|
return
|
|
}
|
|
// validate the query
|
|
if err := view.Validate(); err != nil {
|
|
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to validate request body"))
|
|
return
|
|
}
|
|
|
|
postable := newPostableSavedViewFromLegacyView(&view)
|
|
|
|
if err := postable.Validate(); err != nil {
|
|
render.Error(w, err)
|
|
return
|
|
}
|
|
|
|
uuid, err := handler.module.CreateView(ctx, claims.OrgID, postable)
|
|
if err != nil {
|
|
render.Error(w, err)
|
|
return
|
|
}
|
|
|
|
render.Success(w, http.StatusOK, uuid)
|
|
}
|
|
|
|
func (handler *handler) Get(w 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(w, err)
|
|
return
|
|
}
|
|
|
|
viewID := mux.Vars(r)["id"]
|
|
viewUUID, err := valuer.NewUUID(viewID)
|
|
if err != nil {
|
|
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse view id"))
|
|
return
|
|
}
|
|
|
|
view, err := handler.module.GetView(ctx, claims.OrgID, viewUUID)
|
|
if err != nil {
|
|
render.Error(w, err)
|
|
return
|
|
}
|
|
|
|
legacyView, err := newLegacyViewFromSavedView(view)
|
|
if err != nil {
|
|
render.Error(w, err)
|
|
return
|
|
}
|
|
|
|
render.Success(w, http.StatusOK, legacyView)
|
|
}
|
|
|
|
func (handler *handler) Update(w 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(w, err)
|
|
return
|
|
}
|
|
|
|
viewID := mux.Vars(r)["id"]
|
|
viewUUID, err := valuer.NewUUID(viewID)
|
|
if err != nil {
|
|
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse view id"))
|
|
return
|
|
}
|
|
var view v3.SavedView
|
|
if err := json.NewDecoder(r.Body).Decode(&view); err != nil {
|
|
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode request body"))
|
|
return
|
|
}
|
|
// validate the query
|
|
if err := view.Validate(); err != nil {
|
|
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to validate request body"))
|
|
return
|
|
}
|
|
|
|
updatable := newUpdatableSavedViewFromLegacyView(&view)
|
|
|
|
if err := updatable.Validate(); err != nil {
|
|
render.Error(w, err)
|
|
return
|
|
}
|
|
|
|
if err := handler.module.UpdateView(ctx, claims.OrgID, viewUUID, updatable); err != nil {
|
|
render.Error(w, err)
|
|
return
|
|
}
|
|
|
|
render.Success(w, http.StatusOK, view)
|
|
}
|
|
|
|
func (handler *handler) Delete(w 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(w, err)
|
|
return
|
|
}
|
|
|
|
viewID := mux.Vars(r)["id"]
|
|
viewUUID, err := valuer.NewUUID(viewID)
|
|
if err != nil {
|
|
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse view id"))
|
|
return
|
|
}
|
|
|
|
err = handler.module.DeleteView(ctx, claims.OrgID, viewUUID)
|
|
if err != nil {
|
|
render.Error(w, err)
|
|
return
|
|
}
|
|
|
|
render.Success(w, http.StatusNoContent, nil)
|
|
}
|
|
|
|
func (handler *handler) List(w 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(w, err)
|
|
return
|
|
}
|
|
|
|
sourcePage := r.URL.Query().Get("sourcePage")
|
|
name := r.URL.Query().Get("name")
|
|
|
|
views, err := handler.module.GetViewsForFilters(r.Context(), claims.OrgID, savedviewtypes.Source{String: valuer.NewString(sourcePage)}, name)
|
|
if err != nil {
|
|
render.Error(w, err)
|
|
return
|
|
}
|
|
|
|
legacyViews, err := newLegacyViewsFromSavedViews(views)
|
|
if err != nil {
|
|
render.Error(w, err)
|
|
return
|
|
}
|
|
|
|
render.Success(w, http.StatusOK, legacyViews)
|
|
}
|