Skip to content

Commit 950672e

Browse files
authored
feat: add network check command with results webview (#1002)
Adds a "Coder: Network Check" command that runs `coder netcheck` and renders the report in a dedicated webview panel, reachable from the command palette and the My Workspaces view menu. - Panel shows an overall health banner, warnings, a connectivity summary (UDP, IPv4/IPv6, NAT mapping, hairpinning, port mapping), per-region DERP/STUN latencies, and local interfaces. - A View JSON action exposes the raw report; parse failures keep it reachable. - Slow-connection status bar tooltip links to the network check alongside the latency test; the click stays on the per-workspace ping. - Speed test reuses the extracted one-shot panel plumbing (showResultPanel, runDiagnosticCli) instead of duplicating it. - Telemetry records netcheck severity and bounded counts after the panel shows. Closes #348
1 parent c62d3c1 commit 950672e

62 files changed

Lines changed: 3205 additions & 299 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@
77

88
## Unreleased
99

10+
### Added
11+
12+
- New **Coder: Network Check** command to run
13+
[`coder netcheck`](https://coder.com/docs/reference/cli/netcheck) from the
14+
command palette or the My Workspaces view menu. The report opens in a panel
15+
with an overall health banner, any warnings, a connectivity summary (UDP,
16+
IPv4/IPv6, NAT mapping, hairpinning, port mapping), per-region DERP/STUN
17+
results with latencies, and local interfaces. A View JSON action exposes the
18+
raw output. When a slow connection is detected, the network status bar
19+
tooltip also links to the network check alongside the latency test, for a
20+
deployment-wide view of the network path.
21+
1022
### Fixed
1123

1224
- Propagate VS Code's proxy settings (`http.proxy`, `http.noProxy`, and

package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,11 @@
439439
"title": "Speed Test Workspace",
440440
"category": "Coder"
441441
},
442+
{
443+
"command": "coder.netcheck",
444+
"title": "Network Check",
445+
"category": "Coder"
446+
},
442447
{
443448
"command": "coder.supportBundle",
444449
"title": "Create Support Bundle",
@@ -534,6 +539,10 @@
534539
"command": "coder.speedTest",
535540
"when": "coder.authenticated"
536541
},
542+
{
543+
"command": "coder.netcheck",
544+
"when": "coder.authenticated"
545+
},
537546
{
538547
"command": "coder.supportBundle",
539548
"when": "coder.authenticated"
@@ -622,6 +631,10 @@
622631
"command": "coder.logout",
623632
"when": "coder.authenticated && view == myWorkspaces"
624633
},
634+
{
635+
"command": "coder.netcheck",
636+
"when": "coder.authenticated && view == myWorkspaces"
637+
},
625638
{
626639
"command": "coder.switchDeployment",
627640
"when": "coder.authenticated && view == myWorkspaces"

packages/netcheck/package.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "@repo/netcheck",
3+
"version": "1.0.0",
4+
"description": "Coder network check report webview",
5+
"private": true,
6+
"type": "module",
7+
"scripts": {
8+
"build": "vite build",
9+
"dev": "vite build --watch",
10+
"typecheck": "tsc --noEmit"
11+
},
12+
"dependencies": {
13+
"@repo/shared": "workspace:*",
14+
"@repo/webview-shared": "workspace:*"
15+
},
16+
"devDependencies": {
17+
"@types/vscode-webview": "catalog:",
18+
"typescript": "catalog:",
19+
"vite": "catalog:"
20+
}
21+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { regionName } from "./regions";
2+
3+
import type { NetcheckConnectivity, NetcheckReport } from "@repo/shared";
4+
5+
/** Maps to a `tone-*` CSS class so color lives in the stylesheet, not here. */
6+
export type Tone = "good" | "bad" | "warn" | "neutral";
7+
8+
export interface ConnectivityItem {
9+
label: string;
10+
value: string;
11+
tone: Tone;
12+
}
13+
14+
type Outcome = [value: string, tone: Tone];
15+
16+
/** Connectivity facts derived from the embedded tailscale netcheck probe. */
17+
export function buildConnectivityItems(
18+
report: NetcheckReport,
19+
): ConnectivityItem[] {
20+
const probe = report.derp.netcheck;
21+
if (!probe) {
22+
return [];
23+
}
24+
25+
// Tones: bad = real problem, warn = works but suboptimal, neutral = optional.
26+
const items: ConnectivityItem[] = [
27+
boolItem("UDP", probe.UDP, {
28+
true: ["Reachable", "good"],
29+
false: ["Blocked", "bad"],
30+
}),
31+
boolItem("IPv4", probe.IPv4, {
32+
true: ["Yes", "good"],
33+
false: ["No", "warn"],
34+
}),
35+
boolItem("IPv6", probe.IPv6, {
36+
true: ["Yes", "good"],
37+
false: ["No", "neutral"],
38+
}),
39+
boolItem("NAT mapping", probe.MappingVariesByDestIP, {
40+
true: ["Varies by destination (hard NAT)", "warn"],
41+
false: ["Consistent (easy NAT)", "good"],
42+
}),
43+
boolItem("Hairpinning", probe.HairPinning, {
44+
true: ["Supported", "good"],
45+
false: ["Not supported", "neutral"],
46+
}),
47+
portMappingItem(probe),
48+
];
49+
50+
const preferred = preferredRegionName(report);
51+
if (preferred) {
52+
items.push({ label: "Preferred relay", value: preferred, tone: "good" });
53+
}
54+
return items;
55+
}
56+
57+
/** Renders a boolean probe field; a missing value is a neutral "Unknown". */
58+
function boolItem(
59+
label: string,
60+
state: boolean | null | undefined,
61+
cases: { true: Outcome; false: Outcome },
62+
): ConnectivityItem {
63+
if (typeof state !== "boolean") {
64+
return { label, value: "Unknown", tone: "neutral" };
65+
}
66+
const [value, tone] = state ? cases.true : cases.false;
67+
return { label, value, tone };
68+
}
69+
70+
function portMappingItem(probe: NetcheckConnectivity): ConnectivityItem {
71+
const fields = [
72+
[probe.UPnP, "UPnP"],
73+
[probe.PMP, "NAT-PMP"],
74+
[probe.PCP, "PCP"],
75+
] as const;
76+
const detected = fields.filter(([on]) => on).map(([, name]) => name);
77+
if (detected.length > 0) {
78+
return { label: "Port mapping", value: detected.join(", "), tone: "good" };
79+
}
80+
// A null field means "could not determine", so report "None detected" only
81+
// once a protocol was actually probed.
82+
const probed = fields.some(([on]) => typeof on === "boolean");
83+
return {
84+
label: "Port mapping",
85+
value: probed ? "None detected" : "Unknown",
86+
tone: "neutral",
87+
};
88+
}
89+
90+
function preferredRegionName(report: NetcheckReport): string | undefined {
91+
const id = report.derp.netcheck?.PreferredDERP;
92+
if (!id) {
93+
return undefined;
94+
}
95+
return regionName(report.derp.regions[String(id)], id);
96+
}

packages/netcheck/src/css.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
declare module "*.css";

packages/netcheck/src/format.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
export type TriState = "yes" | "no" | "unknown";
2+
3+
const NANOS_PER_MS = 1_000_000;
4+
5+
/** Below this, show one decimal; at or above, round to whole ms. */
6+
const DECIMAL_PRECISION_BELOW_MS = 100;
7+
8+
export function nanosToMs(nanos: number): number {
9+
return nanos / NANOS_PER_MS;
10+
}
11+
12+
export function formatLatency(ms: number | undefined): string {
13+
if (ms === undefined) {
14+
return "—";
15+
}
16+
if (ms < 1) {
17+
return "<1 ms";
18+
}
19+
if (ms < DECIMAL_PRECISION_BELOW_MS) {
20+
return `${ms.toFixed(1)} ms`;
21+
}
22+
return `${Math.round(ms)} ms`;
23+
}
24+
25+
/** Renders a STUN/relay capability result for a table cell. */
26+
export function formatTriState(value: TriState): string {
27+
switch (value) {
28+
case "yes":
29+
return "Yes";
30+
case "no":
31+
return "Failed";
32+
case "unknown":
33+
return "—";
34+
}
35+
}

packages/netcheck/src/health.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { regionName } from "./regions";
2+
3+
import type {
4+
NetcheckReport,
5+
NetcheckSectionHealth,
6+
NetcheckSeverity,
7+
} from "@repo/shared";
8+
9+
export interface Issue {
10+
kind: "error" | "warning";
11+
code?: string;
12+
message: string;
13+
}
14+
15+
const SEVERITY_LABEL = {
16+
ok: "Healthy",
17+
warning: "Warning",
18+
error: "Error",
19+
} as const satisfies Record<NetcheckSeverity, string>;
20+
21+
const BANNER_TITLE = {
22+
ok: "Network is healthy",
23+
warning: "Network has warnings",
24+
error: "Network problems detected",
25+
} as const satisfies Record<NetcheckSeverity, string>;
26+
27+
const SECTION_STATUS = {
28+
ok: "healthy",
29+
warning: "warning",
30+
error: "error",
31+
} as const satisfies Record<NetcheckSeverity, string>;
32+
33+
export function severityLabel(severity: NetcheckSeverity): string {
34+
return SEVERITY_LABEL[severity];
35+
}
36+
37+
export function bannerTitle(severity: NetcheckSeverity): string {
38+
return BANNER_TITLE[severity];
39+
}
40+
41+
/** One-line status for a report section, e.g. "2 warnings" or "healthy". */
42+
export function sectionSummary(section: NetcheckSectionHealth): string {
43+
const count = section.warnings.length;
44+
if (section.severity === "warning" && count > 0) {
45+
return `${count} warning${count === 1 ? "" : "s"}`;
46+
}
47+
return SECTION_STATUS[section.severity];
48+
}
49+
50+
/** Section errors first, then warnings, so the most severe issues lead. */
51+
export function collectIssues(report: NetcheckReport): Issue[] {
52+
const errors: Issue[] = [];
53+
const warnings: Issue[] = [];
54+
const addSection = (section: NetcheckSectionHealth) => {
55+
if (section.error) {
56+
errors.push({ kind: "error", message: section.error });
57+
}
58+
for (const warning of section.warnings) {
59+
warnings.push({
60+
kind: "warning",
61+
message: warning.message,
62+
...(warning.code ? { code: warning.code } : {}),
63+
});
64+
}
65+
};
66+
addSection(report.derp);
67+
if (report.derp.netcheck_err) {
68+
errors.push({ kind: "error", message: report.derp.netcheck_err });
69+
}
70+
for (const [key, region] of Object.entries(report.derp.regions)) {
71+
const name = regionName(region, Number(key));
72+
// Region warnings are already in the section list; list only errors.
73+
if (region.error) {
74+
errors.push({ kind: "error", message: `${name}: ${region.error}` });
75+
} else if (region.severity === "error") {
76+
errors.push({
77+
kind: "error",
78+
message: `${name}: a node failed its health check`,
79+
});
80+
}
81+
}
82+
addSection(report.interfaces);
83+
return [...errors, ...warnings];
84+
}

0 commit comments

Comments
 (0)