feat(site): refactor health pages (#11025)

This commit is contained in:
Bruno Quaresma 2023-12-05 13:58:51 -03:00 committed by GitHub
parent 2e4e0b2d2c
commit 0f47b58bfb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1618 additions and 464 deletions

View File

@ -38,7 +38,17 @@ export const decorators = [
},
(Story) => {
return (
<QueryClientProvider client={new QueryClient()}>
<QueryClientProvider
client={
new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
},
},
})
}
>
<Story />
</QueryClientProvider>
);

View File

@ -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 = () => {
<Route element={<DashboardLayout />}>
<Route index element={<Navigate to="/workspaces" replace />} />
<Route path="/health" element={<HealthPage />} />
<Route
path="/external-auth/:provider"
element={<ExternalAuthPage />}
@ -339,6 +345,21 @@ export const AppRouter: FC = () => {
<Route path="schedule" element={<WorkspaceSchedulePage />} />
</Route>
<Route path="/health" element={<HealthLayout />}>
<Route index element={<Navigate to="access-url" />} />
<Route path="access-url" element={<AccessURLPage />} />
<Route path="database" element={<DatabasePage />} />
<Route path="derp" element={<DERPPage />} />
<Route
path="derp/regions/:regionId"
element={<DERPRegionPage />}
/>
<Route path="websocket" element={<WebsocketPage />} />
<Route
path="workspace-proxy"
element={<WorkspaceProxyHealthPage />}
/>
</Route>
{/* Using path="*"" means "match anything", so this route
acts like a catch-all for URLs that we don't have explicit
routes for. */}

View File

@ -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 (
<NavbarView
@ -30,6 +33,7 @@ export const Navbar: FC = () => {
canViewAuditLog={canViewAuditLog}
canViewDeployment={canViewDeployment}
canViewAllUsers={canViewAllUsers}
canViewHealth={canViewHealth}
proxyContextValue={
dashboard.experiments.includes("moons") ? proxyContextValue : undefined
}

View File

@ -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);

View File

@ -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<NavItemsProps> = ({
@ -57,6 +59,7 @@ const NavItems: FC<NavItemsProps> = ({
canViewAuditLog,
canViewDeployment,
canViewAllUsers,
canViewHealth,
}) => {
const location = useLocation();
const theme = useTheme();
@ -93,6 +96,11 @@ const NavItems: FC<NavItemsProps> = ({
{Language.deployment}
</NavLink>
)}
{canViewHealth && (
<NavLink css={styles.link} to="/health">
Health
</NavLink>
)}
</nav>
);
};
@ -106,6 +114,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
canViewAuditLog,
canViewDeployment,
canViewAllUsers,
canViewHealth,
proxyContextValue,
}) => {
const theme = useTheme();
@ -150,6 +159,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
canViewAuditLog={canViewAuditLog}
canViewDeployment={canViewDeployment}
canViewAllUsers={canViewAllUsers}
canViewHealth={canViewHealth}
/>
</div>
</Drawer>
@ -167,6 +177,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
canViewAuditLog={canViewAuditLog}
canViewDeployment={canViewDeployment}
canViewAllUsers={canViewAllUsers}
canViewHealth={canViewHealth}
/>
<div css={styles.navMenus}>

View File

@ -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" },
<AccessURLPage />,
),
}),
},
decorators: [
(Story) => {
const queryClient = useQueryClient();
queryClient.setQueryData(["health"], MockHealth);
return <Story />;
},
],
};
export default meta;
type Story = StoryObj;
export const Default: Story = {};

View File

