diff --git a/src/commands/revision/chooseRevisionMode/chooseRevisionMode.ts b/src/commands/revision/chooseRevisionMode/chooseRevisionMode.ts index 7be217e38..c30580ef0 100644 --- a/src/commands/revision/chooseRevisionMode/chooseRevisionMode.ts +++ b/src/commands/revision/chooseRevisionMode/chooseRevisionMode.ts @@ -19,7 +19,7 @@ export async function chooseRevisionMode(context: IActionContext, item?: Contain item ??= await pickContainerApp(context); let hasRevisionDraft: boolean | undefined; - if (item instanceof ContainerAppItem) { + if (ContainerAppItem.isContainerAppItem(item)) { // A revision draft can exist but may be identical to the source, distinguishing the difference in single revisions mode // improves the user experience by allowing us to skip the confirm step, silently discarding drafts instead hasRevisionDraft = item.hasUnsavedChanges(); diff --git a/src/commands/revisionDraft/README.md b/src/commands/revisionDraft/README.md new file mode 100644 index 000000000..29adef298 --- /dev/null +++ b/src/commands/revisionDraft/README.md @@ -0,0 +1,9 @@ +## Detecting Unsaved Changes + +When comparing items for changes in single revision mode, prefer referencing the `containerApp` template. When comparing items in multiple revisions mode, prefer referencing the `revision` template. + +Reason: Even though the `containerApp` template is essentially equivalent to the `latest` revision template... sometimes there are micro-differences present. Although they end up being functionally equivalent, they may not always be equivalent enough to consistently pass a deep copy test (which is how we are set up to detect unsaved changes). + +## Data Sources for Tree Items + +Until the addition of revision drafts, the view has always reflected only one source of truth - the latest deployed changes. With the addition of revision drafts, the view now prioritizes showing the latest draft `Unsaved changes` when they are present. Model properties `containerApp` and `revision` should be kept consistent with the latest deployed changes so that methods like `hasUnsavedChanges` always have a reliable data reference for deep copy comparison. diff --git a/src/commands/revisionDraft/RevisionDraftFileSystem.ts b/src/commands/revisionDraft/RevisionDraftFileSystem.ts index c8e87a27e..5451961b7 100644 --- a/src/commands/revisionDraft/RevisionDraftFileSystem.ts +++ b/src/commands/revisionDraft/RevisionDraftFileSystem.ts @@ -3,15 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Template } from "@azure/arm-appcontainers"; +import { KnownActiveRevisionsMode, type Template } from "@azure/arm-appcontainers"; import { nonNullValueAndProp } from "@microsoft/vscode-azext-utils"; import { Disposable, Event, EventEmitter, FileChangeEvent, FileChangeType, FileStat, FileSystemProvider, FileType, TextDocument, Uri, window, workspace } from "vscode"; import { URI } from "vscode-uri"; import { ext } from "../../extensionVariables"; import { ContainerAppItem } from "../../tree/ContainerAppItem"; import type { ContainerAppsItem } from "../../tree/ContainerAppsBranchDataProvider"; -import type { RevisionDraftItem } from "../../tree/revisionManagement/RevisionDraftItem"; -import type { RevisionItem } from "../../tree/revisionManagement/RevisionItem"; +import type { RevisionsItemModel } from "../../tree/revisionManagement/RevisionItem"; import { localize } from "../../utils/localize"; const notSupported: string = localize('notSupported', 'This operation is not currently supported.'); @@ -50,23 +49,15 @@ export class RevisionDraftFileSystem implements FileSystemProvider { return this.emitter.event; } - // Create - createRevisionDraft(item: ContainerAppItem | RevisionItem | RevisionDraftItem): void { + createRevisionDraft(item: ContainerAppItem | RevisionsItemModel): void { const uri: Uri = this.buildUriFromItem(item); if (this.draftStore.has(uri.path)) { return; } + // Branching path reasoning: https://github.com/microsoft/vscode-azurecontainerapps/blob/main/src/commands/revisionDraft/README.md let file: RevisionDraftFile | undefined; - if (item instanceof ContainerAppItem) { - /** - * Sometimes there are micro-differences present between the latest revision template and the container app template. - * They end up being essentially equivalent, but not so equivalent as to always pass a deep copy test (which is how we detect unsaved changes). - * - * Since only container app template data is shown in single revision mode, and since revision data is not directly present on - * the container app tree item without further changes, it is easier to just use the container app template - * as the primary source of truth when in single revision mode. - */ + if (ContainerAppItem.isContainerAppItem(item) || item.containerApp.revisionsMode === KnownActiveRevisionsMode.Single) { const revisionContent: Uint8Array = Buffer.from(JSON.stringify(nonNullValueAndProp(item.containerApp, 'template'), undefined, 4)); file = new RevisionDraftFile(revisionContent, item.containerApp.id, nonNullValueAndProp(item.containerApp, 'latestRevisionName')); } else { @@ -78,8 +69,7 @@ export class RevisionDraftFileSystem implements FileSystemProvider { this.fireSoon({ type: FileChangeType.Created, uri }); } - // Read - parseRevisionDraft(item: T): Template | undefined { + parseRevisionDraft(item: ContainerAppsItem): Template | undefined { const uri: URI = this.buildUriFromItem(item); if (!this.draftStore.has(uri.path)) { return undefined; @@ -93,12 +83,12 @@ export class RevisionDraftFileSystem implements FileSystemProvider { return contents ? Buffer.from(contents) : Buffer.from(''); } - doesContainerAppsItemHaveRevisionDraft(item: T): boolean { + doesContainerAppsItemHaveRevisionDraft(item: ContainerAppsItem): boolean { const uri: Uri = this.buildUriFromItem(item); return this.draftStore.has(uri.path); } - getRevisionDraftFile(item: T): RevisionDraftFile | undefined { + getRevisionDraftFile(item: ContainerAppsItem): RevisionDraftFile | undefined { const uri: Uri = this.buildUriFromItem(item); return this.draftStore.get(uri.path); } @@ -118,8 +108,7 @@ export class RevisionDraftFileSystem implements FileSystemProvider { } } - // Update - async editRevisionDraft(item: ContainerAppItem | RevisionItem | RevisionDraftItem): Promise { + async editRevisionDraft(item: ContainerAppItem | RevisionsItemModel): Promise { const uri: Uri = this.buildUriFromItem(item); if (!this.draftStore.has(uri.path)) { this.createRevisionDraft(item); @@ -146,8 +135,17 @@ export class RevisionDraftFileSystem implements FileSystemProvider { ext.state.notifyChildrenChanged(file.containerAppId); } - // Delete - discardRevisionDraft(item: T): void { + updateRevisionDraftWithTemplate(item: RevisionsItemModel, template: Template): void { + const uri: Uri = this.buildUriFromItem(item); + if (!this.draftStore.has(uri.path)) { + this.createRevisionDraft(item); + } + + const newContent: Uint8Array = Buffer.from(JSON.stringify(template, undefined, 4)); + this.writeFile(uri, newContent); + } + + discardRevisionDraft(item: ContainerAppsItem): void { const uri: Uri = this.buildUriFromItem(item); if (!this.draftStore.has(uri.path)) { return; @@ -161,8 +159,7 @@ export class RevisionDraftFileSystem implements FileSystemProvider { this.fireSoon({ type: FileChangeType.Deleted, uri }); } - // Helper - private buildUriFromItem(item: T): Uri { + private buildUriFromItem(item: ContainerAppsItem): Uri { return URI.parse(`${RevisionDraftFileSystem.scheme}:/${item.containerApp.name}.json`); } diff --git a/src/commands/revisionDraft/RevisionDraftUpdateBaseStep.ts b/src/commands/revisionDraft/RevisionDraftUpdateBaseStep.ts new file mode 100644 index 000000000..2ef787597 --- /dev/null +++ b/src/commands/revisionDraft/RevisionDraftUpdateBaseStep.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Template } from "@azure/arm-appcontainers"; +import { AzureWizardExecuteStep, nonNullValueAndProp } from "@microsoft/vscode-azext-utils"; +import type { Progress } from "vscode"; +import { ext } from "../../extensionVariables"; +import type { RevisionsItemModel } from "../../tree/revisionManagement/RevisionItem"; +import type { IContainerAppContext } from "../IContainerAppContext"; + +export abstract class RevisionDraftUpdateBaseStep extends AzureWizardExecuteStep { + /** + * This property holds the active template revisions used for updating the revision draft + */ + protected revisionDraftTemplate: Template; + + constructor(readonly baseItem: RevisionsItemModel) { + super(); + this.revisionDraftTemplate = this.initRevisionDraftTemplate(); + } + + abstract execute(context: T, progress: Progress<{ message?: string | undefined; increment?: number | undefined }>): Promise; + abstract shouldExecute(context: T): boolean; + + /** + * Call this method to upload `revisionDraftTemplate` changes to the container app revision draft + */ + protected updateRevisionDraftWithTemplate(): void { + ext.revisionDraftFileSystem.updateRevisionDraftWithTemplate(this.baseItem, this.revisionDraftTemplate); + } + + private initRevisionDraftTemplate(): Template { + let template: Template | undefined = ext.revisionDraftFileSystem.parseRevisionDraft(this.baseItem); + if (!template) { + template = nonNullValueAndProp(this.baseItem.revision, 'template'); + } + return template; + } +} diff --git a/src/commands/scaling/addScaleRule/AddScaleRuleStep.ts b/src/commands/scaling/addScaleRule/AddScaleRuleStep.ts index 433362132..9e73a005f 100644 --- a/src/commands/scaling/addScaleRule/AddScaleRuleStep.ts +++ b/src/commands/scaling/addScaleRule/AddScaleRuleStep.ts @@ -3,55 +3,54 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { ScaleRule, Template } from "@azure/arm-appcontainers"; -import { AzureWizardExecuteStep, nonNullProp } from "@microsoft/vscode-azext-utils"; -import { Progress, window } from "vscode"; +import { type ScaleRule } from "@azure/arm-appcontainers"; +import { nonNullProp } from "@microsoft/vscode-azext-utils"; import { ScaleRuleTypes } from "../../../constants"; import { ext } from "../../../extensionVariables"; +import type { RevisionsItemModel } from "../../../tree/revisionManagement/RevisionItem"; import { localize } from "../../../utils/localize"; -import { updateContainerApp } from "../../../utils/updateContainerApp"; +import { getParentResource } from "../../../utils/revisionDraftUtils"; +import { RevisionDraftUpdateBaseStep } from "../../revisionDraft/RevisionDraftUpdateBaseStep"; import type { IAddScaleRuleContext } from "./IAddScaleRuleContext"; -export class AddScaleRuleStep extends AzureWizardExecuteStep { - public priority: number = 100; +export class AddScaleRuleStep extends RevisionDraftUpdateBaseStep { + public priority: number = 200; - public async execute(context: IAddScaleRuleContext, _progress: Progress<{ message?: string | undefined; increment?: number | undefined }>): Promise { - const adding = localize('addingScaleRule', 'Adding {0} rule "{1}" to "{2}"...', context.ruleType, context.ruleName, context.containerApp.name); - const added = localize('addedScaleRule', 'Successfully added {0} rule "{1}" to "{2}".', context.ruleType, context.ruleName, context.containerApp.name); + constructor(baseItem: RevisionsItemModel) { + super(baseItem); + } - const template: Template = context.containerApp.template || {}; - template.scale = context.scale || {}; - template.scale.rules = context.scaleRules || []; + public async execute(context: IAddScaleRuleContext): Promise { + this.revisionDraftTemplate.scale ||= {}; + this.revisionDraftTemplate.scale.rules ||= []; - const scaleRule: ScaleRule = this.buildRule(context); - this.integrateRule(context, template.scale.rules, scaleRule); + context.scaleRule = this.buildRule(context); + this.integrateRule(context, this.revisionDraftTemplate.scale.rules, context.scaleRule); + this.updateRevisionDraftWithTemplate(); - ext.outputChannel.appendLog(adding); - await updateContainerApp(context, context.subscription, context.containerApp, { template }); - context.scaleRule = scaleRule; - void window.showInformationMessage(added); - ext.outputChannel.appendLog(added); + const resourceName = getParentResource(context.containerApp, this.baseItem.revision).name; + ext.outputChannel.appendLog(localize('addedScaleRule', 'Added {0} rule "{1}" to "{2}" (draft)', context.newRuleType, context.newRuleName, resourceName)); } public shouldExecute(context: IAddScaleRuleContext): boolean { - return context.ruleName !== undefined && context.ruleType !== undefined; + return !!context.newRuleName && !!context.newRuleType; } private buildRule(context: IAddScaleRuleContext): ScaleRule { - const scaleRule: ScaleRule = { name: context.ruleName }; - switch (context.ruleType) { + const scaleRule: ScaleRule = { name: context.newRuleName }; + switch (context.newRuleType) { case ScaleRuleTypes.HTTP: scaleRule.http = { metadata: { - concurrentRequests: nonNullProp(context, 'concurrentRequests') + concurrentRequests: nonNullProp(context, 'newHttpConcurrentRequests') } }; break; case ScaleRuleTypes.Queue: scaleRule.azureQueue = { - queueName: context.queueName, - queueLength: context.queueLength, - auth: [{ secretRef: context.secretRef, triggerParameter: context.triggerParameter }] + queueName: context.newQueueName, + queueLength: context.newQueueLength, + auth: [{ secretRef: context.newQueueSecretRef, triggerParameter: context.newQueueTriggerParameter }] } break; default: @@ -60,12 +59,12 @@ export class AddScaleRuleStep extends AzureWizardExecuteStep rule.http); if (idx !== -1) { - scaleRules.splice(idx, 0); + scaleRules.splice(idx, 1); } break; case ScaleRuleTypes.Queue: diff --git a/src/commands/scaling/addScaleRule/IAddScaleRuleContext.ts b/src/commands/scaling/addScaleRule/IAddScaleRuleContext.ts index 025e71007..2d3a63712 100644 --- a/src/commands/scaling/addScaleRule/IAddScaleRuleContext.ts +++ b/src/commands/scaling/addScaleRule/IAddScaleRuleContext.ts @@ -3,29 +3,32 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Scale, ScaleRule } from "@azure/arm-appcontainers"; +import { ScaleRule } from "@azure/arm-appcontainers"; +import type { ExecuteActivityContext } from "@microsoft/vscode-azext-utils"; import type { ContainerAppModel } from "../../../tree/ContainerAppItem"; import type { IContainerAppContext } from "../../IContainerAppContext"; -export interface IAddScaleRuleContext extends IContainerAppContext { +export interface IAddScaleRuleContext extends IContainerAppContext, ExecuteActivityContext { // Make containerApp _required_ containerApp: ContainerAppModel; - scale: Scale; - scaleRules: ScaleRule[]; + /** + * The name of the parent resource (`ContainerAppModel | Revision`) + */ + parentResourceName: string; // Base Rule Properties - ruleName?: string; - ruleType?: string; + newRuleName?: string; + newRuleType?: string; // HTTP Rule Properties - concurrentRequests?: string; + newHttpConcurrentRequests?: string; // Queue Rule Properties - queueName?: string; - queueLength?: number; - secretRef?: string; - triggerParameter?: string; + newQueueName?: string; + newQueueLength?: number; + newQueueSecretRef?: string; + newQueueTriggerParameter?: string; scaleRule?: ScaleRule; } diff --git a/src/commands/scaling/addScaleRule/ScaleRuleNameStep.ts b/src/commands/scaling/addScaleRule/ScaleRuleNameStep.ts index ae45dc74f..d3507bef1 100644 --- a/src/commands/scaling/addScaleRule/ScaleRuleNameStep.ts +++ b/src/commands/scaling/addScaleRule/ScaleRuleNameStep.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { ScaleRule } from '@azure/arm-appcontainers'; +import { ContainerApp, ContainerAppsAPIClient, KnownActiveRevisionsMode, Revision, ScaleRule } from '@azure/arm-appcontainers'; import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import { createContainerAppsAPIClient } from '../../../utils/azureClients'; import { localize } from '../../../utils/localize'; import type { IAddScaleRuleContext } from './IAddScaleRuleContext'; @@ -12,31 +13,48 @@ export class ScaleRuleNameStep extends AzureWizardPromptStep { - context.ruleName = (await context.ui.showInputBox({ + context.newRuleName = (await context.ui.showInputBox({ prompt: localize('scaleRuleNamePrompt', 'Enter a name for the new scale rule.'), - validateInput: (name: string | undefined): string | undefined => { - return validateScaleRuleInput(name, context.containerApp?.name, context.scaleRules); - } + validateInput: this.validateInput, + asyncValidationTask: (name: string) => this.validateNameAvailable(context, name) })).trim(); } public shouldPrompt(context: IAddScaleRuleContext): boolean { - return context.ruleName === undefined; + return !context.newRuleName; } -} -function validateScaleRuleInput(name: string | undefined, containerAppName: string, scaleRules: ScaleRule[]): string | undefined { - name = name ? name.trim() : ''; + private validateInput(name: string | undefined): string | undefined { + name = name ? name.trim() : ''; + + if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(name)) { + return localize('invalidChar', `A name must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character.`); + } - if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(name)) { - return localize('invalidChar', `A name must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character.`); + return undefined; } - const scaleRuleExists: boolean = !!scaleRules?.some((rule) => { - return rule?.name?.length && rule?.name === name; - }); - if (scaleRuleExists) { - return localize('scaleRuleExists', 'The scale rule "{0}" already exists in container app "{1}". Please enter a unique name.', name, containerAppName); + private async validateNameAvailable(context: IAddScaleRuleContext, name: string): Promise { + const client: ContainerAppsAPIClient = await createContainerAppsAPIClient(context); + const resourceGroupName: string = context.containerApp.resourceGroup; + + let scaleRules: ScaleRule[] | undefined; + if (context.containerApp.revisionsMode === KnownActiveRevisionsMode.Single) { + const containerApp: ContainerApp = await client.containerApps.get(resourceGroupName, context.parentResourceName); + scaleRules = containerApp.template?.scale?.rules ?? []; + } else { + const revision: Revision = await client.containerAppsRevisions.getRevision(resourceGroupName, context.containerApp.name, context.parentResourceName); + scaleRules = revision.template?.scale?.rules ?? []; + } + + const scaleRuleExists: boolean = !!scaleRules?.some((rule) => { + return rule.name?.length && rule.name === name; + }); + + if (scaleRuleExists) { + return localize('scaleRuleExists', 'The scale rule "{0}" already exists in container app "{1}". Please enter a unique name.', name, context.containerApp.name); + } + + return undefined; } - return undefined; } diff --git a/src/commands/scaling/addScaleRule/ScaleRuleTypeStep.ts b/src/commands/scaling/addScaleRule/ScaleRuleTypeListStep.ts similarity index 82% rename from src/commands/scaling/addScaleRule/ScaleRuleTypeStep.ts rename to src/commands/scaling/addScaleRule/ScaleRuleTypeListStep.ts index 5f3a4cebe..56b45325c 100644 --- a/src/commands/scaling/addScaleRule/ScaleRuleTypeStep.ts +++ b/src/commands/scaling/addScaleRule/ScaleRuleTypeListStep.ts @@ -14,22 +14,26 @@ import { QueueAuthTriggerStep } from './queue/QueueAuthTriggerStep'; import { QueueLengthStep } from './queue/QueueLengthStep'; import { QueueNameStep } from './queue/QueueNameStep'; -export class ScaleRuleTypeStep extends AzureWizardPromptStep { +export class ScaleRuleTypeListStep extends AzureWizardPromptStep { public hideStepCount: boolean = true; public async prompt(context: IAddScaleRuleContext): Promise { - const placeHolder: string = localize('chooseScaleType', 'Choose scale type'); - const qpItems: QuickPickItem[] = Object.values(ScaleRuleTypes).map(type => { return { label: type } }); - context.ruleType = (await context.ui.showQuickPick(qpItems, { placeHolder })).label; + const qpItems: QuickPickItem[] = Object.values(ScaleRuleTypes).map(type => { + return { label: type }; + }); + + context.newRuleType = (await context.ui.showQuickPick(qpItems, { + placeHolder: localize('chooseScaleType', 'Choose scale type') + })).label; } public shouldPrompt(context: IAddScaleRuleContext): boolean { - return context.ruleType === undefined; + return !context.newRuleType; } public async getSubWizard(context: IAddScaleRuleContext): Promise> { const promptSteps: AzureWizardPromptStep[] = []; - switch (context.ruleType) { + switch (context.newRuleType) { case ScaleRuleTypes.HTTP: promptSteps.push(new HttpConcurrentRequestsStep()); break; diff --git a/src/commands/scaling/addScaleRule/addScaleRule.ts b/src/commands/scaling/addScaleRule/addScaleRule.ts index ad4fc1af4..e05e75406 100644 --- a/src/commands/scaling/addScaleRule/addScaleRule.ts +++ b/src/commands/scaling/addScaleRule/addScaleRule.ts @@ -3,75 +3,44 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ContainerAppsAPIClient, KnownActiveRevisionsMode, Revision } from "@azure/arm-appcontainers"; -import { uiUtils } from "@microsoft/vscode-azext-azureutils"; -import { AzureWizard, IActionContext, IAzureQuickPickItem, createSubscriptionContext, nonNullProp, nonNullValue } from "@microsoft/vscode-azext-utils"; -import type { AzureSubscription } from "@microsoft/vscode-azureresources-api"; -import { ext } from "../../../extensionVariables"; -import type { ContainerAppItem, ContainerAppModel } from "../../../tree/ContainerAppItem"; -import { ScaleRuleGroupItem } from "../../../tree/scaling/ScaleRuleGroupItem"; -import { createContainerAppsClient } from "../../../utils/azureClients"; +import { Revision } from "@azure/arm-appcontainers"; +import { AzureWizard, IActionContext, createSubscriptionContext, nonNullProp } from "@microsoft/vscode-azext-utils"; +import type { ContainerAppModel } from "../../../tree/ContainerAppItem"; +import type { ScaleRuleGroupItem } from "../../../tree/scaling/ScaleRuleGroupItem"; +import { createActivityContext } from "../../../utils/activityUtils"; import { localize } from "../../../utils/localize"; -import { pickContainerApp } from "../../../utils/pickItem/pickContainerApp"; +import { pickScaleRuleGroup } from "../../../utils/pickItem/pickScale"; +import { getParentResource } from "../../../utils/revisionDraftUtils"; import { AddScaleRuleStep } from "./AddScaleRuleStep"; import type { IAddScaleRuleContext } from "./IAddScaleRuleContext"; import { ScaleRuleNameStep } from "./ScaleRuleNameStep"; -import { ScaleRuleTypeStep } from "./ScaleRuleTypeStep"; - -const addScaleRuleTitle = localize('addScaleRuleTitle', 'Add Scale Rule'); +import { ScaleRuleTypeListStep } from "./ScaleRuleTypeListStep"; export async function addScaleRule(context: IActionContext, node?: ScaleRuleGroupItem): Promise { - const { subscription, containerApp, revision } = node ?? await getContainerAppAndRevision(context); + const item: ScaleRuleGroupItem = node ?? await pickScaleRuleGroup(context, { autoSelectDraft: true }); + const { subscription, containerApp, revision } = item; + + const parentResource: ContainerAppModel | Revision = getParentResource(containerApp, revision); - const scale = nonNullValue(revision?.template?.scale); const wizardContext: IAddScaleRuleContext = { ...context, ...createSubscriptionContext(subscription), - scale, - subscription, + ...await createActivityContext(), containerApp, - scaleRules: scale.rules ?? [], + subscription, + parentResourceName: nonNullProp(parentResource, 'name') }; const wizard: AzureWizard = new AzureWizard(wizardContext, { - title: addScaleRuleTitle, - promptSteps: [new ScaleRuleNameStep(), new ScaleRuleTypeStep()], - executeSteps: [new AddScaleRuleStep()], + title: localize('addScaleRuleTitle', 'Add scale rule to container app "{0}" (draft)', containerApp.name), + promptSteps: [new ScaleRuleNameStep(), new ScaleRuleTypeListStep()], + executeSteps: [new AddScaleRuleStep(item)], showLoadingPrompt: true }); await wizard.prompt(); - await wizard.execute(); - ext.state.notifyChildrenChanged(containerApp.managedEnvironmentId); -} -export async function getContainerAppAndRevision(context: IActionContext): Promise<{ containerApp: ContainerAppModel, revision: Revision, subscription: AzureSubscription }> { - const containerAppItem = await pickContainerApp(context, { - title: addScaleRuleTitle, - }); - const revision = await pickRevision(context, containerAppItem); - return { containerApp: containerAppItem.containerApp, revision, subscription: containerAppItem.subscription }; -} + wizardContext.activityTitle = localize('addScaleRuleTitle', 'Add {0} rule "{1}" to "{2}" (draft)', wizardContext.newRuleType, wizardContext.newRuleName, parentResource.name); -/** - * If the container app is in single revision mode, returns the latest revision. Otherwise, prompts the user to pick a revision. - */ -async function pickRevision(context: IActionContext, node: ContainerAppItem): Promise { - const client: ContainerAppsAPIClient = await createContainerAppsClient(context, node.subscription); - - if (node.containerApp.revisionsMode === KnownActiveRevisionsMode.Single) { - return await client.containerAppsRevisions.getRevision(node.containerApp.resourceGroup, node.containerApp.name, nonNullProp(node.containerApp, 'latestRevisionName')); - } else { - async function getPicks(): Promise[]> { - const revisions = await uiUtils.listAllIterator(client.containerAppsRevisions.listRevisions(node.containerApp.resourceGroup, node.containerApp.name)); - return revisions.map(r => ({ - label: nonNullProp(r, 'name'), - data: r, - })); - } - return (await context.ui.showQuickPick>(getPicks(), { - canPickMany: false, - placeHolder: localize('selectRevision', 'Select revision'), - })).data; - } + await wizard.execute(); } diff --git a/src/commands/scaling/addScaleRule/http/HttpConcurrentRequestsStep.ts b/src/commands/scaling/addScaleRule/http/HttpConcurrentRequestsStep.ts index 6aa7206ca..1286d9017 100644 --- a/src/commands/scaling/addScaleRule/http/HttpConcurrentRequestsStep.ts +++ b/src/commands/scaling/addScaleRule/http/HttpConcurrentRequestsStep.ts @@ -9,14 +9,14 @@ import { PositiveRealNumberBaseStep } from '../PositiveRealNumberBaseStep'; export class HttpConcurrentRequestsStep extends PositiveRealNumberBaseStep { public async prompt(context: IAddScaleRuleContext): Promise { - context.concurrentRequests = (await context.ui.showInputBox({ + context.newHttpConcurrentRequests = (await context.ui.showInputBox({ prompt: localize('concurrentRequestsPrompt', 'Enter the number of concurrent requests.'), - validateInput: (value: string | undefined): string | undefined => this.validateInput(value) + validateInput: this.validateInput })).trim(); } public shouldPrompt(context: IAddScaleRuleContext): boolean { - return context.concurrentRequests === undefined; + return !context.newHttpConcurrentRequests; } } diff --git a/src/commands/scaling/addScaleRule/queue/QueueAuthSecretStep.ts b/src/commands/scaling/addScaleRule/queue/QueueAuthSecretStep.ts index 00e1f907b..ab43f3ac3 100644 --- a/src/commands/scaling/addScaleRule/queue/QueueAuthSecretStep.ts +++ b/src/commands/scaling/addScaleRule/queue/QueueAuthSecretStep.ts @@ -22,11 +22,10 @@ export class QueueAuthSecretStep extends AzureWizardPromptStep { return { label: nonNullProp(secret, "name") }; }); - context.secretRef = (await context.ui.showQuickPick(qpItems, { placeHolder })).label; + context.newQueueSecretRef = (await context.ui.showQuickPick(qpItems, { placeHolder })).label; } public shouldPrompt(context: IAddScaleRuleContext): boolean { - return context.secretRef === undefined; + return context.newQueueSecretRef === undefined; } } - diff --git a/src/commands/scaling/addScaleRule/queue/QueueAuthTriggerStep.ts b/src/commands/scaling/addScaleRule/queue/QueueAuthTriggerStep.ts index e165367ef..83606331d 100644 --- a/src/commands/scaling/addScaleRule/queue/QueueAuthTriggerStep.ts +++ b/src/commands/scaling/addScaleRule/queue/QueueAuthTriggerStep.ts @@ -9,14 +9,14 @@ import type { IAddScaleRuleContext } from '../IAddScaleRuleContext'; export class QueueAuthTriggerStep extends AzureWizardPromptStep { public async prompt(context: IAddScaleRuleContext): Promise { - context.triggerParameter = (await context.ui.showInputBox({ + context.newQueueTriggerParameter = (await context.ui.showInputBox({ prompt: localize('queueAuthTriggerPrompt', 'Enter a corresponding trigger parameter.'), - validateInput: (value: string | undefined): string | undefined => this.validateInput(value) + validateInput: this.validateInput })).trim(); } public shouldPrompt(context: IAddScaleRuleContext): boolean { - return context.triggerParameter === undefined; + return !context.newQueueTriggerParameter; } private validateInput(name: string | undefined): string | undefined { diff --git a/src/commands/scaling/addScaleRule/queue/QueueLengthStep.ts b/src/commands/scaling/addScaleRule/queue/QueueLengthStep.ts index 61a13618d..59789aa40 100644 --- a/src/commands/scaling/addScaleRule/queue/QueueLengthStep.ts +++ b/src/commands/scaling/addScaleRule/queue/QueueLengthStep.ts @@ -9,14 +9,14 @@ import { PositiveRealNumberBaseStep } from '../PositiveRealNumberBaseStep'; export class QueueLengthStep extends PositiveRealNumberBaseStep { public async prompt(context: IAddScaleRuleContext): Promise { - context.queueLength = Number((await context.ui.showInputBox({ + context.newQueueLength = Number((await context.ui.showInputBox({ prompt: localize('queueLengthPrompt', 'Enter a queue length.'), - validateInput: (value: string | undefined): string | undefined => this.validateInput(value) + validateInput: this.validateInput })).trim()); } public shouldPrompt(context: IAddScaleRuleContext): boolean { - return context.queueLength === undefined; + return !context.newQueueLength; } } diff --git a/src/commands/scaling/addScaleRule/queue/QueueNameStep.ts b/src/commands/scaling/addScaleRule/queue/QueueNameStep.ts index 2f384e6d2..73a4e22b1 100644 --- a/src/commands/scaling/addScaleRule/queue/QueueNameStep.ts +++ b/src/commands/scaling/addScaleRule/queue/QueueNameStep.ts @@ -3,24 +3,20 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { ContainerApp, ScaleRule } from '@azure/arm-appcontainers'; import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; import { localize } from '../../../../utils/localize'; import type { IAddScaleRuleContext } from '../IAddScaleRuleContext'; export class QueueNameStep extends AzureWizardPromptStep { - containerApp: ContainerApp | undefined; - scaleRules: ScaleRule[] | undefined; - public async prompt(context: IAddScaleRuleContext): Promise { - context.queueName = (await context.ui.showInputBox({ + context.newQueueName = (await context.ui.showInputBox({ prompt: localize('queueNamePrompt', 'Enter a name for the queue.'), - validateInput: (value: string | undefined): string | undefined => this.validateInput(value) + validateInput: this.validateInput })).trim(); } public shouldPrompt(context: IAddScaleRuleContext): boolean { - return context.queueName === undefined; + return !context.newQueueName; } private validateInput(name: string | undefined): string | undefined { diff --git a/src/commands/scaling/editScalingRange.ts b/src/commands/scaling/editScalingRange.ts index 68c961ffc..16e40bbba 100644 --- a/src/commands/scaling/editScalingRange.ts +++ b/src/commands/scaling/editScalingRange.ts @@ -6,13 +6,13 @@ import { IActionContext, nonNullValue } from "@microsoft/vscode-azext-utils"; import { ProgressLocation, window } from "vscode"; import { ext } from "../../extensionVariables"; -import { ScaleItem } from "../../tree/scaling/ScaleItem"; +import type { ScaleItem } from "../../tree/scaling/ScaleItem"; import { localize } from "../../utils/localize"; +import { pickScale } from "../../utils/pickItem/pickScale"; import { updateContainerApp } from "../../utils/updateContainerApp"; -import { getContainerAppAndRevision } from "./addScaleRule/addScaleRule"; export async function editScalingRange(context: IActionContext, node?: ScaleItem): Promise { - const { containerApp, revision, subscription } = node ?? await getContainerAppAndRevision(context); + const { containerApp, revision, subscription } = node ?? await pickScale(context, { autoSelectDraft: true }); const scale = nonNullValue(revision?.template?.scale); const prompt: string = localize('editScalingRange', 'Set the range of application replicas that get created in response to a scale rule. Set any range within the minimum of 0 and the maximum of 10 replicas'); diff --git a/src/tree/ContainerAppItem.ts b/src/tree/ContainerAppItem.ts index bd4377b68..eff65c52a 100644 --- a/src/tree/ContainerAppItem.ts +++ b/src/tree/ContainerAppItem.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ContainerApp, ContainerAppsAPIClient, KnownActiveRevisionsMode, Revision } from "@azure/arm-appcontainers"; +import { ContainerApp, ContainerAppsAPIClient, KnownActiveRevisionsMode, Revision, Template } from "@azure/arm-appcontainers"; import { getResourceGroupFromId, uiUtils } from "@microsoft/vscode-azext-azureutils"; import { AzureWizard, DeleteConfirmationStep, IActionContext, callWithTelemetryAndErrorHandling, createContextValue, createSubscriptionContext, nonNullProp, nonNullValue } from "@microsoft/vscode-azext-utils"; import { AzureSubscription, ViewPropertiesModel } from "@microsoft/vscode-azureresources-api"; @@ -109,6 +109,12 @@ export class ContainerAppItem implements ContainerAppsItem, RevisionsDraftModel } } + static isContainerAppItem(item: unknown): item is ContainerAppItem { + return typeof item === 'object' && + typeof (item as ContainerAppItem).contextValue === 'string' && + ContainerAppItem.contextValueRegExp.test((item as ContainerAppItem).contextValue); + } + static async List(context: IActionContext, subscription: AzureSubscription, managedEnvironmentId: string): Promise { const subContext = createSubscriptionContext(subscription); const client: ContainerAppsAPIClient = await createContainerAppsAPIClient([context, subContext]); @@ -163,8 +169,8 @@ export class ContainerAppItem implements ContainerAppsItem, RevisionsDraftModel } hasUnsavedChanges(): boolean { - const draftTemplate = ext.revisionDraftFileSystem.parseRevisionDraft(this); - if (!this.containerApp.template || !draftTemplate) { + const draftTemplate: Template | undefined = ext.revisionDraftFileSystem.parseRevisionDraft(this); + if (!draftTemplate) { return false; } diff --git a/src/tree/revisionManagement/RevisionDraftDescendantBase.ts b/src/tree/revisionManagement/RevisionDraftDescendantBase.ts new file mode 100644 index 000000000..91e4fe12b --- /dev/null +++ b/src/tree/revisionManagement/RevisionDraftDescendantBase.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import type { Revision } from "@azure/arm-appcontainers"; +import type { TreeElementBase } from "@microsoft/vscode-azext-utils"; +import type { AzureSubscription } from "@microsoft/vscode-azureresources-api"; +import type { TreeItem } from "vscode"; +import type { ContainerAppModel } from "../ContainerAppItem"; +import type { RevisionsDraftModel } from "./RevisionDraftItem"; +import { RevisionsItemModel } from "./RevisionItem"; + +/** + * Can be implemented by any tree item that has the potential to show up as a RevisionDraftItem's descendant + */ +export abstract class RevisionDraftDescendantBase implements RevisionsItemModel, RevisionsDraftModel { + constructor(readonly subscription: AzureSubscription, readonly containerApp: ContainerAppModel, readonly revision: Revision) { } + + private init(): void { + this.hasUnsavedChanges() ? this.setDraftProperties() : this.setProperties(); + } + + // Build the tree items inside a local static method first so that extra '...args' are scoped when we init `setDraftProperties` and `setProperties` + static createTreeItem(RevisionDraftDescendant: DescendantConstructor, subscription: AzureSubscription, containerApp: ContainerAppModel, revision: Revision, ...args: unknown[]): T { + const descendant: T = new RevisionDraftDescendant(subscription, containerApp, revision, ...args); + descendant.init(); + return descendant; + } + + abstract getTreeItem(): TreeItem | Promise; + getChildren?(): TreeElementBase[] | Promise; + + /** + * Used to determine if the tree item is in a draft state + */ + abstract hasUnsavedChanges(): boolean; + + /** + * Properties to display when the tree item has no unsaved changes + */ + protected abstract setProperties(): void; + + /** + * Properties to display when the tree item has unsaved changes + */ + protected abstract setDraftProperties(): void; +} + +type DescendantConstructor = { new(subscription: AzureSubscription, containerApp: ContainerAppModel, revision: Revision, ...args: unknown[]): T }; diff --git a/src/tree/revisionManagement/RevisionDraftItem.ts b/src/tree/revisionManagement/RevisionDraftItem.ts index 77e543002..552dee30a 100644 --- a/src/tree/revisionManagement/RevisionDraftItem.ts +++ b/src/tree/revisionManagement/RevisionDraftItem.ts @@ -19,7 +19,7 @@ import { RevisionItem, type RevisionsItemModel } from "./RevisionItem"; // For tree items that depend on the container app's revision draft template export interface RevisionsDraftModel { - hasUnsavedChanges: () => boolean | Promise; + hasUnsavedChanges(): boolean | Promise; } export class RevisionDraftItem implements RevisionsItemModel, RevisionsDraftModel { @@ -52,8 +52,14 @@ export class RevisionDraftItem implements RevisionsItemModel, RevisionsDraftMode return createContextValue(values); } + static isRevisionDraftItem(item: unknown): item is RevisionDraftItem { + return typeof item === 'object' && + (item as RevisionDraftItem).id === 'string' && + (item as RevisionDraftItem).id.split('/').at(-1) === RevisionDraftItem.idSuffix; + } + static hasDescendant(item: RevisionsItemModel): boolean { - if (item instanceof RevisionDraftItem) { + if (RevisionDraftItem.isRevisionDraftItem(item)) { return false; } diff --git a/src/tree/revisionManagement/RevisionItem.ts b/src/tree/revisionManagement/RevisionItem.ts index bd3f490e5..2ff9c3304 100644 --- a/src/tree/revisionManagement/RevisionItem.ts +++ b/src/tree/revisionManagement/RevisionItem.ts @@ -13,6 +13,7 @@ import { treeUtils } from "../../utils/treeUtils"; import type { ContainerAppModel } from "../ContainerAppItem"; import type { ContainerAppsItem, TreeElementBase } from "../ContainerAppsBranchDataProvider"; import { ScaleItem } from "../scaling/ScaleItem"; +import { RevisionDraftDescendantBase } from "./RevisionDraftDescendantBase"; export interface RevisionsItemModel extends ContainerAppsItem { revision: Revision; @@ -65,7 +66,7 @@ export class RevisionItem implements RevisionsItemModel { static getTemplateChildren(subscription: AzureSubscription, containerApp: ContainerAppModel, revision: Revision): TreeElementBase[] { return [ - new ScaleItem(subscription, containerApp, revision) + RevisionDraftDescendantBase.createTreeItem(ScaleItem, subscription, containerApp, revision) ]; } diff --git a/src/tree/scaling/ScaleItem.ts b/src/tree/scaling/ScaleItem.ts index 05502d1bc..4f3e1917f 100644 --- a/src/tree/scaling/ScaleItem.ts +++ b/src/tree/scaling/ScaleItem.ts @@ -4,51 +4,64 @@ *--------------------------------------------------------------------------------------------*/ import { KnownActiveRevisionsMode, Revision, Scale } from "@azure/arm-appcontainers"; -import { createGenericElement, nonNullValue } from "@microsoft/vscode-azext-utils"; +import { createGenericElement, nonNullValueAndProp } from "@microsoft/vscode-azext-utils"; import type { AzureSubscription, ViewPropertiesModel } from "@microsoft/vscode-azureresources-api"; import * as deepEqual from 'deep-eql'; import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from "vscode"; import { ext } from "../../extensionVariables"; import { localize } from "../../utils/localize"; +import { getParentResource } from "../../utils/revisionDraftUtils"; import { treeUtils } from "../../utils/treeUtils"; import type { ContainerAppModel } from "../ContainerAppItem"; import type { TreeElementBase } from "../ContainerAppsBranchDataProvider"; -import { RevisionDraftItem, RevisionsDraftModel } from "../revisionManagement/RevisionDraftItem"; -import type { RevisionsItemModel } from "../revisionManagement/RevisionItem"; -import { createScaleRuleGroupItem } from "./ScaleRuleGroupItem"; +import { RevisionDraftDescendantBase } from "../revisionManagement/RevisionDraftDescendantBase"; +import { RevisionDraftItem } from "../revisionManagement/RevisionDraftItem"; +import { ScaleRuleGroupItem } from "./ScaleRuleGroupItem"; const minMaxReplicaItemContextValue: string = 'minMaxReplicaItem'; const scaling: string = localize('scaling', 'Scaling'); -export class ScaleItem implements RevisionsItemModel, RevisionsDraftModel { +export class ScaleItem extends RevisionDraftDescendantBase { static readonly contextValue: string = 'scaleItem'; static readonly contextValueRegExp: RegExp = new RegExp(ScaleItem.contextValue); - constructor( - readonly subscription: AzureSubscription, - readonly containerApp: ContainerAppModel, - readonly revision: Revision) { } + // Used as the basis for the view; can reflect either the original or the draft changes + private scale: Scale; + + constructor(subscription: AzureSubscription, containerApp: ContainerAppModel, revision: Revision) { + super(subscription, containerApp, revision); + } id: string = `${this.parentResource.id}/scale`; + label: string; + + // Use getter here because some properties aren't available until after the constructor is run + get viewProperties(): ViewPropertiesModel { + return { + data: this.scale, + label: `${this.parentResource.name} Scaling`, + }; + } - viewProperties: ViewPropertiesModel = { - data: this.scale, - label: `${this.parentResource.name} Scaling`, - }; + private get parentResource(): ContainerAppModel | Revision { + return getParentResource(this.containerApp, this.revision); + } - get scale(): Scale { - return nonNullValue(this.revision?.template?.scale); + protected setProperties(): void { + this.label = scaling; + this.scale = nonNullValueAndProp(this.parentResource.template, 'scale'); } - get parentResource(): ContainerAppModel | Revision { - return this.revision?.name === this.containerApp.latestRevisionName ? this.containerApp : this.revision; + protected setDraftProperties(): void { + this.label = `${scaling}*`; + this.scale = nonNullValueAndProp(ext.revisionDraftFileSystem.parseRevisionDraft(this), 'scale'); } getTreeItem(): TreeItem { return { id: this.id, - label: this.hasUnsavedChanges() ? `${scaling}*` : scaling, + label: this.label, contextValue: ScaleItem.contextValue, iconPath: treeUtils.getIconPath('scaling'), collapsibleState: TreeItemCollapsibleState.Collapsed, @@ -56,38 +69,46 @@ export class ScaleItem implements RevisionsItemModel, RevisionsDraftModel { } getChildren(): TreeElementBase[] { - let scale: Scale | undefined; - - if (this.hasUnsavedChanges()) { - scale = ext.revisionDraftFileSystem.parseRevisionDraft(this)?.scale; - } else if (this.containerApp.revisionsMode === KnownActiveRevisionsMode.Single) { - scale = this.containerApp.template?.scale; - } else { - scale = this.revision.template?.scale; - } - + const replicasLabel: string = localize('minMax', 'Min / max replicas'); return [ createGenericElement({ - label: localize('minMax', 'Min / max replicas'), - description: `${scale?.minReplicas ?? 0} / ${scale?.maxReplicas ?? 0}`, + label: this.replicasHaveUnsavedChanges() ? `${replicasLabel}*` : replicasLabel, + description: `${this.scale?.minReplicas ?? 0} / ${this.scale?.maxReplicas ?? 0}`, contextValue: minMaxReplicaItemContextValue, iconPath: new ThemeIcon('dash'), }), - createScaleRuleGroupItem(this.subscription, this.containerApp, this.revision, scale?.rules ?? []), + RevisionDraftDescendantBase.createTreeItem(ScaleRuleGroupItem, this.subscription, this.containerApp, this.revision) ]; } hasUnsavedChanges(): boolean { - const scaleDraftTemplate = ext.revisionDraftFileSystem.parseRevisionDraft(this)?.scale; - if (!scaleDraftTemplate) { + // We only care about showing changes to descendants of the revision draft item when in multiple revisions mode + if (this.containerApp.revisionsMode === KnownActiveRevisionsMode.Multiple && !RevisionDraftItem.hasDescendant(this)) { + return false; + } + + const draftTemplate = ext.revisionDraftFileSystem.parseRevisionDraft(this)?.scale; + const currentTemplate = this.parentResource.template?.scale; + + if (!draftTemplate) { + return false; + } + + return !deepEqual(currentTemplate, draftTemplate); + } + + replicasHaveUnsavedChanges(): boolean { + if (this.containerApp.revisionsMode === KnownActiveRevisionsMode.Multiple && !RevisionDraftItem.hasDescendant(this)) { return false; } - if (this.containerApp.revisionsMode === KnownActiveRevisionsMode.Single) { - return !!this.containerApp.template?.scale && !deepEqual(this.containerApp.template.scale, scaleDraftTemplate); - } else { - // We only care about showing changes to descendants of the revision draft item when in multiple revisions mode - return !!this.revision.template?.scale && RevisionDraftItem.hasDescendant(this) && !deepEqual(this.revision.template.scale, scaleDraftTemplate); + const draftTemplate = ext.revisionDraftFileSystem.parseRevisionDraft(this)?.scale; + const currentTemplate = this.parentResource.template?.scale; + + if (!draftTemplate) { + return false; } + + return draftTemplate.minReplicas !== currentTemplate?.minReplicas || draftTemplate.maxReplicas !== currentTemplate?.maxReplicas; } } diff --git a/src/tree/scaling/ScaleRuleGroupItem.ts b/src/tree/scaling/ScaleRuleGroupItem.ts index 6696c2918..5a467e23d 100644 --- a/src/tree/scaling/ScaleRuleGroupItem.ts +++ b/src/tree/scaling/ScaleRuleGroupItem.ts @@ -3,43 +3,84 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Revision, ScaleRule } from "@azure/arm-appcontainers"; -import { AzureSubscription } from "@microsoft/vscode-azureresources-api"; -import { ThemeIcon, TreeItemCollapsibleState } from "vscode"; +import { KnownActiveRevisionsMode, Revision, ScaleRule } from "@azure/arm-appcontainers"; +import type { TreeElementBase } from "@microsoft/vscode-azext-utils"; +import type { AzureSubscription, ViewPropertiesModel } from "@microsoft/vscode-azureresources-api"; +import * as deepEqual from "deep-eql"; +import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from "vscode"; +import { ext } from "../../extensionVariables"; import { localize } from "../../utils/localize"; -import { ContainerAppModel } from "../ContainerAppItem"; -import { RevisionsItemModel } from "../revisionManagement/RevisionItem"; -import { createScaleRuleItem } from "./ScaleRuleItem"; +import { getParentResource } from "../../utils/revisionDraftUtils"; +import type { ContainerAppModel } from "../ContainerAppItem"; +import { RevisionDraftDescendantBase } from "../revisionManagement/RevisionDraftDescendantBase"; +import { RevisionDraftItem } from "../revisionManagement/RevisionDraftItem"; +import { ScaleRuleItem } from "./ScaleRuleItem"; -export interface ScaleRuleGroupItem extends RevisionsItemModel { - scaleRules: ScaleRule[]; -} +const scaleRulesLabel: string = localize('scaleRules', 'Scale Rules'); + +export class ScaleRuleGroupItem extends RevisionDraftDescendantBase { + static readonly contextValue: string = 'scaleRuleGroupItem'; + static readonly contextValueRegExp: RegExp = new RegExp(ScaleRuleGroupItem.contextValue); + + // Used as the basis for the view; can reflect either the original or the draft changes + private scaleRules: ScaleRule[]; + + constructor(subscription: AzureSubscription, containerApp: ContainerAppModel, revision: Revision) { + super(subscription, containerApp, revision); + } + + id: string = `${this.parentResource.id}/scalerules`; + label: string; + + // Use getter here because some properties aren't available until after the constructor is run + get viewProperties(): ViewPropertiesModel { + return { + data: this.scaleRules, + label: `${this.parentResource.name} ${scaleRulesLabel}`, + }; + } + + private get parentResource(): ContainerAppModel | Revision { + return getParentResource(this.containerApp, this.revision); + } -const scaleRuleGroupItemContextValue: string = 'scaleRuleGroupItem'; - -export function createScaleRuleGroupItem(subscription: AzureSubscription, containerApp: ContainerAppModel, revision: Revision, scaleRules: ScaleRule[]): ScaleRuleGroupItem { - const parentResource = revision.name === containerApp.latestRevisionName ? containerApp : revision; - const id = `${parentResource.id}/scalerules`; - - return { - id, - subscription, - containerApp, - scaleRules, - viewProperties: { - data: scaleRules, - label: `${parentResource.name} Scale Rules`, - }, - revision, - getTreeItem: () => ({ - id, - label: localize('scaleRules', 'Scale Rules'), + protected setProperties(): void { + this.label = scaleRulesLabel; + this.scaleRules = this.parentResource.template?.scale?.rules ?? []; + } + + protected setDraftProperties(): void { + this.label = `${scaleRulesLabel}*`; + this.scaleRules = ext.revisionDraftFileSystem.parseRevisionDraft(this)?.scale?.rules ?? []; + } + + getTreeItem(): TreeItem { + return { + id: this.id, + label: this.label, + contextValue: ScaleRuleGroupItem.contextValue, iconPath: new ThemeIcon('symbol-constant'), - contextValue: scaleRuleGroupItemContextValue, collapsibleState: TreeItemCollapsibleState.Collapsed, - }), - getChildren: async () => { - return scaleRules.map(scaleRule => createScaleRuleItem(subscription, containerApp, revision, scaleRule)); - }, - }; + } + } + + getChildren(): TreeElementBase[] { + return this.scaleRules.map(scaleRule => RevisionDraftDescendantBase.createTreeItem(ScaleRuleItem, this.subscription, this.containerApp, this.revision, scaleRule, this.hasUnsavedChanges())) ?? []; + } + + hasUnsavedChanges(): boolean { + // We only care about showing changes to descendants of the revision draft item when in multiple revisions mode + if (this.containerApp.revisionsMode === KnownActiveRevisionsMode.Multiple && !RevisionDraftItem.hasDescendant(this)) { + return false; + } + + const draftTemplate: ScaleRule[] | undefined = ext.revisionDraftFileSystem.parseRevisionDraft(this)?.scale?.rules; + const currentTemplate: ScaleRule[] | undefined = this.parentResource.template?.scale?.rules; + + if (!draftTemplate) { + return false; + } + + return !deepEqual(currentTemplate, draftTemplate); + } } diff --git a/src/tree/scaling/ScaleRuleItem.ts b/src/tree/scaling/ScaleRuleItem.ts index 0e1fbf83d..454050495 100644 --- a/src/tree/scaling/ScaleRuleItem.ts +++ b/src/tree/scaling/ScaleRuleItem.ts @@ -3,52 +3,89 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Revision, ScaleRule } from "@azure/arm-appcontainers"; -import { nonNullProp } from "@microsoft/vscode-azext-utils"; -import { AzureSubscription } from "@microsoft/vscode-azureresources-api"; +import { KnownActiveRevisionsMode, Revision, ScaleRule } from "@azure/arm-appcontainers"; +import type { AzureSubscription, ViewPropertiesModel } from "@microsoft/vscode-azureresources-api"; +import * as deepEqual from "deep-eql"; import { ThemeIcon, TreeItem } from "vscode"; import { localize } from "../../utils/localize"; -import { ContainerAppModel } from "../ContainerAppItem"; -import { ContainerAppsItem } from "../ContainerAppsBranchDataProvider"; +import { getParentResource } from "../../utils/revisionDraftUtils"; +import type { ContainerAppModel } from "../ContainerAppItem"; +import { RevisionDraftDescendantBase } from "../revisionManagement/RevisionDraftDescendantBase"; +import { RevisionDraftItem } from "../revisionManagement/RevisionDraftItem"; -export interface ScaleRuleItem extends ContainerAppsItem { - scaleRule: ScaleRule; -} +const scaleRuleLabel: string = localize('scaleRule', 'Scale Rule'); -const scaleRuleItemContextValue: string = 'scaleRuleItem'; - -export function createScaleRuleItem(subscription: AzureSubscription, containerApp: ContainerAppModel, revision: Revision, scaleRule: ScaleRule): ScaleRuleItem { - const parentResource = revision.name === containerApp.latestRevisionName ? containerApp : revision; - - const id = `${parentResource.id}/${scaleRule.name}`; - - return { - id, - subscription, - containerApp, - scaleRule, - viewProperties: { - data: scaleRule, - label: `${parentResource.name} ${localize('scaleRule', 'Scale Rule')} ${scaleRule.name}`, - }, - getTreeItem: (): TreeItem => ({ - id, - label: nonNullProp(scaleRule, 'name'), - iconPath: new ThemeIcon('dash'), - contextValue: scaleRuleItemContextValue, - description: getDescription(scaleRule), - }), +export class ScaleRuleItem extends RevisionDraftDescendantBase { + static readonly contextValue: string = 'scaleRuleItem'; + static readonly contextValueRegExp: RegExp = new RegExp(ScaleRuleItem.contextValue); + + constructor( + subscription: AzureSubscription, + containerApp: ContainerAppModel, + revision: Revision, + + // Used as the basis for the view; can reflect either the original or the draft changes + readonly scaleRule: ScaleRule, + readonly isDraft: boolean + ) { + super(subscription, containerApp, revision); + } + + id: string = `${this.parentResource.id}/scalerules/${this.scaleRule.name}`; + label: string; + + viewProperties: ViewPropertiesModel = { + data: this.scaleRule, + label: `${this.parentResource.name} ${scaleRuleLabel} ${this.scaleRule.name}`, }; -} -function getDescription(scaleRule: ScaleRule): string { - if (scaleRule.http) { - return localize('http', "HTTP"); - } else if (scaleRule.azureQueue) { - return localize('azureQueue', 'Azure Queue'); - } else if (scaleRule.custom) { - return localize('custom', 'Custom'); - } else { - return localize('unknown', 'Unknown'); + private get description(): string { + if (this.scaleRule.http) { + return localize('http', "HTTP"); + } else if (this.scaleRule.azureQueue) { + return localize('azureQueue', 'Azure Queue'); + } else if (this.scaleRule.custom) { + return localize('custom', 'Custom'); + } else { + return localize('unknown', 'Unknown'); + } + } + + private get parentResource(): ContainerAppModel | Revision { + return getParentResource(this.containerApp, this.revision); + } + + protected setProperties(): void { + this.label = this.scaleRule.name ?? ''; + } + + protected setDraftProperties(): void { + this.label = `${this.scaleRule.name}*`; + } + + getTreeItem(): TreeItem { + return { + id: this.id, + label: this.label, + contextValue: ScaleRuleItem.contextValue, + iconPath: new ThemeIcon('dash'), + description: this.description + } + } + + hasUnsavedChanges(): boolean { + // We only care about showing changes to descendants of the revision draft item when in multiple revisions mode + if (this.containerApp.revisionsMode === KnownActiveRevisionsMode.Multiple && !RevisionDraftItem.hasDescendant(this)) { + return false; + } + + if (!this.isDraft) { + return false; + } + + const currentRules: ScaleRule[] = this.parentResource.template?.scale?.rules ?? []; + const currentRule: ScaleRule | undefined = currentRules.find(rule => rule.name === this.scaleRule.name); + + return !currentRule || !deepEqual(this.scaleRule, currentRule); } } diff --git a/src/utils/pickItem/PickItemOptions.ts b/src/utils/pickItem/PickItemOptions.ts index 1e4dc0316..c78e2733f 100644 --- a/src/utils/pickItem/PickItemOptions.ts +++ b/src/utils/pickItem/PickItemOptions.ts @@ -3,6 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +export interface RevisionDraftPickItemOptions extends PickItemOptions { + // Automatically select the RevisionDraftItem if one exists + autoSelectDraft?: boolean; +} + export interface RevisionPickItemOptions extends PickItemOptions { // Automatically select a RevisionItem without re-prompting the user selectByRevisionName?: string; diff --git a/src/utils/pickItem/pickRevision.ts b/src/utils/pickItem/pickRevision.ts index 66eb2c807..e8a738525 100644 --- a/src/utils/pickItem/pickRevision.ts +++ b/src/utils/pickItem/pickRevision.ts @@ -21,7 +21,7 @@ export function getPickRevisionDraftStep(): AzureWizardPromptStep { +export function getPickRevisionStep(revisionName?: string | RegExp): AzureWizardPromptStep { let revisionFilter: RegExp | undefined; if (revisionName) { revisionFilter = revisionName instanceof RegExp ? revisionName : new RegExp(revisionName); @@ -32,24 +32,29 @@ function getPickRevisionStep(revisionName?: string | RegExp): AzureWizardPromptS return new ContextValueQuickPickStep(ext.rgApiV2.resources.azureResourceTreeDataProvider, { contextValueFilter: { include: revisionFilter }, skipIfOne: true, + }, { + placeHolder: localize('selectRevisionItem', 'Select a revision'), + noPicksMessage: localize('noRevisions', 'Selected container app has no revisions'), }); } -function getPickRevisionsStep(): AzureWizardPromptStep { +export function getPickRevisionsStep(): AzureWizardPromptStep { return new ContextValueQuickPickStep(ext.rgApiV2.resources.azureResourceTreeDataProvider, { contextValueFilter: { include: RevisionsItem.contextValueRegExp }, skipIfOne: true, - }, { - placeHolder: localize('selectRevisionItem', 'Select a revision') }); } export async function pickRevision(context: IActionContext, startingNode?: ContainerAppItem | RevisionsItem, options?: RevisionPickItemOptions): Promise { startingNode ??= await pickContainerApp(context); + if (startingNode.containerApp.revisionsMode === KnownActiveRevisionsMode.Single) { + throw new NoResourceFoundError(Object.assign(context, { noItemFoundErrorMessage: localize('singleRevisionModeError', 'Revision items do not exist in single revision mode.') })); + } + const promptSteps: AzureWizardPromptStep[] = []; - if (startingNode instanceof ContainerAppItem) { + if (ContainerAppItem.isContainerAppItem(startingNode)) { promptSteps.push(getPickRevisionsStep()); } diff --git a/src/utils/pickItem/pickScale.ts b/src/utils/pickItem/pickScale.ts new file mode 100644 index 000000000..15bfedc02 --- /dev/null +++ b/src/utils/pickItem/pickScale.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import { KnownActiveRevisionsMode } from "@azure/arm-appcontainers"; +import { AzureWizardPromptStep, ContextValueQuickPickStep, IActionContext, QuickPickWizardContext, runQuickPickWizard } from "@microsoft/vscode-azext-utils"; +import { ext } from "../../extensionVariables"; +import type { ContainerAppItem } from "../../tree/ContainerAppItem"; +import { ScaleItem } from "../../tree/scaling/ScaleItem"; +import { ScaleRuleGroupItem } from "../../tree/scaling/ScaleRuleGroupItem"; +import type { RevisionDraftPickItemOptions } from "./PickItemOptions"; +import { pickContainerApp } from "./pickContainerApp"; +import { getPickRevisionDraftStep, getPickRevisionStep, getPickRevisionsStep } from "./pickRevision"; + +function getPickScaleStep(): AzureWizardPromptStep { + return new ContextValueQuickPickStep(ext.rgApiV2.resources.azureResourceTreeDataProvider, { + contextValueFilter: { include: ScaleItem.contextValueRegExp }, + skipIfOne: true, + }); +} + +/** + * Assumes starting from the ContainerAppItem + */ +function getPickScaleSteps(containerAppItem: ContainerAppItem, options?: RevisionDraftPickItemOptions): AzureWizardPromptStep[] { + const promptSteps: AzureWizardPromptStep[] = []; + if (containerAppItem.containerApp.revisionsMode === KnownActiveRevisionsMode.Multiple) { + promptSteps.push(getPickRevisionsStep()); + + if (options?.autoSelectDraft && ext.revisionDraftFileSystem.doesContainerAppsItemHaveRevisionDraft(containerAppItem)) { + promptSteps.push(getPickRevisionDraftStep()); + } else { + promptSteps.push(getPickRevisionStep()); + } + } + + promptSteps.push(getPickScaleStep()); + return promptSteps; +} + +function getPickScaleRuleGroupStep(): AzureWizardPromptStep { + return new ContextValueQuickPickStep(ext.rgApiV2.resources.azureResourceTreeDataProvider, { + contextValueFilter: { include: ScaleRuleGroupItem.contextValue }, + skipIfOne: true, + }); +} + +export async function pickScale(context: IActionContext, options?: RevisionDraftPickItemOptions): Promise { + const containerAppItem: ContainerAppItem = await pickContainerApp(context); + return await runQuickPickWizard(context, { + promptSteps: getPickScaleSteps(containerAppItem, { autoSelectDraft: options?.autoSelectDraft }), + title: options?.title, + }, containerAppItem); +} + +export async function pickScaleRuleGroup(context: IActionContext, options?: RevisionDraftPickItemOptions): Promise { + const containerAppItem: ContainerAppItem = await pickContainerApp(context); + + const promptSteps: AzureWizardPromptStep[] = [ + ...getPickScaleSteps(containerAppItem, { autoSelectDraft: options?.autoSelectDraft }), + getPickScaleRuleGroupStep() + ]; + + return await runQuickPickWizard(context, { + promptSteps, + title: options?.title, + }, containerAppItem); +} diff --git a/src/utils/revisionDraftUtils.ts b/src/utils/revisionDraftUtils.ts new file mode 100644 index 000000000..9322690e5 --- /dev/null +++ b/src/utils/revisionDraftUtils.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { KnownActiveRevisionsMode, Revision } from "@azure/arm-appcontainers"; +import { ContainerAppModel } from "../tree/ContainerAppItem"; + +/** + * Use to always select the correct parent resource model + * https://github.com/microsoft/vscode-azurecontainerapps/blob/main/src/commands/revisionDraft/README.md + */ +export function getParentResource(containerApp: ContainerAppModel, revision: Revision): ContainerAppModel | Revision { + return containerApp.revisionsMode === KnownActiveRevisionsMode.Single ? containerApp : revision; +}