diff --git a/.eslintrc.js b/.eslintrc.js index b2aa738c..91cfd97d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -81,6 +81,7 @@ module.exports = { 'no-unneeded-ternary': 2, 'no-whitespace-before-property': 2, 'object-curly-spacing': [2, 'always'], + 'semi': 2, 'semi-spacing': 2, 'space-before-blocks': 2, 'space-before-function-paren': [2, 'never'], diff --git a/app/components/pivot-table.js b/app/components/pivot-table.js index b472f9cd..ca012a98 100644 --- a/app/components/pivot-table.js +++ b/app/components/pivot-table.js @@ -1,10 +1,15 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ import { computed } from '@ember/object'; import $ from 'jquery'; import Component from '@ember/component'; +import { debounce } from '@ember/runloop'; export default Component.extend({ rows: null, - columns: null, initialOptions: null, init() { @@ -29,6 +34,15 @@ export default Component.extend({ didInsertElement() { this._super(...arguments); + this.insertPivotTable(); + }, + + didUpdateAttrs() { + this._super(...arguments); + debounce(this, this.insertPivotTable, 500, true); + }, + + insertPivotTable() { let { rows, options } = this.getProperties('rows', 'options'); this.$('.pivot-table-container').pivotUI(rows, options); }, diff --git a/app/components/pretty-json.js b/app/components/pretty-json.js index 3591fc5b..ed64dd74 100644 --- a/app/components/pretty-json.js +++ b/app/components/pretty-json.js @@ -13,9 +13,13 @@ export default Component.extend({ data: null, defaultLevels: 2, - didInsertElement() { + didRender() { this._super(...arguments); + this.$().empty().append(this.getRenderData()); + }, + + getRenderData() { let formatter = new JSONFormatter(this.get('data'), this.get('defaultLevels'), { hoverPreviewEnabled: true }); - this.$().append(formatter.render()); + return formatter.render(); } }); diff --git a/app/components/queries-table.js b/app/components/queries-table.js index 8f8cbc5e..c7c730a7 100644 --- a/app/components/queries-table.js +++ b/app/components/queries-table.js @@ -14,7 +14,6 @@ export default Component.extend(PaginatedTable, { classNames: ['queries-table'], queries: null, pageSize: 10, - isFixed: true, extractors: EmberObject.create({ name(row) { let name = row.get('name'); diff --git a/app/components/query-information.js b/app/components/query-information.js new file mode 100644 index 00000000..f9f2c12f --- /dev/null +++ b/app/components/query-information.js @@ -0,0 +1,30 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +import Component from '@ember/component'; +import { inject as service } from '@ember/service'; +import { alias } from '@ember/object/computed'; + +export default Component.extend({ + querier: service(), + query: null, + querySnapshot: null, + + isRunningQuery: alias('querier.isRunningQuery').readOnly(), + + actions: { + cancelClick() { + this.sendAction('cancelClick'); + }, + + reRunClick(query) { + this.sendAction('reRunClick', query); + }, + + queryClick(query) { + this.sendAction('queryClick', query); + } + } +}); diff --git a/app/components/query-input.js b/app/components/query-input.js index 9adb77d0..a19c6926 100644 --- a/app/components/query-input.js +++ b/app/components/query-input.js @@ -11,7 +11,6 @@ import Component from '@ember/component'; import { isEqual } from '@ember/utils'; import { SUBFIELD_SEPARATOR } from 'bullet-ui/models/column'; import { AGGREGATIONS } from 'bullet-ui/models/aggregation'; -import { EMIT_TYPES } from 'bullet-ui/models/window'; import BuilderAdapter from 'bullet-ui/mixins/builder-adapter'; export default Component.extend(BuilderAdapter, { @@ -30,7 +29,6 @@ export default Component.extend(BuilderAdapter, { scroller: service(), schema: null, isListening: false, - listenDuration: 0, hasError: false, hasSaved: false, @@ -39,9 +37,8 @@ export default Component.extend(BuilderAdapter, { return this.builderFilters(schema); }).readOnly(), - showAggregationSize: computed('query.{aggregation.type,window.emit.type,isWindowless}', function() { - return isEqual(this.get('query.aggregation.type'), AGGREGATIONS.get('RAW')) && - (this.get('query.isWindowless') || isEqual(this.get('query.window.emit.type'), EMIT_TYPES.get('TIME'))); + showAggregationSize: computed('query.{aggregation.type,isWindowless}', function() { + return isEqual(this.get('query.aggregation.type'), AGGREGATIONS.get('RAW')) && this.get('query.isWindowless'); }), didInsertElement() { @@ -79,7 +76,6 @@ export default Component.extend(BuilderAdapter, { reset() { this.setProperties({ isListening: false, - listenDuration: 0, hasError: false, hasSaved: false }); @@ -119,8 +115,7 @@ export default Component.extend(BuilderAdapter, { this.save().then(() => { this.setProperties({ isListening: true, - hasSaved: true, - listenDuration: this.get('query.duration') * 1000 + hasSaved: true }); this.$(this.get('queryBuilderInputs')).attr('disabled', true); this.sendAction('fireQuery'); diff --git a/app/components/records-charter.js b/app/components/records-charter.js index 3250758b..faed3d12 100644 --- a/app/components/records-charter.js +++ b/app/components/records-charter.js @@ -16,14 +16,16 @@ export default Component.extend({ columns: null, rows: null, chartType: 'bar', + config: null, simpleMode: true, notSimpleMode: not('simpleMode').readOnly(), - cannotModeSwitch: alias('model.isRaw').readOnly(), + cannotModeSwitch: alias('config.isRaw').readOnly(), canModeSwitch: not('cannotModeSwitch').readOnly(), pivotMode: or('notSimpleMode', 'cannotModeSwitch').readOnly(), - pivotOptions: computed('model.pivotOptions', function() { - return JSON.parse(this.get('model.pivotOptions')); + pivotOptions: computed('config.pivotOptions', function() { + let options = this.get('config.pivotOptions') || '{}'; + return JSON.parse(options); }).readOnly(), sampleRow: computed('rows', 'columns', function() { @@ -41,9 +43,9 @@ export default Component.extend({ return typicalRow; }), - independentColumns: computed('model', 'sampleRow', 'columns', function() { + independentColumns: computed('config', 'sampleRow', 'columns', function() { let { columns, sampleRow } = this.getProperties('columns', 'sampleRow'); - let isDistribution = this.get('model.isDistribution'); + let isDistribution = this.get('config.isDistribution'); if (isDistribution) { return A(columns.filter(c => this.isAny(c, 'Quantile', 'Range'))); } @@ -51,9 +53,9 @@ export default Component.extend({ return A(columns.filter(c => this.isType(sampleRow, c, 'string'))); }), - dependentColumns: computed('model', 'sampleRow', 'columns', function() { + dependentColumns: computed('config', 'sampleRow', 'columns', function() { let { columns, sampleRow } = this.getProperties('columns', 'sampleRow'); - let isDistribution = this.get('model.isDistribution'); + let isDistribution = this.get('config.isDistribution'); if (isDistribution) { return A(columns.filter(c => this.isAny(c, 'Count', 'Value', 'Probability'))); } diff --git a/app/components/records-table.js b/app/components/records-table.js index 2aec0fd6..2bbca61c 100644 --- a/app/components/records-table.js +++ b/app/components/records-table.js @@ -7,17 +7,21 @@ import EmberObject, { computed } from '@ember/object'; import Component from '@ember/component'; import Table from 'ember-light-table'; import PaginatedTable from 'bullet-ui/mixins/paginated-table'; +import { isNone } from '@ember/utils'; export default Component.extend(PaginatedTable, { classNames: ['records-table'], columnNames: null, cellComponent: 'cells/record-entry', pageSize: 10, - isFixed: true, + appendMode: false, + + sortedByColumn: null, + // Use natural types in the results useDefaultStringExtractor: false, - rawRows: null, + table: null, columns: computed('columnNames', function() { let names = this.get('columnNames'); @@ -33,8 +37,18 @@ export default Component.extend(PaginatedTable, { init() { this._super(...arguments); this.set('table', new Table(this.get('columns'))); + this.set('sortColumn', null); + }, + didReceiveAttrs() { + this._super(...arguments); this.set('rows', this.get('rawRows').map(row => EmberObject.create(row))); - this.addPages(1); + let sortColumn = this.get('sortColumn'); + let hasSortedColumn = !isNone(sortColumn); + this.reset(hasSortedColumn); + if (hasSortedColumn) { + this.sortBy(sortColumn.valuePath, sortColumn.ascending ? 'ascending' : 'descending'); + } + this.addPages(); } }); diff --git a/app/components/records-viewer.js b/app/components/records-viewer.js index c32b4752..34e21116 100644 --- a/app/components/records-viewer.js +++ b/app/components/records-viewer.js @@ -14,16 +14,17 @@ import Component from '@ember/component'; export default Component.extend({ fileSaver: service(), classNames: ['records-viewer'], + aggregateMode: false, showRawData: false, showTable: false, showChart: false, - // Copy of the model - model: null, + config: null, metadata: null, records: null, fileName: 'results', + model: null, - enableCharting: not('model.isSingleRow').readOnly(), + enableCharting: not('config.isSingleRow').readOnly(), columns: computed('records', function() { return A(this.extractUniqueColumns(this.get('records'))); @@ -50,6 +51,15 @@ export default Component.extend({ return this.makeCSVString(columns, rows); }).readOnly(), + init() { + this._super(...arguments); + if (this.get('config.isReallyRaw')) { + this.set('showRawData', true); + } else { + this.set('showTable', true); + } + }, + extractUniqueColumns(records) { let columns = new Set(); records.forEach(record => { diff --git a/app/components/result-viewer.js b/app/components/result-viewer.js new file mode 100644 index 00000000..051c1bd6 --- /dev/null +++ b/app/components/result-viewer.js @@ -0,0 +1,99 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { alias, and, not } from '@ember/object/computed'; +import { inject as service } from '@ember/service'; +import { isNone } from '@ember/utils'; + +export default Component.extend({ + classNames: ['result-viewer'], + querier: service(), + + query: null, + result: null, + selectedWindow: null, + autoUpdate: true, + // Tweaks the time for the window duration by this to adjust for Ember scheduling delays + jitter: -300, + + isRunningQuery: alias('querier.isRunningQuery').readOnly(), + isRaw: alias('result.isRaw').readOnly(), + errorWindow: alias('result.errorWindow').readOnly(), + hasData: alias('result.hasData').readOnly(), + numberOfWindows: alias('result.windows.length').readOnly(), + windowEmitEvery: alias('query.window.emit.every').readOnly(), + isTimeWindow: alias('query.window.isTimeBased').readOnly(), + isRecordWindow: not('isTimeWindow').readOnly(), + isRawRecordWindow: and('isRecordWindow', 'isRaw').readOnly(), + aggregateMode: alias('isRawRecordWindow').readOnly(), + + showAutoUpdate: computed('hasError', 'aggregateMode', 'hasData', function() { + return this.get('hasData') && !this.get('hasError') && !this.get('aggregateMode'); + }), + + hasError: computed('errorWindow', function() { + return !isNone(this.get('errorWindow')); + }).readOnly(), + + metadata: computed('hasError', 'autoUpdate', 'selectedWindow', 'result.windows.[]', function() { + return this.get('hasError') ? this.get('errorWindow.metadata') : this.getSelectedWindow('metadata'); + }).readOnly(), + + records: computed('autoUpdate', 'aggregateMode', 'selectedWindow', 'result.windows.[]', function() { + return this.get('aggregateMode') ? this.getAllWindowRecords() : this.getSelectedWindow('records'); + }).readOnly(), + + queryDuration: computed('query.duration', function() { + return this.get('query.duration') * 1000; + }).readOnly(), + + windowDuration: computed('windowEmitEvery', function() { + return this.get('jitter') + (this.get('windowEmitEvery') * 1000); + }).readOnly(), + + config: computed('result.{isRaw,isReallyRaw,isDistribution,isSingleRow}', function() { + return { + isRaw: this.get('result.isRaw'), + isReallyRaw: this.get('result.isReallyRaw'), + isDistribution: this.get('result.isDistribution'), + isSingleRow: this.get('result.isSingleRow'), + pivotOptions: this.get('result.pivotOptions') + }; + }).readOnly(), + + didReceiveAttrs() { + this._super(...arguments); + this.set('selectedWindow', null); + this.set('autoUpdate', true); + }, + + getSelectedWindow(property) { + let windowProperty = this.get(`result.windows.lastObject.${property}`); + if (!this.get('autoUpdate')) { + let selectedWindow = this.get('selectedWindow'); + windowProperty = isNone(selectedWindow) ? windowProperty : selectedWindow[property]; + } + return windowProperty; + }, + + getAllWindowRecords() { + return this.get('result.windows').getEach('records').reduce((p, c) => p.concat(c), []); + }, + + actions: { + changeWindow(selectedWindow) { + this.set('selectedWindow', selectedWindow); + this.set('autoUpdate', false); + }, + + changeAutoUpdate(autoUpdate) { + // Turn On => reset selectedWindow. Turn Off => Last window + this.set('selectedWindow', autoUpdate ? null : this.get('result.windows.lastObject')); + this.set('autoUpdate', autoUpdate); + } + } +}); diff --git a/app/components/result-window-placeholder.js b/app/components/result-window-placeholder.js new file mode 100644 index 00000000..d39ee739 --- /dev/null +++ b/app/components/result-window-placeholder.js @@ -0,0 +1,12 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +import Component from '@ember/component'; + +export default Component.extend({ + classNames: ['result-window-placeholder'], + tagName: 'span', + windowCount: 0 +}); diff --git a/app/components/results-table.js b/app/components/results-table.js index 6bec3e51..001486e0 100644 --- a/app/components/results-table.js +++ b/app/components/results-table.js @@ -13,15 +13,14 @@ export default Component.extend(PaginatedTable, { classNames: ['results-table'], results: null, pageSize: 5, - isFixed: true, extractors: EmberObject.create({ created(row) { return row.get('created'); }, - records(row) { - return row.get('records.length'); + windows(row) { + return row.get('windows.length'); } }), @@ -31,7 +30,7 @@ export default Component.extend(PaginatedTable, { columns: A([ { label: 'Date', valuePath: 'created', width: '150px', cellComponent: 'cells/result-date-entry' }, - { label: '# Records', valuePath: 'records', width: '80px', cellComponent: 'cells/result-number-entry' } + { label: '# Windows', valuePath: 'windows', width: '80px', cellComponent: 'cells/result-number-entry' } ]), init() { diff --git a/app/components/schema-table.js b/app/components/schema-table.js index 3a137881..625f6c02 100644 --- a/app/components/schema-table.js +++ b/app/components/schema-table.js @@ -15,7 +15,6 @@ export default Component.extend(PaginatedTable, { classNameBindings: ['isNested'], queries: null, pageSize: 10, - isFixed: true, /** * If set to true, the table behaves as a nested table without headers and will not expand further. Meant to * be used when the table is nested within itself. diff --git a/app/components/timed-progress-bar.js b/app/components/timed-progress-bar.js index bcfbd615..90d2e6d8 100644 --- a/app/components/timed-progress-bar.js +++ b/app/components/timed-progress-bar.js @@ -4,42 +4,81 @@ * See the LICENSE file associated with the project for terms. */ import { cancel, later } from '@ember/runloop'; -import { htmlSafe } from '@ember/string'; import { computed } from '@ember/object'; import Component from '@ember/component'; export default Component.extend({ + // Component API duration: 10000, active: false, - updateInterval: 500, + updateInterval: 200, + // The value doesn't matter. Using this as a 'observer' to trigger timing again. + retriggerOnChangeIn: null, + + // Ember Progress Bar configuration + shape: 'Circle', + strokeWidth: 8.0, + color: '#555', + doneColor: '#21D87D', + trailColor: '#eee', + // https://github.com/jeremyckahn/shifty/blob/e13d78f887b3783b6d93e501d26cc37a9fa2d206/src/easing-functions.js + easing: 'linear', + animationDuration: 200, + useStep: true, + + // Private component configuration finished: undefined, startTime: null, endTime: null, runTime: null, percentNow: 0, futureTimer: null, + timingDone: true, + + showDone: computed('timingDone', 'active', function() { + return this.get('timingDone') || !this.get('active'); + }).readOnly(), - progressWidth: computed('percentNow', function() { - let percent = this.get('percentNow'); - return htmlSafe(`width: ${percent}%`); + progress: computed('percentNow', 'timingDone', function() { + // Deliberately based on timingDone instead of showDone to show the progress percent at which the query became done + return this.get('timingDone') ? 1 : this.get('percentNow'); + }).readOnly(), + + strokeColor: computed('showDone', 'color', function() { + return this.get('showDone') ? this.get('doneColor') : this.get('color'); }), - valueNow: computed('percentNow', 'timingDone', function() { - if (this.get('timingDone')) { - return 'Collecting results...'; + options: computed('strokeWidth', 'strokeColor', 'trailColor', function() { + let { strokeWidth, strokeColor, trailColor, easing, animationDuration, useStep } + = this.getProperties('strokeWidth', 'strokeColor', 'trailColor', 'easing', 'animationDuration', 'useStep'); + + let options = { + strokeWidth, + color: strokeColor, + trailColor, + easing, + duration: animationDuration + }; + if (useStep) { + options.step = this.get('step'); } - return `${this.get('percentNow')}%`; - }), + return options; + }).readOnly(), didReceiveAttrs() { this._super(...arguments); + this.destroyTimer(); // Don't do any timing unless active. Also any changes with active true should restart timer. if (this.get('active')) { - this.destroyTimer(); this.startTiming(); } }, + step(state, path) { + let displayText = (path.value() * 100).toFixed(0); + path.setText(`${displayText}%`); + }, + destroyTimer() { // Docs say this should return false or undefined if it doesn't exist cancel(this.get('futureTimer')); @@ -52,7 +91,7 @@ export default Component.extend({ startTiming() { let now = Date.now(); let magnitude = parseFloat(this.get('duration')); - magnitude = magnitude <= 0 ? 100 : magnitude; + magnitude = magnitude <= 0 ? 1 : magnitude; let end = new Date(now + magnitude).getTime(); this.setProperties({ @@ -60,6 +99,8 @@ export default Component.extend({ endTime: end, duration: magnitude, runTime: end - now, + percentNow: 0.0, + timingDone: false, futureTimer: later(this, this.timer, this.get('updateInterval')) }); }, @@ -67,7 +108,7 @@ export default Component.extend({ timer() { let timeNow = Date.now(); let delta = (timeNow - this.get('startTime')) / this.get('runTime'); - this.set('percentNow', Math.min(Math.floor(100 * delta), 100)); + this.set('percentNow', Math.min(delta, 1)); if (timeNow >= this.get('endTime')) { this.set('timingDone', true); this.sendAction('finished'); diff --git a/app/components/window-input.js b/app/components/window-input.js index 661c4648..de5d97ec 100644 --- a/app/components/window-input.js +++ b/app/components/window-input.js @@ -31,12 +31,13 @@ export default Component.extend({ }), // Helper equalities for template - everyForRecordBasedWindow: alias('settings.defaultValues.everyForRecordBasedWindow').readOnly(), - everyForTimeBasedWindow: alias('settings.defaultValues.everyForTimeBasedWindow').readOnly(), + defaultEveryForRecordWindow: alias('settings.defaultValues.everyForRecordBasedWindow').readOnly(), + defaultEveryForTimeWindow: alias('settings.defaultValues.everyForTimeBasedWindow').readOnly(), isWindowless: alias('query.isWindowless').readOnly(), isTimeBasedWindow: equal('emitType', EMIT_TYPES.get('TIME')).readOnly(), isRecordBasedWindow: equal('emitType', EMIT_TYPES.get('RECORD')).readOnly(), isRawAggregation: equal('query.aggregation.type', AGGREGATIONS.get('RAW')).readOnly(), + recordBasedWindowDisabled: computed('isRawAggregation', 'disabled', function() { return this.get('disabled') || !this.get('isRawAggregation'); }).readOnly(), @@ -45,8 +46,9 @@ export default Component.extend({ allIncludeTypeDisabled: computed('isRawAggregation', 'includeDisabled', function() { return this.get('includeDisabled') || this.get('isRawAggregation'); }).readOnly(), + everyFieldName: computed('isRecordBasedWindow', function() { - return this.get('isRecordBasedWindow') ? 'every (records)' : 'every (seconds)'; + return `Frequency (${this.get('isRecordBasedWindow') ? 'records' : 'seconds'})`; }).readOnly(), replaceWindow(emitType, emitEvery, includeType) { @@ -59,7 +61,7 @@ export default Component.extend({ return this.get('queryManager').addWindow(this.get('query')); }, - removeWindow() { + deleteWindow() { return this.get('queryManager').deleteWindow(this.get('query')); }, @@ -67,9 +69,9 @@ export default Component.extend({ changeEmitType(emitType) { if (isEqual(emitType, EMIT_TYPES.get('RECORD'))) { this.set('includeType', INCLUDE_TYPES.get('WINDOW')); - this.replaceWindow(emitType, this.get('everyForRecordBasedWindow'), INCLUDE_TYPES.get('WINDOW')); + this.replaceWindow(emitType, this.get('defaultEveryForRecordWindow'), INCLUDE_TYPES.get('WINDOW')); } else { - this.replaceWindow(emitType, this.get('everyForTimeBasedWindow'), this.get('includeType')); + this.replaceWindow(emitType, this.get('defaultEveryForTimeWindow'), this.get('includeType')); } }, @@ -81,8 +83,8 @@ export default Component.extend({ this.addWindow(); }, - removeWindow() { - this.removeWindow(); + deleteWindow() { + this.deleteWindow(); } } }); diff --git a/app/components/result-metadata.js b/app/components/window-metadata.js similarity index 94% rename from app/components/result-metadata.js rename to app/components/window-metadata.js index d0027ed0..14f28924 100644 --- a/app/components/result-metadata.js +++ b/app/components/window-metadata.js @@ -7,7 +7,7 @@ import { computed } from '@ember/object'; import Component from '@ember/component'; export default Component.extend({ - classNames: ['result-metadata'], + classNames: ['window-metadata'], classNameBindings: ['expanded:is-expanded'], expanded: false, expansionIconClasses: computed('expanded', function() { diff --git a/app/mixins/paginated-table.js b/app/mixins/paginated-table.js index 3eb4fed3..c3634739 100644 --- a/app/mixins/paginated-table.js +++ b/app/mixins/paginated-table.js @@ -14,6 +14,8 @@ export default Mixin.create({ firstNewRow: 0, extractors: null, useDefaultStringExtractor: true, + sortColumn: null, + appendMode: false, numberOfRows: computed('rows.[]', function() { return this.get('rows.length'); @@ -79,15 +81,19 @@ export default Mixin.create({ }); }, - reset() { + reset(forceReset = false) { let table = this.get('table'); - table.setRows([]); - this.set('firstNewRow', 0); + // Reset table if not in appendMode + if (!this.get('appendMode') || forceReset) { + table.setRows([]); + this.set('firstNewRow', 0); + } }, actions: { onColumnClick(column) { - this.reset(); + this.set('sortColumn', column); + this.reset(true); this.sortBy(column.valuePath, column.ascending ? 'ascending' : 'descending'); this.addPages(); }, diff --git a/app/mixins/queryable.js b/app/mixins/queryable.js new file mode 100644 index 00000000..65bc7c85 --- /dev/null +++ b/app/mixins/queryable.js @@ -0,0 +1,41 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +import Mixin from '@ember/object/mixin'; + +export default Mixin.create({ + resultHandler(routeContext) { + routeContext.transitionTo('result', routeContext.get('savedResult.id')); + }, + + errorHandler(error, routeContext) { + console.error(error); // eslint-disable-line no-console + routeContext.transitionTo('errored'); + }, + + windowHandler(message, routeContext) { + routeContext.get('queryManager').addSegment(routeContext.get('savedResult'), message); + }, + + submitQuery(query, result, routeContext) { + let handlers = { + success: this.resultHandler, + error: this.errorHandler, + message: this.windowHandler + }; + routeContext.set('savedResult', result); + routeContext.get('querier').send(query, handlers, routeContext); + }, + + lateSubmitQuery(query, routeContext) { + let handlers = { + success: () => { }, + error: this.errorHandler, + message: this.windowHandler + }; + // savedResult already exists and points to result + routeContext.get('querier').send(query, handlers, routeContext); + } +}); diff --git a/app/models/query.js b/app/models/query.js index c5578d6c..71df3cb5 100644 --- a/app/models/query.js +++ b/app/models/query.js @@ -7,6 +7,7 @@ import { isEmpty, isEqual } from '@ember/utils'; import { A } from '@ember/array'; import { computed } from '@ember/object'; import DS from 'ember-data'; +import { pluralize } from 'ember-inflector'; import { validator, buildValidations } from 'ember-cp-validations'; import { AGGREGATIONS } from 'bullet-ui/models/aggregation'; import { EMIT_TYPES, INCLUDE_TYPES } from 'bullet-ui/models/window'; @@ -53,9 +54,9 @@ export default DS.Model.extend(Validations, { isWindowless: computed('window', function() { return isEmpty(this.get('window.id')); - }), + }).readOnly(), - hasUnsavedFields: computed('projections.@each.field', 'aggregation.groups.@each.field', function() { + hasUnsavedFields: computed('projections.@each.name', 'aggregation.groups.@each.name', function() { let projections = this.getWithDefault('projections', A()); let groups = this.getWithDefault('aggregation.groups', A()); return this.hasNoName(projections) || this.hasNoName(groups); @@ -72,7 +73,7 @@ export default DS.Model.extend(Validations, { groupsSummary: computed('aggregation.groups.@each.name', function() { return this.summarizeFieldLike(this.get('aggregation.groups')); - }), + }).readOnly(), metricsSummary: computed('aggregation.metrics.@each.{type,name}', function() { let metrics = this.getWithDefault('aggregation.metrics', A()); @@ -85,7 +86,7 @@ export default DS.Model.extend(Validations, { } return isEmpty(name) ? `${type}(${field})` : name; }).join(', '); - }), + }).readOnly(), aggregationSummary: computed('aggregation.type', 'aggregation.attributes.{type,newName,threshold}', 'groupsSummary', 'metricsSummary', function() { let type = this.get('aggregation.type'); @@ -116,7 +117,7 @@ export default DS.Model.extend(Validations, { return metricsSummary; } return `${groupsSummary}, ${metricsSummary}`; - }), + }).readOnly(), fieldsSummary: computed('projectionsSummary', 'aggregationSummary', function() { let projectionsSummary = this.get('projectionsSummary'); @@ -126,7 +127,7 @@ export default DS.Model.extend(Validations, { return isEmpty(projectionsSummary) ? 'All' : projectionsSummary; } return this.get('aggregationSummary'); - }), + }).readOnly(), windowSummary: computed('isWindowless', 'window.{emit.type,emit.every,include.type}', function() { if (this.get('isWindowless')) { @@ -135,8 +136,8 @@ export default DS.Model.extend(Validations, { let emitType = this.get('window.emit.type'); let emitEvery = this.get('window.emit.every'); let includeType = this.get('window.include.type'); - return `Every ${emitEvery} ${isEqual(emitType, EMIT_TYPES.get('TIME')) ? 'seconds' : 'records'} ${isEqual(includeType, INCLUDE_TYPES.get('ALL')) ? ', Cumulative' : ''}`; - }), + return `Every ${emitEvery} ${this.getEmitUnit(emitType, emitEvery)}${this.getIncludeType(includeType)}`; + }).readOnly(), latestResult: computed('results.[]', function() { let results = this.get('results'); @@ -162,5 +163,14 @@ export default DS.Model.extend(Validations, { hasNoName(fieldLike) { return isEmpty(fieldLike) ? false : fieldLike.any(f => !isEmpty(f.get('field')) && isEmpty(f.get('name'))); + }, + + getEmitUnit(emitType, emitEvery) { + let unit = isEqual(emitType, EMIT_TYPES.get('TIME')) ? 'second' : 'record'; + return Number(emitEvery) === 1 ? unit : pluralize(unit); + }, + + getIncludeType(includeType) { + return isEqual(includeType, INCLUDE_TYPES.get('ALL')) ? ', Cumulative' : ''; } }); diff --git a/app/models/result.js b/app/models/result.js index 825b107e..69b6917a 100644 --- a/app/models/result.js +++ b/app/models/result.js @@ -3,8 +3,10 @@ * Licensed under the terms of the Apache License, Version 2.0. * See the LICENSE file associated with the project for terms. */ -import { equal, or, alias } from '@ember/object/computed'; +import { equal, or } from '@ember/object/computed'; +import { isEmpty, isNone } from '@ember/utils'; import { computed } from '@ember/object'; +import { A } from '@ember/array'; import DS from 'ember-data'; import { AGGREGATIONS } from 'bullet-ui/models/aggregation'; @@ -15,32 +17,42 @@ export default DS.Model.extend({ } }), query: DS.belongsTo('query', { autoSave: true }), - // TODO: change it to async: true when metadata and records computed properties are removed. - segments: DS.hasMany('segment', { dependent: 'destroy' }), + + // Not using hasMany to handle extremely high volume (rate-limited) results. + windows: DS.attr('window-array', { + defaultValue() { + return A(); + } + }), + pivotOptions: DS.attr('string'), querySnapshot: DS.attr(), - // TODO: Remove them after result page has been changed. - metadata: alias('segments.lastObject.metadata'), - records: alias('segments.lastObject.records'), - - isRaw: equal('querySnapshot.type', AGGREGATIONS.get('RAW')), - isCountDistinct: equal('querySnapshot.type', AGGREGATIONS.get('COUNT_DISTINCT')), - isGroup: equal('querySnapshot.type', AGGREGATIONS.get('GROUP')), - isDistribution: equal('querySnapshot.type', AGGREGATIONS.get('DISTRIBUTION')), - isTopK: equal('querySnapshot.type', AGGREGATIONS.get('TOP_K')), + isRaw: equal('querySnapshot.type', AGGREGATIONS.get('RAW')).readOnly(), + isCountDistinct: equal('querySnapshot.type', AGGREGATIONS.get('COUNT_DISTINCT')).readOnly(), + isGroup: equal('querySnapshot.type', AGGREGATIONS.get('GROUP')).readOnly(), + isDistribution: equal('querySnapshot.type', AGGREGATIONS.get('DISTRIBUTION')).readOnly(), + isTopK: equal('querySnapshot.type', AGGREGATIONS.get('TOP_K')).readOnly(), isReallyRaw: computed('isRaw', 'querySnapshot.projectionsSize', function() { return this.get('isRaw') && this.get('querySnapshot.projectionsSize') === 0; - }), + }).readOnly(), isGroupAll: computed('isGroup', 'querySnapshot.groupsSize', function() { return this.get('isGroup') && this.get('querySnapshot.groupsSize') === 0; - }), + }).readOnly(), isGroupBy: computed('isGroup', 'querySnapshot.{metricsSize,groupsSize}', function() { return this.get('isGroup') && this.get('querySnapshot.metricsSize') >= 1 && this.get('querySnapshot.groupsSize') >= 1; - }), + }).readOnly(), + + isSingleRow: or('isCountDistinct', 'isGroupAll').readOnly(), + + errorWindow: computed('windows.[]', function() { + return this.get('windows').find(window => !isNone(window.metadata.errors)); + }).readOnly(), - isSingleRow: or('isCountDistinct', 'isGroupAll') + hasData: computed('windows.[]', function() { + return !isEmpty(this.get('windows')); + }).readOnly() }); diff --git a/app/models/segment.js b/app/models/segment.js deleted file mode 100644 index 71d8de4f..00000000 --- a/app/models/segment.js +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2018, Yahoo Inc. - * Licensed under the terms of the Apache License, Version 2.0. - * See the LICENSE file associated with the project for terms. - */ -import { A } from '@ember/array'; -import EmberObject from '@ember/object'; -import DS from 'ember-data'; - -export default DS.Model.extend({ - metadata: DS.attr({ - defaultValue() { - return EmberObject.create(); - } - }), - records: DS.attr({ - defaultValue() { - return A(); - } - }), - created: DS.attr('date', { - defaultValue() { - return new Date(Date.now()); - } - }), - result: DS.belongsTo('result', { autoSave: true }), - pivotOptions: DS.attr('string') -}); diff --git a/app/models/window.js b/app/models/window.js index ab1312da..31bed267 100644 --- a/app/models/window.js +++ b/app/models/window.js @@ -6,8 +6,9 @@ import EmberObject from '@ember/object'; import DS from 'ember-data'; import { validator, buildValidations } from 'ember-cp-validations'; +import { equal } from '@ember/object/computed'; -let emitType = EmberObject.extend({ +let EmitTypes = EmberObject.extend({ TIME: 'Time Based', RECORD: 'Record Based', @@ -24,14 +25,14 @@ let emitType = EmberObject.extend({ } }); -let includeType = EmberObject.extend({ +let IncludeTypes = EmberObject.extend({ WINDOW: 'Everything in Window', - ALL: 'Everything from Start', + ALL: 'Everything from Start of Query', init() { this._super(...arguments); this.set('API', { - 'Everything from Start': 'ALL' + 'Everything from Start of Query': 'ALL' }); }, @@ -40,18 +41,18 @@ let includeType = EmberObject.extend({ } }); -export const EMIT_TYPES = emitType.create(); -export const INCLUDE_TYPES = includeType.create(); +export const EMIT_TYPES = EmitTypes.create(); +export const INCLUDE_TYPES = IncludeTypes.create(); let Validations = buildValidations({ 'emit.every': { - description: 'emit frequency', validators: [ + description: 'Emit frequency', validators: [ validator('presence', true), validator('number', { integer: true, allowString: true, gte: 1, - message: 'emit frequency must be a positive integer' + message: 'Emit frequency must be a positive integer' }), validator('window-emit-frequency') ] @@ -75,5 +76,7 @@ export default DS.Model.extend(Validations, { }); } }), - query: DS.belongsTo('query', { autoSave: true }) + query: DS.belongsTo('query', { autoSave: true }), + + isTimeBased: equal('emit.type', EMIT_TYPES.get('TIME')) }); diff --git a/app/routes/query.js b/app/routes/query.js index 07c69157..ad3539a5 100644 --- a/app/routes/query.js +++ b/app/routes/query.js @@ -6,35 +6,17 @@ import { hash, resolve } from 'rsvp'; import { inject as service } from '@ember/service'; import Route from '@ember/routing/route'; +import Queryable from 'bullet-ui/mixins/queryable'; -export default Route.extend({ +export default Route.extend(Queryable, { querier: service(), queryManager: service(), - resultHandler(context) { - context.transitionTo('result', context.get('result.id')); - }, - - errorHandler(error, context) { - console.error(error); // eslint-disable-line no-console - context.transitionTo('errored'); - }, - - segmentHandler(message, context) { - context.get('queryManager').addSegment(context.get('result'), message); - }, - actions: { fireQuery() { this.get('queryManager').addResult(this.paramsFor('query').query_id).then(result => { this.store.findRecord('query', this.paramsFor('query').query_id).then(query => { - this.set('result', result); - let handlers = { - success: this.resultHandler, - error: this.errorHandler, - message: this.segmentHandler - }; - this.get('querier').send(query, handlers, this); + this.submitQuery(query, result, this); }); }); } diff --git a/app/routes/result.js b/app/routes/result.js index 19e5b0d5..f4f97eb2 100644 --- a/app/routes/result.js +++ b/app/routes/result.js @@ -6,21 +6,29 @@ import { hash } from 'rsvp'; import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; +import Queryable from 'bullet-ui/mixins/queryable'; -export default Route.extend({ +export default Route.extend(Queryable, { querier: service(), + queryManager: service(), model(params) { return this.store.findRecord('result', params.result_id).catch(() => { this.transitionTo('missing', 'not-found'); }); }, - // Force the fetching of query and filter + afterModel(model) { - return hash({ - segments: model.get('segments'), - query: model.get('query'), - filter: model.get('query').then(query => query.get('filter')) + // Fetch all the things + return model.get('query').then(query => { + return hash({ + filter: query.get('filter'), + projections: query.get('projections'), + aggregation: query.get('aggregation').then(aggregation => { + return hash({ groups: aggregation.get('groups'), metrics: aggregation.get('metrics') }); + }), + window: query.get('window') + }); }); }, @@ -29,9 +37,33 @@ export default Route.extend({ this.transitionTo('query', query.get('id')); }, + reRunClick(query) { + this.get('queryManager').addResult(query.get('id')).then(result => { + this.set('hasPendingSubmit', true); + this.set('pendingQuery', query); + this.set('savedResult', result); + // Sends us to a new result page but don't want to start the new query till we finish transitioning. + this.resultHandler(this); + }); + }, + + cancelClick() { + this.get('querier').cancel(); + }, + willTransition() { this.get('querier').cancel(); return true; + }, + + didTransition() { + if (this.get('hasPendingSubmit')) { + let pendingQuery = this.get('pendingQuery'); + this.lateSubmitQuery(pendingQuery, this); + this.set('hasPendingSubmit', false); + this.set('pendingQuery', null); + } + return true; } } }); diff --git a/app/services/querier.js b/app/services/querier.js index a3403d97..8bbc0414 100644 --- a/app/services/querier.js +++ b/app/services/querier.js @@ -7,6 +7,7 @@ import { A } from '@ember/array'; import $ from 'jquery'; import { isNone, isEmpty, isEqual } from '@ember/utils'; import EmberObject from '@ember/object'; +import { alias } from '@ember/object/computed'; import Service, { inject as service } from '@ember/service'; import Filterizer from 'bullet-ui/mixins/filterizer'; import { AGGREGATIONS, DISTRIBUTIONS } from 'bullet-ui/models/aggregation'; @@ -20,6 +21,9 @@ export default Service.extend(Filterizer, { subfieldSeparator: '.', delimiter: ',', apiMode: true, + pendingRequest: null, + + isRunningQuery: alias('stompWebsocket.isConnected').readOnly(), /** * Recreates a Ember Data like representation from an API query specification. @@ -143,12 +147,14 @@ export default Service.extend(Filterizer, { if (isEmpty(json)) { return false; } + let emit = EmberObject.create({ type: EMIT_TYPES.get(json.emit.type), - every: Number(json.emit.every) / 1000 + every: isEqual(json.emit.type, 'TIME') ? Number(json.emit.every) / 1000 : Number(json.emit.every) }); + let include = EmberObject.create(); - if (!isEmpty(json.include) && isEqual(json.include.type, INCLUDE_TYPES.apiKey(INCLUDE_TYPES.get('ALL')))) { + if (!isEmpty(json.include) && isEqual(json.include.type, 'ALL')) { include.set('type', INCLUDE_TYPES.get('ALL')); } else { include.set('type', INCLUDE_TYPES.get('WINDOW')); @@ -176,12 +182,15 @@ export default Service.extend(Filterizer, { }, reformatWindow(window) { + let emitType = window.get('emit.type'); + let emitEvery = window.get('emit.every'); let json = { emit: { - type: EMIT_TYPES.apiKey(window.get('emit.type')), - every: Number(window.get('emit.every')) * 1000 + type: EMIT_TYPES.apiKey(emitType), + every: isEqual(emitType, EMIT_TYPES.get('TIME')) ? Number(emitEvery) * 1000 : Number(emitEvery) } }; + let includeType = window.get('include.type'); if (isEqual(includeType, INCLUDE_TYPES.get('ALL'))) { json.include = { type: INCLUDE_TYPES.apiKey(includeType) }; @@ -349,24 +358,11 @@ export default Service.extend(Filterizer, { return object; }, - /** - * Exposes the low-level StompClient object in order to abort. - * - * @param {Object} data The Query model. - * @param {Object} handlers The Object which contains the functions to invoke on success, failure or message. - * @param {Object} context The context for the handlers. - */ send(data, handlers, context) { - data = this.reformat(data); - let stompClient = this.get('stompWebsocket').createStompClient(data, handlers, context); - this.set('pendingRequest', stompClient); + this.get('stompWebsocket').startStompClient(this.reformat(data), handlers, context); }, cancel() { - let pendingRequest = this.get('pendingRequest'); - if (pendingRequest) { - pendingRequest.disconnect(); - this.set('pendingRequest', null); - } + this.get('stompWebsocket').disconnect(); } }); diff --git a/app/services/query-manager.js b/app/services/query-manager.js index ec79dd97..cd6519eb 100644 --- a/app/services/query-manager.js +++ b/app/services/query-manager.js @@ -6,15 +6,27 @@ import EmberObject from '@ember/object'; import Service, { inject as service } from '@ember/service'; import { isBlank, isEmpty, isEqual } from '@ember/utils'; +import { computed, get, getProperties } from '@ember/object'; +import { debounce } from '@ember/runloop'; import { AGGREGATIONS, DISTRIBUTION_POINTS } from 'bullet-ui/models/aggregation'; import { pluralize } from 'ember-inflector'; import ZLib from 'npm:browserify-zlib'; import Base64 from 'npm:urlsafe-base64'; import { all, Promise, resolve } from 'rsvp'; +import config from '../config/environment'; export default Service.extend({ store: service(), querier: service(), + saveSegmentDebounceInterval: 100, + debounceSegmentSaves: config.APP.SETTINGS.debounceSegmentSaves, + + windowNumberProperty: computed('settings', function() { + let mapping = this.get('settings.defaultValues.metadataKeyMapping'); + let { windowSection, windowNumber } = getProperties(mapping, 'windowSection', 'windowNumber'); + return `${windowSection}.${windowNumber}`; + }).readOnly(), + copyModelRelationship(from, to, fields, inverseName, inverseValue) { fields.forEach(field => { @@ -122,14 +134,16 @@ export default Service.extend({ }, addSegment(result, data) { - let segment = this.get('store').createRecord('segment', { + let position = result.get('windows.length'); + result.get('windows').pushObject({ metadata: data.meta, records: data.records, - result: result - }); - return result.save().then(() => { - return segment.save(); + sequence: get(data.meta, this.get('windowNumberProperty')), + index: position, + created: new Date(Date.now()) }); + let shouldDebounce = this.get('debounceSegmentSaves'); + return shouldDebounce ? debounce(result, result.save, this.get('saveSegmentDebounceInterval')) : result.save(); }, setAggregationAttributes(query, fields) { @@ -312,22 +326,13 @@ export default Service.extend({ }, deleteResults(query) { - return query.get('results').then(results => { - let promises = results.toArray().map(r => this.deleteAllSegments(r)); - return all(promises); - }).then(() => { + return query.get('results').then(() => { return this.deleteMultiple('results', query, 'query').then(() => { return query.save(); }); }); }, - deleteAllSegments(result) { - return this.deleteMultiple('segments', result, 'result').then(() => { - return result.save(); - }); - }, - deleteQuery(query) { return all([ this.deleteSingle('filter', query, 'query'), @@ -344,12 +349,5 @@ export default Service.extend({ return this.get('store').findAll('query').then(queries => { queries.forEach(q => this.deleteResults(q)); }); - }, - - setIfNotEmpty(object, key, value) { - if (!isEmpty(value)) { - object.set(key, value); - } - return object; } }); diff --git a/app/services/stomp-websocket.js b/app/services/stomp-websocket.js index 3a083788..55de1522 100644 --- a/app/services/stomp-websocket.js +++ b/app/services/stomp-websocket.js @@ -3,19 +3,20 @@ * Licensed under the terms of the Apache License, Version 2.0. * See the LICENSE file associated with the project for terms. */ -import { isEqual } from '@ember/utils'; +import { isEqual, isNone } from '@ember/utils'; import { computed } from '@ember/object'; -import Service, { inject as service } from '@ember/service'; +import Service from '@ember/service'; import SockJS from 'npm:sockjs-client'; import Stomp from 'npm:@stomp/stompjs'; const ACK_TYPE = 'ACK'; const COMPLETE_TYPE = 'COMPLETE'; +const FAIL_TYPE = 'FAIL'; const NEW_QUERY_TYPE = 'NEW_QUERY'; const SESSION_LENGTH = 64; export default Service.extend({ - querier: service(), + client: null, url: computed('settings', function() { return `${this.get('settings.queryHost')}/${this.get('settings.queryNamespace')}/${this.get('settings.queryPath')}`; @@ -29,12 +30,16 @@ export default Service.extend({ return this.get('settings.queryStompResponseChannel'); }), + isConnected: computed('client', function() { + return !isNone(this.get('client')); + }), + makeStompMessageHandler(stompClient, handlers, context) { return payload => { let { type, content } = JSON.parse(payload.body); if (!isEqual(type, ACK_TYPE)) { - if (isEqual(type, COMPLETE_TYPE)) { - this.get('querier').cancel(); + if (isEqual(type, COMPLETE_TYPE) || isEqual(type, FAIL_TYPE)) { + this.set('client', null); } handlers.message(JSON.parse(content), context); } @@ -58,11 +63,11 @@ export default Service.extend({ makeStompErrorHandler(handlers, context) { return (...args) => { - handlers.error(`Error when connecting the server: ${args}`, context); + handlers.error(`Error while communicating with the server: ${args}`, context); }; }, - createStompClient(data, handlers, context) { + startStompClient(data, handlers, context) { let url = this.get('url'); let ws = new SockJS(url, [], { sessionId: SESSION_LENGTH }); let stompClient = Stomp.over(ws); @@ -70,7 +75,16 @@ export default Service.extend({ let onStompConnect = this.makeStompConnectHandler(stompClient, data, handlers, context); let onStompError = this.makeStompErrorHandler(handlers, context); + + this.set('client', stompClient); stompClient.connect({ }, onStompConnect, onStompError); - return stompClient; + }, + + disconnect() { + let client = this.get('client'); + if (!isNone(client)) { + client.disconnect(); + this.set('client', null); + } } }); diff --git a/app/styles/components/column-field.scss b/app/styles/components/column-field.scss index 0b3999f5..d49d2af3 100644 --- a/app/styles/components/column-field.scss +++ b/app/styles/components/column-field.scss @@ -21,6 +21,6 @@ } .column-mainfield, .column-onlyfield { - @import "power-select-inputs"; + @import "power-select-query-field"; } } diff --git a/app/styles/components/control-container.scss b/app/styles/components/control-container.scss new file mode 100644 index 00000000..604c8132 --- /dev/null +++ b/app/styles/components/control-container.scss @@ -0,0 +1,58 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +.auto-update-wrapper { + @import "mode-toggle"; + .mode-toggle { + justify-content: flex-start; + .mode { + width: 45px; + height: 20px; + font-size: $font-size-regular-small; + } + } + span { + font-size: $font-size-regular; + font-color: $inactive-grey; + font-family: $font-family-regular; + } + &.no-visibility { + visibility: hidden; + } +} + +.window-selector { + $input-height: 50px; + $width: 400px; + @import "power-select-field"; + + display: flex; + flex-direction: column; + align-items: center; + align-self: center; + + .ember-power-select-trigger { + border: 1px solid $inactive-grey; + border-radius: 2px; + display: flex; + justify-content: center; + align-items: center; + width: $width; + font-size: $font-size-regular-large; + } + .window-progress-indicator { + svg { + height: 5px !important; + width: $width !important; + } + } +} + +.query-progress-indicator { + svg { + height: 60px !important; + width: 60px !important; + } +} diff --git a/app/styles/components/mode-toggle.scss b/app/styles/components/mode-toggle.scss index 2e71fd68..714823c9 100644 --- a/app/styles/components/mode-toggle.scss +++ b/app/styles/components/mode-toggle.scss @@ -9,11 +9,7 @@ $mode-inactive-color: $special-button-inactive-color; display: flex; - justify-content: flex-end; - margin: 10px 0; - .mode { - width: 70px; padding: 0 0 2px 0; background-color: $mode-inactive-color; color: $white; diff --git a/app/styles/components/nested-info-container.scss b/app/styles/components/nested-info-container.scss new file mode 100644 index 00000000..a6d831d9 --- /dev/null +++ b/app/styles/components/nested-info-container.scss @@ -0,0 +1,21 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +.information-container { + color: $heading-text-color; + + .information-wrapper { + position: relative; + padding: 5px $information-wrapper-padding-right 15px $information-wrapper-padding-left; + background-color: $background-grey; + border-radius: 4px; + .information-text h4 { + margin-bottom: 15px; + font-size: $font-size-regular; + font-weight: $font-weight-strong; + text-transform: uppercase; + } + } +} diff --git a/app/styles/components/output-data-input.scss b/app/styles/components/output-data-input.scss index 48db8d27..fad0c11f 100644 --- a/app/styles/components/output-data-input.scss +++ b/app/styles/components/output-data-input.scss @@ -54,7 +54,7 @@ } &.metrics-container .metrics-selection { - @import "power-select-inputs"; + @import "power-select-query-field"; } } diff --git a/app/styles/components/power-select-inputs.scss b/app/styles/components/power-select-field.scss similarity index 63% rename from app/styles/components/power-select-inputs.scss rename to app/styles/components/power-select-field.scss index 307caff4..8335f5d1 100644 --- a/app/styles/components/power-select-inputs.scss +++ b/app/styles/components/power-select-field.scss @@ -3,28 +3,20 @@ * Licensed under the terms of the Apache License, Version 2.0. * See the LICENSE file associated with the project for terms. */ -.power-select-label { - @extend %query-input-label; - left: $padding-base-horizontal + 14px; -} .ember-power-select-trigger { &:focus { box-shadow: none; } // Make the selects as tall as the inputs - height: $query-input-height; + height: $input-height; overflow-y: hidden; border-radius: $input-radius-regular; - border-color: transparent; - .ember-power-select-selected-item { + .ember-power-select-selected-item, .ember-power-select-placeholder { display: inline-block; font-family: $font-family-regular; font-weight: $font-weight-regular; font-size: $font-size-regular; - // margin-top: $font-size-small; - padding-top: $font-size-small - 2px; - padding-left: 2px; color: $text-color-regular; } } diff --git a/app/styles/components/power-select-query-field.scss b/app/styles/components/power-select-query-field.scss new file mode 100644 index 00000000..514d0837 --- /dev/null +++ b/app/styles/components/power-select-query-field.scss @@ -0,0 +1,17 @@ +/* + * Copyright 2016, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +.power-select-label { + @extend %query-input-label; + left: $padding-base-horizontal + 14px; +} +$input-height: $query-input-height; + +@import "power-select-field"; +.ember-power-select-trigger { + border-color: transparent; + padding-top: $font-size-small - 2px; + padding-left: 2px; +} diff --git a/app/styles/components/query-blurb.scss b/app/styles/components/query-blurb.scss index 3ba54bc0..ccd85ae0 100644 --- a/app/styles/components/query-blurb.scss +++ b/app/styles/components/query-blurb.scss @@ -3,9 +3,8 @@ * Licensed under the terms of the Apache License, Version 2.0. * See the LICENSE file associated with the project for terms. */ -.filter-summary-text, .fields-summary-text { +.filter-summary-text, .fields-summary-text, .window-summary-text { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } - diff --git a/app/styles/components/query-input.scss b/app/styles/components/query-input.scss index 534fb792..d8b70f1d 100644 --- a/app/styles/components/query-input.scss +++ b/app/styles/components/query-input.scss @@ -43,7 +43,6 @@ font-size: $font-size-regular-large; } - %query-input-label { position: absolute; font-size: $font-size-small; @@ -106,7 +105,6 @@ %validated-input-row { @import "validated-input"; margin-top: 10px; - margin-left: 5px; input { height: 45px; } @@ -222,7 +220,6 @@ .aggregation-size { padding-right: 0; width: 165px; - margin-left: -15px; margin-right: 0; } .query-duration { @@ -244,8 +241,6 @@ position: fixed; bottom: 0; - @import "timed-progress-bar"; - hr { margin-left: (-$query-panel-side-padding); margin-right: (-$grid-gutter-width/2) + (-$query-panel-side-padding); @@ -263,15 +258,6 @@ font-size: $font-size-regular; } - .cancel-button { - @extend %secondary-button; - border-width: 2px; - width: 140px; - height: 40px; - font-size: $font-size-regular; - font-weight: $font-weight-medium; - } - .save-button { @extend %secondary-button; border-width: 2px; @@ -287,7 +273,7 @@ // Running state page styling &.query-running { - .btn:not(.cancel-button) { + .btn { background-color: $inactive-button-color !important; border-color: $inactive-button-color !important; } diff --git a/app/styles/components/no-results-help.scss b/app/styles/components/query-killed-help.scss similarity index 97% rename from app/styles/components/no-results-help.scss rename to app/styles/components/query-killed-help.scss index 1673d88b..a2130e54 100644 --- a/app/styles/components/no-results-help.scss +++ b/app/styles/components/query-killed-help.scss @@ -3,10 +3,10 @@ * Licensed under the terms of the Apache License, Version 2.0. * See the LICENSE file associated with the project for terms. */ -.bummer { +.killed { margin-top: 100px; - .bummer-image > img { + .killed-image > img { width: 100px; height: 100px; } @@ -65,4 +65,4 @@ } } } -} \ No newline at end of file +} diff --git a/app/styles/components/query-step-help.scss b/app/styles/components/query-step-help.scss new file mode 100644 index 00000000..ae48ac17 --- /dev/null +++ b/app/styles/components/query-step-help.scss @@ -0,0 +1,89 @@ +/* + * Copyright 2016, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +.row { + margin-top: 100px; + + .image > img { + width: 100px; + height: 100px; + } + + .image.animate > img { + animation: rotate 2s linear infinite; + } + + @keyframes rotate { + from { + transform: rotate(0deg) + } + to { + transform: rotate(359deg) + } + } + + h2 { + margin-top: 25px; + font-family: $font-family-regular; + font-size: $font-size-ultra-large; + font-weight: $font-weight-strong; + color: $heading-text-color; + } + h3 { + margin-top: 25px; + font-family: $font-family-regular; + font-size: $font-size-large; + font-weight: $font-weight-strong; + color: $heading-text-color; + } + h4 { + font-family: $font-family-regular; + font-size: $font-size-regular-large; + font-weight: $font-weight-regular; + color: $header-help-color; + } + .steps { + text-align: left; + margin: auto; + margin-top: 30px; + width: 600px; + + h4 { + font-family: $font-family-regular; + font-size: $font-size-regular; + font-weight: $font-weight-strong; + color: $heading-text-color; + } + p { + font-family: $font-family-regular; + font-size: $font-size-regular; + font-weight: $font-weight-regular; + color: $header-help-color; + } + + ol { + list-style-type: none; + margin: 0; + counter-reset: li-counter; + li { + position: relative; + &:before { + position: absolute; + width: 23px; + height: 23px; + left: -35px; + + content: counter(li-counter); + counter-increment: li-counter; + text-align: center; + border-radius: 30px; + background-color: $table-body-blue-text-color; + color: white; + padding: 1.5px 0 0 0.5px; + } + } + } + } +} diff --git a/app/styles/components/records-charter.scss b/app/styles/components/records-charter.scss index b61cce0d..26fbf7fe 100644 --- a/app/styles/components/records-charter.scss +++ b/app/styles/components/records-charter.scss @@ -13,6 +13,13 @@ user-select: none; @import 'mode-toggle'; + .mode-toggle { + justify-content: flex-end; + margin: 10px 0; + .mode { + width: 70px; + } + } .visual-container { @import 'pivot-table'; diff --git a/app/styles/components/records-raw-viewer.scss b/app/styles/components/records-raw-viewer.scss index f143df11..e4c7804f 100644 --- a/app/styles/components/records-raw-viewer.scss +++ b/app/styles/components/records-raw-viewer.scss @@ -7,6 +7,14 @@ @import 'mode-toggle'; @import 'pretty-json'; + .mode-toggle { + justify-content: flex-end; + margin: 10px 0; + .mode { + width: 70px; + } + } + .raw-display { pre { background-color: $white; diff --git a/app/styles/components/records-viewer.scss b/app/styles/components/records-viewer.scss index dbeee3c6..a9d5750f 100644 --- a/app/styles/components/records-viewer.scss +++ b/app/styles/components/records-viewer.scss @@ -5,12 +5,9 @@ */ .records-viewer { $records-header-color: #00C877; - margin-right: $results-right-space; .records-title { - @import "section-heading-with-help"; - - margin-top: 35px; + margin-top: 15px; margin-bottom: 15px; padding-left: 0; @@ -19,6 +16,7 @@ align-items: center; .records-header { + margin-left: 110px; color: $records-header-color; font-size: $font-size-regular; font-weight: $font-weight-strong; diff --git a/app/styles/components/result-metadata.scss b/app/styles/components/result-metadata.scss deleted file mode 100644 index 0cdacb11..00000000 --- a/app/styles/components/result-metadata.scss +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the terms of the Apache License, Version 2.0. - * See the LICENSE file associated with the project for terms. - */ - .result-metadata { - @import "pretty-json"; - - margin-top: 5px; - - .expand-bar { - height: 25px; - margin-top: 10px; - display: flex; - align-items: center; - justify-content: center; - - &:hover { - cursor: pointer; - background-color: $background-grey-darker; - } - - i { - color: $primary-button-color; - } - } - } diff --git a/app/styles/components/result-query-information.scss b/app/styles/components/result-query-information.scss new file mode 100644 index 00000000..15f5679f --- /dev/null +++ b/app/styles/components/result-query-information.scss @@ -0,0 +1,54 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +.query-title { + margin-bottom: 25px; + color: $heading-text-color; + font-size: $font-size-large; + font-weight: $font-weight-strong; +} + +.query-blurb-wrapper { + $button-width: 130px; + $button-height: 38px; + + @import "query-blurb"; + + cursor: pointer; + + &:hover { + background-color: $background-grey-darker; + } + + .query-blurb > .filter-summary-text, .query-blurb > .projections-summary-text { + display: block; + } + + .query-text { + margin-right: $results-right-space + $button-width; + } + + .query-button { + float: right; + display: inline-block; + position: absolute; + right: $results-right-space; + margin-top: (-$button-height)/2; + width: $button-width; + height: $button-height; + font-size: $font-size-regular; + font-weight: $font-weight-medium; + } + + .link-button { + @extend %primary-button; + top: 25%; + } + + .rerun-button,.cancel-button { + @extend %secondary-button; + top: 75%; + } +} diff --git a/app/styles/components/result-viewer.scss b/app/styles/components/result-viewer.scss new file mode 100644 index 00000000..0f8cf3fd --- /dev/null +++ b/app/styles/components/result-viewer.scss @@ -0,0 +1,30 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +.window-information { + @import "window-metadata"; + margin-bottom: 20px;; +} + +.control-container { + display: flex; + justify-content: space-between; + align-items: center; + align-content: center; + margin-bottom: 20px; + min-height: 10px; + + @import "control-container"; + +} + +.records-container { + @import "records-viewer"; + @import "query-step-help"; + margin-bottom: 20px; + .help-text { + margin-right: $results-right-space; + } +} diff --git a/app/styles/components/running-query.scss b/app/styles/components/running-query.scss new file mode 100644 index 00000000..63199809 --- /dev/null +++ b/app/styles/components/running-query.scss @@ -0,0 +1,26 @@ +/* + * Copyright 2016, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +.running { + margin-top: 300px; + + .image > img { + width: 50px; + height: 50px; + } + h2 { + margin-top: 25px; + font-family: $font-family-regular; + font-size: $font-size-ultra-large; + font-weight: $font-weight-strong; + color: $heading-text-color; + } + h3 { + font-family: $font-family-regular; + font-size: $font-size-regular-large + 1; + font-weight: $font-weight-regular; + color: $header-help-color; + } +} diff --git a/app/styles/components/section-heading-with-help.scss b/app/styles/components/section-heading-with-help.scss index e6311d86..98371de0 100644 --- a/app/styles/components/section-heading-with-help.scss +++ b/app/styles/components/section-heading-with-help.scss @@ -16,4 +16,3 @@ } @import "info-popover"; } - diff --git a/app/styles/components/timed-progress-bar.scss b/app/styles/components/timed-progress-bar.scss index 63706d84..e69de29b 100644 --- a/app/styles/components/timed-progress-bar.scss +++ b/app/styles/components/timed-progress-bar.scss @@ -1,22 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the terms of the Apache License, Version 2.0. - * See the LICENSE file associated with the project for terms. - */ -.progress { - $timed-progress-bar-color: $success-green; - $timed-progress-bar-height: 38px; - width: 90%; - - .progress-bar { - - border-radius: 4px; - font-size: $font-size-large; - - background-color: $timed-progress-bar-color; - // To vertically center - line-height: $timed-progress-bar-height; - } - border: 1px solid $timed-progress-bar-color; - height: $timed-progress-bar-height; -} diff --git a/app/styles/components/validated-input.scss b/app/styles/components/validated-input.scss index 56efb5bc..e7fd30d3 100644 --- a/app/styles/components/validated-input.scss +++ b/app/styles/components/validated-input.scss @@ -6,6 +6,7 @@ .validated-input { .error-tooltip-link { @extend %error-icon; + margin-left: 15px; } &.has-error > .labeled-input { margin-left: -15px; diff --git a/app/styles/components/window-input.scss b/app/styles/components/window-input.scss index e9ee4381..37b32f11 100644 --- a/app/styles/components/window-input.scss +++ b/app/styles/components/window-input.scss @@ -1,43 +1,45 @@ .window-input { - .add-button { - @extend %add-button; - margin-bottom: 10px; - i { - padding-right: 5px; - } - } - - .remove-button { - @extend %remove-button; - } - - @import "info-popover"; - padding: 15px; margin-top: 5px; margin-bottom: 15px; max-width: 800px; - background-color: $box-rectangle-color; - position: relative; - .ember-radio-button { - @extend %ember-radio-button; - display: inline-block; - margin-right: 30px; + .no-window-section { + .add-button { + @extend %add-button; + margin-top: 15px; + i { + padding-right: 5px; + } + } + margin-bottom: 30px; } - .subsection-header { - @extend %subsection-header; - } + .window-section { + display: flex; + flex-flow: column nowrap; + padding: 15px; + background-color: $box-rectangle-color; - .row { - @extend %validated-input-row; - .validated-input { - .labeled-input { - padding-right: 0; - width: 150px; - margin-left: -5px; - margin-right: 0; - } + .subsection-header { + @extend %subsection-header; } + + .delete-button { + @extend %remove-button; + min-height: 20px; + align-self: flex-end; + } + + .ember-radio-button { + @extend %ember-radio-button; + display: inline-block; + margin-right: 30px; + } + + .row { + @extend %validated-input-row; + } + } + } diff --git a/app/styles/components/window-metadata.scss b/app/styles/components/window-metadata.scss new file mode 100644 index 00000000..cc5f1d89 --- /dev/null +++ b/app/styles/components/window-metadata.scss @@ -0,0 +1,67 @@ +/* + * Copyright 2016, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ + .window-metadata { + @import "pretty-json"; + + background-color: $background-grey; + padding: 5px; + margin-top: 10px; + border-radius: 4px; + max-height: 50px; + overflow: hidden; + transition: max-height $default-transition-time ease-in-out; + + .information-text h4 { + margin-bottom: 15px; + font-size: $font-size-regular; + font-weight: $font-weight-strong; + text-transform: uppercase; + } + + .expand-bar { + display: flex; + align-items: center; + justify-content: center; + height: 25px; + i { + color: $inactive-grey; + } + } + + &:hover { + cursor: pointer; + background-color: $background-grey-darker; + i { + color: $black; + } + } + + &.is-expanded { + padding: 30px; + padding-bottom: 0; + max-height: 700px; + pre { + max-height: 600px; + } + &:hover { + cursor: default; + background-color: $background-grey; + i { + color: $inactive-grey; + } + } + .expand-bar { + &:hover { + cursor: pointer; + background-color: $background-grey-darker; + i { + color: $black; + } + } + margin: 0 -30px 0 -30px; + } + } + } diff --git a/app/styles/result.scss b/app/styles/result.scss index 4a6178ae..8d4573b7 100644 --- a/app/styles/result.scss +++ b/app/styles/result.scss @@ -3,106 +3,25 @@ * Licensed under the terms of the Apache License, Version 2.0. * See the LICENSE file associated with the project for terms. */ -$results-right-space: 30px; -$results-left-space: $left-bar-separation-width; -$results-inner-left-space: 20px; -$results-inner-right-space: 35px; -$information-wrapper-padding-left: 20px; -$information-wrapper-padding-right: 35px; +.results-container { + $results-right-space: 30px; + $results-left-space: $left-bar-separation-width; + $results-inner-left-space: 20px; + $results-inner-right-space: 35px; + $information-wrapper-padding-left: 20px; + $information-wrapper-padding-right: 35px; -.information-container { - margin: 22px $results-right-space 0 $results-left-space; - color: $heading-text-color; -} - -.query-information{ - .query-title { - margin-bottom: 25px; - color: $heading-text-color; - font-size: $font-size-large; - font-weight: $font-weight-strong; - } -} - -.information-container { - - .information-wrapper { - padding: 5px $information-wrapper-padding-right 15px $information-wrapper-padding-left; - background-color: $background-grey; - border-radius: 4px; - position: relative; - - .information-text { - h4 { - margin-bottom: 15px; - font-size: $font-size-regular; - font-weight: $font-weight-strong; - text-transform: uppercase; - } - } - - } -} -.query-information .query-blurb-wrapper { - @import "components/query-blurb"; - - $button-width: 130px; - $button-height: 38px; + @import "components/nested-info-container"; + @import "components/section-heading-with-help"; - cursor: pointer; - - &:hover { - background-color: $background-grey-darker; - } - - .query-blurb > .filter-summary-text, .query-blurb > .projections-summary-text { - display: block; - } - .query-text { - margin-right: $results-right-space + $button-width; - } - .query-link-button { - @extend %primary-button; - - float: right; - display: inline-block; - position: absolute; - top: 50%; - right: $results-right-space; - margin-top: (-$button-height)/2; - width: $button-width; - height: $button-height; - font-size: $font-size-regular; - font-weight: $font-weight-medium; - } -} - -.result-information { - margin-top: 10px; - margin-bottom: 30px; - .metadata-wrapper { - padding-bottom: 0; - .metadata-text { - @import "components/result-metadata"; - @import "components/section-heading-with-help"; + margin: 22px $results-right-space 0 $results-left-space; - .section-header .section-title { - margin-top: 10px; - } - .expand-bar { - margin-left: (-$information-wrapper-padding-left); - margin-right: (-$information-wrapper-padding-right); - } - } + .query-section { + @import "components/result-query-information"; } -} -.result-container { - @import "components/records-viewer"; - @import "components/no-results-help"; - margin-left: $left-bar-separation-width; - margin-bottom: 30px; - .help-text { - margin-right: $results-right-space; + .result-section { + margin-top: 20px; + @import "components/result-viewer"; } } diff --git a/app/templates/components/mode-toggle.hbs b/app/templates/components/mode-toggle.hbs index 26b1fd97..b0cad1d9 100644 --- a/app/templates/components/mode-toggle.hbs +++ b/app/templates/components/mode-toggle.hbs @@ -3,7 +3,6 @@ Licensed under the terms of the Apache License, Version 2.0. See the LICENSE file associated with the project for terms. --}} -
diff --git a/app/templates/components/query-information.hbs b/app/templates/components/query-information.hbs new file mode 100644 index 00000000..a2673414 --- /dev/null +++ b/app/templates/components/query-information.hbs @@ -0,0 +1,23 @@ +{{!-- + Copyright 2018, Yahoo Inc. + Licensed under the terms of the Apache License, Version 2.0. + See the LICENSE file associated with the project for terms. + --}} +
+
+

Query Definition

+ {{query-blurb query=query summary=querySnapshot}} +
+ + {{#if isRunningQuery}} + + {{else}} + + {{/if}} +
diff --git a/app/templates/components/query-input.hbs b/app/templates/components/query-input.hbs index 38d1a014..d91f7886 100644 --- a/app/templates/components/query-input.hbs +++ b/app/templates/components/query-input.hbs @@ -54,7 +54,7 @@
-

Window

+

Windowing

{{#info-popover title="Window"}} {{partial "partials/window-help"}} {{/info-popover}} @@ -82,7 +82,6 @@
-

diff --git a/app/templates/components/records-viewer.hbs b/app/templates/components/records-viewer.hbs index 08b95177..4697e046 100644 --- a/app/templates/components/records-viewer.hbs +++ b/app/templates/components/records-viewer.hbs @@ -6,14 +6,14 @@
-

Query Results

- {{#info-popover title="Query Results"}} - {{partial "partials/results-help"}} +

Records

+ {{#info-popover title="Visualization"}} + {{partial "partials/records-help"}} {{/info-popover}}
- {{records.length}} records returned from your query + {{records.length}} records in this view
@@ -55,8 +55,8 @@ {{#if showRawData}} {{records-raw-viewer data=records}} {{else if showTable}} - {{records-table columnNames=columns rawRows=records}} + {{records-table appendMode=aggregateMode columnNames=columns rawRows=records}} {{else if showChart}} - {{records-charter model=model columns=columns rows=records}} + {{records-charter config=config columns=columns rows=records model=model}} {{/if}}
diff --git a/app/templates/components/result-viewer.hbs b/app/templates/components/result-viewer.hbs new file mode 100644 index 00000000..faf8bd73 --- /dev/null +++ b/app/templates/components/result-viewer.hbs @@ -0,0 +1,64 @@ +{{!-- + Copyright 2018, Yahoo Inc. + Licensed under the terms of the Apache License, Version 2.0. + See the LICENSE file associated with the project for terms. +--}} +
+ +
+ Show Latest + {{mode-toggle isToggled=autoUpdate toggledText="On" notToggledText="Off" onToggled=(action "changeAutoUpdate")}} +
+ + {{#unless hasError}} + +
+ {{#if isTimeWindow}} +
+ {{timed-progress-bar shape='Line' active=isRunningQuery useStep=false duration=windowDuration + retriggerOnChangeIn=numberOfWindows}} +
+ {{/if}} + + {{#unless aggregateMode}} + {{#power-select selected=selectedWindow options=result.windows + placeholderComponent=(component "result-window-placeholder" windowCount=numberOfWindows) + searchField="sequence" searchPlaceholder="Search with your window number in the metadata..." + onchange=(action "changeWindow") as |window|}} + #{{window.sequence}}: + {{window.records.length}} records at + {{moment-format window.created 'dddd, hh:MM:SS a'}} + {{/power-select}} + {{/unless}} +
+ +
+ {{timed-progress-bar shape='Circle' active=isRunningQuery duration=queryDuration}} +
+ + {{/unless}} +
+ +{{#if hasData}} +
+ +

Results & Metadata

+ {{#info-popover title="Results"}} + {{partial "partials/results-help"}} + {{/info-popover}} +
+ {{window-metadata metadata=metadata}} +
+{{/if}} + +
+ {{#if hasError}} + {{partial "partials/query-killed"}} + {{else if hasData}} + {{records-viewer records=records metadata=metadata config=config aggregateMode=aggregateMode model=result}} + {{else if isRunningQuery}} + {{partial "partials/running-query"}} + {{else}} + {{partial "partials/no-data-help"}} + {{/if}} +
diff --git a/app/templates/components/result-window-placeholder.hbs b/app/templates/components/result-window-placeholder.hbs new file mode 100644 index 00000000..b0b58957 --- /dev/null +++ b/app/templates/components/result-window-placeholder.hbs @@ -0,0 +1,6 @@ +{{!-- + Copyright 2018, Yahoo Inc. + Licensed under the terms of the Apache License, Version 2.0. + See the LICENSE file associated with the project for terms. + --}} +Switch between {{windowCount}} windows... diff --git a/app/templates/components/timed-progress-bar.hbs b/app/templates/components/timed-progress-bar.hbs index 2bde6ea6..3bbb6a53 100644 --- a/app/templates/components/timed-progress-bar.hbs +++ b/app/templates/components/timed-progress-bar.hbs @@ -3,9 +3,10 @@ Licensed under the terms of the Apache License, Version 2.0. See the LICENSE file associated with the project for terms. --}} -
-
- {{valueNow}} - {{yield}} -
+
+ {{#if showDone}} + {{ember-progress-bar shape=shape useDefaultStep=false setProgress=progress options=options}} + {{else}} + {{ember-progress-bar shape=shape useDefaultStep=false progress=progress options=options}} + {{/if}}
diff --git a/app/templates/components/window-input.hbs b/app/templates/components/window-input.hbs index bb6e9a4f..c7589d45 100644 --- a/app/templates/components/window-input.hbs +++ b/app/templates/components/window-input.hbs @@ -4,42 +4,51 @@ See the LICENSE file associated with the project for terms. --}} {{#if isWindowless}} -
No Window
- -{{else}} -
-
+{{else}} +
+
+ Select how your window emits + +
-
Select Your Emit Type
-
- {{#radio-button radioId="time-based" value=EMIT_TYPES.TIME groupValue=emitType changed=(action "changeEmitType" EMIT_TYPES.TIME) disabled=disabled}} - {{EMIT_TYPES.TIME}} - {{/radio-button}} - {{#radio-button radioId="record-based" value=EMIT_TYPES.RECORD groupValue=emitType changed=(action "changeEmitType" EMIT_TYPES.RECORD) disabled=recordBasedWindowDisabled}} - {{EMIT_TYPES.RECORD}} - {{/radio-button}} -
- -
Emit Frequency
-
- {{validated-input inputClassNames="col-xs-3" fieldName=everyFieldName model=query.window valuePath="emit.every" type="number" placeholder="Number" disabled=everyDisabled}} -
- - {{#if isTimeBasedWindow}} -
Select your Include Type
-
- {{#radio-button radioId="include-window" value=INCLUDE_TYPES.WINDOW groupValue=includeType changed=(action "changeIncludeType" INCLUDE_TYPES.WINDOW) disabled=includeDisabled}} - {{INCLUDE_TYPES.WINDOW}} +
+ {{#radio-button radioId="time-based" value=EMIT_TYPES.TIME groupValue=emitType changed=(action "changeEmitType" EMIT_TYPES.TIME) disabled=disabled}} + {{EMIT_TYPES.TIME}} {{/radio-button}} - {{#radio-button radioId="include-all" value=INCLUDE_TYPES.ALL groupValue=includeType changed=(action "changeIncludeType" INCLUDE_TYPES.ALL) disabled=allIncludeTypeDisabled}} - {{INCLUDE_TYPES.ALL}} + {{#radio-button radioId="record-based" value=EMIT_TYPES.RECORD groupValue=emitType changed=(action "changeEmitType" EMIT_TYPES.RECORD) disabled=recordBasedWindowDisabled}} + {{EMIT_TYPES.RECORD}} {{/radio-button}}
- {{/if}} + +
Emit Every
+
+ {{validated-input inputClassNames="col-xs-3" fieldName=everyFieldName model=query.window valuePath="emit.every" type="number" placeholder="Number" disabled=everyDisabled}} +
+ + {{#if isTimeBasedWindow}} +
Select what is included in each window
+
+ {{#radio-button radioId="include-window" value=INCLUDE_TYPES.WINDOW groupValue=includeType changed=(action "changeIncludeType" INCLUDE_TYPES.WINDOW) disabled=includeDisabled}} + {{INCLUDE_TYPES.WINDOW}} + {{/radio-button}} + {{#radio-button radioId="include-all" value=INCLUDE_TYPES.ALL groupValue=includeType changed=(action "changeIncludeType" INCLUDE_TYPES.ALL) disabled=allIncludeTypeDisabled}} + {{INCLUDE_TYPES.ALL}} + {{/radio-button}} +
+ {{#if isRawAggregation}} +
+ {{validated-input inputClassNames="col-xs-3 window-size" model=query.aggregation valuePath="size" fieldName="Maximum Rows in Window" type="number" placeholder="Number" disabled=disabled}} +
+ {{/if}} + {{/if}} + +
{{/if}} diff --git a/app/templates/components/result-metadata.hbs b/app/templates/components/window-metadata.hbs similarity index 79% rename from app/templates/components/result-metadata.hbs rename to app/templates/components/window-metadata.hbs index 76133545..b48231db 100644 --- a/app/templates/components/result-metadata.hbs +++ b/app/templates/components/window-metadata.hbs @@ -3,10 +3,9 @@ Licensed under the terms of the Apache License, Version 2.0. See the LICENSE file associated with the project for terms. --}} - {{#if expanded}} {{pretty-json data=metadata defaultLevels=4}} {{/if}} -
+
diff --git a/app/templates/partials/-no-results-help.hbs b/app/templates/partials/-no-data-help.hbs similarity index 88% rename from app/templates/partials/-no-results-help.hbs rename to app/templates/partials/-no-data-help.hbs index c07ecd05..ece81204 100644 --- a/app/templates/partials/-no-results-help.hbs +++ b/app/templates/partials/-no-data-help.hbs @@ -5,10 +5,10 @@ --}}
-
+
-

Bummer! No records matched your filters.

+

Bummer! No records matched your filters for this window.

Bullet eavesdrops on newly generated records occurring after you submit your query.

Give this a try:

diff --git a/app/templates/partials/-query-killed.hbs b/app/templates/partials/-query-killed.hbs new file mode 100644 index 00000000..d7d6df60 --- /dev/null +++ b/app/templates/partials/-query-killed.hbs @@ -0,0 +1,25 @@ +{{!-- + Copyright 2018, Yahoo Inc. + Licensed under the terms of the Apache License, Version 2.0. + See the LICENSE file associated with the project for terms. + --}} +
+
+
+ +
+

You exceeded the rate limit for your query!

+

Your query returned too much data too fast and was killed. Find more information in the metadata above.

+
+

Try these:

+
    +
  1. +

    Add more filters to your query

    +
  2. +
  3. +

    Add a Time Based window to your query instead

    +
  4. +
+
+
+
diff --git a/app/templates/partials/-records-help.hbs b/app/templates/partials/-records-help.hbs new file mode 100644 index 00000000..30674b80 --- /dev/null +++ b/app/templates/partials/-records-help.hbs @@ -0,0 +1,49 @@ +{{!-- + Copyright 2016, Yahoo Inc. + Licensed under the terms of the Apache License, Version 2.0. + See the LICENSE file associated with the project for terms. + --}} +

+ The results can be displayed either as raw JSON format, formatted in a tabular view, displayed as a chart or pivoted on. Toggle between the views by clicking Show Raw Data or Show as Table or Show as Chart icons. +

+ +
Saving and Sharing
+

The output can be saved locally as JSON, CSV, or a flattened CSV (where any map fields and lists are exploded till the base elements)

+

It is not possible to natively share query or result URLs from Bullet since all queries and results are stored on the local browser only.

+ +
Raw JSON View
+

If you selected the Show All Fields option under the Raw Data selection on the Query page, or clicked on Show Raw Data view toggle at the top right of the page, the data is displayed in a raw JSON format.

+ +
Table View
+

If you elected to aggregate data or chose to Select Fields under the Raw Data selection on the query page, or clicked on the Show as Table button, the data is displayed in a table format.

+
    +
  • If a table cell is clicked on, a popup window appears with a JSON representation of the cell data. This is particularly useful for long strings, maps, and other complex data types
  • +
  • Columns width can be adjusted by dragging the vertical column divider at the top of the table
  • +
  • Columns can be rearranged by dragging and dropping them in the desired location
  • +
  • Columns can be also be sorted by clicking on their headers
  • +
+ +
Chart View
+

+ Unless you performed a Group All query (a Group query with no Group Fields) or a Count Distinct query, you will see a Show as Chart option. This option graphs your data as a chart with a + guess as to what your independent and dependent fields are. There are two sub-options that appear in this mode. You can see the pre-determined chart as described before by default or + by clicking the Chart sub-option. You can also click the Pivot option to to pivot and aggregate your result data in various forms by using various aggregations and dragging/dropping + various fields to slice and dice. +

+ +
Simple Chart
+

+ For all queries besides Raw Data queries with Select all Fields, Bullet will try to find your independent fields by finding the string fields in your result and your dependent fields by using the numeric fields in this view. + If there are multiple independent fields, Bullet will separate them using a / in the chart. The chart is a Bar chart. +

+ +

If you performed a Raw data query and chose to Show all Data, the Chart sub-option is disabled. You are only able to pivot on the data.

+ +
Pivot
+

+ If you find that the Chart option is not sufficient and you want aggregate your data some more, you can click the Pivot option. This will launch a UI where you can choose various + ways to display the data (Table by default) and choose various aggregation operations (Count by default). You can also drag and drop fields from a unused region to either a horizontal + or a vertical region to pivot on the data as you would in most spreadsheet applications. +

+ +

You can also click the dropdown next to each field to include or exclude various values for the visualization.

diff --git a/app/templates/partials/-result-metadata-help.hbs b/app/templates/partials/-result-metadata-help.hbs deleted file mode 100644 index 4d7941fa..00000000 --- a/app/templates/partials/-result-metadata-help.hbs +++ /dev/null @@ -1,66 +0,0 @@ -{{!-- - Copyright 2016, Yahoo Inc. - Licensed under the terms of the Apache License, Version 2.0. - See the LICENSE file associated with the project for terms. - --}} -

- You can see metadata for your results by clicking the expand caret at the bottom of this block. The fields shown here are configured by the maintainer of Bullet. Not all the fields shown here are described below and some are debugging information for the maintainer. Bullet uses Sketches to approximate results if it is computationally intractable to get the result exactly. For a primer on Sketches, please visit the Sketches home page. -

- -
Query Creation Time
-

- {{settings.defaultValues.metadataKeyMapping.queryCreationTime}} is the start time of the query. This denotes the time Bullet received the query. This is useful to help you determine whether the data you were looking for had or had not reached Bullet yet. -

- -
Query Termination Time
-

- {{settings.defaultValues.metadataKeyMapping.queryTerminationTime}} is the finish time of the query. This denotes the time Bullet terminated the query. This is useful to help you determine whether the data you were looking for had or had not reached Bullet yet. -

- -
Result Estimated
-

- You may notice a {{settings.defaultValues.metadataKeyMapping.estimatedResult}} entry which denotes whether the result was estimated or not. If the result was not estimated, the Standard Deviations section (see below) would show exact upper and lower bounds for the estimate. -

- -
Standard Deviations
-

- If you see a {{settings.defaultValues.metadataKeyMapping.standardDeviations}} entry in the metadata, there are 3 different entries at "1", "2" and "3", each having an upper and a lower bound. These are the ranges of the true value of an approximated result at 1, 2 and 3 Standard Deviations of Confidence. -

    -
  • 1 Standard Deviation is 68.27% confidence
  • -
  • 2 Standard Deviation is 95.45% confidence
  • -
  • 3 Standard Deviation is 99.73% confidence
  • -
-

-

- In other words, if you were performing a Count Distinct and the result was estimated, then these Standard Deviations help you to bound the true value with these levels of confidence. For example, if your Count Distinct result was 34326.58, and the Upper and Lower Bound at "1" Standard Deviation was 34522.56, 34131.69 respectively. This means that we can be sure with 68% confidence that the true value of the count distinct was between 34131 and 34522. Similarly, for the other standard deviations. -

- -
Uniques Estimate
-

- The {{settings.defaultValues.metadataKeyMapping.uniquesEstimate}} key is present if you have Grouped Data and the number of unique groups exceeded the maximum number of rows returned ({{settings.defaultValues.sketches.groupByMaxEntries}} by default). If so, the value for the key denotes the approximate number of unique groups there were. You can use the Standard Deviations section as usual to estimate the true range of the estimate. You will have received a uniform sample of these unique groups (with the right values for your metrics) as the result. -

- -
Theta
-

- {{settings.defaultValues.metadataKeyMapping.theta}} denotes the theta value of the Sketch that was used underneath to perform a Count Distinct or a Group operation. This is particularly useful if you have Grouped Data and you have more groups than can be returned. If you have performed metrics like Sum or Count, you can add them up over the returned rows in the Result below and divide by this Sketch Theta. The resulting number is an estimate of the Sum or Count across all your unique values that could not be returned. -

- -
Minimum and Maximum
-

- {{settings.defaultValues.metadataKeyMapping.minimumValue}} and {{settings.defaultValues.metadataKeyMapping.minimumValue}} denote the minimum and maximum value seen for a Distribution operation. This is useful if you still wanted to see the minimum and maximum but were looking at a narrower region of the domain. -

- -
Items Seen
-

- {{settings.defaultValues.metadataKeyMapping.itemsSeen}} denotes the number of data records seen for your query (that matched the filters). This is provided only for Distribution and Top K operations. -

- -
Normalized Rank Error
-

- {{settings.defaultValues.metadataKeyMapping.normalizedRankError}} represents the Normalized Rank Error for the Distribution operation. This value is only applicated if the result was estimated. This error is independent of amount of data seen or the distribution of your data. The normalized rank is obtained by taking the values of the field in your distribution, sorting them and dividing the resulting ranks by the number of values. This creates a rank ranging from 0 to 1 for each item. The normalized rank error refers to how off the rank of an item could be in this domain. For example, if you were obtaining the median or the 0.5 quantile value for an item and the normalized rank error was 0.005, this means that the value provided as the result had a normalized rank somewhere between 0.495 and 0.505 with 99% confidence. In practice, the smaller the number, the more accurate your result is (if it was estimated). -

- -
Maximum Count Error
-

- {{settings.defaultValues.metadataKeyMapping.maximumCountError}} represents the length of the interval for a count estimate in a Top K result. This is only applicable if the result was estimated. The count estimate provided by Bullet is the upper bound of the range. The true count lies somewhere between the upper bound and upper bound - the maximum count error. Note that if two items have counts that are closer than the maximum count error, it is possible that the one with the higher count may have a true count that is smaller than the true count of the lower one. Conversely, if two items are separated by more than this error, then you can be certain that the rank of the one with the higher count is indeed higher than the one with the lower count. -

diff --git a/app/templates/partials/-results-help.hbs b/app/templates/partials/-results-help.hbs index f252304c..335af04f 100644 --- a/app/templates/partials/-results-help.hbs +++ b/app/templates/partials/-results-help.hbs @@ -1,49 +1,138 @@ {{!-- - Copyright 2016, Yahoo Inc. + Copyright 2018, Yahoo Inc. Licensed under the terms of the Apache License, Version 2.0. See the LICENSE file associated with the project for terms. --}} +

Controlling your Results

+

- Once Bullet has finished running the query, all the matched records are returned to the browser. The results can be displayed either as raw JSON format, formatted in a tabular view, displayed as a chart or pivoted on. Toggle between the views by clicking Show Raw Data or Show as Table or Show as Chart icons. + The results page shows you the various rows returned by your running query and lets you navigate through your windows (if your + query configured the Window section). You can track of the progress of your query below and use the Cancel/Rerun buttons above + to stop or rerun the query. You can also click the query definition above or the Edit button to iterate on your query.

-
Saving and Sharing
-

The output can be saved locally as JSON, CSV, or a flattened CSV (where any map fields and lists are exploded till the base elements)

-

It is not possible to natively share query or result URLs from Bullet since all queries and results are stored on the local browser only.

+
Navigating through Windows
+If your query included a Window, the UI supports a few different ways to navigate through them. + +
Window Navigation Dropdown
+

+ If your query had a window and it was not aggregated across windows, you can navigate to a particular window by using the dropdown. If the maintainer configured the {{settings.defaultValues.metadataKeyMapping.windowNumber}} metadata entry, the windows can be searched using their expected sequence. The dropdown + also shows the total number of windows that have been currently received. +

+ +
Showing the Last Window
+

If you navigate to a particular window or turn off automatically moving to the latest window, you can investigate the results in a particular window by using the window navigation dropdown.

+ +
Aggregate across Windows
+

If you are running a RAW query with a Record Based Window, the resulting windows are automatically combined and shown together.

+ +
Timing indicators
+

The overall query progress (defined by your Maximum Duration) is shown on the right.

+

If you are running a Time Window based query, the approximate time to your next window is shown as a bar above the window selection dropdown.

-
Raw JSON View
-

If you selected the Show All Fields option under the Raw Data selection on the Query page, or clicked on Show Raw Data view toggle at the top right of the page, the data is displayed in a raw JSON format.

+
Rate Limiting Errors
+

+ It is possible to write a query (with a Record Based window for instance), that retrieves data from Bullet too fast. If this happens, your query will be killed and an error message will be shown + to you. You can edit your query to add more filters or switch to a different kind of window to prevent this from happening. The rate that cuts off a query is defined by your maintainer. +

+ +

Result Metadata

+ +

+ You can see metadata for your results by clicking the expand caret at the bottom of this block. The fields shown here are configured by the maintainer of Bullet. Not all the fields shown here are described below and some are debugging information for the maintainer. Also, the maintainer may have turned off metadata entirely. Bullet uses Sketches to approximate results if it is computationally intractable to get the result exactly. For a primer on Sketches, please visit the Sketches home page. +

-
Table View
-

If you elected to aggregate data or chose to Select Fields under the Raw Data selection on the query page, or clicked on the Show as Table button, the data is displayed in a table format.

+

There are three main sections to the metadata

    -
  • If a table cell is clicked on, a popup window appears with a JSON representation of the cell data. This is particularly useful for long strings, maps, and other complex data types
  • -
  • Columns width can be adjusted by dragging the vertical column divider at the top of the table
  • -
  • Columns can be rearranged by dragging and dropping them in the desired location
  • -
  • Columns can be also be sorted by clicking on their headers
  • +
  • {{settings.defaultValues.metadataKeyMapping.querySection}}
  • +
  • {{settings.defaultValues.metadataKeyMapping.sketchSection}}
  • +
  • {{settings.defaultValues.metadataKeyMapping.windowSection}}
-
Chart View
+
{{settings.defaultValues.metadataKeyMapping.querySection}}
+

+ This section lists metadata about the query such as its receive time, finish time (only on the final window), the query ID and the raw JSON version of the Bullet query. +

+ +
Query Creation Time
+

+ {{settings.defaultValues.metadataKeyMapping.queryCreationTime}} is the start time of the query. This denotes the time Bullet received the query. This is useful to help you determine whether the data you were looking for had or had not reached Bullet yet. +

+ +
Query Termination Time
+

+ {{settings.defaultValues.metadataKeyMapping.queryTerminationTime}} is the finish time of the query. This denotes the time Bullet terminated the query. This is useful to help you determine whether the data you were looking for had or had not reached Bullet yet. +

+ +
{{settings.defaultValues.metadataKeyMapping.sketchSection}}
+

+ This section lists metadata about the Sketch used in the query, if any. +

+ +
Result Estimated
+

+ You may notice a {{settings.defaultValues.metadataKeyMapping.estimatedResult}} entry which denotes whether the result was estimated or not. If the result was not estimated, the Standard Deviations section (see below) would show exact upper and lower bounds for the estimate. +

+ +
Standard Deviations
+

+ If you see a {{settings.defaultValues.metadataKeyMapping.standardDeviations}} entry in the metadata, there are 3 different entries at "1", "2" and "3", each having an upper and a lower bound. These are the ranges of the true value of an approximated result at 1, 2 and 3 Standard Deviations of Confidence. +

    +
  • 1 Standard Deviation is 68.27% confidence
  • +
  • 2 Standard Deviation is 95.45% confidence
  • +
  • 3 Standard Deviation is 99.73% confidence
  • +
+

+

+ In other words, if you were performing a Count Distinct and the result was estimated, then these Standard Deviations help you to bound the true value with these levels of confidence. For example, if your Count Distinct result was 34326.58, and the Upper and Lower Bound at "1" Standard Deviation was 34522.56, 34131.69 respectively. This means that we can be sure with 68% confidence that the true value of the count distinct was between 34131 and 34522. Similarly, for the other standard deviations. +

+ +
Uniques Estimate
+

+ The {{settings.defaultValues.metadataKeyMapping.uniquesEstimate}} key is present if you have Grouped Data and the number of unique groups exceeded the maximum number of rows returned ({{settings.defaultValues.sketches.groupByMaxEntries}} by default). If so, the value for the key denotes the approximate number of unique groups there were. You can use the Standard Deviations section as usual to estimate the true range of the estimate. You will have received a uniform sample of these unique groups (with the right values for your metrics) as the result. +

+ +
Theta
+

+ {{settings.defaultValues.metadataKeyMapping.theta}} denotes the theta value of the Sketch that was used underneath to perform a Count Distinct or a Group operation. This is particularly useful if you have Grouped Data and you have more groups than can be returned. If you have performed metrics like Sum or Count, you can add them up over the returned rows in the Result below and divide by this Sketch Theta. The resulting number is an estimate of the Sum or Count across all your unique values that could not be returned. +

+ +
Minimum and Maximum

- Unless you performed a Group All query (a Group query with no Group Fields) or a Count Distinct query, you will see a Show as Chart option. This option graphs your data as a chart with a - guess as to what your independent and dependent fields are. There are two sub-options that appear in this mode. You can see the pre-determined chart as described before by default or - by clicking the Chart sub-option. You can also click the Pivot option to to pivot and aggregate your result data in various forms by using various aggregations and dragging/dropping - various fields to slice and dice. + {{settings.defaultValues.metadataKeyMapping.minimumValue}} and {{settings.defaultValues.metadataKeyMapping.minimumValue}} denote the minimum and maximum value seen for a Distribution operation. This is useful if you still wanted to see the minimum and maximum but were looking at a narrower region of the domain.

-
Simple Chart
+
Items Seen

- For all queries besides Raw Data queries with Select all Fields, Bullet will try to find your independent fields by finding the string fields in your result and your dependent fields by using the numeric fields in this view. - If there are multiple independent fields, Bullet will separate them using a / in the chart. The chart is a Bar chart. + {{settings.defaultValues.metadataKeyMapping.itemsSeen}} denotes the number of data records seen for your query (that matched the filters). This is provided only for Distribution and Top K operations.

-

If you performed a Raw data query and chose to Show all Data, the Chart sub-option is disabled. You are only able to pivot on the data.

+
Normalized Rank Error
+

+ {{settings.defaultValues.metadataKeyMapping.normalizedRankError}} represents the Normalized Rank Error for the Distribution operation. This value is only applicated if the result was estimated. This error is independent of amount of data seen or the distribution of your data. The normalized rank is obtained by taking the values of the field in your distribution, sorting them and dividing the resulting ranks by the number of values. This creates a rank ranging from 0 to 1 for each item. The normalized rank error refers to how off the rank of an item could be in this domain. For example, if you were obtaining the median or the 0.5 quantile value for an item and the normalized rank error was 0.005, this means that the value provided as the result had a normalized rank somewhere between 0.495 and 0.505 with 99% confidence. In practice, the smaller the number, the more accurate your result is (if it was estimated). +

-
Pivot
+
Maximum Count Error

- If you find that the Chart option is not sufficient and you want aggregate your data some more, you can click the Pivot option. This will launch a UI where you can choose various - ways to display the data (Table by default) and choose various aggregation operations (Count by default). You can also drag and drop fields from a unused region to either a horizontal - or a vertical region to pivot on the data as you would in most spreadsheet applications. + {{settings.defaultValues.metadataKeyMapping.maximumCountError}} represents the length of the interval for a count estimate in a Top K result. This is only applicable if the result was estimated. The count estimate provided by Bullet is the upper bound of the range. The true count lies somewhere between the upper bound and upper bound - the maximum count error. Note that if two items have counts that are closer than the maximum count error, it is possible that the one with the higher count may have a true count that is smaller than the true count of the lower one. Conversely, if two items are separated by more than this error, then you can be certain that the rank of the one with the higher count is indeed higher than the one with the lower count.

-

You can also click the dropdown next to each field to include or exclude various values for the visualization.

+
{{settings.defaultValues.metadataKeyMapping.windowSection}}
+

+ If you specified a window for your query, this section lists the various window related metadata for your query, such as the number of the window etc. +

+ +
Window Number
+

+ The {{settings.defaultValues.metadataKeyMapping.windowNumber}} is the monotonically increasing sequence that denotes which window this particular set of records belong to. Windows start at 1 and increase by 1. +

+ +
Window Size
+

+ The {{settings.defaultValues.metadataKeyMapping.windowSize}} tells you how many records are in this window. +

+ +
Expected Emit Time
+

+ For Time Based windows, the {{settings.defaultValues.metadataKeyMapping.expectedEmitTime}} tells you when your window was expected to be emitted. You can compare it to the actual emit time of the window to help you debug why, if a window arrived later than expected. +

diff --git a/app/templates/partials/-running-query.hbs b/app/templates/partials/-running-query.hbs new file mode 100644 index 00000000..7f22711c --- /dev/null +++ b/app/templates/partials/-running-query.hbs @@ -0,0 +1,14 @@ +{{!-- + Copyright 2018, Yahoo Inc. + Licensed under the terms of the Apache License, Version 2.0. + See the LICENSE file associated with the project for terms. + --}} +
+
+
+ +
+

Finding data for your query...

+

Remember that queries operate on data generated after you submit your query.

+
+
diff --git a/app/templates/partials/-window-help.hbs b/app/templates/partials/-window-help.hbs index 31260a8e..a9e6d7b2 100644 --- a/app/templates/partials/-window-help.hbs +++ b/app/templates/partials/-window-help.hbs @@ -3,4 +3,60 @@ Licensed under the terms of the Apache License, Version 2.0. See the LICENSE file associated with the project for terms. --}} -

This section lets you configure your windowing. To be updated.

+

+ This section lets you configure your windowing. This lets you get multiple results while the query is running instead + of just one result at the end. +

+ +
Window Types
+ +

There are three main types of windows currently supported.

+
    +
  • +

    + Time Based lets you break up your query duration into chunks of time specified by your Frequency. + Your selected aggregation is applied for each window and the result is returned to you at the end. This corresponds + to a Tumbling window on time if you have done streaming windowed queries before. Note that, + Frequency has a configured minimum of {{settings.defaultValues.sketches.windowEmitFrequencyMinSecs}} + seconds and a maximum of your query duration. +

    +

    + If you are using this window with a Raw Data query, you can also also choose the maximum numbers of rows you want + per window. The allowed maximum is {{settings.defaultValues.rawMaxSize}}. This will return the + window as soon as this maximum is reached. +

    +

    For other aggregations, the data has to be collected till the window duration is reached because existing rows + may be updated with future data. The maximum rows per window is defaulted to + {{settings.defaultValues.aggregationMaxSize}}. +

    +
  • +
  • +

    + Record Based lets you get your results as they come in. You can now use Frequency to specify how + many records should be present in each window. This is currently only allowed if your aggregation is Raw Data and + you may only retrieve your records one at a time. These correspond to a class of + Sliding windows in streaming windowed queries parlance. +

    +
  • +
  • +

    + Everything from Start of Query windows or Additive windows let you perform your aggregation + since the start of the query. This is only allowed for Time Based windows whose aggregation is + not Raw Data. +

    +
  • +
+ +
Constraints
+

+ Windowing is an actively developed area in Bullet, so we have only enabled a few simple window types. Different styles + of windowing such as Hopping, Session etc. are planned. +

+ +
Rate Limiting
+

+ With Raw Data queries and Record Based windows, it is possible to return entirely too much data from the backend if + your query is not configured correctly (with no filters, for example). If this happens, your query will be + terminated as you exceed the rate limit. You can find out details in the Result Metadata section + when that happens. +

diff --git a/app/templates/result.hbs b/app/templates/result.hbs index 3872bd06..5ad07c8a 100644 --- a/app/templates/result.hbs +++ b/app/templates/result.hbs @@ -4,46 +4,18 @@ See the LICENSE file associated with the project for terms. --}}
-
-
-

- {{if model.query.name model.query.name "New Untitled Query"}} -

-
-
-
-

Query Definition

- {{query-blurb query=model.query summary=model.querySnapshot}} -
- +
+
+ {{query-information query=model.query querySnapshot=model.querySnapshot + reRunClick=(route-action "reRunClick" model.query) + cancelClick=(route-action "cancelClick") + queryClick=(route-action "queryClick" model.query)}}
-
- diff --git a/app/transforms/window-array.js b/app/transforms/window-array.js new file mode 100644 index 00000000..3923b04c --- /dev/null +++ b/app/transforms/window-array.js @@ -0,0 +1,19 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +import DS from 'ember-data'; +import { A } from '@ember/array'; + +export default DS.Transform.extend({ + deserialize(serialized) { + return A(serialized); + }, + + serialize(deserialized) { + let array = []; + deserialized.forEach(item => array.push(item)); + return array; + } +}); diff --git a/app/validators/window-emit-frequency.js b/app/validators/window-emit-frequency.js index 79ad0208..915591d1 100644 --- a/app/validators/window-emit-frequency.js +++ b/app/validators/window-emit-frequency.js @@ -17,7 +17,7 @@ const WindowEmitFrequency = BaseValidator.extend({ if (emitEvery > duration) { return `The window emit frequency should not be longer than the query duration (${duration} seconds)`; } else if (emitEvery < windowEmitFrequencyMinSecs) { - return `The maintainer has configured Bullet to support a minimum of ${windowEmitFrequencyMinSecs} for emit frequency`; + return `The maintainer has configured Bullet to support a minimum of ${windowEmitFrequencyMinSecs}s for emit frequency`; } } return true; diff --git a/config/.jshintrc b/config/.jshintrc deleted file mode 100644 index c1f2978b..00000000 --- a/config/.jshintrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "node": true -} diff --git a/config/env-settings.json b/config/env-settings.json index f3f906da..df49a081 100644 --- a/config/env-settings.json +++ b/config/env-settings.json @@ -2,7 +2,7 @@ "default": { "queryHost": "https://foo.bar.com:4443", "queryNamespace": "bullet/api", - "queryPath": "drpc", + "queryPath": "ws-query", "queryStompRequestChannel": "/server/request", "queryStompResponseChannel": "/client/response", "schemaHost": "https://foo.bar.com:4443", @@ -10,13 +10,13 @@ "helpLinks": [ { "name": "Tutorials", - "link": "https://yahoo.github.io/bullet-docs/ui/usage" + "link": "https://bullet-db.github.io/ui/usage" } ], - "bugLink": "https://github.com/yahoo/bullet-ui/issues", - "modelVersion": 2, + "bugLink": "https://github.com/bullet-db/bullet-ui/issues", + "modelVersion": 3, "migrations": { - "deletions": "result" + "deletions": "query" }, "defaultValues": { "aggregationMaxSize": 512, @@ -40,17 +40,24 @@ "topKErrorType": "No False Negatives" }, "metadataKeyMapping": { - "theta": "theta", - "uniquesEstimate": "uniques_estimate", - "queryCreationTime": "query_receive_time", - "queryTerminationTime": "query_finish_time", - "estimatedResult": "was_estimated", - "standardDeviations": "standard_deviations", - "normalizedRankError": "normalized_rank_error", - "maximumCountError": "maximum_count_error", - "itemsSeen": "items_seen", - "minimumValue": "minimum_value", - "maximumValue": "maximum_value" + "querySection": "Query", + "windowSection": "Window", + "sketchSection": "Sketch", + "theta": "Theta", + "uniquesEstimate": "Uniques Estimate", + "queryCreationTime": "Receive Time", + "queryTerminationTime": "Finish Time", + "estimatedResult": "Was Estimated", + "standardDeviations": "Standard Deviations", + "normalizedRankError": "Normalized Rank Error", + "maximumCountError": "Maximum Count Error", + "itemsSeen": "Items Seen", + "minimumValue": "Minimum Value", + "maximumValue": "Maximum Value", + "windowNumber": "Number", + "windowSize": "Size", + "windowEmitTime": "Emit Time", + "expectedEmitTime": "Expected Emit Time" } } } diff --git a/config/environment.js b/config/environment.js index 7f6672a8..f7e82d34 100644 --- a/config/environment.js +++ b/config/environment.js @@ -6,10 +6,10 @@ /* eslint-env node */ - const INTERNAL_APP_SETTINGS = { - adapter: 'indexeddb' -} + adapter: 'indexeddb', + debounceSegmentSaves: false +}; const TEST_SETTINGS = { queryHost: 'https://foo.bar.com:4443', @@ -31,6 +31,7 @@ const TEST_SETTINGS = { deletions: 'none' }, adapter: 'local', + debounceSegmentSaves: false, defaultValues: { aggregationMaxSize: 512, rawMaxSize: 100, @@ -53,20 +54,26 @@ const TEST_SETTINGS = { topKErrorType: 'No False Negatives' }, metadataKeyMapping: { - theta: 'theta', - uniquesEstimate: 'uniques_estimate', - queryCreationTime: 'query_receive_time', - queryTerminationTime: 'query_finish_time', - estimatedResult: 'was_estimated', - standardDeviations: 'standard_deviations', - normalizedRankError: 'normalized_rank_error', - maximumCountError: 'maximum_count_error', - itemsSeen: 'items_seen', - minimumValue: 'minimum_value', - maximumValue: 'maximum_value' + querySection: 'Query', + windowSection: 'Window', + sketchSection: 'Sketch', + theta: 'Theta', + uniquesEstimate: 'Uniques Estimate', + queryCreationTime: 'Receive Time', + queryTerminationTime: 'Finish Time', + estimatedResult: 'Was Estimated', + standardDeviations: 'Standard Deviations', + normalizedRankError: 'Normalized Rank Error', + maximumCountError: 'Maximum Count Error', + itemsSeen: 'Items Seen', + minimumValue: 'Minimum Value', + maximumValue: 'Maximum Value', + windowNumber: 'Number', + windowSize: 'Size', + expectedEmitTime: 'Expected Emit Time' } } -} +}; module.exports = function(environment) { let configuration = require('./env-settings.json'); diff --git a/package.json b/package.json index 32723728..3c69548d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "lint": "jscs . --verbose && yarn run eslint config/* app/* tests/*", "lint-fix": "jscs . --fix --verbose && yarn run eslint config/* app/* tests/* --fix", "build-package": "yarn && yarn run -s build && rm -rf node_modules && yarn install --production && yarn run -s archive", - "archive": "tar --exclude='.jshintrc' --exclude='.DS_Store' --exclude='coverage.js' --exclude='environment.js' -czf bullet-ui-v${BULLET_VERSION}.tar.gz dist express-server.js server config node_modules/" + "archive": "tar --exclude='.eslintrc.js' --exclude='.DS_Store' --exclude='coverage.js' --exclude='environment.js' -czf bullet-ui-v${BULLET_VERSION}.tar.gz dist express-server.js server config node_modules/" }, "repository": { "type": "git", @@ -48,10 +48,11 @@ ], "license": "Apache-2.0", "devDependencies": { + "@stomp/stompjs": "~4.0.1", "broccoli-asset-rev": "~2.4.5", "browserify-zlib": "~0.2.0", - "chart.js": "~2.7.2", "c3": "~0.5.3", + "chart.js": "~2.7.2", "ember-ajax": "~3.1.0", "ember-browserify": "~1.2.2", "ember-cli": "~3.0.0", @@ -74,7 +75,7 @@ "ember-cli-sri": "~2.1.1", "ember-cli-test-loader": "~2.2.0", "ember-cli-uglify": "~2.0.0", - "ember-cp-validations": "3.5.1", + "ember-cp-validations": "3.5.3", "ember-data": "~3.0.0", "ember-export-application-global": "~2.0.0", "ember-light-table": "1.12.2", @@ -82,6 +83,7 @@ "ember-localforage": "1.5.8", "ember-moment": "7.0.1", "ember-power-select": "1.10.4", + "ember-progress-bar": "^1.0.0", "ember-promise-helpers": "~1.0.3", "ember-radio-button": "1.2.3", "ember-resolver": "~4.0.0", @@ -89,7 +91,7 @@ "ember-route-action-helper": "2.0.6", "ember-scroll-to": "0.6.4", "ember-sinon": "~2.1.0", - "ember-source": "~3.0.0", + "ember-source": "~2.18.0", "ember-tether": "0.4.1", "ember-tooltips": "2.9.2", "eslint": "~4.19.0", @@ -99,8 +101,8 @@ "eslint-plugin-node": "~6.0.1", "file-saver": "~1.3.8", "interactjs": "~1.2.8", - "jQuery-QueryBuilder-Subfield": "bullet-db/jQuery-QueryBuilder-Subfield#v1.0.2", "jQuery-QueryBuilder-Placeholders": "bullet-db/jQuery-QueryBuilder-Placeholders#v1.0.1", + "jQuery-QueryBuilder-Subfield": "bullet-db/jQuery-QueryBuilder-Subfield#v1.0.2", "jquery-ui": "~1.12.1", "jscs": "~3.0.7", "json-formatter-js": "~2.2.0", @@ -108,7 +110,6 @@ "pivottable": "~2.20.0", "pretender": "1.4.0", "sockjs-client": "~1.1.4", - "@stomp/stompjs": "~4.0.1", "urlsafe-base64": "~1.0.0" }, "dependencies": { diff --git a/public/assets/images/killed.png b/public/assets/images/killed.png new file mode 100644 index 00000000..ab042de7 Binary files /dev/null and b/public/assets/images/killed.png differ diff --git a/public/assets/images/smiley.png b/public/assets/images/smiley.png new file mode 100644 index 00000000..bca53ab0 Binary files /dev/null and b/public/assets/images/smiley.png differ diff --git a/tests/acceptance/query-firing-test.js b/tests/acceptance/query-firing-test.js index c7213423..6b35eef0 100644 --- a/tests/acceptance/query-firing-test.js +++ b/tests/acceptance/query-firing-test.js @@ -263,7 +263,7 @@ module('Acceptance | query firing', function(hooks) { assert.equal(find('.output-container .top-k-display-name input').value, 'cnt'); }); - test('creating a window query', async function(assert) { + test('creating a windowed query', async function(assert) { assert.expect(7); this.mockedAPI.mock([RESULTS.RAW], COLUMNS.BASIC); @@ -283,7 +283,7 @@ module('Acceptance | query firing', function(hooks) { assert.equal(findAll('.window-input .ember-radio-button').length, 4); assert.ok(find('.window-input #time-based').parentElement.classList.contains('checked')); - await click('.window-input .remove-button'); + await click('.window-input .delete-button'); assert.equal(findAll('.window-input .add-button').length, 1); }); }); diff --git a/tests/acceptance/result-error-test.js b/tests/acceptance/result-error-test.js index bb19ed9b..53a68f8e 100644 --- a/tests/acceptance/result-error-test.js +++ b/tests/acceptance/result-error-test.js @@ -7,7 +7,7 @@ import { module, test } from 'qunit'; import RESULTS from '../fixtures/results'; import COLUMNS from '../fixtures/columns'; import { setupForAcceptanceTest } from '../helpers/setup-for-acceptance-test'; -import { visit, currentURL } from '@ember/test-helpers'; +import { visit, currentURL, click, currentRouteName, findAll } from '@ember/test-helpers'; module('Acceptance | result error', function(hooks) { setupForAcceptanceTest(hooks, [RESULTS.SINGLE], COLUMNS.BASIC); @@ -17,4 +17,25 @@ module('Acceptance | result error', function(hooks) { assert.equal(currentURL(), '/not-found'); }); + + test('it shows an error display when receiving an error metadata response', async function(assert) { + assert.expect(2); + this.mockedAPI.mock([RESULTS.ERROR], COLUMNS.BASIC); + + await visit('/queries/new'); + await click('.submit-button'); + assert.equal(currentRouteName(), 'result'); + assert.equal(findAll('.records-container .killed').length, 1); + }); + + test('it handles a fail response from the client by still display the result', async function(assert) { + assert.expect(2); + this.mockedAPI.mock([RESULTS.MULTIPLE], COLUMNS.BASIC); + this.mockedAPI.sendFailMessageAt(0); + + await visit('/queries/new'); + await click('.submit-button'); + assert.equal(currentRouteName(), 'result'); + assert.equal(findAll('.records-container .raw-display').length, 1); + }); }); diff --git a/tests/acceptance/result-lifecycle-test.js b/tests/acceptance/result-lifecycle-test.js index 1b704bd0..fa46a9d7 100644 --- a/tests/acceptance/result-lifecycle-test.js +++ b/tests/acceptance/result-lifecycle-test.js @@ -52,12 +52,12 @@ module('Acceptance | result lifecycle', function(hooks) { await click('.submit-button'); assert.equal(currentRouteName(), 'result'); assert.equal(findAll('.records-table').length, 1); - assert.equal(findAll('.result-metadata').length, 1); - assert.notOk(find('.result-metadata').classList.contains('is-expanded')); - assert.equal(findAll('.result-metadata pre').length, 0); - await click('.result-metadata .expand-bar'); - assert.ok(find('.result-metadata').classList.contains('is-expanded')); - assert.equal(findAll('.result-metadata pre').length, 1); + assert.equal(findAll('.window-metadata').length, 1); + assert.notOk(find('.window-metadata').classList.contains('is-expanded')); + assert.equal(findAll('.window-metadata pre').length, 0); + await click('.window-metadata .expand-bar'); + assert.ok(find('.window-metadata').classList.contains('is-expanded')); + assert.equal(findAll('.window-metadata pre').length, 1); }); test('it lets you expand result entries in a popover', async function(assert) { @@ -93,16 +93,17 @@ module('Acceptance | result lifecycle', function(hooks) { assert.equal(findAll('.lt-body .lt-row .lt-cell').length, 0); assert.equal(findAll('.pretty-json-container').length, 1); await click('.chart-view'); + // await pauseTest(); assert.equal(findAll('.lt-body .lt-row .lt-cell').length, 0); assert.equal(findAll('.pretty-json-container').length, 0); assert.equal(findAll('.records-charter').length, 1); assert.equal(findAll('.pivot-table-container').length, 1); assert.equal(findAll('.pvtUi').length, 1); // Only pivot view - assert.equal(findAll('.records-chater .mode-toggle').length, 0); + assert.equal(findAll('.records-charter .mode-toggle').length, 0); }); - test('it lets swap between a row, tabular, simple and pivot chart views when it is not a raw query', async function(assert) { + test('it lets you swap between a row, tabular, simple and pivot chart views when it is not a raw query', async function(assert) { assert.expect(15); this.mockedAPI.mock([RESULTS.DISTRIBUTION], COLUMNS.BASIC); @@ -127,12 +128,12 @@ module('Acceptance | result lifecycle', function(hooks) { assert.equal(findAll('.pretty-json-container').length, 0); assert.equal(findAll('.records-charter').length, 1); assert.equal(findAll('.records-charter .mode-toggle').length, 1); - assert.ok(find('.mode-toggle .left-view').classList.contains('selected')); + assert.ok(find('.records-charter .mode-toggle .left-view').classList.contains('selected')); assert.equal(findAll('.records-charter canvas').length, 1); - await click('.mode-toggle .right-view'); - assert.ok(find('.mode-toggle .right-view').classList.contains('selected')); - assert.equal(findAll('.pivot-table-container').length, 1); - assert.equal(findAll('.pvtUi').length, 1); + await click('.records-charter .mode-toggle .right-view'); + assert.ok(find('.records-charter .mode-toggle .right-view').classList.contains('selected')); + assert.equal(findAll('.records-charter .pivot-table-container').length, 1); + assert.equal(findAll('.records-charter .pvtUi').length, 1); }); test('it saves and restores pivot table options', async function(assert) { @@ -148,8 +149,8 @@ module('Acceptance | result lifecycle', function(hooks) { await click('.submit-button'); await click('.chart-view'); - await click('.mode-toggle .right-view'); - assert.ok(find('.mode-toggle .right-view').classList.contains('selected')); + await click('.records-charter .mode-toggle .right-view'); + assert.ok(find('.records-charter .mode-toggle .right-view').classList.contains('selected')); assert.equal(findAll('.pivot-table-container').length, 1); assert.equal(findAll('.pvtUi').length, 1); assert.equal(find('.pvtUi select.pvtRenderer').value, 'Table'); @@ -162,7 +163,7 @@ module('Acceptance | result lifecycle', function(hooks) { await click('.queries-table .query-results-entry'); await click('.query-results-entry-popover .results-table .result-date-entry'); await click('.chart-view'); - await click('.mode-toggle .right-view'); + await click('.records-charter .mode-toggle .right-view'); assert.equal(find('.pvtUi select.pvtRenderer').value, 'Bar Chart'); assert.equal(find('.pvtUi select.pvtAggregator').value, 'Sum'); }); @@ -179,11 +180,25 @@ module('Acceptance | result lifecycle', function(hooks) { assert.equal(findAll('.raw-display').length, 1); assert.equal(findAll('.pretty-json-container').length, 1); assert.equal(findAll('.raw-json-display').length, 0); - await click('.mode-toggle .right-view'); + await click('.records-viewer .mode-toggle .right-view'); assert.equal(findAll('.records-charter').length, 0); assert.equal(findAll('.lt-body .lt-row .lt-cell').length, 0); assert.equal(findAll('.raw-display').length, 1); assert.equal(findAll('.pretty-json-container').length, 0); assert.equal(findAll('.raw-json-display').length, 1); }); + + test('it lets you rerun a query', async function(assert) { + assert.expect(3); + + this.mockedAPI.mock([RESULTS.MULTIPLE], COLUMNS.BASIC); + + await visit('/queries/new'); + await click('.submit-button'); + assert.equal(currentRouteName(), 'result'); + await click('.rerun-button'); + assert.equal(currentRouteName(), 'result'); + await visit('queries'); + assert.equal(find('.queries-table .query-results-entry .length-entry').textContent.trim(), '2 Results'); + }); }); diff --git a/tests/fixtures/results.js b/tests/fixtures/results.js index 2b27e676..2928f442 100644 --- a/tests/fixtures/results.js +++ b/tests/fixtures/results.js @@ -17,6 +17,16 @@ const RESULTS = { } ] }, + ERROR: { + meta: { errors: [] }, + records: [ + { + foo: 'test', + timestamp: 1231231231, + domain: 'foo' + } + ] + }, MULTIPLE: { meta: {}, records: [ diff --git a/tests/helpers/mocked-api.js b/tests/helpers/mocked-api.js index 9005cc1b..afe4a1bf 100644 --- a/tests/helpers/mocked-api.js +++ b/tests/helpers/mocked-api.js @@ -11,12 +11,24 @@ export default EmberObject.extend({ type: null, dataArray: null, server: null, + respondImmediately: true, + errorMessageIndex: -1, mock(dataArray, columns, delay = 0) { this.shutdown(); this.set('type', 'mockAPI'); this.set('dataArray', dataArray); this.set('server', mockAPI(columns, delay)); + this.set('respondImmediately', true); + this.set('errorMessageIndex', -1); + }, + + sendFailMessageAt(index) { + let dataArray = this.get('dataArray'); + if (isEmpty(dataArray) || dataArray.length <= index) { + return; + } + this.set('errorMessageIndex', index); }, fail(columns) { @@ -44,13 +56,21 @@ export default EmberObject.extend({ this.set('onStompMessage', onStompMessage); }, - send() { + getResponseType(index, totalMessages, errorMessageIndex) { + if (isEqual(index, errorMessageIndex)) { + return 'FAIL'; + } + return isEqual(index, totalMessages - 1) ? 'COMPLETE' : 'MESSAGE'; + }, + + respondWithData() { let onStompMessage = this.get('onStompMessage'); let dataArray = this.get('dataArray'); if (onStompMessage && !isEmpty(dataArray)) { let length = dataArray.length; + let errorMessageIndex = this.get('errorMessageIndex'); dataArray.forEach((data, i) => { - let responseType = isEqual(i, length - 1) ? 'COMPLETE' : 'MESSAGE'; + let responseType = this.getResponseType(i, length, errorMessageIndex); let response = { body: JSON.stringify({ type: responseType, @@ -62,5 +82,11 @@ export default EmberObject.extend({ } }, - disconnect() {} + send() { + if (this.get('respondImmediately')) { + this.respondWithData(); + } + }, + + disconnect() { } }); diff --git a/tests/helpers/mocked-query.js b/tests/helpers/mocked-query.js index f6d7e7f0..e93a2213 100644 --- a/tests/helpers/mocked-query.js +++ b/tests/helpers/mocked-query.js @@ -141,7 +141,7 @@ export default EmberObject.extend({ this.topLevelPropertyAsPromise('window'); }, - removeWindow() { + deleteWindow() { this.set('_window', null); this.topLevelPropertyAsPromise('window'); }, diff --git a/tests/helpers/setup-for-acceptance-test.js b/tests/helpers/setup-for-acceptance-test.js index faf8cb7a..57830415 100644 --- a/tests/helpers/setup-for-acceptance-test.js +++ b/tests/helpers/setup-for-acceptance-test.js @@ -5,7 +5,7 @@ */ import EmberObject from '@ember/object'; import { setupApplicationTest } from 'ember-qunit'; -import mockedAPI from './mocked-api'; +import MockedAPI from './mocked-api'; import sinon from 'sinon'; import Stomp from 'npm:@stomp/stompjs'; import registerPowerSelectHelpers from 'ember-power-select/test-support/helpers'; @@ -40,7 +40,7 @@ export function setupForAcceptanceTest(hooks, results, columns) { basicSetupForAcceptanceTest(hooks); hooks.beforeEach(function() { - this.mockedAPI = mockedAPI.create(); + this.mockedAPI = MockedAPI.create(); this.stub = sinon.stub(Stomp, 'over').returns(this.mockedAPI); this.mockedAPI.mock(results, columns); }); diff --git a/tests/integration/components/query-information-test.js b/tests/integration/components/query-information-test.js new file mode 100644 index 00000000..41cda806 --- /dev/null +++ b/tests/integration/components/query-information-test.js @@ -0,0 +1,77 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +import EmberObject from '@ember/object'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, click } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; + +module('Integration | Component | query information', function(hooks) { + setupRenderingTest(hooks); + + test('it displays a query summary', async function(assert) { + this.set('mockSnapshot', EmberObject.create({ filterSummary: 'foo', fieldsSummary: 'bar', windowSummary: 'baz' })); + await render(hbs`{{query-information querySnapshot=mockSnapshot}}`); + let textContent = this.element.textContent.trim(); + assert.ok(textContent.indexOf('foo') !== -1); + assert.ok(textContent.indexOf('bar') !== -1); + assert.ok(textContent.indexOf('baz') !== -1); + }); + + test('it displays an edit and a rerun button', async function(assert) { + await render(hbs`{{query-information}}`); + assert.equal(this.element.querySelectorAll('.link-button').length, 1); + assert.equal(this.element.querySelectorAll('.rerun-button').length, 1); + }); + + test('it displays an edit and a cancel button when running a query', async function(assert) { + this.set('mockQuerier', EmberObject.create({ isRunningQuery: true })); + await render(hbs`{{query-information querier=mockQuerier}}`); + assert.equal(this.element.querySelectorAll('.link-button').length, 1); + assert.equal(this.element.querySelectorAll('.cancel-button').length, 1); + }); + + test('it cancels a query', async function(assert) { + assert.expect(2); + this.set('mockCancelClick', () => { + assert.ok(true); + }); + this.set('mockQuerier', EmberObject.create({ isRunningQuery: true })); + await render(hbs`{{query-information querier=mockQuerier cancelClick=(action mockCancelClick)}}`); + assert.equal(this.element.querySelectorAll('.cancel-button').length, 1); + await click('button.cancel-button'); + }); + + test('it reruns a query', async function(assert) { + assert.expect(2); + this.set('mockReRunClick', () => { + assert.ok(true); + }); + await render(hbs`{{query-information reRunClick=(action mockReRunClick)}}`); + assert.equal(this.element.querySelectorAll('.rerun-button').length, 1); + await click('button.rerun-button'); + }); + + test('it links to the query', async function(assert) { + assert.expect(2); + this.set('mockQueryClick', () => { + assert.ok(true); + }); + await render(hbs`{{query-information queryClick=(action mockQueryClick)}}`); + assert.equal(this.element.querySelectorAll('.link-button').length, 1); + await click('button.link-button'); + }); + + test('it links to the query from the whole wrapper too', async function(assert) { + assert.expect(2); + this.set('mockQueryClick', () => { + assert.ok(true); + }); + await render(hbs`{{query-information queryClick=(action mockQueryClick)}}`); + assert.equal(this.element.querySelectorAll('.query-blurb-wrapper').length, 1); + await click('div.query-blurb-wrapper'); + }); +}); diff --git a/tests/integration/components/records-charter-test.js b/tests/integration/components/records-charter-test.js index 86a4e113..9afb43de 100644 --- a/tests/integration/components/records-charter-test.js +++ b/tests/integration/components/records-charter-test.js @@ -15,10 +15,11 @@ module('Integration | Component | records charter', function(hooks) { test('it starts off in chart mode and allows you to switch to pivot mode', async function(assert) { assert.expect(5); - this.set('mockModel', EmberObject.create({ isRaw: false, isDistribution: true, pivotOptions: null, save() { } })); + this.set('mockConfig', EmberObject.create({ isRaw: false, isDistribution: true, pivotOptions: null })); + this.set('mockModel', EmberObject.create({ save() { } })); this.set('mockRows', RESULTS.DISTRIBUTION.records); this.set('mockColumns', ['Probability', 'Count', 'Range']); - await render(hbs`{{records-charter rows=mockRows columns=mockColumns model=mockModel}}`); + await render(hbs`{{records-charter rows=mockRows columns=mockColumns model=mockModel config=mockConfig}}`); assert.ok(this.element.querySelector('.mode-toggle .left-view').classList.contains('selected')); assert.equal(this.element.querySelectorAll('.visual-container canvas').length, 1); @@ -30,30 +31,33 @@ module('Integration | Component | records charter', function(hooks) { test('it charts a single dependent column', async function(assert) { assert.expect(2); - this.set('mockModel', EmberObject.create({ isRaw: false, pivotOptions: null, save() { } })); + this.set('mockConfig', EmberObject.create({ isRaw: false, pivotOptions: null })); + this.set('mockModel', EmberObject.create({ save() { } })); this.set('mockRows', RESULTS.SINGLE.records); this.set('mockColumns', ['foo', 'timestamp', 'domain']); - await render(hbs`{{records-charter rows=mockRows columns=mockColumns model=mockModel}}`); + await render(hbs`{{records-charter rows=mockRows columns=mockColumns model=mockModel config=mockConfig}}`); assert.ok(this.element.querySelector('.mode-toggle .left-view').classList.contains('selected')); assert.equal(this.element.querySelectorAll('.visual-container canvas').length, 1); }); test('it charts multiple dependent columns', async function(assert) { assert.expect(2); - this.set('mockModel', EmberObject.create({ isRaw: false, pivotOptions: null, save() { } })); + this.set('mockConfig', EmberObject.create({ isRaw: false, pivotOptions: null })); + this.set('mockModel', EmberObject.create({ save() { } })); this.set('mockRows', RESULTS.GROUP_MULTIPLE_METRICS.records); this.set('mockColumns', ['foo', 'bar', 'COUNT', 'avg_bar', 'sum_foo']); - await render(hbs`{{records-charter rows=mockRows columns=mockColumns model=mockModel}}`); + await render(hbs`{{records-charter rows=mockRows columns=mockColumns model=mockModel config=mockConfig}}`); assert.ok(this.element.querySelector('.mode-toggle .left-view').classList.contains('selected')); assert.equal(this.element.querySelectorAll('.visual-container canvas').length, 1); }); test('it enables only the pivot mode if the results are raw', async function(assert) { assert.expect(3); - this.set('mockModel', EmberObject.create({ isRaw: true, pivotOptions: null, save() { } })); + this.set('mockConfig', EmberObject.create({ isRaw: true, pivotOptions: null })); + this.set('mockModel', EmberObject.create({ save() { } })); this.set('mockRows', RESULTS.SINGLE.records); this.set('mockColumns', ['foo', 'timestamp', 'domain']); - await render(hbs`{{records-charter rows=mockRows columns=mockColumns model=mockModel}}`); + await render(hbs`{{records-charter rows=mockRows columns=mockColumns model=mockModel config=mockConfig}}`); assert.equal(this.element.querySelectorAll('.mode-toggle').length, 0); assert.equal(this.element.querySelectorAll('.visual-container .pivot-table-container').length, 1); assert.equal(this.element.querySelectorAll('.visual-container .pivot-table-container .pvtUi').length, 1); @@ -61,10 +65,8 @@ module('Integration | Component | records charter', function(hooks) { test('it saves pivot table configurations', async function(assert) { assert.expect(8); + this.set('mockConfig', EmberObject.create({ isRaw: false, isDistribution: true, pivotOptions: null })); this.set('mockModel', EmberObject.create({ - isRaw: false, - isDistribution: true, - pivotOptions: null, save() { // Called twice assert.ok(true); @@ -72,7 +74,7 @@ module('Integration | Component | records charter', function(hooks) { })); this.set('mockRows', RESULTS.DISTRIBUTION.records); this.set('mockColumns', ['Probability', 'Count', 'Range']); - await render(hbs`{{records-charter rows=mockRows columns=mockColumns model=mockModel}}`); + await render(hbs`{{records-charter rows=mockRows columns=mockColumns model=mockModel config=mockConfig}}`); assert.ok(this.element.querySelector('.mode-toggle .left-view').classList.contains('selected')); assert.equal(this.element.querySelectorAll('.visual-container canvas').length, 1); diff --git a/tests/integration/components/records-viewer-test.js b/tests/integration/components/records-viewer-test.js index 48900b05..1357616e 100644 --- a/tests/integration/components/records-viewer-test.js +++ b/tests/integration/components/records-viewer-test.js @@ -46,9 +46,9 @@ module('Integration | Component | records viewer', function(hooks) { test('it allows swapping between table and the raw views', async function(assert) { assert.expect(11); - this.set('mockModel', { isSingleRow: true }); + this.set('mockConfig', { isSingleRow: true }); this.set('mockRecords', RESULTS.SINGLE.records); - await render(hbs`{{records-viewer model=mockModel records=mockRecords}}`); + await render(hbs`{{records-viewer config=mockConfig records=mockRecords}}`); await click('.table-view'); assert.equal(this.element.querySelectorAll('.chart-view').length, 0); assert.ok(this.element.querySelector('.table-view').classList.contains('active')); @@ -117,9 +117,9 @@ module('Integration | Component | records viewer', function(hooks) { test('it enables charting mode if the results have more than one row', async function(assert) { assert.expect(9); this.set('tableMode', true); - this.set('mockModel', { isSingleRow: false }); + this.set('mockConfig', { isSingleRow: false }); this.set('mockRecords', RESULTS.GROUP.records); - await render(hbs`{{records-viewer model=mockModel records=mockRecords showTable=tableMode}}`); + await render(hbs`{{records-viewer config=mockConfig records=mockRecords showTable=tableMode}}`); assert.equal(this.element.querySelectorAll('.chart-view').length, 1); assert.equal(this.element.querySelectorAll('.raw-display').length, 0); assert.equal(this.element.querySelectorAll('.records-table').length, 1); @@ -136,15 +136,14 @@ module('Integration | Component | records viewer', function(hooks) { test('it allows you to switch to pivot mode', async function(assert) { assert.expect(12); this.set('tableMode', true); + this.set('mockConfig', EmberObject.create({ isSingleRow: false, pivotOptions: null })); this.set('mockModel', EmberObject.create({ - isSingleRow: false, - pivotOptions: null, save() { assert.ok(true); } })); this.set('mockRecords', RESULTS.DISTRIBUTION.records); - await render(hbs`{{records-viewer model=mockModel records=mockRecords showTable=tableMode}}`); + await render(hbs`{{records-viewer model=mockModel config=mockConfig records=mockRecords showTable=tableMode}}`); assert.equal(this.element.querySelectorAll('.chart-view').length, 1); assert.equal(this.element.querySelectorAll('.raw-display').length, 0); assert.equal(this.element.querySelectorAll('.records-table').length, 1); @@ -163,15 +162,14 @@ module('Integration | Component | records viewer', function(hooks) { test('it enables only pivot mode if the results are raw', async function(assert) { assert.expect(10); this.set('tableMode', true); + this.set('mockConfig', EmberObject.create({ isRaw: true, pivotOptions: null })); this.set('mockModel', EmberObject.create({ - isRaw: true, - pivotOptions: null, save() { assert.ok(true); } })); this.set('mockRecords', RESULTS.GROUP.records); - await render(hbs`{{records-viewer model=mockModel records=mockRecords showTable=tableMode}}`); + await render(hbs`{{records-viewer model=mockModel config=mockConfig records=mockRecords showTable=tableMode}}`); assert.equal(this.element.querySelectorAll('.chart-view').length, 1); assert.equal(this.element.querySelectorAll('.raw-display').length, 0); assert.equal(this.element.querySelectorAll('.records-table').length, 1); diff --git a/tests/integration/components/result-viewer-test.js b/tests/integration/components/result-viewer-test.js new file mode 100644 index 00000000..27535c58 --- /dev/null +++ b/tests/integration/components/result-viewer-test.js @@ -0,0 +1,140 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +import EmberObject from '@ember/object'; +import { A } from '@ember/array'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, click } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; + +module('Integration | Component | result viewer', function(hooks) { + setupRenderingTest(hooks); + + function makeQuery(isTimeBased, duration = 0.01) { + return EmberObject.create({ + window: { isTimeBased }, + duration + }); + } + + function makeResult(errorWindow, isRaw, hasData, windows) { + windows.forEach((window, i) => window.index = i); + return EmberObject.create({ + errorWindow, isRaw, hasData, windows: A(windows) + }); + } + + function makeWindow(sequence, meta = { }, records = [{ }]) { + return { sequence, meta, records, created: Date.now() }; + } + + test('it allows auto update toggling if there is data, no errors, and not in aggregate mode', async function(assert) { + // Error window + this.set('mockQuery', makeQuery(true)); + this.set('mockResult', makeResult({ }, false, true, [{ records: [] }])); + await render(hbs`{{result-viewer query=mockQuery result=mockResult}}`); + assert.ok(this.element.querySelector('.auto-update-wrapper').classList.contains('no-visibility')); + + // Raw but Record based window -> auto aggregate + this.set('mockQuery', makeQuery(false)); + this.set('mockResult', makeResult(null, true, true, [{ records: [] }])); + await render(hbs`{{result-viewer query=mockQuery result=mockResult}}`); + assert.ok(this.element.querySelector('.auto-update-wrapper').classList.contains('no-visibility')); + + // No data + this.set('mockQuery', makeQuery(true)); + this.set('mockResult', makeResult(null, false, false, [])); + await render(hbs`{{result-viewer query=mockQuery result=mockResult}}`); + assert.ok(this.element.querySelector('.auto-update-wrapper').classList.contains('no-visibility')); + + // Not Raw and Time Based + this.set('mockQuery', makeQuery(true)); + this.set('mockResult', makeResult(null, false, true, [{ records: [] }])); + await render(hbs`{{result-viewer query=mockQuery result=mockResult}}`); + assert.notOk(this.element.querySelector('.auto-update-wrapper').classList.contains('no-visibility')); + + // Raw and Time Based + this.set('mockQuery', makeQuery(true)); + this.set('mockResult', makeResult(null, true, true, [{ records: [] }])); + await render(hbs`{{result-viewer query=mockQuery result=mockResult}}`); + assert.notOk(this.element.querySelector('.auto-update-wrapper').classList.contains('no-visibility')); + }); + + test('it does not show window switching controls if there is an error or if in aggregate mode', async function(assert) { + // Error + this.set('mockQuery', makeQuery(true)); + this.set('mockResult', makeResult({ }, false, true, [{ records: [] }])); + await render(hbs`{{result-viewer query=mockQuery result=mockResult}}`); + assert.equal(this.element.querySelectorAll('.window-selector .ember-power-select-trigger').length, 0); + + // Raw Record -> auto aggregate + this.set('mockQuery', makeQuery(false)); + this.set('mockResult', makeResult(null, true, true, [{ records: [] }])); + await render(hbs`{{result-viewer query=mockQuery result=mockResult}}`); + assert.equal(this.element.querySelectorAll('.window-selector .ember-power-select-trigger').length, 0); + + // Raw Time + this.set('mockQuery', makeQuery(true)); + this.set('mockResult', makeResult(null, true, true, [{ records: [] }])); + await render(hbs`{{result-viewer query=mockQuery result=mockResult}}`); + assert.equal(this.element.querySelectorAll('.window-selector .ember-power-select-trigger').length, 1); + + // Other Time + this.set('mockQuery', makeQuery(true)); + this.set('mockResult', makeResult(null, false, true, [{ records: [] }])); + await render(hbs`{{result-viewer query=mockQuery result=mockResult}}`); + assert.equal(this.element.querySelectorAll('.window-selector .ember-power-select-trigger').length, 1); + }); + + test('it only shows window timing progress if it is a time window', async function(assert) { + // Error + this.set('mockQuery', makeQuery(true)); + this.set('mockResult', makeResult({ }, false, true, [{ records: [] }])); + await render(hbs`{{result-viewer query=mockQuery result=mockResult}}`); + assert.equal(this.element.querySelectorAll('.window-selector .window-progress-indicator').length, 0); + + // Raw Record -> auto aggregate + this.set('mockQuery', makeQuery(true)); + this.set('mockResult', makeResult(null, true, true, [{ records: [] }])); + await render(hbs`{{result-viewer query=mockQuery result=mockResult}}`); + assert.equal(this.element.querySelectorAll('.window-selector .ember-power-select-trigger').length, 1); + }); + + test('it only shows query timing progress if there is no error', async function(assert) { + this.set('mockQuery', makeQuery(true)); + this.set('mockResult', makeResult({ }, false, true, [{ records: [] }])); + await render(hbs`{{result-viewer query=mockQuery result=mockResult}}`); + assert.equal(this.element.querySelectorAll('.control-container .query-progress-indicator').length, 0); + + this.set('mockQuery', makeQuery(true)); + this.set('mockResult', makeResult(null, true, true, [{ records: [] }])); + await render(hbs`{{result-viewer query=mockQuery result=mockResult}}`); + assert.equal(this.element.querySelectorAll('.control-container .query-progress-indicator').length, 1); + }); + + test('it shows a done progress bar if there is no query running', async function(assert) { + this.set('mockQuery', makeQuery(true)); + this.set('mockResult', makeResult(null, false, true, [{ records: [] }])); + this.set('mockQuerier', EmberObject.create({ isRunningQuery: false })); + await render(hbs`{{result-viewer query=mockQuery result=mockResult querier=mockQuerier}}`); + assert.equal(this.element.querySelector('.control-container .query-progress-indicator').textContent.trim(), '100%'); + }); + + test('it lets you turn auto update on and off', async function(assert) { + this.set('mockQuery', makeQuery(true)); + this.set('mockResult', makeResult(null, false, true, [makeWindow(1), makeWindow(2)])); + await render(hbs`{{result-viewer query=mockQuery result=mockResult}}`); + assert.ok(this.element.querySelector('.auto-update-wrapper .mode-toggle .left-view').classList.contains('selected')); + let selectedWindowText = this.element.querySelector('.window-selector .result-window-placeholder').textContent.trim(); + assert.equal(selectedWindowText, 'Switch between 2 windows...'); + + await click('.auto-update-wrapper .mode-toggle .right-view'); + assert.ok(this.element.querySelector('.auto-update-wrapper .mode-toggle .right-view').classList.contains('selected')); + selectedWindowText = this.element.querySelector('.window-selector .ember-power-select-selected-item').textContent.trim(); + assert.equal(this.element.querySelectorAll('.window-selector .result-window-placeholder').length, 0); + assert.ok(selectedWindowText.indexOf('#2') !== -1); + }); +}); diff --git a/tests/integration/components/result-window-placeholder-test.js b/tests/integration/components/result-window-placeholder-test.js new file mode 100644 index 00000000..ecdcaec5 --- /dev/null +++ b/tests/integration/components/result-window-placeholder-test.js @@ -0,0 +1,23 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; + +module('Integration | Component | result window placeholder', function(hooks) { + setupRenderingTest(hooks); + + test('it displays a message to switch between windows', async function(assert) { + await render(hbs`{{result-window-placeholder}}`); + assert.equal(this.element.textContent.trim(), 'Switch between 0 windows...'); + }); + + test('it displays a message to switch between the given count of windows', async function(assert) { + await render(hbs`{{result-window-placeholder windowCount=100}}`); + assert.equal(this.element.textContent.trim(), 'Switch between 100 windows...'); + }); +}); diff --git a/tests/integration/components/results-table-test.js b/tests/integration/components/results-table-test.js index ef6fc6d8..d5ba6d0b 100644 --- a/tests/integration/components/results-table-test.js +++ b/tests/integration/components/results-table-test.js @@ -16,20 +16,20 @@ module('Integration | Component | results table', function(hooks) { test('it displays a row with two cells in two columns', async function(assert) { assert.expect(4); this.set('mockResults', A([ - EmberObject.create({ created: new Date(2014, 11, 31), records: A([1, 2, 3]) }) + EmberObject.create({ created: new Date(2014, 11, 31), windows: A([{ }, { }, { }]) }) ])); await render(hbs`{{results-table results=mockResults}}`); assert.equal(this.element.querySelectorAll('.lt-head .lt-column')[0].textContent.trim(), 'Date'); - assert.equal(this.element.querySelectorAll('.lt-head .lt-column')[1].textContent.trim(), '# Records'); + assert.equal(this.element.querySelectorAll('.lt-head .lt-column')[1].textContent.trim(), '# Windows'); assert.equal(this.element.querySelectorAll('.lt-body .lt-row .lt-cell')[0].textContent.trim(), '31 Dec 12:00 AM'); assert.equal(this.element.querySelectorAll('.lt-body .lt-row .lt-cell')[1].textContent.trim(), '3'); }); - test('it sorts by the number of records column on click', async function(assert) { + test('it sorts by the number of windows column on click', async function(assert) { assert.expect(2); this.set('mockResults', A([ - EmberObject.create({ created: new Date(2014, 11, 31), records: A([1, 2, 3]) }), - EmberObject.create({ created: new Date(2015, 1, 1), records: A() }) + EmberObject.create({ created: new Date(2014, 11, 31), windows: A([{ }, { }, { }]) }), + EmberObject.create({ created: new Date(2015, 1, 1), windows: A() }) ])); await render(hbs`{{results-table results=mockResults}}`); assert.equal(this.element.querySelectorAll('.lt-head .lt-column.is-sortable').length, 2); @@ -42,8 +42,8 @@ module('Integration | Component | results table', function(hooks) { test('it sorts by the date column on click', async function(assert) { assert.expect(2); this.set('mockResults', A([ - EmberObject.create({ created: new Date(2015, 1, 1), records: A() }), - EmberObject.create({ created: new Date(2014, 11, 31), records: A([1, 2, 3]) }) + EmberObject.create({ created: new Date(2015, 1, 1), windows: A() }), + EmberObject.create({ created: new Date(2014, 11, 31), windows: A([{ }, { }, { }]) }) ])); await render(hbs`{{results-table results=mockResults}}`); assert.equal(this.element.querySelectorAll('.lt-head .lt-column.is-sortable').length, 2); @@ -56,11 +56,11 @@ module('Integration | Component | results table', function(hooks) { test('it sends the resultClick action on click', async function(assert) { assert.expect(2); this.set('mockResultClick', result => { - assert.equal(result.get('records.length'), 3); + assert.equal(result.get('windows.length'), 3); }); this.set('mockResults', A([ - EmberObject.create({ created: new Date(2015, 1, 1), records: A() }), - EmberObject.create({ created: new Date(2014, 11, 31), records: A([1, 2, 3]) }) + EmberObject.create({ created: new Date(2015, 1, 1), windows: A() }), + EmberObject.create({ created: new Date(2014, 11, 31), windows: A([{ }, { }, { }]) }) ])); await render(hbs`{{results-table results=mockResults resultClick=(action mockResultClick)}}`); assert.equal(this.element.querySelectorAll('.lt-head .lt-column.is-sortable').length, 2); diff --git a/tests/integration/components/timed-progress-bar-test.js b/tests/integration/components/timed-progress-bar-test.js index 85036d4f..b0456cfb 100644 --- a/tests/integration/components/timed-progress-bar-test.js +++ b/tests/integration/components/timed-progress-bar-test.js @@ -13,33 +13,19 @@ module('Integration | Component | timed progress bar', function(hooks) { test('it renders', async function(assert) { await render(hbs`{{timed-progress-bar}}`); - assert.equal(this.element.textContent.trim(), '0%'); + assert.equal(this.element.textContent.trim(), '100%'); await render(hbs` {{#timed-progress-bar}} template block text {{/timed-progress-bar}} `); - assert.ok(this.element.textContent.trim().match('0%\\s+template block text')); - }); - - test('it starts as inactive', async function(assert) { - await render(hbs`{{timed-progress-bar}}`); - assert.ok(this.element.querySelector('.progress').getAttribute('class').indexOf('hidden') !== -1); + assert.ok(this.element.textContent.trim().match('100%')); }); test('it can be made active', async function(assert) { - this.set('isActive', false); - await render(hbs`{{timed-progress-bar active=isActive}}`); - assert.ok(this.element.querySelector('.progress').getAttribute('class').indexOf('hidden') !== -1); - this.set('isActive', true); - assert.ok(this.element.querySelector('.progress').getAttribute('class').indexOf('hidden') === -1); - }); - - test('it changes from percentage to a message when done', async function(assert) { - assert.expect(1); - await render(hbs`{{timed-progress-bar active=true duration=100}}`); - assert.equal(this.element.textContent.trim(), 'Collecting results...'); + await render(hbs`{{timed-progress-bar active=true duration=100 updateInterval=100}}`); + assert.equal(this.element.textContent.trim(), '100%'); }); test('it calls the finished action', async function(assert) { @@ -49,4 +35,9 @@ module('Integration | Component | timed progress bar', function(hooks) { }); await render(hbs`{{timed-progress-bar active=true duration=100 finished=(action finishedAction)}}`); }); + + test('it can skip displaying a percentage', async function(assert) { + await render(hbs`{{timed-progress-bar useStep=false}}`); + assert.equal(this.element.textContent.trim(), ''); + }); }); diff --git a/tests/integration/components/window-input-test.js b/tests/integration/components/window-input-test.js index 434a4c22..5a12a6bd 100644 --- a/tests/integration/components/window-input-test.js +++ b/tests/integration/components/window-input-test.js @@ -16,13 +16,12 @@ module('Integration | Component | window-input', function(hooks) { setupRenderingTest(hooks); test('it renders without window', async function(assert) { - assert.expect(2); + assert.expect(1); let mockQuery = MockQuery.create(); this.set('mockQuery', mockQuery); await render(hbs `{{window-input query=mockQuery}}`); - assert.equal(this.element.querySelector('.subsection-header').textContent.trim(), 'No Window'); - assert.equal(this.element.querySelectorAll('.add-button').length, 1); + assert.equal(this.element.querySelectorAll('.no-window-section .add-button').length, 1); }); test('it renders when aggregation is raw', async function(assert) { @@ -87,7 +86,7 @@ module('Integration | Component | window-input', function(hooks) { assert.ok(this.element.querySelector('#include-all').parentElement.classList.contains('checked')); }); - test('it adds window', async function(assert) { + test('it adds a window', async function(assert) { assert.expect(4); let mockQuery = MockQuery.create(); @@ -103,28 +102,27 @@ module('Integration | Component | window-input', function(hooks) { await click('.add-button'); assert.equal(this.element.querySelectorAll('.add-button').length, 0); - assert.equal(this.element.querySelectorAll('.remove-button').length, 1); + assert.equal(this.element.querySelectorAll('.delete-button').length, 1); }); - test('it removes window', async function(assert) { - assert.expect(5); + test('it deletes a window', async function(assert) { + assert.expect(4); let mockQuery = MockQuery.create(); mockQuery.addWindow(EMIT_TYPES.get('TIME'), 2, INCLUDE_TYPES.get('WINDOW')); mockQuery.addAggregation(AGGREGATIONS.get('GROUP')); this.set('mockQuery', mockQuery); - this.set('mockRemoveWindow', () => { + this.set('mockDeleteWindow', () => { assert.ok(true); - mockQuery.removeWindow(); + mockQuery.deleteWindow(); return resolve(); }); - await render(hbs `{{window-input removeWindow=mockRemoveWindow query=mockQuery}}`); - assert.equal(this.element.querySelectorAll('.remove-button').length, 1); + await render(hbs `{{window-input deleteWindow=mockDeleteWindow query=mockQuery}}`); + assert.equal(this.element.querySelectorAll('.delete-button').length, 1); - await click('.remove-button'); - assert.equal(this.element.querySelectorAll('.remove-button').length, 0); - assert.equal(this.element.querySelector('.subsection-header').textContent.trim(), 'No Window'); - assert.equal(this.element.querySelectorAll('.add-button').length, 1); + await click('.delete-button'); + assert.equal(this.element.querySelectorAll('.delete-button').length, 0); + assert.equal(this.element.querySelectorAll('.no-window-section .add-button').length, 1); }); }); diff --git a/tests/integration/components/result-metadata-test.js b/tests/integration/components/window-metadata-test.js similarity index 73% rename from tests/integration/components/result-metadata-test.js rename to tests/integration/components/window-metadata-test.js index f44c5034..7ec13c47 100644 --- a/tests/integration/components/result-metadata-test.js +++ b/tests/integration/components/window-metadata-test.js @@ -8,16 +8,16 @@ import { setupRenderingTest } from 'ember-qunit'; import { render, click } from '@ember/test-helpers'; import hbs from 'htmlbars-inline-precompile'; -module('Integration | Component | result metadata', function(hooks) { +module('Integration | Component | window metadata', function(hooks) { setupRenderingTest(hooks); test('it does not block render', async function(assert) { - await render(hbs`{{result-metadata}}`); + await render(hbs`{{window-metadata}}`); assert.equal(this.element.textContent.trim(), ''); await render(hbs` - {{#result-metadata}} + {{#window-metadata}} template block text - {{/result-metadata}} + {{/window-metadata}} `); assert.equal(this.element.textContent.trim(), ''); }); @@ -26,10 +26,10 @@ module('Integration | Component | result metadata', function(hooks) { assert.expect(3); this.set('mockMetadata', 'custom metadata'); - await render(hbs`{{result-metadata metadata=mockMetadata}}`); - assert.notOk(this.element.querySelector('.result-metadata').classList.contains('is-expanded')); + await render(hbs`{{window-metadata metadata=mockMetadata}}`); + assert.notOk(this.element.querySelector('.window-metadata').classList.contains('is-expanded')); await click('.expand-bar'); - assert.ok(this.element.querySelector('.result-metadata').classList.contains('is-expanded')); + assert.ok(this.element.querySelector('.window-metadata').classList.contains('is-expanded')); assert.equal(this.element.querySelector('pre').textContent.trim(), '"custom metadata"'); }); }); diff --git a/tests/unit/mixins/queryable-test.js b/tests/unit/mixins/queryable-test.js new file mode 100644 index 00000000..f0c65f5e --- /dev/null +++ b/tests/unit/mixins/queryable-test.js @@ -0,0 +1,105 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +import EmberObject from '@ember/object'; +import QueryableMixin from 'bullet-ui/mixins/queryable'; +import { module, test } from 'qunit'; + +module('Unit | Mixin | queryable', function() { + test('it transitions to a saved result when calling resultHandler', function(assert) { + assert.expect(2); + let QueryableObject = EmberObject.extend(QueryableMixin); + let subject = QueryableObject.create(); + let mockTransitionTo = (item, id) => { + assert.equal(item, 'result'); + assert.equal(id, 'foo'); + }; + subject.set('savedResult', EmberObject.create({ id: 'foo' })); + subject.set('transitionTo', mockTransitionTo); + subject.resultHandler(subject); + }); + + test('it transitions to an error when calling errorHandler', function(assert) { + assert.expect(1); + let QueryableObject = EmberObject.extend(QueryableMixin); + let subject = QueryableObject.create(); + let mockTransition = item => { + assert.equal(item, 'errored'); + }; + subject.set('transitionTo', mockTransition); + subject.errorHandler('Mocked Test Error', subject); + }); + + test('it adds a window to the savedResult when calling windowHandler', function(assert) { + assert.expect(2); + let QueryableObject = EmberObject.extend(QueryableMixin); + let subject = QueryableObject.create(); + let mockAddSegment = (result, message) => { + assert.equal(result.get('id'), 'foo'); + assert.equal(message.get('id'), 'bar'); + }; + subject.set('savedResult', EmberObject.create({ id: 'foo' })); + subject.set('queryManager', { addSegment: mockAddSegment }); + subject.windowHandler(EmberObject.create({ id: 'bar' }), subject); + }); + + test('it can submit a query, save a result and attach the appropriate handlers', function(assert) { + assert.expect(6); + + let QueryableObject = EmberObject.extend(QueryableMixin); + let subject = QueryableObject.create(); + + let mockTransitionTo = () => { + // Will be called twice (success and error) + assert.ok(true); + }; + let mockSend = (query, handlers, context) => { + assert.equal(query.get('id'), 'foo'); + handlers.success(context); + handlers.error('Mocked Test Error', context); + handlers.message(EmberObject.create({ id: 'baz' }), context); + }; + let mockAddSegment = (result, message) => { + assert.equal(result.get('id'), 'bar'); + assert.equal(message.get('id'), 'baz'); + }; + + subject.set('queryManager', { addSegment: mockAddSegment }); + subject.set('querier', { send: mockSend }); + subject.set('transitionTo', mockTransitionTo); + subject.submitQuery(EmberObject.create({ id: 'foo' }), EmberObject.create({ id: 'bar' }), subject); + assert.equal(subject.get('savedResult.id'), 'bar'); + }); + + test('it can late submit a query using a saved result and attach the appropriate handlers', function(assert) { + assert.expect(5); + + let QueryableObject = EmberObject.extend(QueryableMixin); + let subject = QueryableObject.create(); + + let mockTransitionTo = () => { + // Will be called once on error + assert.ok(true); + }; + let mockSend = (query, handlers, context) => { + assert.equal(query.get('id'), 'foo'); + handlers.success(context); + handlers.error('Mocked Test Error', context); + handlers.message(EmberObject.create({ id: 'baz' }), context); + }; + let mockAddSegment = (result, message) => { + assert.equal(result.get('id'), 'bar'); + assert.equal(message.get('id'), 'baz'); + }; + + subject.set('queryManager', { addSegment: mockAddSegment }); + subject.set('querier', { send: mockSend }); + subject.set('transitionTo', mockTransitionTo); + subject.set('savedResult', EmberObject.create({ id: 'bar' })); + subject.lateSubmitQuery(EmberObject.create({ id: 'foo' }), subject); + // Make sure result is still the same + assert.equal(subject.get('savedResult.id'), 'bar'); + }); +}); diff --git a/tests/unit/models/segment-test.js b/tests/unit/models/segment-test.js deleted file mode 100644 index 77ef9da8..00000000 --- a/tests/unit/models/segment-test.js +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the terms of the Apache License, Version 2.0. - * See the LICENSE file associated with the project for terms. - */ -import { run } from '@ember/runloop'; -import { isPresent } from '@ember/utils'; -import { module, test } from 'qunit'; -import { setupTest } from 'ember-qunit'; - -module('Unit | Model | segment', function(hooks) { - setupTest(hooks); - - test('it sets its default values right', function(assert) { - let now = parseInt(Date.now()); - let model = run(() => this.owner.lookup('service:store').createRecord('segment')); - let created = model.get('created'); - assert.equal(Object.keys(model.get('metadata')).length, 0); - assert.equal(model.get('records').length, 0); - assert.ok(isPresent(created)); - assert.ok(parseInt(created.getTime()) >= now); - }); -}); diff --git a/tests/unit/models/window-test.js b/tests/unit/models/window-test.js index 4f6ba66d..348d2434 100644 --- a/tests/unit/models/window-test.js +++ b/tests/unit/models/window-test.js @@ -24,6 +24,6 @@ module('Unit | Model | window', function(hooks) { }); test('it maps the api types for the include types properly', function(assert) { - assert.equal(INCLUDE_TYPES.apiKey('Everything from Start'), 'ALL'); + assert.equal(INCLUDE_TYPES.apiKey('Everything from Start of Query'), 'ALL'); }); }); diff --git a/tests/unit/services/querier-test.js b/tests/unit/services/querier-test.js index 841e4ff6..55830859 100644 --- a/tests/unit/services/querier-test.js +++ b/tests/unit/services/querier-test.js @@ -11,6 +11,7 @@ import MockQuery from '../../helpers/mocked-query'; import FILTERS from '../../fixtures/filters'; import { AGGREGATIONS, DISTRIBUTIONS } from 'bullet-ui/models/aggregation'; import { METRICS } from 'bullet-ui/models/metric'; +import { EMIT_TYPES, INCLUDE_TYPES } from 'bullet-ui/models/window'; module('Unit | Service | querier', function(hooks) { setupTest(hooks); @@ -404,6 +405,42 @@ module('Unit | Service | querier', function(hooks) { }); }); + test('it formats a time window correctly', function(assert) { + let service = this.owner.lookup('service:querier'); + let query = MockQuery.create({ duration: 10 }); + query.addAggregation(AGGREGATIONS.get('RAW'), 10); + query.addWindow(EMIT_TYPES.get('TIME'), 2, null); + assert.deepEqual(service.reformat(query), { + aggregation: { size: 10, type: 'RAW' }, + duration: 10000, + window: { emit: { type: 'TIME', every: 2000 } } + }); + }); + + test('it formats a record window correctly', function(assert) { + let service = this.owner.lookup('service:querier'); + let query = MockQuery.create({ duration: 10 }); + query.addAggregation(AGGREGATIONS.get('RAW'), 10); + query.addWindow(EMIT_TYPES.get('RECORD'), 1, null); + assert.deepEqual(service.reformat(query), { + aggregation: { size: 10, type: 'RAW' }, + duration: 10000, + window: { emit: { type: 'RECORD', every: 1 } } + }); + }); + + test('it formats a include all window correctly', function(assert) { + let service = this.owner.lookup('service:querier'); + let query = MockQuery.create({ duration: 10 }); + query.addAggregation(AGGREGATIONS.get('RAW'), 10); + query.addWindow(EMIT_TYPES.get('TIME'), 2, INCLUDE_TYPES.get('ALL')); + assert.deepEqual(service.reformat(query), { + aggregation: { size: 10, type: 'RAW' }, + duration: 10000, + window: { emit: { type: 'TIME', every: 2000 }, include: { type: 'ALL' } } + }); + }); + test('it recreates a query with a name not created in api mode correctly', function(assert) { let service = this.owner.factoryFor('service:querier').create({ apiMode: false }); let query = { @@ -837,4 +874,46 @@ module('Unit | Service | querier', function(hooks) { duration: 10 }); }); + + test('it recreates a time window correctly', function(assert) { + let service = this.owner.lookup('service:querier'); + let query = { + aggregation: { type: 'RAW', size: 10 }, + window: { emit: { type: 'TIME', every: 5000 } }, + duration: 10000 + }; + assertEmberEqual(assert, service.recreate(query), { + aggregation: { size: 10, type: 'Raw' }, + duration: 10, + window: { emit: { type: 'Time Based', every: 5 }, include: { type: 'Everything in Window' } } + }); + }); + + test('it recreates a record window correctly', function(assert) { + let service = this.owner.lookup('service:querier'); + let query = { + aggregation: { type: 'RAW', size: 10 }, + window: { emit: { type: 'RECORD', every: 1 } }, + duration: 10000 + }; + assertEmberEqual(assert, service.recreate(query), { + aggregation: { size: 10, type: 'Raw' }, + duration: 10, + window: { emit: { type: 'Record Based', every: 1 }, include: { type: 'Everything in Window' } } + }); + }); + + test('it recreates a include all window correctly', function(assert) { + let service = this.owner.lookup('service:querier'); + let query = { + aggregation: { type: 'RAW', size: 10 }, + window: { emit: { type: 'TIME', every: 1000 }, include: { type: 'ALL' } }, + duration: 10000 + }; + assertEmberEqual(assert, service.recreate(query), { + aggregation: { size: 10, type: 'Raw' }, + duration: 10, + window: { emit: { type: 'Time Based', every: 1 }, include: { type: 'Everything from Start of Query' } } + }); + }); }); diff --git a/tests/unit/transforms/window-array-test.js b/tests/unit/transforms/window-array-test.js new file mode 100644 index 00000000..7924f57b --- /dev/null +++ b/tests/unit/transforms/window-array-test.js @@ -0,0 +1,28 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { A } from '@ember/array'; + +module('Unit | Transform | window array', function(hooks) { + setupTest(hooks); + + test('it can deserialize an array to an Ember array', function(assert) { + let transform = this.owner.lookup('transform:window-array'); + let deserialized = transform.get('deserialize')(['foo', 'bar']); + assert.ok(deserialized.get('length'), 2); + assert.ok(deserialized.objectAt(0), 'foo'); + assert.ok(deserialized.objectAt(1), 'bar'); + }); + + test('it can serialize an Ember array to an array', function(assert) { + let transform = this.owner.lookup('transform:window-array'); + let serialized = transform.get('serialize')(A(['foo', 'bar'])); + assert.ok(serialized.length, 2); + assert.ok(serialized[0], 'foo'); + assert.ok(serialized[1], 'bar'); + }); +}); diff --git a/tests/unit/validators/window-emit-frequency-test.js b/tests/unit/validators/window-emit-frequency-test.js index c52bf17d..02ca9c94 100644 --- a/tests/unit/validators/window-emit-frequency-test.js +++ b/tests/unit/validators/window-emit-frequency-test.js @@ -43,7 +43,7 @@ module('Unit | Validator | window-emit-frequency', function(hooks) { } } }); - let expected = 'The maintainer has configured Bullet to support a minimum of 10 for emit frequency'; + let expected = 'The maintainer has configured Bullet to support a minimum of 10s for emit frequency'; assert.equal(validator.validate(9, null, mockModel), expected); assert.ok(validator.validate(10, null, mockModel)); }); diff --git a/yarn.lock b/yarn.lock index 408f0591..e6dc847f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3545,16 +3545,16 @@ ember-concurrency@^0.8.12: ember-cli-babel "^6.8.2" ember-maybe-import-regenerator "^0.1.5" -ember-cp-validations@3.5.1: - version "3.5.1" - resolved "https://registry.yarnpkg.com/ember-cp-validations/-/ember-cp-validations-3.5.1.tgz#55632a87b1db84f3d54bc769c1c07ff241e9f49f" +ember-cp-validations@3.5.3: + version "3.5.3" + resolved "https://registry.yarnpkg.com/ember-cp-validations/-/ember-cp-validations-3.5.3.tgz#059d173e2f0904232516b23c4b774675e672ae11" dependencies: ember-cli-babel "^6.6.0" ember-cli-version-checker "^2.0.0" ember-getowner-polyfill "^2.0.1" - ember-require-module "^0.1.2" + ember-require-module "0.1.3" ember-string-ishtmlsafe-polyfill "^2.0.0" - ember-validators "^1.0.4" + ember-validators "1.0.4" exists-sync "0.0.4" walk-sync "^0.3.1" @@ -3731,6 +3731,15 @@ ember-power-select@1.10.4: ember-text-measurer "^0.4.0" ember-truth-helpers "^2.0.0" +ember-progress-bar@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/ember-progress-bar/-/ember-progress-bar-1.0.0.tgz#5129fdee5efdc69881da567ef6501109de8f8290" + dependencies: + broccoli-funnel "^1.0.1" + ember-cli-babel "^6.6.0" + ember-cli-htmlbars "^2.0.1" + progressbar.js "^1.0.1" + ember-promise-helpers@~1.0.3: version "1.0.6" resolved "https://registry.yarnpkg.com/ember-promise-helpers/-/ember-promise-helpers-1.0.6.tgz#6ff9f451330f4608ec4696de473a7f54bc179236" @@ -3762,18 +3771,12 @@ ember-raf-scheduler@0.1.0: dependencies: ember-cli-babel "^6.6.0" -ember-require-module@^0.1.2: +ember-require-module@0.1.3, ember-require-module@^0.1.2: version "0.1.3" resolved "https://registry.yarnpkg.com/ember-require-module/-/ember-require-module-0.1.3.tgz#f82f60552142179152d28ec97ebd75d967cae1dc" dependencies: ember-cli-babel "^6.9.2" -ember-require-module@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/ember-require-module/-/ember-require-module-0.2.0.tgz#eafe436737ead4762220a9166b78364abf754274" - dependencies: - ember-cli-babel "^6.9.2" - ember-resolver@~4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/ember-resolver/-/ember-resolver-4.0.0.tgz#6c8c91366639010eac739dd1734548219fbd84ac" @@ -3841,9 +3844,9 @@ ember-sinon@~2.1.0: ember-cli-babel "^6.6.0" sinon "^4.4.5" -ember-source@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/ember-source/-/ember-source-3.0.0.tgz#51811cae98d2ceec53bcfbaa876d02b2b5b2159f" +ember-source@~2.18.0: + version "2.18.2" + resolved "https://registry.yarnpkg.com/ember-source/-/ember-source-2.18.2.tgz#75d00eef5488bfe504044b025c752ba924eaf87f" dependencies: broccoli-funnel "^2.0.1" broccoli-merge-trees "^2.0.0" @@ -3852,6 +3855,7 @@ ember-source@~3.0.0: ember-cli-normalize-entity-name "^1.0.0" ember-cli-path-utils "^1.0.0" ember-cli-string-utils "^1.1.0" + ember-cli-test-info "^1.0.0" ember-cli-valid-component-name "^1.0.0" ember-cli-version-checker "^2.1.0" ember-router-generator "^1.2.3" @@ -3895,12 +3899,12 @@ ember-truth-helpers@^2.0.0: dependencies: ember-cli-babel "^6.8.2" -ember-validators@^1.0.4: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ember-validators/-/ember-validators-1.1.1.tgz#34b06f7c4bde4e57c30cb9dfc6566647c1c140c1" +ember-validators@1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/ember-validators/-/ember-validators-1.0.4.tgz#7d89c2511945c52bd20c5384e715138f902133bb" dependencies: ember-cli-babel "^6.9.2" - ember-require-module "^0.2.0" + ember-require-module "^0.1.2" ember-wormhole@^0.5.1, ember-wormhole@^0.5.2: version "0.5.4" @@ -5717,15 +5721,15 @@ istextorbinary@2.1.0: editions "^1.1.1" textextensions "1 || 2" -jQuery-QueryBuilder-Placeholders@yahoo/jQuery-QueryBuilder-Placeholders#v1.0.1: +jQuery-QueryBuilder-Placeholders@bullet-db/jQuery-QueryBuilder-Placeholders#v1.0.1: version "1.0.1" - resolved "https://codeload.github.com/yahoo/jQuery-QueryBuilder-Placeholders/tar.gz/5e79b0b088fab6b429a031f8ae6d810a5fbf6ad5" + resolved "https://codeload.github.com/bullet-db/jQuery-QueryBuilder-Placeholders/tar.gz/5e79b0b088fab6b429a031f8ae6d810a5fbf6ad5" dependencies: jQuery-QueryBuilder "^2.4.0" -jQuery-QueryBuilder-Subfield@yahoo/jQuery-QueryBuilder-Subfield#v1.0.2: +jQuery-QueryBuilder-Subfield@bullet-db/jQuery-QueryBuilder-Subfield#v1.0.2: version "1.0.2" - resolved "https://codeload.github.com/yahoo/jQuery-QueryBuilder-Subfield/tar.gz/029560a1120ac616ba08dd2d184b909396f4ba77" + resolved "https://codeload.github.com/bullet-db/jQuery-QueryBuilder-Subfield/tar.gz/029560a1120ac616ba08dd2d184b909396f4ba77" dependencies: jQuery-QueryBuilder "^2.4.0" @@ -7444,6 +7448,12 @@ progress@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f" +progressbar.js@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/progressbar.js/-/progressbar.js-1.0.1.tgz#f7fbfc195240fe0bb32f6f7bdb2e7ff400ea71f9" + dependencies: + shifty "^1.5.2" + promise-map-series@^0.2.0, promise-map-series@^0.2.1: version "0.2.3" resolved "https://registry.yarnpkg.com/promise-map-series/-/promise-map-series-0.2.3.tgz#c2d377afc93253f6bd03dbb77755eb88ab20a847" @@ -8215,6 +8225,10 @@ shellwords@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" +shifty@^1.5.2: + version "1.5.4" + resolved "https://registry.yarnpkg.com/shifty/-/shifty-1.5.4.tgz#d4362fc914dd280ddf6e522be408b21203208346" + signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"