diff --git a/CHANGELOG.md b/CHANGELOG.md index 83db034842..bda8e92f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ from published versions since it shows up in the VS Code extension changelog tab and is confusing to users. Add it back between releases if needed. --> +## Unreleased + +### Added + +- New **Coder: Network Check** command to run + [`coder netcheck`](https://coder.com/docs/reference/cli/netcheck) from the + command palette or the My Workspaces view menu. The report opens in a panel + with an overall health banner, any warnings, a connectivity summary (UDP, + IPv4/IPv6, NAT mapping, hairpinning, port mapping), per-region DERP/STUN + results with latencies, and local interfaces. A View JSON action exposes the + raw output. + ## [v1.15.0](https://github.com/coder/vscode-coder/releases/tag/v1.15.0) 2026-06-12 ### Added diff --git a/package.json b/package.json index b4a8129e6c..a517ab9e79 100644 --- a/package.json +++ b/package.json @@ -439,6 +439,11 @@ "title": "Speed Test Workspace", "category": "Coder" }, + { + "command": "coder.netcheck", + "title": "Network Check", + "category": "Coder" + }, { "command": "coder.supportBundle", "title": "Create Support Bundle", @@ -534,6 +539,10 @@ "command": "coder.speedTest", "when": "coder.authenticated" }, + { + "command": "coder.netcheck", + "when": "coder.authenticated" + }, { "command": "coder.supportBundle", "when": "coder.authenticated" @@ -622,6 +631,10 @@ "command": "coder.logout", "when": "coder.authenticated && view == myWorkspaces" }, + { + "command": "coder.netcheck", + "when": "coder.authenticated && view == myWorkspaces" + }, { "command": "coder.switchDeployment", "when": "coder.authenticated && view == myWorkspaces" diff --git a/packages/netcheck/package.json b/packages/netcheck/package.json new file mode 100644 index 0000000000..563293ea9b --- /dev/null +++ b/packages/netcheck/package.json @@ -0,0 +1,21 @@ +{ + "name": "@repo/netcheck", + "version": "1.0.0", + "description": "Coder network check report webview", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@repo/shared": "workspace:*", + "@repo/webview-shared": "workspace:*" + }, + "devDependencies": { + "@types/vscode-webview": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/packages/netcheck/src/connectivity.ts b/packages/netcheck/src/connectivity.ts new file mode 100644 index 0000000000..d849599893 --- /dev/null +++ b/packages/netcheck/src/connectivity.ts @@ -0,0 +1,96 @@ +import { regionName } from "./regions"; + +import type { NetcheckConnectivity, NetcheckReport } from "@repo/shared"; + +/** Maps to a `tone-*` CSS class so color lives in the stylesheet, not here. */ +export type Tone = "good" | "bad" | "warn" | "neutral"; + +export interface ConnectivityItem { + label: string; + value: string; + tone: Tone; +} + +type Outcome = [value: string, tone: Tone]; + +/** Connectivity facts derived from the embedded tailscale netcheck probe. */ +export function buildConnectivityItems( + report: NetcheckReport, +): ConnectivityItem[] { + const probe = report.derp.netcheck; + if (!probe) { + return []; + } + + // Tones: bad = real problem, warn = works but suboptimal, neutral = optional. + const items: ConnectivityItem[] = [ + boolItem("UDP", probe.UDP, { + true: ["Reachable", "good"], + false: ["Blocked", "bad"], + }), + boolItem("IPv4", probe.IPv4, { + true: ["Yes", "good"], + false: ["No", "warn"], + }), + boolItem("IPv6", probe.IPv6, { + true: ["Yes", "good"], + false: ["No", "neutral"], + }), + boolItem("NAT mapping", probe.MappingVariesByDestIP, { + true: ["Varies by destination (hard NAT)", "warn"], + false: ["Consistent (easy NAT)", "good"], + }), + boolItem("Hairpinning", probe.HairPinning, { + true: ["Supported", "good"], + false: ["Not supported", "neutral"], + }), + portMappingItem(probe), + ]; + + const preferred = preferredRegionName(report); + if (preferred) { + items.push({ label: "Preferred relay", value: preferred, tone: "good" }); + } + return items; +} + +/** Renders a boolean probe field; a missing value is a neutral "Unknown". */ +function boolItem( + label: string, + state: boolean | null | undefined, + cases: { true: Outcome; false: Outcome }, +): ConnectivityItem { + if (typeof state !== "boolean") { + return { label, value: "Unknown", tone: "neutral" }; + } + const [value, tone] = state ? cases.true : cases.false; + return { label, value, tone }; +} + +function portMappingItem(probe: NetcheckConnectivity): ConnectivityItem { + const fields = [ + [probe.UPnP, "UPnP"], + [probe.PMP, "NAT-PMP"], + [probe.PCP, "PCP"], + ] as const; + const detected = fields.filter(([on]) => on).map(([, name]) => name); + if (detected.length > 0) { + return { label: "Port mapping", value: detected.join(", "), tone: "good" }; + } + // A null field means "could not determine", so report "None detected" only + // once a protocol was actually probed. + const probed = fields.some(([on]) => typeof on === "boolean"); + return { + label: "Port mapping", + value: probed ? "None detected" : "Unknown", + tone: "neutral", + }; +} + +function preferredRegionName(report: NetcheckReport): string | undefined { + const id = report.derp.netcheck?.PreferredDERP; + if (!id) { + return undefined; + } + return regionName(report.derp.regions[String(id)], id); +} diff --git a/packages/netcheck/src/css.d.ts b/packages/netcheck/src/css.d.ts new file mode 100644 index 0000000000..cbe652dbe0 --- /dev/null +++ b/packages/netcheck/src/css.d.ts @@ -0,0 +1 @@ +declare module "*.css"; diff --git a/packages/netcheck/src/format.ts b/packages/netcheck/src/format.ts new file mode 100644 index 0000000000..a9ff69502e --- /dev/null +++ b/packages/netcheck/src/format.ts @@ -0,0 +1,35 @@ +export type TriState = "yes" | "no" | "unknown"; + +const NANOS_PER_MS = 1_000_000; + +/** Below this, show one decimal; at or above, round to whole ms. */ +const DECIMAL_PRECISION_BELOW_MS = 100; + +export function nanosToMs(nanos: number): number { + return nanos / NANOS_PER_MS; +} + +export function formatLatency(ms: number | undefined): string { + if (ms === undefined) { + return "—"; + } + if (ms < 1) { + return "<1 ms"; + } + if (ms < DECIMAL_PRECISION_BELOW_MS) { + return `${ms.toFixed(1)} ms`; + } + return `${Math.round(ms)} ms`; +} + +/** Renders a STUN/relay capability result for a table cell. */ +export function formatTriState(value: TriState): string { + switch (value) { + case "yes": + return "Yes"; + case "no": + return "Failed"; + case "unknown": + return "—"; + } +} diff --git a/packages/netcheck/src/health.ts b/packages/netcheck/src/health.ts new file mode 100644 index 0000000000..b874bcffd7 --- /dev/null +++ b/packages/netcheck/src/health.ts @@ -0,0 +1,84 @@ +import { regionName } from "./regions"; + +import type { + NetcheckReport, + NetcheckSectionHealth, + NetcheckSeverity, +} from "@repo/shared"; + +export interface Issue { + kind: "error" | "warning"; + code?: string; + message: string; +} + +const SEVERITY_LABEL = { + ok: "Healthy", + warning: "Warning", + error: "Error", +} as const satisfies Record; + +const BANNER_TITLE = { + ok: "Network is healthy", + warning: "Network has warnings", + error: "Network problems detected", +} as const satisfies Record; + +const SECTION_STATUS = { + ok: "healthy", + warning: "warning", + error: "error", +} as const satisfies Record; + +export function severityLabel(severity: NetcheckSeverity): string { + return SEVERITY_LABEL[severity]; +} + +export function bannerTitle(severity: NetcheckSeverity): string { + return BANNER_TITLE[severity]; +} + +/** One-line status for a report section, e.g. "2 warnings" or "healthy". */ +export function sectionSummary(section: NetcheckSectionHealth): string { + const count = section.warnings.length; + if (section.severity === "warning" && count > 0) { + return `${count} warning${count === 1 ? "" : "s"}`; + } + return SECTION_STATUS[section.severity]; +} + +/** Section errors first, then warnings, so the most severe issues lead. */ +export function collectIssues(report: NetcheckReport): Issue[] { + const errors: Issue[] = []; + const warnings: Issue[] = []; + const addSection = (section: NetcheckSectionHealth) => { + if (section.error) { + errors.push({ kind: "error", message: section.error }); + } + for (const warning of section.warnings) { + warnings.push({ + kind: "warning", + message: warning.message, + ...(warning.code ? { code: warning.code } : {}), + }); + } + }; + addSection(report.derp); + if (report.derp.netcheck_err) { + errors.push({ kind: "error", message: report.derp.netcheck_err }); + } + for (const [key, region] of Object.entries(report.derp.regions)) { + const name = regionName(region, Number(key)); + // Region warnings are already in the section list; list only errors. + if (region.error) { + errors.push({ kind: "error", message: `${name}: ${region.error}` }); + } else if (region.severity === "error") { + errors.push({ + kind: "error", + message: `${name}: a node failed its health check`, + }); + } + } + addSection(report.interfaces); + return [...errors, ...warnings]; +} diff --git a/packages/netcheck/src/index.css b/packages/netcheck/src/index.css new file mode 100644 index 0000000000..b9137c3227 --- /dev/null +++ b/packages/netcheck/src/index.css @@ -0,0 +1,304 @@ +/* Base reset, body, and button styles come from @repo/webview-shared/base.css. */ + +/* Local aliases composed from VS Code theme tokens so every theme (light, + * dark, high contrast) renders consistently. */ +:root { + --border: var( + --vscode-widget-border, + var(--vscode-panel-border, rgba(128, 128, 128, 0.35)) + ); + --muted: var(--vscode-descriptionForeground); + --card-bg: var(--vscode-editorWidget-background, transparent); +} + +#root { + max-width: 56em; + margin: 0 auto; +} + +.page-header { + margin: 0 0 1.5em; +} + +.eyebrow { + margin: 0 0 0.4em; + font-size: 0.8em; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.deployment-host { + margin: 0; + font-size: 1.8em; + font-weight: 600; + word-break: break-all; +} + +/* Severity accents shared by the banner, issue list, and table cells. */ +.severity-ok { + --severity-color: var(--vscode-testing-iconPassed, #2ea043); +} + +.severity-warning { + --severity-color: var(--vscode-editorWarning-foreground, #d29922); +} + +.severity-error { + --severity-color: var(--vscode-errorForeground, #f85149); +} + +.status-banner { + display: flex; + align-items: center; + gap: 0.85em; + padding: 0.9em 1.1em; + border-radius: 6px; + background: var(--card-bg); + /* Severity-tinted background; the plain card-bg above is the fallback for + * engines without color-mix support. */ + background: color-mix(in srgb, var(--severity-color) 8%, var(--card-bg)); + border: 1px solid var(--border); + border-left: 3px solid var(--severity-color); +} + +.status-dot { + flex-shrink: 0; + width: 0.75em; + height: 0.75em; + border-radius: 50%; + background: var(--severity-color); +} + +.status-title { + margin: 0; + font-size: 1.1em; + font-weight: 600; +} + +.status-detail { + margin: 0.2em 0 0; + color: var(--muted); +} + +.report-section { + margin-top: 1.25em; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--card-bg); + overflow: hidden; +} + +.report-section h2 { + margin: 0; + padding: 0.7em 1em; + font-size: 0.8em; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; + border-bottom: 1px solid var(--border); +} + +.section-body { + padding: 1em; + overflow-x: auto; +} + +/* Tables run edge-to-edge inside the card; everything else keeps its padding. */ +.section-body:has(> .report-table) { + padding: 0; +} + +.issue-list { + display: flex; + flex-direction: column; + gap: 0.5em; + margin: 0; + padding: 0; + list-style: none; +} + +.issue { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.25em 0.6em; + padding: 0.55em 0.8em; + border-radius: 4px; + background: var(--vscode-textBlockQuote-background, rgba(128, 128, 128, 0.1)); + border: 1px solid var(--border); + border-left: 3px solid var(--severity-color); +} + +.issue-kind { + flex-shrink: 0; + font-size: 0.8em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--severity-color); +} + +.issue-message { + flex: 1; + min-width: 12em; +} + +.issue-code { + font-family: var(--vscode-editor-font-family); + font-size: 0.85em; + color: var(--muted); +} + +.conn-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(11em, 1fr)); + gap: 0.75em; +} + +.conn-item { + padding: 0.6em 0.8em; + border-radius: 4px; + background: var(--vscode-editor-background); + border: 1px solid var(--border); +} + +.conn-label { + display: block; + font-size: 0.8em; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.25em; +} + +.conn-value { + font-weight: 600; +} + +.tone-good { + color: var(--vscode-testing-iconPassed, #2ea043); +} + +.tone-bad { + color: var(--vscode-errorForeground, #f85149); +} + +.tone-warn { + color: var(--vscode-editorWarning-foreground, #d29922); +} + +.tone-neutral { + color: var(--muted); +} + +.report-table { + width: 100%; + border-collapse: collapse; +} + +.report-table th, +.report-table td { + padding: 0.5em 0.75em; + text-align: left; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +.report-table th:first-child, +.report-table td:first-child { + padding-left: 1em; +} + +.report-table th:last-child, +.report-table td:last-child { + padding-right: 1em; +} + +.report-table th { + font-size: 0.8em; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; + background: var( + --vscode-keybindingTable-headerBackground, + rgba(128, 128, 128, 0.08) + ); +} + +.report-table tbody tr:last-child td { + border-bottom: none; +} + +.report-table tbody tr:hover { + background: var(--vscode-list-hoverBackground); +} + +.region-name { + font-weight: 600; + white-space: normal; +} + +.severity-text { + display: inline-flex; + align-items: center; + gap: 0.45em; +} + +.severity-text .status-dot { + width: 0.55em; + height: 0.55em; +} + +.badge { + margin-left: 0.6em; + padding: 0.1em 0.55em; + border-radius: 1em; + font-size: 0.75em; + font-weight: 500; + vertical-align: middle; + white-space: nowrap; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); +} + +.addresses { + font-family: var(--vscode-editor-font-family); + font-size: 0.9em; + white-space: normal; + word-break: break-word; +} + +.actions { + margin-top: 1.5em; +} + +.error, +.empty { + margin: 0.5em 0; +} + +.empty { + color: var(--muted); +} + +@media (max-width: 30em) { + body { + padding: 0.75em; + } + + .page-header { + margin-bottom: 1em; + } + + .deployment-host { + font-size: 1.4em; + } + + .section-body { + padding: 0.75em; + } +} diff --git a/packages/netcheck/src/index.ts b/packages/netcheck/src/index.ts new file mode 100644 index 0000000000..35eb33c8b7 --- /dev/null +++ b/packages/netcheck/src/index.ts @@ -0,0 +1,38 @@ +import { NetcheckApi, toError, type NetcheckData } from "@repo/shared"; +import { + errorMessage, + sendCommand, + subscribeNotifications, +} from "@repo/webview-shared"; +import "@repo/webview-shared/base.css"; + +import "./index.css"; +import { renderPage } from "./page"; + +function main(): void { + // The extension re-sends `data` on visibility/theme changes; each render + // replaces the root, clearing any prior error. + subscribeNotifications(NetcheckApi, { + data: (data) => render(data), + }); + // Signal we're subscribed; the extension waits for this before sending. + sendCommand(NetcheckApi.ready); +} + +function render(data: NetcheckData): void { + const root = document.getElementById("root"); + if (!root) { + return; + } + try { + root.replaceChildren( + ...renderPage(data, () => sendCommand(NetcheckApi.viewJson)), + ); + } catch (err) { + root.replaceChildren( + errorMessage(`Failed to render network check: ${toError(err).message}`), + ); + } +} + +main(); diff --git a/packages/netcheck/src/page.ts b/packages/netcheck/src/page.ts new file mode 100644 index 0000000000..05bf2b4278 --- /dev/null +++ b/packages/netcheck/src/page.ts @@ -0,0 +1,213 @@ +import { + overallNetcheckSeverity, + type NetcheckData, + type NetcheckInterface, + type NetcheckReport, + type NetcheckSeverity, +} from "@repo/shared"; +import { emptyMessage, pageHeader, viewJsonAction } from "@repo/webview-shared"; + +import { buildConnectivityItems } from "./connectivity"; +import { formatLatency, formatTriState } from "./format"; +import { + bannerTitle, + collectIssues, + sectionSummary, + severityLabel, + type Issue, +} from "./health"; +import { buildRegionRows, type RegionRow } from "./regions"; + +export function renderPage( + { host, report }: NetcheckData, + onViewJson: () => void, +): HTMLElement[] { + const children = [ + pageHeader("Network Check", host, "deployment-host"), + renderBanner(report), + ]; + + const issues = collectIssues(report); + if (issues.length > 0) { + children.push(renderSection("Issues", renderIssues(issues))); + } + children.push( + renderSection("Connectivity", renderConnectivity(report)), + renderSection("DERP relay regions", renderRegions(report)), + renderSection( + "Local interfaces", + renderInterfaces(report.interfaces.interfaces), + ), + viewJsonAction(onViewJson), + ); + return children; +} + +function renderBanner(report: NetcheckReport): HTMLElement { + const severity = overallNetcheckSeverity(report); + const banner = el("div", `status-banner severity-${severity}`); + const text = el("div"); + text.append( + el("p", "status-title", bannerTitle(severity)), + el( + "p", + "status-detail", + [ + `DERP & STUN: ${sectionSummary(report.derp)}`, + `Local interfaces: ${sectionSummary(report.interfaces)}`, + ].join(" · "), + ), + ); + banner.append(el("span", "status-dot"), text); + return banner; +} + +function renderIssues(issues: Issue[]): HTMLElement { + const list = el("ul", "issue-list"); + for (const issue of issues) { + const item = el("li", `issue severity-${issue.kind}`); + item.append( + el("span", "issue-kind", issue.kind === "error" ? "Error" : "Warning"), + el("span", "issue-message", issue.message), + ); + if (issue.code) { + item.append(el("span", "issue-code", issue.code)); + } + list.append(item); + } + return list; +} + +function renderConnectivity(report: NetcheckReport): HTMLElement { + const items = buildConnectivityItems(report); + if (items.length === 0) { + return emptyMessage( + "The connectivity probe returned no data. Use View JSON for details.", + ); + } + const grid = el("div", "conn-grid"); + for (const item of items) { + const cell = el("div", "conn-item"); + cell.append( + el("span", "conn-label", item.label), + el("span", `conn-value tone-${item.tone}`, item.value), + ); + grid.append(cell); + } + return grid; +} + +function renderRegions(report: NetcheckReport): HTMLElement { + const rows = buildRegionRows(report); + if (rows.length === 0) { + return emptyMessage("No DERP regions in the deployment's relay map."); + } + return renderTable( + ["Region", "Status", "Latency", "STUN", "Relay"], + rows, + renderRegionRow, + ); +} + +function renderRegionRow(row: RegionRow): HTMLTableRowElement { + const name = el("td", "region-name", row.name); + if (row.preferred) { + name.append(badge("Preferred")); + } + if (row.embeddedRelay) { + name.append(badge("Embedded")); + } + + const status = renderSeverityCell(row.severity); + if (row.error) { + status.title = row.error; + } + + const tr = el("tr"); + tr.append( + name, + status, + el("td", undefined, formatLatency(row.latencyMs)), + el("td", undefined, formatTriState(row.stun)), + el("td", undefined, formatTriState(row.relay)), + ); + return tr; +} + +function renderInterfaces(interfaces: NetcheckInterface[]): HTMLElement { + if (interfaces.length === 0) { + return emptyMessage("No active network interfaces found."); + } + return renderTable(["Name", "MTU", "Addresses"], interfaces, (iface) => { + const tr = el("tr"); + tr.append( + el("td", undefined, iface.name), + el("td", undefined, String(iface.mtu)), + el("td", "addresses", iface.addresses.join(", ")), + ); + return tr; + }); +} + +function renderSection(title: string, body: HTMLElement): HTMLElement { + const section = el("section", "report-section"); + // Tables flush to the card edges via `.section-body:has(> table)` in CSS. + const content = el("div", "section-body"); + content.append(body); + section.append(el("h2", undefined, title), content); + return section; +} + +function renderTable( + headers: string[], + rows: T[], + renderRow: (row: T) => HTMLTableRowElement, +): HTMLTableElement { + const table = el("table", "report-table"); + table.append(renderTableHead(...headers)); + const tbody = el("tbody"); + for (const row of rows) { + tbody.append(renderRow(row)); + } + table.append(tbody); + return table; +} + +function renderTableHead(...headers: string[]): HTMLElement { + const thead = el("thead"); + const tr = el("tr"); + tr.append(...headers.map((header) => el("th", undefined, header))); + thead.append(tr); + return thead; +} + +function renderSeverityCell(severity: NetcheckSeverity): HTMLTableCellElement { + const td = el("td"); + const status = el("span", `severity-text severity-${severity}`); + status.append( + el("span", "status-dot"), + document.createTextNode(severityLabel(severity)), + ); + td.append(status); + return td; +} + +/** Create an element with an optional class and text content. */ +function el( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) { + node.className = className; + } + if (text !== undefined) { + node.textContent = text; + } + return node; +} + +function badge(text: string): HTMLElement { + return el("span", "badge", text); +} diff --git a/packages/netcheck/src/regions.ts b/packages/netcheck/src/regions.ts new file mode 100644 index 0000000000..8a8c129670 --- /dev/null +++ b/packages/netcheck/src/regions.ts @@ -0,0 +1,98 @@ +import { nanosToMs, type TriState } from "./format"; + +import type { + NetcheckRegionReport, + NetcheckReport, + NetcheckSeverity, +} from "@repo/shared"; + +export interface RegionRow { + name: string; + severity: NetcheckSeverity; + latencyMs: number | undefined; + preferred: boolean; + embeddedRelay: boolean; + stun: TriState; + relay: TriState; + error: string | undefined; +} + +export function regionName( + region: NetcheckRegionReport | undefined, + id: number, +): string { + return region?.region?.RegionName || `Region ${id}`; +} + +/** Rows for the regions table: preferred first, then by latency, then name. */ +export function buildRegionRows(report: NetcheckReport): RegionRow[] { + const probe = report.derp.netcheck; + return Object.entries(report.derp.regions) + .map(([key, region]) => + toRegionRow( + region, + Number(key), + probe?.PreferredDERP, + probe?.RegionLatency[key], + ), + ) + .toSorted(compareRegionRows); +} + +function toRegionRow( + region: NetcheckRegionReport, + id: number, + preferredId: number | undefined, + latencyNanos: number | undefined, +): RegionRow { + // STUN and relay capability come from different node sets. + const relayNodes = region.node_reports.filter( + (n) => !(n.node?.STUNOnly ?? false), + ); + const stunNodes = region.node_reports.filter((n) => n.stun.Enabled); + return { + name: regionName(region, id), + severity: region.severity, + latencyMs: regionLatencyMs(latencyNanos, relayNodes), + // 0 is the "undetermined" sentinel, not a real region id. + preferred: Boolean(preferredId) && id === preferredId, + embeddedRelay: region.region?.EmbeddedRelay ?? false, + stun: anyTriState(stunNodes, (n) => n.stun.CanSTUN), + relay: anyTriState(relayNodes, (n) => n.can_exchange_messages), + error: region.error ?? undefined, + }; +} + +function regionLatencyMs( + probedNanos: number | undefined, + relayNodes: Array<{ round_trip_ping_ms: number }>, +): number | undefined { + if (probedNanos !== undefined && probedNanos > 0) { + return nanosToMs(probedNanos); + } + const pings = relayNodes + .map((n) => n.round_trip_ping_ms) + .filter((ms) => ms > 0); + return pings.length > 0 ? Math.min(...pings) : undefined; +} + +function anyTriState(items: T[], check: (item: T) => boolean): TriState { + if (items.length === 0) { + return "unknown"; + } + return items.some(check) ? "yes" : "no"; +} + +function compareRegionRows(a: RegionRow, b: RegionRow): number { + if (a.preferred !== b.preferred) { + return a.preferred ? -1 : 1; + } + // Missing latencies sort last; the `!==` guard avoids Infinity - Infinity + // (NaN), letting two unmeasured regions fall through to name order. + const aLatency = a.latencyMs ?? Infinity; + const bLatency = b.latencyMs ?? Infinity; + if (aLatency !== bLatency) { + return aLatency - bLatency; + } + return a.name.localeCompare(b.name); +} diff --git a/packages/netcheck/tsconfig.json b/packages/netcheck/tsconfig.json new file mode 100644 index 0000000000..e1940bf7a8 --- /dev/null +++ b/packages/netcheck/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.packages.json", + "compilerOptions": { + "paths": { + "@repo/shared": ["../shared/src"], + "@repo/webview-shared": ["../webview-shared/src"] + } + }, + "include": ["src"] +} diff --git a/packages/netcheck/vite.config.ts b/packages/netcheck/vite.config.ts new file mode 100644 index 0000000000..8277382644 --- /dev/null +++ b/packages/netcheck/vite.config.ts @@ -0,0 +1,3 @@ +import { createWebviewConfig } from "../webview-shared/createWebviewConfig"; + +export default createWebviewConfig("netcheck", __dirname); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 08f8c7f31e..4f9b15143b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -17,5 +17,18 @@ export { type SpeedtestResult, } from "./speedtest/api"; +// Netcheck API +export { NetcheckApi } from "./netcheck/api"; +export { overallNetcheckSeverity, worstSeverity } from "./netcheck/utils"; +export type { + NetcheckConnectivity, + NetcheckData, + NetcheckInterface, + NetcheckRegionReport, + NetcheckReport, + NetcheckSectionHealth, + NetcheckSeverity, +} from "./netcheck/types"; + // Workspaces API export { WorkspacesApi } from "./workspaces/api"; diff --git a/packages/shared/src/netcheck/api.ts b/packages/shared/src/netcheck/api.ts new file mode 100644 index 0000000000..4e745002ee --- /dev/null +++ b/packages/shared/src/netcheck/api.ts @@ -0,0 +1,12 @@ +import { defineCommand, defineNotification } from "../ipc/protocol"; + +import type { NetcheckData } from "./types"; + +export const NetcheckApi = { + /** Extension pushes the parsed report to the webview */ + data: defineNotification("netcheck/data"), + /** Webview signals that its message subscription is active */ + ready: defineCommand("netcheck/ready"), + /** Webview requests to open raw JSON in a text editor */ + viewJson: defineCommand("netcheck/viewJson"), +} as const; diff --git a/packages/shared/src/netcheck/types.ts b/packages/shared/src/netcheck/types.ts new file mode 100644 index 0000000000..a9709819e1 --- /dev/null +++ b/packages/shared/src/netcheck/types.ts @@ -0,0 +1,72 @@ +/** + * Domain types for a `coder netcheck` report: the fields the webview renders, + * normalized during parsing (null regions dropped, null lists become `[]`). The + * DERP section mirrors codersdk `DERPHealthReport`. CLI/tailscale JSON field + * names are kept as-is; types we add like NetcheckData use camelCase. + */ + +export type NetcheckSeverity = "ok" | "warning" | "error"; + +/** Health fields shared by the DERP and interfaces sections of the report. */ +export interface NetcheckSectionHealth { + /** Parse-time rollup: errors (and, for DERP, probe/region severity) folded in. */ + severity: NetcheckSeverity; + warnings: Array<{ code: string; message: string }>; + error?: string | null; +} + +interface NetcheckNodeReport { + can_exchange_messages: boolean; + round_trip_ping_ms: number; + stun: { Enabled: boolean; CanSTUN: boolean }; + node?: { STUNOnly?: boolean | null } | null; +} + +export interface NetcheckRegionReport { + severity: NetcheckSeverity; + error?: string | null; + region?: { + RegionID: number; + RegionName: string; + EmbeddedRelay: boolean; + } | null; + node_reports: NetcheckNodeReport[]; +} + +export interface NetcheckConnectivity { + UDP: boolean; + IPv4: boolean; + IPv6: boolean; + MappingVariesByDestIP?: boolean | null; + HairPinning?: boolean | null; + UPnP?: boolean | null; + PMP?: boolean | null; + PCP?: boolean | null; + /** Region ID of the preferred DERP region; 0 when undetermined. */ + PreferredDERP: number; + /** Latency per DERP region ID, in nanoseconds. */ + RegionLatency: Record; +} + +export interface NetcheckInterface { + name: string; + mtu: number; + addresses: string[]; +} + +export interface NetcheckReport { + derp: NetcheckSectionHealth & { + regions: Record; + netcheck?: NetcheckConnectivity | null; + netcheck_err?: string | null; + }; + interfaces: NetcheckSectionHealth & { + interfaces: NetcheckInterface[]; + }; +} + +export interface NetcheckData { + /** Hostname of the deployment the report was generated against. */ + host: string; + report: NetcheckReport; +} diff --git a/packages/shared/src/netcheck/utils.ts b/packages/shared/src/netcheck/utils.ts new file mode 100644 index 0000000000..0688deb4e7 --- /dev/null +++ b/packages/shared/src/netcheck/utils.ts @@ -0,0 +1,24 @@ +import type { NetcheckReport, NetcheckSeverity } from "./types"; + +const SEVERITY_RANK = { + ok: 0, + warning: 1, + error: 2, +} as const satisfies Record; + +/** Worst of the given severities. */ +export function worstSeverity( + severities: readonly NetcheckSeverity[], +): NetcheckSeverity { + return severities.reduce( + (worst, s) => (SEVERITY_RANK[s] > SEVERITY_RANK[worst] ? s : worst), + "ok", + ); +} + +/** Worst of the two section severities (errors are folded in at parse). */ +export function overallNetcheckSeverity( + report: NetcheckReport, +): NetcheckSeverity { + return worstSeverity([report.derp.severity, report.interfaces.severity]); +} diff --git a/packages/speedtest/src/index.css b/packages/speedtest/src/index.css index e56432bf33..e6515ea0d2 100644 --- a/packages/speedtest/src/index.css +++ b/packages/speedtest/src/index.css @@ -1,17 +1,7 @@ -*, -*::before, -*::after { - box-sizing: border-box; -} +/* Base reset, body, and button styles come from @repo/webview-shared/base.css. */ body { - margin: 0; - padding: 1.5em; min-width: 22em; - background: var(--vscode-editor-background); - color: var(--vscode-editor-foreground); - font-family: var(--vscode-font-family); - font-size: var(--vscode-font-size); } .page-header { @@ -75,25 +65,6 @@ body { height: 100%; } -.actions { - display: flex; - justify-content: center; -} - -button { - padding: 0.4em 1em; - border: 1px solid var(--vscode-button-border, transparent); - border-radius: 2px; - background: var(--vscode-button-secondaryBackground); - color: var(--vscode-button-secondaryForeground); - font: inherit; - cursor: pointer; -} - -button:hover { - background: var(--vscode-button-secondaryHoverBackground); -} - .tooltip { position: absolute; padding: 0.25em 0.5em; @@ -118,10 +89,6 @@ button:hover { margin: 2em 0; } -.error { - color: var(--vscode-errorForeground); -} - .empty { opacity: 0.7; } diff --git a/packages/speedtest/src/index.ts b/packages/speedtest/src/index.ts index 476f0071be..b0fd9da45d 100644 --- a/packages/speedtest/src/index.ts +++ b/packages/speedtest/src/index.ts @@ -1,5 +1,13 @@ import { SpeedtestApi, type SpeedtestResult, toError } from "@repo/shared"; -import { sendCommand, subscribeNotifications } from "@repo/webview-shared"; +import { + emptyMessage, + errorMessage, + pageHeader, + sendCommand, + subscribeNotifications, + viewJsonAction, +} from "@repo/webview-shared"; +import "@repo/webview-shared/base.css"; import { renderLineChart } from "./chart"; import { @@ -47,38 +55,22 @@ function renderPage( } root.innerHTML = ""; - root.appendChild(renderHeading(workspaceId)); + root.appendChild(pageHeader("Speed Test", workspaceId, "workspace-id")); root.appendChild(renderSummary(data)); const samples = toChartSamples(data.intervals); if (samples.length === 0) { - root.appendChild(renderEmptyMessage()); - root.appendChild(renderActions(onViewJson)); + root.appendChild(emptyMessage("No samples returned from the speed test.")); + root.appendChild(viewJsonAction(onViewJson)); return () => undefined; } const chart = renderChart(samples); root.appendChild(chart.container); - root.appendChild(renderActions(onViewJson)); + root.appendChild(viewJsonAction(onViewJson)); return chart.cleanup; } -function renderHeading(workspaceId: string): HTMLElement { - const header = document.createElement("header"); - header.className = "page-header"; - - const eyebrow = document.createElement("p"); - eyebrow.className = "eyebrow"; - eyebrow.textContent = "Speed Test"; - - const heading = document.createElement("h1"); - heading.className = "workspace-id"; - heading.textContent = workspaceId; - - header.append(eyebrow, heading); - return header; -} - function renderSummary(data: SpeedtestResult): HTMLElement { const summary = document.createElement("div"); summary.className = "summary"; @@ -190,32 +182,11 @@ function renderChart(samples: ChartPoint[]): { }; } -function renderActions(onViewJson: () => void): HTMLElement { - const actions = document.createElement("div"); - actions.className = "actions"; - const viewBtn = document.createElement("button"); - viewBtn.textContent = "View JSON"; - viewBtn.addEventListener("click", onViewJson); - actions.appendChild(viewBtn); - return actions; -} - -function renderEmptyMessage(): HTMLElement { - const p = document.createElement("p"); - p.className = "empty"; - p.textContent = "No samples returned from the speed test."; - return p; -} - function showError(message: string): void { const root = document.getElementById("root"); - if (!root) { - return; + if (root) { + root.replaceChildren(errorMessage(message)); } - const p = document.createElement("p"); - p.className = "error"; - p.textContent = message; - root.replaceChildren(p); } main(); diff --git a/packages/webview-shared/package.json b/packages/webview-shared/package.json index a1e482a3da..c828259d53 100644 --- a/packages/webview-shared/package.json +++ b/packages/webview-shared/package.json @@ -13,6 +13,7 @@ "types": "./src/api.ts", "default": "./src/api.ts" }, + "./base.css": "./src/base.css", "./logger": { "types": "./src/logger.ts", "default": "./src/logger.ts" diff --git a/packages/webview-shared/src/base.css b/packages/webview-shared/src/base.css new file mode 100644 index 0000000000..b427146f19 --- /dev/null +++ b/packages/webview-shared/src/base.css @@ -0,0 +1,44 @@ +/* Base styles shared by the vanilla webviews. Page layout lives in each webview's own index.css. */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 1.5em; + background: var(--vscode-editor-background); + color: var(--vscode-foreground, var(--vscode-editor-foreground)); + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); +} + +.actions { + display: flex; + justify-content: center; +} + +button { + padding: 0.4em 1em; + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 2px; + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + font: inherit; + cursor: pointer; +} + +button:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + +button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.error { + color: var(--vscode-errorForeground); +} diff --git a/packages/webview-shared/src/dom.ts b/packages/webview-shared/src/dom.ts new file mode 100644 index 0000000000..99f15a440d --- /dev/null +++ b/packages/webview-shared/src/dom.ts @@ -0,0 +1,47 @@ +/** DOM builders shared by the vanilla webviews. */ + +/** A page header: an eyebrow label above a title with the given class. */ +export function pageHeader( + eyebrow: string, + title: string, + titleClass: string, +): HTMLElement { + const header = document.createElement("header"); + header.className = "page-header"; + const eyebrowEl = document.createElement("p"); + eyebrowEl.className = "eyebrow"; + eyebrowEl.textContent = eyebrow; + const titleEl = document.createElement("h1"); + titleEl.className = titleClass; + titleEl.textContent = title; + header.append(eyebrowEl, titleEl); + return header; +} + +/** An action bar with a "View JSON" button wired to `onClick`. */ +export function viewJsonAction(onClick: () => void): HTMLElement { + const actions = document.createElement("div"); + actions.className = "actions"; + const button = document.createElement("button"); + button.textContent = "View JSON"; + button.addEventListener("click", onClick); + actions.append(button); + return actions; +} + +/** A message shown in place of a section or result that has no data. */ +export function emptyMessage(text: string): HTMLElement { + return paragraph("empty", text); +} + +/** A top-level failure message. */ +export function errorMessage(text: string): HTMLElement { + return paragraph("error", text); +} + +function paragraph(className: string, text: string): HTMLElement { + const p = document.createElement("p"); + p.className = className; + p.textContent = text; + return p; +} diff --git a/packages/webview-shared/src/index.ts b/packages/webview-shared/src/index.ts index d46be34b5f..95bedc7110 100644 --- a/packages/webview-shared/src/index.ts +++ b/packages/webview-shared/src/index.ts @@ -14,3 +14,6 @@ export { sendCommand, subscribeNotifications, } from "./ipc"; + +// DOM builders shared by the vanilla webviews. +export { pageHeader, viewJsonAction, emptyMessage, errorMessage } from "./dom"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd7e507354..f8d5ecbcda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -315,6 +315,25 @@ importers: specifier: 'catalog:' version: 6.0.3 + packages/netcheck: + dependencies: + '@repo/shared': + specifier: workspace:* + version: link:../shared + '@repo/webview-shared': + specifier: workspace:* + version: link:../webview-shared + devDependencies: + '@types/vscode-webview': + specifier: 'catalog:' + version: 1.57.5 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vite: + specifier: 'catalog:' + version: 8.0.16(@types/node@22.19.21)(esbuild@0.28.1) + packages/shared: devDependencies: typescript: diff --git a/src/command/diagnosticFlow.ts b/src/command/diagnosticFlow.ts new file mode 100644 index 0000000000..1b3708b673 --- /dev/null +++ b/src/command/diagnosticFlow.ts @@ -0,0 +1,83 @@ +import * as vscode from "vscode"; +import { ZodError } from "zod"; + +import { toError } from "../error/errorUtils"; +import { withCancellableProgress, type ProgressContext } from "../progress"; +import { openJsonBeside } from "../webviews/openJson"; + +import type { DiagnosticTrace } from "../instrumentation/diagnostics"; +import type { Logger } from "../logging/logger"; + +export interface DiagnosticCliOptions { + telemetry: DiagnosticTrace; + logger: Logger; + /** Display name used in messages, e.g. "Speed test". */ + name: string; + progressTitle: string; + /** Invoke the CLI under the progress notification; resolves to raw JSON. */ + exec: (ctx: ProgressContext) => Promise; + /** + * Parse the output, record success telemetry, and show the results. Parsing + * happens here so parse failures map to the `parse_error` telemetry path. + */ + parseAndDisplay: (rawJson: string) => void; +} + +/** + * Shared tail of the CLI diagnostic commands: runs the CLI under a + * cancellable progress notification, then parses and displays its output, + * mapping cancellation, CLI failures, and parse failures to telemetry and + * user-facing error messages. + */ +export async function runDiagnosticCli( + options: DiagnosticCliOptions, +): Promise { + const { telemetry, logger, name } = options; + const result = await withCancellableProgress(options.exec, { + location: vscode.ProgressLocation.Notification, + title: options.progressTitle, + cancellable: true, + }); + + if (!result.ok) { + if (result.cancelled) { + telemetry.abort("progress"); + return; + } + telemetry.error(); + logger.error(`${name} failed`, result.error); + vscode.window.showErrorMessage( + `${name} failed: ${toError(result.error).message}`, + ); + return; + } + + // Display failed but the output is valid; offer it rather than dropping it. + const offerRawOutput = (message: string) => { + void vscode.window + .showErrorMessage(message, "View Output") + .then((choice) => { + if (choice === "View Output") { + void openJsonBeside(result.value, name, logger); + } + }); + }; + + try { + options.parseAndDisplay(result.value); + } catch (err) { + if (err instanceof ZodError || err instanceof SyntaxError) { + telemetry.error("parse_error"); + logger.error(`Failed to parse ${name} output`, err); + offerRawOutput( + `${name} output did not match the expected format. Check \`Output > Coder\` for details.`, + ); + return; + } + telemetry.error(); + logger.error(`Failed to display ${name} results`, err); + offerRawOutput( + `${name} could not display its results: ${toError(err).message}`, + ); + } +} diff --git a/src/commands.ts b/src/commands.ts index 0a09033027..cc070887ca 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -3,13 +3,13 @@ import * as os from "node:os"; import * as path from "node:path"; import * as semver from "semver"; import * as vscode from "vscode"; -import { ZodError } from "zod"; import { createWorkspaceIdentifier, extractAgents, workspaceStatusLabel, } from "./api/api-helper"; +import { runDiagnosticCli } from "./command/diagnosticFlow"; import * as cliExec from "./core/cliExec"; import { CertificateError } from "./error/certificateError"; import { toError } from "./error/errorUtils"; @@ -48,6 +48,7 @@ import { appendVsCodeLogs } from "./supportBundle/appendVsCodeLogs"; import { runExportTelemetryCommand } from "./telemetry/export/command"; import { openInBrowser, toRemoteAuthority, toSafeHost } from "./util"; import { vscodeProposed } from "./vscodeProposed"; +import { parseNetcheckReport } from "./webviews/netcheck/types"; import { parseSpeedtestResult } from "./webviews/speedtest/types"; import { AgentTreeItem, @@ -70,6 +71,7 @@ import type { DeploymentManager } from "./deployment/deploymentManager"; import type { Logger } from "./logging/logger"; import type { LoginCoordinator, LoginMethod } from "./login/loginCoordinator"; import type { TelemetryService } from "./telemetry/service"; +import type { NetcheckPanelFactory } from "./webviews/netcheck/netcheckPanelFactory"; import type { SpeedtestPanelFactory } from "./webviews/speedtest/speedtestPanelFactory"; import type { DuplicateWorkspaceIpc, @@ -133,6 +135,7 @@ export class Commands { private readonly loginCoordinator: LoginCoordinator; private readonly duplicateWorkspaceIpc: DuplicateWorkspaceIpc; private readonly speedtestPanelFactory: SpeedtestPanelFactory; + private readonly netcheckPanelFactory: NetcheckPanelFactory; private readonly telemetryService: TelemetryService; private readonly authTelemetry: AuthTelemetry; private readonly diagnosticTelemetry: DiagnosticTelemetry; @@ -168,6 +171,7 @@ export class Commands { this.loginCoordinator = serviceContainer.getLoginCoordinator(); this.duplicateWorkspaceIpc = serviceContainer.getDuplicateWorkspaceIpc(); this.speedtestPanelFactory = serviceContainer.getSpeedtestPanelFactory(); + this.netcheckPanelFactory = serviceContainer.getNetcheckPanelFactory(); } /** @@ -310,8 +314,12 @@ export class Commands { const seconds = Number(input.trim()); telemetry.setRequestedDuration(seconds); - const result = await withCancellableProgress( - async ({ signal, progress }) => { + await runDiagnosticCli({ + telemetry, + logger: this.logger, + name: "Speed test", + progressTitle: `Running speed test for ${workspaceId}`, + exec: async ({ signal, progress }) => { progress.report({ message: "Connecting..." }); const env = await this.resolveCliEnv(client); @@ -334,49 +342,65 @@ export class Commands { stopProgress(); } }, - { - location: vscode.ProgressLocation.Notification, - title: `Running speed test for ${workspaceId}`, - cancellable: true, + parseAndDisplay: (rawJson) => { + const parsed = parseSpeedtestResult(rawJson); + this.speedtestPanelFactory.show({ + result: parsed, + rawJson, + workspaceId, + }); + // Record success after the panel shows, so a display failure is an + // error rather than a contradictory success span. + telemetry.succeedSpeedtest(parsed); }, + }); + } + + /** + * Run a network check against the current deployment and display the + * report in a webview panel. Can be triggered from the sidebar or command + * palette. + */ + public async netcheck(): Promise { + await this.diagnosticTelemetry.trace("netcheck", (telemetry) => + this.runNetcheck(telemetry), ); + } - if (!result.ok) { - if (result.cancelled) { - telemetry.abort("progress"); - return; - } + private async runNetcheck(telemetry: DiagnosticTrace): Promise { + // Netcheck reports on the deployment as a whole, so unlike the other + // diagnostics there is no workspace to resolve; prefer the deployment + // the current remote workspace belongs to. + const client = this.remoteWorkspaceClient ?? this.extensionClient; + const baseUrl = client.getAxiosInstance().defaults.baseURL; + if (!baseUrl) { telemetry.error(); - this.logger.error("Speed test failed", result.error); - vscode.window.showErrorMessage( - `Speed test failed: ${toError(result.error).message}`, - ); + vscode.window.showErrorMessage("You are not logged in"); return; } + const host = toSafeHost(baseUrl); - try { - const parsed = parseSpeedtestResult(result.value); - telemetry.succeedSpeedtest(parsed); - this.speedtestPanelFactory.show({ - result: parsed, - rawJson: result.value, - workspaceId, - }); - } catch (err) { - if (err instanceof ZodError || err instanceof SyntaxError) { - telemetry.error("parse_error"); - this.logger.error("Failed to parse speedtest output", err); - vscode.window.showErrorMessage( - "Speed test output did not match the expected format. Check `Output > Coder` for details.", - ); - return; - } - telemetry.error(); - this.logger.error("Failed to display speedtest results", err); - vscode.window.showErrorMessage( - `Speed test returned unexpected output: ${toError(err).message}`, - ); - } + await runDiagnosticCli({ + telemetry, + logger: this.logger, + name: "Network check", + progressTitle: `Running network check for ${host}`, + exec: async ({ signal, progress }) => { + progress.report({ message: "Resolving CLI..." }); + const env = await this.resolveCliEnv(client); + progress.report({ + message: "Gathering network report. This may take a few seconds...", + }); + return await cliExec.netcheck(env, signal); + }, + parseAndDisplay: (rawJson) => { + const report = parseNetcheckReport(rawJson); + this.netcheckPanelFactory.show({ host, report }, rawJson); + // Record success after the panel shows, so a display failure is an + // error rather than a contradictory success span. + telemetry.succeedNetcheck(report); + }, + }); } public async supportBundle(item?: OpenableTreeItem): Promise { diff --git a/src/core/cliExec.ts b/src/core/cliExec.ts index f372400799..d25980c1e6 100644 --- a/src/core/cliExec.ts +++ b/src/core/cliExec.ts @@ -79,6 +79,26 @@ export async function speedtest( } } +/** + * Run `coder netcheck` and return the raw JSON report. + */ +export async function netcheck( + env: CliEnv, + signal?: AbortSignal, +): Promise { + const globalFlags = getGlobalFlags(env.configs, env.auth); + try { + const result = await execFileAsync( + env.binary, + [...globalFlags, "netcheck"], + { signal }, + ); + return result.stdout; + } catch (error) { + throw cliError(error); + } +} + /** * Run `coder support bundle` and save the output zip to the given path. */ diff --git a/src/core/commandManager.ts b/src/core/commandManager.ts index 2a18bbf78a..723a4fe21b 100644 --- a/src/core/commandManager.ts +++ b/src/core/commandManager.ts @@ -30,6 +30,7 @@ export const CODER_COMMAND_IDS = [ "coder.pingWorkspace:views", "coder.speedTest", "coder.speedTest:views", + "coder.netcheck", "coder.supportBundle", "coder.supportBundle:views", "coder.tasks.refresh", diff --git a/src/core/container.ts b/src/core/container.ts index ba64d8cbf0..289cf428ab 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -8,6 +8,7 @@ import { buildSession, extractExtensionVersion } from "../telemetry/event"; import { newSessionId } from "../telemetry/ids"; import { TelemetryService } from "../telemetry/service"; import { LocalJsonlSink } from "../telemetry/sinks/localJsonlSink"; +import { NetcheckPanelFactory } from "../webviews/netcheck/netcheckPanelFactory"; import { SpeedtestPanelFactory } from "../webviews/speedtest/speedtestPanelFactory"; import { DuplicateWorkspaceIpc } from "../workspace/duplicateWorkspaceIpc"; @@ -37,6 +38,7 @@ export class ServiceContainer implements vscode.Disposable { private readonly duplicateWorkspaceIpc: DuplicateWorkspaceIpc; private readonly oauthCallback: OAuthCallback; private readonly speedtestPanelFactory: SpeedtestPanelFactory; + private readonly netcheckPanelFactory: NetcheckPanelFactory; private readonly telemetryService: TelemetryService; private readonly commandManager: CommandManager; @@ -117,6 +119,10 @@ export class ServiceContainer implements vscode.Disposable { context.extensionUri, this.logger, ); + this.netcheckPanelFactory = new NetcheckPanelFactory( + context.extensionUri, + this.logger, + ); this.commandManager = new CommandManager(this.telemetryService); } @@ -165,6 +171,10 @@ export class ServiceContainer implements vscode.Disposable { return this.speedtestPanelFactory; } + getNetcheckPanelFactory(): NetcheckPanelFactory { + return this.netcheckPanelFactory; + } + getTelemetryService(): TelemetryService { return this.telemetryService; } diff --git a/src/extension.ts b/src/extension.ts index 5221f7594a..e097daa876 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -358,6 +358,7 @@ async function doActivate( "coder.speedTest:views", commands.speedTest.bind(commands), ); + commandManager.register("coder.netcheck", commands.netcheck.bind(commands)); commandManager.register( "coder.supportBundle", commands.supportBundle.bind(commands), diff --git a/src/instrumentation/EVENTS.md b/src/instrumentation/EVENTS.md index f6a8998601..b4171cf69b 100644 --- a/src/instrumentation/EVENTS.md +++ b/src/instrumentation/EVENTS.md @@ -290,13 +290,16 @@ Emitted by `DiagnosticTelemetry` around each diagnostic command. | Attribute | Values | | ------------------------------------------ | ---------------------------------------------------------------- | -| `command` | `speed_test`, `support_bundle`, `export_telemetry` | +| `command` | `speed_test`, `netcheck`, `support_bundle`, `export_telemetry` | | `abort_stage` | `workspace_picker`, `input`, `prompt`, `save_dialog`, `progress` | | `error.type` | `fetch_error`, `parse_error`, `unsupported_cli`, `error` | | `format` | `json`, `otlp` (telemetry export only) | +| `severity` | `ok`, `warning`, `error` (netcheck only) | | `requested_duration_seconds` (measurement) | speed test only | | `interval.count` (measurement) | speed test only | | `throughput_mbits` (measurement) | speed test only | +| `region.count` (measurement) | netcheck only; DERP regions in the report | +| `warning.count` (measurement) | netcheck only; warnings across report sections | | `event.count` (measurement) | telemetry export only | | `file.skipped_count` (measurement) | telemetry export only; unreadable files skipped, omitted at zero | diff --git a/src/instrumentation/diagnostics.ts b/src/instrumentation/diagnostics.ts index 337ce9e5c6..4413f066f6 100644 --- a/src/instrumentation/diagnostics.ts +++ b/src/instrumentation/diagnostics.ts @@ -1,6 +1,10 @@ -import { recordAborted, recordError } from "./outcomes"; +import { + overallNetcheckSeverity, + type NetcheckReport, + type SpeedtestResult, +} from "@repo/shared"; -import type { SpeedtestResult } from "@repo/shared"; +import { recordAborted, recordError } from "./outcomes"; import type { ExportResult } from "../telemetry/export/pipeline"; import type { ExportFormat } from "../telemetry/export/writers/types"; @@ -11,6 +15,7 @@ import type { WorkspacePickerErrorCategory } from "./workspaceOpen"; export type DiagnosticCommand = | "speed_test" + | "netcheck" | "support_bundle" | "export_telemetry"; export type DiagnosticErrorCategory = @@ -31,6 +36,7 @@ export interface DiagnosticTrace { setRequestedDuration(seconds: number): void; succeedSpeedtest(result: SpeedtestResult): void; succeedExport(format: ExportFormat, result: ExportResult): void; + succeedNetcheck(report: NetcheckReport): void; } /** Emits `command.diagnostic.completed` around each diagnostic command. */ @@ -79,4 +85,16 @@ class SpanDiagnosticTrace implements DiagnosticTrace { this.span.setMeasurement("file.skipped_count", result.skippedFileCount); } } + + public succeedNetcheck(report: NetcheckReport): void { + this.span.setProperty("severity", overallNetcheckSeverity(report)); + this.span.setMeasurement( + "region.count", + Object.keys(report.derp.regions).length, + ); + this.span.setMeasurement( + "warning.count", + report.derp.warnings.length + report.interfaces.warnings.length, + ); + } } diff --git a/src/webviews/netcheck/netcheckPanelFactory.ts b/src/webviews/netcheck/netcheckPanelFactory.ts new file mode 100644 index 0000000000..bd56fcb921 --- /dev/null +++ b/src/webviews/netcheck/netcheckPanelFactory.ts @@ -0,0 +1,46 @@ +import { + buildCommandHandlers, + buildRequestHandlers, + NetcheckApi, + type NetcheckData, +} from "@repo/shared"; + +import { notifyWebview } from "../dispatch"; +import { showResultPanel } from "../resultPanel"; + +import type * as vscode from "vscode"; + +import type { Logger } from "../../logging/logger"; + +/** Creates webview panels that render `coder netcheck` reports. */ +export class NetcheckPanelFactory { + public constructor( + private readonly extensionUri: vscode.Uri, + private readonly logger: Logger, + ) {} + + public show(data: NetcheckData, rawJson: string): void { + showResultPanel({ + extensionUri: this.extensionUri, + logger: this.logger, + viewType: "coder.netcheckPanel", + webviewName: "netcheck", + title: `Network Check: ${data.host}`, + rawJson, + jsonErrorLabel: "network check", + notify: (webview) => notifyWebview(webview, NetcheckApi.data, data), + // Both builders emit a compile error if any command or request in the + // API lacks a handler here; the empty `{}` below is still load-bearing. + buildHandlers: ({ sendData, openRawJson }) => ({ + commands: buildCommandHandlers(NetcheckApi, { + // Webview signals it's subscribed; safe to push the payload now. + ready: () => { + sendData(); + }, + viewJson: () => openRawJson(), + }), + requests: buildRequestHandlers(NetcheckApi, {}), + }), + }); + } +} diff --git a/src/webviews/netcheck/types.ts b/src/webviews/netcheck/types.ts new file mode 100644 index 0000000000..a427a57e09 --- /dev/null +++ b/src/webviews/netcheck/types.ts @@ -0,0 +1,162 @@ +import { z } from "zod"; + +import { worstSeverity, type NetcheckReport } from "@repo/shared"; + +import type { + DERPHealthReport, + DERPNodeReport, + DERPRegionReport, + NetcheckReport as TailscaleNetcheckReport, +} from "coder/site/src/api/typesGenerated"; + +/** + * The coder SDK fields the parser reads, as a compile-time drift guard: an + * upstream rename or removal fails the build. (Leaf type changes are caught at + * runtime by the schema; the `interfaces` section has no SDK type.) + */ +type _NetcheckSdkFields = + | keyof Pick< + DERPHealthReport, + | "severity" + | "warnings" + | "error" + | "regions" + | "netcheck" + | "netcheck_err" + > + | keyof Pick< + DERPRegionReport, + "severity" | "error" | "region" | "node_reports" + > + | keyof Pick< + DERPNodeReport, + "can_exchange_messages" | "round_trip_ping_ms" | "stun" | "node" + > + | keyof Pick< + TailscaleNetcheckReport, + | "UDP" + | "IPv4" + | "IPv6" + | "MappingVariesByDestIP" + | "HairPinning" + | "UPnP" + | "PMP" + | "PCP" + | "PreferredDERP" + | "RegionLatency" + >; + +const SeveritySchema = z.enum(["ok", "warning", "error"]); + +/** The CLI emits `null` instead of `[]` for empty lists. */ +function emptyIfNull(item: T) { + return z + .array(item) + .nullish() + .transform((v) => v ?? []); +} + +const HealthMessageSchema = z.object({ + code: z.string(), + message: z.string(), +}); + +const WarningsSchema = emptyIfNull(HealthMessageSchema); + +const NodeReportSchema = z.object({ + can_exchange_messages: z.boolean(), + round_trip_ping_ms: z.number(), + stun: z.object({ + Enabled: z.boolean(), + CanSTUN: z.boolean(), + }), + node: z + .object({ STUNOnly: z.boolean().nullish() }) + .nullish() + .transform((v) => v ?? undefined), +}); + +const RegionReportSchema = z + .object({ + severity: SeveritySchema, + error: z.string().nullish(), + region: z + .object({ + RegionID: z.number(), + RegionName: z.string(), + EmbeddedRelay: z.boolean(), + }) + .nullish(), + node_reports: emptyIfNull(NodeReportSchema), + }) + // Fold error into severity so the banner, summary, cell, and telemetry agree. + .transform((r) => ({ ...r, severity: r.error ? "error" : r.severity })); + +const ConnectivitySchema = z.object({ + UDP: z.boolean(), + IPv4: z.boolean(), + IPv6: z.boolean(), + MappingVariesByDestIP: z.boolean().nullish(), + HairPinning: z.boolean().nullish(), + UPnP: z.boolean().nullish(), + PMP: z.boolean().nullish(), + PCP: z.boolean().nullish(), + PreferredDERP: z.number(), + RegionLatency: z + .record(z.string(), z.number()) + .nullish() + .transform((v) => v ?? {}), +}); + +const InterfaceSchema = z.object({ + name: z.string(), + mtu: z.number(), + addresses: z.array(z.string()), +}); + +const NetcheckReportSchema = z.object({ + derp: z + .object({ + severity: SeveritySchema, + warnings: WarningsSchema, + error: z.string().nullish(), + // Region values are nullable pointers in the CLI; drop null entries. + regions: z + .record(z.string(), RegionReportSchema.nullable()) + .nullish() + .transform((v) => { + const regions: Record< + string, + z.output + > = {}; + for (const [id, region] of Object.entries(v ?? {})) { + if (region !== null) { + regions[id] = region; + } + } + return regions; + }), + netcheck: ConnectivitySchema.nullish(), + netcheck_err: z.string().nullish(), + }) + // Roll a failed probe and the worst region up into the section severity. + .transform((d) => ({ + ...d, + severity: worstSeverity([ + d.netcheck_err || d.error ? "error" : d.severity, + ...Object.values(d.regions).map((r) => r.severity), + ]), + })), + interfaces: z + .object({ + severity: SeveritySchema, + warnings: WarningsSchema, + error: z.string().nullish(), + interfaces: emptyIfNull(InterfaceSchema), + }) + .transform((i) => ({ ...i, severity: i.error ? "error" : i.severity })), +}); + +export function parseNetcheckReport(json: string): NetcheckReport { + return NetcheckReportSchema.parse(JSON.parse(json)); +} diff --git a/src/webviews/openJson.ts b/src/webviews/openJson.ts new file mode 100644 index 0000000000..297503acfd --- /dev/null +++ b/src/webviews/openJson.ts @@ -0,0 +1,23 @@ +import * as vscode from "vscode"; + +import type { Logger } from "../logging/logger"; + +/** Open `content` as a JSON document beside the active editor, surfacing failures. */ +export async function openJsonBeside( + content: string, + label: string, + logger: Logger, +): Promise { + try { + const doc = await vscode.workspace.openTextDocument({ + content, + language: "json", + }); + await vscode.window.showTextDocument(doc, vscode.ViewColumn.Beside); + } catch (err) { + logger.error(`Failed to open ${label} JSON`, err); + vscode.window.showErrorMessage( + `Failed to open ${label} JSON. Check \`Output > Coder\` for details.`, + ); + } +} diff --git a/src/webviews/resultPanel.ts b/src/webviews/resultPanel.ts new file mode 100644 index 0000000000..79981d203c --- /dev/null +++ b/src/webviews/resultPanel.ts @@ -0,0 +1,110 @@ +import * as vscode from "vscode"; + +import { + dispatchCommand, + dispatchRequest, + isIpcCommand, + isIpcRequest, + onWhileVisible, +} from "./dispatch"; +import { getWebviewHtml } from "./html"; +import { openJsonBeside } from "./openJson"; + +import type { Logger } from "../logging/logger"; + +export interface ResultPanelHandlerContext { + /** Push the payload to the webview. */ + sendData: () => void; + /** Open the raw CLI JSON in an editor beside the panel. */ + openRawJson: () => Promise; +} + +export interface ResultPanelOptions { + extensionUri: vscode.Uri; + logger: Logger; + /** Panel view type, e.g. `coder.speedtestPanel`. */ + viewType: string; + /** Bundle name under `dist/webviews/`. */ + webviewName: string; + title: string; + /** Raw CLI output backing the open-JSON action. */ + rawJson: string; + /** Human-readable feature name used in error messages, e.g. "speed test". */ + jsonErrorLabel: string; + /** Push the payload notification to the webview. */ + notify: (webview: vscode.Webview) => void; + /** + * Build the handler maps with `buildCommandHandlers` and + * `buildRequestHandlers` so the compile-time exhaustiveness check stays + * with the concrete API definition. + */ + buildHandlers: (ctx: ResultPanelHandlerContext) => { + commands: Record void | Promise>; + requests: Record Promise>; + }; +} + +/** + * Create a webview panel that renders a one-shot CLI result. Owns the panel + * scaffolding shared by such panels: HTML/CSP generation, payload re-send on + * visibility and theme changes, message dispatch, and disposal. + */ +export function showResultPanel(options: ResultPanelOptions): void { + const { extensionUri, logger, webviewName, title } = options; + const panel = vscode.window.createWebviewPanel( + options.viewType, + title, + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(extensionUri, "dist", "webviews", webviewName), + ], + }, + ); + + panel.iconPath = { + light: vscode.Uri.joinPath(extensionUri, "media", "logo-black.svg"), + dark: vscode.Uri.joinPath(extensionUri, "media", "logo-white.svg"), + }; + + panel.webview.html = getWebviewHtml( + panel.webview, + extensionUri, + webviewName, + title, + ); + + const sendData = () => options.notify(panel.webview); + const openRawJson = () => + openJsonBeside(options.rawJson, options.jsonErrorLabel, logger); + const { commands, requests } = options.buildHandlers({ + sendData, + openRawJson, + }); + + // Webview JS is discarded when hidden (no retainContextWhenHidden), and + // renderers may cache theme colors, so we re-send on visibility or theme + // change to rehydrate and redraw. + const disposables: vscode.Disposable[] = [ + onWhileVisible(panel, panel.onDidChangeViewState, sendData), + onWhileVisible(panel, vscode.window.onDidChangeActiveColorTheme, sendData), + panel.webview.onDidReceiveMessage((message: unknown) => { + if (isIpcRequest(message)) { + void dispatchRequest(message, requests, panel.webview, { logger }); + } else if (isIpcCommand(message)) { + void dispatchCommand(message, commands, { logger }); + } else { + logger.warn( + `Ignoring unrecognized ${webviewName} webview message`, + message, + ); + } + }), + ]; + panel.onDidDispose(() => { + for (const d of disposables) { + d.dispose(); + } + }); +} diff --git a/src/webviews/speedtest/speedtestPanelFactory.ts b/src/webviews/speedtest/speedtestPanelFactory.ts index eadfbf9c81..33057f4973 100644 --- a/src/webviews/speedtest/speedtestPanelFactory.ts +++ b/src/webviews/speedtest/speedtestPanelFactory.ts @@ -1,5 +1,3 @@ -import * as vscode from "vscode"; - import { buildCommandHandlers, buildRequestHandlers, @@ -8,15 +6,10 @@ import { type SpeedtestResult, } from "@repo/shared"; -import { - dispatchCommand, - dispatchRequest, - isIpcCommand, - isIpcRequest, - notifyWebview, - onWhileVisible, -} from "../dispatch"; -import { getWebviewHtml } from "../html"; +import { notifyWebview } from "../dispatch"; +import { showResultPanel } from "../resultPanel"; + +import type * as vscode from "vscode"; import type { Logger } from "../../logging/logger"; @@ -34,94 +27,28 @@ export class SpeedtestPanelFactory { ) {} public show({ result, rawJson, workspaceId }: SpeedtestChartPayload): void { - const title = `Speed Test: ${workspaceId}`; - const panel = vscode.window.createWebviewPanel( - "coder.speedtestPanel", - title, - vscode.ViewColumn.One, - { - enableScripts: true, - localResourceRoots: [ - vscode.Uri.joinPath( - this.extensionUri, - "dist", - "webviews", - "speedtest", - ), - ], - }, - ); - - panel.iconPath = { - light: vscode.Uri.joinPath(this.extensionUri, "media", "logo-black.svg"), - dark: vscode.Uri.joinPath(this.extensionUri, "media", "logo-white.svg"), - }; - - panel.webview.html = getWebviewHtml( - panel.webview, - this.extensionUri, - "speedtest", - title, - ); - - // Webview JS is discarded when hidden (no retainContextWhenHidden), and - // the canvas caches theme colors into pixels, so we re-send on visibility - // or theme change to rehydrate and redraw. const payload: SpeedtestData = { workspaceId, result }; - const sendData = () => - notifyWebview(panel.webview, SpeedtestApi.data, payload); - - // Both builders emit a compile error if any command or request in the - // API lacks a handler here; the empty `{}` below is still load-bearing. - const commandHandlers = buildCommandHandlers(SpeedtestApi, { - // Webview signals it's subscribed; safe to push the payload now. - ready: () => { - sendData(); - }, - viewJson: async () => { - try { - const doc = await vscode.workspace.openTextDocument({ - content: rawJson, - language: "json", - }); - await vscode.window.showTextDocument(doc, vscode.ViewColumn.Beside); - } catch (err) { - this.logger.error("Failed to open speedtest JSON", err); - vscode.window.showErrorMessage( - "Failed to open speed test JSON. Check `Output > Coder` for details.", - ); - } - }, - }); - const requestHandlers = buildRequestHandlers(SpeedtestApi, {}); - - const logger = this.logger; - const disposables: vscode.Disposable[] = [ - onWhileVisible(panel, panel.onDidChangeViewState, sendData), - onWhileVisible( - panel, - vscode.window.onDidChangeActiveColorTheme, - sendData, - ), - panel.webview.onDidReceiveMessage((message: unknown) => { - if (isIpcRequest(message)) { - void dispatchRequest(message, requestHandlers, panel.webview, { - logger, - }); - } else if (isIpcCommand(message)) { - void dispatchCommand(message, commandHandlers, { logger }); - } else { - logger.warn( - "Ignoring unrecognized speedtest webview message", - message, - ); - } + showResultPanel({ + extensionUri: this.extensionUri, + logger: this.logger, + viewType: "coder.speedtestPanel", + webviewName: "speedtest", + title: `Speed Test: ${workspaceId}`, + rawJson, + jsonErrorLabel: "speed test", + notify: (webview) => notifyWebview(webview, SpeedtestApi.data, payload), + // Both builders emit a compile error if any command or request in the + // API lacks a handler here; the empty `{}` below is still load-bearing. + buildHandlers: ({ sendData, openRawJson }) => ({ + commands: buildCommandHandlers(SpeedtestApi, { + // Webview signals it's subscribed; safe to push the payload now. + ready: () => { + sendData(); + }, + viewJson: () => openRawJson(), + }), + requests: buildRequestHandlers(SpeedtestApi, {}), }), - ]; - panel.onDidDispose(() => { - for (const d of disposables) { - d.dispose(); - } }); } } diff --git a/test/tsconfig.json b/test/tsconfig.json index 03446ad208..5bc005d0c1 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -4,6 +4,7 @@ "rootDir": "..", "types": ["node", "mocha"], "jsx": "react-jsx", + "resolveJsonModule": true, // Both mappings needed: exact match for root, wildcard for subpaths "paths": { "@/*": ["../src/*"], @@ -12,6 +13,7 @@ "@repo/webview-shared": ["../packages/webview-shared/src/index.ts"], "@repo/webview-shared/*": ["../packages/webview-shared/src/*"], "@repo/tasks/*": ["../packages/tasks/src/*"], + "@repo/netcheck/*": ["../packages/netcheck/src/*"], "@repo/speedtest/*": ["../packages/speedtest/src/*"] } }, diff --git a/test/unit/command/diagnosticFlow.test.ts b/test/unit/command/diagnosticFlow.test.ts new file mode 100644 index 0000000000..fe04812c9f --- /dev/null +++ b/test/unit/command/diagnosticFlow.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; +import { ZodError } from "zod"; + +import { + runDiagnosticCli, + type DiagnosticCliOptions, +} from "@/command/diagnosticFlow"; + +import { createMockLogger } from "../../mocks/testHelpers"; + +import type { DiagnosticTrace } from "@/instrumentation/diagnostics"; + +function setup() { + vi.clearAllMocks(); + // Run the task with a non-cancelled token, like the real progress UI. + vi.mocked(vscode.window.withProgress).mockImplementation( + async (_opts, task) => + task( + { report: vi.fn() }, + { + isCancellationRequested: false, + onCancellationRequested: vi.fn(() => ({ dispose: vi.fn() })), + }, + ), + ); + // The action handler chains off the returned thenable, so it must resolve. + vi.mocked(vscode.window.showErrorMessage).mockResolvedValue(undefined); + + const telemetry: DiagnosticTrace = { + abort: vi.fn(), + error: vi.fn(), + setRequestedDuration: vi.fn(), + succeedSpeedtest: vi.fn(), + succeedExport: vi.fn(), + succeedNetcheck: vi.fn(), + }; + const display = vi.fn(); + + const run = (over: Partial = {}) => + runDiagnosticCli({ + telemetry, + logger: createMockLogger(), + name: "Network check", + progressTitle: "Running network check", + exec: () => Promise.resolve("{}"), + parseAndDisplay: display, + ...over, + }); + + return { telemetry, display, run }; +} + +const abortError = () => { + const err = new Error("cancelled"); + err.name = "AbortError"; + return err; +}; + +describe("runDiagnosticCli", () => { + it("records an abort and skips display when the run is cancelled", async () => { + const { telemetry, display, run } = setup(); + + await run({ exec: () => Promise.reject(abortError()) }); + + expect(telemetry.abort).toHaveBeenCalledWith("progress"); + expect(telemetry.error).not.toHaveBeenCalled(); + expect(display).not.toHaveBeenCalled(); + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); + }); + + it("records an error and reports the failure when the CLI fails", async () => { + const { telemetry, run } = setup(); + + await run({ exec: () => Promise.reject(new Error("boom")) }); + + expect(telemetry.error).toHaveBeenCalledWith(); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Network check failed: boom", + ); + }); + + it("maps a parse failure to parse_error and offers the raw output", async () => { + const { telemetry, run } = setup(); + + await run({ + parseAndDisplay: () => { + throw new ZodError([]); + }, + }); + + expect(telemetry.error).toHaveBeenCalledWith("parse_error"); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining("did not match the expected format"), + "View Output", + ); + }); + + it("treats a SyntaxError as a parse failure", async () => { + const { telemetry, run } = setup(); + + await run({ + parseAndDisplay: () => { + throw new SyntaxError("Unexpected token"); + }, + }); + + expect(telemetry.error).toHaveBeenCalledWith("parse_error"); + }); + + it("maps a non-parse display failure to a generic error", async () => { + const { telemetry, run } = setup(); + + await run({ + parseAndDisplay: () => { + throw new Error("panel disposed"); + }, + }); + + expect(telemetry.error).toHaveBeenCalledWith(); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Network check could not display its results: panel disposed", + "View Output", + ); + }); + + it("opens the raw output in an editor when the user picks View Output", async () => { + const { run } = setup(); + vi.mocked(vscode.window.showErrorMessage).mockResolvedValue( + "View Output" as unknown as undefined, + ); + vi.mocked(vscode.workspace.openTextDocument).mockResolvedValue( + {} as vscode.TextDocument, + ); + vi.mocked(vscode.window.showTextDocument).mockResolvedValue( + {} as vscode.TextEditor, + ); + + await run({ + exec: () => Promise.resolve("RAW-OUTPUT"), + parseAndDisplay: () => { + throw new SyntaxError("bad"); + }, + }); + + await vi.waitFor(() => + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith({ + content: "RAW-OUTPUT", + language: "json", + }), + ); + expect(vscode.window.showTextDocument).toHaveBeenCalled(); + }); + + it("displays the parsed output on success without recording an error", async () => { + const { telemetry, display, run } = setup(); + + await run({ exec: () => Promise.resolve('{"ok":true}') }); + + expect(display).toHaveBeenCalledWith('{"ok":true}'); + expect(telemetry.error).not.toHaveBeenCalled(); + expect(telemetry.abort).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/command/updateWorkspace.telemetry.test.ts b/test/unit/command/updateWorkspace.telemetry.test.ts index 3374a85fea..a1776668c9 100644 --- a/test/unit/command/updateWorkspace.telemetry.test.ts +++ b/test/unit/command/updateWorkspace.telemetry.test.ts @@ -29,6 +29,7 @@ function setup() { getLoginCoordinator: () => ({}), getDuplicateWorkspaceIpc: () => ({}), getSpeedtestPanelFactory: () => ({}), + getNetcheckPanelFactory: () => ({}), } as unknown as ServiceContainer; const commands = new Commands( container, diff --git a/test/unit/commands.netcheck.test.ts b/test/unit/commands.netcheck.test.ts new file mode 100644 index 0000000000..901b2091f5 --- /dev/null +++ b/test/unit/commands.netcheck.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { Commands } from "@/commands"; +import { toSafeHost } from "@/util"; + +import { createTelemetryHarness } from "../mocks/telemetry"; +import { createMockLogger, MockUserInteraction } from "../mocks/testHelpers"; + +import type { CoderApi } from "@/api/coderApi"; +import type { ServiceContainer } from "@/core/container"; +import type { DeploymentManager } from "@/deployment/deploymentManager"; +import type { NetcheckPanelFactory } from "@/webviews/netcheck/netcheckPanelFactory"; + +vi.mock("@/workspace/workspacesProvider", () => ({ + AgentTreeItem: class {}, + WorkspaceTreeItem: class {}, +})); + +function clientWithBaseUrl(baseURL: string | undefined): CoderApi { + return { + getAxiosInstance: () => ({ defaults: { baseURL } }), + } as unknown as CoderApi; +} + +function setup(options: { extensionBaseUrl?: string } = {}) { + vi.clearAllMocks(); + new MockUserInteraction(); + const { sink, service } = createTelemetryHarness(); + + const serviceContainer = { + getTelemetryService: () => service, + getLogger: () => createMockLogger(), + getPathResolver: () => ({}), + getMementoManager: () => ({}), + getSecretsManager: () => ({}), + getCliManager: () => ({}), + getLoginCoordinator: () => ({}), + getDuplicateWorkspaceIpc: () => ({}), + getSpeedtestPanelFactory: () => ({}), + getNetcheckPanelFactory: () => ({}) as NetcheckPanelFactory, + } as unknown as ServiceContainer; + + const commands = new Commands( + serviceContainer, + clientWithBaseUrl(options.extensionBaseUrl), + {} as DeploymentManager, + ); + + return { commands, sink }; +} + +/** Capture the progress title and end the run early so the CLI never executes. */ +function captureProgressTitle(): () => string | undefined { + let title: string | undefined; + vi.mocked(vscode.window.withProgress).mockImplementation((opts) => { + title = (opts as { title?: string }).title; + return Promise.resolve({ ok: false, cancelled: true }); + }); + return () => title; +} + +describe("Commands.netcheck", () => { + it("reports not-logged-in and skips the CLI when no client has a base URL", async () => { + const { commands, sink } = setup({ extensionBaseUrl: undefined }); + + await commands.netcheck(); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "You are not logged in", + ); + expect(vscode.window.withProgress).not.toHaveBeenCalled(); + expect( + sink.expectOne("command.diagnostic.completed").properties, + ).toMatchObject({ command: "netcheck", "error.type": "error" }); + }); + + it("derives the host from the extension client when there is no remote workspace", async () => { + const { commands } = setup({ extensionBaseUrl: "https://ext.coder.test" }); + const title = captureProgressTitle(); + + await commands.netcheck(); + + expect(title()).toContain(toSafeHost("https://ext.coder.test")); + }); + + it("prefers the remote workspace client over the extension client", async () => { + const { commands } = setup({ extensionBaseUrl: "https://ext.coder.test" }); + commands.remoteWorkspaceClient = clientWithBaseUrl( + "https://remote.coder.test", + ); + const title = captureProgressTitle(); + + await commands.netcheck(); + + expect(title()).toContain(toSafeHost("https://remote.coder.test")); + }); +}); diff --git a/test/unit/commands.telemetry.test.ts b/test/unit/commands.telemetry.test.ts index 0903392396..629efdf144 100644 --- a/test/unit/commands.telemetry.test.ts +++ b/test/unit/commands.telemetry.test.ts @@ -19,6 +19,7 @@ import type { SecretsManager } from "@/core/secretsManager"; import type { DeploymentManager } from "@/deployment/deploymentManager"; import type { Deployment } from "@/deployment/types"; import type { LoginCoordinator, LoginResult } from "@/login/loginCoordinator"; +import type { NetcheckPanelFactory } from "@/webviews/netcheck/netcheckPanelFactory"; import type { SpeedtestPanelFactory } from "@/webviews/speedtest/speedtestPanelFactory"; import type { DuplicateWorkspaceIpc } from "@/workspace/duplicateWorkspaceIpc"; @@ -108,6 +109,7 @@ function setup(options: SetupOptions = {}) { getLoginCoordinator: () => loginCoordinator, getDuplicateWorkspaceIpc: () => ({}) as DuplicateWorkspaceIpc, getSpeedtestPanelFactory: () => ({}) as SpeedtestPanelFactory, + getNetcheckPanelFactory: () => ({}) as NetcheckPanelFactory, } as ServiceContainer; const extensionClient = { diff --git a/test/unit/core/cliExec.test.ts b/test/unit/core/cliExec.test.ts index 823f951b9f..774f6e9de3 100644 --- a/test/unit/core/cliExec.test.ts +++ b/test/unit/core/cliExec.test.ts @@ -201,6 +201,48 @@ describe("cliExec", () => { }); }); + describe("netcheck", () => { + it("passes global and header flags", async () => { + const { configs, env } = setup({ + mode: "url", + url: "http://localhost:3000", + }); + configs.set("coder.headerCommand", "my-header-cmd"); + const args = (await cliExec.netcheck(env)).trim().split("\n"); + expect(args).toEqual([ + "--url", + "http://localhost:3000", + "--header-command", + "my-header-cmd", + "netcheck", + ]); + }); + + it("surfaces stderr instead of full command line on failure", async () => { + const code = [ + writeStderrJs("You are not logged in\n"), + `process.exit(1);`, + ].join("\n"); + const bin = await writeExecutable(tmp, "netcheck-err", code); + const { env } = setup({ mode: "global-config", configDir: "/tmp" }, bin); + await expect(cliExec.netcheck(env)).rejects.toThrow( + "You are not logged in", + ); + }); + + it("preserves AbortError name when cancelled via signal", async () => { + // Hangs forever so the only way out is the abort signal. + const code = `setInterval(() => {}, 1000);`; + const bin = await writeExecutable(tmp, "netcheck-hang", code); + const { env } = setup({ mode: "global-config", configDir: "/tmp" }, bin); + const ac = new AbortController(); + ac.abort(); + await expect(cliExec.netcheck(env, ac.signal)).rejects.toMatchObject({ + name: "AbortError", + }); + }); + }); + describe("supportBundle", () => { it("passes global, header, and command-specific flags", async () => { // Use a binary that writes args to the --output-file path diff --git a/test/unit/instrumentation/diagnostics.test.ts b/test/unit/instrumentation/diagnostics.test.ts index f4165daf8b..ee4eee76d9 100644 --- a/test/unit/instrumentation/diagnostics.test.ts +++ b/test/unit/instrumentation/diagnostics.test.ts @@ -4,6 +4,8 @@ import { DiagnosticTelemetry } from "@/instrumentation/diagnostics"; import { createTelemetryHarness } from "../../mocks/telemetry"; +import type { NetcheckReport } from "@repo/shared"; + function setup() { const { sink, service } = createTelemetryHarness(); return { sink, telemetry: new DiagnosticTelemetry(service) }; @@ -65,4 +67,40 @@ describe("DiagnosticTelemetry", () => { properties: { result: "success" }, }); }); + + it("records netcheck severity and bounded counts", async () => { + const { sink, telemetry } = setup(); + const report: NetcheckReport = { + derp: { + severity: "warning", + warnings: [{ code: "EDERP01", message: "Region latency is high" }], + regions: { + "999": { severity: "ok", node_reports: [] }, + "1000": { severity: "ok", node_reports: [] }, + }, + }, + interfaces: { + severity: "warning", + warnings: [{ code: "EIF01", message: "MTU is low" }], + interfaces: [], + }, + }; + + await telemetry.trace("netcheck", (trace) => { + trace.succeedNetcheck(report); + return Promise.resolve(); + }); + + expect(sink.expectOne("command.diagnostic.completed")).toMatchObject({ + measurements: { + "region.count": 2, + "warning.count": 2, + }, + properties: { + command: "netcheck", + severity: "warning", + result: "success", + }, + }); + }); }); diff --git a/test/unit/webviews/netcheck/fixtures/netcheck-report.json b/test/unit/webviews/netcheck/fixtures/netcheck-report.json new file mode 100644 index 0000000000..23fa2d338c --- /dev/null +++ b/test/unit/webviews/netcheck/fixtures/netcheck-report.json @@ -0,0 +1,133 @@ +{ + "derp": { + "severity": "ok", + "warnings": [], + "dismissed": false, + "healthy": true, + "regions": { + "999": { + "healthy": true, + "severity": "ok", + "warnings": [], + "region": { + "EmbeddedRelay": true, + "RegionID": 999, + "RegionCode": "coder", + "RegionName": "Council Bluffs, Iowa", + "Nodes": [ + { + "Name": "999b", + "RegionID": 999, + "HostName": "dev.coder.com", + "STUNPort": -1, + "STUNOnly": false, + "DERPPort": 443 + } + ] + }, + "node_reports": [ + { + "healthy": true, + "severity": "ok", + "warnings": [], + "node": { + "Name": "999b", + "RegionID": 999, + "HostName": "dev.coder.com", + "STUNPort": -1, + "STUNOnly": false, + "DERPPort": 443 + }, + "node_info": { + "TokenBucketBytesPerSecond": 0, + "TokenBucketBytesBurst": 0 + }, + "can_exchange_messages": true, + "round_trip_ping": "60ms", + "round_trip_ping_ms": 60, + "uses_websocket": false, + "client_logs": [[], []], + "client_errs": [[], []], + "stun": { "Enabled": false, "CanSTUN": false, "Error": null } + } + ] + }, + "1000": { + "healthy": true, + "severity": "ok", + "warnings": [], + "region": { + "EmbeddedRelay": false, + "RegionID": 1000, + "RegionCode": "coder_stun_1000", + "RegionName": "Coder STUN 1000", + "Nodes": [ + { + "Name": "1000stun0", + "RegionID": 1000, + "HostName": "stun.l.google.com", + "STUNPort": 19302, + "STUNOnly": true + } + ] + }, + "node_reports": [ + { + "healthy": true, + "severity": "ok", + "warnings": [], + "node": { + "Name": "1000stun0", + "RegionID": 1000, + "HostName": "stun.l.google.com", + "STUNPort": 19302, + "STUNOnly": true + }, + "node_info": { + "TokenBucketBytesPerSecond": 0, + "TokenBucketBytesBurst": 0 + }, + "can_exchange_messages": false, + "round_trip_ping": "", + "round_trip_ping_ms": 0, + "uses_websocket": false, + "client_logs": [], + "client_errs": [], + "stun": { "Enabled": true, "CanSTUN": true, "Error": null } + } + ] + } + }, + "netcheck": { + "UDP": true, + "IPv6": false, + "IPv4": true, + "IPv6CanSend": false, + "IPv4CanSend": true, + "OSHasIPv6": true, + "ICMPv4": false, + "MappingVariesByDestIP": false, + "HairPinning": null, + "UPnP": false, + "PMP": false, + "PCP": false, + "PreferredDERP": 999, + "RegionLatency": { "999": 27706829, "1000": 28003498 }, + "RegionV4Latency": { "999": 27706829 }, + "RegionV6Latency": {}, + "GlobalV4": "64.130.54.176:56069", + "GlobalV6": "", + "CaptivePortal": null + }, + "netcheck_logs": [] + }, + "interfaces": { + "severity": "ok", + "warnings": null, + "dismissed": false, + "interfaces": [ + { "name": "lo", "mtu": 65536, "addresses": ["127.0.0.1/8", "::1/128"] }, + { "name": "eth0", "mtu": 1500, "addresses": ["172.20.0.99/16"] } + ] + } +} diff --git a/test/unit/webviews/netcheck/netcheckPanelFactory.test.ts b/test/unit/webviews/netcheck/netcheckPanelFactory.test.ts new file mode 100644 index 0000000000..73283fdb72 --- /dev/null +++ b/test/unit/webviews/netcheck/netcheckPanelFactory.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { NetcheckPanelFactory } from "@/webviews/netcheck/netcheckPanelFactory"; + +import { NetcheckApi, type NetcheckReport } from "@repo/shared"; + +import { + createMockLogger, + createMockWebviewPanel, + type WebviewPanelTestHooks, +} from "../../../mocks/testHelpers"; + +const sampleReport: NetcheckReport = { + derp: { + severity: "ok", + warnings: [], + regions: { + "999": { + severity: "ok", + region: { RegionID: 999, RegionName: "Embedded", EmbeddedRelay: true }, + node_reports: [ + { + can_exchange_messages: true, + round_trip_ping_ms: 60, + stun: { Enabled: false, CanSTUN: false }, + }, + ], + }, + }, + netcheck: { + UDP: true, + IPv4: true, + IPv6: false, + PreferredDERP: 999, + RegionLatency: { "999": 27706829 }, + }, + }, + interfaces: { + severity: "ok", + warnings: [], + interfaces: [{ name: "eth0", mtu: 1500, addresses: ["172.20.0.99/16"] }], + }, +}; + +interface Harness { + panel: vscode.WebviewPanel; + hooks: WebviewPanelTestHooks; +} + +function openReport(rawJson = '{"raw":true}'): Harness { + let panel!: vscode.WebviewPanel; + let hooks!: WebviewPanelTestHooks; + + vi.mocked(vscode.window.createWebviewPanel).mockImplementation((...args) => { + const built = createMockWebviewPanel(...args); + panel = built.panel; + hooks = built.hooks; + return panel; + }); + + const factory = new NetcheckPanelFactory( + vscode.Uri.file("/ext"), + createMockLogger(), + ); + + factory.show({ host: "dev.coder.com", report: sampleReport }, rawJson); + return { panel, hooks }; +} + +// The shared panel mechanism (visibility/theme re-push, disposal, viewJson) is +// covered by resultPanel.test.ts; this only checks the netcheck-specific wiring. +describe("NetcheckPanelFactory", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("opens a titled webview and pushes the host and report after the webview signals ready", () => { + const { panel, hooks } = openReport(); + + expect(panel.viewType).toBe("coder.netcheckPanel"); + expect(panel.title).toBe("Network Check: dev.coder.com"); + expect(panel.webview.html).toContain("Network Check: dev.coder.com"); + expect(hooks.postedMessages).toEqual([]); + + hooks.sendFromWebview({ method: NetcheckApi.ready.method }); + + expect(hooks.postedMessages).toEqual([ + { + type: NetcheckApi.data.method, + data: { host: "dev.coder.com", report: sampleReport }, + }, + ]); + }); +}); diff --git a/test/unit/webviews/netcheck/types.test.ts b/test/unit/webviews/netcheck/types.test.ts new file mode 100644 index 0000000000..438edbf898 --- /dev/null +++ b/test/unit/webviews/netcheck/types.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { ZodError } from "zod"; + +import { parseNetcheckReport } from "@/webviews/netcheck/types"; + +// A trimmed-but-realistic `coder netcheck` payload, including CLI fields the +// parser ignores (node_info, client_logs, RegionV4Latency, ...). +import validReport from "./fixtures/netcheck-report.json"; + +describe("parseNetcheckReport", () => { + it("parses a realistic CLI payload", () => { + const report = parseNetcheckReport(JSON.stringify(validReport)); + + expect(report.derp.severity).toBe("ok"); + expect(Object.keys(report.derp.regions)).toEqual(["999", "1000"]); + expect(report.derp.regions["999"].region?.RegionName).toBe( + "Council Bluffs, Iowa", + ); + expect(report.derp.regions["999"].node_reports[0].round_trip_ping_ms).toBe( + 60, + ); + expect(report.derp.netcheck?.PreferredDERP).toBe(999); + expect(report.derp.netcheck?.RegionLatency["999"]).toBe(27706829); + expect(report.interfaces.interfaces).toHaveLength(2); + }); + + it("normalizes null warning lists to empty arrays", () => { + const report = parseNetcheckReport(JSON.stringify(validReport)); + expect(report.interfaces.warnings).toEqual([]); + expect(report.derp.warnings).toEqual([]); + }); + + it("drops null region entries", () => { + const withNullRegion = structuredClone(validReport) as Record< + string, + unknown + > & { derp: { regions: Record } }; + withNullRegion.derp.regions["1001"] = null; + + const report = parseNetcheckReport(JSON.stringify(withNullRegion)); + expect(Object.keys(report.derp.regions)).toEqual(["999", "1000"]); + }); + + it("tolerates a missing netcheck probe section", () => { + const withoutProbe = structuredClone(validReport) as { + derp: { netcheck?: unknown; netcheck_err?: string }; + }; + delete withoutProbe.derp.netcheck; + withoutProbe.derp.netcheck_err = "probe failed"; + + const report = parseNetcheckReport(JSON.stringify(withoutProbe)); + expect(report.derp.netcheck).toBeUndefined(); + expect(report.derp.netcheck_err).toBe("probe failed"); + }); + + it("preserves warning codes and messages", () => { + const withWarnings = structuredClone(validReport) as { + derp: { severity: string; warnings: unknown[] }; + }; + withWarnings.derp.severity = "warning"; + withWarnings.derp.warnings = [ + { code: "EDERP01", message: "Region latency is high" }, + ]; + + const report = parseNetcheckReport(JSON.stringify(withWarnings)); + expect(report.derp.warnings).toEqual([ + { code: "EDERP01", message: "Region latency is high" }, + ]); + }); + + it("rolls a region error up into the derp severity", () => { + const r = structuredClone(validReport) as { + derp: { + severity: string; + regions: Record; + }; + }; + r.derp.severity = "ok"; + r.derp.regions["999"].severity = "ok"; + r.derp.regions["999"].error = "region check panicked"; + + const report = parseNetcheckReport(JSON.stringify(r)); + expect(report.derp.regions["999"].severity).toBe("error"); + expect(report.derp.severity).toBe("error"); + }); + + it("escalates section severity from netcheck_err and interfaces error", () => { + const r = structuredClone(validReport) as { + derp: { severity: string; netcheck?: unknown; netcheck_err?: string }; + interfaces: { severity: string; error?: string }; + }; + r.derp.severity = "ok"; + delete r.derp.netcheck; + r.derp.netcheck_err = "probe failed"; + r.interfaces.severity = "ok"; + r.interfaces.error = "interface enumeration failed"; + + const report = parseNetcheckReport(JSON.stringify(r)); + expect(report.derp.severity).toBe("error"); + expect(report.interfaces.severity).toBe("error"); + }); + + it("escalates derp severity from a section error alone", () => { + const r = structuredClone(validReport) as { + derp: { severity: string; error?: string }; + }; + r.derp.severity = "ok"; + r.derp.error = "DERP map unreachable"; + + const report = parseNetcheckReport(JSON.stringify(r)); + expect(report.derp.severity).toBe("error"); + }); + + it("throws ZodError when a required field is missing", () => { + const missingSeverity = structuredClone(validReport) as { + derp: { severity?: string }; + }; + delete missingSeverity.derp.severity; + expect(() => parseNetcheckReport(JSON.stringify(missingSeverity))).toThrow( + ZodError, + ); + }); + + it("throws ZodError when a field has the wrong type", () => { + const wrongType = structuredClone(validReport) as { + derp: { netcheck: { UDP: unknown } }; + }; + wrongType.derp.netcheck.UDP = "yes"; + expect(() => parseNetcheckReport(JSON.stringify(wrongType))).toThrow( + ZodError, + ); + }); + + it("throws SyntaxError on malformed JSON", () => { + expect(() => parseNetcheckReport("not json")).toThrow(SyntaxError); + }); +}); diff --git a/test/unit/webviews/resultPanel.test.ts b/test/unit/webviews/resultPanel.test.ts new file mode 100644 index 0000000000..0133b29630 --- /dev/null +++ b/test/unit/webviews/resultPanel.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { showResultPanel } from "@/webviews/resultPanel"; + +import { + createMockLogger, + createMockWebviewPanel, + setActiveColorTheme, + type WebviewPanelTestHooks, +} from "../../mocks/testHelpers"; + +const READY = "test/ready"; +const VIEW_JSON = "test/viewJson"; +const DATA = "test/data"; +const payload = { value: 1 }; + +interface Harness { + panel: vscode.WebviewPanel; + hooks: WebviewPanelTestHooks; +} + +function open(rawJson = '{"raw":true}'): Harness { + let panel!: vscode.WebviewPanel; + let hooks!: WebviewPanelTestHooks; + + vi.mocked(vscode.window.createWebviewPanel).mockImplementation((...args) => { + const built = createMockWebviewPanel(...args); + panel = built.panel; + hooks = built.hooks; + return panel; + }); + + showResultPanel({ + extensionUri: vscode.Uri.file("/ext"), + logger: createMockLogger(), + viewType: "coder.testPanel", + webviewName: "test", + title: "Test Panel", + rawJson, + jsonErrorLabel: "test", + notify: (webview) => { + void webview.postMessage({ type: DATA, data: payload }); + }, + buildHandlers: ({ sendData, openRawJson }) => ({ + commands: { + [READY]: () => sendData(), + [VIEW_JSON]: () => { + void openRawJson(); + }, + }, + requests: {}, + }), + }); + return { panel, hooks }; +} + +describe("showResultPanel", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("opens a titled webview and pushes the payload only after the webview signals ready", () => { + const { panel, hooks } = open(); + + expect(panel.viewType).toBe("coder.testPanel"); + expect(panel.title).toBe("Test Panel"); + expect(panel.webview.html).toContain("Test Panel"); + expect(hooks.postedMessages).toEqual([]); + + hooks.sendFromWebview({ method: READY }); + + expect(hooks.postedMessages).toEqual([{ type: DATA, data: payload }]); + }); + + it("re-pushes the payload when the panel returns to visible", () => { + const { hooks } = open(); + hooks.sendFromWebview({ method: READY }); + const before = hooks.postedMessages.length; + + hooks.setVisible(true); + + expect(hooks.postedMessages.length - before).toBe(1); + }); + + it("does not push while the panel is hidden", () => { + const { hooks } = open(); + hooks.sendFromWebview({ method: READY }); + const before = hooks.postedMessages.length; + + hooks.setVisible(false); + + expect(hooks.postedMessages.length).toBe(before); + }); + + it("re-pushes the payload on theme change while visible", () => { + const { hooks } = open(); + hooks.sendFromWebview({ method: READY }); + const before = hooks.postedMessages.length; + + setActiveColorTheme(vscode.ColorThemeKind.Light); + + expect(hooks.postedMessages.length - before).toBe(1); + }); + + it("opens the raw JSON beside the panel on the viewJson command", async () => { + const doc = { uri: vscode.Uri.file("/tmp/doc") } as vscode.TextDocument; + vi.mocked(vscode.workspace.openTextDocument).mockResolvedValue(doc); + + const { hooks } = open('{"ok":1}'); + hooks.sendFromWebview({ method: VIEW_JSON }); + + await vi.waitFor(() => + expect(vscode.window.showTextDocument).toHaveBeenCalledWith( + doc, + vscode.ViewColumn.Beside, + ), + ); + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith({ + content: '{"ok":1}', + language: "json", + }); + }); + + it("does not surface an error dialog for unknown command methods", async () => { + const { hooks } = open(); + hooks.sendFromWebview({ method: "test/bogus" }); + // Dispatch is async; let the rejection settle before asserting. + await Promise.resolve(); + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); + }); + + it("stops responding to visibility and theme events after disposal", () => { + const { hooks } = open(); + hooks.fireDispose(); + const before = hooks.postedMessages.length; + + hooks.setVisible(true); + setActiveColorTheme(vscode.ColorThemeKind.Light); + + expect(hooks.postedMessages.length).toBe(before); + }); +}); diff --git a/test/unit/webviews/speedtest/fixtures/speedtest-result.json b/test/unit/webviews/speedtest/fixtures/speedtest-result.json new file mode 100644 index 0000000000..c38849e69d --- /dev/null +++ b/test/unit/webviews/speedtest/fixtures/speedtest-result.json @@ -0,0 +1,11 @@ +{ + "overall": { + "start_time_seconds": 0, + "end_time_seconds": 5, + "throughput_mbits": 100 + }, + "intervals": [ + { "start_time_seconds": 0, "end_time_seconds": 1, "throughput_mbits": 95 }, + { "start_time_seconds": 1, "end_time_seconds": 2, "throughput_mbits": 105 } + ] +} diff --git a/test/unit/webviews/speedtest/speedtestPanelFactory.test.ts b/test/unit/webviews/speedtest/speedtestPanelFactory.test.ts index 2f8e515156..4db0d242fb 100644 --- a/test/unit/webviews/speedtest/speedtestPanelFactory.test.ts +++ b/test/unit/webviews/speedtest/speedtestPanelFactory.test.ts @@ -8,7 +8,6 @@ import { type SpeedtestResult, SpeedtestApi } from "@repo/shared"; import { createMockLogger, createMockWebviewPanel, - setActiveColorTheme, type WebviewPanelTestHooks, } from "../../../mocks/testHelpers"; @@ -53,12 +52,14 @@ function openChart(rawJson = '{"raw":true}'): Harness { return { panel, hooks }; } +// The shared panel mechanism (visibility/theme re-push, disposal, viewJson) is +// covered by resultPanel.test.ts; this only checks the speedtest-specific wiring. describe("SpeedtestPanelFactory", () => { beforeEach(() => { vi.resetAllMocks(); }); - it("opens a titled webview with HTML and pushes the payload after the webview signals ready", () => { + it("opens a titled webview and pushes the workspace result after the webview signals ready", () => { const { panel, hooks } = openChart(); expect(panel.viewType).toBe("coder.speedtestPanel"); @@ -75,72 +76,4 @@ describe("SpeedtestPanelFactory", () => { }, ]); }); - - it("re-pushes the payload when the panel returns to visible", () => { - const { hooks } = openChart(); - hooks.sendFromWebview({ method: SpeedtestApi.ready.method }); - const before = hooks.postedMessages.length; - - hooks.setVisible(true); - - expect(hooks.postedMessages.length - before).toBe(1); - }); - - it("does not push while the panel is hidden", () => { - const { hooks } = openChart(); - hooks.sendFromWebview({ method: SpeedtestApi.ready.method }); - const before = hooks.postedMessages.length; - - hooks.setVisible(false); - - expect(hooks.postedMessages.length).toBe(before); - }); - - it("re-pushes the payload on theme change while visible", () => { - const { hooks } = openChart(); - hooks.sendFromWebview({ method: SpeedtestApi.ready.method }); - const before = hooks.postedMessages.length; - - setActiveColorTheme(vscode.ColorThemeKind.Light); - - expect(hooks.postedMessages.length - before).toBe(1); - }); - - it("opens the raw JSON beside when the webview requests viewJson", async () => { - const doc = { uri: vscode.Uri.file("/tmp/doc") } as vscode.TextDocument; - vi.mocked(vscode.workspace.openTextDocument).mockResolvedValue(doc); - - const { hooks } = openChart('{"ok":1}'); - hooks.sendFromWebview({ method: SpeedtestApi.viewJson.method }); - - await vi.waitFor(() => - expect(vscode.window.showTextDocument).toHaveBeenCalledWith( - doc, - vscode.ViewColumn.Beside, - ), - ); - expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith({ - content: '{"ok":1}', - language: "json", - }); - }); - - it("does not surface an error dialog for unknown command methods", async () => { - const { hooks } = openChart(); - hooks.sendFromWebview({ method: "speedtest/bogus" }); - // Dispatch is async; let the rejection settle before asserting. - await Promise.resolve(); - expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); - }); - - it("stops responding to visibility and theme events after disposal", () => { - const { hooks } = openChart(); - hooks.fireDispose(); - const before = hooks.postedMessages.length; - - hooks.setVisible(true); - setActiveColorTheme(vscode.ColorThemeKind.Light); - - expect(hooks.postedMessages.length).toBe(before); - }); }); diff --git a/test/unit/webviews/speedtest/types.test.ts b/test/unit/webviews/speedtest/types.test.ts index fd847ec606..32f2abd7c2 100644 --- a/test/unit/webviews/speedtest/types.test.ts +++ b/test/unit/webviews/speedtest/types.test.ts @@ -3,21 +3,11 @@ import { ZodError } from "zod"; import { parseSpeedtestResult } from "@/webviews/speedtest/types"; -const validJson = JSON.stringify({ - overall: { - start_time_seconds: 0, - end_time_seconds: 5, - throughput_mbits: 100, - }, - intervals: [ - { start_time_seconds: 0, end_time_seconds: 1, throughput_mbits: 95 }, - { start_time_seconds: 1, end_time_seconds: 2, throughput_mbits: 105 }, - ], -}); +import validResult from "./fixtures/speedtest-result.json"; describe("parseSpeedtestResult", () => { it("returns parsed data for a valid payload", () => { - const result = parseSpeedtestResult(validJson); + const result = parseSpeedtestResult(JSON.stringify(validResult)); expect(result.overall.throughput_mbits).toBe(100); expect(result.intervals).toHaveLength(2); }); diff --git a/test/webview/netcheck/connectivity.test.ts b/test/webview/netcheck/connectivity.test.ts new file mode 100644 index 0000000000..75358691b1 --- /dev/null +++ b/test/webview/netcheck/connectivity.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { buildConnectivityItems } from "@repo/netcheck/connectivity"; + +import { report } from "./fixtures"; + +describe("buildConnectivityItems", () => { + it("returns nothing when the probe section is missing", () => { + expect(buildConnectivityItems(report())).toEqual([]); + }); + + it("derives tones and labels from the probe", () => { + const items = buildConnectivityItems( + report({ + derp: { + severity: "ok", + warnings: [], + regions: { + "999": { + severity: "ok", + region: { + RegionID: 999, + RegionName: "Embedded", + EmbeddedRelay: true, + }, + node_reports: [], + }, + }, + netcheck: { + UDP: false, + IPv4: true, + IPv6: false, + MappingVariesByDestIP: true, + HairPinning: null, + UPnP: true, + PMP: false, + PCP: true, + PreferredDERP: 999, + RegionLatency: {}, + }, + }, + }), + ); + + const byLabel = Object.fromEntries(items.map((i) => [i.label, i])); + expect(byLabel["UDP"]).toMatchObject({ value: "Blocked", tone: "bad" }); + expect(byLabel["IPv4"]).toMatchObject({ value: "Yes", tone: "good" }); + expect(byLabel["IPv6"]).toMatchObject({ value: "No", tone: "neutral" }); + expect(byLabel["NAT mapping"]).toMatchObject({ + value: "Varies by destination (hard NAT)", + tone: "warn", + }); + expect(byLabel["Hairpinning"]).toMatchObject({ + value: "Unknown", + tone: "neutral", + }); + expect(byLabel["Port mapping"]).toMatchObject({ + value: "UPnP, PCP", + tone: "good", + }); + expect(byLabel["Preferred relay"]).toMatchObject({ value: "Embedded" }); + }); + + it("distinguishes an undetermined port mapping probe from none detected", () => { + const portMapping = (fields: { + UPnP?: boolean | null; + PMP?: boolean | null; + PCP?: boolean | null; + }) => { + const items = buildConnectivityItems( + report({ + derp: { + netcheck: { + UDP: true, + IPv4: true, + IPv6: false, + PreferredDERP: 0, + RegionLatency: {}, + ...fields, + }, + }, + }), + ); + return items.find((i) => i.label === "Port mapping"); + }; + + expect(portMapping({ UPnP: null, PMP: null, PCP: null })).toMatchObject({ + value: "Unknown", + tone: "neutral", + }); + expect(portMapping({ UPnP: false, PMP: false, PCP: false })).toMatchObject({ + value: "None detected", + tone: "neutral", + }); + }); + + it("omits the preferred relay when PreferredDERP is the 0 sentinel", () => { + const items = buildConnectivityItems( + report({ + derp: { + netcheck: { + UDP: true, + IPv4: true, + IPv6: false, + PreferredDERP: 0, + RegionLatency: {}, + }, + }, + }), + ); + expect(items.find((i) => i.label === "Preferred relay")).toBeUndefined(); + }); +}); diff --git a/test/webview/netcheck/fixtures.ts b/test/webview/netcheck/fixtures.ts new file mode 100644 index 0000000000..b8fb4a5145 --- /dev/null +++ b/test/webview/netcheck/fixtures.ts @@ -0,0 +1,21 @@ +import type { NetcheckReport } from "@repo/shared"; + +export function report(overrides?: { + derp?: Partial; + interfaces?: Partial; +}): NetcheckReport { + return { + derp: { + severity: "ok", + warnings: [], + regions: {}, + ...overrides?.derp, + }, + interfaces: { + severity: "ok", + warnings: [], + interfaces: [], + ...overrides?.interfaces, + }, + }; +} diff --git a/test/webview/netcheck/format.test.ts b/test/webview/netcheck/format.test.ts new file mode 100644 index 0000000000..8ab9b15486 --- /dev/null +++ b/test/webview/netcheck/format.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { + formatLatency, + formatTriState, + nanosToMs, +} from "@repo/netcheck/format"; + +describe("formatLatency", () => { + it("formats missing, sub-millisecond, fractional, and large values", () => { + expect(formatLatency(undefined)).toBe("—"); + expect(formatLatency(0.4)).toBe("<1 ms"); + expect(formatLatency(27.706829)).toBe("27.7 ms"); + expect(formatLatency(251.640563)).toBe("252 ms"); + }); +}); + +describe("nanosToMs", () => { + it("converts nanoseconds to milliseconds", () => { + expect(nanosToMs(27706829)).toBeCloseTo(27.706829); + }); +}); + +describe("formatTriState", () => { + it("maps yes/no/unknown to capability labels", () => { + expect(formatTriState("yes")).toBe("Yes"); + expect(formatTriState("no")).toBe("Failed"); + expect(formatTriState("unknown")).toBe("—"); + }); +}); diff --git a/test/webview/netcheck/health.test.ts b/test/webview/netcheck/health.test.ts new file mode 100644 index 0000000000..fed0ec7597 --- /dev/null +++ b/test/webview/netcheck/health.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import { collectIssues, sectionSummary } from "@repo/netcheck/health"; + +import { report } from "./fixtures"; + +describe("sectionSummary", () => { + it("summarizes severity with warning counts", () => { + expect(sectionSummary({ severity: "ok", warnings: [] })).toBe("healthy"); + expect( + sectionSummary({ + severity: "warning", + warnings: [{ code: "X", message: "m" }], + }), + ).toBe("1 warning"); + expect( + sectionSummary({ + severity: "warning", + warnings: [ + { code: "X", message: "m" }, + { code: "Y", message: "n" }, + ], + }), + ).toBe("2 warnings"); + expect(sectionSummary({ severity: "error", warnings: [] })).toBe("error"); + }); +}); + +describe("collectIssues", () => { + it("lists section errors before warnings", () => { + const issues = collectIssues( + report({ + derp: { + severity: "warning", + warnings: [{ code: "EDERP01", message: "latency is high" }], + netcheck_err: "probe failed", + }, + interfaces: { + severity: "warning", + warnings: [{ code: "EIF01", message: "MTU is low" }], + }, + }), + ); + + expect(issues).toEqual([ + { kind: "error", message: "probe failed" }, + { kind: "warning", code: "EDERP01", message: "latency is high" }, + { kind: "warning", code: "EIF01", message: "MTU is low" }, + ]); + }); + + it("lists region errors prefixed, and section warnings only once", () => { + const issues = collectIssues( + report({ + derp: { + severity: "error", + // The CLI folds region/node warnings into the section list. + warnings: [{ code: "ERLY", message: "high latency" }], + regions: { + "5": { + severity: "error", + error: "region unreachable", + region: { + RegionID: 5, + RegionName: "Tokyo", + EmbeddedRelay: false, + }, + node_reports: [], + }, + }, + }, + }), + ); + + expect(issues).toEqual([ + { kind: "error", message: "Tokyo: region unreachable" }, + { kind: "warning", code: "ERLY", message: "high latency" }, + ]); + }); + + it("synthesizes an issue for a region whose node is unhealthy but carries no message", () => { + const issues = collectIssues( + report({ + derp: { + regions: { + "3": { + severity: "error", + region: { + RegionID: 3, + RegionName: "Frankfurt", + EmbeddedRelay: true, + }, + node_reports: [ + { + can_exchange_messages: false, + round_trip_ping_ms: 0, + stun: { Enabled: true, CanSTUN: true }, + }, + ], + }, + }, + }, + }), + ); + + expect(issues).toEqual([ + { kind: "error", message: "Frankfurt: a node failed its health check" }, + ]); + }); + + it("returns nothing for a healthy report", () => { + expect(collectIssues(report())).toEqual([]); + }); +}); diff --git a/test/webview/netcheck/page.test.ts b/test/webview/netcheck/page.test.ts new file mode 100644 index 0000000000..0fb0a031a0 --- /dev/null +++ b/test/webview/netcheck/page.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import { renderPage } from "@repo/netcheck/page"; + +import { report } from "./fixtures"; + +import type { NetcheckData } from "@repo/shared"; + +function mount(els: HTMLElement[]): HTMLElement { + const root = document.createElement("div"); + root.append(...els); + return root; +} + +const headings = (root: HTMLElement) => + [...root.querySelectorAll("h2")].map((h) => h.textContent); + +function golden(): NetcheckData { + return { + host: "coder.example.com", + report: report({ + derp: { + severity: "warning", + warnings: [{ code: "EDERP01", message: "latency is high" }], + regions: { + "1": { + severity: "ok", + region: { + RegionID: 1, + RegionName: "New York", + EmbeddedRelay: true, + }, + node_reports: [ + { + can_exchange_messages: true, + round_trip_ping_ms: 12, + stun: { Enabled: true, CanSTUN: true }, + node: { STUNOnly: false }, + }, + ], + }, + }, + netcheck: { + UDP: true, + IPv4: true, + IPv6: false, + PreferredDERP: 1, + RegionLatency: { "1": 12_000_000 }, + }, + }, + interfaces: { + severity: "ok", + warnings: [], + interfaces: [{ name: "eth0", mtu: 1500, addresses: ["10.0.0.2"] }], + }, + }), + }; +} + +describe("renderPage", () => { + it("renders every section with the host, badges, and the View JSON action", () => { + const root = mount(renderPage(golden(), () => undefined)); + + expect(root.querySelector("h1")?.textContent).toBe("coder.example.com"); + expect(headings(root)).toEqual([ + "Issues", + "Connectivity", + "DERP relay regions", + "Local interfaces", + ]); + expect( + [...root.querySelectorAll(".badge")].map((b) => b.textContent), + ).toEqual(["Preferred", "Embedded"]); + expect(root.querySelector(".actions button")?.textContent).toBe( + "View JSON", + ); + }); + + it("shows empty-state messages and omits Issues for a healthy, empty report", () => { + const root = mount( + renderPage({ host: "h", report: report() }, () => undefined), + ); + + // Connectivity, regions, and interfaces each render their empty state. + expect(root.querySelectorAll("p.empty")).toHaveLength(3); + expect(headings(root)).not.toContain("Issues"); + }); +}); diff --git a/test/webview/netcheck/regions.test.ts b/test/webview/netcheck/regions.test.ts new file mode 100644 index 0000000000..35536e7c2b --- /dev/null +++ b/test/webview/netcheck/regions.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; + +import { buildRegionRows } from "@repo/netcheck/regions"; + +import { report } from "./fixtures"; + +describe("buildRegionRows", () => { + const baseNode = { + can_exchange_messages: true, + round_trip_ping_ms: 60, + stun: { Enabled: false, CanSTUN: false }, + node: { STUNOnly: false }, + } as const; + + it("derives latency from the probe, falling back to node round trips", () => { + const rows = buildRegionRows( + report({ + derp: { + severity: "ok", + warnings: [], + regions: { + "1": { + severity: "ok", + region: { + RegionID: 1, + RegionName: "Probed", + EmbeddedRelay: false, + }, + node_reports: [baseNode], + }, + "2": { + severity: "ok", + region: { + RegionID: 2, + RegionName: "Pinged", + EmbeddedRelay: false, + }, + node_reports: [baseNode], + }, + }, + netcheck: { + UDP: true, + IPv4: true, + IPv6: false, + PreferredDERP: 0, + RegionLatency: { "1": 30_000_000 }, + }, + }, + }), + ); + + const probed = rows.find((r) => r.name === "Probed"); + const pinged = rows.find((r) => r.name === "Pinged"); + expect(probed?.latencyMs).toBe(30); + expect(pinged?.latencyMs).toBe(60); + }); + + it("sorts the preferred region first, then by latency", () => { + const region = (id: number, name: string) => ({ + severity: "ok" as const, + region: { RegionID: id, RegionName: name, EmbeddedRelay: false }, + node_reports: [baseNode], + }); + const rows = buildRegionRows( + report({ + derp: { + severity: "ok", + warnings: [], + regions: { + "1": region(1, "Fast"), + "2": region(2, "Slow"), + "3": region(3, "Preferred"), + }, + netcheck: { + UDP: true, + IPv4: true, + IPv6: false, + PreferredDERP: 3, + RegionLatency: { + "1": 10_000_000, + "2": 90_000_000, + "3": 50_000_000, + }, + }, + }, + }), + ); + + expect(rows.map((r) => r.name)).toEqual(["Preferred", "Fast", "Slow"]); + expect(rows[0].preferred).toBe(true); + }); + + it("marks STUN-only regions as not relaying and reports STUN capability", () => { + const rows = buildRegionRows( + report({ + derp: { + severity: "ok", + warnings: [], + regions: { + "1000": { + severity: "ok", + region: { + RegionID: 1000, + RegionName: "STUN only", + EmbeddedRelay: false, + }, + node_reports: [ + { + can_exchange_messages: false, + round_trip_ping_ms: 0, + stun: { Enabled: true, CanSTUN: true }, + node: { STUNOnly: true }, + }, + ], + }, + }, + }, + }), + ); + + expect(rows[0]).toMatchObject({ + name: "STUN only", + stun: "yes", + relay: "unknown", + latencyMs: undefined, + }); + }); + + it("marks no region preferred when PreferredDERP is the 0 sentinel", () => { + const region = (id: number, name: string) => ({ + severity: "ok" as const, + region: { RegionID: id, RegionName: name, EmbeddedRelay: false }, + node_reports: [baseNode], + }); + const rows = buildRegionRows( + report({ + derp: { + severity: "ok", + warnings: [], + regions: { "0": region(0, "Zero"), "1": region(1, "One") }, + netcheck: { + UDP: true, + IPv4: true, + IPv6: false, + PreferredDERP: 0, + RegionLatency: {}, + }, + }, + }), + ); + + expect(rows.every((r) => !r.preferred)).toBe(true); + }); + + it("falls back to a numeric name when region metadata is missing", () => { + const rows = buildRegionRows( + report({ + derp: { + severity: "ok", + warnings: [], + regions: { + "7": { severity: "ok", node_reports: [] }, + }, + }, + }), + ); + expect(rows[0].name).toBe("Region 7"); + }); +}); diff --git a/test/webview/netcheck/severity.test.ts b/test/webview/netcheck/severity.test.ts new file mode 100644 index 0000000000..288e536423 --- /dev/null +++ b/test/webview/netcheck/severity.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { overallNetcheckSeverity } from "@repo/shared"; + +import { report } from "./fixtures"; + +describe("overallNetcheckSeverity", () => { + it("takes the worst of the two section severities", () => { + expect(overallNetcheckSeverity(report())).toBe("ok"); + expect( + overallNetcheckSeverity(report({ interfaces: { severity: "warning" } })), + ).toBe("warning"); + expect( + overallNetcheckSeverity(report({ derp: { severity: "error" } })), + ).toBe("error"); + }); +}); diff --git a/test/webview/shared/dom.test.ts b/test/webview/shared/dom.test.ts new file mode 100644 index 0000000000..35b8e244d1 --- /dev/null +++ b/test/webview/shared/dom.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + emptyMessage, + errorMessage, + viewJsonAction, +} from "@repo/webview-shared"; + +describe("viewJsonAction", () => { + it("renders a View JSON button that fires onClick", () => { + const onClick = vi.fn(); + const actions = viewJsonAction(onClick); + const button = actions.querySelector("button"); + + expect(actions.className).toBe("actions"); + expect(button?.textContent).toBe("View JSON"); + + button?.click(); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); + +describe("emptyMessage / errorMessage", () => { + it("render labeled paragraphs", () => { + const empty = emptyMessage("nothing here"); + expect(empty.className).toBe("empty"); + expect(empty.textContent).toBe("nothing here"); + + const error = errorMessage("it broke"); + expect(error.className).toBe("error"); + expect(error.textContent).toBe("it broke"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index a462f3647d..fd29ab7a53 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -44,6 +44,7 @@ export default defineConfig({ alias: { "@repo/webview-shared": webviewSharedAlias, "@repo/tasks": path.resolve(__dirname, "packages/tasks/src"), + "@repo/netcheck": path.resolve(__dirname, "packages/netcheck/src"), "@repo/speedtest": path.resolve( __dirname, "packages/speedtest/src",