Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { Meta, StoryObj } from '@storybook/react'

import { LemonSkeleton } from '@posthog/lemon-ui'

import { HogFunctionType, HogWatcherState } from '~/types'

import { expect, within } from 'storybook/test'

import { HogFunctionStatusIndicator } from './HogFunctionStatusIndicator'

const makeHogFunction = ({ enabled = true, state }: { enabled?: boolean; state?: HogWatcherState }): HogFunctionType =>
({
id: `destination-${state ?? 'unknown'}-${enabled ? 'enabled' : 'paused'}`,
enabled,
type: 'destination',
status: state === undefined ? null : { state, tokens: 0 },
}) as HogFunctionType

const statusRows: { label: string; hogFunction: HogFunctionType }[] = [
{ label: 'Unknown', hogFunction: makeHogFunction({}) },
{ label: 'Healthy', hogFunction: makeHogFunction({ state: HogWatcherState.healthy }) },
{ label: 'Degraded', hogFunction: makeHogFunction({ state: HogWatcherState.overflowed }) },
{ label: 'Forcefully degraded', hogFunction: makeHogFunction({ state: HogWatcherState.forcefully_degraded }) },
{ label: 'Error-disabled', hogFunction: makeHogFunction({ state: HogWatcherState.disabled }) },
{ label: 'Forcefully disabled', hogFunction: makeHogFunction({ state: HogWatcherState.forcefully_disabled }) },
{ label: 'Paused manually', hogFunction: makeHogFunction({ enabled: false }) },
]

const meta: Meta<typeof HogFunctionStatusIndicator> = {
title: 'Scenes-App/HogFunctions/Components/HogFunctionStatusIndicator',
component: HogFunctionStatusIndicator,
parameters: {
a11y: { test: 'error' },
},
}

export default meta
type Story = StoryObj<typeof HogFunctionStatusIndicator>

