mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat(site): improve DERP health page readability (#22984)
## Why The DERP health page displayed raw field names like `MappingVariesByDestIP`, `PMP`, `PCP`, `HairPinning` with no context. Users without deep networking knowledge had no way to understand what these flags meant or why they mattered. This change makes the page self-documenting. ## What - DERPPage (`/health/derp`) - Replace flat pill row with four logically grouped tables: **Connectivity**, **IPv6 Support**, **NAT Traversal**, **Port Mapping**. - Rename section from "Flags" to "Network Checks". - Surface `CaptivePortal` flag (previously missing from the UI entirely). - Invert display of `MappingVariesByDestIP` and `CaptivePortal` so green always means good. - Handle `null` boolean fields (e.g. UPnP, PMP, PCP) with a distinct "not checked" neutral icon. - DERPRegionPage (`/health/derp/regions/:regionId`) - Replace per-node `BooleanPill` row with a table showing **Exchange Messages**, **Direct HTTP Upgrade**, **STUN Enabled**, and **STUN Reachable** per node. - Invert `uses_websocket` display as "Direct HTTP Upgrade" (green when websocket is not needed). - Surface **STUN Enabled** and **STUN Reachable** per node (data was returned by the API but never rendered). - Add null guards for `region` and `node` (remove `!` non-null assertions). - Convert all emotion/MUI styles to Tailwind classes; remove `reportStyles` object and `useTheme` import. - Content.tsx (shared) - Adds an exported `StatusIcon` component with three states: `true` (green check), `false` (red minus), `null` (neutral help icon).
This commit is contained in:
@@ -4,6 +4,7 @@ import type { HealthCode, HealthSeverity } from "api/typesGenerated";
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
CircleCheckIcon,
|
||||
CircleHelpIcon,
|
||||
CircleMinusIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -183,6 +184,21 @@ export const Pill: React.FC<PillProps> = ({ icon, children, ...divProps }) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface StatusIconProps {
|
||||
value: boolean | null;
|
||||
}
|
||||
|
||||
export const StatusIcon: FC<StatusIconProps> = ({ value }) => {
|
||||
if (value === null) {
|
||||
return <CircleHelpIcon className="size-icon-sm text-content-disabled" />;
|
||||
}
|
||||
return value ? (
|
||||
<CircleCheckIcon className="size-icon-sm text-content-success" />
|
||||
) : (
|
||||
<CircleMinusIcon className="size-icon-sm text-content-destructive" />
|
||||
);
|
||||
};
|
||||
|
||||
type BooleanPillProps = Omit<ComponentProps<typeof Pill>, "icon" | "value"> & {
|
||||
value: boolean | null;
|
||||
};
|
||||
|
||||
@@ -6,12 +6,12 @@ import type {
|
||||
} from "api/typesGenerated";
|
||||
import { Alert } from "components/Alert/Alert";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { Table, TableBody, TableCell, TableRow } from "components/Table/Table";
|
||||
import { MapPinIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Link, useOutletContext } from "react-router";
|
||||
import { pageTitle } from "utils/page";
|
||||
import {
|
||||
BooleanPill,
|
||||
Header,
|
||||
HeaderTitle,
|
||||
HealthMessageDocsLink,
|
||||
@@ -19,29 +19,105 @@ import {
|
||||
Logs,
|
||||
Main,
|
||||
SectionLabel,
|
||||
StatusIcon,
|
||||
} from "./Content";
|
||||
import { DismissWarningButton } from "./DismissWarningButton";
|
||||
import { healthyColor } from "./healthyColor";
|
||||
|
||||
const flags: BooleanKeys<NetcheckReport>[] = [
|
||||
"UDP",
|
||||
"IPv6",
|
||||
"IPv4",
|
||||
"IPv6CanSend",
|
||||
"IPv4CanSend",
|
||||
"OSHasIPv6",
|
||||
"ICMPv4",
|
||||
"MappingVariesByDestIP",
|
||||
"HairPinning",
|
||||
"UPnP",
|
||||
"PMP",
|
||||
"PCP",
|
||||
];
|
||||
|
||||
type BooleanKeys<T> = {
|
||||
[K in keyof T]: T[K] extends boolean | null ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
interface FlagInfo {
|
||||
label: string;
|
||||
description: string;
|
||||
invert?: boolean;
|
||||
}
|
||||
|
||||
const flagDescriptions: Record<BooleanKeys<NetcheckReport>, FlagInfo> = {
|
||||
UDP: {
|
||||
label: "UDP",
|
||||
description: "Whether a UDP STUN round trip completed successfully.",
|
||||
},
|
||||
IPv6: {
|
||||
label: "IPv6",
|
||||
description: "Whether an IPv6 STUN round trip completed successfully.",
|
||||
},
|
||||
IPv4: {
|
||||
label: "IPv4",
|
||||
description: "Whether an IPv4 STUN round trip completed successfully.",
|
||||
},
|
||||
IPv6CanSend: {
|
||||
label: "IPv6 Send",
|
||||
description: "Whether this server can send IPv6 packets.",
|
||||
},
|
||||
IPv4CanSend: {
|
||||
label: "IPv4 Send",
|
||||
description: "Whether this server can send IPv4 packets.",
|
||||
},
|
||||
OSHasIPv6: {
|
||||
label: "OS IPv6 Support",
|
||||
description: "Whether the operating system supports IPv6.",
|
||||
},
|
||||
ICMPv4: {
|
||||
label: "ICMP Ping",
|
||||
description: "Whether an ICMPv4 round trip completed successfully.",
|
||||
},
|
||||
MappingVariesByDestIP: {
|
||||
label: "No Symmetric NAT",
|
||||
description:
|
||||
"Whether STUN results are consistent across destinations. Symmetric NAT may degrade peer-to-peer connectivity.",
|
||||
invert: true,
|
||||
},
|
||||
HairPinning: {
|
||||
label: "NAT Hairpinning",
|
||||
description:
|
||||
"Whether the router supports communication between local devices through the public IP address.",
|
||||
},
|
||||
UPnP: {
|
||||
label: "UPnP",
|
||||
description: "Whether Universal Plug and Play was detected on the LAN.",
|
||||
},
|
||||
PMP: {
|
||||
label: "NAT-PMP",
|
||||
description: "Whether NAT Port Mapping Protocol was detected on the LAN.",
|
||||
},
|
||||
PCP: {
|
||||
label: "PCP",
|
||||
description: "Whether Port Control Protocol was detected on the LAN.",
|
||||
},
|
||||
CaptivePortal: {
|
||||
label: "No Captive Portal",
|
||||
description:
|
||||
"Whether HTTP traffic is free from captive portal interception.",
|
||||
invert: true,
|
||||
},
|
||||
};
|
||||
|
||||
interface FlagGroup {
|
||||
title: string;
|
||||
flags: BooleanKeys<NetcheckReport>[];
|
||||
}
|
||||
|
||||
const flagGroups: FlagGroup[] = [
|
||||
{
|
||||
title: "Connectivity",
|
||||
flags: ["UDP", "IPv4", "IPv6", "ICMPv4", "CaptivePortal"],
|
||||
},
|
||||
{
|
||||
title: "IPv6 Support",
|
||||
flags: ["OSHasIPv6", "IPv4CanSend", "IPv6CanSend"],
|
||||
},
|
||||
{
|
||||
title: "NAT Traversal",
|
||||
flags: ["MappingVariesByDestIP", "HairPinning"],
|
||||
},
|
||||
{
|
||||
title: "Port Mapping",
|
||||
flags: ["UPnP", "PMP", "PCP"],
|
||||
},
|
||||
];
|
||||
|
||||
const DERPPage: FC = () => {
|
||||
const { derp } = useOutletContext<HealthcheckReport>();
|
||||
const { netcheck, regions, netcheck_logs: logs } = derp;
|
||||
@@ -75,19 +151,44 @@ const DERPPage: FC = () => {
|
||||
})}
|
||||
|
||||
<section>
|
||||
<SectionLabel>Flags</SectionLabel>
|
||||
<div css={{ display: "flex", flexWrap: "wrap", gap: 12 }}>
|
||||
{flags.map((flag) => (
|
||||
<BooleanPill key={flag} value={safeNetcheck[flag]}>
|
||||
{flag}
|
||||
</BooleanPill>
|
||||
))}
|
||||
</div>
|
||||
<SectionLabel>Network Checks</SectionLabel>
|
||||
{flagGroups.map((group) => (
|
||||
<div key={group.title} className="mb-6">
|
||||
<h5 className="text-xs uppercase tracking-wide text-content-secondary m-0 mb-2">
|
||||
{group.title}
|
||||
</h5>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{group.flags.map((flag) => (
|
||||
<TableRow key={flag}>
|
||||
<TableCell className="w-8">
|
||||
<StatusIcon
|
||||
value={
|
||||
safeNetcheck[flag] === null
|
||||
? null
|
||||
: flagDescriptions[flag].invert
|
||||
? !safeNetcheck[flag]
|
||||
: safeNetcheck[flag]
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium whitespace-nowrap w-36">
|
||||
{flagDescriptions[flag].label}
|
||||
</TableCell>
|
||||
<TableCell className="text-content-secondary">
|
||||
{flagDescriptions[flag].description}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionLabel>Regions</SectionLabel>
|
||||
<div css={{ display: "flex", flexWrap: "wrap", gap: 12 }}>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{Object.values(regions ?? {})
|
||||
.filter((region) => {
|
||||
// Values can technically be null
|
||||
@@ -100,9 +201,12 @@ const DERPPage: FC = () => {
|
||||
return 0;
|
||||
})
|
||||
.map(({ severity, region }) => {
|
||||
if (!region) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button variant="outline" key={region!.RegionID} asChild>
|
||||
<Link to={`/health/derp/regions/${region!.RegionID}`}>
|
||||
<Button variant="outline" key={region.RegionID} asChild>
|
||||
<Link to={`/health/derp/regions/${region.RegionID}`}>
|
||||
<MapPinIcon
|
||||
style={{
|
||||
color: healthyColor(
|
||||
@@ -111,23 +215,18 @@ const DERPPage: FC = () => {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{region!.RegionName}
|
||||
{region.RegionName}
|
||||
</Link>
|
||||
</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,
|
||||
})}
|
||||
className="rounded-lg border border-solid border-border text-content-secondary"
|
||||
/>
|
||||
</section>
|
||||
</Main>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
|
||||
import type {
|
||||
DERPNodeReport,
|
||||
DERPRegionReport,
|
||||
@@ -6,6 +5,7 @@ import type {
|
||||
HealthSeverity,
|
||||
} from "api/typesGenerated";
|
||||
import { Alert } from "components/Alert/Alert";
|
||||
import { Table, TableBody, TableCell, TableRow } from "components/Table/Table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -26,10 +26,16 @@ import {
|
||||
Logs,
|
||||
Main,
|
||||
Pill,
|
||||
StatusIcon,
|
||||
} from "./Content";
|
||||
|
||||
interface NodeCheckRow {
|
||||
label: string;
|
||||
description: string;
|
||||
value: boolean | null;
|
||||
}
|
||||
|
||||
const DERPRegionPage: FC = () => {
|
||||
const theme = useTheme();
|
||||
const healthStatus = useOutletContext<HealthcheckReport>();
|
||||
const params = useParams() as { regionId: string };
|
||||
const regionId = Number(params.regionId);
|
||||
@@ -40,37 +46,26 @@ const DERPRegionPage: FC = () => {
|
||||
severity,
|
||||
} = healthStatus.derp.regions[regionId] as DERPRegionReport;
|
||||
|
||||
if (!region) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{pageTitle(region!.RegionName, "Health")}</title>
|
||||
<title>{pageTitle(region.RegionName, "Health")}</title>
|
||||
|
||||
<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: "1.2",
|
||||
}}
|
||||
className="text-xs no-underline text-content-secondary font-medium inline-flex items-center hover:text-content-primary mb-2 leading-tight"
|
||||
to="/health/derp"
|
||||
>
|
||||
<ChevronLeftIcon
|
||||
className="size-icon-xs"
|
||||
css={{ verticalAlign: "middle", marginRight: 8 }}
|
||||
/>
|
||||
<ChevronLeftIcon className="size-icon-xs align-middle mr-2" />
|
||||
Back to DERP
|
||||
</Link>
|
||||
<HeaderTitle>
|
||||
<HealthyDot severity={severity as HealthSeverity} />
|
||||
{region!.RegionName}
|
||||
{region.RegionName}
|
||||
</HeaderTitle>
|
||||
</hgroup>
|
||||
</Header>
|
||||
@@ -90,11 +85,11 @@ const DERPRegionPage: FC = () => {
|
||||
})}
|
||||
|
||||
<section>
|
||||
<div css={{ display: "flex", flexWrap: "wrap", gap: 12 }}>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Pill icon={<HashIcon className="size-icon-sm" />}>
|
||||
{region!.RegionID}
|
||||
{region.RegionID}
|
||||
</Pill>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Region ID</TooltipContent>
|
||||
@@ -102,70 +97,119 @@ const DERPRegionPage: FC = () => {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Pill icon={<CodeIcon className="size-icon-sm" />}>
|
||||
{region!.RegionCode}
|
||||
{region.RegionCode}
|
||||
</Pill>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Region Code</TooltipContent>
|
||||
</Tooltip>
|
||||
<BooleanPill value={region!.EmbeddedRelay}>
|
||||
Embedded Relay
|
||||
</BooleanPill>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BooleanPill value={region.EmbeddedRelay}>
|
||||
Embedded Relay
|
||||
</BooleanPill>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Whether this region uses a relay server embedded in the Coder
|
||||
deployment.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{reports.map((report) => {
|
||||
report = report as DERPNodeReport; // Can technically be null
|
||||
{reports.map((rawReport) => {
|
||||
if (!rawReport) {
|
||||
return null;
|
||||
}
|
||||
const report = rawReport as DERPNodeReport;
|
||||
const { node, client_logs: logs } = report;
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const latencyColor = getLatencyColor(report.round_trip_ping_ms);
|
||||
const latencyBackground = getLatencyColor(
|
||||
report.round_trip_ping_ms,
|
||||
"background",
|
||||
);
|
||||
const checks: NodeCheckRow[] = [
|
||||
{
|
||||
label: "Exchange Messages",
|
||||
description:
|
||||
"Whether DERP clients can relay messages through this node.",
|
||||
value: report.can_exchange_messages,
|
||||
},
|
||||
{
|
||||
label: "Direct HTTP Upgrade",
|
||||
description:
|
||||
"Whether the connection used a direct HTTP upgrade instead of falling back to WebSocket. Fallback may indicate the DERP upgrade header is being blocked.",
|
||||
value: !report.uses_websocket,
|
||||
},
|
||||
{
|
||||
label: "STUN Enabled",
|
||||
description: "Whether STUN is enabled on this node.",
|
||||
value: report.stun.Enabled,
|
||||
},
|
||||
{
|
||||
label: "STUN Reachable",
|
||||
description:
|
||||
"Whether this node responded to a STUN request successfully.",
|
||||
value: report.stun.CanSTUN,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<section
|
||||
key={node!.HostName}
|
||||
css={{
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
}}
|
||||
key={node.HostName}
|
||||
className="border border-solid border-border rounded-lg overflow-hidden text-sm"
|
||||
>
|
||||
<header css={reportStyles.header}>
|
||||
<header className="p-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h4 css={reportStyles.title}>{node!.HostName}</h4>
|
||||
<div css={reportStyles.ports}>
|
||||
<span>DERP Port: {node!.DERPPort ?? "None"}</span>
|
||||
<span>STUN Port: {node!.STUNPort ?? "None"}</span>
|
||||
<h4 className="font-medium m-0 leading-none">
|
||||
{node.HostName}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 text-content-secondary text-xs leading-tight mt-2">
|
||||
<span>DERP Port: {node.DERPPort ?? "None"}</span>
|
||||
<span>STUN Port: {node.STUNPort ?? "None"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div css={reportStyles.pills}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Pill
|
||||
className={latencyColor}
|
||||
icon={<StatusCircle background={latencyBackground} />}
|
||||
>
|
||||
{report.round_trip_ping_ms}ms
|
||||
</Pill>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Round trip ping
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<BooleanPill value={report.can_exchange_messages}>
|
||||
Exchange Messages
|
||||
</BooleanPill>
|
||||
<BooleanPill value={report.uses_websocket}>
|
||||
Websocket
|
||||
</BooleanPill>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Pill
|
||||
className={latencyColor}
|
||||
icon={<StatusCircle background={latencyBackground} />}
|
||||
>
|
||||
{report.round_trip_ping_ms}ms
|
||||
</Pill>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Round trip ping</TooltipContent>
|
||||
</Tooltip>
|
||||
</header>
|
||||
<Logs lines={logs?.flat() ?? []} css={reportStyles.logs} />
|
||||
|
||||
<Table>
|
||||
<TableBody className="[&>tr>td:first-of-type]:border-l-0 [&>tr>td:last-child]:border-r-0 [&>tr:last-child>td]:border-b-0 [&>tr>td]:!rounded-none">
|
||||
{checks.map((check) => (
|
||||
<TableRow key={check.label}>
|
||||
<TableCell className="w-8">
|
||||
<StatusIcon value={check.value} />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium whitespace-nowrap w-40">
|
||||
{check.label}
|
||||
</TableCell>
|
||||
<TableCell className="text-content-secondary">
|
||||
{check.description}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Logs
|
||||
lines={logs?.flat() ?? []}
|
||||
className="border-0 border-t border-solid border-border"
|
||||
/>
|
||||
{report.client_errs.length > 0 && (
|
||||
<Logs
|
||||
lines={report.client_errs.flat()}
|
||||
css={[reportStyles.logs, reportStyles.clientErrors]}
|
||||
className="border-0 border-t border-solid border-border bg-surface-destructive text-content-destructive"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
@@ -186,45 +230,4 @@ const StatusCircle: FC<StatusCircleProps> = ({ background }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const reportStyles = {
|
||||
header: {
|
||||
padding: 24,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
title: {
|
||||
fontWeight: 500,
|
||||
margin: 0,
|
||||
lineHeight: "1",
|
||||
},
|
||||
pills: {
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
},
|
||||
ports: (theme) => ({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: 12,
|
||||
lineHeight: "1.2",
|
||||
marginTop: 8,
|
||||
}),
|
||||
divider: (theme) => ({
|
||||
height: 1,
|
||||
backgroundColor: theme.palette.divider,
|
||||
}),
|
||||
logs: (theme) => ({
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
}),
|
||||
clientErrors: (theme) => ({
|
||||
background: theme.roles.error.background,
|
||||
color: theme.roles.error.text,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
export default DERPRegionPage;
|
||||
|
||||
Reference in New Issue
Block a user