Feature Management: Update admin page UI after a successful update (#76380)

* Feature Management: Update admin page UI after a successful update

* lint

* lint

* refactor
This commit is contained in:
João Calisto
2023-10-13 11:54:34 +01:00
committed by GitHub
parent 2857870bfb
commit 9fc0e1566e
7 changed files with 101 additions and 19 deletions

View File

@ -412,6 +412,7 @@ func (hs *HTTPServer) registerRoutes() {
if hs.Features.IsEnabled(featuremgmt.FlagFeatureToggleAdminPage) { if hs.Features.IsEnabled(featuremgmt.FlagFeatureToggleAdminPage) {
apiRoute.Group("/featuremgmt", func(featuremgmtRoute routing.RouteRegister) { apiRoute.Group("/featuremgmt", func(featuremgmtRoute routing.RouteRegister) {
featuremgmtRoute.Get("/state", authorize(ac.EvalPermission(ac.ActionFeatureManagementRead)), hs.GetFeatureMgmtState)
featuremgmtRoute.Get("/", authorize(ac.EvalPermission(ac.ActionFeatureManagementRead)), hs.GetFeatureToggles) featuremgmtRoute.Get("/", authorize(ac.EvalPermission(ac.ActionFeatureManagementRead)), hs.GetFeatureToggles)
featuremgmtRoute.Post("/", authorize(ac.EvalPermission(ac.ActionFeatureManagementWrite)), hs.UpdateFeatureToggle) featuremgmtRoute.Post("/", authorize(ac.EvalPermission(ac.ActionFeatureManagementWrite)), hs.UpdateFeatureToggle)
}) })

View File

@ -78,9 +78,16 @@ func (hs *HTTPServer) UpdateFeatureToggle(ctx *contextmodel.ReqContext) response
return response.Respond(http.StatusBadRequest, "Failed to perform webhook request") return response.Respond(http.StatusBadRequest, "Failed to perform webhook request")
} }
hs.Features.SetRestartRequired()
return response.Respond(http.StatusOK, "feature toggles updated successfully") return response.Respond(http.StatusOK, "feature toggles updated successfully")
} }
func (hs *HTTPServer) GetFeatureMgmtState(ctx *contextmodel.ReqContext) response.Response {
fmState := hs.Features.GetState()
return response.Respond(http.StatusOK, fmState)
}
// isFeatureHidden returns whether a toggle should be hidden from the admin page. // isFeatureHidden returns whether a toggle should be hidden from the admin page.
// filters out statuses Unknown, Experimental, and Private Preview // filters out statuses Unknown, Experimental, and Private Preview
func isFeatureHidden(flag featuremgmt.FeatureFlag, hideCfg map[string]struct{}) bool { func isFeatureHidden(flag featuremgmt.FeatureFlag, hideCfg map[string]struct{}) bool {

View File

@ -14,13 +14,14 @@ var (
) )
type FeatureManager struct { type FeatureManager struct {
isDevMod bool isDevMod bool
licensing licensing.Licensing restartRequired bool
flags map[string]*FeatureFlag licensing licensing.Licensing
enabled map[string]bool // only the "on" values flags map[string]*FeatureFlag
config string // path to config file enabled map[string]bool // only the "on" values
vars map[string]any config string // path to config file
log log.Logger vars map[string]any
log log.Logger
} }
// This will merge the flags with the current configuration // This will merge the flags with the current configuration
@ -148,6 +149,14 @@ func (fm *FeatureManager) GetFlags() []FeatureFlag {
return v return v
} }
func (fm *FeatureManager) GetState() *FeatureManagerState {
return &FeatureManagerState{RestartRequired: fm.restartRequired}
}
func (fm *FeatureManager) SetRestartRequired() {
fm.restartRequired = true
}
// Check to see if a feature toggle exists by name // Check to see if a feature toggle exists by name
func (fm *FeatureManager) LookupFlag(name string) (FeatureFlag, bool) { func (fm *FeatureManager) LookupFlag(name string) (FeatureFlag, bool) {
f, ok := fm.flags[name] f, ok := fm.flags[name]

View File

@ -127,3 +127,7 @@ type FeatureToggleDTO struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
ReadOnly bool `json:"readOnly,omitempty"` ReadOnly bool `json:"readOnly,omitempty"`
} }
type FeatureManagerState struct {
RestartRequired bool `json:"restartRequired"`
}

View File

