diff --git a/package.json b/package.json index 837c47f5..12243dad 100644 --- a/package.json +++ b/package.json @@ -211,7 +211,7 @@ "@vscode/test-electron": "^1.6.2", "@vscode/vsce": "^2.16.0", "bufferutil": "^4.0.7", - "coder": "https://github.com/coder/coder", + "coder": "https://github.com/coder/coder#main", "dayjs": "^1.11.7", "eslint": "^7.19.0", "eslint-config-prettier": "^8.3.0", @@ -231,7 +231,9 @@ "webpack-cli": "^5.0.1" }, "dependencies": { + "@types/ua-parser-js": "^0.7.36", "axios": "0.26.1", + "date-fns": "^2.30.0", "eventsource": "^2.0.2", "find-process": "^1.4.7", "fs-extra": "^11.1.0", @@ -241,9 +243,10 @@ "pretty-bytes": "^6.0.0", "semver": "^7.3.8", "tar-fs": "^2.1.1", + "ua-parser-js": "^1.0.35", "which": "^2.0.2", "ws": "^8.11.0", "yaml": "^1.10.0", "zod": "^3.21.4" } -} \ No newline at end of file +} diff --git a/src/WorkspaceAction.ts b/src/WorkspaceAction.ts new file mode 100644 index 00000000..b32ed175 --- /dev/null +++ b/src/WorkspaceAction.ts @@ -0,0 +1,176 @@ +import axios from "axios" +import { getWorkspaces } from "coder/site/src/api/api" +import { Workspace, WorkspacesResponse, WorkspaceBuild } from "coder/site/src/api/typesGenerated" +import { formatDistanceToNowStrict } from "date-fns" +import * as vscode from "vscode" +import { Storage } from "./storage" + +interface NotifiedWorkspace { + workspace: Workspace + wasNotified: boolean + impendingActionDeadline: string +} + +type WithRequired = T & Required> + +type WorkspaceWithDeadline = Workspace & { latest_build: WithRequired } +type WorkspaceWithDeletingAt = WithRequired + +export class WorkspaceAction { + // We use this same interval in the Dashboard to poll for updates on the Workspaces page. + #POLL_INTERVAL: number = 1000 * 5 + #fetchWorkspacesInterval?: ReturnType + + #ownedWorkspaces: Workspace[] = [] + #workspacesApproachingAutostop: NotifiedWorkspace[] = [] + #workspacesApproachingDeletion: NotifiedWorkspace[] = [] + + private constructor( + private readonly vscodeProposed: typeof vscode, + private readonly storage: Storage, + ownedWorkspaces: Workspace[], + ) { + this.#ownedWorkspaces = ownedWorkspaces + + // seed initial lists + this.updateNotificationLists() + + this.notifyAll() + + // set up polling so we get current workspaces data + this.pollGetWorkspaces() + } + + static async init(vscodeProposed: typeof vscode, storage: Storage) { + // fetch all workspaces owned by the user and set initial public class fields + let ownedWorkspacesResponse: WorkspacesResponse + try { + ownedWorkspacesResponse = await getWorkspaces({ q: "owner:me" }) + } catch (error) { + let status + if (axios.isAxiosError(error)) { + status = error.response?.status + } + if (status !== 401) { + storage.writeToCoderOutputChannel( + `Failed to fetch owned workspaces. Some workspace notifications may be missing: ${error}`, + ) + } + + ownedWorkspacesResponse = { workspaces: [], count: 0 } + } + return new WorkspaceAction(vscodeProposed, storage, ownedWorkspacesResponse.workspaces) + } + + updateNotificationLists() { + this.#workspacesApproachingAutostop = this.#ownedWorkspaces + .filter(this.filterWorkspacesImpendingAutostop) + .map((workspace) => + this.transformWorkspaceObjects(workspace, this.#workspacesApproachingAutostop, workspace.latest_build.deadline), + ) + + this.#workspacesApproachingDeletion = this.#ownedWorkspaces + .filter(this.filterWorkspacesImpendingDeletion) + .map((workspace) => + this.transformWorkspaceObjects(workspace, this.#workspacesApproachingDeletion, workspace.deleting_at), + ) + } + + filterWorkspacesImpendingAutostop(workspace: Workspace): workspace is WorkspaceWithDeadline { + // a workspace is eligible for autostop if the workspace is running and it has a deadline + if (workspace.latest_build.status !== "running" || !workspace.latest_build.deadline) { + return false + } + + const hourMilli = 1000 * 60 * 60 + // return workspaces with a deadline that is in 1 hr or less + return Math.abs(new Date().getTime() - new Date(workspace.latest_build.deadline).getTime()) <= hourMilli + } + + filterWorkspacesImpendingDeletion(workspace: Workspace): workspace is WorkspaceWithDeletingAt { + if (!workspace.deleting_at) { + return false + } + + const dayMilli = 1000 * 60 * 60 * 24 + + // return workspaces with a deleting_at that is 24 hrs or less + return Math.abs(new Date().getTime() - new Date(workspace.deleting_at).getTime()) <= dayMilli + } + + transformWorkspaceObjects(workspace: Workspace, workspaceList: NotifiedWorkspace[], deadlineField: string) { + const wasNotified = workspaceList.find((nw) => nw.workspace.id === workspace.id)?.wasNotified ?? false + const impendingActionDeadline = formatDistanceToNowStrict(new Date(deadlineField)) + return { workspace, wasNotified, impendingActionDeadline } + } + + async pollGetWorkspaces() { + let errorCount = 0 + this.#fetchWorkspacesInterval = setInterval(async () => { + try { + const workspacesResult = await getWorkspaces({ q: "owner:me" }) + this.#ownedWorkspaces = workspacesResult.workspaces + this.updateNotificationLists() + this.notifyAll() + } catch (error) { + errorCount++ + + let status + if (axios.isAxiosError(error)) { + status = error.response?.status + } + if (status !== 401) { + this.storage.writeToCoderOutputChannel( + `Failed to poll owned workspaces. Some workspace notifications may be missing: ${error}`, + ) + } + if (errorCount === 3) { + clearInterval(this.#fetchWorkspacesInterval) + } + } + }, this.#POLL_INTERVAL) + } + + notifyAll() { + this.notifyImpendingAutostop() + this.notifyImpendingDeletion() + } + + notifyImpendingAutostop() { + this.#workspacesApproachingAutostop?.forEach((notifiedWorkspace: NotifiedWorkspace) => { + if (notifiedWorkspace.wasNotified) { + // don't message the user; we've already messaged + return + } + + // we display individual notifications for each workspace as VS Code + // intentionally strips new lines from the message text + // https://github.com/Microsoft/vscode/issues/48900 + this.vscodeProposed.window.showInformationMessage( + `${notifiedWorkspace.workspace.name} is scheduled to shut down in ${notifiedWorkspace.impendingActionDeadline}.`, + ) + notifiedWorkspace.wasNotified = true + }) + } + + notifyImpendingDeletion() { + this.#workspacesApproachingDeletion?.forEach((notifiedWorkspace: NotifiedWorkspace) => { + if (notifiedWorkspace.wasNotified) { + // don't message the user; we've already messaged + return + } + + // we display individual notifications for each workspace as VS Code + // intentionally strips new lines from the message text + // https://github.com/Microsoft/vscode/issues/48900 + this.vscodeProposed.window.showInformationMessage( + `${notifiedWorkspace.workspace.name} is scheduled for deletion in ${notifiedWorkspace.impendingActionDeadline}.`, + ) + notifiedWorkspace.wasNotified = true + }) + } + + cleanupWorkspaceActions() { + clearInterval(this.#fetchWorkspacesInterval) + } +} diff --git a/src/remote.ts b/src/remote.ts index 5d2f1134..4ae08740 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -19,6 +19,7 @@ import prettyBytes from "pretty-bytes" import * as semver from "semver" import * as vscode from "vscode" import * as ws from "ws" +import { WorkspaceAction } from "./WorkspaceAction" import { SSHConfig, SSHValues, defaultSSHConfigResponse, mergeSSHConfigValues } from "./sshConfig" import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport" import { Storage } from "./storage" @@ -126,6 +127,9 @@ export class Remote { this.registerLabelFormatter(remoteAuthority, this.storage.workspace.owner_name, this.storage.workspace.name), ) + // Initialize any WorkspaceAction notifications (auto-off, upcoming deletion) + const action = await WorkspaceAction.init(this.vscodeProposed, this.storage) + let buildComplete: undefined | (() => void) if (this.storage.workspace.latest_build.status === "stopped") { this.vscodeProposed.window.withProgress( @@ -427,6 +431,7 @@ export class Remote { return { dispose: () => { eventSource.close() + action.cleanupWorkspaceActions() disposables.forEach((d) => d.dispose()) }, } diff --git a/src/storage.ts b/src/storage.ts index 588f3408..e6bc8473 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -298,6 +298,11 @@ export class Storage { }) } + public writeToCoderOutputChannel(message: string) { + this.output.appendLine(message) + this.output.show(true) + } + private async updateURL(): Promise { const url = this.getURL() axios.defaults.baseURL = url diff --git a/webpack.config.js b/webpack.config.js index 1943a85e..7aa71696 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -23,6 +23,8 @@ const config = { resolve: { // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader extensions: [".ts", ".js"], + // the Coder dependency uses absolute paths + modules: ["./node_modules", "./node_modules/coder/site/src"], }, module: { rules: [ diff --git a/yarn.lock b/yarn.lock index 4b5a6a8e..d2a0e272 100644 --- a/yarn.lock +++ b/yarn.lock @@ -163,6 +163,13 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.13.tgz#ddf1eb5a813588d2fb1692b70c6fce75b945c088" integrity sha512-gFDLKMfpiXCsjt4za2JA9oTMn70CeseCehb11kRZgvd7+F67Hih3OHOK24cRrWECJ/ljfPGac6ygXAs/C8kIvw== +"@babel/runtime@^7.21.0": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.5.tgz#8564dd588182ce0047d55d7a75e93921107b57ec" + integrity sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA== + dependencies: + regenerator-runtime "^0.13.11" + "@babel/template@^7.18.10", "@babel/template@^7.20.7": version "7.20.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8" @@ -542,6 +549,11 @@ dependencies: "@types/node" "*" +"@types/ua-parser-js@^0.7.36": + version "0.7.36" + resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz#9bd0b47f26b5a3151be21ba4ce9f5fa457c5f190" + integrity sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ== + "@types/unist@^2.0.0", "@types/unist@^2.0.2": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" @@ -1416,9 +1428,9 @@ co@3.1.0: resolved "https://registry.yarnpkg.com/co/-/co-3.1.0.tgz#4ea54ea5a08938153185e15210c68d9092bc1b78" integrity sha512-CQsjCRiNObI8AtTsNIBDRMQ4oMR83CzEswHYahClvul7gKk+lDQiOKv+5qh7LQWf5sh6jkZNispz/QlsZxyNgA== -"coder@https://github.com/coder/coder": +"coder@https://github.com/coder/coder#main": version "0.0.0" - resolved "https://github.com/coder/coder#a6fa8cac582f2fc54eca0191bd54fd43d6d67ac2" + resolved "https://github.com/coder/coder#140683813d794081a0c0dbab70ec7eee16c5f5c4" collapse-white-space@^1.0.2: version "1.0.6" @@ -1530,6 +1542,13 @@ css-what@^6.1.0: resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== +date-fns@^2.30.0: + version "2.30.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0" + integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw== + dependencies: + "@babel/runtime" "^7.21.0" + dayjs@^1.11.7: version "1.11.7" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.7.tgz#4b296922642f70999544d1144a2c25730fce63e2" @@ -3887,6 +3906,11 @@ rechoir@^0.8.0: dependencies: resolve "^1.20.0" +regenerator-runtime@^0.13.11: + version "0.13.11" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" + integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== + regexp.prototype.flags@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac" @@ -5250,6 +5274,11 @@ typescript@^4.1.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78" integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg== +ua-parser-js@^1.0.35: + version "1.0.35" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.35.tgz#c4ef44343bc3db0a3cbefdf21822f1b1fc1ab011" + integrity sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA== + uc.micro@^1.0.1, uc.micro@^1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"