Skip to content

Commit 713b2bd

Browse files
Kira-PilotKira Pilotcode-asher
authored
feat: add VS code notifications for workspace actions (#111)
Co-authored-by: Kira Pilot <[email protected]> Co-authored-by: Asher <[email protected]>
1 parent e2eb13a commit 713b2bd

File tree

6 files changed

+224
-4
lines changed

6 files changed

+224
-4
lines changed

package.json

+5-2
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@
211211
"@vscode/test-electron": "^1.6.2",
212212
"@vscode/vsce": "^2.16.0",
213213
"bufferutil": "^4.0.7",
214-
"coder": "https://github.com/coder/coder",
214+
"coder": "https://github.com/coder/coder#main",
215215
"dayjs": "^1.11.7",
216216
"eslint": "^7.19.0",
217217
"eslint-config-prettier": "^8.3.0",
@@ -231,7 +231,9 @@
231231
"webpack-cli": "^5.0.1"
232232
},
233233
"dependencies": {
234+
"@types/ua-parser-js": "^0.7.36",
234235
"axios": "0.26.1",
236+
"date-fns": "^2.30.0",
235237
"eventsource": "^2.0.2",
236238
"find-process": "^1.4.7",
237239
"fs-extra": "^11.1.0",
@@ -241,9 +243,10 @@
241243
"pretty-bytes": "^6.0.0",
242244
"semver": "^7.3.8",
243245
"tar-fs": "^2.1.1",
246+
"ua-parser-js": "^1.0.35",
244247
"which": "^2.0.2",
245248
"ws": "^8.11.0",
246249
"yaml": "^1.10.0",
247250
"zod": "^3.21.4"
248251
}
249-
}
252+
}

src/WorkspaceAction.ts

+176
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import axios from "axios"
2+
import { getWorkspaces } from "coder/site/src/api/api"
3+
import { Workspace, WorkspacesResponse, WorkspaceBuild } from "coder/site/src/api/typesGenerated"
4+
import { formatDistanceToNowStrict } from "date-fns"
5+
import * as vscode from "vscode"
6+
import { Storage } from "./storage"
7+
8+
interface NotifiedWorkspace {
9+
workspace: Workspace
10+
wasNotified: boolean
11+
impendingActionDeadline: string
12+
}
13+
14+
type WithRequired<T, K extends keyof T> = T & Required<Pick<T, K>>
15+
16+
type WorkspaceWithDeadline = Workspace & { latest_build: WithRequired<WorkspaceBuild, "deadline"> }
17+
type WorkspaceWithDeletingAt = WithRequired<Workspace, "deleting_at">
18+
19+
export class WorkspaceAction {
20+
// We use this same interval in the Dashboard to poll for updates on the Workspaces page.
21+
#POLL_INTERVAL: number = 1000 * 5
22+
#fetchWorkspacesInterval?: ReturnType<typeof setInterval>
23+
24+
#ownedWorkspaces: Workspace[] = []
25+
#workspacesApproachingAutostop: NotifiedWorkspace[] = []
26+
#workspacesApproachingDeletion: NotifiedWorkspace[] = []
27+
28+
private constructor(
29+
private readonly vscodeProposed: typeof vscode,
30+
private readonly storage: Storage,
31+
ownedWorkspaces: Workspace[],
32+
) {
33+
this.#ownedWorkspaces = ownedWorkspaces
34+
35+
// seed initial lists
36+
this.updateNotificationLists()
37+
38+
this.notifyAll()
39+
40+
// set up polling so we get current workspaces data
41+
this.pollGetWorkspaces()
42+
}
43+
44+
static async init(vscodeProposed: typeof vscode, storage: Storage) {
45+
// fetch all workspaces owned by the user and set initial public class fields
46+
let ownedWorkspacesResponse: WorkspacesResponse
47+
try {
48+
ownedWorkspacesResponse = await getWorkspaces({ q: "owner:me" })
49+
} catch (error) {
50+
let status
51+
if (axios.isAxiosError(error)) {
52+
status = error.response?.status
53+
}
54+
if (status !== 401) {
55+
storage.writeToCoderOutputChannel(
56+
`Failed to fetch owned workspaces. Some workspace notifications may be missing: ${error}`,
57+
)
58+
}
59+
60+
ownedWorkspacesResponse = { workspaces: [], count: 0 }
61+
}
62+
return new WorkspaceAction(vscodeProposed, storage, ownedWorkspacesResponse.workspaces)
63+
}
64+
65+
updateNotificationLists() {
66+
this.#workspacesApproachingAutostop = this.#ownedWorkspaces
67+
.filter(this.filterWorkspacesImpendingAutostop)
68+
.map((workspace) =>
69+
this.transformWorkspaceObjects(workspace, this.#workspacesApproachingAutostop, workspace.latest_build.deadline),
70+
)
71+
72+
this.#workspacesApproachingDeletion = this.#ownedWorkspaces
73+
.filter(this.filterWorkspacesImpendingDeletion)
74+
.map((workspace) =>
75+
this.transformWorkspaceObjects(workspace, this.#workspacesApproachingDeletion, workspace.deleting_at),
76+
)
77+
}
78+
79+
filterWorkspacesImpendingAutostop(workspace: Workspace): workspace is WorkspaceWithDeadline {
80+
// a workspace is eligible for autostop if the workspace is running and it has a deadline
81+
if (workspace.latest_build.status !== "running" || !workspace.latest_build.deadline) {
82+
return false
83+
}
84+
85+
const hourMilli = 1000 * 60 * 60
86+
// return workspaces with a deadline that is in 1 hr or less
87+
return Math.abs(new Date().getTime() - new Date(workspace.latest_build.deadline).getTime()) <= hourMilli
88+
}
89+
90+
filterWorkspacesImpendingDeletion(workspace: Workspace): workspace is WorkspaceWithDeletingAt {
91+
if (!workspace.deleting_at) {
92+
return false
93+
}
94+
95+
const dayMilli = 1000 * 60 * 60 * 24
96+
97+
// return workspaces with a deleting_at that is 24 hrs or less
98+
return Math.abs(new Date().getTime() - new Date(workspace.deleting_at).getTime()) <= dayMilli
99+
}
100+
101+
transformWorkspaceObjects(workspace: Workspace, workspaceList: NotifiedWorkspace[], deadlineField: string) {
102+
const wasNotified = workspaceList.find((nw) => nw.workspace.id === workspace.id)?.wasNotified ?? false
103+
const impendingActionDeadline = formatDistanceToNowStrict(new Date(deadlineField))
104+
return { workspace, wasNotified, impendingActionDeadline }
105+
}
106+
107+
async pollGetWorkspaces() {
108+
let errorCount = 0
109+
this.#fetchWorkspacesInterval = setInterval(async () => {
110+
try {
111+
const workspacesResult = await getWorkspaces({ q: "owner:me" })
112+
this.#ownedWorkspaces = workspacesResult.workspaces
113+
this.updateNotificationLists()
114+
this.notifyAll()
115+
} catch (error) {
116+
errorCount++
117+
118+
let status
119+
if (axios.isAxiosError(error)) {
120+
status = error.response?.status
121+
}
122+
if (status !== 401) {
123+
this.storage.writeToCoderOutputChannel(
124+
`Failed to poll owned workspaces. Some workspace notifications may be missing: ${error}`,
125+
)
126+
}
127+
if (errorCount === 3) {
128+
clearInterval(this.#fetchWorkspacesInterval)
129+
}
130+
}
131+
}, this.#POLL_INTERVAL)
132+
}
133+
134+
notifyAll() {
135+
this.notifyImpendingAutostop()
136+
this.notifyImpendingDeletion()
137+
}
138+
139+
notifyImpendingAutostop() {
140+
this.#workspacesApproachingAutostop?.forEach((notifiedWorkspace: NotifiedWorkspace) => {
141+
if (notifiedWorkspace.wasNotified) {
142+
// don't message the user; we've already messaged
143+
return
144+
}
145+
146+
// we display individual notifications for each workspace as VS Code
147+
// intentionally strips new lines from the message text
148+
// https://github.com/Microsoft/vscode/issues/48900
149+
this.vscodeProposed.window.showInformationMessage(
150+
`${notifiedWorkspace.workspace.name} is scheduled to shut down in ${notifiedWorkspace.impendingActionDeadline}.`,
151+
)
152+
notifiedWorkspace.wasNotified = true
153+
})
154+
}
155+
156+
notifyImpendingDeletion() {
157+
this.#workspacesApproachingDeletion?.forEach((notifiedWorkspace: NotifiedWorkspace) => {
158+
if (notifiedWorkspace.wasNotified) {
159+
// don't message the user; we've already messaged
160+
return
161+
}
162+
163+
// we display individual notifications for each workspace as VS Code
164+
// intentionally strips new lines from the message text
165+
// https://github.com/Microsoft/vscode/issues/48900
166+
this.vscodeProposed.window.showInformationMessage(
167+
`${notifiedWorkspace.workspace.name} is scheduled for deletion in ${notifiedWorkspace.impendingActionDeadline}.`,
168+
)
169+
notifiedWorkspace.wasNotified = true
170+
})
171+
}
172+
173+
cleanupWorkspaceActions() {
174+
clearInterval(this.#fetchWorkspacesInterval)
175+
}
176+
}

src/remote.ts

+5
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import prettyBytes from "pretty-bytes"
1919
import * as semver from "semver"
2020
import * as vscode from "vscode"
2121
import * as ws from "ws"
22+
import { WorkspaceAction } from "./WorkspaceAction"
2223
import { SSHConfig, SSHValues, defaultSSHConfigResponse, mergeSSHConfigValues } from "./sshConfig"
2324
import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"
2425
import { Storage } from "./storage"
@@ -126,6 +127,9 @@ export class Remote {
126127
this.registerLabelFormatter(remoteAuthority, this.storage.workspace.owner_name, this.storage.workspace.name),
127128
)
128129

130+
// Initialize any WorkspaceAction notifications (auto-off, upcoming deletion)
131+
const action = await WorkspaceAction.init(this.vscodeProposed, this.storage)
132+
129133
let buildComplete: undefined | (() => void)
130134
if (this.storage.workspace.latest_build.status === "stopped") {
131135
this.vscodeProposed.window.withProgress(
@@ -427,6 +431,7 @@ export class Remote {
427431
return {
428432
dispose: () => {
429433
eventSource.close()
434+
action.cleanupWorkspaceActions()
430435
disposables.forEach((d) => d.dispose())
431436
},
432437
}

src/storage.ts

+5
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ export class Storage {
298298
})
299299
}
300300

301+
public writeToCoderOutputChannel(message: string) {
302+
this.output.appendLine(message)
303+
this.output.show(true)
304+
}
305+
301306
private async updateURL(): Promise<void> {
302307
const url = this.getURL()
303308
axios.defaults.baseURL = url

webpack.config.js

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const config = {
2323
resolve: {
2424
// support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader
2525
extensions: [".ts", ".js"],
26+
// the Coder dependency uses absolute paths
27+
modules: ["./node_modules", "./node_modules/coder/site/src"],
2628
},
2729
module: {
2830
rules: [

yarn.lock

+31-2
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,13 @@
163163
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.13.tgz#ddf1eb5a813588d2fb1692b70c6fce75b945c088"
164164
integrity sha512-gFDLKMfpiXCsjt4za2JA9oTMn70CeseCehb11kRZgvd7+F67Hih3OHOK24cRrWECJ/ljfPGac6ygXAs/C8kIvw==
165165

166+
"@babel/runtime@^7.21.0":
167+
version "7.22.5"
168+
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.5.tgz#8564dd588182ce0047d55d7a75e93921107b57ec"
169+
integrity sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA==
170+
dependencies:
171+
regenerator-runtime "^0.13.11"
172+
166173
"@babel/template@^7.18.10", "@babel/template@^7.20.7":
167174
version "7.20.7"
168175
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8"
@@ -542,6 +549,11 @@
542549
dependencies:
543550
"@types/node" "*"
544551

552+
"@types/ua-parser-js@^0.7.36":
553+
version "0.7.36"
554+
resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz#9bd0b47f26b5a3151be21ba4ce9f5fa457c5f190"
555+
integrity sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==
556+
545557
"@types/unist@^2.0.0", "@types/unist@^2.0.2":
546558
version "2.0.6"
547559
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
@@ -1416,9 +1428,9 @@ [email protected]:
14161428
resolved "https://registry.yarnpkg.com/co/-/co-3.1.0.tgz#4ea54ea5a08938153185e15210c68d9092bc1b78"
14171429
integrity sha512-CQsjCRiNObI8AtTsNIBDRMQ4oMR83CzEswHYahClvul7gKk+lDQiOKv+5qh7LQWf5sh6jkZNispz/QlsZxyNgA==
14181430

1419-
"coder@https://github.com/coder/coder":
1431+
"coder@https://github.com/coder/coder#main":
14201432
version "0.0.0"
1421-
resolved "https://github.com/coder/coder#a6fa8cac582f2fc54eca0191bd54fd43d6d67ac2"
1433+
resolved "https://github.com/coder/coder#140683813d794081a0c0dbab70ec7eee16c5f5c4"
14221434

14231435
collapse-white-space@^1.0.2:
14241436
version "1.0.6"
@@ -1530,6 +1542,13 @@ css-what@^6.1.0:
15301542
resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
15311543
integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==
15321544

1545+
date-fns@^2.30.0:
1546+
version "2.30.0"
1547+
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0"
1548+
integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==
1549+
dependencies:
1550+
"@babel/runtime" "^7.21.0"
1551+
15331552
dayjs@^1.11.7:
15341553
version "1.11.7"
15351554
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.7.tgz#4b296922642f70999544d1144a2c25730fce63e2"
@@ -3887,6 +3906,11 @@ rechoir@^0.8.0:
38873906
dependencies:
38883907
resolve "^1.20.0"
38893908

3909+
regenerator-runtime@^0.13.11:
3910+
version "0.13.11"
3911+
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
3912+
integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
3913+
38903914
regexp.prototype.flags@^1.4.3:
38913915
version "1.4.3"
38923916
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac"
@@ -5250,6 +5274,11 @@ typescript@^4.1.3:
52505274
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78"
52515275
integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==
52525276

5277+
ua-parser-js@^1.0.35:
5278+
version "1.0.35"
5279+
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.35.tgz#c4ef44343bc3db0a3cbefdf21822f1b1fc1ab011"
5280+
integrity sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==
5281+
52535282
uc.micro@^1.0.1, uc.micro@^1.0.5:
52545283
version "1.0.6"
52555284
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"

0 commit comments

Comments
 (0)