@ -30,6 +30,9 @@ export const togglesApi = createApi({
reducerPath: 'togglesApi', reducerPath: 'togglesApi',
baseQuery: backendSrvBaseQuery({ baseUrl: '/api' }), baseQuery: backendSrvBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({ endpoints: (builder) => ({
getManagerState: builder.query<FeatureMgmtState, void>({
query: () => ({ url: '/featuremgmt/state' }),
}),
getFeatureToggles: builder.query<FeatureToggle[], void>({ getFeatureToggles: builder.query<FeatureToggle[], void>({
query: () => ({ url: '/featuremgmt' }), query: () => ({ url: '/featuremgmt' }),
}), }),
@ -50,5 +53,9 @@ type FeatureToggle = {
readOnly?: boolean; readOnly?: boolean;
}; };
export const { useGetFeatureTogglesQuery, useUpdateFeatureTogglesMutation } = togglesApi; type FeatureMgmtState = {
export type { FeatureToggle }; restartRequired: boolean;
};
export const { useGetManagerStateQuery, useGetFeatureTogglesQuery, useUpdateFeatureTogglesMutation } = togglesApi;
export type { FeatureToggle, FeatureMgmtState };

View File

@ -1,26 +1,72 @@
import React from 'react'; import { css } from '@emotion/css';
import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Icon } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
import { useGetFeatureTogglesQuery } from './AdminFeatureTogglesAPI'; import { useGetFeatureTogglesQuery, useGetManagerStateQuery } from './AdminFeatureTogglesAPI';
import { AdminFeatureTogglesTable } from './AdminFeatureTogglesTable'; import { AdminFeatureTogglesTable } from './AdminFeatureTogglesTable';
export default function AdminFeatureTogglesPage() { export default function AdminFeatureTogglesPage() {
const { data: featureToggles, isLoading, isError } = useGetFeatureTogglesQuery(); const { data: featureToggles, isLoading, isError } = useGetFeatureTogglesQuery();
const { data: featureMgmtState } = useGetManagerStateQuery();
const [updateSuccessful, setUpdateSuccessful] = useState(false);
const styles = useStyles2(getStyles);
const getErrorMessage = () => { const getErrorMessage = () => {
return 'Error fetching feature toggles'; return 'Error fetching feature toggles';
}; };
const handleUpdateSuccess = () => {
setUpdateSuccessful(true);
};
const AlertMessage = () => {
return (
<div className={styles.warning}>
<div className={styles.icon}>
<Icon name="exclamation-triangle" />
</div>
<span className={styles.message}>
{featureMgmtState?.restartRequired || updateSuccessful
? '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'}
</span>
</div>
);
};
return ( return (
<Page navId="feature-toggles"> <Page navId="feature-toggles">
<Page.Contents> <Page.Contents>
<> <>
{isError && getErrorMessage()} {isError && getErrorMessage()}
{isLoading && 'Fetching feature toggles'} {isLoading && 'Fetching feature toggles'}
{featureToggles && <AdminFeatureTogglesTable featureToggles={featureToggles} />} <AlertMessage />
{featureToggles && (
<AdminFeatureTogglesTable featureToggles={featureToggles} onUpdateSuccess={handleUpdateSuccess} />
)}
</> </>
</Page.Contents> </Page.Contents>
</Page> </Page>
); );
} }
function getStyles(theme: GrafanaTheme2) {
return {
warning: css({
display: 'flex',
marginTop: theme.spacing(3),
}),
icon: css({
color: theme.colors.warning.main,
paddingRight: theme.spacing(),
}),
message: css({
color: theme.colors.text.secondary,
marginTop: theme.spacing(0.25),
}),
};
}

View File

@ -6,6 +6,7 @@ import { type FeatureToggle, useUpdateFeatureTogglesMutation } from './AdminFeat
interface Props { interface Props {
featureToggles: FeatureToggle[]; featureToggles: FeatureToggle[];
onUpdateSuccess: () => void;
} }
const sortByName: SortByFn<FeatureToggle> = (a, b) => { const sortByName: SortByFn<FeatureToggle> = (a, b) => {
@ -27,10 +28,11 @@ 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 }: Props) { export function AdminFeatureTogglesTable({ featureToggles, onUpdateSuccess }: Props) {
const [localToggles, setLocalToggles] = useState<FeatureToggle[]>(featureToggles); const [localToggles, setLocalToggles] = useState<FeatureToggle[]>(featureToggles);
const [updateFeatureToggles] = useUpdateFeatureTogglesMutation(); const [updateFeatureToggles] = useUpdateFeatureTogglesMutation();
const [modifiedToggles, setModifiedToggles] = useState<FeatureToggle[]>([]); const [modifiedToggles, setModifiedToggles] = useState<FeatureToggle[]>([]);
const [isSaving, setIsSaving] = useState(false);
const handleToggleChange = (toggle: FeatureToggle, newValue: boolean) => { const handleToggleChange = (toggle: FeatureToggle, newValue: boolean) => {
const updatedToggle = { ...toggle, enabled: newValue }; const updatedToggle = { ...toggle, enabled: newValue };
@ -56,10 +58,16 @@ export function AdminFeatureTogglesTable({ featureToggles }: Props) {
}; };
const handleSaveChanges = async () => { const handleSaveChanges = async () => {
const resp = await updateFeatureToggles(modifiedToggles); setIsSaving(true);
// Reset modifiedToggles after successful update try {
if (!('error' in resp)) { const resp = await updateFeatureToggles(modifiedToggles);
setModifiedToggles([]); // Reset modifiedToggles after successful update
if (!('error' in resp)) {
onUpdateSuccess();
setModifiedToggles([]);
}
} finally {
setIsSaving(false);
} }
}; };
@ -103,8 +111,8 @@ export function AdminFeatureTogglesTable({ featureToggles }: Props) {
return ( return (
<> <>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '0 0 5px 0' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', padding: '0 0 5px 0' }}>
<Button disabled={!hasModifications()} onClick={handleSaveChanges}> <Button disabled={!hasModifications() || isSaving} onClick={handleSaveChanges}>
Save Changes {isSaving ? 'Saving...' : 'Save Changes'}
</Button> </Button>
</div> </div>
<InteractiveTable columns={columns} data={localToggles} getRowId={(featureToggle) => featureToggle.name} /> <InteractiveTable columns={columns} data={localToggles} getRowId={(featureToggle) => featureToggle.name} />