@ -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<HealthcheckReport>();
const accessUrl = healthStatus.access_url;
return (
<>
<Helmet>
<title>{pageTitle("Access URL - Health")}</title>
</Helmet>
<Header>
<HeaderTitle>
<HealthyDot severity={accessUrl.severity} />
Access URL
</HeaderTitle>
</Header>
<Main>
{accessUrl.warnings.map((warning) => {
return (
<Alert key={warning.code} severity="warning">
{warning.message}
</Alert>
);
})}
<GridData>
<GridDataLabel>Severity</GridDataLabel>
<GridDataValue>{accessUrl.severity}</GridDataValue>
<GridDataLabel>Access URL</GridDataLabel>
<GridDataValue>{accessUrl.access_url}</GridDataValue>
<GridDataLabel>Reachable</GridDataLabel>
<GridDataValue>{accessUrl.reachable ? "Yes" : "No"}</GridDataValue>
<GridDataLabel>Status Code</GridDataLabel>
<GridDataValue>{accessUrl.status_code}</GridDataValue>
</GridData>
</Main>
</>
);
};
export default AccessURLPage;

View File

@ -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<HTMLDivElement>) => {
return (
<header
css={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: `36px ${CONTENT_PADDING}px`,
}}
{...props}
/>
);
};
export const HeaderTitle = (props: HTMLAttributes<HTMLHeadingElement>) => {
return (
<h2
css={{
margin: 0,
lineHeight: "1.2",
fontSize: 20,
fontWeight: 500,
display: "flex",
alignItems: "center",
gap: 16,
}}
{...props}
/>
);
};
export const HealthIcon = ({
size,
severity,
}: {
size: number;
severity: HealthSeverity;
}) => {
const theme = useTheme();
const color = healthyColor(theme, severity);
const Icon = severity === "error" ? ErrorOutline : CheckCircleOutlined;
return <Icon css={{ width: size, height: size, color }} />;
};
export const HealthyDot = ({ severity }: { severity: HealthSeverity }) => {
const theme = useTheme();
return (
<div
css={{
width: 8,
height: 8,
borderRadius: 9999,
backgroundColor: healthyColor(theme, severity),
}}
/>
);
};
export const Main = (props: HTMLAttributes<HTMLDivElement>) => {
return (
<main
css={{
padding: `0 ${CONTENT_PADDING}px ${CONTENT_PADDING}px`,
display: "flex",
flexDirection: "column",
gap: 36,
}}
{...props}
/>
);
};
export const GridData = (props: HTMLAttributes<HTMLDivElement>) => {
return (
<div
css={{
lineHeight: "1.4",
display: "grid",
gridTemplateColumns: "auto auto",
gap: 12,
columnGap: 48,
width: "min-content",
whiteSpace: "nowrap",
}}
{...props}
/>
);
};
export const GridDataLabel = (props: HTMLAttributes<HTMLSpanElement>) => {
const theme = useTheme();
return (
<span
css={{
fontSize: 14,
fontWeight: 500,
color: theme.palette.text.secondary,
}}
{...props}
/>
);
};
export const GridDataValue = (props: HTMLAttributes<HTMLSpanElement>) => {
const theme = useTheme();
return (
<span
css={{
fontSize: 14,
color: theme.palette.text.primary,
}}
{...props}
/>
);
};
export const SectionLabel = (props: HTMLAttributes<HTMLHeadingElement>) => {
return (
<h4
{...props}
css={{
fontSize: 14,
fontWeight: 500,
margin: 0,
lineHeight: "1.2",
marginBottom: 16,
}}
/>
);
};
type PillProps = HTMLAttributes<HTMLDivElement> & {
icon: ReactElement;
};
export const Pill = forwardRef<HTMLDivElement, PillProps>((props, ref) => {
const theme = useTheme();
const { icon, children, ...divProps } = props;
return (
<div
ref={ref}
css={{
display: "inline-flex",
alignItems: "center",
height: 32,
borderRadius: 9999,
border: `1px solid ${theme.palette.divider}`,
fontSize: 12,
fontWeight: 500,
padding: "8px 16px 8px 8px",
gap: 8,
cursor: "default",
}}
{...divProps}
>
{cloneElement(icon, { className: css({ width: 14, height: 14 }) })}
{children}
</div>
);
});
type BooleanPillProps = Omit<
ComponentProps<typeof Pill>,
"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 (
<Pill
icon={
value ? (
<CheckCircleOutlined css={{ color }} />
) : (
<DoNotDisturbOnOutlined css={{ color }} />
)
}
{...divProps}
>
{children}
</Pill>
);
};
type LogsProps = { lines: string[] } & HTMLAttributes<HTMLDivElement>;
export const Logs = (props: LogsProps) => {
const theme = useTheme();
const { lines, ...divProps } = props;
return (
<div
css={{
fontFamily: "monospace",
fontSize: 13,
lineHeight: "160%",
padding: 24,
backgroundColor: theme.palette.background.paper,
overflowX: "auto",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
}}
{...divProps}
>
{lines.map((line, index) => (
<span css={{ display: "block" }} key={index}>
{line}
</span>
))}
{lines.length === 0 && (
<span css={{ color: theme.palette.text.secondary }}>
No logs available
</span>
)}
</div>
);
};

