diff --git a/src/actions.ts b/src/actions.ts index b05d5f0..db2becf 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -34,7 +34,7 @@ const cancelDeployment = async (env: Environment) => { return; } - await apiClient.cancelDeployment(id); + return await apiClient.cancelDeployment(id); }; const resumeDeployment = async (env: Environment) => { @@ -43,24 +43,27 @@ const resumeDeployment = async (env: Environment) => { if (!id) { return; } - await apiClient.resumeDeployment(id); + return await apiClient.resumeDeployment(id); }; const redeployEnvironment = async (env: Environment) => { if (!env.id) { return; } - await apiClient.redeployEnvironment(env.id); + return await apiClient.redeployEnvironment(env.id); }; const destroyEnvironment = async (env: Environment) => { if (!env.id) { return; } - await apiClient.destroyEnvironment(env.id); + return await apiClient.destroyEnvironment(env.id); }; -const actions: Record Promise> = { +const actions: Record< + string, + (env: Environment) => Promise<{ id: string } | void> +> = { "env0.redeploy": redeployEnvironment, "env0.abort": abortEnvironmentDeploy, "env0.destroy": destroyEnvironment, @@ -70,8 +73,9 @@ const actions: Record Promise> = { export const registerEnvironmentActions = ( context: vscode.ExtensionContext, + environmentsTree: vscode.TreeView, environmentsDataProvider: Env0EnvironmentsProvider, - restartLogs: (env: Environment) => any + restartLogs: (env: Environment, deploymentId?: string) => any ) => { context.subscriptions.push( vscode.commands.registerCommand("env0.openInEnv0", (env) => { @@ -89,11 +93,16 @@ export const registerEnvironmentActions = ( location: { viewId: ENV0_ENVIRONMENTS_VIEW_ID }, }, async () => { + let actionResponse; try { - await actions[actionCommand](env); + await environmentsTree.reveal(env, { + select: true, + focus: true, + }); + actionResponse = await actions[actionCommand](env); } finally { environmentsDataProvider.refresh(); - restartLogs(env); + restartLogs(env, actionResponse?.id); } } ); diff --git a/src/api-client.ts b/src/api-client.ts index a7cdd9e..2434d58 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -1,6 +1,5 @@ -import { AuthService } from "./auth"; import axios, { AxiosInstance } from "axios"; -import { ENV0_API_URL, ENV0_WEB_URL } from "./common"; +import { ENV0_API_URL } from "./common"; import { EnvironmentModel } from "./get-environments"; import { DeploymentStepLogsResponse, DeploymentStepResponse } from "./types"; @@ -22,46 +21,77 @@ class ApiClient { } public async abortDeployment(deploymentId: string) { - return this.instance.post( + const response = await this.instance.post( `/environments/deployments/${deploymentId}/abort`, {} ); + return response.data; } public async redeployEnvironment(envId: string) { - return this.instance.post(`/environments/${envId}/deployments`, {}); + const response = await this.instance.post<{ id: string }>( + `/environments/${envId}/deployments`, + {} + ); + return response.data; } public async cancelDeployment(deploymentId: string) { - return this.instance.put( + const response = await this.instance.put<{ id: string }>( `/environments/deployments/${deploymentId}/cancel` ); + return response.data; } public async resumeDeployment(deploymentId: string) { - return this.instance.put(`/environments/deployments/${deploymentId}`); + const response = await this.instance.put<{ id: string }>( + `/environments/deployments/${deploymentId}` + ); + return response.data; } public async destroyEnvironment(deploymentId: string) { - this.instance.post(`/environments/${deploymentId}/destroy`, {}); + const response = await this.instance.post<{ id: string }>( + `/environments/${deploymentId}/destroy`, + {} + ); + return response.data; } public async getEnvironments(organizationId: string) { - const res = await this.instance.get(`/environments`, { - params: { organizationId }, - }); + const response = await this.instance.get( + `/environments`, + { + params: { organizationId }, + } + ); - return res.data; + return response.data; } public async getOrganizations() { - const res = await this.instance.get(`/organizations`); - return res.data; + const response = await this.instance.get(`/organizations`); + return response.data; } - public async getDeploymentSteps(deploymentLogId: string) { + public async getDeployment( + deploymentLogId: string, + abortController?: AbortController + ) { + const response = await this.instance.get( + `environments/deployments/${deploymentLogId}`, + { signal: abortController?.signal } + ); + return response.data; + } + + public async getDeploymentSteps( + deploymentLogId: string, + abortController?: AbortController + ) { const response = await this.instance.get( - `/deployments/${deploymentLogId}/steps` + `/deployments/${deploymentLogId}/steps`, + { signal: abortController?.signal } ); return response.data; } @@ -69,12 +99,14 @@ class ApiClient { public async getDeploymentStepLogs( deploymentLogId: string, stepName: string, - stepStartTime?: string | number + stepStartTime?: string | number, + abortController?: AbortController ) { const response = await this.instance.get( `/deployments/${deploymentLogId}/steps/${stepName}/log?startTime=${ stepStartTime ?? "" - }` + }`, + { signal: abortController?.signal } ); return response.data; } diff --git a/src/env0-environments-provider.ts b/src/env0-environments-provider.ts index 02253ca..ae8a5e4 100644 --- a/src/env0-environments-provider.ts +++ b/src/env0-environments-provider.ts @@ -62,6 +62,10 @@ export class Env0EnvironmentsProvider ); } + getParent(element: Environment): vscode.ProviderResult { + return null; + } + private shouldUpdate(environmentsToCompareTo: EnvironmentModel[]): boolean { if (environmentsToCompareTo.length !== this.environments.length) { return true; diff --git a/src/environment-logs-provider.ts b/src/environment-logs-provider.ts new file mode 100644 index 0000000..5c0b94f --- /dev/null +++ b/src/environment-logs-provider.ts @@ -0,0 +1,228 @@ +import * as vscode from "vscode"; +import { Environment } from "./env0-environments-provider"; +import { apiClient } from "./api-client"; +import stripAnsi from "strip-ansi"; +import { + DeploymentStatus, + DeploymentStepLog, + DeploymentStepStatus, +} from "./types"; +import axios from "axios"; +import { setMaxListeners } from "events"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const pollStepLogsInterval = 1000; + +export class EnvironmentLogsProvider { + private static environmentLogsOutputChannel: vscode.OutputChannel; + private readonly abortController = new AbortController(); + private _isAborted = false; + private readonly stepsAlreadyLogged: string[] = []; + constructor( + private readonly env: Environment, + private readonly deploymentId?: string + ) { + // @ts-ignore + setMaxListeners(20, this.abortController.signal); + this.log("Loading logs..."); + if (this.deploymentId) { + this.logDeployment(this.deploymentId).catch((e) => { + if (!axios.isCancel(e)) { + throw e; + } + }); + } else { + this.logDeployment(this.env.latestDeploymentLogId).catch((e) => { + if (!axios.isCancel(e)) { + throw e; + } + }); + } + } + + abort() { + this.abortController.abort(); + this._isAborted = true; + EnvironmentLogsProvider.environmentLogsOutputChannel.clear(); + } + + get isAborted() { + return this._isAborted; + } + + private log(value: string) { + if (!this.isAborted) { + const channel = EnvironmentLogsProvider.environmentLogsOutputChannel; + if (!channel) { + throw new Error( + "environment logs output channel used before initialized" + ); + } + channel.appendLine(value); + } + } + + private async logDeployment(deploymentId: string) { + EnvironmentLogsProvider.environmentLogsOutputChannel.show(); + + const { status } = await apiClient.getDeployment( + deploymentId, + this.abortController + ); + if ( + ![DeploymentStatus.QUEUED, DeploymentStatus.IN_PROGRESS].includes(status) + ) { + return this.logCompletedDeployment(deploymentId); + } + return this.logInProgressDeployment(deploymentId); + } + + private async logCompletedDeployment(deploymentId: string) { + const steps = await apiClient.getDeploymentSteps( + deploymentId, + this.abortController + ); + + const stepsEvents = steps.map(async (step) => ({ + name: step.name, + events: await this.getStepLogs(deploymentId, step.name), + })); + + for await (const step of stepsEvents) { + this.log(`$$$ ${step.name}`); + this.log("#".repeat(100)); + step.events.forEach((event) => this.log(stripAnsi(event.message))); + } + } + + private async getStepLogs( + deploymentId: string, + stepName: string, + startTime?: string | number + ): Promise { + const { events, nextStartTime, hasMoreLogs } = + await apiClient.getDeploymentStepLogs( + deploymentId, + stepName, + startTime, + this.abortController + ); + if (hasMoreLogs && nextStartTime) { + events.concat( + await this.getStepLogs(deploymentId, stepName, nextStartTime) + ); + } + return events; + } + + private async logInProgressDeployment(deploymentId: string) { + let previousStatus; + + while (!this.isAborted) { + const { type, status } = await apiClient.getDeployment( + deploymentId, + this.abortController + ); + + if (status === DeploymentStatus.QUEUED) { + if (previousStatus === DeploymentStatus.QUEUED) { + this.log( + "Queued deployment is still waiting for earlier deployments to finish..." + ); + } else { + this.log("Deployment is queued! Waiting for it to start..."); + } + previousStatus = status; + await sleep(pollStepLogsInterval); + continue; + } + + if ( + status === DeploymentStatus.IN_PROGRESS && + previousStatus === DeploymentStatus.QUEUED + ) { + this.log(`Deployment reached its turn! ${type} is starting...`); + } + + await this.processDeploymentSteps(deploymentId); + + if ( + ![DeploymentStatus.QUEUED, DeploymentStatus.IN_PROGRESS].includes( + status + ) + ) { + if (status === "WAITING_FOR_USER") { + this.log("Deployment is waiting for an approval."); + } + + return status; + } + + previousStatus = status; + await sleep(pollStepLogsInterval); + } + } + + private async processDeploymentSteps(deploymentId: string) { + const steps = await apiClient.getDeploymentSteps( + deploymentId, + this.abortController + ); + + for (const step of steps) { + const alreadyLogged = this.stepsAlreadyLogged.includes(step.name); + + if (!alreadyLogged && step.status !== DeploymentStepStatus.NOT_STARTED) { + this.log(`$$$ ${step.name}`); + this.log("#".repeat(100)); + await this.writeDeploymentStepLog(deploymentId, step.name); + this.stepsAlreadyLogged.push(step.name); + } + } + } + + private async writeDeploymentStepLog( + deploymentLogId: string, + stepName: string + ) { + let shouldPoll = false; + let startTime: number | string; + + do { + const steps = await apiClient.getDeploymentSteps( + deploymentLogId, + this.abortController + ); + + const { status } = steps.find((step) => step.name === stepName) || {}; + const isStepInProgress = status === DeploymentStatus.IN_PROGRESS; + + const { events, nextStartTime, hasMoreLogs } = + await apiClient.getDeploymentStepLogs( + deploymentLogId, + stepName, + startTime!, + this.abortController + ); + + events.forEach((event) => this.log(stripAnsi(event.message))); + + if (nextStartTime) { + startTime = nextStartTime; + } + + if (isStepInProgress) { + await sleep(pollStepLogsInterval); + } + + shouldPoll = hasMoreLogs || isStepInProgress; + } while (shouldPoll); + } + + static initEnvironmentOutputChannel() { + this.environmentLogsOutputChannel = vscode.window.createOutputChannel( + `env0 logs`, + "ansi" + ); + } +} diff --git a/src/extension.ts b/src/extension.ts index 17b7402..8fca81a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,4 @@ import * as vscode from "vscode"; -import stripAnsi from "strip-ansi"; import { AuthService } from "./auth"; import { Env0EnvironmentsProvider, @@ -8,6 +7,7 @@ import { import { getCurrentBranchWithRetry } from "./utils/git"; import { apiClient } from "./api-client"; import { ENV0_ENVIRONMENTS_VIEW_ID } from "./common"; +import { EnvironmentLogsProvider } from "./environment-logs-provider"; import { registerEnvironmentActions } from "./actions"; let logPoller: NodeJS.Timeout; @@ -15,6 +15,7 @@ let environmentPollingInstance: NodeJS.Timer; let _context: vscode.ExtensionContext; export let environmentsTree: vscode.TreeView; export let environmentsDataProvider: Env0EnvironmentsProvider; +let environmentLogsProvider: EnvironmentLogsProvider; // this function used by tests in order to reset the extension state after each test export const _reset = async () => { deactivate(); @@ -49,30 +50,34 @@ export const loadEnvironments = async ( environmentsTree.message = undefined; }; +const restartLogs = async (env: Environment, deploymentId?: string) => { + if (environmentLogsProvider) { + environmentLogsProvider.abort(); + } + environmentLogsProvider = new EnvironmentLogsProvider(env, deploymentId); +}; + const init = async ( context: vscode.ExtensionContext, environmentsDataProvider: Env0EnvironmentsProvider, environmentsTree: vscode.TreeView ) => { await loadEnvironments(environmentsDataProvider, environmentsTree); - const logChannels: Record = {}; - - async function restartLogs(env: Environment) { - Object.values(logChannels).forEach((l) => l.channel.dispose()); - Object.keys(logChannels).forEach((key) => delete logChannels[key]); - clearInterval(logPoller); - if (env.id) { - logPoller = await pollForEnvironmentLogs(env, logChannels); - } - } environmentsTree.onDidChangeSelection(async (e) => { - const env = e.selection[0] ?? e.selection; + const env = e.selection[0]; - restartLogs(env); + if (env) { + restartLogs(env); + } }); - registerEnvironmentActions(context, environmentsDataProvider, restartLogs); + registerEnvironmentActions( + context, + environmentsTree, + environmentsDataProvider, + restartLogs + ); environmentPollingInstance = setInterval(async () => { environmentsDataProvider.refresh(); }, 3000); @@ -80,6 +85,7 @@ const init = async ( export async function activate(context: vscode.ExtensionContext) { _context = context; + EnvironmentLogsProvider.initEnvironmentOutputChannel(); const authService = new AuthService(context); authService.registerLoginCommand(); authService.registerLogoutCommand(); @@ -106,50 +112,3 @@ export function deactivate() { clearInterval(logPoller); clearInterval(environmentPollingInstance); } - -async function pollForEnvironmentLogs( - env: Environment, - logChannels: Record -) { - const logPoller = setInterval(async () => { - const steps = await apiClient.getDeploymentSteps(env.latestDeploymentLogId); - - steps.forEach(async (step) => { - let stepLog = logChannels[step.name]; - if (!stepLog) { - logChannels[step.name] = { - channel: vscode.window.createOutputChannel( - `(env0) ${step.name}`, - "ansi" - ), - }; - stepLog = logChannels[step.name]; - } - - if (stepLog.hasMoreLogs !== false) { - try { - const logs = await apiClient.getDeploymentStepLogs( - env.latestDeploymentLogId, - step.name, - stepLog.startTime - ); - - logs.events.forEach((event) => { - (logChannels[step.name].channel as vscode.OutputChannel).appendLine( - stripAnsi(event.message) - ); - }); - stepLog.startTime = logs.nextStartTime; - stepLog.hasMoreLogs = logs.hasMoreLogs; - if (step.status === "IN_PROGRESS") { - stepLog.channel.show(); - } - } catch (e) { - console.error("oh no", { e }); - } - } - }); - }, 1000); - - return logPoller; -} diff --git a/src/types.ts b/src/types.ts index 177976e..d68d694 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,3 +39,27 @@ export interface Credentials { keyId: string; secret: string; } + +export enum DeploymentStatus { + IN_PROGRESS = "IN_PROGRESS", + FAILURE = "FAILURE", + SUCCESS = "SUCCESS", + TIMEOUT = "TIMEOUT", + INTERNAL_FAILURE = "INTERNAL_FAILURE", + CANCELLED = "CANCELLED", + ABORTED = "ABORTED", + ABORTING = "ABORTING", + QUEUED = "QUEUED", + SKIPPED = "SKIPPED", +} + +export enum DeploymentStepStatus { + NOT_STARTED = "NOT_STARTED", + IN_PROGRESS = "IN_PROGRESS", + WAITING_FOR_USER = "WAITING_FOR_USER", + FAIL = "FAIL", + SUCCESS = "SUCCESS", + CANCELLED = "CANCELLED", + TIMEOUT = "TIMEOUT", + SKIPPED = "SKIPPED", +}