diff --git a/frontend/common/constants.ts b/frontend/common/constants.ts index ca05197fbc26..6a1788b97d5a 100644 --- a/frontend/common/constants.ts +++ b/frontend/common/constants.ts @@ -435,8 +435,12 @@ const Constants = { }, "event_type": "FLAG_UPDATED" }`, + featureHealth: { + unhealthyColor: '#D35400', + }, featurePanelTabs: { ANALYTICS: 'analytics', + FEATURE_HEALTH: 'feature-health', HISTORY: 'history', IDENTITY_OVERRIDES: 'identity-overrides', LINKS: 'links', diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index c35189593f18..abf23ece8e2c 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -639,12 +639,27 @@ export type SAMLAttributeMapping = { export type HealthEventType = 'HEALTHY' | 'UNHEALTHY' +export type FeatureHealthEventReasonTextBlock = { + text: string + title?: string +} + +export type FeatureHealthEventReasonUrlBlock = { + url: string + title?: string +} + +export type HealthEventReason = { + text_blocks: FeatureHealthEventReasonTextBlock[] + url_blocks: FeatureHealthEventReasonUrlBlock[] +} + export type HealthEvent = { created_at: string environment: number feature: number provider_name: string - reason: string + reason: HealthEventReason | null type: HealthEventType } diff --git a/frontend/web/components/CondensedFeatureRow.tsx b/frontend/web/components/CondensedFeatureRow.tsx index 920d4e6419ce..e5001e2e7dbc 100644 --- a/frontend/web/components/CondensedFeatureRow.tsx +++ b/frontend/web/components/CondensedFeatureRow.tsx @@ -9,6 +9,7 @@ import FeatureValue from './FeatureValue' import SegmentOverridesIcon from './SegmentOverridesIcon' import IdentityOverridesIcon from './IdentityOverridesIcon' import Constants from 'common/constants' +import Utils from 'common/utils/utils' export interface CondensedFeatureRowProps { disableControls?: boolean @@ -27,6 +28,7 @@ export interface CondensedFeatureRowProps { isCompact?: boolean fadeEnabled?: boolean fadeValue?: boolean + hasUnhealthyEvents?: boolean index: number } @@ -37,6 +39,7 @@ const CondensedFeatureRow: React.FC = ({ environmentFlags, fadeEnabled, fadeValue, + hasUnhealthyEvents, index, isCompact, onChange, @@ -53,7 +56,14 @@ const CondensedFeatureRow: React.FC = ({ { if (disableControls) return - !readOnly && editFeature(projectFlag, environmentFlags?.[id]) + !readOnly && + editFeature( + projectFlag, + environmentFlags?.[id], + hasUnhealthyEvents + ? Constants.featurePanelTabs.FEATURE_HEALTH + : undefined, + ) }} style={{ ...style }} className={classNames('flex-row', { 'fs-small': isCompact }, className)} diff --git a/frontend/web/components/EditHealthProvider.tsx b/frontend/web/components/EditHealthProvider.tsx index 430b4f2fae9a..25fe452a28a6 100644 --- a/frontend/web/components/EditHealthProvider.tsx +++ b/frontend/web/components/EditHealthProvider.tsx @@ -12,6 +12,7 @@ import { useGetHealthProvidersQuery, } from 'common/services/useHealthProvider' import { components } from 'react-select' +import InfoMessage from './InfoMessage' type EditHealthProviderType = { projectId: number @@ -32,7 +33,6 @@ const CreateHealthProviderForm = ({ projectId }: { projectId: number }) => { const [createProvider, { error, isError, isLoading, isSuccess }] = useCreateHealthProviderMutation() - // TODO: Replace from list of provider options from API const providers = [{ name: 'Sample' }, { name: 'Grafana' }] const providerOptions = providers.map((provider) => ({ @@ -76,17 +76,17 @@ const CreateHealthProviderForm = ({ projectId }: { projectId: number }) => { options={providerOptions} /> +
+ +
-
- -
) } @@ -137,13 +137,38 @@ const EditHealthProvider: FC = ({ unhealthy state in different environments.{' '}

+ +
+ + Follow the documentation to configure alerting using the supported + providers. + +
+
+ + Sample provider:{' '} + + https://docs.flagsmith.com/advanced-use/feature-health#sample-provider + + +
+
+ + Grafana provider:{' '} + + {' '} + https://docs.flagsmith.com/integrations/apm/grafana/#in-grafana-1 + + +
+
diff --git a/frontend/web/components/FeatureRow.tsx b/frontend/web/components/FeatureRow.tsx index 216764e1a61d..f5a27e16b326 100644 --- a/frontend/web/components/FeatureRow.tsx +++ b/frontend/web/components/FeatureRow.tsx @@ -1,4 +1,4 @@ -import React, { FC, useEffect } from 'react' +import React, { FC, useEffect, useMemo } from 'react' import TagValues from './tags/TagValues' import ConfirmToggleFeature from './modals/ConfirmToggleFeature' import ConfirmRemoveFeature from './modals/ConfirmRemoveFeature' @@ -30,6 +30,7 @@ import Switch from './Switch' import AccountStore from 'common/stores/account-store' import CondensedFeatureRow from './CondensedFeatureRow' import { RouterChildContext } from 'react-router' +import { useGetHealthEventsQuery } from 'common/services/useHealthEvents' interface FeatureRowProps { disableControls?: boolean @@ -76,6 +77,11 @@ const FeatureRow: FC = ({ }) => { const protectedTags = useProtectedTags(projectFlag, projectId) + const { data: healthEvents } = useGetHealthEventsQuery( + { projectId: String(projectFlag.project) }, + { skip: !projectFlag?.project }, + ) + useEffect(() => { const { feature } = Utils.fromParam() const { id } = projectFlag @@ -89,6 +95,15 @@ const FeatureRow: FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [environmentFlags, projectFlag]) + const featureUnhealthyEvents = useMemo( + () => + healthEvents?.filter( + (event) => + event.type === 'UNHEALTHY' && event.feature === projectFlag.id, + ), + [healthEvents, projectFlag], + ) + const copyFeature = () => { Utils.copyToClipboard(projectFlag.name) } @@ -168,6 +183,9 @@ const FeatureRow: FC = ({ , = ({ ) } + const openFeatureHealthTab = (id: number) => { + editFeature( + projectFlag, + environmentFlags?.[id], + Constants.featurePanelTabs.FEATURE_HEALTH, + ) + } + const isReadOnly = readOnly || Utils.getFlagsmithHasFeature('read_only_mode') const isFeatureHealthEnabled = Utils.getFlagsmithHasFeature('feature_health') @@ -208,6 +234,9 @@ const FeatureRow: FC = ({ environmentFlags={environmentFlags} permission={permission} editFeature={editFeature} + hasUnhealthyEvents={ + isFeatureHealthEnabled && featureUnhealthyEvents?.length + } onChange={onChange} style={style} className={className} @@ -302,19 +331,39 @@ const FeatureRow: FC = ({ } )} - + { + if (tag?.type === 'UNHEALTHY') { + openFeatureHealthTab(id) + } + }} + > {projectFlag.is_archived && ( )} {!!isCompact && } {isFeatureHealthEnabled && !!isCompact && ( - + { + e?.stopPropagation() + openFeatureHealthTab(id) + }} + /> )} {!isCompact && } {isFeatureHealthEnabled && !isCompact && ( - + { + e?.stopPropagation() + openFeatureHealthTab(id) + }} + /> )} {description && !isCompact && (
void } const UnhealthyFlagWarning: FC = ({ - projectFlag, + featureUnhealthyEvents, + onClick, }) => { - const { data: tags } = useGetTagsQuery( - { projectId: String(projectFlag.project) }, - { refetchOnFocus: false, skip: !projectFlag?.project }, - ) - const { data: healthEvents } = useGetHealthEventsQuery( - { projectId: String(projectFlag.project) }, - { refetchOnFocus: false, skip: !projectFlag?.project }, - ) - const unhealthyTagId = tags?.find((tag) => tag.type === 'UNHEALTHY')?.id - const latestHealthEvent = healthEvents?.find( - (event) => event.feature === projectFlag.id, - ) - - if ( - !unhealthyTagId || - !projectFlag?.tags?.includes(unhealthyTagId) || - latestHealthEvent?.type !== 'UNHEALTHY' - ) - return null + if (!featureUnhealthyEvents?.length) return null return ( - {/* TODO: Provider info and link to issue will be provided by reason via the API */} - {latestHealthEvent.reason} - {latestHealthEvent.reason && ( +
+
+ This feature has {featureUnhealthyEvents?.length} active alert + {featureUnhealthyEvents?.length > 1 ? 's' : ''}. Check them in the + 'Feature Health' tab. - )} +
} > diff --git a/frontend/web/components/modals/CreateFlag.js b/frontend/web/components/modals/CreateFlag.js index ec49e2444d1c..9052fff01c68 100644 --- a/frontend/web/components/modals/CreateFlag.js +++ b/frontend/web/components/modals/CreateFlag.js @@ -49,6 +49,9 @@ import FeatureHistory from 'components/FeatureHistory' import WarningMessage from 'components/WarningMessage' import { getPermission } from 'common/services/usePermission' import { getChangeRequests } from 'common/services/useChangeRequest' +import FeatureHealthTabContent from './FeatureHealthTabContent' +import { IonIcon } from '@ionic/react' +import { warning } from 'ionicons/icons' const CreateFlag = class extends Component { static displayName = 'CreateFlag' @@ -1904,6 +1907,34 @@ const CreateFlag = class extends Component { )} + {this.props.hasUnhealthyEvents && ( + + Feature Health{' '} + + + } + > + + + )} {hasIntegrationWithGithub && projectFlag?.id && ( { + // Index is used here only because the data is read only. + // Backend sorts created_at in descending order. + const initialValue = + textBlocks?.map((_, index) => ({ collapsed: index !== 0, id: index })) ?? [] + const [collapsibleItems, setCollapsibleItems] = + useState<{ id: number; collapsed: boolean }[]>(initialValue) + + const handleCollapse = (index: number) => { + if (!collapsibleItems?.[index]) { + return null + } + + setCollapsibleItems((prev) => { + const updatedItems = [...prev] + updatedItems[index].collapsed = !updatedItems?.[index]?.collapsed + return updatedItems + }) + } + + const color = Constants.featureHealth.unhealthyColor + + if (!textBlocks?.length) { + return null + } + + return ( +
+ Incident Insights + {textBlocks.map((textBlock, index) => ( +
+ {textBlock.title && ( +
+ {textBlock.title ?? 'Event'} + handleCollapse(index)} + /> +
+ )} + + {textBlock.text} + +
+ ))} +
+ ) +} + +const EventURLBlocks = ({ + urlBlocks, +}: { + urlBlocks: FeatureHealthEventReasonUrlBlock[] | undefined +}) => { + if (!urlBlocks?.length) { + return null + } + + return ( +
+
+ Provider Links +
+ {urlBlocks.map((urlBlock, index) => ( + + ))} +
+ ) +} + +const FeatureHealthTabContent: React.FC = ({ + projectId, +}) => { + const { data: healthEvents, isLoading } = useGetHealthEventsQuery( + { projectId: String(projectId) }, + { skip: !projectId }, + ) + + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( +
+
Unhealthy Events
+
+ {healthEvents?.map((event) => ( +
+
+
+ +
{event.provider_name} Provider
+
+
+ + {moment(event.created_at).format('Do MMM YYYY HH:mma')} + +
+
+
+ + +
+
+ ))} +
+
+ ) +} + +export default FeatureHealthTabContent diff --git a/frontend/web/components/pages/HomeAside.tsx b/frontend/web/components/pages/HomeAside.tsx index aa4b21473505..fe04b38ca677 100644 --- a/frontend/web/components/pages/HomeAside.tsx +++ b/frontend/web/components/pages/HomeAside.tsx @@ -22,6 +22,7 @@ import { components } from 'react-select' import SettingsIcon from 'components/svg/SettingsIcon' import BuildVersion from 'components/BuildVersion' import { useGetHealthEventsQuery } from 'common/services/useHealthEvents' +import Constants from 'common/constants' type HomeAsideType = { environmentId: string @@ -109,7 +110,7 @@ const HomeAside: FC = ({ }) => { const { data: healthEvents } = useGetHealthEventsQuery( { projectId: projectId }, - { refetchOnFocus: false, skip: !projectId }, + { skip: !projectId }, ) useEffect(() => { @@ -199,7 +200,7 @@ const HomeAside: FC = ({ container: (base: any) => ({ ...base, border: hasUnhealthyEnvironments - ? '1px solid #D35400' + ? `1px solid ${Constants.featureHealth.unhealthyColor}` : 'none', borderRadius: 6, padding: 0, diff --git a/frontend/web/components/tags/Tag.tsx b/frontend/web/components/tags/Tag.tsx index 365b313ea94a..3940f5ae56a7 100644 --- a/frontend/web/components/tags/Tag.tsx +++ b/frontend/web/components/tags/Tag.tsx @@ -5,6 +5,7 @@ import { Tag as TTag } from 'common/types/responses' import ToggleChip from 'components/ToggleChip' import Utils from 'common/utils/utils' import TagContent from './TagContent' +import Constants from 'common/constants' type TagType = { className?: string @@ -20,7 +21,7 @@ export const getTagColor = (tag: Partial, selected?: boolean) => { return '#9DA4AE' } if (tag.type === 'UNHEALTHY') { - return '#D35400' + return Constants.featureHealth.unhealthyColor } if (selected) { return tag.color diff --git a/frontend/web/components/tags/TagContent.tsx b/frontend/web/components/tags/TagContent.tsx index dd3e6170d21c..a6a27cba490b 100644 --- a/frontend/web/components/tags/TagContent.tsx +++ b/frontend/web/components/tags/TagContent.tsx @@ -2,7 +2,7 @@ import React, { FC } from 'react' import { Tag as TTag } from 'common/types/responses' import Format from 'common/utils/format' import { IonIcon } from '@ionic/react' -import { alarmOutline, lockClosed } from 'ionicons/icons' +import { alarmOutline, lockClosed, warning } from 'ionicons/icons' import Tooltip from 'components/Tooltip' import { getTagColor } from './Tag' import OrganisationStore from 'common/stores/organisation-store' @@ -30,6 +30,8 @@ const renderIcon = ( switch (tagType) { case 'STALE': return + case 'UNHEALTHY': + return case 'GITHUB': switch (tagLabel) { case 'PR Open': @@ -95,7 +97,7 @@ const getTooltip = (tag: TTag | undefined) => { > ${`${escapeHTML(tag.label)}`} - ${tooltip || ''} + ${tooltip ?? ''}
` } return tooltip diff --git a/frontend/web/components/tags/TagValues.tsx b/frontend/web/components/tags/TagValues.tsx index 4e27248a982c..6e8273a21c1f 100644 --- a/frontend/web/components/tags/TagValues.tsx +++ b/frontend/web/components/tags/TagValues.tsx @@ -5,9 +5,12 @@ import { useGetTagsQuery } from 'common/services/useTag' import Utils from 'common/utils/utils' import Constants from 'common/constants' import { useHasPermission } from 'common/providers/Permission' +import { Tag as TTag } from 'common/types/responses' type TagValuesType = { - onAdd?: () => void + onAdd?: (tag?: TTag) => void + /** Optional callback function to handle click events on tags. If provided, it will override the onAdd callback. */ + onClick?: (tag?: TTag) => void value?: number[] projectId: string children?: ReactNode @@ -22,6 +25,7 @@ const TagValues: FC = ({ hideTags = [], inline, onAdd, + onClick, projectId, value, }) => { @@ -47,7 +51,7 @@ const TagValues: FC = ({ key={tag.id} className='chip--xs' hideNames={hideNames} - onClick={onAdd} + onClick={onAdd ?? onClick} tag={tag} /> ), @@ -61,7 +65,7 @@ const TagValues: FC = ({