diff --git a/pkg/apis/featuretoggle/v0alpha1/types.go b/pkg/apis/featuretoggle/v0alpha1/types.go index 4fa25c5135d..e10c7f2b080 100644 --- a/pkg/apis/featuretoggle/v0alpha1/types.go +++ b/pkg/apis/featuretoggle/v0alpha1/types.go @@ -98,6 +98,9 @@ type ToggleStatus struct { // The flag description Description string `json:"description,omitempty"` + // The feature toggle stage + Stage string `json:"stage"` + // Is the flag enabled Enabled bool `json:"enabled"` diff --git a/pkg/registry/apis/featuretoggle/README.md b/pkg/registry/apis/featuretoggle/README.md new file mode 100644 index 00000000000..7267e2e2819 --- /dev/null +++ b/pkg/registry/apis/featuretoggle/README.md @@ -0,0 +1,5 @@ +This package supports the [Feature toggle admin page](https://grafana.com/docs/grafana/latest/administration/feature-toggles/) feature. + +In order to update feature toggles through the app, the PATCH handler calls a webhook that should update Grafana's configuration and restarts the instance. + +For local development, set the app mode to `development` by adding `app_mode = development` to the top level of your Grafana .ini file. \ No newline at end of file diff --git a/pkg/registry/apis/featuretoggle/current.go b/pkg/registry/apis/featuretoggle/current.go index 7bc5661c75b..aa8204fc4aa 100644 --- a/pkg/registry/apis/featuretoggle/current.go +++ b/pkg/registry/apis/featuretoggle/current.go @@ -51,6 +51,7 @@ func (b *FeatureFlagAPIBuilder) getResolvedToggleState(ctx context.Context) v0al toggle := v0alpha1.ToggleStatus{ Name: name, Description: f.Description, // simplify the UI changes + Stage: f.Stage.String(), Enabled: state.Enabled[name], Writeable: b.features.IsEditableFromAdminPage(name), Source: startupRef, @@ -76,6 +77,17 @@ func (b *FeatureFlagAPIBuilder) getResolvedToggleState(ctx context.Context) v0al return state } +func (b *FeatureFlagAPIBuilder) userCanRead(ctx context.Context, u *user.SignedInUser) bool { + if u == nil { + u, _ = appcontext.User(ctx) + if u == nil { + return false + } + } + ok, err := b.accessControl.Evaluate(ctx, u, ac.EvalPermission(ac.ActionFeatureManagementRead)) + return ok && err == nil +} + func (b *FeatureFlagAPIBuilder) userCanWrite(ctx context.Context, u *user.SignedInUser) bool { if u == nil { u, _ = appcontext.User(ctx) @@ -93,7 +105,24 @@ func (b *FeatureFlagAPIBuilder) handleCurrentStatus(w http.ResponseWriter, r *ht return } + // Check if the user can access toggle info + ctx := r.Context() + user, err := appcontext.User(ctx) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + if !b.userCanRead(ctx, user) { + err = errutil.Unauthorized("featuretoggle.canNotRead", + errutil.WithPublicMessage("missing read permission")).Errorf("user %s does not have read permissions", user.Login) + errhttp.Write(ctx, err, w) + return + } + + // Write the state to the response body state := b.getResolvedToggleState(r.Context()) + w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(state) } @@ -101,7 +130,9 @@ func (b *FeatureFlagAPIBuilder) handleCurrentStatus(w http.ResponseWriter, r *ht func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !b.features.IsFeatureEditingAllowed() { - errhttp.Write(ctx, fmt.Errorf("feature editing is not enabled"), w) + err := errutil.Forbidden("featuretoggle.disabled", + errutil.WithPublicMessage("feature toggles are read-only")).Errorf("feature toggles are not writeable due to missing configuration") + errhttp.Write(ctx, err, w) return } @@ -113,7 +144,7 @@ func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *htt if !b.userCanWrite(ctx, user) { err = errutil.Unauthorized("featuretoggle.canNotWrite", - errutil.WithPublicMessage("missing write permission")) + errutil.WithPublicMessage("missing write permission")).Errorf("user %s does not have write permissions", user.Login) errhttp.Write(ctx, err, w) return } @@ -127,7 +158,7 @@ func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *htt if len(request.Toggles) > 0 { err = errutil.BadRequest("featuretoggle.badRequest", - errutil.WithPublicMessage("can only path the enabled section")) + errutil.WithPublicMessage("can only patch the enabled section")).Errorf("request payload included properties in the read-only Toggles section") errhttp.Write(ctx, err, w) return } @@ -138,7 +169,7 @@ func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *htt if current != v { if !b.features.IsEditableFromAdminPage(k) { err = errutil.BadRequest("featuretoggle.badRequest", - errutil.WithPublicMessage("can not edit toggle: "+k)) + errutil.WithPublicMessage("invalid toggle passed in")).Errorf("can not edit toggle %s", k) errhttp.Write(ctx, err, w) w.WriteHeader(http.StatusBadRequest) return @@ -158,7 +189,8 @@ func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *htt } err = sendWebhookUpdate(b.features.Settings, payload) - if err != nil { + if err != nil && b.cfg.Env != setting.Dev { + err = errutil.Internal("featuretoggle.webhookFailure", errutil.WithPublicMessage("an error occurred while updating feeature toggles")).Errorf("webhook error: %w", err) errhttp.Write(ctx, err, w) return } diff --git a/pkg/registry/apis/featuretoggle/current_test.go b/pkg/registry/apis/featuretoggle/current_test.go new file mode 100644 index 00000000000..7b1b24c52bb --- /dev/null +++ b/pkg/registry/apis/featuretoggle/current_test.go @@ -0,0 +1,460 @@ +package featuretoggle + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" +) + +func TestGetFeatureToggles(t *testing.T) { + t.Run("fails without adequate permissions", func(t *testing.T) { + features := featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, []*featuremgmt.FeatureFlag{{ + // Add this here to ensure the feature works as expected during tests + Name: featuremgmt.FlagFeatureToggleAdminPage, + Stage: featuremgmt.FeatureStageGeneralAvailability, + }}) + + b := NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: false}, &setting.Cfg{}) + + callGetWith(t, b, http.StatusUnauthorized) + }) + + t.Run("should be able to get feature toggles", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: "toggle1", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, + } + disabled := []string{"toggle2"} + + b := newTestAPIBuilder(t, features, disabled, setting.FeatureMgmtSettings{}) + result := callGetWith(t, b, http.StatusOK) + assert.Len(t, result.Toggles, 2) + t1, _ := findResult(t, result, "toggle1") + assert.True(t, t1.Enabled) + t2, _ := findResult(t, result, "toggle2") + assert.False(t, t2.Enabled) + }) + + t.Run("toggles hidden by config are not present in the response", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: "toggle1", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, + } + settings := setting.FeatureMgmtSettings{ + HiddenToggles: map[string]struct{}{"toggle1": {}}, + } + + b := newTestAPIBuilder(t, features, []string{}, settings) + result := callGetWith(t, b, http.StatusOK) + + assert.Len(t, result.Toggles, 1) + assert.Equal(t, "toggle2", result.Toggles[0].Name) + }) + + t.Run("toggles that are read-only by config have the readOnly field set", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: "toggle1", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, + } + disabled := []string{"toggle2"} + settings := setting.FeatureMgmtSettings{ + HiddenToggles: map[string]struct{}{"toggle1": {}}, + ReadOnlyToggles: map[string]struct{}{"toggle2": {}}, + AllowEditing: true, + UpdateWebhook: "bogus", + } + + b := newTestAPIBuilder(t, features, disabled, settings) + result := callGetWith(t, b, http.StatusOK) + + assert.Len(t, result.Toggles, 1) + assert.Equal(t, "toggle2", result.Toggles[0].Name) + assert.False(t, result.Toggles[0].Writeable) + }) + + t.Run("feature toggle defailts", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: "toggle1", + Stage: featuremgmt.FeatureStageUnknown, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStageExperimental, + }, { + Name: "toggle3", + Stage: featuremgmt.FeatureStagePrivatePreview, + }, { + Name: "toggle4", + Stage: featuremgmt.FeatureStagePublicPreview, + AllowSelfServe: true, + }, { + Name: "toggle5", + Stage: featuremgmt.FeatureStageGeneralAvailability, + AllowSelfServe: true, + }, { + Name: "toggle6", + Stage: featuremgmt.FeatureStageDeprecated, + AllowSelfServe: true, + }, { + Name: "toggle7", + Stage: featuremgmt.FeatureStageGeneralAvailability, + AllowSelfServe: false, + }, + } + + t.Run("unknown, experimental, and private preview toggles are hidden by default", func(t *testing.T) { + b := newTestAPIBuilder(t, features, []string{}, setting.FeatureMgmtSettings{}) + result := callGetWith(t, b, http.StatusOK) + + assert.Len(t, result.Toggles, 4) + + _, ok := findResult(t, result, "toggle1") + assert.False(t, ok) + _, ok = findResult(t, result, "toggle2") + assert.False(t, ok) + _, ok = findResult(t, result, "toggle3") + assert.False(t, ok) + }) + + t.Run("only public preview and GA with AllowSelfServe are writeable", func(t *testing.T) { + settings := setting.FeatureMgmtSettings{ + AllowEditing: true, + UpdateWebhook: "bogus", + } + + b := newTestAPIBuilder(t, features, []string{}, settings) + result := callGetWith(t, b, http.StatusOK) + + t4, ok := findResult(t, result, "toggle4") + assert.True(t, ok) + assert.True(t, t4.Writeable) + t5, ok := findResult(t, result, "toggle5") + assert.True(t, ok) + assert.True(t, t5.Writeable) + t6, ok := findResult(t, result, "toggle6") + assert.True(t, ok) + assert.True(t, t6.Writeable) + }) + + t.Run("all toggles are read-only when server is misconfigured", func(t *testing.T) { + settings := setting.FeatureMgmtSettings{ + AllowEditing: false, + UpdateWebhook: "", + } + b := newTestAPIBuilder(t, features, []string{}, settings) + result := callGetWith(t, b, http.StatusOK) + + assert.Len(t, result.Toggles, 4) + + t4, ok := findResult(t, result, "toggle4") + assert.True(t, ok) + assert.False(t, t4.Writeable) + t5, ok := findResult(t, result, "toggle5") + assert.True(t, ok) + assert.False(t, t5.Writeable) + t6, ok := findResult(t, result, "toggle6") + assert.True(t, ok) + assert.False(t, t6.Writeable) + }) + }) +} + +func TestSetFeatureToggles(t *testing.T) { + t.Run("fails when the user doesn't have write permissions", func(t *testing.T) { + s := setting.FeatureMgmtSettings{ + AllowEditing: true, + UpdateWebhook: "random", + } + features := featuremgmt.WithFeatureManager(s, []*featuremgmt.FeatureFlag{{ + // Add this here to ensure the feature works as expected during tests + Name: featuremgmt.FlagFeatureToggleAdminPage, + Stage: featuremgmt.FeatureStageGeneralAvailability, + }}) + + b := NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: false}, &setting.Cfg{}) + msg := callPatchWith(t, b, v0alpha1.ResolvedToggleState{}, http.StatusUnauthorized) + assert.Equal(t, "missing write permission", msg) + }) + + t.Run("fails when update toggle url is not set", func(t *testing.T) { + s := setting.FeatureMgmtSettings{ + AllowEditing: true, + } + b := newTestAPIBuilder(t, nil, []string{}, s) + msg := callPatchWith(t, b, v0alpha1.ResolvedToggleState{}, http.StatusForbidden) + assert.Equal(t, "feature toggles are read-only", msg) + }) + + t.Run("fails with non-existent toggle", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: "toggle1", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, + } + disabled := []string{"toggle2"} + update := v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + "toggle3": true, + }, + } + + s := setting.FeatureMgmtSettings{ + AllowEditing: true, + UpdateWebhook: "random", + } + b := newTestAPIBuilder(t, features, disabled, s) + msg := callPatchWith(t, b, update, http.StatusBadRequest) + assert.Equal(t, "invalid toggle passed in", msg) + }) + + t.Run("fails with read-only toggles", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: featuremgmt.FlagFeatureToggleAdminPage, + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStagePublicPreview, + }, { + Name: "toggle3", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, + } + disabled := []string{"toggle2", "toggle3"} + + s := setting.FeatureMgmtSettings{ + AllowEditing: true, + UpdateWebhook: "random", + ReadOnlyToggles: map[string]struct{}{ + "toggle3": {}, + }, + } + + t.Run("because it is the feature toggle admin page toggle", func(t *testing.T) { + update := v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + featuremgmt.FlagFeatureToggleAdminPage: true, + }, + } + b := newTestAPIBuilder(t, features, disabled, s) + callPatchWith(t, b, update, http.StatusNotModified) + }) + + t.Run("because it is not GA or Deprecated", func(t *testing.T) { + update := v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + "toggle2": true, + }, + } + b := newTestAPIBuilder(t, features, disabled, s) + msg := callPatchWith(t, b, update, http.StatusBadRequest) + assert.Equal(t, "invalid toggle passed in", msg) + }) + + t.Run("because it is configured to be read-only", func(t *testing.T) { + update := v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + "toggle2": true, + }, + } + b := newTestAPIBuilder(t, features, disabled, s) + msg := callPatchWith(t, b, update, http.StatusBadRequest) + assert.Equal(t, "invalid toggle passed in", msg) + }) + }) + + t.Run("when all conditions met", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: featuremgmt.FlagFeatureToggleAdminPage, + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStagePublicPreview, + }, { + Name: "toggle3", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle4", + Stage: featuremgmt.FeatureStageGeneralAvailability, + AllowSelfServe: true, + }, { + Name: "toggle5", + Stage: featuremgmt.FeatureStageDeprecated, + AllowSelfServe: true, + }, + } + disabled := []string{"toggle2", "toggle3", "toggle4"} + + s := setting.FeatureMgmtSettings{ + AllowEditing: true, + UpdateWebhook: "random", + UpdateWebhookToken: "token", + ReadOnlyToggles: map[string]struct{}{ + "toggle3": {}, + }, + } + + update := v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + "toggle4": true, + "toggle5": false, + }, + } + t.Run("fail when webhook request is not successful", func(t *testing.T) { + webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer webhookServer.Close() + s.UpdateWebhook = webhookServer.URL + + b := newTestAPIBuilder(t, features, disabled, s) + msg := callPatchWith(t, b, update, http.StatusInternalServerError) + assert.Equal(t, "an error occurred while updating feeature toggles", msg) + }) + + t.Run("succeed when webhook request is not successful but app is in dev mode", func(t *testing.T) { + webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer webhookServer.Close() + s.UpdateWebhook = webhookServer.URL + + b := newTestAPIBuilder(t, features, disabled, s) + b.cfg.Env = setting.Dev + callPatchWith(t, b, update, http.StatusOK) + }) + + t.Run("succeed when webhook request is successful", func(t *testing.T) { + webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer "+s.UpdateWebhookToken, r.Header.Get("Authorization")) + + var req featuremgmt.FeatureToggleWebhookPayload + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + + assert.Equal(t, "true", req.FeatureToggles["toggle4"]) + assert.Equal(t, "false", req.FeatureToggles["toggle5"]) + w.WriteHeader(http.StatusOK) + })) + defer webhookServer.Close() + s.UpdateWebhook = webhookServer.URL + + b := newTestAPIBuilder(t, features, disabled, s) + msg := callPatchWith(t, b, update, http.StatusOK) + assert.Equal(t, "feature toggles updated successfully", msg) + }) + }) +} + +func findResult(t *testing.T, result v0alpha1.ResolvedToggleState, name string) (v0alpha1.ToggleStatus, bool) { + t.Helper() + + for _, t := range result.Toggles { + if t.Name == name { + return t, true + } + } + return v0alpha1.ToggleStatus{}, false +} + +func callGetWith(t *testing.T, b *FeatureFlagAPIBuilder, expectedCode int) v0alpha1.ResolvedToggleState { + w := response.CreateNormalResponse(http.Header{}, []byte{}, 0) + req := &http.Request{ + Method: "GET", + Header: http.Header{}, + } + req.Header.Add("content-type", "application/json") + req = req.WithContext(appcontext.WithUser(req.Context(), &user.SignedInUser{})) + b.handleCurrentStatus(w, req) + + rts := v0alpha1.ResolvedToggleState{} + require.NoError(t, json.Unmarshal(w.Body(), &rts)) + require.Equal(t, expectedCode, w.Status()) + + // Tests don't expect the feature toggle admin page feature to be present, so remove them from the resolved toggle state + for i, t := range rts.Toggles { + if t.Name == "featureToggleAdminPage" { + rts.Toggles = append(rts.Toggles[0:i], rts.Toggles[i+1:]...) + } + } + + return rts +} + +func callPatchWith(t *testing.T, b *FeatureFlagAPIBuilder, update v0alpha1.ResolvedToggleState, expectedCode int) string { + w := response.CreateNormalResponse(http.Header{}, []byte{}, 0) + + body, err := json.Marshal(update) + require.NoError(t, err) + + req := &http.Request{ + Method: "PATCH", + Body: io.NopCloser(bytes.NewReader(body)), + Header: http.Header{}, + } + req.Header.Add("content-type", "application/json") + req = req.WithContext(appcontext.WithUser(req.Context(), &user.SignedInUser{})) + b.handleCurrentStatus(w, req) + + require.NotNil(t, w.Body()) + require.Equal(t, expectedCode, w.Status()) + + // Extract the public facing message if this is an error + if w.Status() > 399 { + res := map[string]any{} + require.NoError(t, json.Unmarshal(w.Body(), &res)) + + return res["message"].(string) + } + + return string(w.Body()) +} + +func newTestAPIBuilder( + t *testing.T, + serverFeatures []*featuremgmt.FeatureFlag, + disabled []string, // the flags that are disabled + settings setting.FeatureMgmtSettings, +) *FeatureFlagAPIBuilder { + t.Helper() + features := featuremgmt.WithFeatureManager(settings, append([]*featuremgmt.FeatureFlag{{ + // Add this here to ensure the feature works as expected during tests + Name: featuremgmt.FlagFeatureToggleAdminPage, + Stage: featuremgmt.FeatureStageGeneralAvailability, + }}, serverFeatures...), disabled...) + + return NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: true}, &setting.Cfg{}) +} diff --git a/pkg/registry/apis/featuretoggle/register.go b/pkg/registry/apis/featuretoggle/register.go index f5ae2466c42..f04eb82bbec 100644 --- a/pkg/registry/apis/featuretoggle/register.go +++ b/pkg/registry/apis/featuretoggle/register.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" ) var _ builder.APIGroupBuilder = (*FeatureFlagAPIBuilder)(nil) @@ -27,17 +28,19 @@ var gv = v0alpha1.SchemeGroupVersion type FeatureFlagAPIBuilder struct { features *featuremgmt.FeatureManager accessControl accesscontrol.AccessControl + cfg *setting.Cfg } -func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager, accessControl accesscontrol.AccessControl) *FeatureFlagAPIBuilder { - return &FeatureFlagAPIBuilder{features, accessControl} +func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager, accessControl accesscontrol.AccessControl, cfg *setting.Cfg) *FeatureFlagAPIBuilder { + return &FeatureFlagAPIBuilder{features, accessControl, cfg} } func RegisterAPIService(features *featuremgmt.FeatureManager, accessControl accesscontrol.AccessControl, apiregistration builder.APIRegistrar, + cfg *setting.Cfg, ) *FeatureFlagAPIBuilder { - builder := NewFeatureFlagAPIBuilder(features, accessControl) + builder := NewFeatureFlagAPIBuilder(features, accessControl, cfg) apiregistration.RegisterAPI(builder) return builder } diff --git a/pkg/services/apiserver/standalone/factory.go b/pkg/services/apiserver/standalone/factory.go index 0768109a0d1..eec8ddacb1a 100644 --- a/pkg/services/apiserver/standalone/factory.go +++ b/pkg/services/apiserver/standalone/factory.go @@ -79,6 +79,7 @@ func (p *DummyAPIFactory) MakeAPIServer(gv schema.GroupVersion) (builder.APIGrou return featuretoggle.NewFeatureFlagAPIBuilder( featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, nil), // none... for now &actest.FakeAccessControl{ExpectedEvaluate: false}, + &setting.Cfg{}, ), nil case "testdata.datasource.grafana.app": diff --git a/pkg/services/featuremgmt/manager.go b/pkg/services/featuremgmt/manager.go index 74ea3350412..457436790a5 100644 --- a/pkg/services/featuremgmt/manager.go +++ b/pkg/services/featuremgmt/manager.go @@ -151,6 +151,7 @@ func (fm *FeatureManager) IsEditableFromAdminPage(key string) bool { return false } return flag.Stage == FeatureStageGeneralAvailability || + flag.Stage == FeatureStagePublicPreview || flag.Stage == FeatureStageDeprecated } diff --git a/pkg/services/featuremgmt/models.go b/pkg/services/featuremgmt/models.go index b9c8b9ff7ea..cb22273ec35 100644 --- a/pkg/services/featuremgmt/models.go +++ b/pkg/services/featuremgmt/models.go @@ -119,7 +119,7 @@ type FeatureFlag struct { Owner codeowner `json:"-"` // Owner person or team that owns this feature flag // Recommended properties - control behavior of the feature toggle management page in the UI - AllowSelfServe bool `json:"allowSelfServe,omitempty"` // allow users with the right privileges to toggle this from the UI (GeneralAvailability and Deprecated toggles only) + AllowSelfServe bool `json:"allowSelfServe,omitempty"` // allow users with the right privileges to toggle this from the UI (GeneralAvailability, PublicPreview, and Deprecated toggles only) HideFromAdminPage bool `json:"hideFromAdminPage,omitempty"` // GA, Deprecated, and PublicPreview toggles only: don't display this feature in the UI; if this is a GA toggle, add a comment with the reasoning // CEL-GO expression. Using the value "true" will mean this is on by default diff --git a/public/app/features/admin/AdminFeatureTogglesAPI.ts b/public/app/features/admin/AdminFeatureTogglesAPI.ts index 62f0c8b5f26..6ecba0c8b7d 100644 --- a/public/app/features/admin/AdminFeatureTogglesAPI.ts +++ b/public/app/features/admin/AdminFeatureTogglesAPI.ts @@ -4,6 +4,7 @@ export type FeatureToggle = { name: string; description?: string; enabled: boolean; + stage: string; readOnly?: boolean; hidden?: boolean; }; @@ -28,6 +29,7 @@ interface K8sToggleSpec { enabled: boolean; writeable: boolean; source: K8sToggleSource; + stage: string; } interface K8sToggleSource { @@ -53,6 +55,7 @@ class K8sAPI implements FeatureTogglesAPI { description: t.description!, enabled: t.enabled, readOnly: !Boolean(t.writeable), + stage: t.stage, hidden: false, // only return visible things })), }; diff --git a/public/app/features/admin/AdminFeatureTogglesPage.tsx b/public/app/features/admin/AdminFeatureTogglesPage.tsx index 4d231b644fd..0684405b3ee 100644 --- a/public/app/features/admin/AdminFeatureTogglesPage.tsx +++ b/public/app/features/admin/AdminFeatureTogglesPage.tsx @@ -10,15 +10,13 @@ import { getTogglesAPI } from './AdminFeatureTogglesAPI'; import { AdminFeatureTogglesTable } from './AdminFeatureTogglesTable'; export default function AdminFeatureTogglesPage() { - const [reload] = useState(1); + const [reload, setReload] = useState(1); const togglesApi = getTogglesAPI(); const featureState = useAsync(() => togglesApi.getFeatureToggles(), [reload]); - const [updateSuccessful, setUpdateSuccessful] = useState(false); const styles = useStyles2(getStyles); const handleUpdateSuccess = () => { - setUpdateSuccessful(true); - // setReload(reload+1); << would trigger updating the server state! + setReload(reload + 1); }; const EditingAlert = () => { @@ -28,7 +26,7 @@ export default function AdminFeatureTogglesPage() { - {featureState.value?.restartRequired || updateSuccessful + {featureState.value?.restartRequired ? 'A restart is pending for your Grafana instance to apply the latest feature toggle changes' : 'Saving feature toggle changes will prompt a restart of the instance, which may take a few minutes'} @@ -57,7 +55,7 @@ export default function AdminFeatureTogglesPage() { {featureState.error} {featureState.loading && 'Fetching feature toggles'} - {featureState.value?.restartRequired && } + {featureState.value && ( (featureToggles); const [localToggles, setLocalToggles] = useState(featureToggles); const [isSaving, setIsSaving] = useState(false); + const [showSaveModel, setShowSaveModal] = useState(false); const togglesApi = getTogglesAPI(); const handleToggleChange = (toggle: FeatureToggle, newValue: boolean) => { @@ -58,6 +59,14 @@ export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdat } }; + const saveButtonRef = useRef(null); + const showSaveChangesModal = (show: boolean) => () => { + setShowSaveModal(show); + if (!show && saveButtonRef.current) { + saveButtonRef.current.focus(); + } + }; + const getModifiedToggles = (): FeatureToggle[] => { return localToggles.filter((toggle, index) => toggle.enabled !== serverToggles.current[index].enabled); }; @@ -72,11 +81,30 @@ export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdat return 'Feature management is not configured for editing'; } if (readOnlyToggle) { - return 'Preview features are not editable'; + return 'This is a non-editable feature'; } return ''; }; + const getStageCell = (stage: string) => { + switch (stage) { + case 'GA': + return ( + +
GA
+
+ ); + case 'privatePreview': + case 'preview': + case 'experimental': + return 'Beta'; + case 'deprecated': + return 'Deprecated'; + default: + return stage; + } + }; + const columns = [ { id: 'name', @@ -90,20 +118,32 @@ export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdat cell: ({ cell: { value } }: CellProps) =>
{value}
, sortType: sortByDescription, }, + { + id: 'stage', + header: 'Stage', + cell: ({ cell: { value } }: CellProps) =>
{getStageCell(value)}
, + }, { id: 'enabled', header: 'State', - cell: ({ row }: CellProps) => ( - + cell: ({ row }: CellProps) => { + const renderStateSwitch = (
handleToggleChange(row.original, e.currentTarget.checked)} + transparent={row.original.readOnly} />
-
- ), + ); + + return row.original.readOnly ? ( + {renderStateSwitch} + ) : ( + renderStateSwitch + ); + }, sortType: sortByEnabled, }, ]; @@ -112,9 +152,28 @@ export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdat <> {allowEditing && (
- + +

+ Some features are stable (GA) and enabled by default, whereas some are currently in their preliminary + Beta phase, available for early adoption. +

+

We advise understanding the implications of each feature change before making modifications.

+
+ } + confirmText="Save changes" + onConfirm={async () => { + showSaveChangesModal(false)(); + handleSaveChanges(); + }} + onDismiss={showSaveChangesModal(false)} + /> )} featureToggle.name} />