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