View File

@ -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" }, <DERPPage />),
}),
},
decorators: [
(Story) => {
const queryClient = useQueryClient();
queryClient.setQueryData(["health"], MockHealth);
return <Story />;
},
],
};
export default meta;
type Story = StoryObj;
export const Default: Story = {};

View File

@ -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<HealthcheckReport>();
const { netcheck, regions, netcheck_logs: logs } = derp;
const theme = useTheme();
return (
<>
<Helmet>
<title>{pageTitle("DERP - Health")}</title>
</Helmet>
<Header>
<HeaderTitle>
<HealthyDot severity={derp.severity as HealthSeverity} />
DERP
</HeaderTitle>
</Header>
<Main>
{derp.warnings.map((warning: HealthMessage) => {
return (
<Alert key={warning.code} severity="warning">
{warning.message}
</Alert>
);
})}
<section>
<SectionLabel>Flags</SectionLabel>
<div css={{ display: "flex", flexWrap: "wrap", gap: 12 }}>
{flags.map((flag) => (
<BooleanPill key={flag} value={netcheck[flag]}>
{flag}
</BooleanPill>
))}
</div>
</section>
<section>
<SectionLabel>Regions</SectionLabel>
<div css={{ display: "flex", flexWrap: "wrap", gap: 12 }}>
{Object.values(regions)
.sort((a, b) => {
if (a.region && b.region) {
return a.region.RegionName.localeCompare(b.region.RegionName);
}
})
.map(({ severity, region }) => {
return (
<Button
startIcon={
<LocationOnOutlined
css={{
width: 16,
height: 16,
color: healthyColor(
theme,
severity as HealthSeverity,
),
}}
/>
}
component={Link}
to={`/health/derp/regions/${region.RegionID}`}
key={region.RegionID}
>
{region.RegionName}
</Button>
);
})}
</div>
</section>
<section>
<SectionLabel>Logs</SectionLabel>
<Logs
lines={logs}
css={(theme) => ({
borderRadius: 8,
border: `1px solid ${theme.palette.divider}`,
color: theme.palette.text.secondary,
})}
/>
</section>
</Main>
</>
);
};
export default DERPPage;

View File

@ -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` },
<DERPRegionPage />,
),
}),
},
decorators: [
(Story) => {
const queryClient = useQueryClient();
queryClient.setQueryData(["health"], MockHealth);
return <Story />;
},
],
};
export default meta;
type Story = StoryObj;
export const Default: Story = {};

View File

@ -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<HealthcheckReport>();
const params = useParams() as { regionId: string };
const regionId = Number(params.regionId);
const {
region,
node_reports: reports,
warnings,
severity,
} = healthStatus.derp.regions[regionId];
return (
<>
<Helmet>
<title>{pageTitle(`${region.RegionName} - Health`)}</title>
</Helmet>
<Header>
<hgroup>
<Link
css={{
fontSize: 12,
textDecoration: "none",
color: theme.palette.text.secondary,
fontWeight: 500,
display: "inline-flex",
alignItems: "center",
"&:hover": {
color: theme.palette.text.primary,
},
marginBottom: 8,
lineHeight: "120%",
}}
to="/health/derp"
>
<ArrowBackOutlined
css={{ fontSize: 12, verticalAlign: "middle", marginRight: 8 }}
/>
Back to DERP
</Link>
<HeaderTitle>
<HealthyDot severity={severity as HealthSeverity} />
{region.RegionName}
</HeaderTitle>
</hgroup>
</Header>
<Main>
{warnings.map((warning: HealthMessage) => {
return (
<Alert key={warning.code} severity="warning">
{warning.message}
</Alert>
);
})}
<section>
<div css={{ display: "flex", flexWrap: "wrap", gap: 12 }}>
<Tooltip title="Region ID">
<Pill icon={<TagOutlined />}>{region.RegionID}</Pill>
</Tooltip>
<Tooltip title="Region Code">
<Pill icon={<CodeOutlined />}>{region.RegionCode}</Pill>
</Tooltip>
<BooleanPill value={region.EmbeddedRelay}>
Embedded Relay
</BooleanPill>
</div>
</section>
{reports.map((report) => {
const { node, client_logs: logs } = report;
const latencyColor = getLatencyColor(
theme,
report.round_trip_ping_ms,
);
return (
<section
key={node.HostName}
css={{
border: `1px solid ${theme.palette.divider}`,
borderRadius: 8,
fontSize: 14,
}}
>
<header
css={{
padding: 24,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<h4
css={{
fontWeight: 500,
margin: 0,
lineHeight: "120%",
}}
>
{node.HostName}
</h4>
<div
css={{
display: "flex",
alignItems: "center",
gap: 8,
color: theme.palette.text.secondary,
fontSize: 12,
lineHeight: "120%",
marginTop: 8,
}}
>
<span>DERP Port: {node.DERPPort ?? "None"}</span>
<span>STUN Port: {node.STUNPort ?? "None"}</span>
</div>
</div>
<div css={{ display: "flex", gap: 8, alignItems: "center" }}>
<Tooltip title="Round trip ping">
<Pill
css={{ color: latencyColor }}
icon={
<div
css={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
css={{
width: 8,
height: 8,
backgroundColor: latencyColor,
borderRadius: 9999,
}}
/>
</div>
}
>
{report.round_trip_ping_ms}ms
</Pill>
</Tooltip>
<BooleanPill value={report.can_exchange_messages}>
Exchange Messages
</BooleanPill>
<BooleanPill value={report.uses_websocket}>
Websocket
</BooleanPill>
</div>
</header>
<Logs
lines={logs?.[0] ?? []}
css={{
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
borderTop: `1px solid ${theme.palette.divider}`,
}}
/>
</section>
);
})}
</Main>
</>
);
};
export default DERPRegionPage;

View File

@ -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" },
<DatabasePage />,
),
}),
},
decorators: [
(Story) => {
const queryClient = useQueryClient();
queryClient.setQueryData(["health"], MockHealth);
return <Story />;
},
],
};
export default meta;
type Story = StoryObj;
export const Default: Story = {};

View File

@ -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<HealthcheckReport>();
const database = healthStatus.database;
return (
<>
<Helmet>
<title>{pageTitle("Database - Health")}</title>
</Helmet>
<Header>
<HeaderTitle>
<HealthyDot severity={database.severity} />
Database
</HeaderTitle>
</Header>
<Main>
{database.warnings.map((warning) => {
return (
<Alert key={warning.code} severity="warning">
{warning.message}
</Alert>
);
})}
<GridData>
<GridDataLabel>Reachable</GridDataLabel>
<GridDataValue>{database.reachable ? "Yes" : "No"}</GridDataValue>
<GridDataLabel>Latency</GridDataLabel>
<GridDataValue>{database.latency_ms}ms</GridDataValue>
<GridDataLabel>Threshold</GridDataLabel>
<GridDataValue>{database.threshold_ms}ms</GridDataValue>
</GridData>
</Main>
</>
);
};
export default DatabasePage;

View File

@ -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 (
<>
<Helmet>
<title>{pageTitle("Health")}</title>
</Helmet>
{healthStatus ? (
<DashboardFullPage>
<div
css={{
display: "flex",
flexBasis: 0,
flex: 1,
overflow: "hidden",
}}
>
<div
css={{
width: 256,
flexShrink: 0,
borderRight: `1px solid ${theme.palette.divider}`,
fontSize: 14,
}}
>
<div
css={{
padding: 24,
display: "flex",
flexDirection: "column",
gap: 16,
}}
>
<div>
<div
css={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<HealthIcon size={32} severity={healthStatus.severity} />
<Tooltip title="Refresh health checks">
<IconButton
size="small"
disabled={isRefreshing}
data-testid="healthcheck-refresh-button"
onClick={() => {
forceRefresh();
}}
>
{isRefreshing ? (
<CircularProgress size={16} />
) : (
<ReplayIcon css={{ width: 20, height: 20 }} />
)}
</IconButton>
</Tooltip>
</div>
<div css={{ fontWeight: 500, marginTop: 16 }}>
{healthStatus.healthy ? "Healthy" : "Unhealthy"}
</div>
<div
css={{
color: theme.palette.text.secondary,
lineHeight: "150%",
}}
>
{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"}
</div>
</div>
<div css={{ display: "flex", flexDirection: "column" }}>
<span css={{ fontWeight: 500 }}>Last check</span>
<span
css={{
color: theme.palette.text.secondary,
lineHeight: "150%",
}}
>
{createDayString(healthStatus.time)}
</span>
</div>
<div css={{ display: "flex", flexDirection: "column" }}>
<span css={{ fontWeight: 500 }}>Version</span>
<span
css={{
color: theme.palette.text.secondary,
lineHeight: "150%",
}}
>
{healthStatus.coder_version}
</span>
</div>
</div>
<nav css={{ display: "flex", flexDirection: "column", gap: 1 }}>
{Object.keys(sections)
.sort()
.map((key) => {
const label = sections[key as keyof typeof sections];
const healthSection =
healthStatus[key as keyof typeof sections];
return (
<NavLink
end
key={key}
to={`/health/${kebabCase(key)}`}
className={({ isActive }) =>
css({
background: isActive
? theme.palette.action.hover
: "none",
pointerEvents: isActive ? "none" : "auto",
color: isActive
? theme.palette.text.primary
: theme.palette.text.secondary,
border: "none",
fontSize: 14,
width: "100%",
display: "flex",
alignItems: "center",
gap: 12,
textAlign: "left",
height: 36,
padding: "0 24px",
cursor: "pointer",
textDecoration: "none",
"&:hover": {
background: theme.palette.action.hover,
color: theme.palette.text.primary,
},
})
}
>
<HealthIcon
size={16}
severity={healthSection.severity as HealthSeverity}
/>
{label}
</NavLink>
);
})}
</nav>
</div>
<div css={{ overflowY: "auto", width: "100%" }}>
<Suspense fallback={<Loader />}>
<Outlet context={healthStatus} />
</Suspense>
</div>
</div>
</DashboardFullPage>
) : (
<Loader />
)}
</>
);
}

View File

@ -1,171 +0,0 @@
import { Meta, StoryObj } from "@storybook/react";
import { HealthPageView } from "./HealthPage";
import { MockHealth } from "testHelpers/entities";
const meta: Meta<typeof HealthPageView> = {
title: "pages/HealthPage",
component: HealthPageView,
args: {
tab: {
value: "derp",
set: () => {},
},
healthStatus: MockHealth,
},
};
export default meta;
type Story = StoryObj<typeof HealthPageView>;
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" }],
},
},
},
};

View File

@ -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 (
<>
<Helmet>
<title>{pageTitle("Health")}</title>
</Helmet>
{healthStatus ? (
<HealthPageView
tab={tab}
healthStatus={healthStatus}
forceRefresh={forceRefresh}
isRefreshing={isRefreshing}
/>
) : (
<Loader />
)}
</>
);
}
interface HealthPageViewProps {
healthStatus: Awaited<ReturnType<typeof getHealth>>;
tab: ReturnType<typeof useTab>;
forceRefresh: () => void;
isRefreshing: boolean;
}
export const HealthPageView: FC<HealthPageViewProps> = ({
healthStatus,
tab,
forceRefresh,
isRefreshing,
}) => {
const theme = useTheme();
return (
<DashboardFullPage>
<div
css={{
display: "flex",
flexBasis: 0,
flex: 1,
overflow: "hidden",
}}
>
<div
css={{
width: 256,
flexShrink: 0,
borderRight: `1px solid ${theme.palette.divider}`,
fontSize: 14,
}}
>
<div
css={{
padding: 24,
display: "flex",
flexDirection: "column",
gap: 16,
}}
>
<div>
<div
css={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
{healthStatus.healthy ? (
<CheckCircleOutlined
css={{
width: 32,
height: 32,
color: theme.palette.success.light,
}}
/>
) : (
<ErrorOutline
css={{
width: 32,
height: 32,
color: theme.palette.error.light,
}}
/>
)}
<Tooltip title="Refresh health checks">
<IconButton
size="small"
disabled={isRefreshing}
data-testid="healthcheck-refresh-button"
onClick={forceRefresh}
>
{isRefreshing ? (
<CircularProgress size={16} />
) : (
<ReplayIcon css={{ width: 20, height: 20 }} />
)}
</IconButton>
</Tooltip>
</div>
<div css={{ fontWeight: 500, marginTop: 16 }}>
{healthStatus.healthy ? "Healthy" : "Unhealthy"}
</div>
<div
css={{
color: theme.palette.text.secondary,
lineHeight: "150%",
}}
>
{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"}
</div>
</div>
<div css={{ display: "flex", flexDirection: "column" }}>
<span css={{ fontWeight: 500 }}>Last check</span>
<span
css={{
color: theme.palette.text.secondary,
lineHeight: "150%",
}}
>
{createDayString(healthStatus.time)}
</span>
</div>
<div css={{ display: "flex", flexDirection: "column" }}>
<span css={{ fontWeight: 500 }}>Version</span>
<span
css={{
color: theme.palette.text.secondary,
lineHeight: "150%",
}}
>
{healthStatus.coder_version}
</span>
</div>
</div>
<nav css={{ display: "flex", flexDirection: "column", gap: 1 }}>
{Object.keys(sections)
.sort()
.map((key) => {
const label = sections[key as keyof typeof sections];
const isActive = tab.value === key;
const healthSection =
healthStatus[key as keyof typeof sections];
const isHealthy = healthSection.healthy;
const isWarning = healthSection.warnings?.length > 0;
return (
<button
key={key}
onClick={() => {
tab.set(key);
}}
css={[
styles.sectionLink,
isActive && styles.activeSectionLink,
]}
>
{isHealthy ? (
isWarning ? (
<CheckCircleOutlined
css={{
width: 16,
height: 16,
color: theme.palette.warning.light,
}}
/>
) : (
<CheckCircleOutlined
css={{
width: 16,
height: 16,
color: theme.palette.success.light,
}}
/>
)
) : (
<ErrorOutline
css={{
width: 16,
height: 16,
color: theme.palette.error.main,
}}
/>
)}
{label}
</button>
);
})}
</nav>
</div>
<div css={{ overflowY: "auto", width: "100%" }} data-chromatic="ignore">
<SyntaxHighlighter
language="json"
editorProps={{ height: "100%" }}
value={JSON.stringify(
healthStatus[tab.value as keyof typeof healthStatus],
null,
2,
)}
/>
</div>
</div>
</DashboardFullPage>
);
};
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<string, Interpolation<Theme>>;

View File

@ -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" },
<WebsocketPage />,
),
}),
},
decorators: [
(Story) => {
const queryClient = useQueryClient();
queryClient.setQueryData(["health"], MockHealth);
return <Story />;
},
],
};
export default meta;
type Story = StoryObj;
export const Default: Story = {};

View File

@ -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<HealthcheckReport>();
const { websocket } = healthStatus;
const theme = useTheme();
return (
<>
<Helmet>
<title>{pageTitle("Websocket - Health")}</title>
</Helmet>
<Header>
<HeaderTitle>
<HealthyDot severity={websocket.severity} />
Websocket
</HeaderTitle>
</Header>
<Main>
{websocket.warnings.map((warning) => {
return (
<Alert key={warning} severity="warning">
{warning}
</Alert>
);
})}
<section>
<Tooltip title="Code">
<Pill icon={<CodeOutlined />}>{websocket.code}</Pill>
</Tooltip>
</section>
<section>
<SectionLabel>Body</SectionLabel>
<div
css={{
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 8,
fontSize: 14,
padding: 24,
fontFamily: MONOSPACE_FONT_FAMILY,
}}
>
{websocket.body !== "" ? (
websocket.body
) : (
<span css={{ color: theme.palette.text.secondary }}>
No body message
</span>
)}
</div>
</section>
</Main>
</>
);
};
export default WebsocketPage;

View File

@ -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" },
<WorkspaceProxyPage />,
),
}),
},
decorators: [
(Story) => {
const queryClient = useQueryClient();
queryClient.setQueryData(["health"], MockHealth);
return <Story />;
},
],
};
export default meta;
type Story = StoryObj;
export const Default: Story = {};

View File

@ -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<HealthcheckReport>();
const { workspace_proxy } = healthStatus;
const { regions } = workspace_proxy.workspace_proxies;
const theme = useTheme();
return (
<>
<Helmet>
<title>{pageTitle("Workspace Proxy - Health")}</title>
</Helmet>
<Header>
<HeaderTitle>
<HealthyDot severity={workspace_proxy.severity} />
Workspace Proxy
</HeaderTitle>
</Header>
<Main>
{workspace_proxy.warnings.map((warning) => {
return (
<Alert key={warning.code} severity="warning">
{warning.message}
</Alert>
);
})}
{regions.map((region) => {
const warnings = region.status?.report?.warnings ?? [];
return (
<div
key={region.id}
css={{
borderRadius: 8,
border: `1px solid ${theme.palette.divider}`,
fontSize: 14,
}}
>
<header
css={{
padding: 24,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 24,
}}
>
<div css={{ display: "flex", alignItems: "center", gap: 24 }}>
<div
css={{
width: 36,
height: 36,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<img
src={region.icon_url}
css={{ objectFit: "fill", width: "100%", height: "100%" }}
alt=""
/>
</div>
<div css={{ lineHeight: "160%" }}>
<h4 css={{ fontWeight: 500, margin: 0 }}>
{region.display_name}
</h4>
<span css={{ color: theme.palette.text.secondary }}>
{region.version}
</span>
</div>
</div>
<div css={{ display: "flex", flexWrap: "wrap", gap: 12 }}>
{region.wildcard_hostname && (
<Tooltip title="Wildcard Hostname">
<Pill icon={<PublicOutlined />}>
{region.wildcard_hostname}
</Pill>
</Tooltip>
)}
{region.version && (
<Tooltip title="Version">
<Pill icon={<TagOutlined />}>{region.version}</Pill>
</Tooltip>
)}
<BooleanPill value={region.derp_enabled}>
DERP Enabled
</BooleanPill>
<BooleanPill value={region.derp_only}>DERP Only</BooleanPill>
<BooleanPill value={region.deleted}>Deleted</BooleanPill>
</div>
</header>
<div
css={{
borderTop: `1px solid ${theme.palette.divider}`,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "8px 24px",
fontSize: 12,
color: theme.palette.text.secondary,
}}
>
{warnings.length > 0 ? (
<div css={{ display: "flex", flexDirection: "column" }}>
{warnings.map((warning, i) => (
<span key={i}>{warning}</span>
))}
</div>
) : (
<span>No warnings</span>
)}
<span>{createDayString(region.updated_at)}</span>
</div>
</div>
);
})}
</Main>
</>
);
};
export default WorkspaceProxyPage;

View File

@ -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;
}
};

View File

@ -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",