diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 4d94e9bc21..6ce7eb21c0 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -8024,7 +8024,8 @@ const docTemplate = `{
"tailnet_pg_coordinator",
"single_tailnet",
"template_restart_requirement",
- "template_insights_page"
+ "template_insights_page",
+ "deployment_health_page"
],
"x-enum-varnames": [
"ExperimentMoons",
@@ -8032,7 +8033,8 @@ const docTemplate = `{
"ExperimentTailnetPGCoordinator",
"ExperimentSingleTailnet",
"ExperimentTemplateRestartRequirement",
- "ExperimentTemplateInsightsPage"
+ "ExperimentTemplateInsightsPage",
+ "ExperimentDeploymentHealthPage"
]
},
"codersdk.Feature": {
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index d77432f76a..500307a23f 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -7185,7 +7185,8 @@
"tailnet_pg_coordinator",
"single_tailnet",
"template_restart_requirement",
- "template_insights_page"
+ "template_insights_page",
+ "deployment_health_page"
],
"x-enum-varnames": [
"ExperimentMoons",
@@ -7193,7 +7194,8 @@
"ExperimentTailnetPGCoordinator",
"ExperimentSingleTailnet",
"ExperimentTemplateRestartRequirement",
- "ExperimentTemplateInsightsPage"
+ "ExperimentTemplateInsightsPage",
+ "ExperimentDeploymentHealthPage"
]
},
"codersdk.Feature": {
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index e27731b6aa..305e14f944 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -1871,6 +1871,9 @@ const (
// Insights page
ExperimentTemplateInsightsPage Experiment = "template_insights_page"
+ // Deployment health page
+ ExperimentDeploymentHealthPage Experiment = "deployment_health_page"
+
// Add new experiments here!
// ExperimentExample Experiment = "example"
)
@@ -1881,6 +1884,7 @@ const (
// not be included here and will be essentially hidden.
var ExperimentsAll = Experiments{
ExperimentTemplateInsightsPage,
+ ExperimentDeploymentHealthPage,
}
// Experiments is a list of experiments that are enabled for the deployment.
diff --git a/docs/api/schemas.md b/docs/api/schemas.md
index b62322a54b..db7ec28732 100644
--- a/docs/api/schemas.md
+++ b/docs/api/schemas.md
@@ -2677,6 +2677,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `single_tailnet` |
| `template_restart_requirement` |
| `template_insights_page` |
+| `deployment_health_page` |
## codersdk.Feature
diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx
index 34cbc0ed6d..449d4925fe 100644
--- a/site/src/AppRouter.tsx
+++ b/site/src/AppRouter.tsx
@@ -183,6 +183,7 @@ const TemplateInsightsPage = lazy(
() =>
import("./pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage"),
)
+const HealthPage = lazy(() => import("./pages/HealthPage/HealthPage"))
export const AppRouter: FC = () => {
return (
@@ -197,6 +198,8 @@ export const AppRouter: FC = () => {
}>
} />
+ } />
+
} />
} />
diff --git a/site/src/api/api.ts b/site/src/api/api.ts
index a1ac0a1636..bd2f36967f 100644
--- a/site/src/api/api.ts
+++ b/site/src/api/api.ts
@@ -1405,3 +1405,15 @@ export const getInsightsTemplate = async (
const response = await axios.get(`/api/v2/insights/templates?${params}`)
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")
+}
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 07da6e5de4..d483fa3ccc 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -1563,6 +1563,7 @@ export const Entitlements: Entitlement[] = [
// From codersdk/deployment.go
export type Experiment =
+ | "deployment_health_page"
| "moons"
| "single_tailnet"
| "tailnet_pg_coordinator"
@@ -1570,6 +1571,7 @@ export type Experiment =
| "template_restart_requirement"
| "workspace_actions"
export const Experiments: Experiment[] = [
+ "deployment_health_page",
"moons",
"single_tailnet",
"tailnet_pg_coordinator",
diff --git a/site/src/components/Dashboard/DashboardLayout.tsx b/site/src/components/Dashboard/DashboardLayout.tsx
index 7ffd518d5f..e7f01c9e3a 100644
--- a/site/src/components/Dashboard/DashboardLayout.tsx
+++ b/site/src/components/Dashboard/DashboardLayout.tsx
@@ -16,6 +16,7 @@ import Box 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 styles = useStyles()
@@ -30,6 +31,7 @@ export const DashboardLayout: FC = () => {
return (
<>
+
{canViewDeployment && }
diff --git a/site/src/components/Dashboard/HealthBanner.tsx b/site/src/components/Dashboard/HealthBanner.tsx
new file mode 100644
index 0000000000..a8f5a2d065
--- /dev/null
+++ b/site/src/components/Dashboard/HealthBanner.tsx
@@ -0,0 +1,45 @@
+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 "@tanstack/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/DeploySettingsLayout/Sidebar.tsx b/site/src/components/DeploySettingsLayout/Sidebar.tsx
index 1f365d8cea..e8b0445e4c 100644
--- a/site/src/components/DeploySettingsLayout/Sidebar.tsx
+++ b/site/src/components/DeploySettingsLayout/Sidebar.tsx
@@ -6,6 +6,7 @@ import LockRounded from "@mui/icons-material/LockOutlined"
import Globe from "@mui/icons-material/PublicOutlined"
import HubOutlinedIcon from "@mui/icons-material/HubOutlined"
import VpnKeyOutlined from "@mui/icons-material/VpnKeyOutlined"
+import MonitorHeartOutlined from "@mui/icons-material/MonitorHeartOutlined"
import { GitIcon } from "components/Icons/GitIcon"
import { Stack } from "components/Stack/Stack"
import { ElementType, PropsWithChildren, ReactNode, FC } from "react"
@@ -93,6 +94,14 @@ export const Sidebar: React.FC = () => {
>
Security
+ {dashboard.experiments.includes("deployment_health_page") && (
+ }
+ >
+ Health
+
+ )}
)
}
diff --git a/site/src/components/PageHeader/FullWidthPageHeader.tsx b/site/src/components/PageHeader/FullWidthPageHeader.tsx
index 7e381848ec..ced199ee8d 100644
--- a/site/src/components/PageHeader/FullWidthPageHeader.tsx
+++ b/site/src/components/PageHeader/FullWidthPageHeader.tsx
@@ -1,11 +1,17 @@
import { makeStyles } from "@mui/styles"
import { FC, PropsWithChildren } from "react"
+import { combineClasses } from "utils/combineClasses"
-export const FullWidthPageHeader: FC = ({ children }) => {
+export const FullWidthPageHeader: FC<
+ PropsWithChildren & { sticky?: boolean }
+> = ({ children, sticky = true }) => {
const styles = useStyles()
return (
-
+
)
@@ -35,8 +41,7 @@ const useStyles = makeStyles((theme) => ({
display: "flex",
alignItems: "center",
gap: theme.spacing(6),
- position: "sticky",
- top: 0,
+
zIndex: 10,
flexWrap: "wrap",
@@ -48,6 +53,10 @@ const useStyles = makeStyles((theme) => ({
flexDirection: "column",
},
},
+ sticky: {
+ position: "sticky",
+ top: 0,
+ },
actions: {
marginLeft: "auto",
[theme.breakpoints.down("md")]: {
diff --git a/site/src/components/Stats/Stats.tsx b/site/src/components/Stats/Stats.tsx
index 5adb4174f5..c2674e9c7f 100644
--- a/site/src/components/Stats/Stats.tsx
+++ b/site/src/components/Stats/Stats.tsx
@@ -1,11 +1,12 @@
+import Box from "@mui/material/Box"
import { makeStyles } from "@mui/styles"
import { ComponentProps, FC, PropsWithChildren } from "react"
import { combineClasses } from "utils/combineClasses"
-export const Stats: FC> = (props) => {
+export const Stats: FC> = (props) => {
const styles = useStyles()
return (
-
@@ -16,18 +17,18 @@ export const StatsItem: FC<
{
label: string
value: string | number | JSX.Element
- } & ComponentProps<"div">
+ } & ComponentProps
> = ({ label, value, ...divProps }) => {
const styles = useStyles()
return (
-
{label}:
{value}
-
+
)
}
diff --git a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx
index d37c884f1b..2dfe42d17c 100644
--- a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx
+++ b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx
@@ -1,4 +1,4 @@
-import { FC } from "react"
+import { ComponentProps, FC } from "react"
import Editor, { DiffEditor, loader } from "@monaco-editor/react"
import * as monaco from "monaco-editor"
import { useCoderTheme } from "./coderTheme"
@@ -9,8 +9,10 @@ loader.config({ monaco })
export const SyntaxHighlighter: FC<{
value: string
language: string
+ editorProps?: ComponentProps &
+ ComponentProps
compareWith?: string
-}> = ({ value, compareWith, language }) => {
+}> = ({ value, compareWith, language, editorProps }) => {
const styles = useStyles()
const hasDiff = compareWith && value !== compareWith
const coderTheme = useCoderTheme()
@@ -25,6 +27,7 @@ export const SyntaxHighlighter: FC<{
renderSideBySide: true,
readOnly: true,
},
+ ...editorProps,
}
if (coderTheme.isLoading) {
@@ -46,5 +49,6 @@ const useStyles = makeStyles((theme) => ({
wrapper: {
padding: theme.spacing(1, 0),
background: theme.palette.background.paper,
+ height: "100%",
},
}))
diff --git a/site/src/pages/HealthPage/HealthPage.stories.tsx b/site/src/pages/HealthPage/HealthPage.stories.tsx
new file mode 100644
index 0000000000..dd7baddb70
--- /dev/null
+++ b/site/src/pages/HealthPage/HealthPage.stories.tsx
@@ -0,0 +1,33 @@
+import { Meta, StoryObj } from "@storybook/react"
+import { HealthPageView } from "./HealthPage"
+import { MockHealth } from "testHelpers/entities"
+
+const meta: Meta = {
+ title: "pages/HealthPageView",
+ component: HealthPageView,
+ args: {
+ tab: {
+ value: "derp",
+ set: () => {},
+ },
+ healthStatus: MockHealth,
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+export const HealthPage: Story = {}
+
+export const UnhealthPage: Story = {
+ args: {
+ healthStatus: {
+ ...MockHealth,
+ healthy: false,
+ derp: {
+ ...MockHealth.derp,
+ healthy: false,
+ },
+ },
+ },
+}
diff --git a/site/src/pages/HealthPage/HealthPage.tsx b/site/src/pages/HealthPage/HealthPage.tsx
new file mode 100644
index 0000000000..55f5f820f8
--- /dev/null
+++ b/site/src/pages/HealthPage/HealthPage.tsx
@@ -0,0 +1,247 @@
+import Box from "@mui/material/Box"
+import { useQuery } from "@tanstack/react-query"
+import { getHealth } from "api/api"
+import { Loader } from "components/Loader/Loader"
+import { useTab } from "hooks"
+import { Helmet } from "react-helmet-async"
+import { pageTitle } from "utils/page"
+import { colors } from "theme/colors"
+import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined"
+import ErrorOutline from "@mui/icons-material/ErrorOutline"
+import { SyntaxHighlighter } from "components/SyntaxHighlighter/SyntaxHighlighter"
+import { Stack } from "components/Stack/Stack"
+import {
+ FullWidthPageHeader,
+ PageHeaderTitle,
+ PageHeaderSubtitle,
+} from "components/PageHeader/FullWidthPageHeader"
+import { Stats, StatsItem } from "components/Stats/Stats"
+import { makeStyles } from "@mui/styles"
+import { createDayString } from "utils/createDayString"
+
+const sections = {
+ derp: "DERP",
+ access_url: "Access URL",
+ websocket: "Websocket",
+ database: "Database",
+} as const
+
+export default function HealthPage() {
+ const tab = useTab("tab", "derp")
+ const { data: healthStatus } = useQuery({
+ queryKey: ["health"],
+ queryFn: () => getHealth(),
+ refetchInterval: 10_000,
+ })
+
+ return (
+ <>
+
+ {pageTitle("Health")}
+
+
+ {healthStatus ? (
+
+ ) : (
+
+ )}
+ >
+ )
+}
+
+export function HealthPageView({
+ healthStatus,
+ tab,
+}: {
+ healthStatus: Awaited>["data"]
+ tab: ReturnType
+}) {
+ const styles = useStyles()
+
+ return (
+
+
+
+ {healthStatus.healthy ? (
+ theme.palette.success.light,
+ }}
+ />
+ ) : (
+ theme.palette.error.main,
+ }}
+ />
+ )}
+
+
+
+ {healthStatus.healthy ? "Healthy" : "Unhealthy"}
+
+
+ {healthStatus.healthy
+ ? "All systems operational"
+ : "Some issues have been detected"}
+
+
+
+
+
+
+
+
+
+
+ theme.spacing(32),
+ flexShrink: 0,
+ borderRight: (theme) => `1px solid ${theme.palette.divider}`,
+ height: "100%",
+ }}
+ >
+ theme.palette.text.secondary,
+ padding: (theme) => theme.spacing(1.5, 3),
+ letterSpacing: "0.5px",
+ }}
+ >
+ Health
+
+
+ {Object.keys(sections)
+ .sort()
+ .map((key) => {
+ const label = sections[key as keyof typeof sections]
+ const isActive = tab.value === key
+ const isHealthy =
+ healthStatus[key as keyof typeof sections].healthy
+
+ return (
+ {
+ tab.set(key)
+ }}
+ sx={{
+ background: isActive ? colors.gray[13] : "none",
+ border: "none",
+ fontSize: 14,
+ width: "100%",
+ display: "flex",
+ alignItems: "center",
+ gap: 1,
+ textAlign: "left",
+ height: 36,
+ padding: (theme) => theme.spacing(0, 3),
+ cursor: "pointer",
+ pointerEvents: isActive ? "none" : "auto",
+ color: (theme) =>
+ isActive
+ ? theme.palette.text.primary
+ : theme.palette.text.secondary,
+ "&:hover": {
+ background: (theme) => theme.palette.action.hover,
+ color: (theme) => theme.palette.text.primary,
+ },
+ }}
+ >
+ {isHealthy ? (
+ theme.palette.success.light,
+ }}
+ />
+ ) : (
+ theme.palette.error.main,
+ }}
+ />
+ )}
+ {label}
+
+ )
+ })}
+
+
+ {/* 62px - navbar and 36px - the bottom bar */}
+
+
+
+
+
+ )
+}
+
+const useStyles = makeStyles((theme) => ({
+ stats: {
+ padding: 0,
+ border: 0,
+ gap: theme.spacing(6),
+ rowGap: theme.spacing(3),
+ flex: 1,
+
+ [theme.breakpoints.down("md")]: {
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "flex-start",
+ gap: theme.spacing(1),
+ },
+ },
+
+ statsItem: {
+ flexDirection: "column",
+ gap: 0,
+ padding: 0,
+
+ "& > span:first-of-type": {
+ fontSize: 12,
+ fontWeight: 500,
+ },
+ },
+}))
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts
index da79830725..8b20854801 100644
--- a/site/src/testHelpers/entities.ts
+++ b/site/src/testHelpers/entities.ts
@@ -1862,3 +1862,422 @@ export const MockLicenseResponse: GetLicensesResponse[] = [
},
},
]
+
+export const MockHealth = {
+ time: "2023-08-01T16:51:03.29792825Z",
+ healthy: true,
+ failing_sections: null,
+ derp: {
+ healthy: true,
+ regions: {
+ "999": {
+ healthy: true,
+ region: {
+ EmbeddedRelay: true,
+ RegionID: 999,
+ RegionCode: "coder",
+ RegionName: "Council Bluffs, Iowa",
+ Nodes: [
+ {
+ Name: "999stun0",
+ RegionID: 999,
+ HostName: "stun.l.google.com",
+ STUNPort: 19302,
+ STUNOnly: true,
+ },
+ {
+ Name: "999b",
+ RegionID: 999,
+ HostName: "dev.coder.com",
+ STUNPort: -1,
+ DERPPort: 443,
+ },
+ ],
+ },
+ node_reports: [
+ {
+ healthy: true,
+ node: {
+ Name: "999stun0",
+ RegionID: 999,
+ HostName: "stun.l.google.com",
+ STUNPort: 19302,
+ STUNOnly: true,
+ },
+ node_info: {
+ TokenBucketBytesPerSecond: 0,
+ TokenBucketBytesBurst: 0,
+ },
+ can_exchange_messages: false,
+ round_trip_ping: 0,
+ uses_websocket: false,
+ client_logs: [],
+ client_errs: [],
+ error: null,
+ stun: {
+ Enabled: true,
+ CanSTUN: true,
+ Error: null,
+ },
+ },
+ {
+ healthy: true,
+ node: {
+ Name: "999b",
+ RegionID: 999,
+ HostName: "dev.coder.com",
+ STUNPort: -1,
+ DERPPort: 443,
+ },
+ node_info: {
+ TokenBucketBytesPerSecond: 0,
+ TokenBucketBytesBurst: 0,
+ },
+ can_exchange_messages: true,
+ round_trip_ping: 7674330,
+ uses_websocket: false,
+ client_logs: [
+ [
+ "derphttp.Client.Connect: connecting to https://dev.coder.com/derp",
+ ],
+ [
+ "derphttp.Client.Connect: connecting to https://dev.coder.com/derp",
+ ],
+ ],
+ client_errs: [[], []],
+ error: null,
+ stun: {
+ Enabled: false,
+ CanSTUN: false,
+ Error: null,
+ },
+ },
+ ],
+ error: null,
+ },
+ "10007": {
+ healthy: true,
+ region: {
+ EmbeddedRelay: false,
+ RegionID: 10007,
+ RegionCode: "coder_sydney",
+ RegionName: "sydney",
+ Nodes: [
+ {
+ Name: "10007stun0",
+ RegionID: 10007,
+ HostName: "stun.l.google.com",
+ STUNPort: 19302,
+ STUNOnly: true,
+ },
+ {
+ Name: "10007a",
+ RegionID: 10007,
+ HostName: "sydney.dev.coder.com",
+ STUNPort: -1,
+ DERPPort: 443,
+ },
+ ],
+ },
+ node_reports: [
+ {
+ healthy: true,
+ node: {
+ Name: "10007stun0",
+ RegionID: 10007,
+ HostName: "stun.l.google.com",
+ STUNPort: 19302,
+ STUNOnly: true,
+ },
+ node_info: {
+ TokenBucketBytesPerSecond: 0,
+ TokenBucketBytesBurst: 0,
+ },
+ can_exchange_messages: false,
+ round_trip_ping: 0,
+ uses_websocket: false,
+ client_logs: [],
+ client_errs: [],
+ error: null,
+ stun: {
+ Enabled: true,
+ CanSTUN: true,
+ Error: null,
+ },
+ },
+ {
+ healthy: true,
+ node: {
+ Name: "10007a",
+ RegionID: 10007,
+ HostName: "sydney.dev.coder.com",
+ STUNPort: -1,
+ DERPPort: 443,
+ },
+ node_info: {
+ TokenBucketBytesPerSecond: 0,
+ TokenBucketBytesBurst: 0,
+ },
+ can_exchange_messages: true,
+ round_trip_ping: 170527034,
+ uses_websocket: false,
+ client_logs: [
+ [
+ "derphttp.Client.Connect: connecting to https://sydney.dev.coder.com/derp",
+ ],
+ [
+ "derphttp.Client.Connect: connecting to https://sydney.dev.coder.com/derp",
+ ],
+ ],
+ client_errs: [[], []],
+ error: null,
+ stun: {
+ Enabled: false,
+ CanSTUN: false,
+ Error: null,
+ },
+ },
+ ],
+ error: null,
+ },
+ "10008": {
+ healthy: true,
+ region: {
+ EmbeddedRelay: false,
+ RegionID: 10008,
+ RegionCode: "coder_europe-frankfurt",
+ RegionName: "europe-frankfurt",
+ Nodes: [
+ {
+ Name: "10008stun0",
+ RegionID: 10008,
+ HostName: "stun.l.google.com",
+ STUNPort: 19302,
+ STUNOnly: true,
+ },
+ {
+ Name: "10008a",
+ RegionID: 10008,
+ HostName: "europe.dev.coder.com",
+ STUNPort: -1,
+ DERPPort: 443,
+ },
+ ],
+ },
+ node_reports: [
+ {
+ healthy: true,
+ node: {
+ Name: "10008stun0",
+ RegionID: 10008,
+ HostName: "stun.l.google.com",
+ STUNPort: 19302,
+ STUNOnly: true,
+ },
+ node_info: {
+ TokenBucketBytesPerSecond: 0,
+ TokenBucketBytesBurst: 0,
+ },
+ can_exchange_messages: false,
+ round_trip_ping: 0,
+ uses_websocket: false,
+ client_logs: [],
+ client_errs: [],
+ error: null,
+ stun: {
+ Enabled: true,
+ CanSTUN: true,
+ Error: null,
+ },
+ },
+ {
+ healthy: true,
+ node: {
+ Name: "10008a",
+ RegionID: 10008,
+ HostName: "europe.dev.coder.com",
+ STUNPort: -1,
+ DERPPort: 443,
+ },
+ node_info: {
+ TokenBucketBytesPerSecond: 0,
+ TokenBucketBytesBurst: 0,
+ },
+ can_exchange_messages: true,
+ round_trip_ping: 111329690,
+ uses_websocket: false,
+ client_logs: [
+ [
+ "derphttp.Client.Connect: connecting to https://europe.dev.coder.com/derp",
+ ],
+ [
+ "derphttp.Client.Connect: connecting to https://europe.dev.coder.com/derp",
+ ],
+ ],
+ client_errs: [[], []],
+ error: null,
+ stun: {
+ Enabled: false,
+ CanSTUN: false,
+ Error: null,
+ },
+ },
+ ],
+ error: null,
+ },
+ "10009": {
+ healthy: true,
+ region: {
+ EmbeddedRelay: false,
+ RegionID: 10009,
+ RegionCode: "coder_brazil-saopaulo",
+ RegionName: "brazil-saopaulo",
+ Nodes: [
+ {
+ Name: "10009stun0",
+ RegionID: 10009,
+ HostName: "stun.l.google.com",
+ STUNPort: 19302,
+ STUNOnly: true,
+ },
+ {
+ Name: "10009a",
+ RegionID: 10009,
+ HostName: "brazil.dev.coder.com",
+ STUNPort: -1,
+ DERPPort: 443,
+ },
+ ],
+ },
+ node_reports: [
+ {
+ healthy: true,
+ node: {
+ Name: "10009stun0",
+ RegionID: 10009,
+ HostName: "stun.l.google.com",
+ STUNPort: 19302,
+ STUNOnly: true,
+ },
+ node_info: {
+ TokenBucketBytesPerSecond: 0,
+ TokenBucketBytesBurst: 0,
+ },
+ can_exchange_messages: false,
+ round_trip_ping: 0,
+ uses_websocket: false,
+ client_logs: [],
+ client_errs: [],
+ error: null,
+ stun: {
+ Enabled: true,
+ CanSTUN: true,
+ Error: null,
+ },
+ },
+ {
+ healthy: true,
+ node: {
+ Name: "10009a",
+ RegionID: 10009,
+ HostName: "brazil.dev.coder.com",
+ STUNPort: -1,
+ DERPPort: 443,
+ },
+ node_info: {
+ TokenBucketBytesPerSecond: 0,
+ TokenBucketBytesBurst: 0,
+ },
+ can_exchange_messages: true,
+ round_trip_ping: 138185506,
+ uses_websocket: false,
+ client_logs: [
+ [
+ "derphttp.Client.Connect: connecting to https://brazil.dev.coder.com/derp",
+ ],
+ [
+ "derphttp.Client.Connect: connecting to https://brazil.dev.coder.com/derp",
+ ],
+ ],
+ client_errs: [[], []],
+ error: null,
+ stun: {
+ Enabled: false,
+ CanSTUN: false,
+ Error: null,
+ },
+ },
+ ],
+ error: null,
+ },
+ },
+ netcheck: {
+ UDP: true,
+ IPv6: false,
+ IPv4: true,
+ IPv6CanSend: false,
+ IPv4CanSend: true,
+ OSHasIPv6: true,
+ ICMPv4: false,
+ MappingVariesByDestIP: false,
+ HairPinning: null,
+ UPnP: false,
+ PMP: false,
+ PCP: false,
+ PreferredDERP: 999,
+ RegionLatency: {
+ "999": 1638180,
+ "10007": 174853022,
+ "10008": 112142029,
+ "10009": 138855606,
+ },
+ RegionV4Latency: {
+ "999": 1638180,
+ "10007": 174853022,
+ "10008": 112142029,
+ "10009": 138855606,
+ },
+ RegionV6Latency: {},
+ GlobalV4: "34.71.26.24:55368",
+ GlobalV6: "",
+ CaptivePortal: null,
+ },
+ netcheck_err: null,
+ netcheck_logs: [
+ "netcheck: netcheck.runProbe: got STUN response for 10007stun0 from 34.71.26.24:55368 (9b07930007da49dd7df79bc7) in 1.791799ms",
+ "netcheck: netcheck.runProbe: got STUN response for 999stun0 from 34.71.26.24:55368 (7397fec097f1d5b01364566b) in 1.791529ms",
+ "netcheck: netcheck.runProbe: got STUN response for 10008stun0 from 34.71.26.24:55368 (1fdaaa016ca386485f097f68) in 2.192899ms",
+ "netcheck: netcheck.runProbe: got STUN response for 10009stun0 from 34.71.26.24:55368 (2596fe60895fbd9542823a76) in 2.146459ms",
+ "netcheck: netcheck.runProbe: got STUN response for 10007stun0 from 34.71.26.24:55368 (19ec320f3b76e8b027b06d3e) in 2.139619ms",
+ "netcheck: netcheck.runProbe: got STUN response for 999stun0 from 34.71.26.24:55368 (a17973bc57c35e606c0f46f5) in 2.131089ms",
+ "netcheck: netcheck.runProbe: got STUN response for 10008stun0 from 34.71.26.24:55368 (c958e15209d139a6e410f13a) in 2.127549ms",
+ "netcheck: netcheck.runProbe: got STUN response for 10009stun0 from 34.71.26.24:55368 (284a1b64dff22f40a3514524) in 2.107549ms",
+ "netcheck: [v1] measureAllICMPLatency: listen ip4:icmp 0.0.0.0: socket: operation not permitted",
+ "netcheck: [v1] report: udp=true v6=false v6os=true mapvarydest=false hair= portmap= v4a=34.71.26.24:55368 derp=999 derpdist=999v4:2ms,10007v4:175ms,10008v4:112ms,10009v4:139ms",
+ ],
+ error: null,
+ },
+ access_url: {
+ access_url: "https://dev.coder.com",
+ healthy: true,
+ reachable: true,
+ status_code: 200,
+ healthz_response: "OK",
+ error: null,
+ },
+ websocket: {
+ healthy: true,
+ response: {
+ body: "",
+ code: 101,
+ },
+ error: null,
+ },
+ database: {
+ healthy: true,
+ reachable: true,
+ latency: 92570,
+ error: null,
+ },
+ coder_version: "v0.27.1-devel+c575292",
+}
diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts
index 27418b2c4a..9d90e09724 100644
--- a/site/src/testHelpers/handlers.ts
+++ b/site/src/testHelpers/handlers.ts
@@ -387,4 +387,8 @@ export const handlers = [
rest.get("/api/v2/workspaceagents/:agent/logs", (_, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockWorkspaceAgentLogs))
}),
+
+ rest.get("/api/v2/debug/health", (_, res, ctx) => {
+ return res(ctx.status(200), ctx.json(M.MockHealth))
+ }),
]