blob: ebd9c09d7f9d4c4d3069393e15120890b5b215d4 [file] [log] [blame]
/**
* Copyright 2019 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {LiveAnnouncer} from '@angular/cdk/a11y';
import {COMMA, ENTER} from '@angular/cdk/keycodes';
import {AfterViewInit, Component, Inject, OnInit, ViewChild} from '@angular/core';
import {MatButton} from '@angular/material/button';
import {MatChipInputEvent} from '@angular/material/chips';
import {MatStepper} from '@angular/material/stepper';
import {Title} from '@angular/platform-browser';
import {ActivatedRoute, Params, Router} from '@angular/router';
import {forkJoin, Observable, of as observableOf} from 'rxjs';
import {catchError, filter, finalize, first, map, switchMap} from 'rxjs/operators';
import {TestResourceForm} from '../build_channels/test_resource_form';
import {APP_DATA, AppData} from '../services/app_data';
import {MttClient} from '../services/mtt_client';
import {DeviceSearchCriteria, LabDeviceInfosResponse} from '../services/mtt_lab_models';
import * as mttModels from '../services/mtt_models';
import {RerunContext, ShardingMode, testResourceDefToObj} from '../services/mtt_models';
import {MttObjectMapService, newMttObjectMap} from '../services/mtt_object_map';
import {Notifier} from '../services/notifier';
import {TfcClient} from '../services/tfc_client';
import {FormChangeTracker} from '../shared/can_deactivate';
import {APPLICATION_NAME} from '../shared/shared_module';
import {TestRunConfigForm} from '../shared/test_run_config_form';
import {buildApiErrorMessage, resetStepCompletion} from '../shared/util';
enum Step {
CONFIGURE_TEST_RUN = 0,
SELECT_DEVICES = 1,
ADD_ACTIONS = 2,
SET_TEST_RESOURCES = 3,
ADD_RERUN_CONFIGS = 4,
}
const TOTAL_STEPS = 5;
const DISK_SPACE_USAGE_ALARMS = ['disk_space._data.disk_space_usage'];
/**
* Form for running a new test
*/
@Component({
selector: 'new-test-run-page',
styleUrls: ['new_test_run_page.css'],
templateUrl: './new_test_run_page.ng.html',
})
export class NewTestRunPage extends FormChangeTracker implements OnInit,
AfterViewInit {
@ViewChild('backButton', {static: false}) backButton?: MatButton;
// Validation variable
@ViewChild(TestRunConfigForm, {static: true})
testRunConfigForm!: TestRunConfigForm;
@ViewChild(TestResourceForm, {static: true})
testResourceForm!: TestResourceForm;
errorMessage = '';
warningMessage = '';
// Record each step whether it has finished or not
stepCompletionStatusMap: {[stepNum: number]: boolean} = {};
step = Step;
resetStepCompletion = resetStepCompletion;
totalSteps = TOTAL_STEPS;
prevTestRunId?: string;
rerunContext?: RerunContext;
mttObjectMap = newMttObjectMap();
testRunConfig = mttModels.initTestRunConfig();
rerunConfigs: mttModels.TestRunConfig[] = [];
selectedDeviceActions: mttModels.DeviceAction[] = [];
selectedTestRunActions: mttModels.TestRunAction[] = [];
labels: string[] = [];
nodeConfigTestResourceUrls: mttModels.NameValuePair[] = [];
isLoading = false;
isStartingTestRun = false;
/** Keys used to separate labels */
readonly separatorKeyCodes: number[] = [ENTER, COMMA];
back() {
this.router.navigate(['tests']);
}
constructor(
private readonly liveAnnouncer: LiveAnnouncer,
private readonly router: Router,
private readonly route: ActivatedRoute,
private readonly mttClient: MttClient,
private readonly mttObjectMapService: MttObjectMapService,
private readonly notifier: Notifier,
private readonly title: Title,
private readonly tfcClient: TfcClient,
@Inject(APP_DATA) readonly appData: AppData,
) {
super();
}
ngOnInit() {
this.title.setTitle(`${APPLICATION_NAME} - New Test Run`);
this.route.queryParams.pipe(first()).subscribe((params: Params) => {
this.prevTestRunId = params['prevTestRunId'];
this.testRunConfig.test_id = params['testId'];
this.loadData(this.testRunConfig.test_id, this.prevTestRunId);
});
// Initialize step completion status map
resetStepCompletion(0, this.stepCompletionStatusMap, this.totalSteps);
}
ngAfterViewInit() {
this.backButton!.focus();
}
/**
* Triggered on click next button in stepper
* @param stepper MatStepper
*/
goForward(stepper: MatStepper): void {
if (this.validateStep(stepper.selectedIndex)) {
this.stepCompletionStatusMap[stepper.selectedIndex] = true;
// wait for data to populate stepper
setTimeout(() => {
stepper.next();
}, 100);
}
}
/**
* Validate each step and populate error messages
* @param currentStep Indicate which step are we validating
*/
validateStep(currentStep: Step): boolean {
this.errorMessage = '';
this.invalidInputs = [];
switch (currentStep) {
case Step.CONFIGURE_TEST_RUN: {
const errors = this.testRunConfigForm.validateContents();
if (errors.length) {
this.errorMessage = errors.map(e => 'Error: ' + e).join('\n');
return false;
}
this.invalidInputs = this.testRunConfigForm.getInvalidInputs();
return !this.invalidInputs.length;
}
case Step.SELECT_DEVICES: {
const res = !!this.testRunConfig.device_specs &&
0 < this.testRunConfig.device_specs.length;
if (!res) {
this.errorMessage = 'Device spec is required';
}
return res;
}
case Step.SET_TEST_RESOURCES: {
this.invalidInputs = this.testResourceForm.getInvalidInputs();
return !this.invalidInputs.length;
}
default: {
break;
}
}
return true;
}
/**
* Load MTT models data
* @param testId ID of the selected test to run
* @param prevTestRunId ID of a previous test run to rerun
*/
loadData(testId?: string, prevTestRunId?: string) {
// Make API calls
const mttObjectMapObs =
this.mttObjectMapService.getMttObjectMap(true /* forceUpdate */);
const nodeConfigObs = this.mttClient.getNodeConfig();
const prevTestRunObs = prevTestRunId ?
this.mttClient.getTestRun(prevTestRunId) :
observableOf(null);
this.isLoading = true;
this.liveAnnouncer.announce('Loading', 'polite');
// Get API call results
forkJoin([mttObjectMapObs, nodeConfigObs, prevTestRunObs])
.pipe(first())
.subscribe(
([mttObjectMapRes, nodeConfigRes, prevTestRunRes]) => {
this.mttObjectMap = mttObjectMapRes;
// Node Configs
this.nodeConfigTestResourceUrls =
nodeConfigRes.test_resource_default_download_urls || [];
// Load config values from either previous test run or test
// suite config defaults
if (prevTestRunRes) {
this.loadPrevTestRun(prevTestRunRes);
} else {
this.testRunConfig = mttModels.initTestRunConfig(
testId ? this.mttObjectMap.testMap[testId] :
Object.values(this.mttObjectMap.testMap)[0]);
}
this.updateTestResources();
this.isLoading = false;
this.liveAnnouncer.announce('Test run loaded', 'assertive');
},
error => {
this.notifier.showError(
'Failed to load test run.', buildApiErrorMessage(error));
});
}
/** Load config values from a previous test run */
loadPrevTestRun(prevTestRun: mttModels.TestRun|null) {
if (!prevTestRun) {
return;
}
if (prevTestRun.test) {
this.testRunConfig.test_id = prevTestRun.test.id;
}
if (prevTestRun.test_run_config) {
/**
* TODO: If the run target is something other than serials,
* the run target will be cleared.
*/
this.testRunConfig = prevTestRun.test_run_config;
this.testRunConfig.device_specs = this.testRunConfig.device_specs || [];
if (this.prevTestRunId &&
this.testRunConfig.sharding_mode === ShardingMode.MODULE) {
this.testRunConfig.sharding_mode = ShardingMode.RUNNER;
// Clears selected devices as the retry needs to go with runner mode and
// previous selected devices might be crossing hosts.
this.testRunConfig.device_specs = [];
this.testRunConfig.shard_count = 0;
}
// Load device actions
const deviceActionIds = this.testRunConfig.before_device_action_ids || [];
this.selectedDeviceActions = deviceActionIds.map(
id => this.mttObjectMap.deviceActionMap[id] || {});
// Load test run actions
const testRunActionRefs = this.testRunConfig.test_run_action_refs || [];
this.selectedTestRunActions = testRunActionRefs.map(ref => {
const action = this.mttObjectMap.testRunActionMap[ref.action_id] || {};
action.options = ref.options;
return action;
});
}
if (prevTestRun.test_resources) {
this.testRunConfig.test_resource_objs = prevTestRun.test_resources;
}
this.labels = prevTestRun.labels || [];
}
/**
* Update the device actions and the test resources according to the selected
* run targets
*/
updateSelectedDeviceActions(deviceSpecs: string[]) {
this.selectedDeviceActions = mttModels.updateSelectedDeviceActions(
this.selectedDeviceActions,
Object.values(this.mttObjectMap.deviceActionMap), deviceSpecs);
this.updateConfigDeviceActionIds();
}
/**
* Converts the selected device actions to ids and stores it in the config
*/
updateConfigDeviceActionIds() {
this.testRunConfig.before_device_action_ids =
this.selectedDeviceActions.map(action => action.id);
this.updateTestResources();
}
/**
* Converts the selected test run actions to ids and stores it in the config
*/
updateConfigTestRunActionIds() {
this.testRunConfig.test_run_action_refs =
this.selectedTestRunActions.map(action => {
return {action_id: action.id, options: action.options};
});
}
/**
* Under set test resource types, we need to update the required
* test resources on data changes
*/
updateTestResources() {
const updatedObjsMap: {[name: string]: mttModels.TestResourceObj;} = {};
// Get resource defs from Test
if (typeof this.testRunConfig.test_id !== 'undefined') {
const test = this.mttObjectMap.testMap[this.testRunConfig.test_id];
if (test && test.test_resource_defs) {
for (const def of test.test_resource_defs) {
updatedObjsMap[def.name] = testResourceDefToObj(def);
}
}
}
// Get resource defs from Device Actions
for (const deviceAction of this.selectedDeviceActions) {
if (deviceAction.test_resource_defs) {
for (const def of deviceAction.test_resource_defs) {
updatedObjsMap[def.name] = testResourceDefToObj(def);
}
}
}
// Overwrite urls with node config default download values
for (const nodeUrl of this.nodeConfigTestResourceUrls) {
if (nodeUrl.name in updatedObjsMap) {
updatedObjsMap[nodeUrl.name].url = nodeUrl.value || '';
}
}
// Overwrite urls with previously entered values
if (this.testRunConfig.test_resource_objs) {
for (const oldObj of this.testRunConfig.test_resource_objs) {
if (oldObj.name && oldObj.name in updatedObjsMap) {
updatedObjsMap[oldObj.name] = oldObj;
}
}
}
// Save updated values
this.testRunConfig.test_resource_objs = Object.values(updatedObjsMap);
}
addLabel(event: MatChipInputEvent) {
const input = event.chipInput.inputElement;
const value = event.value;
if ((value || '').trim() && this.labels.indexOf(value.trim()) === -1) {
this.labels.push(value.trim());
}
if (input) {
input.value = '';
}
}
removeLabel(label: string) {
const index = this.labels.indexOf(label);
if (index >= 0) {
this.labels.splice(index, 1);
}
}
startTestRun(ignoreWarnings = false) {
// TODO: Add input verification and error message
if (!this.validateStep(Step.SET_TEST_RESOURCES)) {
return;
}
this.isStartingTestRun = true;
this.checkDiskSpace(ignoreWarnings)
.pipe(
filter(result => result), switchMap(() => {
const newTestRunRequest: mttModels.NewTestRunRequest = {
labels: this.labels,
test_run_config: {...this.testRunConfig} as
mttModels.TestRunConfig,
rerun_context: this.rerunContext || {},
rerun_configs: this.rerunConfigs,
};
return this.mttClient.createNewTestRunRequest(newTestRunRequest)
.pipe(first());
}),
finalize(() => {
this.isStartingTestRun = false;
}))
.subscribe(
result => {
super.resetForm();
this.router.navigate([`test_runs/${result.id}`]);
this.notifier.showMessage(`Test run '${result.id}' started`);
},
error => {
this.notifier.showError(
'Failed to schedule a new test run.',
buildApiErrorMessage(error));
});
}
checkDiskSpace(skip = false): Observable<boolean> {
if (skip) {
return observableOf(true);
}
const deviceSpecs =
(this.testRunConfig.device_specs || [])
.concat(this.rerunConfigs.map(config => config.device_specs || [])
.flat());
const deviceSerial = this.getDeviceSerials(deviceSpecs);
let deviceInfoObservable: Observable<LabDeviceInfosResponse>;
if (deviceSerial.length === 0) {
deviceInfoObservable =
observableOf({more: false} as LabDeviceInfosResponse);
} else {
const query: DeviceSearchCriteria = {
deviceSerial,
includeOfflineDevices: false,
};
deviceInfoObservable =
this.tfcClient.queryDeviceInfos(query, deviceSerial.length);
}
return deviceInfoObservable.pipe(
map(response => {
const hostnames = new Set<string>();
// Hostname of controller
if (this.appData.hostname) {
hostnames.add(this.appData.hostname);
}
// Hostnames of selected devices
for (const deviceInfo of response.deviceInfos || []) {
if (deviceInfo.hostname) {
hostnames.add(deviceInfo.hostname);
}
}
return Array.from(hostnames);
}),
switchMap(hostnames => {
if (hostnames.length === 0) {
return observableOf([]);
}
return forkJoin(hostnames.map(
hostname =>
this.mttClient.netdata
.getAlarms(DISK_SPACE_USAGE_ALARMS, hostname)
.pipe(catchError(
() => observableOf(
{alarms: []} as mttModels.NetdataAlarmList)))));
}),
map(alarmLists => {
const alarms =
alarmLists.map(alarmList => alarmList.alarms || []).flat();
if (alarms.length === 0) {
return true;
}
this.warningMessage = `Low disk space warning on ${
alarms[0].hostname} (${alarms[0].value} used)`;
if (alarms.length > 1) {
this.warningMessage += ` and ${alarms.length - 1} other host(s)`;
}
return false;
}));
}
private getDeviceSerials(deviceSpecs: string[]): string[] {
const deviceSerials = new Set<string>();
for (const spec of deviceSpecs) {
const match = /^device_serial:(\S+)$/.exec(spec);
if (match) {
deviceSerials.add(match[1]);
}
}
return Array.from(deviceSerials);
}
}