diff --git a/extensions/vscode/src/api/types/events.ts b/extensions/vscode/src/api/types/events.ts index 07a628037..31857c960 100644 --- a/extensions/vscode/src/api/types/events.ts +++ b/extensions/vscode/src/api/types/events.ts @@ -1254,7 +1254,7 @@ export interface PublishFailure extends EventStreamMessage { data: { dashboardUrl: string; url: string; - canceled?: string; // not defined if not user cancelled. Value of "true" if true. + canceled?: string; // not defined if not user canceled. Value of "true" if true. // and other non-defined attributes }; error: string; // translated internally diff --git a/extensions/vscode/src/events.ts b/extensions/vscode/src/events.ts index 0d09258e1..7d2cb2a6e 100644 --- a/extensions/vscode/src/events.ts +++ b/extensions/vscode/src/events.ts @@ -76,6 +76,9 @@ export function displayEventStreamMessage(msg: EventStreamMessage): string { if (msg.data.dashboardUrl) { return `Deployment failed, click to view Connect logs: ${msg.data.dashboardUrl}`; } + if (msg.data.canceled === "true") { + return "Deployment canceled"; + } return "Deployment failed"; } if (msg.error !== undefined) { @@ -95,8 +98,8 @@ export class EventStream extends Readable implements Disposable { private messages: EventStreamMessage[] = []; // Map to store event callbacks private callbacks: Map = new Map(); - // Cancelled Event Streams - Suppressed when received - private cancelledLocalIDs: string[] = []; + // Canceled Event Streams - Suppressed when received + private canceledLocalIDs: string[] = []; /** * Creates a new instance of the EventStream class. @@ -170,19 +173,25 @@ export class EventStream extends Readable implements Disposable { * @returns undefined */ public suppressMessages(localId: string) { - this.cancelledLocalIDs.push(localId); + this.canceledLocalIDs.push(localId); } private processMessage(msg: EventStreamMessage) { - const localId = msg.data.localId; - if (localId && this.cancelledLocalIDs.includes(localId)) { + // Some log messages passed on from Connect include + // the localId using snake_case, rather than pascalCase. + // To filter correctly, we need to check for both. + + const localId = msg.data.localId || msg.data.local_id; + if (localId && this.canceledLocalIDs.includes(localId)) { // suppress and ignore return; } + // Trace message - // console.debug( - // `eventSource trace: ${event.type}: ${JSON.stringify(event)}`, - // ); + // Uncomment the following code if you want to dump every message to the + // console as it is received. + // console.debug(`eventSource trace: ${msg.type}: ${JSON.stringify(msg)}`); + // Add the message to the messages array this.messages.push(msg); // Emit a 'message' event with the message as the payload diff --git a/extensions/vscode/src/multiStepInputs/newCredential.ts b/extensions/vscode/src/multiStepInputs/newCredential.ts index e2210d55c..145116ea9 100644 --- a/extensions/vscode/src/multiStepInputs/newCredential.ts +++ b/extensions/vscode/src/multiStepInputs/newCredential.ts @@ -54,7 +54,7 @@ export async function newCredential( totalSteps: -1, data: { // each attribute is initialized to undefined - // to be returned when it has not been cancelled + // to be returned when it has not been canceled url: startingServerUrl, // eventual type is string apiKey: undefined, // eventual type is string name: undefined, // eventual type is string diff --git a/extensions/vscode/src/multiStepInputs/newDeployment.ts b/extensions/vscode/src/multiStepInputs/newDeployment.ts index d80bf812e..7d6d09a27 100644 --- a/extensions/vscode/src/multiStepInputs/newDeployment.ts +++ b/extensions/vscode/src/multiStepInputs/newDeployment.ts @@ -357,7 +357,7 @@ export async function newDeployment( title: "Select Entrypoint File (main file for your project)", }); if (!fileUris || !fileUris[0]) { - // cancelled. + // canceled. continue; } const fileUri = fileUris[0]; diff --git a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts index f6a1134f2..435db01c3 100644 --- a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts +++ b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts @@ -245,7 +245,7 @@ export async function selectNewOrExistingConfig( totalSteps: -1, data: { // each attribute is initialized to undefined - // to be returned when it has not been cancelled to assist type guards + // to be returned when it has not been canceled to assist type guards // Note: We can't initialize existingConfigurationName to a specific initial // config, as we then wouldn't be able to detect if the user hit ESC to exit // the selection. :-( diff --git a/extensions/vscode/src/state.ts b/extensions/vscode/src/state.ts index 0280ff576..944f587f4 100644 --- a/extensions/vscode/src/state.ts +++ b/extensions/vscode/src/state.ts @@ -167,6 +167,26 @@ export class PublisherState implements Disposable { } } + updateContentRecord( + newValue: ContentRecord | PreContentRecord | PreContentRecordWithConfig, + ) { + const existingContentRecord = this.findContentRecord( + newValue.saveName, + newValue.projectDir, + ); + if (existingContentRecord) { + const crIndex = this.contentRecords.findIndex( + (contentRecord) => + contentRecord.deploymentPath === existingContentRecord.deploymentPath, + ); + if (crIndex !== -1) { + this.contentRecords[crIndex] = newValue; + } else { + this.contentRecords.push(newValue); + } + } + } + async getSelectedConfiguration() { const contentRecord = await this.getSelectedContentRecord(); if (!contentRecord) { diff --git a/extensions/vscode/src/views/deployProgress.ts b/extensions/vscode/src/views/deployProgress.ts index cabe4a681..24ce89645 100644 --- a/extensions/vscode/src/views/deployProgress.ts +++ b/extensions/vscode/src/views/deployProgress.ts @@ -1,15 +1,27 @@ // Copyright (C) 2024 by Posit Software, PBC. import { ProgressLocation, Uri, env, window } from "vscode"; -import { EventStreamMessage, eventMsgToString, useApi } from "src/api"; +import { + EventStreamMessage, + eventMsgToString, + useApi, + ContentRecord, + PreContentRecord, + PreContentRecordWithConfig, +} from "src/api"; import { EventStream, UnregisterCallback } from "src/events"; import { getSummaryStringFromError } from "src/utils/errors"; +type UpdateActiveContentRecordCB = ( + contentRecord: ContentRecord | PreContentRecord | PreContentRecordWithConfig, +) => void; + export function deployProject( deploymentName: string, dir: string, localID: string, stream: EventStream, + updateActiveContentRecordCB: UpdateActiveContentRecordCB, ) { window.withProgress( { @@ -38,11 +50,15 @@ export function deployProject( streamID = "NEVER_A_VALID_STREAM"; unregisterAll(); try { - await api.contentRecords.cancelDeployment( + const response = await api.contentRecords.cancelDeployment( deploymentName, dir, localID, ); + + // update the UX locally + updateActiveContentRecordCB(response.data); + // we must have been successful... // inject a psuedo end of publishing event stream.injectMessage({ @@ -53,7 +69,7 @@ export function deployProject( url: "", // and other non-defined attributes localId: localID, - cancelled: "true", + canceled: "true", message: "Deployment has been dismissed (but will continue to be processed on the Connect Server).", }, @@ -65,9 +81,7 @@ export function deployProject( "deployProject, token.onCancellationRequested", error, ); - window.showErrorMessage( - `Unable to abort deployment: ${summary}`, - ); + window.showErrorMessage(`Unable to abort deployment: ${summary}`); } resolveCB(); }); diff --git a/extensions/vscode/src/views/homeView.ts b/extensions/vscode/src/views/homeView.ts index c5b717c61..c474aabb1 100644 --- a/extensions/vscode/src/views/homeView.ts +++ b/extensions/vscode/src/views/homeView.ts @@ -36,6 +36,7 @@ import { useApi, AllContentRecordTypes, EnvironmentConfig, + PreContentRecordWithConfig, } from "src/api"; import { EventStream } from "src/events"; import { getPythonInterpreterPath, getRInterpreterPath } from "../utils/vscode"; @@ -213,6 +214,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { projectDir, response.data.localId, this.stream, + this.updateActiveContentRecordLocally.bind(this), ); } catch (error: unknown) { // Most failures will occur on the event stream. These are the ones which @@ -315,6 +317,19 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { } } + private updateActiveContentRecordLocally( + activeContentRecord: + | ContentRecord + | PreContentRecord + | PreContentRecordWithConfig, + ) { + // update our local state, so we don't wait on file refreshes + this.state.updateContentRecord(activeContentRecord); + + // refresh the webview + this.updateWebViewViewContentRecords(); + } + private onPublishStart() { this.webviewConduit.sendMsg({ kind: HostToWebviewMessageType.PUBLISH_START, @@ -954,7 +969,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { activeConfig.configuration.environment, ); if (name === undefined) { - // Cancelled by the user + // Canceled by the user return; } diff --git a/extensions/vscode/src/views/logs.ts b/extensions/vscode/src/views/logs.ts index 3ed65f493..6f3b46251 100644 --- a/extensions/vscode/src/views/logs.ts +++ b/extensions/vscode/src/views/logs.ts @@ -41,6 +41,7 @@ enum LogStageStatus { inProgress, completed, failed, + canceled, } type LogStage = { @@ -182,14 +183,17 @@ export class LogsTreeDataProvider implements TreeDataProvider { }); this.stream.register("publish/failure", async (msg: EventStreamMessage) => { - this.publishingStage.status = LogStageStatus.failed; + const failedOrCanceledStatus = msg.data.canceled + ? LogStageStatus.canceled + : LogStageStatus.failed; + this.publishingStage.status = failedOrCanceledStatus; this.publishingStage.events.push(msg); this.stages.forEach((stage) => { if (stage.status === LogStageStatus.notStarted) { stage.status = LogStageStatus.neverStarted; } else if (stage.status === LogStageStatus.inProgress) { - stage.status = LogStageStatus.failed; + stage.status = failedOrCanceledStatus; } }); @@ -204,8 +208,8 @@ export class LogsTreeDataProvider implements TreeDataProvider { errorMessage = handleEventCodedError(msg); } else { errorMessage = - msg.data.cancelled === "true" - ? `Deployment cancelled: ${msg.data.message}` + msg.data.canceled === "true" + ? `Deployment canceled: ${msg.data.message}` : `Deployment failed: ${msg.data.message}`; } const selection = await window.showErrorMessage(errorMessage, ...options); @@ -259,7 +263,11 @@ export class LogsTreeDataProvider implements TreeDataProvider { (msg: EventStreamMessage) => { const stage = this.stages.get(stageName); if (stage) { - stage.status = LogStageStatus.failed; + if (msg.data.canceled === "true") { + stage.status = LogStageStatus.canceled; + } else { + stage.status = LogStageStatus.failed; + } stage.events.push(msg); } this.refresh(); @@ -413,6 +421,11 @@ export class LogsTreeStageItem extends TreeItem { this.iconPath = new ThemeIcon("error"); this.collapsibleState = TreeItemCollapsibleState.Expanded; break; + case LogStageStatus.canceled: + this.label = this.stage.inactiveLabel; + this.iconPath = new ThemeIcon("circle-slash"); + this.collapsibleState = TreeItemCollapsibleState.Expanded; + break; } } } diff --git a/extensions/vscode/webviews/homeView/src/components/EvenEasierDeploy.vue b/extensions/vscode/webviews/homeView/src/components/EvenEasierDeploy.vue index 1685a127b..439493223 100644 --- a/extensions/vscode/webviews/homeView/src/components/EvenEasierDeploy.vue +++ b/extensions/vscode/webviews/homeView/src/components/EvenEasierDeploy.vue @@ -406,7 +406,7 @@ const lastStatusDescription = computed(() => { : "Not Yet Deployed"; } if (isAbortedContentRecord.value) { - return "Last Deployment Cancelled"; + return "Last Deployment Canceled"; } return "Last Deployment Successful"; }); diff --git a/extensions/vscode/webviews/homeView/vite.config.ts b/extensions/vscode/webviews/homeView/vite.config.ts index 313cd65b3..af8b8fa31 100644 --- a/extensions/vscode/webviews/homeView/vite.config.ts +++ b/extensions/vscode/webviews/homeView/vite.config.ts @@ -41,9 +41,9 @@ export default defineConfig({ enabled: true, thresholds: { functions: 30.13, - lines: 17.46, + lines: 17.37, branches: 44.82, - statements: 17.46, + statements: 17.37, autoUpdate: true, }, }, diff --git a/internal/deployment/deployment.go b/internal/deployment/deployment.go index 30abbf38a..e4f4054e5 100644 --- a/internal/deployment/deployment.go +++ b/internal/deployment/deployment.go @@ -177,7 +177,7 @@ func (d *Deployment) WriteFile( return existingDeployment, nil } if existingDeployment.AbortedAt != "" { - log.Debug("Skipping deployment record update since deployment has been cancelled") + log.Debug("Skipping deployment record update since deployment has been canceled") return existingDeployment, nil } } diff --git a/internal/publish/publish.go b/internal/publish/publish.go index 46d8e4db2..59504c410 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -224,10 +224,10 @@ func (p *defaultPublisher) writeDeploymentRecord(forceUpdate bool) (*deployment. func CancelDeployment( deploymentPath util.AbsolutePath, - localID state.LocalDeploymentID, + localID string, log logging.Logger, ) (*deployment.Deployment, error) { - // This function only marks the deployment record as being cancelled. + // This function only marks the deployment record as being canceled. // It does not cancel the anonymous function which is publishing to the server // This is because the server API does not support cancellation at this time. @@ -240,7 +240,7 @@ func CancelDeployment( target.AbortedAt = time.Now().Format(time.RFC3339) // Possibly update the deployment file - d, err := target.WriteFile(deploymentPath, target.LocalID, false, log) + d, err := target.WriteFile(deploymentPath, localID, false, log) return d, err } diff --git a/internal/services/api/api_service.go b/internal/services/api/api_service.go index e86f6ca74..a7cbe961d 100644 --- a/internal/services/api/api_service.go +++ b/internal/services/api/api_service.go @@ -170,7 +170,7 @@ func RouterHandlerFunc(base util.AbsolutePath, lister accounts.AccountList, log Methods(http.MethodPost) // POST /api/deployments/$NAME/cancel/$LOCALID cancels a deployment - r.Handle(ToPath("deployments", "{name}", "cancel", "{localid}"), PostCancelDeploymentHandlerFunc(base, log)). + r.Handle(ToPath("deployments", "{name}", "cancel", "{localid}"), PostDeploymentCancelHandlerFunc(base, log)). Methods(http.MethodPost) // DELETE /api/deployments/$NAME diff --git a/internal/services/api/deployment_dto.go b/internal/services/api/deployment_dto.go index 61be0d50b..0b3063efb 100644 --- a/internal/services/api/deployment_dto.go +++ b/internal/services/api/deployment_dto.go @@ -32,6 +32,8 @@ type preDeploymentDTO struct { ServerURL string `json:"serverUrl"` SaveName string `json:"saveName"` CreatedAt string `json:"createdAt"` + LocalID string `toml:"local_id,omitempty" json:"localId"` + AbortedAt string `toml:"aborted_at,omitempty" json:"abortedAt"` ConfigName string `json:"configurationName,omitempty"` ConfigPath string `json:"configurationPath,omitempty"` Error *types.AgentError `json:"deploymentError,omitempty"` @@ -105,6 +107,8 @@ func deploymentAsDTO(d *deployment.Deployment, err error, projectDir util.Absolu ServerURL: d.ServerURL, SaveName: saveName, // TODO: remove this duplicate (remove frontend references first) CreatedAt: d.CreatedAt, + AbortedAt: d.AbortedAt, + LocalID: d.LocalID, ConfigName: d.ConfigName, ConfigPath: configPath, Error: d.Error, diff --git a/internal/services/api/post_cancel_deployment.go b/internal/services/api/post_deployment_cancel.go similarity index 84% rename from internal/services/api/post_cancel_deployment.go rename to internal/services/api/post_deployment_cancel.go index c3869873c..7123ce455 100644 --- a/internal/services/api/post_cancel_deployment.go +++ b/internal/services/api/post_deployment_cancel.go @@ -12,11 +12,10 @@ import ( "github.com/posit-dev/publisher/internal/deployment" "github.com/posit-dev/publisher/internal/logging" "github.com/posit-dev/publisher/internal/publish" - "github.com/posit-dev/publisher/internal/state" "github.com/posit-dev/publisher/internal/util" ) -func PostCancelDeploymentHandlerFunc(base util.AbsolutePath, log logging.Logger) http.HandlerFunc { +func PostDeploymentCancelHandlerFunc(base util.AbsolutePath, log logging.Logger) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { name := mux.Vars(req)["name"] localId := mux.Vars(req)["localid"] @@ -26,7 +25,7 @@ func PostCancelDeploymentHandlerFunc(base util.AbsolutePath, log logging.Logger) return } path := deployment.GetDeploymentPath(projectDir, name) - latest, err := publish.CancelDeployment(path, state.LocalDeploymentID(localId), log) + latest, err := publish.CancelDeployment(path, localId, log) if err != nil { if errors.Is(err, fs.ErrNotExist) { http.NotFound(w, req) diff --git a/internal/services/api/post_deployment_cancel_test.go b/internal/services/api/post_deployment_cancel_test.go new file mode 100644 index 000000000..bef0e3c4e --- /dev/null +++ b/internal/services/api/post_deployment_cancel_test.go @@ -0,0 +1,124 @@ +// Copyright (C) 2024 by Posit Software, PBC. + +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/posit-dev/publisher/internal/deployment" + "github.com/posit-dev/publisher/internal/logging" + "github.com/posit-dev/publisher/internal/util" + "github.com/posit-dev/publisher/internal/util/utiltest" + "github.com/spf13/afero" + "github.com/stretchr/testify/suite" +) + +type PostDeploymentCancelTestSuite struct { + utiltest.Suite + log logging.Logger + fs afero.Fs + cwd util.AbsolutePath +} + +func TestPostDeploymentCancelTestSuite(t *testing.T) { + suite.Run(t, new(PostDeploymentCancelTestSuite)) +} + +func (s *PostDeploymentCancelTestSuite) SetupSuite() { + s.log = logging.New() +} + +func (s *PostDeploymentCancelTestSuite) SetupTest() { + s.fs = afero.NewMemMapFs() + cwd, err := util.Getwd(s.fs) + s.Nil(err) + s.cwd = cwd + s.cwd.MkdirAll(0700) +} + +func (s *PostDeploymentCancelTestSuite) Test200WithLocalIDMatch() { + deploymentName := "newDeployment" + localId := "abc" + + d := deployment.New() + d.LocalID = localId + + _, err := d.WriteFile(deployment.GetDeploymentPath(s.cwd, deploymentName), "abc", true, s.log) + s.NoError(err) + + h := PostDeploymentCancelHandlerFunc(s.cwd, s.log) + + rec := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/deployments/"+deploymentName+"/cancel/"+localId, nil) + s.NoError(err) + req = mux.SetURLVars(req, map[string]string{ + "name": deploymentName, + "localid": localId, + }) + h(rec, req) + + s.Equal(http.StatusOK, rec.Result().StatusCode) + s.Equal("application/json", rec.Header().Get("content-type")) + + res := preDeploymentDTO{} + dec := json.NewDecoder(rec.Body) + dec.DisallowUnknownFields() + s.NoError(dec.Decode(&res)) + + s.NotEmpty(res.AbortedAt) +} + +func (s *PostDeploymentCancelTestSuite) Test200WithoutLocalIDMatch() { + deploymentName := "newDeployment" + + d := deployment.New() + d.LocalID = "abc" + + _, err := d.WriteFile(deployment.GetDeploymentPath(s.cwd, deploymentName), "abc", true, s.log) + s.NoError(err) + + h := PostDeploymentCancelHandlerFunc(s.cwd, s.log) + + rec := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/deployments/"+deploymentName+"/cancel/xyz", nil) + s.NoError(err) + req = mux.SetURLVars(req, map[string]string{ + "name": deploymentName, + "localid": "xyz", // not current localID in file + }) + h(rec, req) + + s.Equal(http.StatusOK, rec.Result().StatusCode) + s.Equal("application/json", rec.Header().Get("content-type")) + + res := preDeploymentDTO{} + dec := json.NewDecoder(rec.Body) + dec.DisallowUnknownFields() + s.NoError(dec.Decode(&res)) + + // request was successful but not applied + s.Empty(res.AbortedAt) +} + +func (s *PostDeploymentCancelTestSuite) Test404() { + deploymentName := "newDeployment" + localId := "abc" + + h := PostDeploymentCancelHandlerFunc(s.cwd, s.log) + + rec := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/deployments/"+deploymentName+"/cancel/"+localId, nil) + s.NoError(err) + req = mux.SetURLVars(req, map[string]string{ + "name": deploymentName, + "localid": localId, + }) + h(rec, req) + + s.Equal(http.StatusNotFound, rec.Result().StatusCode) + s.Equal("text/plain; charset=utf-8", rec.Header().Get("content-type")) +}