feat(telemetry): add telemetry to collect submitted model number (#9)

This commit is contained in:
Eric Chen 2019-12-03 17:16:11 +08:00 коммит произвёл GitHub
Родитель 987de6caab
Коммит 0ae6a04679
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
13 изменённых файлов: 225 добавлений и 128 удалений

Просмотреть файл

@ -39,7 +39,7 @@ steps:
displayName: Compile Sources
- script: |
# node scripts/genAiKey.js
node scripts/genAiKey.js
npm install -g vsce
vsce package
displayName: Build VSIX Package

Просмотреть файл

@ -4,7 +4,8 @@
"description": "Author IoT Plug and Play models, publish and manage with Model Repository",
"version": "0.0.1",
"publisher": "vsciot-vscode",
"aiKey": "5b869bc6-ca93-4f24-aa87-92871a3a616e",
"preview": true,
"aiKey": "25cf12e8-2dc7-4556-b54e-40fb9d1dfba1",
"icon": "logo.png",
"license": "SEE LICENSE IN LICENSE.txt",
"engines": {
@ -61,7 +62,7 @@
],
"configuration": [
{
"title": "IoT Plug and Play Configuration",
"title": "IoT Plug and Play",
"properties": {
"azure-digital-twins.publicRepositoryUrl": {
"type": "string",

Просмотреть файл

@ -3,7 +3,7 @@ const fs = require("fs");
const PACKAGE_JSON_FILENAME = "package.json";
if (process.env.BUILD_SOURCEBRANCH) {
const IS_PROD = new RegExp(proces.env.PROD_TAG).test(process.env.BUILD_SOURCEBRANCH);
const IS_PROD = new RegExp(process.env.PROD_TAG).test(process.env.BUILD_SOURCEBRANCH);
if (IS_PROD) {
const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON_FILENAME));
packageJson.aiKey = process.env.PROD_AIKEY;

Просмотреть файл

@ -8,6 +8,9 @@ export class Constants {
public static readonly EXTENSION_NAME = "azure-digital-twins";
public static readonly CHANNEL_NAME = "IoT Plug and Play";
public static readonly UTF8 = "utf8";
public static readonly BASE64 = "base64";
public static readonly SHA256 = "sha256";
public static readonly HEX = "hex";
public static readonly EMPTY_STRING = "";
public static readonly DEFAULT_SEPARATOR = ",";
public static readonly COMPLETION_TRIGGER = '"';

Просмотреть файл

@ -83,7 +83,7 @@ export class NSAT {
};
this.telemetryClient.sendEvent("nsat.survey/userAsked");
const button = await window.showInformationMessage(
"Do you mind taking a quick feedback survey about the Azure IoT Edge Extension for VS Code?",
"Do you mind taking a quick feedback survey about IoT Plug and Play Extension for VS Code?",
take,
remind,
never,

Просмотреть файл

@ -4,29 +4,14 @@
import * as fs from "fs";
import * as vscode from "vscode";
import TelemetryReporter from "vscode-extension-telemetry";
/**
* Operation result of telemetry
*/
export enum TelemetryResult {
Succeeded = "Succeeded",
Failed = "Failed",
Cancelled = "Cancelled",
}
/**
* Context of telemetry
*/
export interface TelemetryContext {
start: number;
properties: { [key: string]: string };
measurements: { [key: string]: number };
}
import { TelemetryContext } from "./telemetryContext";
/**
* Telemetry client
*/
export class TelemetryClient {
private static readonly IS_INTERNAL = "isInternal";
/**
* validate content of package json
* @param packageJSON package json
@ -50,7 +35,7 @@ export class TelemetryClient {
private isInternal: boolean = false;
constructor(context: vscode.ExtensionContext) {
const packageJSON = JSON.parse(fs.readFileSync(context.asAbsolutePath("./package.json"), "utf8"));
if (!packageJSON || TelemetryClient.validatePackageJSON(packageJSON)) {
if (!packageJSON || !TelemetryClient.validatePackageJSON(packageJSON)) {
return;
}
this.extensionId = `${packageJSON.publisher}.${packageJSON.name}`;
@ -69,49 +54,14 @@ export class TelemetryClient {
return;
}
if (telemetryContext) {
telemetryContext.setProperty(TelemetryClient.IS_INTERNAL, this.isInternal.toString());
this.client.sendTelemetryEvent(eventName, telemetryContext.properties, telemetryContext.measurements);
} else {
this.client.sendTelemetryEvent(eventName);
const properties = { [TelemetryClient.IS_INTERNAL]: this.isInternal.toString() };
this.client.sendTelemetryEvent(eventName, properties);
}
}
/**
* create telemetry context
*/
public createContext(): TelemetryContext {
const context: TelemetryContext = { start: Date.now(), properties: {}, measurements: {} };
context.properties.isInternal = this.isInternal.toString();
context.properties.result = TelemetryResult.Succeeded;
return context;
}
/**
* set telemetry context as error
* @param context telemetry context
* @param error error
*/
public setErrorContext(context: TelemetryContext, error: Error): void {
context.properties.result = TelemetryResult.Failed;
context.properties.error = error.name;
context.properties.errorMessage = error.message;
}
/**
* set telemetry context as cancel
* @param context telemetry context
*/
public setCancelContext(context: TelemetryContext): void {
context.properties.result = TelemetryResult.Cancelled;
}
/**
* close telemetry context
* @param context telemetry context
*/
public closeContext(context: TelemetryContext) {
context.measurements.duration = (Date.now() - context.start) / 1000;
}
/**
* dispose telemetry client
*/

Просмотреть файл

@ -0,0 +1,68 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.
import { UserCancelledError } from "./userCancelledError";
/**
* Operation result
*/
enum OperationResult {
Success = "Succeeded",
Fail = "Failed",
Cancel = "Cancelled",
}
/**
* Telemetry context
*/
export class TelemetryContext {
/**
* start a new context
*/
public static startNew(): TelemetryContext {
return new TelemetryContext();
}
public properties: { [key: string]: string };
public measurements: { [key: string]: number };
private start: number;
private constructor() {
this.start = Date.now();
this.properties = {};
this.measurements = {};
}
/**
* set property
* @param name property name
* @param value property value
*/
public setProperty(name: string, value: string): void {
this.properties[name] = value;
}
/**
* set error
* @param error error
*/
public setError(error: Error): void {
if (error instanceof UserCancelledError) {
this.properties.result = OperationResult.Cancel;
} else {
this.properties.result = OperationResult.Fail;
this.properties.error = error.name;
this.properties.errorMessage = error.message;
}
}
/**
* end the context
*/
public end(): void {
if (!this.properties.result) {
this.properties.result = OperationResult.Success;
}
this.measurements.duration = (Date.now() - this.start) / 1000;
}
}

Просмотреть файл

@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.
import { createHash } from "crypto";
import * as fs from "fs-extra";
import * as path from "path";
import { DeviceModelManager, ModelType } from "../deviceModel/deviceModelManager";
@ -143,5 +144,15 @@ export class Utility {
return fs.readJson(filePath, { encoding: Constants.UTF8 });
}
/**
* get hash value of payload
* @param payload payload
*/
public static hash(payload: string): string {
return createHash(Constants.SHA256)
.update(payload)
.digest(Constants.HEX);
}
private constructor() {}
}

Просмотреть файл

@ -8,7 +8,8 @@ import { Command } from "./common/command";
import { Constants } from "./common/constants";
import { NSAT } from "./common/nsat";
import { ProcessError } from "./common/processError";
import { TelemetryClient, TelemetryContext } from "./common/telemetryClient";
import { TelemetryClient } from "./common/telemetryClient";
import { TelemetryContext } from "./common/telemetryContext";
import { UserCancelledError } from "./common/userCancelledError";
import { DeviceModelManager, ModelType } from "./deviceModel/deviceModelManager";
import { DigitalTwinCompletionItemProvider } from "./intelliSense/digitalTwinCompletionItemProvider";
@ -24,7 +25,12 @@ export function activate(context: vscode.ExtensionContext) {
const telemetryClient = new TelemetryClient(context);
const nsat = new NSAT(Constants.NSAT_SURVEY_URL, telemetryClient);
const deviceModelManager = new DeviceModelManager(context, outputChannel);
const modelRepositoryManager = new ModelRepositoryManager(context, outputChannel, Constants.WEB_VIEW_PATH);
const modelRepositoryManager = new ModelRepositoryManager(
context,
Constants.WEB_VIEW_PATH,
outputChannel,
telemetryClient,
);
const apiProvider = new ApiProvider(modelRepositoryManager);
telemetryClient.sendEvent(Constants.EXTENSION_ACTIVATED_MSG);
@ -171,15 +177,15 @@ function initCommand(
): void {
context.subscriptions.push(
vscode.commands.registerCommand(command, async (...args: any[]) => {
const telemetryContext: TelemetryContext = telemetryClient.createContext();
const telemetryContext: TelemetryContext = TelemetryContext.startNew();
telemetryClient.sendEvent(`${command}.start`);
try {
return await callback(...args);
} catch (error) {
telemetryContext.setError(error);
if (error instanceof UserCancelledError) {
outputChannel.warn(error.message);
} else {
telemetryClient.setErrorContext(telemetryContext, error);
UI.showNotification(MessageType.Error, error.message);
if (error instanceof ProcessError) {
const message = `${error.message}\n${error.stack}`;
@ -189,7 +195,7 @@ function initCommand(
}
}
} finally {
telemetryClient.closeContext(telemetryContext);
telemetryContext.end();
telemetryClient.sendEvent(`${command}.end`, telemetryContext);
outputChannel.show();
if (enableSurvey) {

Просмотреть файл

@ -7,12 +7,6 @@ import { ModelType } from "../deviceModel/deviceModelManager";
import { GetResult, MetaModelType, SearchOptions, SearchResult } from "./modelRepositoryInterface";
import { RepositoryInfo } from "./modelRepositoryManager";
const ETAG_HEADER = "etag";
const MODEL_ID_HEADER = "x-ms-model-id";
const MODEL_PATH = "models";
const SEARCH_PATH = "models/search";
const CONTENT_TYPE = "application/json";
/**
* Http method type
*/
@ -42,8 +36,8 @@ export class ModelRepositoryClient {
request(options)
.then((response) => {
const result: GetResult = {
etag: response.headers[ETAG_HEADER],
modelId: response.headers[MODEL_ID_HEADER],
etag: response.headers[ModelRepositoryClient.ETAG_HEADER],
modelId: response.headers["x-ms-model-id"],
content: response.body,
};
return resolve(result);
@ -102,7 +96,7 @@ export class ModelRepositoryClient {
return new Promise<string>((resolve, reject) => {
request(options)
.then((response) => {
const result: string = response.headers[ETAG_HEADER];
const result: string = response.headers[ModelRepositoryClient.ETAG_HEADER];
return resolve(result);
})
.catch((err) => {
@ -129,6 +123,8 @@ export class ModelRepositoryClient {
});
}
private static readonly ETAG_HEADER = "etag";
/**
* convert to meta model type
* @param type model type
@ -152,8 +148,8 @@ export class ModelRepositoryClient {
*/
private static createOptions(method: HttpMethod, repoInfo: RepositoryInfo, modelId?: string): request.OptionsWithUri {
const uri = modelId
? `${repoInfo.hostname}/${MODEL_PATH}/${encodeURIComponent(modelId)}`
: `${repoInfo.hostname}/${SEARCH_PATH}`;
? `${repoInfo.hostname}/models/${encodeURIComponent(modelId)}`
: `${repoInfo.hostname}/models/search`;
const qs: any = { "api-version": repoInfo.apiVersion };
if (repoInfo.repositoryId) {
qs.repositoryId = repoInfo.repositoryId;
@ -165,7 +161,7 @@ export class ModelRepositoryClient {
qs,
encoding: Constants.UTF8,
json: true,
headers: { "Authorization": accessToken, "Content-Type": CONTENT_TYPE },
headers: { "Authorization": accessToken, "Content-Type": "application/json" },
resolveWithFullResponse: true,
};
return options;

Просмотреть файл

@ -1,24 +1,9 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.
import { createHmac, Hmac } from "crypto";
import { createHmac } from "crypto";
import { Constants } from "../common/constants";
const PROPERTY_SEPARATOR = ";";
const KEY_VALUE_SEPARATOR = "=";
const PROPERTY_COUNT = 4;
const HOSTNAME_PROPERTY = "HostName";
const REPOSITORY_ID_PROPERTY = "RepositoryId";
const SHARED_ACCESS_KEY_NAME_PROPERTY = "SharedAccessKeyName";
const SHARED_ACCESS_KEY_PROPERTY = "SharedAccessKey";
const HOSTNAME_REGEX = new RegExp("[a-zA-Z0-9_\\-\\.]+$");
const SHARED_ACCESS_KEY_NAME_REGEX = new RegExp("^[a-zA-Z0-9_\\-@\\.]+$");
const BASE64 = "base64";
const SHA256 = "sha256";
const MILLISECONDS = 1000;
const EXPIRY_IN_MINUTES = 30;
const SECONDS_PER_MINUTE = 60;
/**
* Model repository connection
*/
@ -32,12 +17,12 @@ export class ModelRepositoryConnection {
throw new Error(`Connection string ${Constants.NOT_EMPTY_MSG}`);
}
const map: { [key: string]: string } = {};
const properties: string[] = connectionString.split(PROPERTY_SEPARATOR);
if (properties.length !== PROPERTY_COUNT) {
const properties: string[] = connectionString.split(";");
if (properties.length !== ModelRepositoryConnection.PROPERTY_COUNT) {
throw new Error(Constants.CONNECTION_STRING_INVALID_FORMAT_MSG);
}
for (const property of properties) {
const index: number = property.indexOf(KEY_VALUE_SEPARATOR);
const index: number = property.indexOf("=");
if (index <= 0) {
throw new Error(Constants.CONNECTION_STRING_INVALID_FORMAT_MSG);
}
@ -50,15 +35,24 @@ export class ModelRepositoryConnection {
}
// validate connection
const connection = new ModelRepositoryConnection(
map[HOSTNAME_PROPERTY],
map[REPOSITORY_ID_PROPERTY],
map[SHARED_ACCESS_KEY_NAME_PROPERTY],
map[SHARED_ACCESS_KEY_PROPERTY],
map[ModelRepositoryConnection.HOSTNAME_PROPERTY],
map[ModelRepositoryConnection.REPOSITORY_ID_PROPERTY],
map[ModelRepositoryConnection.SHARED_ACCESS_KEY_NAME_PROPERTY],
map[ModelRepositoryConnection.SHARED_ACCESS_KEY_PROPERTY],
);
connection.validate();
return connection;
}
private static readonly PROPERTY_COUNT = 4;
private static readonly HOSTNAME_PROPERTY = "HostName";
private static readonly REPOSITORY_ID_PROPERTY = "RepositoryId";
private static readonly SHARED_ACCESS_KEY_NAME_PROPERTY = "SharedAccessKeyName";
private static readonly SHARED_ACCESS_KEY_PROPERTY = "SharedAccessKey";
private static readonly HOSTNAME_REGEX = new RegExp("[a-zA-Z0-9_\\-\\.]+$");
private static readonly SHARED_ACCESS_KEY_NAME_REGEX = new RegExp("^[a-zA-Z0-9_\\-@\\.]+$");
private static readonly EXPIRY_IN_MINUTES = 30;
private readonly expiry: string;
private constructor(
public readonly hostName: string,
@ -67,7 +61,7 @@ export class ModelRepositoryConnection {
public readonly sharedAccessKey: string,
) {
const now: number = new Date().getTime();
this.expiry = (Math.round(now / MILLISECONDS) + EXPIRY_IN_MINUTES * SECONDS_PER_MINUTE).toString();
this.expiry = (Math.round(now / 1000) + ModelRepositoryConnection.EXPIRY_IN_MINUTES * 60).toString();
}
/**
@ -77,10 +71,12 @@ export class ModelRepositoryConnection {
const endpoint: string = encodeURIComponent(this.hostName);
const payload: string = [encodeURIComponent(this.repositoryId), endpoint, this.expiry].join("\n").toLowerCase();
const signature: Buffer = Buffer.from(payload, Constants.UTF8);
const secret: Buffer = Buffer.from(this.sharedAccessKey, BASE64);
const hmac: Hmac = createHmac(SHA256, secret);
hmac.update(signature);
const hash: string = encodeURIComponent(hmac.digest(BASE64));
const secret: Buffer = Buffer.from(this.sharedAccessKey, Constants.BASE64);
const hash: string = encodeURIComponent(
createHmac(Constants.SHA256, secret)
.update(signature)
.digest(Constants.BASE64),
);
return (
"SharedAccessSignature " +
`sr=${endpoint}&sig=${hash}&se=${this.expiry}&skn=${this.sharedAccessKeyName}&rid=${this.repositoryId}`
@ -91,19 +87,28 @@ export class ModelRepositoryConnection {
* validate model repository connection
*/
private validate(): void {
if (!this.hostName || !HOSTNAME_REGEX.test(this.hostName)) {
throw new Error(`${Constants.CONNECTION_STRING_INVALID_FORMAT_MSG} on property ${HOSTNAME_PROPERTY}`);
if (!this.hostName || !ModelRepositoryConnection.HOSTNAME_REGEX.test(this.hostName)) {
throw new Error(
`${Constants.CONNECTION_STRING_INVALID_FORMAT_MSG} on property ${ModelRepositoryConnection.HOSTNAME_PROPERTY}`,
);
}
if (!this.repositoryId) {
throw new Error(`${Constants.CONNECTION_STRING_INVALID_FORMAT_MSG} on property ${REPOSITORY_ID_PROPERTY}`);
}
if (!this.sharedAccessKeyName || !SHARED_ACCESS_KEY_NAME_REGEX.test(this.sharedAccessKeyName)) {
throw new Error(
`${Constants.CONNECTION_STRING_INVALID_FORMAT_MSG} on property ${SHARED_ACCESS_KEY_NAME_PROPERTY}`,
`${Constants.CONNECTION_STRING_INVALID_FORMAT_MSG} on property ${ModelRepositoryConnection.REPOSITORY_ID_PROPERTY}`,
);
}
if (
!this.sharedAccessKeyName ||
!ModelRepositoryConnection.SHARED_ACCESS_KEY_NAME_REGEX.test(this.sharedAccessKeyName)
) {
throw new Error(
`${Constants.CONNECTION_STRING_INVALID_FORMAT_MSG} on property ${ModelRepositoryConnection.SHARED_ACCESS_KEY_NAME_PROPERTY}`,
);
}
if (!this.sharedAccessKey) {
throw new Error(`${Constants.CONNECTION_STRING_INVALID_FORMAT_MSG} on property ${SHARED_ACCESS_KEY_PROPERTY}`);
throw new Error(
`${Constants.CONNECTION_STRING_INVALID_FORMAT_MSG} on property ${ModelRepositoryConnection.SHARED_ACCESS_KEY_PROPERTY}`,
);
}
}
}

Просмотреть файл

@ -5,13 +5,16 @@ import * as vscode from "vscode";
import { VSCExpress } from "vscode-express";
import { BadRequestError } from "../common/badRequestError";
import { ColorizedChannel } from "../common/colorizedChannel";
import { Command } from "../common/command";
import { Configuration } from "../common/configuration";
import { Constants } from "../common/constants";
import { CredentialStore } from "../common/credentialStore";
import { ProcessError } from "../common/processError";
import { TelemetryClient } from "../common/telemetryClient";
import { TelemetryContext } from "../common/telemetryContext";
import { UserCancelledError } from "../common/userCancelledError";
import { Utility } from "../common/utility";
import { ModelType } from "../deviceModel/deviceModelManager";
import { DeviceModelManager, ModelType } from "../deviceModel/deviceModelManager";
import { DigitalTwinConstants } from "../intelliSense/digitalTwinConstants";
import { ChoiceType, MessageType, UI } from "../view/ui";
import { UIConstants } from "../view/uiConstants";
@ -139,7 +142,12 @@ export class ModelRepositoryManager {
private readonly express: VSCExpress;
private readonly component: string;
constructor(context: vscode.ExtensionContext, private readonly outputChannel: ColorizedChannel, filePath: string) {
constructor(
context: vscode.ExtensionContext,
filePath: string,
private readonly outputChannel: ColorizedChannel,
private readonly telemetryClient: TelemetryClient,
) {
this.express = new VSCExpress(context, filePath);
this.component = Constants.MODEL_REPOSITORY_COMPONENT;
}
@ -197,8 +205,8 @@ export class ModelRepositoryManager {
* submit files to model repository
*/
public async submitFiles(): Promise<void> {
const files: string[] | undefined = await UI.selectModelFiles(UIConstants.SELECT_MODELS_LABEL);
if (!files || files.length === 0) {
const files: string[] = await UI.selectModelFiles(UIConstants.SELECT_MODELS_LABEL);
if (files.length === 0) {
return;
}
// check unsaved files and save
@ -213,13 +221,16 @@ export class ModelRepositoryManager {
}
}
const usageData = new Map<ModelType, string[]>();
try {
const repoInfo: RepositoryInfo = await ModelRepositoryManager.createRepositoryInfo(false);
await this.doSubmitLoopSilently(repoInfo, files);
await this.doSubmitLoopSilently(repoInfo, files, usageData);
} catch (error) {
const operation = `Submit models to ${RepositoryType.Company}`;
throw new ProcessError(operation, error, this.component);
}
// send usage data
this.sendUsageDataOfSubmit(usageData);
}
/**
@ -414,15 +425,20 @@ export class ModelRepositoryManager {
* submit model files silently, fault tolerant and don't throw exception
* @param repoInfo repository info
* @param files model file list
* @param usageData usage data
*/
private async doSubmitLoopSilently(repoInfo: RepositoryInfo, files: string[]): Promise<void> {
const option: SubmitOptions = { overwrite: false };
private async doSubmitLoopSilently(
repoInfo: RepositoryInfo,
files: string[],
usageData: Map<ModelType, string[]>,
): Promise<void> {
const options: SubmitOptions = { overwrite: false };
for (const file of files) {
const operation = `Submit file ${file}`;
this.outputChannel.start(operation, this.component);
try {
await this.doSubmitModel(repoInfo, file, option);
await this.doSubmitModel(repoInfo, file, options, usageData);
this.outputChannel.end(operation, this.component);
} catch (error) {
this.outputChannel.error(operation, this.component, error);
@ -433,12 +449,19 @@ export class ModelRepositoryManager {
/**
* submit model file to repository
* @param repoInfo repository info
* @param file model file
* @param option submit options
* @param filePath model file path
* @param options submit options
* @param usageData usage data
*/
private async doSubmitModel(repoInfo: RepositoryInfo, file: string, option: SubmitOptions): Promise<void> {
const content = await Utility.getJsonContent(file);
private async doSubmitModel(
repoInfo: RepositoryInfo,
filePath: string,
option: SubmitOptions,
usageData: Map<ModelType, string[]>,
): Promise<void> {
const content = await Utility.getJsonContent(filePath);
const modelId: string = content[DigitalTwinConstants.ID];
const modelType: ModelType = DeviceModelManager.convertToModelType(content[DigitalTwinConstants.TYPE]);
let result: GetResult | undefined;
try {
result = await ModelRepositoryClient.getModel(repoInfo, modelId, true);
@ -467,5 +490,39 @@ export class ModelRepositoryManager {
}
}
await ModelRepositoryClient.updateModel(repoInfo, modelId, content);
// record submitted model id
let modelIds: string[] | undefined = usageData.get(modelType);
if (!modelIds) {
modelIds = [];
usageData.set(modelType, modelIds);
}
modelIds.push(modelId);
}
/**
* send usage data of SubmitFiles
* @param usageData usage data
*/
private sendUsageDataOfSubmit(usageData: Map<ModelType, string[]>): void {
const telemetryContext: TelemetryContext = TelemetryContext.startNew();
let propertyName: string = Constants.EMPTY_STRING;
let propertyValue: string;
for (const [key, value] of usageData) {
switch (key) {
case ModelType.Interface:
propertyName = "interfaceId";
break;
case ModelType.CapabilityModel:
propertyName = "capabilityModelId";
break;
default:
}
if (propertyName) {
propertyValue = value.map((id) => Utility.hash(id)).join(Constants.DEFAULT_SEPARATOR);
telemetryContext.setProperty(propertyName, propertyValue);
}
}
this.telemetryClient.sendEvent(`${Command.SubmitFiles}.data`, telemetryContext);
}
}

Просмотреть файл

@ -192,11 +192,11 @@ export class UI {
* @param label label
* @param type model type
*/
public static async selectModelFiles(label: string, type?: ModelType): Promise<string[] | undefined> {
public static async selectModelFiles(label: string, type?: ModelType): Promise<string[]> {
const fileInfos: ModelFileInfo[] = await UI.findModelFiles(type);
if (fileInfos.length === 0) {
UI.showNotification(MessageType.Warn, UIConstants.MODELS_NOT_FOUND_MSG);
return undefined;
return [];
}
const items: Array<QuickPickItemWithData<string>> = fileInfos.map((f) => {
return {