From 4ba577333e0836460923feb09d8f17d7e424f11a Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 16 Jul 2025 23:35:23 +0300 Subject: [PATCH 1/3] Add agent metadata statusbar to monitor resource usage --- src/agentMetadataHelper.ts | 81 ++++++++++++++++++++++++++++++++++++++ src/remote.ts | 52 +++++++++++++++++++++++- src/workspacesProvider.ts | 79 ++++++------------------------------- 3 files changed, 143 insertions(+), 69 deletions(-) create mode 100644 src/agentMetadataHelper.ts diff --git a/src/agentMetadataHelper.ts b/src/agentMetadataHelper.ts new file mode 100644 index 00000000..d7c746ef --- /dev/null +++ b/src/agentMetadataHelper.ts @@ -0,0 +1,81 @@ +import { Api } from "coder/site/src/api/api"; +import { WorkspaceAgent } from "coder/site/src/api/typesGenerated"; +import { EventSource } from "eventsource"; +import * as vscode from "vscode"; +import { createStreamingFetchAdapter } from "./api"; +import { + AgentMetadataEvent, + AgentMetadataEventSchemaArray, + errToStr, +} from "./api-helper"; + +export type AgentMetadataWatcher = { + onChange: vscode.EventEmitter["event"]; + dispose: () => void; + metadata?: AgentMetadataEvent[]; + error?: unknown; +}; + +/** + * Opens an SSE connection to watch metadata for a given workspace agent. + * Emits onChange when metadata updates or an error occurs. + */ +export function createAgentMetadataWatcher( + agentId: WorkspaceAgent["id"], + restClient: Api, +): AgentMetadataWatcher { + // TODO: Is there a better way to grab the url and token? + const url = restClient.getAxiosInstance().defaults.baseURL; + const metadataUrl = new URL( + `${url}/api/v2/workspaceagents/${agentId}/watch-metadata`, + ); + const eventSource = new EventSource(metadataUrl.toString(), { + fetch: createStreamingFetchAdapter(restClient.getAxiosInstance()), + }); + + let disposed = false; + const onChange = new vscode.EventEmitter(); + const watcher: AgentMetadataWatcher = { + onChange: onChange.event, + dispose: () => { + if (!disposed) { + eventSource.close(); + disposed = true; + } + }, + }; + + eventSource.addEventListener("data", (event) => { + try { + const dataEvent = JSON.parse(event.data); + const metadata = AgentMetadataEventSchemaArray.parse(dataEvent); + + // Overwrite metadata if it changed. + if (JSON.stringify(watcher.metadata) !== JSON.stringify(metadata)) { + watcher.metadata = metadata; + onChange.fire(null); + } + } catch (error) { + watcher.error = error; + onChange.fire(null); + } + }); + + return watcher; +} + +export function formatMetadataError(error: unknown): string { + return "Failed to query metadata: " + errToStr(error, "no error provided"); +} + +export function formatEventLabel(metadataEvent: AgentMetadataEvent): string { + return getEventName(metadataEvent) + ": " + getEventValue(metadataEvent); +} + +export function getEventName(metadataEvent: AgentMetadataEvent): string { + return metadataEvent.description.display_name.trim(); +} + +export function getEventValue(metadataEvent: AgentMetadataEvent): string { + return metadataEvent.result.value.replace(/\n/g, "").trim(); +} diff --git a/src/remote.ts b/src/remote.ts index 7ce460c9..f49d94c2 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -1,6 +1,6 @@ import { isAxiosError } from "axios"; import { Api } from "coder/site/src/api/api"; -import { Workspace } from "coder/site/src/api/typesGenerated"; +import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"; import find from "find-process"; import * as fs from "fs/promises"; import * as jsonc from "jsonc-parser"; @@ -9,6 +9,12 @@ import * as path from "path"; import prettyBytes from "pretty-bytes"; import * as semver from "semver"; import * as vscode from "vscode"; +import { + createAgentMetadataWatcher, + getEventValue, + formatEventLabel, + formatMetadataError, +} from "./agentMetadataHelper"; import { createHttpAgent, makeCoderSdk, @@ -624,6 +630,8 @@ export class Remote { }), ); + this.createAgentMetadataStatusBar(agent, workspaceRestClient, disposables); + this.storage.output.info("Remote setup complete"); // Returning the URL and token allows the plugin to authenticate its own @@ -966,6 +974,48 @@ export class Remote { return loop(); } + /** + * Creates and manages a status bar item that displays metadata information for a given workspace agent. + * The status bar item updates dynamically based on changes to the agent's metadata, + * and hides itself if no metadata is available or an error occurs. + */ + private createAgentMetadataStatusBar( + agent: WorkspaceAgent, + restClient: Api, + disposables: vscode.Disposable[], + ): void { + const statusBarItem = vscode.window.createStatusBarItem( + "agentMetadata", + vscode.StatusBarAlignment.Left, + ); + disposables.push(statusBarItem); + + const agentWatcher = createAgentMetadataWatcher(agent.id, restClient); + disposables.push(agentWatcher); + + agentWatcher.onChange( + () => { + if (agentWatcher.error) { + this.storage.output.warn(formatMetadataError(agentWatcher.error)); + statusBarItem.hide(); + return; + } + + if (agentWatcher.metadata && agentWatcher.metadata.length > 0) { + statusBarItem.text = getEventValue(agentWatcher.metadata[0]); + statusBarItem.tooltip = agentWatcher.metadata + .map((metadata) => formatEventLabel(metadata)) + .join("\n"); + statusBarItem.show(); + } else { + statusBarItem.hide(); + } + }, + undefined, + disposables, + ); + } + // closeRemote ends the current remote session. public async closeRemote() { await vscode.commands.executeCommand("workbench.action.remote.close"); diff --git a/src/workspacesProvider.ts b/src/workspacesProvider.ts index 64b74e7d..d0ddc6c7 100644 --- a/src/workspacesProvider.ts +++ b/src/workspacesProvider.ts @@ -4,16 +4,18 @@ import { WorkspaceAgent, WorkspaceApp, } from "coder/site/src/api/typesGenerated"; -import { EventSource } from "eventsource"; import * as path from "path"; import * as vscode from "vscode"; -import { createStreamingFetchAdapter } from "./api"; +import { + AgentMetadataWatcher, + createAgentMetadataWatcher, + formatEventLabel, + formatMetadataError, +} from "./agentMetadataHelper"; import { AgentMetadataEvent, - AgentMetadataEventSchemaArray, extractAllAgents, extractAgents, - errToStr, } from "./api-helper"; import { Storage } from "./storage"; @@ -22,13 +24,6 @@ export enum WorkspaceQuery { All = "", } -type AgentWatcher = { - onChange: vscode.EventEmitter["event"]; - dispose: () => void; - metadata?: AgentMetadataEvent[]; - error?: unknown; -}; - /** * Polls workspaces using the provided REST client and renders them in a tree. * @@ -42,7 +37,8 @@ export class WorkspaceProvider { // Undefined if we have never fetched workspaces before. private workspaces: WorkspaceTreeItem[] | undefined; - private agentWatchers: Record = {}; + private agentWatchers: Record = + {}; private timeout: NodeJS.Timeout | undefined; private fetching = false; private visible = false; @@ -139,7 +135,7 @@ export class WorkspaceProvider return this.agentWatchers[agent.id]; } // Otherwise create a new watcher. - const watcher = monitorMetadata(agent.id, restClient); + const watcher = createAgentMetadataWatcher(agent.id, restClient); watcher.onChange(() => this.refresh()); this.agentWatchers[agent.id] = watcher; return watcher; @@ -313,53 +309,6 @@ export class WorkspaceProvider } } -// monitorMetadata opens an SSE endpoint to monitor metadata on the specified -// agent and registers a watcher that can be disposed to stop the watch and -// emits an event when the metadata changes. -function monitorMetadata( - agentId: WorkspaceAgent["id"], - restClient: Api, -): AgentWatcher { - // TODO: Is there a better way to grab the url and token? - const url = restClient.getAxiosInstance().defaults.baseURL; - const metadataUrl = new URL( - `${url}/api/v2/workspaceagents/${agentId}/watch-metadata`, - ); - const eventSource = new EventSource(metadataUrl.toString(), { - fetch: createStreamingFetchAdapter(restClient.getAxiosInstance()), - }); - - let disposed = false; - const onChange = new vscode.EventEmitter(); - const watcher: AgentWatcher = { - onChange: onChange.event, - dispose: () => { - if (!disposed) { - eventSource.close(); - disposed = true; - } - }, - }; - - eventSource.addEventListener("data", (event) => { - try { - const dataEvent = JSON.parse(event.data); - const metadata = AgentMetadataEventSchemaArray.parse(dataEvent); - - // Overwrite metadata if it changed. - if (JSON.stringify(watcher.metadata) !== JSON.stringify(metadata)) { - watcher.metadata = metadata; - onChange.fire(null); - } - } catch (error) { - watcher.error = error; - onChange.fire(null); - } - }); - - return watcher; -} - /** * A tree item that represents a collapsible section with child items */ @@ -375,20 +324,14 @@ class SectionTreeItem extends vscode.TreeItem { class ErrorTreeItem extends vscode.TreeItem { constructor(error: unknown) { - super( - "Failed to query metadata: " + errToStr(error, "no error provided"), - vscode.TreeItemCollapsibleState.None, - ); + super(formatMetadataError(error), vscode.TreeItemCollapsibleState.None); this.contextValue = "coderAgentMetadata"; } } class AgentMetadataTreeItem extends vscode.TreeItem { constructor(metadataEvent: AgentMetadataEvent) { - const label = - metadataEvent.description.display_name.trim() + - ": " + - metadataEvent.result.value.replace(/\n/g, "").trim(); + const label = formatEventLabel(metadataEvent); super(label, vscode.TreeItemCollapsibleState.None); const collected_at = new Date( From 9d0d6104e67b19badfe2927dbb3f67b98cbc19c6 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Sat, 19 Jul 2025 23:24:05 +0300 Subject: [PATCH 2/3] Review comment: return a disposables array when creating the statusbar --- CHANGELOG.md | 2 ++ src/remote.ts | 47 ++++++++++++++++++++++------------------------- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80371d86..d9d1e65a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ update notifications. - Coder output panel enhancements: All log entries now include timestamps, and you can filter messages by log level in the panel. +- Added an agent metadata monitor status bar item, so you can view your active + agent metadata at a glance. ## [v1.9.2](https://github.com/coder/vscode-coder/releases/tag/v1.9.2) 2025-06-25 diff --git a/src/remote.ts b/src/remote.ts index f49d94c2..809c424b 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -630,7 +630,9 @@ export class Remote { }), ); - this.createAgentMetadataStatusBar(agent, workspaceRestClient, disposables); + disposables.push( + ...this.createAgentMetadataStatusBar(agent, workspaceRestClient), + ); this.storage.output.info("Remote setup complete"); @@ -982,38 +984,33 @@ export class Remote { private createAgentMetadataStatusBar( agent: WorkspaceAgent, restClient: Api, - disposables: vscode.Disposable[], - ): void { + ): vscode.Disposable[] { const statusBarItem = vscode.window.createStatusBarItem( "agentMetadata", vscode.StatusBarAlignment.Left, ); - disposables.push(statusBarItem); const agentWatcher = createAgentMetadataWatcher(agent.id, restClient); - disposables.push(agentWatcher); - agentWatcher.onChange( - () => { - if (agentWatcher.error) { - this.storage.output.warn(formatMetadataError(agentWatcher.error)); - statusBarItem.hide(); - return; - } + const onChangeDisposable = agentWatcher.onChange(() => { + if (agentWatcher.error) { + this.storage.output.warn(formatMetadataError(agentWatcher.error)); + statusBarItem.hide(); + return; + } - if (agentWatcher.metadata && agentWatcher.metadata.length > 0) { - statusBarItem.text = getEventValue(agentWatcher.metadata[0]); - statusBarItem.tooltip = agentWatcher.metadata - .map((metadata) => formatEventLabel(metadata)) - .join("\n"); - statusBarItem.show(); - } else { - statusBarItem.hide(); - } - }, - undefined, - disposables, - ); + if (agentWatcher.metadata && agentWatcher.metadata.length > 0) { + statusBarItem.text = getEventValue(agentWatcher.metadata[0]); + statusBarItem.tooltip = agentWatcher.metadata + .map((metadata) => formatEventLabel(metadata)) + .join("\n"); + statusBarItem.show(); + } else { + statusBarItem.hide(); + } + }); + + return [statusBarItem, agentWatcher, onChangeDisposable]; } // closeRemote ends the current remote session. From dfc1a3ab95b9ae7c2f3d1261ada5a3fd4f4c8584 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 22 Jul 2025 21:46:55 +0300 Subject: [PATCH 3/3] Show the statusbar even when fetching metadata fails --- src/remote.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/remote.ts b/src/remote.ts index 809c424b..d5967c1d 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -994,16 +994,29 @@ export class Remote { const onChangeDisposable = agentWatcher.onChange(() => { if (agentWatcher.error) { - this.storage.output.warn(formatMetadataError(agentWatcher.error)); - statusBarItem.hide(); + const errMessage = formatMetadataError(agentWatcher.error); + this.storage.output.warn(errMessage); + + statusBarItem.text = "$(warning) Agent Status Unavailable"; + statusBarItem.tooltip = errMessage; + statusBarItem.color = new vscode.ThemeColor( + "statusBarItem.warningForeground", + ); + statusBarItem.backgroundColor = new vscode.ThemeColor( + "statusBarItem.warningBackground", + ); + statusBarItem.show(); return; } if (agentWatcher.metadata && agentWatcher.metadata.length > 0) { - statusBarItem.text = getEventValue(agentWatcher.metadata[0]); + statusBarItem.text = + "$(dashboard) " + getEventValue(agentWatcher.metadata[0]); statusBarItem.tooltip = agentWatcher.metadata .map((metadata) => formatEventLabel(metadata)) .join("\n"); + statusBarItem.color = undefined; + statusBarItem.backgroundColor = undefined; statusBarItem.show(); } else { statusBarItem.hide();