mirror of https://github.com/coder/coder.git
feat(site): refactor health pages (#11025)
This commit is contained in:
parent
2e4e0b2d2c
commit
0f47b58bfb
|
@ -38,7 +38,17 @@ export const decorators = [
|
|||
},
|
||||
(Story) => {
|
||||
return (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Story />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
|
|
@ -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. */}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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 = {};
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 = {};
|
|
@ -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;
|
|
@ -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 = {};
|
|
@ -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;
|
|
@ -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 = {};
|
|
@ -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;
|
|
@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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>>;
|
|
@ -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 = {};
|
|
@ -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;
|
|
@ -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 = {};
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue