diff --git a/frontend/src/scenes/health/components/HealthEmptyState.test.tsx b/frontend/src/scenes/health/components/HealthEmptyState.test.tsx new file mode 100644 index 000000000000..edd98984ba97 --- /dev/null +++ b/frontend/src/scenes/health/components/HealthEmptyState.test.tsx @@ -0,0 +1,25 @@ +import '@testing-library/jest-dom' + +import { cleanup, render, screen } from '@testing-library/react' + +import { HealthEmptyState } from './HealthEmptyState' + +describe('HealthEmptyState', () => { + afterEach(cleanup) + + it('points a project with no events at install instead of declaring it healthy', () => { + render() + + expect(screen.getByText('Health checks have not run yet')).toBeInTheDocument() + // LemonBanner renders the action twice for its responsive layout, so there is at least one. + expect(screen.getAllByText('Install PostHog').length).toBeGreaterThan(0) + expect(screen.queryByText('All systems healthy')).not.toBeInTheDocument() + }) + + it('declares a project with events healthy when no issues are found', () => { + render() + + expect(screen.getByText('All systems healthy')).toBeInTheDocument() + expect(screen.queryByText('Install PostHog')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/scenes/health/components/HealthEmptyState.tsx b/frontend/src/scenes/health/components/HealthEmptyState.tsx new file mode 100644 index 000000000000..52ee058cd130 --- /dev/null +++ b/frontend/src/scenes/health/components/HealthEmptyState.tsx @@ -0,0 +1,38 @@ +import { LemonBanner } from '@posthog/lemon-ui' + +import { urls } from 'scenes/urls' + +import { ProductKey } from '~/queries/schema/schema-general' +import { OnboardingStepKey } from '~/types' + +export function HealthEmptyState({ hasIngestedEvents }: { hasIngestedEvents: boolean }): JSX.Element { + // Without any ingested events the checks have nothing to run against, so treat an empty result + // set as "not set up yet" and point the user at install rather than claiming everything is fine. + if (!hasIngestedEvents) { + return ( + +

Health checks have not run yet

+

+ Health checks start once your project receives data. Install PostHog to send your first events. +

+
+ ) + } + + return ( + +

All systems healthy

+

No active health issues found for your project.

+
+ ) +} diff --git a/frontend/src/scenes/health/components/HealthIssueList.tsx b/frontend/src/scenes/health/components/HealthIssueList.tsx index a812f1715f40..c9d1cabfcebe 100644 --- a/frontend/src/scenes/health/components/HealthIssueList.tsx +++ b/frontend/src/scenes/health/components/HealthIssueList.tsx @@ -10,20 +10,23 @@ import type { HealthIssueCategory } from '../healthCategories' import { healthSceneLogic } from '../healthSceneLogic' import { severityToTagType, worstSeverity } from '../healthUtils' import type { HealthIssue } from '../types' +import { HealthEmptyState } from './HealthEmptyState' import { HealthIssueCard } from './HealthIssueCard' export const HealthIssueList = (): JSX.Element => { - const { issues, healthIssuesLoading, healthIssues } = useValues(healthSceneLogic) + const { issues, healthIssuesLoading, healthIssues, hasIngestedEvents, currentTeam } = useValues(healthSceneLogic) const { snoozeIssue, dismissIssue, undismissIssue, loadHealthIssues } = useActions(healthSceneLogic) + const loadingSkeleton = ( +
+ + + +
+ ) + if (healthIssuesLoading && !healthIssues) { - return ( -
- - - -
- ) + return loadingSkeleton } if (!healthIssuesLoading && healthIssues === null) { @@ -35,12 +38,14 @@ export const HealthIssueList = (): JSX.Element => { } if (issues.length === 0) { - return ( - -

All systems healthy

-

No active health issues found for your project.

-
- ) + // The health request resolves off currentTeamIdStrict, which falls back to "@current", so it + // can finish before currentTeam itself loads (e.g. during OAuth bootstrap). Until the team + // resolves we can't tell "no events yet" from "already installed", so keep the loading state + // rather than flash the install prompt at a project that is already installed. + if (!currentTeam) { + return loadingSkeleton + } + return } const groupedByCategory: Partial> = {} diff --git a/frontend/src/scenes/health/components/HealthIssueSummaryCards.tsx b/frontend/src/scenes/health/components/HealthIssueSummaryCards.tsx index b86ce5e53a86..e296832c0870 100644 --- a/frontend/src/scenes/health/components/HealthIssueSummaryCards.tsx +++ b/frontend/src/scenes/health/components/HealthIssueSummaryCards.tsx @@ -13,7 +13,7 @@ import { severityColor } from '../healthUtils' import type { CategoryHealthSummary } from '../types' export const HealthIssueSummaryCards = (): JSX.Element => { - const { categorySummaries, healthIssuesLoading, healthIssues } = useValues(healthSceneLogic) + const { categorySummaries, healthIssuesLoading, healthIssues, hasIngestedEvents } = useValues(healthSceneLogic) if (healthIssuesLoading && !healthIssues) { return ( @@ -29,6 +29,13 @@ export const HealthIssueSummaryCards = (): JSX.Element => { return <> } + // A project that has never ingested an event has no data for any check to run against, so the + // per-category "healthy" cards would be a premature success claim — and would contradict the + // install prompt the empty issue list shows just below them. Hide the cards until data arrives. + if (!hasIngestedEvents) { + return <> + } + return (
{categorySummaries.map((summary: CategoryHealthSummary) => ( diff --git a/frontend/src/scenes/health/components/HealthTables.stories.tsx b/frontend/src/scenes/health/components/HealthTables.stories.tsx index 34d48612cd3b..954ac1440b8f 100644 --- a/frontend/src/scenes/health/components/HealthTables.stories.tsx +++ b/frontend/src/scenes/health/components/HealthTables.stories.tsx @@ -4,6 +4,7 @@ import DataModelingDetailContent from '../categoryDetail/categories/DataModeling import { SdkOutdatedRenderer } from '../renderers/SdkOutdatedRenderer' import type { HealthIssue, HealthIssueSeverity } from '../types' import { DataModelingHealthTable } from './DataModelingHealthTable' +import { HealthEmptyState } from './HealthEmptyState' import { IngestionWarningTable } from './IngestionWarningTable' import { PipelineHealthTable } from './PipelineHealthTable' import { WebAnalyticsHealthTable } from './WebAnalyticsHealthTable' @@ -298,6 +299,10 @@ export const WebAnalyticsEmpty: StoryFn = () => ( ) +export const EmptyAllHealthy: StoryFn = () => + +export const EmptyNoEventsYet: StoryFn = () => + export const SdkOutdatedDefault: StoryFn = () => export const SdkOutdatedEmpty: StoryFn = () => diff --git a/frontend/src/scenes/health/healthSceneLogic.tsx b/frontend/src/scenes/health/healthSceneLogic.tsx index 666964fa3ee8..418a831085ed 100644 --- a/frontend/src/scenes/health/healthSceneLogic.tsx +++ b/frontend/src/scenes/health/healthSceneLogic.tsx @@ -8,11 +8,17 @@ import { sceneConfigurations } from 'scenes/scenes' import { Scene } from 'scenes/sceneTypes' import { teamLogic } from 'scenes/teamLogic' -import { Breadcrumb } from '~/types' +import { Breadcrumb, TeamPublicType, TeamType } from '~/types' import { CATEGORY_ORDER, HEALTH_CATEGORY_CONFIG, categoryForKind } from './healthCategories' import type { CategoryHealthSummary, HealthIssue, HealthIssueSeverity } from './types' -import { REFRESH_COOLDOWN_MS, REFRESH_POLL_COUNT, REFRESH_POLL_INTERVAL_MS, SEVERITY_ORDER } from './types' +import { + HEALTH_ISSUES_LOAD_TIMEOUT_MS, + REFRESH_COOLDOWN_MS, + REFRESH_POLL_COUNT, + REFRESH_POLL_INTERVAL_MS, + SEVERITY_ORDER, +} from './types' export interface HealthIssuesResponse { results: HealthIssue[] @@ -23,9 +29,11 @@ export interface HealthIssuesResponse { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface healthSceneLogicValues { + currentTeam: TeamPublicType | TeamType | null // teamLogic currentTeamIdStrict: number | string // teamLogic breadcrumbs: Breadcrumb[] categorySummaries: CategoryHealthSummary[] + hasIngestedEvents: boolean healthIssues: HealthIssuesResponse | null healthIssuesLoading: boolean isManualRefresh: boolean @@ -88,6 +96,7 @@ export interface healthSceneLogicMeta { __keaTypeGenInternalSelectorTypes: { issues: (healthIssues: HealthIssuesResponse | null) => HealthIssue[] totalCount: (healthIssues: HealthIssuesResponse | null) => number + hasIngestedEvents: (currentTeam: TeamPublicType | TeamType | null) => boolean categorySummaries: (issues: HealthIssue[]) => CategoryHealthSummary[] } } @@ -102,7 +111,7 @@ export type healthSceneLogicType = MakeLogicType< export const healthSceneLogic = kea([ path(['scenes', 'health', 'healthSceneLogic']), connect({ - values: [teamLogic, ['currentTeamIdStrict']], + values: [teamLogic, ['currentTeamIdStrict', 'currentTeam']], }), actions({ setShowDismissed: (show: boolean) => ({ show }), @@ -156,7 +165,7 @@ export const healthSceneLogic = kea([ const queryString = new URLSearchParams(params).toString() const url = `api/environments/${values.currentTeamIdStrict}/health_issues/?${queryString}` - return await api.get(url) + return await api.get(url, { signal: AbortSignal.timeout(HEALTH_ISSUES_LOAD_TIMEOUT_MS) }) }, }, ], @@ -170,6 +179,12 @@ export const healthSceneLogic = kea([ (s) => [s.healthIssues], (healthIssues: HealthIssuesResponse | null): number => healthIssues?.count ?? 0, ], + // A project that has never ingested an event has no data for any check to run against, so an + // empty result set there means "checks have not run yet", not "everything is healthy". + hasIngestedEvents: [ + (s) => [s.currentTeam], + (currentTeam: TeamPublicType | TeamType | null): boolean => !!currentTeam?.ingested_event, + ], categorySummaries: [ (s) => [s.issues], (issues: HealthIssue[]): CategoryHealthSummary[] => { diff --git a/frontend/src/scenes/health/types.ts b/frontend/src/scenes/health/types.ts index a7a23f432704..1e78d07a3fe0 100644 --- a/frontend/src/scenes/health/types.ts +++ b/frontend/src/scenes/health/types.ts @@ -12,6 +12,10 @@ export const REFRESH_COOLDOWN_MS = 5 * 60 * 1000 export const REFRESH_POLL_INTERVAL_MS = 5000 export const REFRESH_POLL_COUNT = 12 +// Bound the issues load so a request that never settles fails instead of leaving the page on +// skeletons forever. On timeout the loader rejects and the scene falls through to its retry banner. +export const HEALTH_ISSUES_LOAD_TIMEOUT_MS = 30 * 1000 + export interface HealthIssue { id: string kind: string