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:
João Calisto
2023-10-26 10:42:00 +01:00
committed by GitHub
parent c4bf32fa2d
commit 7869ca1932
7 changed files with 62 additions and 47 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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