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" 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