diff --git a/CHANGELOG.md b/CHANGELOG.md index c32ffad0..3073ba28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ depending on how you connected, it could be possible to get two different sessions for an agent. Existing connections may still have this problem, only new connections are fixed. +- 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/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..d5967c1d 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,10 @@ export class Remote { }), ); + disposables.push( + ...this.createAgentMetadataStatusBar(agent, workspaceRestClient), + ); + this.storage.output.info("Remote setup complete"); // Returning the URL and token allows the plugin to authenticate its own @@ -966,6 +976,56 @@ 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, + ): vscode.Disposable[] { + const statusBarItem = vscode.window.createStatusBarItem( + "agentMetadata", + vscode.StatusBarAlignment.Left, + ); + + const agentWatcher = createAgentMetadataWatcher(agent.id, restClient); + + const onChangeDisposable = agentWatcher.onChange(() => { + if (agentWatcher.error) { + 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 = + "$(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(); + } + }); + + return [statusBarItem, agentWatcher, onChangeDisposable]; + } + // 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 59915e58..a33b43cc 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(