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 <kirapilot@Kiras-MacBook-Pro.local> Co-authored-by: Asher <ash@coder.com>
1 parent e2eb13a commit 713b2bd

File tree

6 files changed

+224
-4
lines changed

6 files changed

+224
-4
lines changed

package.json

Lines changed: 5 additions & 2 deletions
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

Lines changed: 176 additions & 0 deletions
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

Lines changed: 5 additions & 0 deletions
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

Lines changed: 5 additions & 0 deletions
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

Lines changed: 2 additions & 0 deletions
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

Lines changed: 31 additions & 2 deletions
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 @@ co@3.1.0:
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy