From cbc0c39792dacd5f268999e2f49d512a6ba097f8 Mon Sep 17 00:00:00 2001 From: Kayla Washburn Date: Fri, 13 Oct 2023 10:39:20 -0600 Subject: [PATCH] fix: display health alert in `DeploymentBannerView` (#10193) --- site/src/api/api.ts | 23 +-- site/src/api/queries/deployment.ts | 9 +- .../components/Dashboard/DashboardLayout.tsx | 2 - .../DeploymentBanner/DeploymentBanner.tsx | 14 +- .../DeploymentBannerView.stories.tsx | 13 +- .../DeploymentBanner/DeploymentBannerView.tsx | 144 ++++++++++++++---- .../src/components/Dashboard/HealthBanner.tsx | 45 ------ .../components/HelpTooltip/HelpTooltip.tsx | 6 +- .../PopoverContainer.stories.tsx | 5 - .../GeneralSettingsPage.tsx | 8 +- site/src/pages/HealthPage/HealthPage.tsx | 4 +- site/src/testHelpers/entities.ts | 15 +- 12 files changed, 177 insertions(+), 111 deletions(-) delete mode 100644 site/src/components/Dashboard/HealthBanner.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 32f8889542..1aa5101d9f 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1516,14 +1516,17 @@ export const getInsightsTemplate = async ( return response.data; }; -export const getHealth = () => { - return axios.get<{ - healthy: boolean; - time: string; - coder_version: string; - derp: { healthy: boolean }; - access_url: { healthy: boolean }; - websocket: { healthy: boolean }; - database: { healthy: boolean }; - }>("/api/v2/debug/health"); +export interface Health { + healthy: boolean; + time: string; + coder_version: string; + access_url: { healthy: boolean }; + database: { healthy: boolean }; + derp: { healthy: boolean }; + websocket: { healthy: boolean }; +} + +export const getHealth = async () => { + const response = await axios.get("/api/v2/debug/health"); + return response.data; }; diff --git a/site/src/api/queries/deployment.ts b/site/src/api/queries/deployment.ts index 0d095258c5..2ad91c7724 100644 --- a/site/src/api/queries/deployment.ts +++ b/site/src/api/queries/deployment.ts @@ -17,6 +17,13 @@ export const deploymentDAUs = () => { export const deploymentStats = () => { return { queryKey: ["deployment", "stats"], - queryFn: () => API.getDeploymentStats(), + queryFn: API.getDeploymentStats, + }; +}; + +export const health = () => { + return { + queryKey: ["deployment", "health"], + queryFn: API.getHealth, }; }; diff --git a/site/src/components/Dashboard/DashboardLayout.tsx b/site/src/components/Dashboard/DashboardLayout.tsx index 4b5fe52d61..8aa96360d2 100644 --- a/site/src/components/Dashboard/DashboardLayout.tsx +++ b/site/src/components/Dashboard/DashboardLayout.tsx @@ -15,7 +15,6 @@ import Box, { BoxProps } from "@mui/material/Box"; import InfoOutlined from "@mui/icons-material/InfoOutlined"; import Button from "@mui/material/Button"; import { docs } from "utils/docs"; -import { HealthBanner } from "./HealthBanner"; export const DashboardLayout: FC = () => { const permissions = usePermissions(); @@ -29,7 +28,6 @@ export const DashboardLayout: FC = () => { return ( <> - {canViewDeployment && } diff --git a/site/src/components/Dashboard/DeploymentBanner/DeploymentBanner.tsx b/site/src/components/Dashboard/DeploymentBanner/DeploymentBanner.tsx index 6fc3acea0d..5ebeff79b4 100644 --- a/site/src/components/Dashboard/DeploymentBanner/DeploymentBanner.tsx +++ b/site/src/components/Dashboard/DeploymentBanner/DeploymentBanner.tsx @@ -1,11 +1,18 @@ +import { type FC } from "react"; +import { useQuery } from "react-query"; +import { deploymentStats, health } from "api/queries/deployment"; import { usePermissions } from "hooks/usePermissions"; import { DeploymentBannerView } from "./DeploymentBannerView"; -import { useQuery } from "react-query"; -import { deploymentStats } from "api/queries/deployment"; +import { useDashboard } from "../DashboardProvider"; -export const DeploymentBanner: React.FC = () => { +export const DeploymentBanner: FC = () => { + const dashboard = useDashboard(); const permissions = usePermissions(); const deploymentStatsQuery = useQuery(deploymentStats()); + const healthQuery = useQuery({ + ...health(), + enabled: dashboard.experiments.includes("deployment_health_page"), + }); if (!permissions.viewDeploymentValues || !deploymentStatsQuery.data) { return null; @@ -13,6 +20,7 @@ export const DeploymentBanner: React.FC = () => { return ( deploymentStatsQuery.refetch()} /> diff --git a/site/src/components/Dashboard/DeploymentBanner/DeploymentBannerView.stories.tsx b/site/src/components/Dashboard/DeploymentBanner/DeploymentBannerView.stories.tsx index 38a21c2598..2afecffd53 100644 --- a/site/src/components/Dashboard/DeploymentBanner/DeploymentBannerView.stories.tsx +++ b/site/src/components/Dashboard/DeploymentBanner/DeploymentBannerView.stories.tsx @@ -1,5 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { MockDeploymentStats } from "testHelpers/entities"; +import { + DeploymentHealthUnhealthy, + MockDeploymentStats, +} from "testHelpers/entities"; import { DeploymentBannerView } from "./DeploymentBannerView"; const meta: Meta = { @@ -13,4 +16,10 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Preview: Story = {}; +export const Example: Story = {}; + +export const WithHealthIssues: Story = { + args: { + health: DeploymentHealthUnhealthy, + }, +}; diff --git a/site/src/components/Dashboard/DeploymentBanner/DeploymentBannerView.tsx b/site/src/components/Dashboard/DeploymentBanner/DeploymentBannerView.tsx index dda9337155..27b391d717 100644 --- a/site/src/components/Dashboard/DeploymentBanner/DeploymentBannerView.tsx +++ b/site/src/components/Dashboard/DeploymentBanner/DeploymentBannerView.tsx @@ -1,9 +1,14 @@ -import { DeploymentStats, WorkspaceStatus } from "api/typesGenerated"; -import { FC, useMemo, useEffect, useState } from "react"; +import type { Health } from "api/api"; +import type { DeploymentStats, WorkspaceStatus } from "api/typesGenerated"; +import { + type FC, + useMemo, + useEffect, + useState, + PropsWithChildren, +} from "react"; import prettyBytes from "pretty-bytes"; import BuildingIcon from "@mui/icons-material/Build"; -import { RocketIcon } from "components/Icons/RocketIcon"; -import { MONOSPACE_FONT_FAMILY } from "theme/constants"; import Tooltip from "@mui/material/Tooltip"; import { Link as RouterLink } from "react-router-dom"; import Link from "@mui/material/Link"; @@ -12,13 +17,26 @@ import DownloadIcon from "@mui/icons-material/CloudDownload"; import UploadIcon from "@mui/icons-material/CloudUpload"; import LatencyIcon from "@mui/icons-material/SettingsEthernet"; import WebTerminalIcon from "@mui/icons-material/WebAsset"; -import { TerminalIcon } from "components/Icons/TerminalIcon"; -import dayjs from "dayjs"; import CollectedIcon from "@mui/icons-material/Compare"; import RefreshIcon from "@mui/icons-material/Refresh"; import Button from "@mui/material/Button"; +import { css as className } from "@emotion/css"; +import { + css, + type CSSObject, + type Theme, + type Interpolation, + useTheme, +} from "@emotion/react"; +import dayjs from "dayjs"; +import { TerminalIcon } from "components/Icons/TerminalIcon"; +import { RocketIcon } from "components/Icons/RocketIcon"; +import ErrorIcon from "@mui/icons-material/ErrorOutline"; +import { MONOSPACE_FONT_FAMILY } from "theme/constants"; import { getDisplayWorkspaceStatus } from "utils/workspace"; -import { css, type Theme, type Interpolation, useTheme } from "@emotion/react"; +import { colors } from "theme/colors"; +import { HelpTooltipTitle } from "components/HelpTooltip/HelpTooltip"; +import { Stack } from "components/Stack/Stack"; export const bannerHeight = 36; @@ -49,14 +67,13 @@ const styles = { } satisfies Record>; export interface DeploymentBannerViewProps { - fetchStats?: () => void; + health?: Health; stats?: DeploymentStats; + fetchStats?: () => void; } -export const DeploymentBannerView: FC = ({ - stats, - fetchStats, -}) => { +export const DeploymentBannerView: FC = (props) => { + const { health, stats, fetchStats } = props; const theme = useTheme(); const aggregatedMinutes = useMemo(() => { if (!stats) { @@ -105,6 +122,35 @@ export const DeploymentBannerView: FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps -- We want this to periodically update! }, [timeUntilRefresh, stats]); + const unhealthy = health && !health.healthy; + + const statusBadgeStyle = css` + display: flex; + align-items: center; + justify-content: center; + background-color: ${unhealthy ? colors.red[10] : undefined}; + padding: ${theme.spacing(0, 1.5)}; + height: ${bannerHeight}px; + color: #fff; + + & svg { + width: 16px; + height: 16px; + } + `; + + const statusSummaryStyle = className` + ${theme.typography.body2 as CSSObject} + + margin: ${theme.spacing(0, 0, 0.5, 1.5)}; + width: ${theme.spacing(50)}; + padding: ${theme.spacing(2)}; + color: ${theme.palette.text.primary}; + background-color: ${theme.palette.background.paper}; + border: 1px solid ${theme.palette.divider}; + pointer-events: none; + `; + return (
= ({ height: bannerHeight, bottom: 0, zIndex: 1, - padding: theme.spacing(0, 2), + paddingRight: theme.spacing(2), backgroundColor: theme.palette.background.paper, display: "flex", alignItems: "center", @@ -124,24 +170,51 @@ export const DeploymentBannerView: FC = ({ whiteSpace: "nowrap", }} > - -
- -
+ + + We have detected problems with your Coder deployment. + + + {health.access_url && ( + + Your access URL may be configured incorrectly. + + )} + {health.database && ( + Your database is unhealthy. + )} + {health.derp && ( + + We're noticing DERP proxy issues. + + )} + {health.websocket && ( + + We're noticing websocket issues. + + )} + + + ) : ( + <>Status of your Coder deployment. Only visible for admins! + ) + } + open={process.env.STORYBOOK === "true" ? true : undefined} + css={{ marginRight: theme.spacing(-2) }} + > + {unhealthy ? ( + + + + ) : ( +
+ +
+ )}
Workspaces
@@ -330,3 +403,12 @@ const WorkspaceBuildValue: FC<{ ); }; + +const HealthIssue: FC = ({ children }) => { + return ( + + + {children} + + ); +}; diff --git a/site/src/components/Dashboard/HealthBanner.tsx b/site/src/components/Dashboard/HealthBanner.tsx deleted file mode 100644 index a1bec582fd..0000000000 --- a/site/src/components/Dashboard/HealthBanner.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Alert } from "components/Alert/Alert"; -import { Link as RouterLink } from "react-router-dom"; -import Link from "@mui/material/Link"; -import { colors } from "theme/colors"; -import { useQuery } from "react-query"; -import { getHealth } from "api/api"; -import { useDashboard } from "./DashboardProvider"; - -export const HealthBanner = () => { - const { data: healthStatus } = useQuery({ - queryKey: ["health"], - queryFn: () => getHealth(), - }); - const dashboard = useDashboard(); - const hasHealthIssues = healthStatus && !healthStatus.data.healthy; - - if ( - dashboard.experiments.includes("deployment_health_page") && - hasHealthIssues - ) { - return ( - - We have detected problems with your Coder deployment. Please{" "} - - inspect the health status - - . - - ); - } - - return null; -}; diff --git a/site/src/components/HelpTooltip/HelpTooltip.tsx b/site/src/components/HelpTooltip/HelpTooltip.tsx index 8050c43379..92990c318f 100644 --- a/site/src/components/HelpTooltip/HelpTooltip.tsx +++ b/site/src/components/HelpTooltip/HelpTooltip.tsx @@ -159,9 +159,7 @@ export const HelpTooltip: FC> = ({ ); }; -export const HelpTooltipTitle: FC> = ({ - children, -}) => { +export const HelpTooltipTitle: FC = ({ children }) => { return

{children}

; }; @@ -242,7 +240,7 @@ const styles = { marginBottom: theme.spacing(1), color: theme.palette.text.primary, fontSize: 14, - lineHeight: "120%", + lineHeight: "150%", fontWeight: 600, }), diff --git a/site/src/components/PopoverContainer/PopoverContainer.stories.tsx b/site/src/components/PopoverContainer/PopoverContainer.stories.tsx index f8a848388a..879faf7d38 100644 --- a/site/src/components/PopoverContainer/PopoverContainer.stories.tsx +++ b/site/src/components/PopoverContainer/PopoverContainer.stories.tsx @@ -2,11 +2,6 @@ import { Meta, StoryObj } from "@storybook/react"; import { PopoverContainer } from "./PopoverContainer"; import Button from "@mui/material/Button"; -const numbers: number[] = []; -for (let i = 0; i < 20; i++) { - numbers.push(i + 1); -} - const meta: Meta = { title: "components/PopoverContainer", component: PopoverContainer, diff --git a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx index a36e7c4415..42cfcc1e63 100644 --- a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx @@ -1,11 +1,11 @@ -import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout"; -import { FC } from "react"; +import { type FC } from "react"; import { Helmet } from "react-helmet-async"; -import { pageTitle } from "utils/page"; -import { GeneralSettingsPageView } from "./GeneralSettingsPageView"; import { useQuery } from "react-query"; +import { pageTitle } from "utils/page"; import { deploymentDAUs } from "api/queries/deployment"; import { entitlements } from "api/queries/entitlements"; +import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout"; +import { GeneralSettingsPageView } from "./GeneralSettingsPageView"; const GeneralSettingsPage: FC = () => { const { deploymentValues } = useDeploySettings(); diff --git a/site/src/pages/HealthPage/HealthPage.tsx b/site/src/pages/HealthPage/HealthPage.tsx index 8ab285148a..bba2c2634d 100644 --- a/site/src/pages/HealthPage/HealthPage.tsx +++ b/site/src/pages/HealthPage/HealthPage.tsx @@ -42,7 +42,7 @@ export default function HealthPage() { {healthStatus ? ( - + ) : ( )} @@ -54,7 +54,7 @@ export function HealthPageView({ healthStatus, tab, }: { - healthStatus: Awaited>["data"]; + healthStatus: Awaited>; tab: ReturnType; }) { const styles = useStyles(); diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index dd91f44f24..e819fee968 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1,7 +1,8 @@ import { withDefaultFeatures, - GetLicensesResponse, - DeploymentConfig, + type GetLicensesResponse, + type DeploymentConfig, + type Health, } from "api/api"; import { FieldError } from "api/errors"; import { everyOneGroup } from "utils/groups"; @@ -2752,3 +2753,13 @@ export const MockListeningPortsResponse: TypesGen.WorkspaceAgentListeningPortsRe { process_name: "", network: "", port: 8081 }, ], }; + +export const DeploymentHealthUnhealthy: Health = { + healthy: false, + time: "2023-10-12T23:15:00.000000000Z", + coder_version: "v2.3.0-devel+8cca4915a", + access_url: { healthy: false }, + database: { healthy: false }, + derp: { healthy: false }, + websocket: { healthy: false }, +};