export const AllStates: Story = {
args: {
hogFunction: statusRows[0].hogFunction,
},
render: () => (
<div className="flex flex-col gap-6 max-w-240 w-full p-4">
<section aria-labelledby="destination-list-statuses">
<h3 id="destination-list-statuses">Destination list</h3>
<div className="rounded border bg-surface-primary">
<div className="grid grid-cols-2 gap-4 px-3 py-2 border-b font-semibold">
<span>State</span>
<span>Status</span>
</div>
{statusRows.map(({ label, hogFunction }) => (
<div
key={label}
className="grid grid-cols-2 gap-4 items-center px-3 py-2 border-b last:border-b-0"
>
<span>{label}</span>
<div className="flex">
<HogFunctionStatusIndicator hogFunction={hogFunction} />
</div>
</div>
))}
<div className="grid grid-cols-2 gap-4 items-center px-3 py-2">
<span>Loading</span>
<LemonSkeleton className="w-16 h-5" />
</div>
</div>
</section>

<section aria-labelledby="destination-detail-status">
<h3 id="destination-detail-status">Destination detail</h3>
<div className="flex items-center justify-between gap-4 rounded border bg-surface-primary p-3">
<span className="font-semibold">Status</span>
<HogFunctionStatusIndicator
hogFunction={makeHogFunction({ state: HogWatcherState.forcefully_degraded })}
/>
</div>
</section>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)

await expect(canvas.getAllByLabelText('Status: Active')).toHaveLength(2)
await expect(canvas.getAllByLabelText('Status: Degraded')).toHaveLength(3)
await expect(canvas.getAllByLabelText('Status: Disabled')).toHaveLength(2)
await expect(canvas.getByLabelText('Status: Paused')).toBeVisible()

for (const status of canvas.getAllByLabelText(/^Status:/)) {
await expect(status).toHaveTextContent(/Active|Degraded|Disabled|Paused/)
await expect(status.querySelector('svg')).toHaveAttribute('aria-hidden', 'true')
}
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import '@testing-library/jest-dom'

import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

import { HogFunctionType, HogWatcherState } from '~/types'

import { HogFunctionStatusIndicator } from './HogFunctionStatusIndicator'

const makeHogFunction = ({
enabled = true,
state,
type = 'destination',
}: {
enabled?: boolean
state?: HogWatcherState
type?: HogFunctionType['type']
}): HogFunctionType =>
({
id: 'destination-id',
enabled,
type,
status: state === undefined ? null : { state, tokens: 0 },
}) as HogFunctionType

describe('HogFunctionStatusIndicator', () => {
afterEach(cleanup)

it.each([
['unknown', undefined, 'Active'],
['healthy', HogWatcherState.healthy, 'Active'],
['degraded', HogWatcherState.overflowed, 'Degraded'],
['disabled', HogWatcherState.disabled, 'Disabled'],
['forcefully degraded', HogWatcherState.forcefully_degraded, 'Degraded'],
['forcefully disabled', HogWatcherState.forcefully_disabled, 'Disabled'],
])('renders the %s state with text, a shape, and an accessible name', (_, state, display) => {
render(<HogFunctionStatusIndicator hogFunction={makeHogFunction({ state })} />)

const status = screen.getByLabelText(`Status: ${display}`)

expect(status).toHaveTextContent(display)
expect(status.querySelector('svg')).toHaveAttribute('aria-hidden', 'true')
})

it('distinguishes a manually paused destination from an error-disabled destination', () => {
render(<HogFunctionStatusIndicator hogFunction={makeHogFunction({ enabled: false })} />)

expect(screen.getByLabelText('Status: Paused')).toHaveTextContent('Paused')
expect(screen.queryByLabelText('Status: Disabled')).not.toBeInTheDocument()
})

it('keeps the detailed explanation supplementary to the visible status', async () => {
const user = userEvent.setup()
render(
<HogFunctionStatusIndicator hogFunction={makeHogFunction({ state: HogWatcherState.forcefully_degraded })} />
)

expect(screen.queryByText(/separate processing queue/)).not.toBeInTheDocument()
await user.click(screen.getByLabelText('Status: Degraded'))
expect(await screen.findByText(/separate processing queue/)).toBeVisible()
})

it.each(['site_destination', 'site_app'] as const)('does not show a status for %s functions', (type) => {
const { container } = render(<HogFunctionStatusIndicator hogFunction={makeHogFunction({ type })} />)

expect(container).toBeEmptyDOMElement()
})
})
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
import { LemonDropdown, LemonTag, LemonTagProps } from '@posthog/lemon-ui'
import { IconCheckCircle, IconPause, IconWarning, IconXCircle } from '@posthog/icons'
import { LemonDropdown, LemonTag, type LemonTagProps } from '@posthog/lemon-ui'

import { HogFunctionType, HogWatcherState } from '~/types'

type DisplayOptions = { tagType: LemonTagProps['type']; display: string; description: JSX.Element }
type DisplayOptions = {
tagType: LemonTagProps['type']
display: string
description: JSX.Element
icon: JSX.Element
}
const displayMap: Record<HogWatcherState, DisplayOptions> = {
[HogWatcherState.healthy]: {
tagType: 'success',
display: 'Active',
description: <>The function is running as expected.</>,
icon: <IconCheckCircle className="text-success" aria-hidden="true" />,
},
[HogWatcherState.overflowed]: {
tagType: 'caution',
tagType: 'warning',
display: 'Degraded',
description: (
<>
The function is running slow or has issues performing async requests. It has been moved to the slow lane
and may be processing slower than usual.
</>
),
icon: <IconWarning className="text-warning" aria-hidden="true" />,
},
[HogWatcherState.disabled]: {
tagType: 'danger',
Expand All @@ -28,21 +36,24 @@ const displayMap: Record<HogWatcherState, DisplayOptions> = {
config. Updating your function will re-enable it.
</>
),
icon: <IconXCircle className="text-danger" aria-hidden="true" />,
},
[HogWatcherState.forcefully_degraded]: {
tagType: 'caution',
tagType: 'warning',
display: 'Degraded',
description: (
<>
The function has been forcefully marked as degraded by a PostHog admin. This means it is moved to a
separate processing queue and may experience delays or increased failures.
</>
),
icon: <IconWarning className="text-warning" aria-hidden="true" />,
},
[HogWatcherState.forcefully_disabled]: {
tagType: 'danger',
display: 'Disabled',
description: <>The function has been forcefully disabled by a PostHog admin. Please contact support.</>,
icon: <IconXCircle className="text-danger" aria-hidden="true" />,
},
}

Expand All @@ -55,12 +66,14 @@ const DEFAULT_DISPLAY: DisplayOptions = {
invocations have been performed.
</>
),
icon: <IconCheckCircle className="text-success" aria-hidden="true" />,
}

const DISABLED_MANUALLY_DISPLAY: DisplayOptions = {
tagType: 'default',
display: 'Paused',
description: <>This function is paused</>,
icon: <IconPause className="text-secondary" aria-hidden="true" />,
}

export type HogFunctionStatusIndicatorProps = {
Expand All @@ -74,7 +87,7 @@ export function HogFunctionStatusIndicator({ hogFunction }: HogFunctionStatusInd
return null
}

const { tagType, display, description } = !hogFunction.enabled
const { tagType, display, description, icon } = !hogFunction.enabled
? DISABLED_MANUALLY_DISPLAY
: hogFunction.status?.state
? displayMap[hogFunction.status.state]
Expand All @@ -86,15 +99,26 @@ export function HogFunctionStatusIndicator({ hogFunction }: HogFunctionStatusInd
<>
<div className="p-2 deprecated-space-y-2 max-w-120">
<h2 className="flex gap-2 items-center m-0">
Function status - <LemonTag type={tagType}>{display}</LemonTag>
Function status -
<LemonTag type={tagType} icon={icon} className="!text-primary">
{display}
</LemonTag>
</h2>

<p>{description}</p>
</div>
</>
}
>
<LemonTag type={tagType}>{display}</LemonTag>
<LemonTag
type={tagType}
size="small"
icon={icon}
className="!text-primary"
aria-label={`Status: ${display}`}
>
{display}
</LemonTag>
</LemonDropdown>
)
}
Loading