FeatureToggles: Removed deprecated code (#111750)

This commit is contained in:
Tania
2025-09-29 20:05:44 +03:00
committed by GitHub
parent 073338ec29
commit c952de773d
27 changed files with 13 additions and 1012 deletions
-18
View File
@@ -2148,24 +2148,6 @@ show_ui = true
# Disables TLS in the secure socks proxy
allow_insecure = false
################################## Feature Management ##############################################
# Options to configure the experimental Feature Toggle Admin Page feature, which is behind the `featureToggleAdminPage` feature toggle. Use at your own risk.
[feature_management]
# Allows editing of feature toggles in the feature management page
allow_editing = false
# Allow customization of URL for the controller that manages feature toggles
update_webhook =
# Allow configuring an auth token for feature management update requests
update_webhook_token =
# Hides specific feature toggles from the feature management page
hidden_toggles =
# Disables updating specific feature toggles in the feature management page
read_only_toggles =
#################################### Public Dashboards #####################################
[public_dashboards]
# Set to false to disable public dashboards
-14
View File
@@ -2050,20 +2050,6 @@ default_datasource_uid =
; show_ui = true
; allow_insecure = false
################################## Feature Management ##############################################
[feature_management]
# Options to configure the experimental Feature Toggle Admin Page feature, which is behind the `featureToggleAdminPage` feature toggle. Use at your own risk.
# Allow editing of feature toggles in the feature management page
;allow_editing = false
# Allow customization of URL for the controller that manages feature toggles
;update_webhook =
# Allow configuring an auth token for feature management update requests
;update_webhook_token =
# Hide specific feature toggles from the feature management page
;hidden_toggles =
# Disable updating specific feature toggles in the feature management page
;read_only_toggles =
#################################### Public Dashboards #####################################
[public_dashboards]
# Set to false to disable public dashboards
-5
View File
@@ -1359,11 +1359,6 @@
"count": 1
}
},
"public/app/features/admin/AdminFeatureTogglesTable.tsx": {
"no-restricted-syntax": {
"count": 3
}
},
"public/app/features/admin/ServerStatsCard.tsx": {
"no-restricted-syntax": {
"count": 1
@@ -205,10 +205,6 @@ export interface FeatureToggles {
*/
grafanaAPIServerEnsureKubectlAccess?: boolean;
/**
* Enable admin page for managing feature toggles from the Grafana front-end. Grafana Cloud only.
*/
featureToggleAdminPage?: boolean;
/**
* Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled
* @default true
*/
-5
View File
@@ -125,11 +125,6 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/admin/migrate-to-cloud", authorize(cloudmigration.MigrationAssistantAccess), hs.Index)
}
// feature toggle admin page
if hs.Features.IsEnabledGlobally(featuremgmt.FlagFeatureToggleAdminPage) {
r.Get("/admin/featuretoggles", authorize(ac.EvalPermission(ac.ActionFeatureManagementRead)), hs.Index)
}
// secrets management page
if hs.Features.IsEnabledGlobally(featuremgmt.FlagSecretsManagementAppPlatform) && hs.Features.IsEnabledGlobally(featuremgmt.FlagSecretsManagementAppPlatformUI) {
r.Get("/admin/secrets", authorize(ac.EvalAny(
-6
View File
@@ -1,6 +0,0 @@
// +k8s:deepcopy-gen=package
// +k8s:openapi-gen=true
// +k8s:defaulter-gen=TypeMeta
// +groupName=featuretoggle.grafana.app
package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
@@ -1,54 +0,0 @@
package v0alpha1
import (
"fmt"
"github.com/grafana/grafana/pkg/apimachinery/utils"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
const (
GROUP = "featuretoggle.grafana.app"
VERSION = "v0alpha1"
APIVERSION = GROUP + "/" + VERSION
)
// FeatureResourceInfo represents each feature that may have a toggle
var FeatureResourceInfo = utils.NewResourceInfo(GROUP, VERSION,
"features", "feature", "Feature",
func() runtime.Object { return &Feature{} },
func() runtime.Object { return &FeatureList{} },
utils.TableColumns{
Definition: []metav1.TableColumnDefinition{
{Name: "Name", Type: "string", Format: "name"},
{Name: "Stage", Type: "string", Format: "string", Description: "Where is the flag in the dev cycle"},
{Name: "Owner", Type: "string", Format: "string", Description: "Which team owns the feature"},
},
Reader: func(obj any) ([]interface{}, error) {
r, ok := obj.(*Feature)
if ok {
return []interface{}{
r.Name,
r.Spec.Stage,
r.Spec.Owner,
}, nil
}
return nil, fmt.Errorf("expected resource or info")
},
},
)
// TogglesResourceInfo represents the actual configuration
var TogglesResourceInfo = utils.NewResourceInfo(GROUP, VERSION,
"featuretoggles", "featuretoggle", "FeatureToggles",
func() runtime.Object { return &FeatureToggles{} },
func() runtime.Object { return &FeatureTogglesList{} },
utils.TableColumns{}, // default table converter
)
var (
// SchemeGroupVersion is group version used to register these objects
SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION}
)
@@ -1,215 +0,0 @@
//go:build !ignore_autogenerated
// +build !ignore_autogenerated
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by deepcopy-gen. DO NOT EDIT.
package v0alpha1
import (
commonv0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Feature) DeepCopyInto(out *Feature) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Feature.
func (in *Feature) DeepCopy() *Feature {
if in == nil {
return nil
}
out := new(Feature)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *Feature) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FeatureList) DeepCopyInto(out *FeatureList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]Feature, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureList.
func (in *FeatureList) DeepCopy() *FeatureList {
if in == nil {
return nil
}
out := new(FeatureList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *FeatureList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FeatureSpec) DeepCopyInto(out *FeatureSpec) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureSpec.
func (in *FeatureSpec) DeepCopy() *FeatureSpec {
if in == nil {
return nil
}
out := new(FeatureSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FeatureToggles) DeepCopyInto(out *FeatureToggles) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
if in.Spec != nil {
in, out := &in.Spec, &out.Spec
*out = make(map[string]bool, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureToggles.
func (in *FeatureToggles) DeepCopy() *FeatureToggles {
if in == nil {
return nil
}
out := new(FeatureToggles)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *FeatureToggles) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FeatureTogglesList) DeepCopyInto(out *FeatureTogglesList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]FeatureToggles, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureTogglesList.
func (in *FeatureTogglesList) DeepCopy() *FeatureTogglesList {
if in == nil {
return nil
}
out := new(FeatureTogglesList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *FeatureTogglesList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ResolvedToggleState) DeepCopyInto(out *ResolvedToggleState) {
*out = *in
out.TypeMeta = in.TypeMeta
if in.Enabled != nil {
in, out := &in.Enabled, &out.Enabled
*out = make(map[string]bool, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
if in.Toggles != nil {
in, out := &in.Toggles, &out.Toggles
*out = make([]ToggleStatus, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResolvedToggleState.
func (in *ResolvedToggleState) DeepCopy() *ResolvedToggleState {
if in == nil {
return nil
}
out := new(ResolvedToggleState)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ResolvedToggleState) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ToggleStatus) DeepCopyInto(out *ToggleStatus) {
*out = *in
if in.Source != nil {
in, out := &in.Source, &out.Source
*out = new(commonv0alpha1.ObjectReference)
**out = **in
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToggleStatus.
func (in *ToggleStatus) DeepCopy() *ToggleStatus {
if in == nil {
return nil
}
out := new(ToggleStatus)
in.DeepCopyInto(out)
return out
}
@@ -1,19 +0,0 @@
//go:build !ignore_autogenerated
// +build !ignore_autogenerated
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by defaulter-gen. DO NOT EDIT.
package v0alpha1
import (
runtime "k8s.io/apimachinery/pkg/runtime"
)
// RegisterDefaults adds defaulters functions to the given scheme.
// Public to allow building arbitrary schemes.
// All generated defaulters are covering - they call all nested defaulters.
func RegisterDefaults(scheme *runtime.Scheme) error {
return nil
}
@@ -1,3 +0,0 @@
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1,ResolvedToggleState,Toggles
API rule violation: names_match,github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1,FeatureSpec,FrontendOnly
API rule violation: names_match,github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1,FeatureSpec,Owner
@@ -1,4 +1,4 @@
package v0alpha1
package feature_toggle_api
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -6,6 +6,12 @@ import (
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
const (
GROUP = "featuretoggle.grafana.app"
VERSION = "v0alpha1"
APIVERSION = GROUP + "/" + VERSION
)
// Feature represents a feature in development and information about that feature
// It does *not* know the status, only defines properties about the feature itself
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+1 -95
View File
@@ -6,7 +6,6 @@ import (
"reflect"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
)
var (
@@ -14,10 +13,7 @@ var (
)
type FeatureManager struct {
isDevMod bool
restartRequired bool
Settings setting.FeatureMgmtSettings
isDevMod bool
flags map[string]*FeatureFlag
enabled map[string]bool // only the "on" values
@@ -131,66 +127,6 @@ func (fm *FeatureManager) GetFlags() []FeatureFlag {
return v
}
// isFeatureEditingAllowed checks if the backend is properly configured to allow feature toggle changes from the UI
func (fm *FeatureManager) IsFeatureEditingAllowed() bool {
return fm.Settings.AllowEditing && fm.Settings.UpdateWebhook != ""
}
// indicate if a change has been made (not that accurate, but better than nothing)
func (fm *FeatureManager) IsRestartRequired() bool {
return fm.restartRequired
}
// Flags that can be edited
func (fm *FeatureManager) IsEditableFromAdminPage(key string) bool {
flag, ok := fm.flags[key]
if !ok ||
!fm.IsFeatureEditingAllowed() ||
!flag.AllowSelfServe ||
flag.Name == FlagFeatureToggleAdminPage {
return false
}
return flag.Stage == FeatureStageGeneralAvailability ||
flag.Stage == FeatureStagePublicPreview ||
flag.Stage == FeatureStageDeprecated
}
// Flags that should not be shown in the UI (regardless of their state)
func (fm *FeatureManager) IsHiddenFromAdminPage(key string, lenient bool) bool {
_, hide := fm.Settings.HiddenToggles[key]
flag, ok := fm.flags[key]
if !ok || flag.HideFromAdminPage || hide {
return true // unknown flag (should we show it as a warning!)
}
// Explicitly hidden from configs
_, found := fm.Settings.HiddenToggles[key]
if found {
return true
}
if lenient {
return false
}
return flag.Stage == FeatureStageUnknown ||
flag.Stage == FeatureStageExperimental ||
flag.Stage == FeatureStagePrivatePreview
}
// Get the flags that were explicitly set on startup
func (fm *FeatureManager) GetStartupFlags() map[string]bool {
return fm.startup
}
// Perhaps expose the flag warnings
func (fm *FeatureManager) GetWarning() map[string]string {
return fm.warnings
}
func (fm *FeatureManager) SetRestartRequired() {
fm.restartRequired = true
}
// ############# Test Functions #############
func WithFeatures(spec ...any) FeatureToggles {
@@ -223,33 +159,3 @@ func WithManager(spec ...any) *FeatureManager {
return &FeatureManager{enabled: enabled, flags: features, startup: enabled, warnings: map[string]string{}}
}
// WithFeatureManager is used to define feature toggle manager for testing.
// It should be used when your test feature toggles require metadata beyond `Name` and `Enabled`.
// You should provide a feature toggle Name at a minimum.
func WithFeatureManager(cfg setting.FeatureMgmtSettings, flags []*FeatureFlag, disabled ...string) *FeatureManager {
count := len(flags)
features := make(map[string]*FeatureFlag, count)
enabled := make(map[string]bool, count)
dis := make(map[string]bool)
for _, v := range disabled {
dis[v] = true
}
for _, f := range flags {
if f.Name == "" {
continue
}
features[f.Name] = f
enabled[f.Name] = !dis[f.Name]
}
return &FeatureManager{
Settings: cfg,
enabled: enabled,
flags: features,
startup: enabled,
warnings: map[string]string{},
}
}
+3 -12
View File
@@ -11,7 +11,7 @@ import (
"embed"
"encoding/json"
featuretoggle "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
featuretoggleapi "github.com/grafana/grafana/pkg/services/featuremgmt/feature_toggle_api"
)
var (
@@ -332,15 +332,6 @@ var (
RequiresRestart: true,
Owner: grafanaAppPlatformSquad,
},
{
Name: "featureToggleAdminPage",
Description: "Enable admin page for managing feature toggles from the Grafana front-end. Grafana Cloud only.",
Stage: FeatureStageExperimental,
FrontendOnly: false,
Owner: grafanaBackendServicesSquad,
RequiresRestart: true,
HideFromDocs: true,
},
{
Name: "awsAsyncQueryCaching",
Description: "Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled",
@@ -2073,8 +2064,8 @@ var (
var f embed.FS
// Get the cached feature list (exposed as a k8s resource)
func GetEmbeddedFeatureList() (featuretoggle.FeatureList, error) {
features := featuretoggle.FeatureList{}
func GetEmbeddedFeatureList() (featuretoggleapi.FeatureList, error) {
features := featuretoggleapi.FeatureList{}
body, err := f.ReadFile("toggles_gen.json")
if err == nil {
err = json.Unmarshal(body, &features)
-1
View File
@@ -27,7 +27,6 @@ func ProvideManagerService(cfg *setting.Cfg) (*FeatureManager, error) {
enabled: make(map[string]bool),
startup: make(map[string]bool),
warnings: make(map[string]string),
Settings: cfg.FeatureManagement,
log: log.New("featuremgmt"),
}
-1
View File
@@ -42,7 +42,6 @@ datasourceAPIServers,experimental,@grafana/grafana-app-platform-squad,false,true
grafanaAPIServerWithExperimentalAPIs,experimental,@grafana/grafana-app-platform-squad,true,true,false
provisioning,experimental,@grafana/grafana-app-platform-squad,false,true,false
grafanaAPIServerEnsureKubectlAccess,experimental,@grafana/grafana-app-platform-squad,true,true,false
featureToggleAdminPage,experimental,@grafana/grafana-backend-services-squad,false,true,false
awsAsyncQueryCaching,GA,@grafana/aws-datasources,false,false,false
queryCacheRequestDeduplication,experimental,@grafana/grafana-operator-experience-squad,false,false,false
permissionsFilterRemoveSubquery,experimental,@grafana/search-and-storage,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
42 grafanaAPIServerWithExperimentalAPIs experimental @grafana/grafana-app-platform-squad true true false
43 provisioning experimental @grafana/grafana-app-platform-squad false true false
44 grafanaAPIServerEnsureKubectlAccess experimental @grafana/grafana-app-platform-squad true true false
featureToggleAdminPage experimental @grafana/grafana-backend-services-squad false true false
45 awsAsyncQueryCaching GA @grafana/aws-datasources false false false
46 queryCacheRequestDeduplication experimental @grafana/grafana-operator-experience-squad false false false
47 permissionsFilterRemoveSubquery experimental @grafana/search-and-storage false false false
-4
View File
@@ -179,10 +179,6 @@ const (
// Start an additional https handler and write kubectl options
FlagGrafanaAPIServerEnsureKubectlAccess = "grafanaAPIServerEnsureKubectlAccess"
// FlagFeatureToggleAdminPage
// Enable admin page for managing feature toggles from the Grafana front-end. Grafana Cloud only.
FlagFeatureToggleAdminPage = "featureToggleAdminPage"
// FlagAwsAsyncQueryCaching
// Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled
FlagAwsAsyncQueryCaching = "awsAsyncQueryCaching"
@@ -1577,6 +1577,7 @@
"name": "featureToggleAdminPage",
"resourceVersion": "1758022099771",
"creationTimestamp": "2023-07-18T20:43:32Z",
"deletionTimestamp": "2025-09-29T13:36:16Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-09-16 11:28:19.771156 +0000 UTC"
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
featuretoggleapi "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
featuretoggleapi "github.com/grafana/grafana/pkg/services/featuremgmt/feature_toggle_api"
"github.com/grafana/grafana/pkg/services/featuremgmt/strcase"
)
@@ -44,15 +44,6 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
Text: "Organizations", SubTitle: "Isolated instances of Grafana running on the same server", Id: "global-orgs", Url: s.cfg.AppSubURL + "/admin/orgs", Icon: "building",
})
}
if s.features.IsEnabled(ctx, featuremgmt.FlagFeatureToggleAdminPage) && hasAccess(ac.EvalPermission(ac.ActionFeatureManagementRead)) {
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
Text: "Feature toggles",
SubTitle: "View and edit feature toggles",
Id: "feature-toggles",
Url: s.cfg.AppSubURL + "/admin/featuretoggles",
Icon: "toggle-on",
})
}
if hasAccess(cloudmigration.MigrationAssistantAccess) && s.features.IsEnabled(ctx, featuremgmt.FlagOnPremToCloudMigrations) {
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
Text: "Migrate to Grafana Cloud",
-4
View File
@@ -543,9 +543,6 @@ type Cfg struct {
// Cloud Migration
CloudMigration CloudMigrationSettings
// Feature Management Settings
FeatureManagement FeatureMgmtSettings
// Alerting
AlertingEvaluationTimeout time.Duration
AlertingNotificationTimeout time.Duration
@@ -1429,7 +1426,6 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error {
logSection := iniFile.Section("log")
cfg.UserFacingDefaultError = logSection.Key("user_facing_default_error").MustString("please inspect Grafana server log for details")
cfg.readFeatureManagementConfig()
cfg.readPublicDashboardsSettings()
cfg.readCloudMigrationSettings()
cfg.readSecretsManagerSettings()
-38
View File
@@ -1,38 +0,0 @@
package setting
import (
"github.com/grafana/grafana/pkg/util"
)
type FeatureMgmtSettings struct {
HiddenToggles map[string]struct{}
ReadOnlyToggles map[string]struct{}
AllowEditing bool
UpdateWebhook string
UpdateWebhookToken string
}
func (cfg *Cfg) readFeatureManagementConfig() {
section := cfg.Raw.Section("feature_management")
hiddenToggles := make(map[string]struct{})
readOnlyToggles := make(map[string]struct{})
// parse the comma separated list in `hidden_toggles`.
hiddenTogglesStr := valueAsString(section, "hidden_toggles", "")
for _, feature := range util.SplitString(hiddenTogglesStr) {
hiddenToggles[feature] = struct{}{}
}
// parse the comma separated list in `read_only_toggles`.
readOnlyTogglesStr := valueAsString(section, "read_only_toggles", "")
for _, feature := range util.SplitString(readOnlyTogglesStr) {
readOnlyToggles[feature] = struct{}{}
}
cfg.FeatureManagement.HiddenToggles = hiddenToggles
cfg.FeatureManagement.ReadOnlyToggles = readOnlyToggles
cfg.FeatureManagement.AllowEditing = cfg.SectionWithEnvOverrides("feature_management").Key("allow_editing").MustBool(false)
cfg.FeatureManagement.UpdateWebhook = cfg.SectionWithEnvOverrides("feature_management").Key("update_webhook").MustString("")
cfg.FeatureManagement.UpdateWebhookToken = cfg.SectionWithEnvOverrides("feature_management").Key("update_webhook_token").MustString("")
}
@@ -1,102 +0,0 @@
import { BackendSrvRequest, config } from '@grafana/runtime';
import { getTogglesAPI } from './AdminFeatureTogglesAPI';
// implements @grafana/runtime/BackendSrv
class MockSrv {
constructor() {
this.apiCalls = [];
}
apiCalls: Array<{
url: string;
method: string;
}>;
async get(
url: string,
params?: BackendSrvRequest['params'],
requestId?: BackendSrvRequest['requestId'],
options?: Partial<BackendSrvRequest>
) {
this.apiCalls.push({
url: url,
method: 'get',
});
if (config.featureToggles.kubernetesFeatureToggles && url.indexOf('current') > -1) {
return await { toggles: [] };
}
return await {};
}
async post(url: string, data?: unknown, options?: Partial<BackendSrvRequest>) {
this.apiCalls.push({
url: url,
method: 'post',
});
return await {};
}
async patch(url: string, data: unknown, options?: Partial<BackendSrvRequest>) {
this.apiCalls.push({
url: url,
method: 'patch',
});
return await {};
}
// these aren't needed for this test
async put(url: string, data: unknown, options?: Partial<BackendSrvRequest>) {
return await {};
}
async delete(url: string, data?: unknown, options?: Partial<BackendSrvRequest>) {
return await {};
}
}
const testBackendSrv = new MockSrv();
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => testBackendSrv,
config: {
featureToggles: {
kubernetesFeatureToggles: false,
grafanaAPIServerWithExperimentalAPIs: false,
},
},
}));
describe('AdminFeatureTogglesApi', () => {
beforeEach(() => {
jest.clearAllMocks();
testBackendSrv.apiCalls.length = 0;
});
const originalToggles = { ...config.featureToggles };
afterAll(() => {
config.featureToggles = originalToggles;
});
it('uses the k8s api when the k8s toggles are on', async () => {
config.featureToggles.kubernetesFeatureToggles = true;
config.featureToggles.grafanaAPIServerWithExperimentalAPIs = true;
const togglesApi = getTogglesAPI();
await togglesApi.getFeatureToggles();
await togglesApi.updateFeatureToggles([]);
const expected = [
{
method: 'get',
url: '/apis/featuretoggle.grafana.app/v0alpha1/current',
},
{
method: 'patch',
url: '/apis/featuretoggle.grafana.app/v0alpha1/current',
},
];
expect(testBackendSrv.apiCalls).toEqual(expect.arrayContaining(expected));
});
});
@@ -1,77 +0,0 @@
import { getBackendSrv } from '@grafana/runtime';
export type FeatureToggle = {
name: string;
description?: string;
enabled: boolean;
stage: string;
readOnly?: boolean;
hidden?: boolean;
};
export type CurrentTogglesState = {
restartRequired: boolean;
allowEditing: boolean;
toggles: FeatureToggle[];
};
interface ResolvedToggleState {
kind: 'ResolvedToggleState';
restartRequired?: boolean;
allowEditing?: boolean;
toggles?: K8sToggleSpec[]; // not used in patch
enabled: { [key: string]: boolean };
}
interface K8sToggleSpec {
name: string;
description: string;
enabled: boolean;
writeable: boolean;
source: K8sToggleSource;
stage: string;
}
interface K8sToggleSource {
namespace: string;
name: string;
}
interface FeatureTogglesAPI {
getFeatureToggles(): Promise<CurrentTogglesState>;
updateFeatureToggles(toggles: FeatureToggle[]): Promise<void>;
}
class K8sAPI implements FeatureTogglesAPI {
baseURL = '/apis/featuretoggle.grafana.app/v0alpha1';
async getFeatureToggles(): Promise<CurrentTogglesState> {
const current = await getBackendSrv().get<ResolvedToggleState>(this.baseURL + '/current');
return {
restartRequired: Boolean(current.restartRequired),
allowEditing: Boolean(current.allowEditing),
toggles: current.toggles!.map((t) => ({
name: t.name,
description: t.description!,
enabled: t.enabled,
readOnly: !Boolean(t.writeable),
stage: t.stage,
hidden: false, // only return visible things
})),
};
}
updateFeatureToggles(toggles: FeatureToggle[]): Promise<void> {
const patchBody: ResolvedToggleState = {
kind: 'ResolvedToggleState',
enabled: {},
};
toggles.forEach((t) => {
patchBody.enabled[t.name] = t.enabled;
});
return getBackendSrv().patch(this.baseURL + '/current', patchBody);
}
}
export const getTogglesAPI = (): FeatureTogglesAPI => {
return new K8sAPI();
};
@@ -1,96 +0,0 @@
import { css } from '@emotion/css';
import { useState } from 'react';
import { useAsync } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { useStyles2, Icon, TextLink } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { getTogglesAPI } from './AdminFeatureTogglesAPI';
import { AdminFeatureTogglesTable } from './AdminFeatureTogglesTable';
export default function AdminFeatureTogglesPage() {
const [reload, setReload] = useState(1);
const togglesApi = getTogglesAPI();
const featureState = useAsync(() => togglesApi.getFeatureToggles(), [reload]);
const styles = useStyles2(getStyles);
const handleUpdateSuccess = () => {
setReload(reload + 1);
};
const EditingAlert = () => {
return (
<div className={styles.warning}>
<div className={styles.icon}>
<Icon name="exclamation-triangle" />
</div>
<span className={styles.message}>
{featureState.value?.restartRequired
? t(
'admin.feature-toggles.restart-pending',
'A restart is pending for your Grafana instance to apply the latest feature toggle changes'
)
: t(
'admin.feature-toggles.restart-required',
'Saving feature toggle changes will prompt a restart of the instance, which may take a few minutes'
)}
</span>
</div>
);
};
const subTitle = (
<div>
<Trans i18nKey="admin.feature-toggles.sub-title">
View and edit feature toggles. Read more about feature toggles at{' '}
<TextLink
href="https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/feature-toggles/"
external
>
grafana.com
</TextLink>
.
</Trans>
</div>
);
return (
<Page navId="feature-toggles" subTitle={subTitle}>
<Page.Contents isLoading={featureState.loading}>
<>
{featureState.error?.message}
{featureState.loading && 'Fetching feature toggles'}
<EditingAlert />
{featureState.value && (
<AdminFeatureTogglesTable
featureToggles={featureState.value.toggles}
allowEditing={featureState.value.allowEditing || false}
onUpdateSuccess={handleUpdateSuccess}
/>
)}
</>
</Page.Contents>
</Page>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
warning: css({
display: 'flex',
marginTop: theme.spacing(0.25),
marginBottom: theme.spacing(0.25),
}),
icon: css({
color: theme.colors.warning.main,
paddingRight: theme.spacing(),
}),
message: css({
color: theme.colors.text.secondary,
marginTop: theme.spacing(0.25),
}),
};
}
@@ -1,201 +0,0 @@
import { useState, useRef } from 'react';
import { Trans, t } from '@grafana/i18n';
import { Switch, InteractiveTable, Tooltip, type CellProps, Button, ConfirmModal, type SortByFn } from '@grafana/ui';
import { FeatureToggle, getTogglesAPI } from './AdminFeatureTogglesAPI';
interface Props {
featureToggles: FeatureToggle[];
allowEditing: boolean;
onUpdateSuccess: () => void;
}
const sortByName: SortByFn<FeatureToggle> = (a, b) => {
return a.original.name.localeCompare(b.original.name);
};
const sortByDescription: SortByFn<FeatureToggle> = (a, b) => {
if (!a.original.description && !b.original.description) {
return 0;
} else if (!a.original.description) {
return 1;
} else if (!b.original.description) {
return -1;
}
return a.original.description.localeCompare(b.original.description);
};
const sortByEnabled: SortByFn<FeatureToggle> = (a, b) => {
return a.original.enabled === b.original.enabled ? 0 : a.original.enabled ? 1 : -1;
};
export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdateSuccess }: Props) {
// sort manually, doesn't look like it can be automatically done in the table
featureToggles.sort((a, b) => a.name.localeCompare(b.name));
const serverToggles = useRef<FeatureToggle[]>(featureToggles);
const [localToggles, setLocalToggles] = useState<FeatureToggle[]>(featureToggles);
const [isSaving, setIsSaving] = useState(false);
const [showSaveModel, setShowSaveModal] = useState(false);
const togglesApi = getTogglesAPI();
const handleToggleChange = (toggle: FeatureToggle, newValue: boolean) => {
const updatedToggle = { ...toggle, enabled: newValue };
// Update the local state
const updatedToggles = localToggles.map((t) => (t.name === toggle.name ? updatedToggle : t));
setLocalToggles(updatedToggles);
};
const handleSaveChanges = async () => {
setIsSaving(true);
try {
const modifiedToggles = getModifiedToggles();
await togglesApi.updateFeatureToggles(modifiedToggles);
// Pretend the values came from a new request
serverToggles.current = [...localToggles];
onUpdateSuccess(); // should trigger a new get
} finally {
setIsSaving(false);
}
};
const saveButtonRef = useRef<HTMLButtonElement | null>(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);
};
const hasModifications = () => {
// Check if there are any differences between the original toggles and the local toggles
return localToggles.some((toggle, index) => toggle.enabled !== serverToggles.current[index].enabled);
};
const getToggleTooltipContent = (readOnlyToggle?: boolean) => {
if (!allowEditing) {
return 'Feature management is not configured for editing';
}
if (readOnlyToggle) {
return 'This is a non-editable feature';
}
return '';
};
const getStageCell = (stage: string) => {
switch (stage) {
case 'GA':
return (
<Tooltip
content={t(
'admin.admin-feature-toggles-table.get-stage-cell.content-general-availability',
'General availability'
)}
>
<div>
<Trans i18nKey="admin.admin-feature-toggles-table.get-stage-cell.ga">GA</Trans>
</div>
</Tooltip>
);
case 'privatePreview':
case 'preview':
case 'experimental':
return t('admin.admin-feature-toggles-table.get-stage-cell.beta', 'Beta');
case 'deprecated':
return t('admin.admin-feature-toggles-table.get-stage-cell.deprecated', 'Deprecated');
default:
return stage;
}
};
const columns = [
{
id: 'name',
header: 'Name',
cell: ({ cell: { value } }: CellProps<FeatureToggle, string>) => <div>{value}</div>,
sortType: sortByName,
},
{
id: 'description',
header: 'Description',
cell: ({ cell: { value } }: CellProps<FeatureToggle, string>) => <div>{value}</div>,
sortType: sortByDescription,
},
{
id: 'stage',
header: 'Stage',
cell: ({ cell: { value } }: CellProps<FeatureToggle, string>) => <div>{getStageCell(value)}</div>,
},
{
id: 'enabled',
header: 'State',
cell: ({ row }: CellProps<FeatureToggle, boolean>) => {
const renderStateSwitch = (
<div>
<Switch
value={row.original.enabled}
disabled={row.original.readOnly}
onChange={(e) => handleToggleChange(row.original, e.currentTarget.checked)}
/>
</div>
);
return row.original.readOnly ? (
<Tooltip content={getToggleTooltipContent(row.original.readOnly)}>{renderStateSwitch}</Tooltip>
) : (
renderStateSwitch
);
},
sortType: sortByEnabled,
},
];
return (
<>
{allowEditing && (
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '0 0 5px 0' }}>
<Button disabled={!hasModifications() || isSaving} onClick={showSaveChangesModal(true)} ref={saveButtonRef}>
{isSaving
? t('admin.admin-feature-toggles-table.saving', 'Saving...')
: t('admin.admin-feature-toggles-table.save-changes', 'Save changes')}
</Button>
<ConfirmModal
isOpen={showSaveModel}
title={t(
'admin.admin-feature-toggles-table.title-apply-feature-toggle-changes',
'Apply feature toggle changes'
)}
body={
<div>
<p>
<Trans i18nKey="admin.admin-feature-toggles-table.confirm-modal-body-1">
Some features are stable (GA) and enabled by default, whereas some are currently in their
preliminary Beta phase, available for early adoption.
</Trans>
</p>
<p>
<Trans i18nKey="admin.admin-feature-toggles-table.confirm-modal-body-2">
We advise understanding the implications of each feature change before making modifications.
</Trans>
</p>
</div>
}
confirmText={t('admin.admin-feature-toggles-table.confirmText-save-changes', 'Save changes')}
onConfirm={async () => {
showSaveChangesModal(false)();
handleSaveChanges();
}}
onDismiss={showSaveChangesModal(false)}
/>
</div>
)}
<InteractiveTable columns={columns} data={localToggles} getRowId={(featureToggle) => featureToggle.name} />
</>
);
}
-8
View File
@@ -364,14 +364,6 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "AdminEditOrgPage" */ 'app/features/admin/AdminEditOrgPage')
),
},
{
path: '/admin/featuretoggles',
component: config.featureToggles.featureToggleAdminPage
? SafeDynamicImport(
() => import(/* webpackChunkName: "AdminFeatureTogglesPage" */ 'app/features/admin/AdminFeatureTogglesPage')
)
: () => <Navigate replace to="/admin" />,
},
{
path: '/admin/stats',
component: SafeDynamicImport(
-19
View File
@@ -81,20 +81,6 @@
"title-access-denied": "Access denied"
}
},
"admin-feature-toggles-table": {
"confirm-modal-body-1": "Some features are stable (GA) and enabled by default, whereas some are currently in their preliminary Beta phase, available for early adoption.",
"confirm-modal-body-2": "We advise understanding the implications of each feature change before making modifications.",
"confirmText-save-changes": "Save changes",
"get-stage-cell": {
"beta": "Beta",
"content-general-availability": "General availability",
"deprecated": "Deprecated",
"ga": "GA"
},
"save-changes": "Save changes",
"saving": "Saving...",
"title-apply-feature-toggle-changes": "Apply feature toggle changes"
},
"admin-orgs-table": {
"aria-label-delete-org": "Delete org",
"confirmText-delete": "Delete",
@@ -140,11 +126,6 @@
"title-sort-dashboards-by-popularity-in-search": "Sort dashboards by popularity in search",
"title-team-sync": "Team Sync"
},
"feature-toggles": {
"restart-pending": "A restart is pending for your Grafana instance to apply the latest feature toggle changes",
"restart-required": "Saving feature toggle changes will prompt a restart of the instance, which may take a few minutes",
"sub-title": "View and edit feature toggles. Read more about feature toggles at <2>grafana.com</2>."
},
"get-enterprise": {
"contact-us": "Contact us and get a free trial",
"description": "You can use the trial version for free for 30 days. We will remind you about it five days before the trial period ends.",