mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Feature Management: UI improvements (#76866)
* Feature Management: UI improvements * update UI logic --------- Co-authored-by: Michael Mandrus <michael.mandrus@grafana.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/response"
|
"github.com/grafana/grafana/pkg/api/response"
|
||||||
@@ -36,6 +37,9 @@ func (hs *HTTPServer) GetFeatureToggles(ctx *contextmodel.ReqContext) response.R
|
|||||||
}
|
}
|
||||||
|
|
||||||
dtos = append(dtos, dto)
|
dtos = append(dtos, dto)
|
||||||
|
sort.Slice(dtos, func(i, j int) bool {
|
||||||
|
return dtos[i].Name < dtos[j].Name
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.JSON(http.StatusOK, dtos)
|
return response.JSON(http.StatusOK, dtos)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ var (
|
|||||||
type FeatureManager struct {
|
type FeatureManager struct {
|
||||||
isDevMod bool
|
isDevMod bool
|
||||||
restartRequired bool
|
restartRequired bool
|
||||||
|
allowEditing bool
|
||||||
licensing licensing.Licensing
|
licensing licensing.Licensing
|
||||||
flags map[string]*FeatureFlag
|
flags map[string]*FeatureFlag
|
||||||
enabled map[string]bool // only the "on" values
|
enabled map[string]bool // only the "on" values
|
||||||
@@ -150,7 +151,7 @@ func (fm *FeatureManager) GetFlags() []FeatureFlag {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (fm *FeatureManager) GetState() *FeatureManagerState {
|
func (fm *FeatureManager) GetState() *FeatureManagerState {
|
||||||
return &FeatureManagerState{RestartRequired: fm.restartRequired}
|
return &FeatureManagerState{RestartRequired: fm.restartRequired, AllowEditing: fm.allowEditing}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fm *FeatureManager) SetRestartRequired() {
|
func (fm *FeatureManager) SetRestartRequired() {
|
||||||
|
|||||||
@@ -132,4 +132,5 @@ type FeatureToggleDTO struct {
|
|||||||
|
|
||||||
type FeatureManagerState struct {
|
type FeatureManagerState struct {
|
||||||
RestartRequired bool `json:"restartRequired"`
|
RestartRequired bool `json:"restartRequired"`
|
||||||
|
AllowEditing bool `json:"allowEditing"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ var (
|
|||||||
|
|
||||||
func ProvideManagerService(cfg *setting.Cfg, licensing licensing.Licensing) (*FeatureManager, error) {
|
func ProvideManagerService(cfg *setting.Cfg, licensing licensing.Licensing) (*FeatureManager, error) {
|
||||||
mgmt := &FeatureManager{
|
mgmt := &FeatureManager{
|
||||||
isDevMod: setting.Env != setting.Prod,
|
isDevMod: setting.Env != setting.Prod,
|
||||||
licensing: licensing,
|
licensing: licensing,
|
||||||
flags: make(map[string]*FeatureFlag, 30),
|
flags: make(map[string]*FeatureFlag, 30),
|
||||||
enabled: make(map[string]bool),
|
enabled: make(map[string]bool),
|
||||||
log: log.New("featuremgmt"),
|
allowEditing: cfg.FeatureManagement.AllowEditing && cfg.FeatureManagement.UpdateWebhook != "",
|
||||||
|
log: log.New("featuremgmt"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the standard flags
|
// Register the standard flags
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type FeatureToggle = {
|
|||||||
|
|
||||||
type FeatureMgmtState = {
|
type FeatureMgmtState = {
|
||||||
restartRequired: boolean;
|
restartRequired: boolean;
|
||||||
|
allowEditing: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const { useGetManagerStateQuery, useGetFeatureTogglesQuery, useUpdateFeatureTogglesMutation } = togglesApi;
|
export const { useGetManagerStateQuery, useGetFeatureTogglesQuery, useUpdateFeatureTogglesMutation } = togglesApi;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function AdminFeatureTogglesPage() {
|
|||||||
setUpdateSuccessful(true);
|
setUpdateSuccessful(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AlertMessage = () => {
|
const EditingAlert = () => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.warning}>
|
<div className={styles.warning}>
|
||||||
<div className={styles.icon}>
|
<div className={styles.icon}>
|
||||||
@@ -44,9 +44,13 @@ export default function AdminFeatureTogglesPage() {
|
|||||||
<>
|
<>
|
||||||
{isError && getErrorMessage()}
|
{isError && getErrorMessage()}
|
||||||
{isLoading && 'Fetching feature toggles'}
|
{isLoading && 'Fetching feature toggles'}
|
||||||
<AlertMessage />
|
{featureMgmtState?.allowEditing && <EditingAlert />}
|
||||||
{featureToggles && (
|
{featureToggles && (
|
||||||
<AdminFeatureTogglesTable featureToggles={featureToggles} onUpdateSuccess={handleUpdateSuccess} />
|
<AdminFeatureTogglesTable
|
||||||
|
featureToggles={featureToggles}
|
||||||
|
allowEditing={featureMgmtState?.allowEditing || false}
|
||||||
|
onUpdateSuccess={handleUpdateSuccess}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</Page.Contents>
|
</Page.Contents>
|
||||||
@@ -58,7 +62,8 @@ function getStyles(theme: GrafanaTheme2) {
|
|||||||
return {
|
return {
|
||||||
warning: css({
|
warning: css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
marginTop: theme.spacing(3),
|
marginTop: theme.spacing(0.25),
|
||||||
|
marginBottom: theme.spacing(0.25),
|
||||||
}),
|
}),
|
||||||
icon: css({
|
icon: css({
|
||||||
color: theme.colors.warning.main,
|
color: theme.colors.warning.main,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
|
|
||||||
import { Switch, InteractiveTable, type CellProps, Button, type SortByFn } from '@grafana/ui';
|
import { Switch, InteractiveTable, Tooltip, type CellProps, Button, type SortByFn } from '@grafana/ui';
|
||||||
|
|
||||||
import { type FeatureToggle, useUpdateFeatureTogglesMutation } from './AdminFeatureTogglesAPI';
|
import { type FeatureToggle, useUpdateFeatureTogglesMutation } from './AdminFeatureTogglesAPI';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
featureToggles: FeatureToggle[];
|
featureToggles: FeatureToggle[];
|
||||||
|
allowEditing: boolean;
|
||||||
onUpdateSuccess: () => void;
|
onUpdateSuccess: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,10 +29,10 @@ const sortByEnabled: SortByFn<FeatureToggle> = (a, b) => {
|
|||||||
return a.original.enabled === b.original.enabled ? 0 : a.original.enabled ? 1 : -1;
|
return a.original.enabled === b.original.enabled ? 0 : a.original.enabled ? 1 : -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AdminFeatureTogglesTable({ featureToggles, onUpdateSuccess }: Props) {
|
export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdateSuccess }: Props) {
|
||||||
|
const serverToggles = useRef<FeatureToggle[]>(featureToggles);
|
||||||
const [localToggles, setLocalToggles] = useState<FeatureToggle[]>(featureToggles);
|
const [localToggles, setLocalToggles] = useState<FeatureToggle[]>(featureToggles);
|
||||||
const [updateFeatureToggles] = useUpdateFeatureTogglesMutation();
|
const [updateFeatureToggles] = useUpdateFeatureTogglesMutation();
|
||||||
const [modifiedToggles, setModifiedToggles] = useState<FeatureToggle[]>([]);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
const handleToggleChange = (toggle: FeatureToggle, newValue: boolean) => {
|
const handleToggleChange = (toggle: FeatureToggle, newValue: boolean) => {
|
||||||
@@ -40,43 +41,40 @@ export function AdminFeatureTogglesTable({ featureToggles, onUpdateSuccess }: Pr
|
|||||||
// Update the local state
|
// Update the local state
|
||||||
const updatedToggles = localToggles.map((t) => (t.name === toggle.name ? updatedToggle : t));
|
const updatedToggles = localToggles.map((t) => (t.name === toggle.name ? updatedToggle : t));
|
||||||
setLocalToggles(updatedToggles);
|
setLocalToggles(updatedToggles);
|
||||||
|
|
||||||
// Check if the toggle exists in modifiedToggles
|
|
||||||
const existingToggle = modifiedToggles.find((t) => t.name === toggle.name);
|
|
||||||
|
|
||||||
// If it exists and its state is the same as the updated one, remove it from modifiedToggles
|
|
||||||
if (existingToggle && existingToggle.enabled === newValue) {
|
|
||||||
setModifiedToggles((prev) => prev.filter((t) => t.name !== toggle.name));
|
|
||||||
} else {
|
|
||||||
// Else, add/update the toggle in modifiedToggles
|
|
||||||
setModifiedToggles((prev) => {
|
|
||||||
const newToggles = prev.filter((t) => t.name !== toggle.name);
|
|
||||||
newToggles.push(updatedToggle);
|
|
||||||
return newToggles;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveChanges = async () => {
|
const handleSaveChanges = async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
|
const modifiedToggles = getModifiedToggles();
|
||||||
const resp = await updateFeatureToggles(modifiedToggles);
|
const resp = await updateFeatureToggles(modifiedToggles);
|
||||||
// Reset modifiedToggles after successful update
|
|
||||||
if (!('error' in resp)) {
|
if (!('error' in resp)) {
|
||||||
|
// server toggles successfully updated
|
||||||
|
serverToggles.current = [...localToggles];
|
||||||
onUpdateSuccess();
|
onUpdateSuccess();
|
||||||
setModifiedToggles([]);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getModifiedToggles = (): FeatureToggle[] => {
|
||||||
|
return localToggles.filter((toggle, index) => toggle.enabled !== serverToggles.current[index].enabled);
|
||||||
|
};
|
||||||
|
|
||||||
const hasModifications = () => {
|
const hasModifications = () => {
|
||||||
// Check if there are any differences between the original toggles and the local toggles
|
// Check if there are any differences between the original toggles and the local toggles
|
||||||
return featureToggles.some((originalToggle) => {
|
return localToggles.some((toggle, index) => toggle.enabled !== serverToggles.current[index].enabled);
|
||||||
const modifiedToggle = localToggles.find((t) => t.name === originalToggle.name);
|
};
|
||||||
return modifiedToggle && modifiedToggle.enabled !== originalToggle.enabled;
|
|
||||||
});
|
const getToggleTooltipContent = (readOnlyToggle?: boolean) => {
|
||||||
|
if (!allowEditing) {
|
||||||
|
return 'Feature management is not configured for editing';
|
||||||
|
}
|
||||||
|
if (readOnlyToggle) {
|
||||||
|
return 'Preview features are not editable';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -96,13 +94,15 @@ export function AdminFeatureTogglesTable({ featureToggles, onUpdateSuccess }: Pr
|
|||||||
id: 'enabled',
|
id: 'enabled',
|
||||||
header: 'State',
|
header: 'State',
|
||||||
cell: ({ row }: CellProps<FeatureToggle, boolean>) => (
|
cell: ({ row }: CellProps<FeatureToggle, boolean>) => (
|
||||||
<div>
|
<Tooltip content={getToggleTooltipContent(row.original.readOnly)}>
|
||||||
<Switch
|
<div>
|
||||||
value={row.original.enabled}
|
<Switch
|
||||||
disabled={row.original.readOnly}
|
value={row.original.enabled}
|
||||||
onChange={(e) => handleToggleChange(row.original, e.currentTarget.checked)}
|
disabled={row.original.readOnly}
|
||||||
/>
|
onChange={(e) => handleToggleChange(row.original, e.currentTarget.checked)}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
),
|
),
|
||||||
sortType: sortByEnabled,
|
sortType: sortByEnabled,
|
||||||
},
|
},
|
||||||
@@ -110,11 +110,13 @@ export function AdminFeatureTogglesTable({ featureToggles, onUpdateSuccess }: Pr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '0 0 5px 0' }}>
|
{allowEditing && (
|
||||||
<Button disabled={!hasModifications() || isSaving} onClick={handleSaveChanges}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '0 0 5px 0' }}>
|
||||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
<Button disabled={!hasModifications() || isSaving} onClick={handleSaveChanges}>
|
||||||
</Button>
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<InteractiveTable columns={columns} data={localToggles} getRowId={(featureToggle) => featureToggle.name} />
|
<InteractiveTable columns={columns} data={localToggles} getRowId={(featureToggle) => featureToggle.name} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user