Skip to content

Commit 0e76e62

Browse files
authored
feat: show agent metadata (#92)
1 parent 4c37680 commit 0e76e62

File tree

7 files changed

+170
-65
lines changed

7 files changed

+170
-65
lines changed

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@
226226
"tar-fs": "^2.1.1",
227227
"which": "^2.0.2",
228228
"ws": "^8.11.0",
229-
"yaml": "^1.10.0"
229+
"yaml": "^1.10.0",
230+
"zod": "^3.21.4"
230231
}
231232
}

src/api-helper.ts

+21
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
2+
import { z } from "zod"
23

34
export function extractAgents(workspace: Workspace): WorkspaceAgent[] {
45
const agents = workspace.latest_build.resources.reduce((acc, resource) => {
@@ -7,3 +8,23 @@ export function extractAgents(workspace: Workspace): WorkspaceAgent[] {
78

89
return agents
910
}
11+
12+
export const AgentMetadataEventSchema = z.object({
13+
result: z.object({
14+
collected_at: z.string(),
15+
age: z.number(),
16+
value: z.string(),
17+
error: z.string(),
18+
}),
19+
description: z.object({
20+
display_name: z.string(),
21+
key: z.string(),
22+
script: z.string(),
23+
interval: z.number(),
24+
timeout: z.number(),
25+
}),
26+
})
27+
28+
export const AgentMetadataEventSchemaArray = z.array(AgentMetadataEventSchema)
29+
30+
export type AgentMetadataEvent = z.infer<typeof AgentMetadataEventSchema>

src/commands.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as vscode from "vscode"
55
import { extractAgents } from "./api-helper"
66
import { Remote } from "./remote"
77
import { Storage } from "./storage"
8-
import { WorkspaceTreeItem } from "./workspacesProvider"
8+
import { OpenableTreeItem } from "./workspacesProvider"
99

1010
export class Commands {
1111
public constructor(private readonly vscodeProposed: typeof vscode, private readonly storage: Storage) {}
@@ -118,7 +118,7 @@ export class Commands {
118118
await vscode.commands.executeCommand("vscode.open", uri)
119119
}
120120

121-
public async navigateToWorkspace(workspace: WorkspaceTreeItem) {
121+
public async navigateToWorkspace(workspace: OpenableTreeItem) {
122122
if (workspace) {
123123
const uri = this.storage.getURL() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}`
124124
await vscode.commands.executeCommand("vscode.open", uri)
@@ -130,7 +130,7 @@ export class Commands {
130130
}
131131
}
132132

133-
public async navigateToWorkspaceSettings(workspace: WorkspaceTreeItem) {
133+
public async navigateToWorkspaceSettings(workspace: OpenableTreeItem) {
134134
if (workspace) {
135135
const uri = this.storage.getURL() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}/settings`
136136
await vscode.commands.executeCommand("vscode.open", uri)
@@ -143,7 +143,7 @@ export class Commands {
143143
}
144144
}
145145

146-
public async openFromSidebar(treeItem: WorkspaceTreeItem) {
146+
public async openFromSidebar(treeItem: OpenableTreeItem) {
147147
if (treeItem) {
148148
await openWorkspace(
149149
treeItem.workspaceOwner,

src/extension.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
"use strict"
2-
32
import { getAuthenticatedUser } from "coder/site/src/api/api"
43
import * as module from "module"
54
import * as vscode from "vscode"
@@ -13,8 +12,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
1312
const storage = new Storage(output, ctx.globalState, ctx.secrets, ctx.globalStorageUri, ctx.logUri)
1413
await storage.init()
1514

16-
const myWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.Mine)
17-
const allWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.All)
15+
const myWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.Mine, storage)
16+
const allWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.All, storage)
1817

1918
vscode.window.registerTreeDataProvider("myWorkspaces", myWorkspacesProvider)
2019
vscode.window.registerTreeDataProvider("allWorkspaces", allWorkspacesProvider)

src/remote.ts

-6
Original file line numberDiff line numberDiff line change
@@ -282,12 +282,6 @@ export class Remote {
282282
"Coder-Session-Token": await this.storage.getSessionToken(),
283283
},
284284
})
285-
eventSource.addEventListener("open", () => {
286-
// TODO: Add debug output that we began watching here!
287-
})
288-
eventSource.addEventListener("error", () => {
289-
// TODO: Add debug output that we got an error here!
290-
})
291285

292286
const workspaceUpdatedStatus = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 999)
293287
disposables.push(workspaceUpdatedStatus)

src/workspacesProvider.ts

+136-51
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,178 @@
11
import { getWorkspaces } from "coder/site/src/api/api"
2-
import { WorkspaceAgent } from "coder/site/src/api/typesGenerated"
2+
import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
3+
import EventSource from "eventsource"
34
import * as path from "path"
45
import * as vscode from "vscode"
5-
import { extractAgents } from "./api-helper"
6+
import { AgentMetadataEvent, AgentMetadataEventSchemaArray, extractAgents } from "./api-helper"
7+
import { Storage } from "./storage"
68

79
export enum WorkspaceQuery {
810
Mine = "owner:me",
911
All = "",
1012
}
1113

12-
export class WorkspaceProvider implements vscode.TreeDataProvider<WorkspaceTreeItem> {
13-
constructor(private readonly getWorkspacesQuery: WorkspaceQuery) {}
14+
export class WorkspaceProvider implements vscode.TreeDataProvider<vscode.TreeItem> {
15+
private workspaces: WorkspaceTreeItem[] = []
16+
private agentMetadata: Record<WorkspaceAgent["id"], AgentMetadataEvent[]> = {}
1417

15-
private _onDidChangeTreeData: vscode.EventEmitter<WorkspaceTreeItem | undefined | null | void> =
16-
new vscode.EventEmitter<WorkspaceTreeItem | undefined | null | void>()
17-
readonly onDidChangeTreeData: vscode.Event<WorkspaceTreeItem | undefined | null | void> =
18+
constructor(private readonly getWorkspacesQuery: WorkspaceQuery, private readonly storage: Storage) {
19+
getWorkspaces({ q: this.getWorkspacesQuery })
20+
.then((workspaces) => {
21+
const workspacesTreeItem: WorkspaceTreeItem[] = []
22+
workspaces.workspaces.forEach((workspace) => {
23+
const showMetadata = this.getWorkspacesQuery === WorkspaceQuery.Mine
24+
if (showMetadata) {
25+
const agents = extractAgents(workspace)
26+
agents.forEach((agent) => this.monitorMetadata(agent.id)) // monitor metadata for all agents
27+
}
28+
const treeItem = new WorkspaceTreeItem(
29+
workspace,
30+
this.getWorkspacesQuery === WorkspaceQuery.All,
31+
showMetadata,
32+
)
33+
workspacesTreeItem.push(treeItem)
34+
})
35+
return workspacesTreeItem
36+
})
37+
.then((workspaces) => {
38+
this.workspaces = workspaces
39+
this.refresh()
40+
})
41+
}
42+
43+
private _onDidChangeTreeData: vscode.EventEmitter<vscode.TreeItem | undefined | null | void> =
44+
new vscode.EventEmitter<vscode.TreeItem | undefined | null | void>()
45+
readonly onDidChangeTreeData: vscode.Event<vscode.TreeItem | undefined | null | void> =
1846
this._onDidChangeTreeData.event
1947

20-
refresh(): void {
21-
this._onDidChangeTreeData.fire()
48+
refresh(item: vscode.TreeItem | undefined | null | void): void {
49+
this._onDidChangeTreeData.fire(item)
2250
}
2351

24-
getTreeItem(element: WorkspaceTreeItem): vscode.TreeItem {
52+
async getTreeItem(element: vscode.TreeItem): Promise<vscode.TreeItem> {
2553
return element
2654
}
2755

28-
getChildren(element?: WorkspaceTreeItem): Thenable<WorkspaceTreeItem[]> {
56+
getChildren(element?: vscode.TreeItem): Thenable<vscode.TreeItem[]> {
2957
if (element) {
30-
if (element.agents.length > 0) {
31-
return Promise.resolve(
32-
element.agents.map((agent) => {
33-
const label = agent.name
34-
const detail = `Status: ${agent.status}`
35-
return new WorkspaceTreeItem(label, detail, "", "", agent.name, agent.expanded_directory, [], "coderAgent")
36-
}),
37-
)
58+
if (element instanceof WorkspaceTreeItem) {
59+
const agents = extractAgents(element.workspace)
60+
const agentTreeItems = agents.map((agent) => new AgentTreeItem(agent, element.watchMetadata))
61+
return Promise.resolve(agentTreeItems)
62+
} else if (element instanceof AgentTreeItem) {
63+
const savedMetadata = this.agentMetadata[element.agent.id] || []
64+
return Promise.resolve(savedMetadata.map((metadata) => new AgentMetadataTreeItem(metadata)))
3865
}
66+
3967
return Promise.resolve([])
4068
}
41-
return getWorkspaces({ q: this.getWorkspacesQuery }).then((workspaces) => {
42-
return workspaces.workspaces.map((workspace) => {
43-
const status =
44-
workspace.latest_build.status.substring(0, 1).toUpperCase() + workspace.latest_build.status.substring(1)
45-
46-
const label =
47-
this.getWorkspacesQuery === WorkspaceQuery.All
48-
? `${workspace.owner_name} / ${workspace.name}`
49-
: workspace.name
50-
const detail = `Template: ${workspace.template_display_name || workspace.template_name} • Status: ${status}`
51-
const agents = extractAgents(workspace)
52-
return new WorkspaceTreeItem(
53-
label,
54-
detail,
55-
workspace.owner_name,
56-
workspace.name,
57-
undefined,
58-
agents[0]?.expanded_directory,
59-
agents,
60-
agents.length > 1 ? "coderWorkspaceMultipleAgents" : "coderWorkspaceSingleAgent",
61-
)
62-
})
69+
return Promise.resolve(this.workspaces)
70+
}
71+
72+
async monitorMetadata(agentId: WorkspaceAgent["id"]): Promise<void> {
73+
const agentMetadataURL = new URL(`${this.storage.getURL()}/api/v2/workspaceagents/${agentId}/watch-metadata`)
74+
const agentMetadataEventSource = new EventSource(agentMetadataURL.toString(), {
75+
headers: {
76+
"Coder-Session-Token": await this.storage.getSessionToken(),
77+
},
78+
})
79+
80+
agentMetadataEventSource.addEventListener("data", (event) => {
81+
try {
82+
const dataEvent = JSON.parse(event.data)
83+
const agentMetadata = AgentMetadataEventSchemaArray.parse(dataEvent)
84+
85+
if (agentMetadata.length === 0) {
86+
agentMetadataEventSource.close()
87+
}
88+
89+
const savedMetadata = this.agentMetadata[agentId]
90+
if (JSON.stringify(savedMetadata) !== JSON.stringify(agentMetadata)) {
91+
this.agentMetadata[agentId] = agentMetadata // overwrite existing metadata
92+
this.refresh()
93+
}
94+
} catch (error) {
95+
agentMetadataEventSource.close()
96+
}
6397
})
6498
}
6599
}
66100

67101
type CoderTreeItemType = "coderWorkspaceSingleAgent" | "coderWorkspaceMultipleAgents" | "coderAgent"
68102

69-
export class WorkspaceTreeItem extends vscode.TreeItem {
103+
class AgentMetadataTreeItem extends vscode.TreeItem {
104+
constructor(metadataEvent: AgentMetadataEvent) {
105+
const label =
106+
metadataEvent.description.display_name.trim() + ": " + metadataEvent.result.value.replace(/\n/g, "").trim()
107+
108+
super(label, vscode.TreeItemCollapsibleState.None)
109+
this.tooltip = "Collected at " + metadataEvent.result.collected_at
110+
this.contextValue = "coderAgentMetadata"
111+
}
112+
}
113+
114+
export class OpenableTreeItem extends vscode.TreeItem {
70115
constructor(
71-
public readonly label: string,
72-
public readonly tooltip: string,
116+
label: string,
117+
tooltip: string,
118+
collapsibleState: vscode.TreeItemCollapsibleState,
119+
73120
public readonly workspaceOwner: string,
74121
public readonly workspaceName: string,
75122
public readonly workspaceAgent: string | undefined,
76123
public readonly workspaceFolderPath: string | undefined,
77-
public readonly agents: WorkspaceAgent[],
124+
78125
contextValue: CoderTreeItemType,
79126
) {
80-
super(
81-
label,
82-
contextValue === "coderWorkspaceMultipleAgents"
83-
? vscode.TreeItemCollapsibleState.Collapsed
84-
: vscode.TreeItemCollapsibleState.None,
85-
)
127+
super(label, collapsibleState)
86128
this.contextValue = contextValue
129+
this.tooltip = tooltip
87130
}
88131

89132
iconPath = {
90133
light: path.join(__filename, "..", "..", "media", "logo.svg"),
91134
dark: path.join(__filename, "..", "..", "media", "logo.svg"),
92135
}
93136
}
137+
138+
class AgentTreeItem extends OpenableTreeItem {
139+
constructor(public readonly agent: WorkspaceAgent, watchMetadata = false) {
140+
const label = agent.name
141+
const detail = `Status: ${agent.status}`
142+
super(
143+
label,
144+
detail,
145+
watchMetadata ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None,
146+
"",
147+
"",
148+
agent.name,
149+
agent.expanded_directory,
150+
"coderAgent",
151+
)
152+
}
153+
}
154+
155+
export class WorkspaceTreeItem extends OpenableTreeItem {
156+
constructor(
157+
public readonly workspace: Workspace,
158+
public readonly showOwner: boolean,
159+
public readonly watchMetadata = false,
160+
) {
161+
const status =
162+
workspace.latest_build.status.substring(0, 1).toUpperCase() + workspace.latest_build.status.substring(1)
163+
164+
const label = showOwner ? `${workspace.owner_name} / ${workspace.name}` : workspace.name
165+
const detail = `Template: ${workspace.template_display_name || workspace.template_name} • Status: ${status}`
166+
const agents = extractAgents(workspace)
167+
super(
168+
label,
169+
detail,
170+
showOwner ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.Expanded,
171+
workspace.owner_name,
172+
workspace.name,
173+
undefined,
174+
agents[0]?.expanded_directory,
175+
"coderWorkspaceMultipleAgents",
176+
)
177+
}
178+
}

yarn.lock

+5
Original file line numberDiff line numberDiff line change
@@ -5761,3 +5761,8 @@ yocto-queue@^1.0.0:
57615761
version "1.0.0"
57625762
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
57635763
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
5764+
5765+
zod@^3.21.4:
5766+
version "3.21.4"
5767+
resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db"
5768+
integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==

0 commit comments

Comments
 (0)