Skip to content

Commit e20388b

Browse files
committed
fix: address netcheck webview review feedback
- Fold netcheck_err/section errors into overall severity so the banner, telemetry, and Issues list agree - Distinguish an undetermined port mapping ("Unknown") from "None detected" - Treat PreferredDERP 0 as no preferred region in the regions table - Offer the raw output via a "View Output" action when parse/display fails - Record diagnostic success only after the panel shows - Rename display -> parseAndDisplay; align the display-failure message with the log - Share the View JSON / empty / error DOM builders via @repo/webview-shared - Drop redundant export/satisfies, extract the latency constant, unify table headers - Add tests for severity, port mapping, the preferred sentinel, page render, shared DOM builders, runDiagnosticCli branches, and the netcheck handler
1 parent 88890da commit e20388b

19 files changed

Lines changed: 627 additions & 74 deletions

File tree

packages/netcheck/src/connectivity.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,23 @@ function boolItem(
6868
}
6969

7070
function portMappingItem(probe: NetcheckConnectivity): ConnectivityItem {
71-
const protocols = [
72-
probe.UPnP ? "UPnP" : undefined,
73-
probe.PMP ? "NAT-PMP" : undefined,
74-
probe.PCP ? "PCP" : undefined,
75-
].filter((p): p is string => p !== undefined);
76-
return protocols.length > 0
77-
? { label: "Port mapping", value: protocols.join(", "), tone: "good" }
78-
: { label: "Port mapping", value: "None detected", tone: "neutral" };
71+
const fields = [
72+
[probe.UPnP, "UPnP"],
73+
[probe.PMP, "NAT-PMP"],
74+
[probe.PCP, "PCP"],
75+
] as const;
76+
const detected = fields.filter(([on]) => on).map(([, name]) => name);
77+
if (detected.length > 0) {
78+
return { label: "Port mapping", value: detected.join(", "), tone: "good" };
79+
}
80+
// A null field means "could not determine", so report "None detected" only
81+
// once a protocol was actually probed.
82+
const probed = fields.some(([on]) => typeof on === "boolean");
83+
return {
84+
label: "Port mapping",
85+
value: probed ? "None detected" : "Unknown",
86+
tone: "neutral",
87+
};
7988
}
8089

8190
function preferredRegionName(report: NetcheckReport): string | undefined {

packages/netcheck/src/format.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ export type TriState = "yes" | "no" | "unknown";
22

33
const NANOS_PER_MS = 1_000_000;
44

5+
/** Below this, show one decimal; at or above, round to whole ms. */
6+
const DECIMAL_PRECISION_BELOW_MS = 100;
7+
58
export function nanosToMs(nanos: number): number {
69
return nanos / NANOS_PER_MS;
710
}
@@ -13,7 +16,7 @@ export function formatLatency(ms: number | undefined): string {
1316
if (ms < 1) {
1417
return "<1 ms";
1518
}
16-
if (ms < 100) {
19+
if (ms < DECIMAL_PRECISION_BELOW_MS) {
1720
return `${ms.toFixed(1)} ms`;
1821
}
1922
return `${Math.round(ms)} ms`;

packages/netcheck/src/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { NetcheckApi, toError, type NetcheckData } from "@repo/shared";
2-
import { sendCommand, subscribeNotifications } from "@repo/webview-shared";
2+
import {
3+
errorMessage,
4+
sendCommand,
5+
subscribeNotifications,
6+
} from "@repo/webview-shared";
37
import "@repo/webview-shared/base.css";
48

59
import "./index.css";
6-
import { renderError, renderPage } from "./page";
10+
import { renderPage } from "./page";
711

812
function main(): void {
913
// The extension re-sends `data` on visibility/theme changes; each render
@@ -26,7 +30,7 @@ function render(data: NetcheckData): void {
2630
);
2731
} catch (err) {
2832
root.replaceChildren(
29-
renderError(`Failed to render network check: ${toError(err).message}`),
33+
errorMessage(`Failed to render network check: ${toError(err).message}`),
3034
);
3135
}
3236
}

packages/netcheck/src/page.ts

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type NetcheckReport,
66
type NetcheckSeverity,
77
} from "@repo/shared";
8+
import { emptyMessage, viewJsonAction } from "@repo/webview-shared";
89

910
import { buildConnectivityItems } from "./connectivity";
1011
import { formatLatency, formatTriState } from "./format";
@@ -34,7 +35,7 @@ export function renderPage(
3435
"Local interfaces",
3536
renderInterfaces(report.interfaces.interfaces),
3637
),
37-
renderActions(onViewJson),
38+
viewJsonAction(onViewJson),
3839
);
3940
return children;
4041
}
@@ -178,10 +179,10 @@ function renderTable<T>(
178179
return table;
179180
}
180181

