From f9f29d39d4a8a7b58c4bfad4ca4e87134fd910f0 Mon Sep 17 00:00:00 2001 From: Tiago Fonseca Date: Mon, 27 Jan 2025 13:56:45 -0300 Subject: [PATCH 1/8] feat: Adds health events to the frontend --- frontend/common/services/useHealthEvents.ts | 44 +++++++ frontend/common/services/useTag.ts | 7 +- frontend/common/types/requests.ts | 1 + frontend/common/types/responses.ts | 10 ++ frontend/web/components/modals/CreateFlag.js | 21 ++++ frontend/web/components/pages/FeaturesPage.js | 111 ++++++++++++++++++ 6 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 frontend/common/services/useHealthEvents.ts diff --git a/frontend/common/services/useHealthEvents.ts b/frontend/common/services/useHealthEvents.ts new file mode 100644 index 000000000000..6cf198a75388 --- /dev/null +++ b/frontend/common/services/useHealthEvents.ts @@ -0,0 +1,44 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const healthService = service + .enhanceEndpoints({ addTagTypes: ['HealthEvents'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + getHealthEvents: builder.query< + Res['healthEvents'], + Req['getHealthEvents'] + >({ + providesTags: [{ id: 'LIST', type: 'HealthEvents' }], + query: (query: Req['getHealthEvents']) => ({ + url: `projects/${query.projectId}/feature-health/events/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function getHealthEvents( + store: any, + data: Req['getHealthEvents'], + options?: Parameters< + typeof healthService.endpoints.getHealthEvents.initiate + >[1], +) { + return store.dispatch( + healthService.endpoints.getHealthEvents.initiate(data, options), + ) +} + +// END OF FUNCTION_EXPORTS + +export const { + useGetHealthEventsQuery, + // END OF EXPORTS +} = healthService + +/* Usage examples: +const { data, isLoading } = useGetHealthEventsQuery({ id: 2 }, {}) //get hook +healthService.endpoints.getHealthEvents.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/services/useTag.ts b/frontend/common/services/useTag.ts index c31504295294..d1f657b9b36f 100644 --- a/frontend/common/services/useTag.ts +++ b/frontend/common/services/useTag.ts @@ -68,8 +68,11 @@ export async function getTags( data: Req['getTags'], options?: Parameters[1], ) { - store.dispatch(tagService.endpoints.getTags.initiate(data, options)) - return Promise.all(store.dispatch(tagService.util.getRunningQueriesThunk())) + const result = await store.dispatch( + tagService.endpoints.getTags.initiate(data, options), + ) + await Promise.all(store.dispatch(tagService.util.getRunningQueriesThunk())) + return result } export async function createTag( store: any, diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index bad69b7d2a2a..6179b43a8a65 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -114,6 +114,7 @@ export type Req = { getPermission: { id: string; level: PermissionLevel } getAvailablePermissions: { level: PermissionLevel } getTag: { id: string } + getHealthEvents: { projectId: number | string } updateTag: { projectId: string; tag: Tag } deleteTag: { id: number diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index ba774902fcc1..61ede35e079f 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -637,6 +637,15 @@ export type SAMLAttributeMapping = { idp_attribute_name: string } +export type HealthEvent = { + created_at: string + environment: number + feature: number + provider_name: string + reason: string + type: 'HEALTHY' | 'UNHEALTHY' +} + export type Res = { segments: PagedResponse segment: Segment @@ -671,6 +680,7 @@ export type Res = { availablePermissions: AvailablePermission[] tag: Tag tags: Tag[] + healthEvents: HealthEvent[] account: Account userEmail: {} groupAdmin: { id: string } diff --git a/frontend/web/components/modals/CreateFlag.js b/frontend/web/components/modals/CreateFlag.js index 7b5abb74b185..236c98a288fd 100644 --- a/frontend/web/components/modals/CreateFlag.js +++ b/frontend/web/components/modals/CreateFlag.js @@ -575,6 +575,7 @@ const CreateFlag = class extends Component { const invalid = !!multivariate_options && multivariate_options.length && controlValue < 0 const existingChangeRequest = this.props.changeRequest + const hasUnhealthyEvent = this.props.hasUnhealthyEvent const hideIdentityOverridesTab = Utils.getShouldHideIdentityOverridesTab() const noPermissions = this.props.noPermissions let regexValid = true @@ -1871,6 +1872,26 @@ const CreateFlag = class extends Component { /> )} + + Unhealthy Event{' '} + {this.state.segmentsChanged && ( +
+ * +
+ )} + + } + > + oi +
{!existingChangeRequest && ( parseInt(v)) : [], + healthEvents: [], is_enabled: params.is_enabled === 'true' ? true @@ -64,13 +71,27 @@ const FeaturesPage = class extends Component { typeof params.tags === 'string' ? params.tags.split(',').map((v) => parseInt(v)) : [], + unhealthyTags: [], value_search: typeof params.value_search === 'string' ? params.value_search : '', } ES6Component(this) getTags(getStore(), { projectId: `${this.props.match.params.projectId}`, + }).then((res) => { + this.setState({ + unhealthyTags: res.data?.filter((tag) => tag?.type === UNHEALTHY_TYPE), + }) + }) + + getHealthEvents(getStore(), { + projectId: `${this.props.match.params.projectId}`, + }).then((res) => { + this.setState({ + healthEvents: res.data?.filter((tag) => tag), + }) }) + AppActions.getFeatures( this.props.match.params.projectId, this.props.match.params.environmentId, @@ -217,10 +238,50 @@ const FeaturesPage = class extends Component { ) } + getEnvUnhealthyTags = (tags, environmentId) => { + return tags?.find( + (tag) => + tag?.type === UNHEALTHY_TYPE && tag?.environment === environmentId, + ) + } + + getEnvNamesFromEvents = (events) => + uniq( + events + ?.map( + (event) => ProjectStore.getEnvironmentById(event?.environment)?.name, + ) + ?.filter((name) => name), + )?.join(', ') + + getCurrEnvUnhealthyEvents = (envId) => { + return this.state.healthEvents?.filter( + (event) => event?.environment === envId, + ) + } + + getOtherEnvUnhealthyEvents = (envId) => { + return this.state.healthEvents?.filter( + (event) => event?.environment !== envId, + ) + } + render() { const { environmentId, projectId } = this.props.match.params const readOnly = Utils.getFlagsmithHasFeature('read_only_mode') const environment = ProjectStore.getEnvironment(environmentId) + + const envUnhealthyTags = this.state.unhealthyTags + const isShowingUnhealthyTags = this.state.tags?.includes( + envUnhealthyTags?.id, + ) + const currEnvUnhealthyEvents = this.getCurrEnvUnhealthyEvents( + environment?.id, + ) + const otherEnvUnhealthyEvents = this.getOtherEnvUnhealthyEvents( + environment?.id, + ) + return (
{' '} for your selected environment. + {!!currEnvUnhealthyEvents?.length && ( +
+ +
+ Certain features in this environment are + currently tagged as{' '} + "unhealthy". +
+ {!isShowingUnhealthyTags && ( +
+ +
+ )} +
+ } + /> +
+ )} + {!!otherEnvUnhealthyEvents?.length && ( +
+ + "Unhealthy" events have been detected in the + following environments:{' '} + + {this.getEnvNamesFromEvents( + otherEnvUnhealthyEvents, + )} + +
+ } + /> + + )} Date: Mon, 27 Jan 2025 15:16:35 -0300 Subject: [PATCH 2/8] wip --- frontend/web/components/FeatureRow.js | 1 + frontend/web/components/modals/CreateFlag.js | 2 +- frontend/web/components/pages/FeaturesPage.js | 6 +++++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/web/components/FeatureRow.js b/frontend/web/components/FeatureRow.js index de27284e5847..9abbf4ba95a4 100644 --- a/frontend/web/components/FeatureRow.js +++ b/frontend/web/components/FeatureRow.js @@ -113,6 +113,7 @@ class TheComponent extends Component { projectFlag={projectFlag} noPermissions={!this.props.permission} environmentFlag={environmentFlag} + latestUnhealthyEvent={this.props.latestUnhealthyEvent} tab={tab} flagId={environmentFlag.id} />, diff --git a/frontend/web/components/modals/CreateFlag.js b/frontend/web/components/modals/CreateFlag.js index 236c98a288fd..39f4dfb1255b 100644 --- a/frontend/web/components/modals/CreateFlag.js +++ b/frontend/web/components/modals/CreateFlag.js @@ -575,7 +575,7 @@ const CreateFlag = class extends Component { const invalid = !!multivariate_options && multivariate_options.length && controlValue < 0 const existingChangeRequest = this.props.changeRequest - const hasUnhealthyEvent = this.props.hasUnhealthyEvent + const latestUnhealthyEvent = this.props.latestUnhealthyEvent const hideIdentityOverridesTab = Utils.getShouldHideIdentityOverridesTab() const noPermissions = this.props.noPermissions let regexValid = true diff --git a/frontend/web/components/pages/FeaturesPage.js b/frontend/web/components/pages/FeaturesPage.js index 11e30260bc02..6f3b245d2c6a 100644 --- a/frontend/web/components/pages/FeaturesPage.js +++ b/frontend/web/components/pages/FeaturesPage.js @@ -505,7 +505,7 @@ const FeaturesPage = class extends Component { this.filter, ) }} - onChange={(tags, isAutomated) => { + onChange={(tags) => { FeatureListStore.isLoading = true if ( tags.includes('') && @@ -668,6 +668,10 @@ const FeaturesPage = class extends Component { projectId={projectId} index={i} canDelete={permission} + latestUnhealthyEvent={currEnvUnhealthyEvents?.find( + (event) => + event.feature === projectFlag.id, + )} toggleFlag={toggleFlag} removeFlag={removeFlag} projectFlag={projectFlag} From fd56137ac14ff97f7c0fd575aa822eea2453ce61 Mon Sep 17 00:00:00 2001 From: Tiago Fonseca Date: Mon, 27 Jan 2025 18:59:27 -0300 Subject: [PATCH 3/8] adds warning to feature modal --- frontend/web/components/FeatureRow.js | 15 ++++++++++++++ frontend/web/components/modals/CreateFlag.js | 20 ------------------- frontend/web/components/pages/FeaturesPage.js | 4 ++-- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/frontend/web/components/FeatureRow.js b/frontend/web/components/FeatureRow.js index 9abbf4ba95a4..31a9d93a3238 100644 --- a/frontend/web/components/FeatureRow.js +++ b/frontend/web/components/FeatureRow.js @@ -16,6 +16,7 @@ import Button from './base/forms/Button' import SegmentOverridesIcon from './SegmentOverridesIcon' import IdentityOverridesIcon from './IdentityOverridesIcon' import StaleFlagWarning from './StaleFlagWarning' +import WarningMessage from './WarningMessage' export const width = [200, 70, 55, 70, 450] class TheComponent extends Component { @@ -105,6 +106,20 @@ class TheComponent extends Component { > + {this.props?.latestUnhealthyEvent && ( +
+ + Feature is unhealthy{' '} + } place='bottom'> + {this.props?.latestUnhealthyEvent?.reason} + +
+ } + /> + + )} ,
)} - - Unhealthy Event{' '} - {this.state.segmentsChanged && ( -
- * -
- )} - - } - > - oi -
{!existingChangeRequest && ( Date: Tue, 28 Jan 2025 10:57:24 -0300 Subject: [PATCH 4/8] changes copy text --- frontend/web/components/FeatureRow.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/web/components/FeatureRow.js b/frontend/web/components/FeatureRow.js index 31a9d93a3238..6b17711cbd0c 100644 --- a/frontend/web/components/FeatureRow.js +++ b/frontend/web/components/FeatureRow.js @@ -111,7 +111,7 @@ class TheComponent extends Component { - Feature is unhealthy{' '} + Unhealthy{' '} } place='bottom'> {this.props?.latestUnhealthyEvent?.reason} From 14c577ad0ddc6bcb5d259bdcbd57ade1c8a4818a Mon Sep 17 00:00:00 2001 From: Tiago Fonseca Date: Wed, 29 Jan 2025 18:48:19 -0300 Subject: [PATCH 5/8] address changes and creates provider --- frontend/common/services/useHealthProvider.ts | 70 ++++++ frontend/common/services/useTag.ts | 6 +- frontend/common/types/requests.ts | 2 + frontend/common/types/responses.ts | 11 +- .../web/components/EditHealthProvider.tsx | 237 ++++++++++++++++++ frontend/web/components/FeatureRow.js | 45 ++-- .../web/components/UnhealthyFlagWarning.tsx | 47 ++++ frontend/web/components/modals/CreateFlag.js | 4 +- frontend/web/components/pages/FeaturesPage.js | 117 --------- frontend/web/components/pages/HomeAside.tsx | 65 ++++- .../components/pages/ProjectSettingsPage.js | 20 +- frontend/web/components/tags/Tag.tsx | 3 + frontend/web/components/tags/TagContent.tsx | 9 +- frontend/web/components/tags/TagValues.tsx | 1 + 14 files changed, 489 insertions(+), 148 deletions(-) create mode 100644 frontend/common/services/useHealthProvider.ts create mode 100644 frontend/web/components/EditHealthProvider.tsx create mode 100644 frontend/web/components/UnhealthyFlagWarning.tsx diff --git a/frontend/common/services/useHealthProvider.ts b/frontend/common/services/useHealthProvider.ts new file mode 100644 index 000000000000..75cb892f1b53 --- /dev/null +++ b/frontend/common/services/useHealthProvider.ts @@ -0,0 +1,70 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const healthProviderService = service + .enhanceEndpoints({ addTagTypes: ['HealthProviders'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createHealthProvider: builder.mutation< + Res['healthProvider'], + Req['createHealthProvider'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'HealthProviders' }], + query: (query: Req['createHealthProvider']) => ({ + body: { name: query.name }, + method: 'POST', + url: `projects/${query.projectId}/feature-health/providers/`, + }), + }), + getHealthProviders: builder.query< + Res['healthProviders'], + Req['getHealthProviders'] + >({ + providesTags: [{ id: 'LIST', type: 'HealthProviders' }], + query: (query: Req['getHealthProviders']) => ({ + url: `projects/${query.projectId}/feature-health/providers/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function getHealthProviders( + store: any, + data: Req['getHealthProviders'], + options?: Parameters< + typeof healthProviderService.endpoints.getHealthProviders.initiate + >[1], +) { + return store.dispatch( + healthProviderService.endpoints.getHealthProviders.initiate(data, options), + ) +} + +export async function createHealthProvider( + store: any, + data: Req['createHealthProvider'], + options?: Parameters< + typeof healthProviderService.endpoints.createHealthProvider.initiate + >[1], +) { + return store.dispatch( + healthProviderService.endpoints.createHealthProvider.initiate( + data, + options, + ), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useCreateHealthProviderMutation, + useGetHealthProvidersQuery, + // END OF EXPORTS +} = healthProviderService + +/* Usage examples: +const { data, isLoading } = useGetHealthProvidersQuery({ id: 2 }, {}) //get hook +healthProviderService.endpoints.getHealthProviders.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/services/useTag.ts b/frontend/common/services/useTag.ts index d1f657b9b36f..c62d11b13869 100644 --- a/frontend/common/services/useTag.ts +++ b/frontend/common/services/useTag.ts @@ -68,11 +68,7 @@ export async function getTags( data: Req['getTags'], options?: Parameters[1], ) { - const result = await store.dispatch( - tagService.endpoints.getTags.initiate(data, options), - ) - await Promise.all(store.dispatch(tagService.util.getRunningQueriesThunk())) - return result + return store.dispatch(tagService.endpoints.getTags.initiate(data, options)) } export async function createTag( store: any, diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 6179b43a8a65..33b88d7cbe61 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -115,6 +115,8 @@ export type Req = { getAvailablePermissions: { level: PermissionLevel } getTag: { id: string } getHealthEvents: { projectId: number | string } + getHealthProviders: { projectId: number } + createHealthProvider: { projectId: number; name: string } updateTag: { projectId: string; tag: Tag } deleteTag: { id: number diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 61ede35e079f..6e230aaaa13a 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -352,7 +352,7 @@ export type Tag = { label: string is_system_tag: boolean is_permanent: boolean - type: 'STALE' | 'NONE' + type: 'STALE' | 'UNHEALTHY' | 'NONE' } export type MultivariateFeatureStateValue = { @@ -646,6 +646,13 @@ export type HealthEvent = { type: 'HEALTHY' | 'UNHEALTHY' } +export type HealthProvider = { + created_by: string + name: string + project: number + webhook_url: number +} + export type Res = { segments: PagedResponse segment: Segment @@ -681,6 +688,8 @@ export type Res = { tag: Tag tags: Tag[] healthEvents: HealthEvent[] + healthProvider: HealthProvider + healthProviders: HealthProvider[] account: Account userEmail: {} groupAdmin: { id: string } diff --git a/frontend/web/components/EditHealthProvider.tsx b/frontend/web/components/EditHealthProvider.tsx new file mode 100644 index 000000000000..bec731ea0e0b --- /dev/null +++ b/frontend/web/components/EditHealthProvider.tsx @@ -0,0 +1,237 @@ +import React, { FC } from 'react' +import { + HealthProvider, + Role, + User, + UserGroupSummary, + UserPermission, +} from 'common/types/responses' +import PanelSearch from './PanelSearch' +import Button from './base/forms/Button' + +import { PermissionLevel, Req } from 'common/types/requests' +import { RouterChildContext } from 'react-router' + +import ConfigProvider from 'common/providers/ConfigProvider' +import Icon from './Icon' + +import Utils from 'common/utils/utils' +import { + useCreateHealthProviderMutation, + useGetHealthProvidersQuery, +} from 'common/services/useHealthProvider' +import { components } from 'react-select' + +type EditPermissionModalType = { + group?: UserGroupSummary + projectId: number + className?: string + isGroup?: boolean + level: PermissionLevel + name: string + onSave?: () => void + envId?: number | string | undefined + parentId?: string + parentLevel?: string + parentSettingsLink?: string + roleTabTitle?: string + permissions?: UserPermission[] + push: (route: string) => void + user?: User + role?: Role + roles?: Role[] + permissionChanged?: () => void + isEditUserPermission?: boolean + isEditGroupPermission?: boolean +} + +type EditHealthProviderType = Omit & { + router: RouterChildContext['router'] + tabClassName?: string +} + +const CreateHealthProviderForm = ({ projectId }: { projectId: number }) => { + const [selected, setSelected] = React.useState() + const [createProvider, { isError, isLoading, isSuccess }] = + useCreateHealthProviderMutation() + + const providers = [{ name: 'Sample' }, { name: 'Grafana' }] + + const providerOptions = providers.map((provider) => ({ + label: provider.name, + value: provider.name, + })) + + return ( +
{ + e.preventDefault() + if (!selected) { + return + } + createProvider({ name: selected, projectId }) + }} + > + + +