diff --git a/site/.storybook/preview.jsx b/site/.storybook/preview.jsx index 6d67202178..5fd8b17e7b 100644 --- a/site/.storybook/preview.jsx +++ b/site/.storybook/preview.jsx @@ -38,7 +38,17 @@ export const decorators = [ }, (Story) => { return ( - + ); diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index ff0adab823..94ef2749f8 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -4,6 +4,7 @@ import AuditPage from "pages/AuditPage/AuditPage"; import LoginPage from "pages/LoginPage/LoginPage"; import { SetupPage } from "pages/SetupPage/SetupPage"; import { TemplateLayout } from "pages/TemplatePage/TemplateLayout"; +import { HealthLayout } from "pages/HealthPage/HealthLayout"; import TemplatesPage from "pages/TemplatesPage/TemplatesPage"; import UsersPage from "pages/UsersPage/UsersPage"; import WorkspacesPage from "pages/WorkspacesPage/WorkspacesPage"; @@ -197,9 +198,16 @@ const TemplateInsightsPage = lazy( () => import("./pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage"), ); -const HealthPage = lazy(() => import("./pages/HealthPage/HealthPage")); const GroupsPage = lazy(() => import("./pages/GroupsPage/GroupsPage")); const IconsPage = lazy(() => import("./pages/IconsPage/IconsPage")); +const AccessURLPage = lazy(() => import("./pages/HealthPage/AccessURLPage")); +const DatabasePage = lazy(() => import("./pages/HealthPage/DatabasePage")); +const DERPPage = lazy(() => import("./pages/HealthPage/DERPPage")); +const DERPRegionPage = lazy(() => import("./pages/HealthPage/DERPRegionPage")); +const WebsocketPage = lazy(() => import("./pages/HealthPage/WebsocketPage")); +const WorkspaceProxyHealthPage = lazy( + () => import("./pages/HealthPage/WorkspaceProxyPage"), +); export const AppRouter: FC = () => { return ( @@ -214,8 +222,6 @@ export const AppRouter: FC = () => { }> } /> - } /> - } @@ -339,6 +345,21 @@ export const AppRouter: FC = () => { } /> + }> + } /> + } /> + } /> + } /> + } + /> + } /> + } + /> + {/* Using path="*"" means "match anything", so this route acts like a catch-all for URLs that we don't have explicit routes for. */} diff --git a/site/src/components/Dashboard/Navbar/Navbar.tsx b/site/src/components/Dashboard/Navbar/Navbar.tsx index c84dc1f12b..13f704a86e 100644 --- a/site/src/components/Dashboard/Navbar/Navbar.tsx +++ b/site/src/components/Dashboard/Navbar/Navbar.tsx @@ -19,6 +19,9 @@ export const Navbar: FC = () => { const canViewAllUsers = Boolean(permissions.readAllUsers); const proxyContextValue = useProxy(); const dashboard = useDashboard(); + const canViewHealth = + canViewDeployment && + dashboard.experiments.includes("deployment_health_page"); return ( { canViewAuditLog={canViewAuditLog} canViewDeployment={canViewDeployment} canViewAllUsers={canViewAllUsers} + canViewHealth={canViewHealth} proxyContextValue={ dashboard.experiments.includes("moons") ? proxyContextValue : undefined } diff --git a/site/src/components/Dashboard/Navbar/NavbarView.test.tsx b/site/src/components/Dashboard/Navbar/NavbarView.test.tsx index f54c6ce22f..9a4dfda633 100644 --- a/site/src/components/Dashboard/Navbar/NavbarView.test.tsx +++ b/site/src/components/Dashboard/Navbar/NavbarView.test.tsx @@ -33,6 +33,7 @@ describe("NavbarView", () => { canViewAuditLog canViewDeployment canViewAllUsers + canViewHealth />, ); const workspacesLink = await screen.findByText(navLanguage.workspaces); @@ -48,6 +49,7 @@ describe("NavbarView", () => { canViewAuditLog canViewDeployment canViewAllUsers + canViewHealth />, ); const templatesLink = await screen.findByText(navLanguage.templates); @@ -63,6 +65,7 @@ describe("NavbarView", () => { canViewAuditLog canViewDeployment canViewAllUsers + canViewHealth />, ); const userLink = await screen.findByText(navLanguage.users); @@ -78,6 +81,7 @@ describe("NavbarView", () => { canViewAuditLog canViewDeployment canViewAllUsers + canViewHealth />, ); const auditLink = await screen.findByText(navLanguage.audit); @@ -93,6 +97,7 @@ describe("NavbarView", () => { canViewAuditLog canViewDeployment canViewAllUsers + canViewHealth />, ); const auditLink = await screen.findByText(navLanguage.deployment); diff --git a/site/src/components/Dashboard/Navbar/NavbarView.tsx b/site/src/components/Dashboard/Navbar/NavbarView.tsx index 2ef95770a8..25340c1198 100644 --- a/site/src/components/Dashboard/Navbar/NavbarView.tsx +++ b/site/src/components/Dashboard/Navbar/NavbarView.tsx @@ -33,6 +33,7 @@ export interface NavbarViewProps { canViewAuditLog: boolean; canViewDeployment: boolean; canViewAllUsers: boolean; + canViewHealth: boolean; proxyContextValue?: ProxyContextValue; } @@ -50,6 +51,7 @@ interface NavItemsProps { canViewAuditLog: boolean; canViewDeployment: boolean; canViewAllUsers: boolean; + canViewHealth: boolean; } const NavItems: FC = ({ @@ -57,6 +59,7 @@ const NavItems: FC = ({ canViewAuditLog, canViewDeployment, canViewAllUsers, + canViewHealth, }) => { const location = useLocation(); const theme = useTheme(); @@ -93,6 +96,11 @@ const NavItems: FC = ({ {Language.deployment} )} + {canViewHealth && ( + + Health + + )} ); }; @@ -106,6 +114,7 @@ export const NavbarView: FC = ({ canViewAuditLog, canViewDeployment, canViewAllUsers, + canViewHealth, proxyContextValue, }) => { const theme = useTheme(); @@ -150,6 +159,7 @@ export const NavbarView: FC = ({ canViewAuditLog={canViewAuditLog} canViewDeployment={canViewDeployment} canViewAllUsers={canViewAllUsers} + canViewHealth={canViewHealth} /> @@ -167,6 +177,7 @@ export const NavbarView: FC = ({ canViewAuditLog={canViewAuditLog} canViewDeployment={canViewDeployment} canViewAllUsers={canViewAllUsers} + canViewHealth={canViewHealth} />
diff --git a/site/src/pages/HealthPage/AccessURLPage.stories.tsx b/site/src/pages/HealthPage/AccessURLPage.stories.tsx new file mode 100644 index 0000000000..b57f99eeb8 --- /dev/null +++ b/site/src/pages/HealthPage/AccessURLPage.stories.tsx @@ -0,0 +1,35 @@ +import { StoryObj, Meta } from "@storybook/react"; +import { AccessURLPage } from "./AccessURLPage"; +import { HealthLayout } from "./HealthLayout"; +import { + reactRouterOutlet, + reactRouterParameters, +} from "storybook-addon-react-router-v6"; +import { useQueryClient } from "react-query"; +import { MockHealth } from "testHelpers/entities"; + +const meta: Meta = { + title: "pages/Health/AccessURL", + render: HealthLayout, + parameters: { + layout: "fullscreen", + reactRouter: reactRouterParameters({ + routing: reactRouterOutlet( + { path: "/health/access-url" }, + , + ), + }), + }, + decorators: [ + (Story) => { + const queryClient = useQueryClient(); + queryClient.setQueryData(["health"], MockHealth); + return ; + }, + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/site/src/pages/HealthPage/AccessURLPage.tsx b/site/src/pages/HealthPage/AccessURLPage.tsx new file mode 100644 index 0000000000..446fb7657e --- /dev/null +++ b/site/src/pages/HealthPage/AccessURLPage.tsx @@ -0,0 +1,60 @@ +import { useOutletContext } from "react-router-dom"; +import { + Header, + HeaderTitle, + Main, + GridData, + GridDataLabel, + GridDataValue, + HealthyDot, +} from "./Content"; +import { HealthcheckReport } from "api/typesGenerated"; +import { Alert } from "components/Alert/Alert"; +import { Helmet } from "react-helmet-async"; +import { pageTitle } from "utils/page"; + +export const AccessURLPage = () => { + const healthStatus = useOutletContext(); + const accessUrl = healthStatus.access_url; + + return ( + <> + + {pageTitle("Access URL - Health")} + + +
+ + + Access URL + +
+ +
+ {accessUrl.warnings.map((warning) => { + return ( + + {warning.message} + + ); + })} + + + Severity + {accessUrl.severity} + + Access URL + {accessUrl.access_url} + + Reachable + {accessUrl.reachable ? "Yes" : "No"} + + Status Code + {accessUrl.status_code} + +
+ + ); +}; + +export default AccessURLPage; diff --git a/site/src/pages/HealthPage/Content.tsx b/site/src/pages/HealthPage/Content.tsx new file mode 100644 index 0000000000..fda3b8f7c4 --- /dev/null +++ b/site/src/pages/HealthPage/Content.tsx @@ -0,0 +1,244 @@ +/* eslint-disable jsx-a11y/heading-has-content -- infer from props */ +import { + ComponentProps, + HTMLAttributes, + ReactElement, + cloneElement, + forwardRef, +} from "react"; +import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined"; +import ErrorOutline from "@mui/icons-material/ErrorOutline"; +import { healthyColor } from "./healthyColor"; +import { css } from "@emotion/css"; +import DoNotDisturbOnOutlined from "@mui/icons-material/DoNotDisturbOnOutlined"; +import { HealthSeverity } from "api/typesGenerated"; +import { useTheme } from "@mui/material/styles"; + +const CONTENT_PADDING = 36; + +export const Header = (props: HTMLAttributes) => { + return ( +
+ ); +}; + +export const HeaderTitle = (props: HTMLAttributes) => { + return ( +

+ ); +}; + +export const HealthIcon = ({ + size, + severity, +}: { + size: number; + severity: HealthSeverity; +}) => { + const theme = useTheme(); + const color = healthyColor(theme, severity); + const Icon = severity === "error" ? ErrorOutline : CheckCircleOutlined; + + return ; +}; + +export const HealthyDot = ({ severity }: { severity: HealthSeverity }) => { + const theme = useTheme(); + + return ( +
+ ); +}; + +export const Main = (props: HTMLAttributes) => { + return ( +
+ ); +}; + +export const GridData = (props: HTMLAttributes) => { + return ( +
+ ); +}; + +export const GridDataLabel = (props: HTMLAttributes) => { + const theme = useTheme(); + return ( + + ); +}; + +export const GridDataValue = (props: HTMLAttributes) => { + const theme = useTheme(); + return ( + + ); +}; + +export const SectionLabel = (props: HTMLAttributes) => { + return ( +

+ ); +}; + +type PillProps = HTMLAttributes & { + icon: ReactElement; +}; + +export const Pill = forwardRef((props, ref) => { + const theme = useTheme(); + const { icon, children, ...divProps } = props; + + return ( +
+ {cloneElement(icon, { className: css({ width: 14, height: 14 }) })} + {children} +
+ ); +}); + +type BooleanPillProps = Omit< + ComponentProps, + "children" | "icon" | "value" +> & { + children: string; + value: boolean; +}; + +export const BooleanPill = (props: BooleanPillProps) => { + const { value, children, ...divProps } = props; + const theme = useTheme(); + const color = value ? theme.palette.success.light : theme.palette.error.light; + + return ( + + ) : ( + + ) + } + {...divProps} + > + {children} + + ); +}; + +type LogsProps = { lines: string[] } & HTMLAttributes; + +export const Logs = (props: LogsProps) => { + const theme = useTheme(); + const { lines, ...divProps } = props; + + return ( +
+ {lines.map((line, index) => ( + + {line} + + ))} + {lines.length === 0 && ( + + No logs available + + )} +
+ ); +}; diff --git a/site/src/pages/HealthPage/DERPPage.stories.tsx b/site/src/pages/HealthPage/DERPPage.stories.tsx new file mode 100644 index 0000000000..be1ea969c3 --- /dev/null +++ b/site/src/pages/HealthPage/DERPPage.stories.tsx @@ -0,0 +1,32 @@ +import { StoryObj, Meta } from "@storybook/react"; +import { DERPPage } from "./DERPPage"; +import { HealthLayout } from "./HealthLayout"; +import { + reactRouterOutlet, + reactRouterParameters, +} from "storybook-addon-react-router-v6"; +import { useQueryClient } from "react-query"; +import { MockHealth } from "testHelpers/entities"; + +const meta: Meta = { + title: "pages/Health/DERP", + render: HealthLayout, + parameters: { + layout: "fullscreen", + reactRouter: reactRouterParameters({ + routing: reactRouterOutlet({ path: "/health/derp" }, ), + }), + }, + decorators: [ + (Story) => { + const queryClient = useQueryClient(); + queryClient.setQueryData(["health"], MockHealth); + return ; + }, + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/site/src/pages/HealthPage/DERPPage.tsx b/site/src/pages/HealthPage/DERPPage.tsx new file mode 100644 index 0000000000..d5bab7351b --- /dev/null +++ b/site/src/pages/HealthPage/DERPPage.tsx @@ -0,0 +1,128 @@ +import { Link, useOutletContext } from "react-router-dom"; +import { + Header, + HeaderTitle, + Main, + SectionLabel, + BooleanPill, + Logs, + HealthyDot, +} from "./Content"; +import { + HealthMessage, + HealthSeverity, + HealthcheckReport, +} from "api/typesGenerated"; +import Button from "@mui/material/Button"; +import LocationOnOutlined from "@mui/icons-material/LocationOnOutlined"; +import { healthyColor } from "./healthyColor"; +import { Alert } from "components/Alert/Alert"; +import { Helmet } from "react-helmet-async"; +import { pageTitle } from "utils/page"; +import { useTheme } from "@mui/material/styles"; + +const flags = [ + "UDP", + "IPv6", + "IPv4", + "IPv6CanSend", + "IPv4CanSend", + "OSHasIPv6", + "ICMPv4", + "MappingVariesByDestIP", + "HairPinning", + "UPnP", + "PMP", + "PCP", +]; + +export const DERPPage = () => { + const { derp } = useOutletContext(); + const { netcheck, regions, netcheck_logs: logs } = derp; + const theme = useTheme(); + + return ( + <> + + {pageTitle("DERP - Health")} + + +
+ + + DERP + +
+ +
+ {derp.warnings.map((warning: HealthMessage) => { + return ( + + {warning.message} + + ); + })} + +
+ Flags +
+ {flags.map((flag) => ( + + {flag} + + ))} +
+
+ +
+ Regions +
+ {Object.values(regions) + .sort((a, b) => { + if (a.region && b.region) { + return a.region.RegionName.localeCompare(b.region.RegionName); + } + }) + .map(({ severity, region }) => { + return ( + + ); + })} +
+
+ +
+ Logs + ({ + borderRadius: 8, + border: `1px solid ${theme.palette.divider}`, + color: theme.palette.text.secondary, + })} + /> +
+
+ + ); +}; + +export default DERPPage; diff --git a/site/src/pages/HealthPage/DERPRegionPage.stories.tsx b/site/src/pages/HealthPage/DERPRegionPage.stories.tsx new file mode 100644 index 0000000000..89ef3102a7 --- /dev/null +++ b/site/src/pages/HealthPage/DERPRegionPage.stories.tsx @@ -0,0 +1,39 @@ +import { StoryObj, Meta } from "@storybook/react"; +import { DERPRegionPage } from "./DERPRegionPage"; +import { HealthLayout } from "./HealthLayout"; +import { + reactRouterOutlet, + reactRouterParameters, +} from "storybook-addon-react-router-v6"; +import { useQueryClient } from "react-query"; +import { MockHealth } from "testHelpers/entities"; + +const firstRegionId = Object.values(MockHealth.derp.regions)[0].region + ?.RegionID; + +const meta: Meta = { + title: "pages/Health/DERPRegion", + render: HealthLayout, + parameters: { + layout: "fullscreen", + reactRouter: reactRouterParameters({ + location: { pathParams: { regionId: firstRegionId } }, + routing: reactRouterOutlet( + { path: `/health/derp/regions/:regionId` }, + , + ), + }), + }, + decorators: [ + (Story) => { + const queryClient = useQueryClient(); + queryClient.setQueryData(["health"], MockHealth); + return ; + }, + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/site/src/pages/HealthPage/DERPRegionPage.tsx b/site/src/pages/HealthPage/DERPRegionPage.tsx new file mode 100644 index 0000000000..3dfb1cea2a --- /dev/null +++ b/site/src/pages/HealthPage/DERPRegionPage.tsx @@ -0,0 +1,196 @@ +import { Link, useOutletContext, useParams } from "react-router-dom"; +import { + Header, + HeaderTitle, + Main, + BooleanPill, + Pill, + Logs, + HealthyDot, +} from "./Content"; +import { + HealthMessage, + HealthSeverity, + HealthcheckReport, +} from "api/typesGenerated"; +import CodeOutlined from "@mui/icons-material/CodeOutlined"; +import TagOutlined from "@mui/icons-material/TagOutlined"; +import Tooltip from "@mui/material/Tooltip"; +import { useTheme } from "@mui/material/styles"; +import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined"; +import { getLatencyColor } from "utils/latency"; +import { Alert } from "components/Alert/Alert"; +import { Helmet } from "react-helmet-async"; +import { pageTitle } from "utils/page"; + +export const DERPRegionPage = () => { + const theme = useTheme(); + const healthStatus = useOutletContext(); + const params = useParams() as { regionId: string }; + const regionId = Number(params.regionId); + const { + region, + node_reports: reports, + warnings, + severity, + } = healthStatus.derp.regions[regionId]; + + return ( + <> + + {pageTitle(`${region.RegionName} - Health`)} + + +
+
+ + + Back to DERP + + + + {region.RegionName} + +
+
+ +
+ {warnings.map((warning: HealthMessage) => { + return ( + + {warning.message} + + ); + })} + +
+
+ + }>{region.RegionID} + + + }>{region.RegionCode} + + + Embedded Relay + +
+
+ + {reports.map((report) => { + const { node, client_logs: logs } = report; + const latencyColor = getLatencyColor( + theme, + report.round_trip_ping_ms, + ); + return ( +
+
+
+

+ {node.HostName} +

+
+ DERP Port: {node.DERPPort ?? "None"} + STUN Port: {node.STUNPort ?? "None"} +
+
+ +
+ + +
+
+ } + > + {report.round_trip_ping_ms}ms +
+
+ + Exchange Messages + + + Websocket + +
+
+ +
+ ); + })} +
+ + ); +}; + +export default DERPRegionPage; diff --git a/site/src/pages/HealthPage/DatabasePage.stories.tsx b/site/src/pages/HealthPage/DatabasePage.stories.tsx new file mode 100644 index 0000000000..22cafd7f28 --- /dev/null +++ b/site/src/pages/HealthPage/DatabasePage.stories.tsx @@ -0,0 +1,35 @@ +import { StoryObj, Meta } from "@storybook/react"; +import { DatabasePage } from "./DatabasePage"; +import { HealthLayout } from "./HealthLayout"; +import { + reactRouterOutlet, + reactRouterParameters, +} from "storybook-addon-react-router-v6"; +import { useQueryClient } from "react-query"; +import { MockHealth } from "testHelpers/entities"; + +const meta: Meta = { + title: "pages/Health/Database", + render: HealthLayout, + parameters: { + layout: "fullscreen", + reactRouter: reactRouterParameters({ + routing: reactRouterOutlet( + { path: "/health/database" }, + , + ), + }), + }, + decorators: [ + (Story) => { + const queryClient = useQueryClient(); + queryClient.setQueryData(["health"], MockHealth); + return ; + }, + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/site/src/pages/HealthPage/DatabasePage.tsx b/site/src/pages/HealthPage/DatabasePage.tsx new file mode 100644 index 0000000000..54772d0a01 --- /dev/null +++ b/site/src/pages/HealthPage/DatabasePage.tsx @@ -0,0 +1,57 @@ +import { useOutletContext } from "react-router-dom"; +import { + Header, + HeaderTitle, + Main, + GridData, + GridDataLabel, + GridDataValue, + HealthyDot, +} from "./Content"; +import { HealthcheckReport } from "api/typesGenerated"; +import { Alert } from "components/Alert/Alert"; +import { Helmet } from "react-helmet-async"; +import { pageTitle } from "utils/page"; + +export const DatabasePage = () => { + const healthStatus = useOutletContext(); + const database = healthStatus.database; + + return ( + <> + + {pageTitle("Database - Health")} + + +
+ + + Database + +
+ +
+ {database.warnings.map((warning) => { + return ( + + {warning.message} + + ); + })} + + + Reachable + {database.reachable ? "Yes" : "No"} + + Latency + {database.latency_ms}ms + + Threshold + {database.threshold_ms}ms + +
+ + ); +}; + +export default DatabasePage; diff --git a/site/src/pages/HealthPage/HealthLayout.tsx b/site/src/pages/HealthPage/HealthLayout.tsx new file mode 100644 index 0000000000..11b80469c7 --- /dev/null +++ b/site/src/pages/HealthPage/HealthLayout.tsx @@ -0,0 +1,210 @@ +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { Loader } from "components/Loader/Loader"; +import { Helmet } from "react-helmet-async"; +import { pageTitle } from "utils/page"; +import { createDayString } from "utils/createDayString"; +import { DashboardFullPage } from "components/Dashboard/DashboardLayout"; +import ReplayIcon from "@mui/icons-material/Replay"; +import { health, refreshHealth } from "api/queries/debug"; +import { useTheme } from "@mui/material/styles"; +import IconButton from "@mui/material/IconButton"; +import Tooltip from "@mui/material/Tooltip"; +import CircularProgress from "@mui/material/CircularProgress"; +import { NavLink, Outlet } from "react-router-dom"; +import { css } from "@emotion/css"; +import { kebabCase } from "lodash/fp"; +import { Suspense } from "react"; +import { HealthIcon } from "./Content"; +import { HealthSeverity } from "api/typesGenerated"; + +const sections = { + derp: "DERP", + access_url: "Access URL", + websocket: "Websocket", + database: "Database", + workspace_proxy: "Workspace Proxy", +} as const; + +export function HealthLayout() { + const theme = useTheme(); + const queryClient = useQueryClient(); + const { data: healthStatus } = useQuery({ + ...health(), + refetchInterval: 30_000, + }); + const { mutate: forceRefresh, isLoading: isRefreshing } = useMutation( + refreshHealth(queryClient), + ); + + return ( + <> + + {pageTitle("Health")} + + + {healthStatus ? ( + +
+
+
+
+
+ + + + { + forceRefresh(); + }} + > + {isRefreshing ? ( + + ) : ( + + )} + + +
+
+ {healthStatus.healthy ? "Healthy" : "Unhealthy"} +
+
+ {healthStatus.healthy + ? Object.keys(sections).some( + (key) => + healthStatus[key as keyof typeof sections] + .warnings !== null && + healthStatus[key as keyof typeof sections].warnings + .length > 0, + ) + ? "All systems operational, but performance might be degraded" + : "All systems operational" + : "Some issues have been detected"} +
+
+ +
+ Last check + + {createDayString(healthStatus.time)} + +
+ +
+ Version + + {healthStatus.coder_version} + +
+
+ + +
+ +
+ }> + + +
+
+
+ ) : ( + + )} + + ); +} diff --git a/site/src/pages/HealthPage/HealthPage.stories.tsx b/site/src/pages/HealthPage/HealthPage.stories.tsx deleted file mode 100644 index fdfd86da71..0000000000 --- a/site/src/pages/HealthPage/HealthPage.stories.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react"; -import { HealthPageView } from "./HealthPage"; -import { MockHealth } from "testHelpers/entities"; - -const meta: Meta = { - title: "pages/HealthPage", - component: HealthPageView, - args: { - tab: { - value: "derp", - set: () => {}, - }, - healthStatus: MockHealth, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Example: Story = {}; - -export const AccessURLUnhealthy: Story = { - args: { - healthStatus: { - ...MockHealth, - healthy: false, - severity: "error", - access_url: { - ...MockHealth.access_url, - healthy: false, - error: "ouch", - }, - }, - }, -}; - -export const AccessURLWarning: Story = { - args: { - healthStatus: { - ...MockHealth, - healthy: true, - severity: "warning", - access_url: { - ...MockHealth.access_url, - healthy: true, - warnings: [{ code: "EUNKNOWN", message: "foobar" }], - }, - }, - }, -}; - -export const DatabaseUnhealthy: Story = { - args: { - healthStatus: { - ...MockHealth, - healthy: false, - severity: "error", - database: { - ...MockHealth.database, - healthy: false, - error: "ouch", - }, - }, - }, -}; - -export const DatabaseWarning: Story = { - args: { - healthStatus: { - ...MockHealth, - healthy: true, - severity: "warning", - database: { - ...MockHealth.database, - healthy: true, - warnings: [{ code: "EUNKNOWN", message: "foobar" }], - }, - }, - }, -}; - -export const WebsocketUnhealthy: Story = { - args: { - healthStatus: { - ...MockHealth, - healthy: false, - severity: "error", - websocket: { - ...MockHealth.websocket, - healthy: false, - error: "ouch", - }, - }, - }, -}; - -export const WebsocketWarning: Story = { - args: { - healthStatus: { - ...MockHealth, - healthy: true, - severity: "warning", - websocket: { - ...MockHealth.websocket, - healthy: true, - warnings: ["foobar"], - }, - }, - }, -}; - -export const UnhealthyDERP: Story = { - args: { - healthStatus: { - ...MockHealth, - healthy: false, - severity: "error", - derp: { - ...MockHealth.derp, - healthy: false, - error: "ouch", - }, - }, - }, -}; - -export const DERPWarnings: Story = { - args: { - healthStatus: { - ...MockHealth, - severity: "warning", - derp: { - ...MockHealth.derp, - warnings: [ - { - message: "derp derp derp", - code: "EDERP01", - }, - ], - }, - }, - }, -}; - -export const ProxyUnhealthy: Story = { - args: { - healthStatus: { - ...MockHealth, - severity: "error", - healthy: false, - workspace_proxy: { - ...MockHealth.workspace_proxy, - healthy: false, - error: "ouch", - }, - }, - }, -}; - -export const ProxyWarning: Story = { - args: { - healthStatus: { - ...MockHealth, - severity: "warning", - workspace_proxy: { - ...MockHealth.workspace_proxy, - warnings: [{ code: "EUNKNOWN", message: "foobar" }], - }, - }, - }, -}; diff --git a/site/src/pages/HealthPage/HealthPage.tsx b/site/src/pages/HealthPage/HealthPage.tsx deleted file mode 100644 index 136d338ccb..0000000000 --- a/site/src/pages/HealthPage/HealthPage.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined"; -import ErrorOutline from "@mui/icons-material/ErrorOutline"; -import ReplayIcon from "@mui/icons-material/Replay"; -import { useTheme } from "@mui/material/styles"; -import IconButton from "@mui/material/IconButton"; -import Tooltip from "@mui/material/Tooltip"; -import CircularProgress from "@mui/material/CircularProgress"; -import { type Interpolation, type Theme } from "@emotion/react"; -import { type FC } from "react"; -import { Helmet } from "react-helmet-async"; -import { useMutation, useQuery, useQueryClient } from "react-query"; -import { getHealth } from "api/api"; -import { health, refreshHealth } from "api/queries/debug"; -import { useTab } from "hooks"; -import { createDayString } from "utils/createDayString"; -import { pageTitle } from "utils/page"; -import { DashboardFullPage } from "components/Dashboard/DashboardLayout"; -import { Loader } from "components/Loader/Loader"; -import { SyntaxHighlighter } from "components/SyntaxHighlighter/SyntaxHighlighter"; - -const sections = { - derp: "DERP", - access_url: "Access URL", - websocket: "Websocket", - database: "Database", - workspace_proxy: "Workspace Proxy", -} as const; - -export default function HealthPage() { - const tab = useTab("tab", "derp"); - const queryClient = useQueryClient(); - const { data: healthStatus } = useQuery({ - ...health(), - refetchInterval: 30_000, - }); - const { mutate: forceRefresh, isLoading: isRefreshing } = useMutation( - refreshHealth(queryClient), - ); - - return ( - <> - - {pageTitle("Health")} - - - {healthStatus ? ( - - ) : ( - - )} - - ); -} - -interface HealthPageViewProps { - healthStatus: Awaited>; - tab: ReturnType; - forceRefresh: () => void; - isRefreshing: boolean; -} - -export const HealthPageView: FC = ({ - healthStatus, - tab, - forceRefresh, - isRefreshing, -}) => { - const theme = useTheme(); - - return ( - -
-
-
-
-
- {healthStatus.healthy ? ( - - ) : ( - - )} - - - - {isRefreshing ? ( - - ) : ( - - )} - - -
-
- {healthStatus.healthy ? "Healthy" : "Unhealthy"} -
-
- {healthStatus.healthy - ? Object.keys(sections).some( - (key) => - healthStatus[key as keyof typeof sections].warnings !== - null && - healthStatus[key as keyof typeof sections].warnings - .length > 0, - ) - ? "All systems operational, but performance might be degraded" - : "All systems operational" - : "Some issues have been detected"} -
-
- -
- Last check - - {createDayString(healthStatus.time)} - -
- -
- Version - - {healthStatus.coder_version} - -
-
- - -
-
- -
-
-
- ); -}; - -const styles = { - sectionLink: (theme) => ({ - border: "none", - fontSize: 14, - width: "100%", - display: "flex", - alignItems: "center", - gap: 12, - textAlign: "left", - height: 36, - padding: "0 24px", - cursor: "pointer", - background: "none", - color: theme.palette.text.secondary, - - "&:hover": { - background: theme.palette.action.hover, - color: theme.palette.text.primary, - }, - }), - - activeSectionLink: (theme) => ({ - background: theme.palette.action.hover, - pointerEvents: "none", - color: theme.palette.text.primary, - }), -} satisfies Record>; diff --git a/site/src/pages/HealthPage/WebsocketPage.stories.tsx b/site/src/pages/HealthPage/WebsocketPage.stories.tsx new file mode 100644 index 0000000000..242ff18741 --- /dev/null +++ b/site/src/pages/HealthPage/WebsocketPage.stories.tsx @@ -0,0 +1,35 @@ +import { StoryObj, Meta } from "@storybook/react"; +import { WebsocketPage } from "./WebsocketPage"; +import { HealthLayout } from "./HealthLayout"; +import { + reactRouterOutlet, + reactRouterParameters, +} from "storybook-addon-react-router-v6"; +import { useQueryClient } from "react-query"; +import { MockHealth } from "testHelpers/entities"; + +const meta: Meta = { + title: "pages/Health/Websocket", + render: HealthLayout, + parameters: { + layout: "fullscreen", + reactRouter: reactRouterParameters({ + routing: reactRouterOutlet( + { path: "/health/derp/websocket" }, + , + ), + }), + }, + decorators: [ + (Story) => { + const queryClient = useQueryClient(); + queryClient.setQueryData(["health"], MockHealth); + return ; + }, + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/site/src/pages/HealthPage/WebsocketPage.tsx b/site/src/pages/HealthPage/WebsocketPage.tsx new file mode 100644 index 0000000000..43b1ebd3ee --- /dev/null +++ b/site/src/pages/HealthPage/WebsocketPage.tsx @@ -0,0 +1,78 @@ +import { useOutletContext } from "react-router-dom"; +import { + Header, + HeaderTitle, + HealthyDot, + Main, + Pill, + SectionLabel, +} from "./Content"; +import { HealthcheckReport } from "api/typesGenerated"; +import CodeOutlined from "@mui/icons-material/CodeOutlined"; +import Tooltip from "@mui/material/Tooltip"; +import { useTheme } from "@mui/material/styles"; +import { MONOSPACE_FONT_FAMILY } from "theme/constants"; +import { Alert } from "components/Alert/Alert"; +import { pageTitle } from "utils/page"; +import { Helmet } from "react-helmet-async"; + +export const WebsocketPage = () => { + const healthStatus = useOutletContext(); + const { websocket } = healthStatus; + const theme = useTheme(); + + return ( + <> + + {pageTitle("Websocket - Health")} + + +
+ + + Websocket + +
+ +
+ {websocket.warnings.map((warning) => { + return ( + + {warning} + + ); + })} + +
+ + }>{websocket.code} + +
+ +
+ Body +
+ {websocket.body !== "" ? ( + websocket.body + ) : ( + + No body message + + )} +
+
+
+ + ); +}; + +export default WebsocketPage; diff --git a/site/src/pages/HealthPage/WebsocketProxyPage.stories.tsx b/site/src/pages/HealthPage/WebsocketProxyPage.stories.tsx new file mode 100644 index 0000000000..a5fbf5a8a1 --- /dev/null +++ b/site/src/pages/HealthPage/WebsocketProxyPage.stories.tsx @@ -0,0 +1,35 @@ +import { StoryObj, Meta } from "@storybook/react"; +import { WorkspaceProxyPage } from "./WorkspaceProxyPage"; +import { HealthLayout } from "./HealthLayout"; +import { + reactRouterOutlet, + reactRouterParameters, +} from "storybook-addon-react-router-v6"; +import { useQueryClient } from "react-query"; +import { MockHealth } from "testHelpers/entities"; + +const meta: Meta = { + title: "pages/Health/WorkspaceProxy", + render: HealthLayout, + parameters: { + layout: "fullscreen", + reactRouter: reactRouterParameters({ + routing: reactRouterOutlet( + { path: "/health/workspace-proxy" }, + , + ), + }), + }, + decorators: [ + (Story) => { + const queryClient = useQueryClient(); + queryClient.setQueryData(["health"], MockHealth); + return ; + }, + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/site/src/pages/HealthPage/WorkspaceProxyPage.tsx b/site/src/pages/HealthPage/WorkspaceProxyPage.tsx new file mode 100644 index 0000000000..d6ae28fc29 --- /dev/null +++ b/site/src/pages/HealthPage/WorkspaceProxyPage.tsx @@ -0,0 +1,146 @@ +import { useOutletContext } from "react-router-dom"; +import { + BooleanPill, + Header, + HeaderTitle, + HealthyDot, + Main, + Pill, +} from "./Content"; +import { HealthcheckReport } from "api/typesGenerated"; +import { useTheme } from "@mui/material/styles"; +import { createDayString } from "utils/createDayString"; +import PublicOutlined from "@mui/icons-material/PublicOutlined"; +import Tooltip from "@mui/material/Tooltip"; +import TagOutlined from "@mui/icons-material/TagOutlined"; +import { Alert } from "components/Alert/Alert"; +import { Helmet } from "react-helmet-async"; +import { pageTitle } from "utils/page"; + +export const WorkspaceProxyPage = () => { + const healthStatus = useOutletContext(); + const { workspace_proxy } = healthStatus; + const { regions } = workspace_proxy.workspace_proxies; + const theme = useTheme(); + + return ( + <> + + {pageTitle("Workspace Proxy - Health")} + + +
+ + + Workspace Proxy + +
+ +
+ {workspace_proxy.warnings.map((warning) => { + return ( + + {warning.message} + + ); + })} + + {regions.map((region) => { + const warnings = region.status?.report?.warnings ?? []; + + return ( +
+
+
+
+ +
+
+

+ {region.display_name} +

+ + {region.version} + +
+
+ +
+ {region.wildcard_hostname && ( + + }> + {region.wildcard_hostname} + + + )} + {region.version && ( + + }>{region.version} + + )} + + DERP Enabled + + DERP Only + Deleted +
+
+ +
+ {warnings.length > 0 ? ( +
+ {warnings.map((warning, i) => ( + {warning} + ))} +
+ ) : ( + No warnings + )} + {createDayString(region.updated_at)} +
+
+ ); + })} +
+ + ); +}; + +export default WorkspaceProxyPage; diff --git a/site/src/pages/HealthPage/healthyColor.ts b/site/src/pages/HealthPage/healthyColor.ts new file mode 100644 index 0000000000..d14bc808c0 --- /dev/null +++ b/site/src/pages/HealthPage/healthyColor.ts @@ -0,0 +1,13 @@ +import { Theme } from "@mui/material/styles"; +import { HealthSeverity } from "api/typesGenerated"; + +export const healthyColor = (theme: Theme, severity: HealthSeverity) => { + switch (severity) { + case "ok": + return theme.palette.success.light; + case "warning": + return theme.palette.warning.light; + case "error": + return theme.palette.error.light; + } +}; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 48ce84c7bc..2b422cefe5 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -2835,11 +2835,228 @@ export const MockHealth: TypesGen.HealthcheckReport = { }, workspace_proxy: { healthy: true, - severity: "ok", - warnings: [], + severity: "warning", + warnings: [ + { + code: "EWP04", + message: + 'unhealthy: request to proxy failed: Get "http://127.0.0.1:3001/healthz-report": dial tcp 127.0.0.1:3001: connect: connection refused', + }, + ], dismissed: false, + error: undefined, workspace_proxies: { - regions: [], + regions: [ + { + id: "1a3e5eb8-d785-4f7d-9188-2eeab140cd06", + name: "primary", + display_name: "Council Bluffs, Iowa", + icon_url: "/emojis/1f3e1.png", + healthy: true, + path_app_url: "https://dev.coder.com", + wildcard_hostname: "*--apps.dev.coder.com", + derp_enabled: false, + derp_only: false, + status: { + status: "ok", + report: { + errors: [], + warnings: [], + }, + checked_at: "2023-12-05T14:14:05.829032482Z", + }, + created_at: "0001-01-01T00:00:00Z", + updated_at: "0001-01-01T00:00:00Z", + deleted: false, + version: "", + }, + { + id: "2876ab4d-bcee-4643-944f-d86323642840", + name: "sydney", + display_name: "Sydney GCP", + icon_url: "/emojis/1f1e6-1f1fa.png", + healthy: true, + path_app_url: "https://sydney.dev.coder.com", + wildcard_hostname: "*--apps.sydney.dev.coder.com", + derp_enabled: true, + derp_only: false, + status: { + status: "ok", + report: { + errors: [], + warnings: [], + }, + checked_at: "2023-12-05T14:14:05.250322277Z", + }, + created_at: "2023-05-01T19:15:56.606593Z", + updated_at: "2023-12-05T14:13:36.647535Z", + deleted: false, + version: "v2.4.0-devel+5fad61102", + }, + { + id: "9d786ce0-55b1-4ace-8acc-a4672ff8d41f", + name: "europe-frankfurt", + display_name: "Europe GCP (Frankfurt)", + icon_url: "/emojis/1f1e9-1f1ea.png", + healthy: true, + path_app_url: "https://europe.dev.coder.com", + wildcard_hostname: "*--apps.europe.dev.coder.com", + derp_enabled: true, + derp_only: false, + status: { + status: "ok", + report: { + errors: [], + warnings: [], + }, + checked_at: "2023-12-05T14:14:05.250322277Z", + }, + created_at: "2023-05-01T20:34:11.114005Z", + updated_at: "2023-12-05T14:13:45.941716Z", + deleted: false, + version: "v2.4.0-devel+5fad61102", + }, + { + id: "2e209786-73b1-4838-ba78-e01c9334450a", + name: "brazil-saopaulo", + display_name: "Brazil GCP (Sao Paulo)", + icon_url: "/emojis/1f1e7-1f1f7.png", + healthy: true, + path_app_url: "https://brazil.dev.coder.com", + wildcard_hostname: "*--apps.brazil.dev.coder.com", + derp_enabled: true, + derp_only: false, + status: { + status: "ok", + report: { + errors: [], + warnings: [], + }, + checked_at: "2023-12-05T14:14:05.250322277Z", + }, + created_at: "2023-05-01T20:41:02.76448Z", + updated_at: "2023-12-05T14:13:41.968568Z", + deleted: false, + version: "v2.4.0-devel+5fad61102", + }, + { + id: "c272e80c-0cce-49d6-9782-1b5cf90398e8", + name: "unregistered", + display_name: "UnregisteredProxy", + icon_url: "/emojis/274c.png", + healthy: false, + path_app_url: "", + wildcard_hostname: "", + derp_enabled: true, + derp_only: false, + status: { + status: "unregistered", + report: { + errors: [], + warnings: [], + }, + checked_at: "2023-12-05T14:14:05.250322277Z", + }, + created_at: "2023-07-10T14:51:11.539222Z", + updated_at: "2023-07-10T14:51:11.539223Z", + deleted: false, + version: "", + }, + { + id: "a3efbff1-587b-4677-80a4-dc4f892fed3e", + name: "unhealthy", + display_name: "Unhealthy", + icon_url: "/emojis/1f92e.png", + healthy: false, + path_app_url: "http://127.0.0.1:3001", + wildcard_hostname: "", + derp_enabled: true, + derp_only: false, + status: { + status: "unreachable", + report: { + errors: [ + 'request to proxy failed: Get "http://127.0.0.1:3001/healthz-report": dial tcp 127.0.0.1:3001: connect: connection refused', + ], + warnings: [], + }, + checked_at: "2023-12-05T14:14:05.250322277Z", + }, + created_at: "2023-07-10T14:51:48.407017Z", + updated_at: "2023-07-10T14:51:57.993682Z", + deleted: false, + version: "", + }, + { + id: "b6cefb69-cb6f-46e2-9c9c-39c089fb7e42", + name: "paris-coder", + display_name: "Europe (Paris)", + icon_url: "/emojis/1f1eb-1f1f7.png", + healthy: true, + path_app_url: "https://paris-coder.fly.dev", + wildcard_hostname: "", + derp_enabled: true, + derp_only: false, + status: { + status: "ok", + report: { + errors: [], + warnings: [], + }, + checked_at: "2023-12-05T14:14:05.250322277Z", + }, + created_at: "2023-12-01T09:21:15.996267Z", + updated_at: "2023-12-05T14:13:59.663174Z", + deleted: false, + version: "v2.4.0-devel+5fad61102", + }, + { + id: "72649dc9-03c7-46a8-bc95-96775e93ddc1", + name: "sydney-coder", + display_name: "Australia (Sydney)", + icon_url: "/emojis/1f1e6-1f1fa.png", + healthy: true, + path_app_url: "https://sydney-coder.fly.dev", + wildcard_hostname: "", + derp_enabled: true, + derp_only: false, + status: { + status: "ok", + report: { + errors: [], + warnings: [], + }, + checked_at: "2023-12-05T14:14:05.250322277Z", + }, + created_at: "2023-12-01T09:23:44.505529Z", + updated_at: "2023-12-05T14:13:55.769058Z", + deleted: false, + version: "v2.4.0-devel+5fad61102", + }, + { + id: "1f78398f-e5ae-4c38-aa89-30222181d443", + name: "sao-paulo-coder", + display_name: "Brazil (Sau Paulo)", + icon_url: "/emojis/1f1e7-1f1f7.png", + healthy: true, + path_app_url: "https://sao-paulo-coder.fly.dev", + wildcard_hostname: "", + derp_enabled: true, + derp_only: false, + status: { + status: "ok", + report: { + errors: [], + warnings: [], + }, + checked_at: "2023-12-05T14:14:05.250322277Z", + }, + created_at: "2023-12-01T09:36:00.231252Z", + updated_at: "2023-12-05T14:13:47.015031Z", + deleted: false, + version: "v2.4.0-devel+5fad61102", + }, + ], }, }, coder_version: "v0.27.1-devel+c575292",