181-
function renderTableHead(...labels: string[]): HTMLElement {
182+
function renderTableHead(...headers: string[]): HTMLElement {
182183
const thead = el("thead");
183184
const tr = el("tr");
184-
tr.append(...labels.map((label) => el("th", undefined, label)));
185+
tr.append(...headers.map((header) => el("th", undefined, header)));
185186
thead.append(tr);
186187
return thead;
187188
}
@@ -197,19 +198,6 @@ function renderSeverityCell(severity: NetcheckSeverity): HTMLTableCellElement {
197198
return td;
198199
}
199200

200-
function renderActions(onViewJson: () => void): HTMLElement {
201-
const actions = el("div", "actions");
202-
const viewBtn = el("button", undefined, "View JSON");
203-
viewBtn.addEventListener("click", onViewJson);
204-
actions.append(viewBtn);
205-
return actions;
206-
}
207-
208-
/** Renders a top-level error message in place of the report. */
209-
export function renderError(message: string): HTMLElement {
210-
return el("p", "error", message);
211-
}
212-
213201
/** Create an element with an optional class and text content. */
214202
function el<K extends keyof HTMLElementTagNameMap>(
215203
tag: K,
@@ -229,7 +217,3 @@ function el<K extends keyof HTMLElementTagNameMap>(
229217
function badge(text: string): HTMLElement {
230218
return el("span", "badge", text);
231219
}
232-
233-
function emptyMessage(text: string): HTMLElement {
234-
return el("p", "empty", text);
235-
}

packages/netcheck/src/regions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ function toRegionRow(
5454
name: regionName(region, id),
5555
severity: region.severity,
5656
latencyMs: regionLatencyMs(latencyNanos, relayNodes),
57-
preferred: id === preferredId,
57+
// 0 is the "undetermined" sentinel, not a real region id.
58+
preferred: Boolean(preferredId) && id === preferredId,
5859
embeddedRelay: region.region?.EmbeddedRelay ?? false,
5960
stun: anyTriState(stunNodes, (n) => n.stun.CanSTUN),
6061
relay: anyTriState(relayNodes, (n) => n.can_exchange_messages),

packages/shared/src/netcheck/utils.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,18 @@ const SEVERITY_RANK = {
66
error: 2,
77
} as const satisfies Record<NetcheckSeverity, number>;
88

9-
/** Worst severity across the DERP and interfaces sections. */
9+
/**
10+
* Worst severity across the report. A failed probe (`netcheck_err`) or section
11+
* error counts as an error even when the section severity wasn't raised, so the
12+
* banner agrees with the Issues list.
13+
*/
1014
export function overallNetcheckSeverity(
1115
report: NetcheckReport,
1216
): NetcheckSeverity {
1317
const { derp, interfaces } = report;
18+
if (derp.netcheck_err || derp.error || interfaces.error) {
19+
return "error";
20+
}
1421
return SEVERITY_RANK[derp.severity] >= SEVERITY_RANK[interfaces.severity]
1522
? derp.severity
1623
: interfaces.severity;

packages/speedtest/src/index.ts

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { SpeedtestApi, type SpeedtestResult, toError } from "@repo/shared";
2-
import { sendCommand, subscribeNotifications } from "@repo/webview-shared";
2+
import {
3+
emptyMessage,
4+
errorMessage,
5+
sendCommand,
6+
subscribeNotifications,
7+
viewJsonAction,
8+
} from "@repo/webview-shared";
39
import "@repo/webview-shared/base.css";
410

511
import { renderLineChart } from "./chart";
@@ -53,14 +59,14 @@ function renderPage(
5359

5460
const samples = toChartSamples(data.intervals);
5561
if (samples.length === 0) {
56-
root.appendChild(renderEmptyMessage());
57-
root.appendChild(renderActions(onViewJson));
62+
root.appendChild(emptyMessage("No samples returned from the speed test."));
63+
root.appendChild(viewJsonAction(onViewJson));
5864
return () => undefined;
5965
}
6066

6167
const chart = renderChart(samples);
6268
root.appendChild(chart.container);
63-
root.appendChild(renderActions(onViewJson));
69+
root.appendChild(viewJsonAction(onViewJson));
6470
return chart.cleanup;
6571
}
6672

@@ -191,32 +197,11 @@ function renderChart(samples: ChartPoint[]): {
191197
};
192198
}
193199

194-
function renderActions(onViewJson: () => void): HTMLElement {
195-
const actions = document.createElement("div");
196-
actions.className = "actions";
197-
const viewBtn = document.createElement("button");
198-
viewBtn.textContent = "View JSON";
199-
viewBtn.addEventListener("click", onViewJson);
200-
actions.appendChild(viewBtn);
201-
return actions;
202-
}
203-
204-
function renderEmptyMessage(): HTMLElement {
205-
const p = document.createElement("p");
206-
p.className = "empty";
207-
p.textContent = "No samples returned from the speed test.";
208-
return p;
209-
}
210-
211200
function showError(message: string): void {
212201
const root = document.getElementById("root");
213-
if (!root) {
214-
return;
202+
if (root) {
203+
root.replaceChildren(errorMessage(message));
215204
}
216-
const p = document.createElement("p");
217-
p.className = "error";
218-
p.textContent = message;
219-
root.replaceChildren(p);
220205
}
221206

222207
main();

packages/webview-shared/src/dom.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/** DOM builders shared by the vanilla webviews. */
2+
3+
/** An action bar with a "View JSON" button wired to `onClick`. */
4+
export function viewJsonAction(onClick: () => void): HTMLElement {
5+
const actions = document.createElement("div");
6+
actions.className = "actions";
7+
const button = document.createElement("button");
8+
button.textContent = "View JSON";
9+
button.addEventListener("click", onClick);
10+
actions.append(button);
11+
return actions;
12+
}
13+
14+
/** A message shown in place of a section or result that has no data. */
15+
export function emptyMessage(text: string): HTMLElement {
16+
return paragraph("empty", text);
17+
}
18+
19+
/** A top-level failure message. */
20+
export function errorMessage(text: string): HTMLElement {
21+
return paragraph("error", text);
22+
}
23+
24+
function paragraph(className: string, text: string): HTMLElement {
25+
const p = document.createElement("p");
26+
p.className = className;
27+
p.textContent = text;
28+
return p;
29+
}

packages/webview-shared/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@ export {
1414
sendCommand,
1515
subscribeNotifications,
1616
} from "./ipc";
17+
18+
// DOM builders shared by the vanilla webviews.
19+
export { viewJsonAction, emptyMessage, errorMessage } from "./dom";

src/command/diagnosticFlow.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ export interface DiagnosticCliOptions {
1515
progressTitle: string;
1616
/** Invoke the CLI under the progress notification; resolves to raw JSON. */
1717
exec: (ctx: ProgressContext) => Promise<string>;
18-
/** Parse the output, record success telemetry, and show the results. */
19-
display: (rawJson: string) => void;
18+
/**
19+
* Parse the output, record success telemetry, and show the results. Parsing
20+
* happens here so parse failures map to the `parse_error` telemetry path.
21+
*/
22+
parseAndDisplay: (rawJson: string) => void;
2023
}
2124

2225
/**
@@ -48,21 +51,49 @@ export async function runDiagnosticCli(
4851
return;
4952
}
5053

54+
// On a display failure, keep the report the user waited for recoverable by
55+
// offering its raw output behind a "View Output" action.
56+
const offerRawOutput = (message: string) => {
57+
void vscode.window
58+
.showErrorMessage(message, "View Output")
59+
.then((choice) => {
60+
if (choice === "View Output") {
61+
void openRawOutput(result.value, name, logger);
62+
}
63+
});
64+
};
65+
5166
try {
52-
options.display(result.value);
67+
options.parseAndDisplay(result.value);
5368
} catch (err) {
5469
if (err instanceof ZodError || err instanceof SyntaxError) {
5570
telemetry.error("parse_error");
5671
logger.error(`Failed to parse ${name} output`, err);
57-
vscode.window.showErrorMessage(
72+
offerRawOutput(
5873
`${name} output did not match the expected format. Check \`Output > Coder\` for details.`,
5974
);
6075
return;
6176
}
6277
telemetry.error();
6378
logger.error(`Failed to display ${name} results`, err);
64-
vscode.window.showErrorMessage(
65-
`${name} returned unexpected output: ${toError(err).message}`,
79+
offerRawOutput(
80+
`${name} could not display its results: ${toError(err).message}`,
6681
);
6782
}
6883
}
84+
85+
async function openRawOutput(
86+
rawJson: string,
87+
name: string,
88+
logger: Logger,
89+
): Promise<void> {
90+
try {
91+
const doc = await vscode.workspace.openTextDocument({
92+
content: rawJson,
93+
language: "json",
94+
});
95+
await vscode.window.showTextDocument(doc, vscode.ViewColumn.Beside);
96+
} catch (err) {
97+
logger.error(`Failed to open ${name} output`, err);
98+
}
99+
}

0 commit comments

Comments
 (0)