Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@
},
"dependencies": {
"axios": "0.26.1",
"date-fns": "^2.30.0",
"eventsource": "^2.0.2",
"find-process": "^1.4.7",
"fs-extra": "^11.1.0",
Expand All @@ -246,4 +247,4 @@
"yaml": "^1.10.0",
"zod": "^3.21.4"
}
}
}
151 changes: 151 additions & 0 deletions src/WorkspaceAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { getWorkspaces } from "coder/site/src/api/api"
import { Workspace, WorkspacesResponse } from "coder/site/src/api/typesGenerated"
import { formatDistanceToNowStrict } from "date-fns"
import * as vscode from "vscode"

interface NotifiedWorkspace {
workspace: Workspace
wasNotified: boolean
impendingActionDeadline: string
}

export class WorkspaceAction {
#fetchWorkspacesInterval?: ReturnType<typeof setInterval>

#ownedWorkspaces: Workspace[] = []
#workspacesApproachingAutostop: NotifiedWorkspace[] = []
#workspacesApproachingDeletion: NotifiedWorkspace[] = []

private constructor(private readonly vscodeProposed: typeof vscode, ownedWorkspaces: Workspace[]) {
this.#ownedWorkspaces = ownedWorkspaces

// seed initial lists
this.seedNotificationLists()

this.notifyAll()

// set up polling so we get current workspaces data
this.pollGetWorkspaces()
}

static async init(vscodeProposed: typeof vscode) {
// 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) {
ownedWorkspacesResponse = { workspaces: [], count: 0 }
}
return new WorkspaceAction(vscodeProposed, ownedWorkspacesResponse.workspaces)
}

seedNotificationLists() {
this.#workspacesApproachingAutostop = this.#ownedWorkspaces
.filter(this.filterWorkspacesImpendingAutostop)
.map((workspace: Workspace) => this.transformWorkspaceObjects(workspace, workspace.latest_build.deadline))

this.#workspacesApproachingDeletion = this.#ownedWorkspaces
.filter(this.filterWorkspacesImpendingDeletion)
.map((workspace: Workspace) => this.transformWorkspaceObjects(workspace, workspace.deleting_at))
}

filterWorkspacesImpendingAutostop(workspace: Workspace) {
// a workspace is eligible for autostop if the last build was successful,
// and the workspace is started,
// and it has a deadline
if (
workspace.latest_build.job.status !== "succeeded" ||
workspace.latest_build.transition !== "start" ||
!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) {
if (!workspace.deleting_at) {
return
}

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, deadlineField?: string) {
// the below line is to satisfy TS; we should always pass a deadlineField, e.g
// workspace,deleting_at or workspace.latest_build.deadline
if (!deadlineField) {
return { workspace, wasNotified: true, impendingActionDeadline: "" }
}
const wasNotified =
this.#workspacesApproachingAutostop.find((wn) => wn.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.seedNotificationLists()
this.notifyAll()
} catch (error) {
if (errorCount === 3) {
clearInterval(this.#fetchWorkspacesInterval)
}
errorCount++
}
}, 1000 * 5)
}

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)
}
}
5 changes: 5 additions & 0 deletions src/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

let buildComplete: undefined | (() => void)
if (this.storage.workspace.latest_build.status === "stopped") {
this.vscodeProposed.window.withProgress(
Expand Down Expand Up @@ -427,6 +431,7 @@ export class Remote {
return {
dispose: () => {
eventSource.close()
Action.cleanupWorkspaceActions()
disposables.forEach((d) => d.dispose())
},
}
Expand Down
19 changes: 19 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1530,6 +1537,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"
Expand Down Expand Up @@ -3887,6 +3901,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"
Expand Down