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}
/>
+
+
+ {isLoading ? 'Creating' : 'Create'}
+
+
-
-
- {isLoading ? 'Creating' : 'Create'}
-
-
)
}
@@ -137,13 +137,38 @@ const EditHealthProvider: FC = ({
unhealthy state in different environments.{' '}
Learn about Feature Health.
+
+
+
+ Follow the documentation to configure alerting using the supported
+ providers.
+
+
+
+
+
Provider Name
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) => (
+
window.open(urlBlock.url, '_blank')}
+ >
+ {urlBlock.title ?? 'Link'}{' '}
+
+
+ ))}
+
+ )
+}
+
+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 = ({
onAdd?.()}
type='button'
theme='outline'
>