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
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@
"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.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
153 changes: 153 additions & 0 deletions src/remote/networkStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import prettyBytes from "pretty-bytes";
import * as vscode from "vscode";

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

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

const WARNING_BACKGROUND = new vscode.ThemeColor(
"statusBarItem.warningBackground",
);

export interface NetworkThresholds {
latencyMs: number;
}

function getThresholdConfig(): NetworkThresholds {
const cfg = vscode.workspace.getConfiguration("coder");
return {
latencyMs: cfg.get<number>("networkThreshold.latencyMs", 200),
};
}

export function isLatencySlow(
network: NetworkInfo,
thresholds: NetworkThresholds,
): boolean {
return thresholds.latencyMs > 0 && network.latency > thresholds.latencyMs;
}

export function buildNetworkTooltip(
network: NetworkInfo,
latencySlow: boolean,
thresholds: NetworkThresholds,
): vscode.MarkdownString {
const fmt = (bytesPerSec: number) =>
prettyBytes(bytesPerSec * 8, { bits: true }) + "/s";

const sections: string[] = [];

if (latencySlow) {
sections.push("$(warning) **Slow connection detected**");
}

const metrics: string[] = [];
metrics.push(
latencySlow
? `Latency: ${network.latency.toFixed(2)}ms (threshold: ${thresholds.latencyMs}ms)`
: `Latency: ${network.latency.toFixed(2)}ms`,
);
metrics.push(`Download: ${fmt(network.download_bytes_sec)}`);
metrics.push(`Upload: ${fmt(network.upload_bytes_sec)}`);

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

// Two trailing spaces + \n = hard line break (tight rows within a section).
sections.push(metrics.join(" \n"));

if (latencySlow) {
sections.push(
"[$(pulse) Ping workspace](command:coder.pingWorkspace) · " +
"[$(gear) Configure threshold](command:workbench.action.openSettings?%22coder.networkThreshold%22)",
);
}

// Blank line between sections = paragraph break.
const md = new vscode.MarkdownString(sections.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 and status bar updates.
*/
export class NetworkStatusReporter {
private warningCounter = 0;
private isWarningActive = false;

constructor(private readonly statusBarItem: vscode.StatusBarItem) {}

update(network: NetworkInfo, isStale: boolean): void {
let statusText = "$(globe) ";

// Coder Connect doesn't populate any other stats
if (network.using_coder_connect) {
this.warningCounter = 0;
this.isWarningActive = false;
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 latencySlow = isLatencySlow(network, thresholds);
this.updateWarningState(latencySlow);

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;

if (this.isWarningActive) {
this.statusBarItem.backgroundColor = WARNING_BACKGROUND;
this.statusBarItem.command = "coder.pingWorkspace";
} else {
this.statusBarItem.backgroundColor = undefined;
this.statusBarItem.command = undefined;
}

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

this.statusBarItem.show();
}

private updateWarningState(latencySlow: boolean): void {
if (latencySlow) {
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;
}
}
}
86 changes: 12 additions & 74 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 @@ -457,48 +461,39 @@ export class SshProcessMonitor implements vscode.Disposable {

while (!this.disposed && this.currentPid !== undefined) {
const filePath = path.join(networkInfoPath, `${this.currentPid}.json`);
let search: { needed: true; reason: string } | { needed: false } = {
needed: false,
};
let searchReason: string | undefined;

try {
const stats = await fs.stat(filePath);
const ageMs = Date.now() - stats.mtime.getTime();
readFailures = 0;

if (ageMs > staleThreshold) {
search = {
needed: true,
reason: `Network info stale (${Math.round(ageMs / 1000)}s old)`,
};
searchReason = `Network info stale (${Math.round(ageMs / 1000)}s old)`;
} else {
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++;
logger.debug(
`Failed to read network info (attempt ${readFailures}): ${(error as Error).message}`,
);
if (readFailures >= maxReadFailures) {
search = {
needed: true,
reason: `Network info missing for ${readFailures} attempts`,
};
searchReason = `Network info missing for ${readFailures} attempts`;
}
}

// Search for new process if needed (with throttling)
if (search.needed) {
if (searchReason !== undefined) {
const timeSinceLastSearch = Date.now() - this.lastStaleSearchTime;
if (timeSinceLastSearch < staleThreshold) {
await this.delay(staleThreshold - timeSinceLastSearch);
continue;
}

logger.debug(`${search.reason}, searching for new SSH process`);
logger.debug(`${searchReason}, searching for new SSH process`);
// searchForProcess will update PID if a different process is found
this.lastStaleSearchTime = Date.now();
await this.searchForProcess();
Expand All @@ -508,63 +503,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
21 changes: 20 additions & 1 deletion test/mocks/vscode.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ export const InputBoxValidationSeverity = E({
Error: 3,
});

export class MarkdownString {
value: string;
isTrusted = false;
supportThemeIcons = false;

constructor(value = "") {
this.value = value;
}
}

export class ThemeColor {
id: string;
constructor(id: string) {
this.id = id;
}
}

export class Uri {
constructor(
public scheme: string,
Expand Down Expand Up @@ -118,7 +135,7 @@ export const window = {
};

export const commands = {
registerCommand: vi.fn(),
registerCommand: vi.fn(() => ({ dispose: vi.fn() })),
executeCommand: vi.fn(),
};

Expand Down Expand Up @@ -170,6 +187,8 @@ const vscode = {
InputBoxValidationSeverity,
Uri,
EventEmitter,
MarkdownString,
ThemeColor,
window,
commands,
workspace,
Expand Down
Loading