diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index c20fda3a6fa..4d2ab3739da 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -102,7 +102,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink if s.features.IsEnabled(featuremgmt.FlagFeatureToggleAdminPage) && hasAccess(ac.EvalPermission(ac.ActionFeatureManagementRead)) { configNodes = append(configNodes, &navtree.NavLink{ - Text: "Feature Toggles", SubTitle: "View feature toggles", Id: "feature-toggles", Url: s.cfg.AppSubURL + "/admin/featuretoggles", Icon: "toggle-on", + Text: "Feature Toggles", SubTitle: "View and edit feature toggles", Id: "feature-toggles", Url: s.cfg.AppSubURL + "/admin/featuretoggles", Icon: "toggle-on", }) } diff --git a/public/app/features/admin/AdminFeatureTogglesAPI.ts b/public/app/features/admin/AdminFeatureTogglesAPI.ts index 47857e68ba8..35a67cd455a 100644 --- a/public/app/features/admin/AdminFeatureTogglesAPI.ts +++ b/public/app/features/admin/AdminFeatureTogglesAPI.ts @@ -3,11 +3,23 @@ import { lastValueFrom } from 'rxjs'; import { getBackendSrv } from '@grafana/runtime'; +type QueryArgs = { + url: string; + method?: string; + body?: { featureToggles: FeatureToggle[] }; +}; + const backendSrvBaseQuery = - ({ baseUrl }: { baseUrl: string }): BaseQueryFn<{ url: string }> => - async ({ url }) => { + ({ baseUrl }: { baseUrl: string }): BaseQueryFn => + async ({ url, method = 'GET', body }) => { try { - const { data } = await lastValueFrom(getBackendSrv().fetch({ url: baseUrl + url })); + const { data } = await lastValueFrom( + getBackendSrv().fetch({ + url: baseUrl + url, + method, + data: body, + }) + ); return { data }; } catch (error) { return { error }; @@ -21,14 +33,22 @@ export const togglesApi = createApi({ getFeatureToggles: builder.query({ query: () => ({ url: '/featuremgmt' }), }), + updateFeatureToggles: builder.mutation({ + query: (updatedToggles) => ({ + url: '/featuremgmt', + method: 'POST', + body: { featureToggles: updatedToggles }, + }), + }), }), }); type FeatureToggle = { name: string; + description?: string; enabled: boolean; - description: string; + readOnly?: boolean; }; -export const { useGetFeatureTogglesQuery } = togglesApi; +export const { useGetFeatureTogglesQuery, useUpdateFeatureTogglesMutation } = togglesApi; export type { FeatureToggle }; diff --git a/public/app/features/admin/AdminFeatureTogglesTable.tsx b/public/app/features/admin/AdminFeatureTogglesTable.tsx index aaa37ab67ae..eca6021bf31 100644 --- a/public/app/features/admin/AdminFeatureTogglesTable.tsx +++ b/public/app/features/admin/AdminFeatureTogglesTable.tsx @@ -1,35 +1,113 @@ -import React from 'react'; +import React, { useState } from 'react'; -import { Switch, InteractiveTable, type CellProps } from '@grafana/ui'; +import { Switch, InteractiveTable, type CellProps, Button, type SortByFn } from '@grafana/ui'; -import { type FeatureToggle } from './AdminFeatureTogglesAPI'; +import { type FeatureToggle, useUpdateFeatureTogglesMutation } from './AdminFeatureTogglesAPI'; interface Props { featureToggles: FeatureToggle[]; } +const sortByName: SortByFn = (a, b) => { + return a.original.name.localeCompare(b.original.name); +}; + +const sortByDescription: SortByFn = (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 = (a, b) => { + return a.original.enabled === b.original.enabled ? 0 : a.original.enabled ? 1 : -1; +}; + export function AdminFeatureTogglesTable({ featureToggles }: Props) { + const [localToggles, setLocalToggles] = useState(featureToggles); + const [updateFeatureToggles] = useUpdateFeatureTogglesMutation(); + const [modifiedToggles, setModifiedToggles] = useState([]); + + 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); + + // 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 resp = await updateFeatureToggles(modifiedToggles); + // Reset modifiedToggles after successful update + if (!('error' in resp)) { + setModifiedToggles([]); + } + }; + + const hasModifications = () => { + // Check if there are any differences between the original toggles and the local toggles + return featureToggles.some((originalToggle) => { + const modifiedToggle = localToggles.find((t) => t.name === originalToggle.name); + return modifiedToggle && modifiedToggle.enabled !== originalToggle.enabled; + }); + }; + const columns = [ { id: 'name', header: 'Name', cell: ({ cell: { value } }: CellProps) =>
{value}
, + sortType: sortByName, }, { id: 'description', header: 'Description', cell: ({ cell: { value } }: CellProps) =>
{value}
, + sortType: sortByDescription, }, { id: 'enabled', header: 'State', - cell: ({ cell: { value } }: CellProps) => ( + cell: ({ row }: CellProps) => (
- + handleToggleChange(row.original, e.currentTarget.checked)} + />
), + sortType: sortByEnabled, }, ]; - return featureToggle.name} />; + return ( + <> +
+ +
+ featureToggle.name} /> + + ); }