Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,21 @@
"type": "boolean",
"default": false
},
"coder.networkThreshold.latencyMs": {
"markdownDescription": "Latency threshold in milliseconds. A warning indicator appears in the status bar when latency exceeds this value. Set to `0` to disable.",
"type": "number",
"default": 200
},
"coder.networkThreshold.downloadMbps": {
"markdownDescription": "Download speed threshold in Mbps. A warning indicator appears in the status bar when download speed drops below this value. Set to `0` to disable.",
"type": "number",
"default": 5
},
"coder.networkThreshold.uploadMbps": {
"markdownDescription": "Upload speed threshold in Mbps. A warning indicator appears in the status bar when upload speed drops below this value. Set to `0` to disable.",
"type": "number",
"default": 0
},
"coder.httpClientLogLevel": {
"markdownDescription": "Controls the verbosity of HTTP client logging. This affects what details are logged for each HTTP request and response.",
"type": "string",
Expand Down
258 changes: 258 additions & 0 deletions src/remote/networkStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import prettyBytes from "pretty-bytes";
import * as vscode from "vscode";

import type { NetworkInfo } from "./sshProcess";

/** Bytes per second in 1 Mbps */
const BYTES_PER_MBPS = 125_000;

/** Number of consecutive polls required to trigger or clear a warning */
const WARNING_DEBOUNCE_THRESHOLD = 3;

/**
* Tracks which network thresholds are currently violated.
*/
export interface ThresholdViolations {
latency: boolean;
download: boolean;
upload: boolean;
}

/**
* Reads the network threshold settings from the workspace configuration.
*/
export function getThresholdConfig(): {
latencyMs: number;
downloadMbps: number;
uploadMbps: number;
} {
const cfg = vscode.workspace.getConfiguration("coder");
return {
latencyMs: cfg.get<number>("networkThreshold.latencyMs", 200),
downloadMbps: cfg.get<number>("networkThreshold.downloadMbps", 5),
uploadMbps: cfg.get<number>("networkThreshold.uploadMbps", 0),
};
}

/**
* Checks which thresholds are violated for the given network info.
*/
export function checkThresholdViolations(
network: NetworkInfo,
thresholds: { latencyMs: number; downloadMbps: number; uploadMbps: number },
): ThresholdViolations {
return {
latency: thresholds.latencyMs > 0 && network.latency > thresholds.latencyMs,
download:
thresholds.downloadMbps > 0 &&
network.download_bytes_sec / BYTES_PER_MBPS < thresholds.downloadMbps,
upload:
thresholds.uploadMbps > 0 &&
network.upload_bytes_sec / BYTES_PER_MBPS < thresholds.uploadMbps,
};
}

/**
* Returns true if any threshold is violated.
*/
export function hasAnyViolation(violations: ThresholdViolations): boolean {
return violations.latency || violations.download || violations.upload;
}

/**
* Determines the appropriate command to run when the user clicks
* the status bar item in warning state.
*/
export function getWarningCommand(violations: ThresholdViolations): string {
const latencyOnly =
violations.latency && !violations.download && !violations.upload;
const throughputOnly =
!violations.latency && (violations.download || violations.upload);

if (latencyOnly) {
return "coder.pingWorkspace";
}
if (throughputOnly) {
return "coder.speedTest";
}
// Multiple types of violations — let the user choose
return "coder.showNetworkDiagnostics";
}

/**
* Builds a MarkdownString tooltip showing network metrics and threshold warnings.
*/
export function buildNetworkTooltip(
network: NetworkInfo,
violations: ThresholdViolations,
thresholds: { latencyMs: number; downloadMbps: number; uploadMbps: number },
isWarning: boolean,
): vscode.MarkdownString {
const fmt = (bytesPerSec: number) =>
prettyBytes(bytesPerSec * 8, { bits: true }) + "/s";

const lines: string[] = [];

// Latency
let latencyLine = `Latency: ${network.latency.toFixed(2)}ms`;
if (violations.latency) {
latencyLine += ` $(warning) (threshold: ${thresholds.latencyMs}ms)`;
}
lines.push(latencyLine);

// Download
let downloadLine = `Download: ${fmt(network.download_bytes_sec)}`;
if (violations.download) {
downloadLine += ` $(warning) (threshold: ${thresholds.downloadMbps} Mbit/s)`;
}
lines.push(downloadLine);

// Upload
let uploadLine = `Upload: ${fmt(network.upload_bytes_sec)}`;
if (violations.upload) {
uploadLine += ` $(warning) (threshold: ${thresholds.uploadMbps} Mbit/s)`;
}
lines.push(uploadLine);

// Connection type
if (network.using_coder_connect) {
lines.push("Connection: Coder Connect");
} else if (network.p2p) {
lines.push("Connection: Direct (P2P)");
} else {
lines.push(`Connection: ${network.preferred_derp} (relay)`);
}

if (isWarning) {
lines.push("");
lines.push(
"_Click for diagnostics_ | [Configure thresholds](command:workbench.action.openSettings?%22coder.networkThreshold%22)",
);
}

const md = new vscode.MarkdownString(lines.join("\n\n"));
md.isTrusted = true;
md.supportThemeIcons = true;
return md;
}

/**
* Manages network status bar presentation and slowness warning state.
* Owns the warning debounce logic, status bar updates, and the
* diagnostics command registration.
*/
export class NetworkStatusReporter implements vscode.Disposable {
private warningCounter = 0;
private isWarningActive = false;
private readonly diagnosticsCommand: vscode.Disposable;

constructor(private readonly statusBarItem: vscode.StatusBarItem) {
this.diagnosticsCommand = vscode.commands.registerCommand(
"coder.showNetworkDiagnostics",
async () => {
const pick = await vscode.window.showQuickPick(
[
{ label: "Run Ping", commandId: "coder.pingWorkspace" },
{ label: "Run Speed Test", commandId: "coder.speedTest" },
{
label: "Create Support Bundle",
commandId: "coder.supportBundle",
},
],
{ placeHolder: "Select a diagnostic to run" },
);
if (pick) {
await vscode.commands.executeCommand(pick.commandId);
}
},
);
}

/**
* Updates the status bar with network information and warning state.
*/
update(network: NetworkInfo, isStale: boolean): void {
let statusText = "$(globe) ";

// Coder Connect doesn't populate any other stats
if (network.using_coder_connect) {
this.statusBarItem.text = statusText + "Coder Connect ";
this.statusBarItem.tooltip = "You're connected using Coder Connect.";
this.statusBarItem.backgroundColor = undefined;
this.statusBarItem.command = undefined;
this.statusBarItem.show();
return;
}

const thresholds = getThresholdConfig();
const violations = checkThresholdViolations(network, thresholds);
const activeViolations = this.updateWarningState(violations);

if (network.p2p) {
statusText += "Direct ";
} else {
statusText += network.preferred_derp + " ";
}

const latencyText = isStale
? `(~${network.latency.toFixed(2)}ms)`
: `(${network.latency.toFixed(2)}ms)`;
statusText += latencyText;
this.statusBarItem.text = statusText;

const isWarning = this.isWarningActive;
if (isWarning) {
this.statusBarItem.backgroundColor = new vscode.ThemeColor(
"statusBarItem.warningBackground",
);
this.statusBarItem.command = getWarningCommand(activeViolations);
} else {
this.statusBarItem.backgroundColor = undefined;
this.statusBarItem.command = undefined;
}

this.statusBarItem.tooltip = buildNetworkTooltip(
network,
activeViolations,
thresholds,
isWarning,
);

this.statusBarItem.show();
}

/**
* Updates the debounce counter and returns the effective violations
* (current violations when warning is active, all-clear otherwise).
*/
private updateWarningState(
violations: ThresholdViolations,
): ThresholdViolations {
const noViolations: ThresholdViolations = {
latency: false,
download: false,
upload: false,
};

if (hasAnyViolation(violations)) {
this.warningCounter = Math.min(
this.warningCounter + 1,
WARNING_DEBOUNCE_THRESHOLD,
);
} else {
this.warningCounter = Math.max(this.warningCounter - 1, 0);
}

if (this.warningCounter >= WARNING_DEBOUNCE_THRESHOLD) {
this.isWarningActive = true;
} else if (this.warningCounter === 0) {
this.isWarningActive = false;
}

return this.isWarningActive ? violations : noViolations;
}

dispose(): void {
this.diagnosticsCommand.dispose();
}
}
68 changes: 8 additions & 60 deletions src/remote/sshProcess.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import find from "find-process";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import prettyBytes from "pretty-bytes";
import * as vscode from "vscode";

import { type Logger } from "../logging/logger";
import { findPort } from "../util";

import { NetworkStatusReporter } from "./networkStatus";

import type { Logger } from "../logging/logger";

/**
* Network information from the Coder CLI.
*/
Expand Down Expand Up @@ -76,6 +78,7 @@ export class SshProcessMonitor implements vscode.Disposable {
private logFilePath: string | undefined;
private pendingTimeout: NodeJS.Timeout | undefined;
private lastStaleSearchTime = 0;
private readonly reporter: NetworkStatusReporter;

/**
* Helper to clean up files in a directory.
Expand Down Expand Up @@ -195,6 +198,7 @@ export class SshProcessMonitor implements vscode.Disposable {
vscode.StatusBarAlignment.Left,
1000,
);
this.reporter = new NetworkStatusReporter(this.statusBarItem);
}

/**
Expand Down Expand Up @@ -251,6 +255,7 @@ export class SshProcessMonitor implements vscode.Disposable {
this.pendingTimeout = undefined;
}
this.statusBarItem.dispose();
this.reporter.dispose();
this._onLogFilePathChange.dispose();
this._onPidChange.dispose();
}
Expand Down Expand Up @@ -475,7 +480,7 @@ export class SshProcessMonitor implements vscode.Disposable {
const content = await fs.readFile(filePath, "utf8");
const network = JSON.parse(content) as NetworkInfo;
const isStale = ageMs > networkPollInterval * 2;
this.updateStatusBar(network, isStale);
this.reporter.update(network, isStale);
}
} catch (error) {
readFailures++;
Expand Down Expand Up @@ -508,63 +513,6 @@ export class SshProcessMonitor implements vscode.Disposable {
await this.delay(networkPollInterval);
}
}

/**
* Updates the status bar with network information.
*/
private updateStatusBar(network: NetworkInfo, isStale: boolean): void {
let statusText = "$(globe) ";

// Coder Connect doesn't populate any other stats
if (network.using_coder_connect) {
this.statusBarItem.text = statusText + "Coder Connect ";
this.statusBarItem.tooltip = "You're connected using Coder Connect.";
this.statusBarItem.show();
return;
}

if (network.p2p) {
statusText += "Direct ";
this.statusBarItem.tooltip = "You're connected peer-to-peer ✨.";
} else {
statusText += network.preferred_derp + " ";
this.statusBarItem.tooltip =
"You're connected through a relay 🕵.\nWe'll switch over to peer-to-peer when available.";
}

let tooltip = this.statusBarItem.tooltip;
tooltip +=
"\n\nDownload ↓ " +
prettyBytes(network.download_bytes_sec, { bits: true }) +
"/s • Upload ↑ " +
prettyBytes(network.upload_bytes_sec, { bits: true }) +
"/s\n";

if (!network.p2p) {
const derpLatency = network.derp_latency[network.preferred_derp];
tooltip += `You ↔ ${derpLatency.toFixed(2)}ms ↔ ${network.preferred_derp} ↔ ${(network.latency - derpLatency).toFixed(2)}ms ↔ Workspace`;

let first = true;
for (const region of Object.keys(network.derp_latency)) {
if (region === network.preferred_derp) {
continue;
}
if (first) {
tooltip += `\n\nOther regions:`;
first = false;
}
tooltip += `\n${region}: ${Math.round(network.derp_latency[region] * 100) / 100}ms`;
}
}

this.statusBarItem.tooltip = tooltip;
const latencyText = isStale
? `(~${network.latency.toFixed(2)}ms)`
: `(${network.latency.toFixed(2)}ms)`;
statusText += latencyText;
this.statusBarItem.text = statusText;
this.statusBarItem.show();
}
}

/**
Expand Down
Loading