AMBARI-22615 Log Search UI: improve histogram display. (Istvan Tobias via ababiichuk)
Project: http://git-wip-us.apache.org/repos/asf/ambari/repo Commit: http://git-wip-us.apache.org/repos/asf/ambari/commit/a6a8e220 Tree: http://git-wip-us.apache.org/repos/asf/ambari/tree/a6a8e220 Diff: http://git-wip-us.apache.org/repos/asf/ambari/diff/a6a8e220 Branch: refs/heads/branch-feature-AMBARI-22008-isilon Commit: a6a8e2209eb410785447063d9aa218383f8d222d Parents: d6c4a4f Author: Istvan Tobias <tobias.ist...@gmail.com> Authored: Fri Dec 8 13:32:20 2017 +0200 Committer: Attila Magyar <amag...@hortonworks.com> Committed: Tue Dec 12 16:13:58 2017 +0100 ---------------------------------------------------------------------- .../src/app/classes/histogram-options.ts | 3 +- .../src/app/components/mixins.less | 18 +- .../time-histogram.component.html | 29 ++ .../time-histogram.component.less | 162 +++++++- .../time-histogram.component.spec.ts | 138 ++++++- .../time-histogram/time-histogram.component.ts | 377 ++++++++++++++++--- .../src/app/services/logs-container.service.ts | 4 +- .../src/assets/i18n/en.json | 13 +- 8 files changed, 669 insertions(+), 75 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/ambari/blob/a6a8e220/ambari-logsearch/ambari-logsearch-web/src/app/classes/histogram-options.ts ---------------------------------------------------------------------- diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/classes/histogram-options.ts b/ambari-logsearch/ambari-logsearch-web/src/app/classes/histogram-options.ts index dee5d98..15fefde 100644 --- a/ambari-logsearch/ambari-logsearch-web/src/app/classes/histogram-options.ts +++ b/ambari-logsearch/ambari-logsearch-web/src/app/classes/histogram-options.ts @@ -27,8 +27,7 @@ export interface HistogramStyleOptions { margin?: HistogramMarginOptions; height?: number; tickPadding?: number; - columnWidth?: number; - dragAreaColor?: string; + columnWidth?: {[key:string]: number}; } export interface HistogramOptions extends HistogramStyleOptions { http://git-wip-us.apache.org/repos/asf/ambari/blob/a6a8e220/ambari-logsearch/ambari-logsearch-web/src/app/components/mixins.less ---------------------------------------------------------------------- diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/components/mixins.less b/ambari-logsearch/ambari-logsearch-web/src/app/components/mixins.less index a6e5616..890887a 100644 --- a/ambari-logsearch/ambari-logsearch-web/src/app/components/mixins.less +++ b/ambari-logsearch/ambari-logsearch-web/src/app/components/mixins.less @@ -203,25 +203,11 @@ display: inline-block; width: 0; height: 0; - margin-left: @caret-width * .85; vertical-align: @caret-width * .85; content: ""; .caret-direction(@caret-width, @direction, @color); } // This is the main caret mixin to create the common and the direction related css -.caret(@caret-width; @direction: down; @color: @base-font-color; @position: before) { - - &::before when (@position = before) { - .caret-style(@caret-width, @direction, @color); - } - &::after when (@position = after) { - .caret-style(@caret-width, @direction, @color); - } - - &:empty::before when (@position = before) { - margin-left: 0; - } - &:empty::after when (@position = after) { - margin-left: 0; - } +.caret-mixin(@caret-width; @direction: down; @color: @base-font-color; @position: before) { + .caret-style(@caret-width, @direction, @color); } http://git-wip-us.apache.org/repos/asf/ambari/blob/a6a8e220/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.html ---------------------------------------------------------------------- diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.html b/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.html index 299e46e..1193b2e 100644 --- a/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.html +++ b/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.html @@ -15,4 +15,33 @@ limitations under the License. --> +<header> + <div class="container-fluid"> + <div class="row"> + <div *ngIf="chartTimeGap" class="time-gap col-lg-2 col-md-offset-5"> + {{chartTimeGap.value}} {{chartTimeGap.label | translate}} {{'histogram.gap' | translate}} + </div> + <div class="legends col-md-5" [class.md-offset-7]="!chartTimeGap"> + <div *ngFor="let legend of legends" class="legend {{legend.level | lowercase}}"> + {{ legend.label | translate }} + </div> + </div> + </div> + </div> +</header> <div #container></div> +<footer *ngIf="firstDateTick || lastDateTick"> + <div *ngIf="firstDateTick" class="first-date-tick-label">{{firstDateTick | amTz:timeZone | amDateFormat:historyStartEndTimeFormat}}</div> + <div *ngIf="lastDateTick" class="last-date-tick-label">{{lastDateTick | amTz:timeZone | amDateFormat:historyStartEndTimeFormat}}</div> +</footer> +<div [ngClass]="{hide: !tooltipInfo, 'tooltip-left': tooltipOnTheLeft, 'tooltip-chart': true}" #tooltipEl + [style.top]="tooltipInfo ? (tooltipPosition.top + 'px') : ''" [style.left]="tooltipInfo ? (tooltipPosition.left + 'px') : ''"> + <ng-container *ngIf="tooltipInfo"> + <div class="tooltip-chart-date">{{tooltipInfo.timeStamp | amTz:timeZone | amDateFormat:tickTimeFormat}}</div> + <div *ngFor="let data of tooltipInfo.data" class="level {{data.level | lowercase}}"> + <span class="level-label">{{data.levelLabel | translate }}</span> + <span class="level-value">{{data.value}}</span> + </div> + </ng-container> +</div> + http://git-wip-us.apache.org/repos/asf/ambari/blob/a6a8e220/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.less ---------------------------------------------------------------------- diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.less b/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.less index e8d3240..1d3766d 100644 --- a/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.less +++ b/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.less @@ -16,25 +16,179 @@ * limitations under the License. */ +@import '../mixins'; + :host { - display: block; - cursor: crosshair; + position: relative; + .level-mixin(@level, @size: .8em) { + @name: "@{level}-color"; + border-radius: 100%; + content: ""; + display: inline-block; + height: .8em; + width: .8em; + background-color: @@name; + } + background: #ECECEC; // TODO add style according to actual design + display: block; + /deep/ .axis { .domain { display: none; } - .tick { cursor: default; - line { display: none; } } } + /deep/ svg { + cursor: crosshair; + } + /deep/ .value { cursor: pointer; + rect { + transition: opacity 250ms; + opacity: .8; + &:hover { + opacity: 1; + } + } + } + + /deep/ .tooltip-chart { + background: #fff; + border-radius: 4px; + border: @input-border; + display: block; + font-size: .8em; + margin: 0 1.5em; + min-height: 2em; + min-width: 5em; + padding: .5em; + position: absolute; + &:empty { + display: none; + } + &::before { + .caret-mixin(6px, left, #fff); + left: -6px; + position: absolute; + top: calc(50% - 2px); + } + &.tooltip-left::before { + display: none; + } + &.tooltip-left::after { + .caret-mixin(6px, right, #fff); + right: -6px; + position: absolute; + top: calc(50% - 2px); + } + .tooltip-chart-date { + padding: 0 0 .1em 0; + text-align: center; + } + .level { + display: flex; + &::before { + margin: auto .2em auto 0; + } + .level-label { + flex-grow: 3; + padding: 0 2em 0 0; + } + .level-value { + text-align: right; + } + } + + .fatal::before { + .level-mixin('fatal'); + } + .error::before { + .level-mixin('error'); + } + .warn::before { + .level-mixin('warning'); + } + .info::before { + .level-mixin('info'); + } + .trace::before { + .level-mixin('trace'); + } + .debug::before { + .level-mixin('debug'); + } + .unknown::before { + .level-mixin('unknown'); + } + } + header { + padding: .5rem; } + .legends { + text-align: right; + .legend { + display: inline-block; + font-size: 1rem; + text-transform: uppercase; + padding-right: 1em; + } + .fatal::before { + .level-mixin('fatal'); + } + .error::before { + .level-mixin('error'); + } + .warn::before { + .level-mixin('warning'); + } + .info::before { + .level-mixin('info'); + } + .trace::before { + .level-mixin('trace'); + } + .debug::before { + .level-mixin('debug'); + } + .unknown::before { + .level-mixin('unknown'); + } + } + + .time-gap { + color: #666; + font-size: 1.2rem; + text-align: center; + } + + footer { + display: flex; + div { + color: #666; + flex-grow: 1; + font-size: 1.2rem; + padding: 0 1em .5em 1em; + } + .last-date-tick-label { + text-align: right; + } + } + + /deep/ rect.drag-area { + fill: #fff; + } + + /deep/ rect.unselected-drag-area { + fill: darken(@main-background-color, 10%); + opacity: .6; + } + } http://git-wip-us.apache.org/repos/asf/ambari/blob/a6a8e220/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.spec.ts ---------------------------------------------------------------------- diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.spec.ts b/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.spec.ts index 9e056be..ee14780 100644 --- a/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.spec.ts +++ b/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.spec.ts @@ -19,35 +19,157 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {StoreModule} from '@ngrx/store'; import {AppSettingsService, appSettings} from '@app/services/storage/app-settings.service'; +import {TranslationModules} from '@app/test-config.spec'; +import {MomentModule} from 'angular2-moment'; +import {MomentTimezoneModule} from 'angular-moment-timezone'; +import {TimeZoneAbbrPipe} from '@app/pipes/timezone-abbr.pipe'; +import {ServiceLogsHistogramDataService} from '@app/services/storage/service-logs-histogram-data.service'; import {TimeHistogramComponent} from './time-histogram.component'; +import {LogsContainerService} from '@app/services/logs-container.service'; +import {HttpClientService} from "@app/services/http-client.service"; +import {AppStateService} from "@app/services/storage/app-state.service"; +import {AuditLogsService} from "@app/services/storage/audit-logs.service"; +import {AuditLogsFieldsService} from "@app/services/storage/audit-logs-fields.service"; +import {ServiceLogsService} from "@app/services/storage/service-logs.service"; +import {ServiceLogsFieldsService} from "@app/services/storage/service-logs-fields.service"; +import {ServiceLogsTruncatedService} from "@app/services/storage/service-logs-truncated.service"; +import {TabsService} from "@app/services/storage/tabs.service"; +import {ClustersService} from "@app/services/storage/clusters.service"; +import {ComponentsService} from "@app/services/storage/components.service"; +import {HostsService} from "@app/services/storage/hosts.service"; describe('TimeHistogramComponent', () => { let component: TimeHistogramComponent; let fixture: ComponentFixture<TimeHistogramComponent>; + let histogramData: any; + let customOptions: any; beforeEach(async(() => { + const httpClient = { + get: () => { + return { + subscribe: () => {} + } + } + }; + histogramData = { + "1512476481940": { + "FATAL": 0, + "ERROR": 1000, + "WARN": 700, + "INFO": 0, + "DEBUG": 0, + "TRACE": 0, + "UNKNOWN": 0 + }, "1512472881940": {"FATAL": 0, "ERROR": 2000, "WARN": 900, "INFO": 0, "DEBUG": 0, "TRACE": 0, "UNKNOWN": 0} + }; + customOptions = { + keysWithColors: { + FATAL: '#830A0A', + ERROR: '#E81D1D', + WARN: '#FF8916', + INFO: '#2577B5', + DEBUG: '#65E8FF', + TRACE: '#888', + UNKNOWN: '#BDBDBD' + } + }; TestBed.configureTestingModule({ - declarations: [TimeHistogramComponent], + declarations: [TimeHistogramComponent, TimeZoneAbbrPipe], imports: [ StoreModule.provideStore({ appSettings - }) + }), + ...TranslationModules, + MomentModule, + MomentTimezoneModule ], providers: [ - AppSettingsService + AppSettingsService, + ServiceLogsHistogramDataService, + LogsContainerService, + { + provide: HttpClientService, + useValue: httpClient + }, + AppStateService, + AuditLogsService, + AuditLogsFieldsService, + ServiceLogsService, + ServiceLogsFieldsService, + ServiceLogsHistogramDataService, + ServiceLogsTruncatedService, + TabsService, + ClustersService, + ComponentsService, + HostsService ] }) - .compileComponents(); + .compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(TimeHistogramComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); + fixture = TestBed.createComponent(TimeHistogramComponent); + component = fixture.componentInstance; + component.customOptions = customOptions; + component.svgId = "HistogramSvg"; + component.data = histogramData; + fixture.detectChanges(); + }); it('should create component', () => { expect(component).toBeTruthy(); }); + + const getTimeGapTestCases = [{ + startDate: new Date(2017, 0, 1), + endDate: new Date(2017, 0, 8), + expected: { + unit: 'week', + value: 1, + label: 'histogram.gap.week' + } + }, { + startDate: new Date(2017, 0, 1), + endDate: new Date(2017, 0, 2), + expected: { + unit: 'day', + value: 1, + label: 'histogram.gap.day' + } + }, { + startDate: new Date(2017, 0, 1, 1), + endDate: new Date(2017, 0, 1, 2), + expected: { + unit: 'hour', + value: 1, + label: 'histogram.gap.hour' + } + }, { + startDate: new Date(2017, 0, 1, 1, 1), + endDate: new Date(2017, 0, 1, 1, 2), + expected: { + unit: 'minute', + value: 1, + label: 'histogram.gap.minute' + } + }, { + startDate: new Date(2017, 0, 1, 1, 1, 1), + endDate: new Date(2017, 0, 1, 1, 1, 11), + expected: { + unit: 'second', + value: 10, + label: 'histogram.gap.seconds' + } + }]; + + getTimeGapTestCases.forEach((test) => { + it(`should the getTimeGap return with the proper time gap obj for ${test.expected.value} ${test.expected.unit} difference`, () => { + const getTimeGap: (startDate: Date, endDate: Date) => {value: number, unit: string} = component['getTimeGap']; + const gap = getTimeGap(test.startDate, test.endDate); + expect(gap).toEqual(test.expected); + }); + }); + }); http://git-wip-us.apache.org/repos/asf/ambari/blob/a6a8e220/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.ts ---------------------------------------------------------------------- diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.ts b/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.ts index e255166..fb3092f 100644 --- a/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.ts +++ b/ambari-logsearch/ambari-logsearch-web/src/app/components/time-histogram/time-histogram.component.ts @@ -30,19 +30,19 @@ import {HistogramStyleOptions, HistogramOptions} from '@app/classes/histogram-op }) export class TimeHistogramComponent implements OnInit, AfterViewInit, OnChanges { - constructor(private appSettings: AppSettingsService) { - appSettings.getParameter('timeZone').subscribe((value: string): void => { + constructor(private appSettings: AppSettingsService) {} + + ngOnInit() { + this.appSettings.getParameter('timeZone').subscribe((value: string): void => { this.timeZone = value; this.createHistogram(); }); - } - - ngOnInit() { this.options = Object.assign({}, this.defaultOptions, this.customOptions); } ngAfterViewInit() { this.htmlElement = this.element.nativeElement; + this.tooltipElement = this.tooltipEl.nativeElement; this.host = d3.select(this.htmlElement); } @@ -53,6 +53,9 @@ export class TimeHistogramComponent implements OnInit, AfterViewInit, OnChanges @ViewChild('container') element: ElementRef; + @ViewChild('tooltipEl') + tooltipEl: ElementRef; + @Input() svgId: string; @@ -67,15 +70,20 @@ export class TimeHistogramComponent implements OnInit, AfterViewInit, OnChanges private readonly defaultOptions: HistogramStyleOptions = { margin: { - top: 20, - right: 20, - bottom: 40, + top: 5, + right: 50, + bottom: 30, left: 50 }, - height: 200, + height: 150, tickPadding: 10, - columnWidth: 20, - dragAreaColor: '#FFF' + columnWidth: { + second: 40, + minute: 30, + hour: 25, + day: 20, + base: 20 + } }; private options: HistogramOptions; @@ -99,6 +107,7 @@ export class TimeHistogramComponent implements OnInit, AfterViewInit, OnChanges private yAxis; private htmlElement: HTMLElement; + private tooltipElement: HTMLElement; private dragArea: Selection<SVGGraphicsElement, undefined, SVGGraphicsElement, undefined>; @@ -108,10 +117,68 @@ export class TimeHistogramComponent implements OnInit, AfterViewInit, OnChanges private maxDragX: number; - private readonly timeFormat: string = 'MM/DD HH:mm'; + private readonly tickTimeFormat: string = 'MM/DD HH:mm'; + private readonly historyStartEndTimeFormat = 'dddd, MMMM DD, YYYY'; histogram: any; + /** + * This property is to hold the data of the bar where the mouse is over. + */ + private tooltipInfo: {data: object, timeStamp: number}; + /** + * This is the computed position of the tooltip relative to the @htmlElement which is the container of the histogram. + * It is set when the mousemoving over the bars in the @handleRectMouseMove method. + */ + private tooltipPosition: {top: number, left: number}; + /** + * This property indicates if the tooltip should be positioned on the left side of the cursor or not. + * It should be true when the tooltip is out from the window. + * @type {boolean} + */ + private tooltipOnTheLeft: boolean = false; + /** + * This property holds the data structure describing the gaps between the xAxis ticks. + * The unit property can be: second, minute, hour, day + * The value is the number of the given unit. + */ + private chartTimeGap: {value: number, unit: string, label: string} | null; + /** + * This is the rectangle element to represent the unselected time range on the left side of the selected time range + */ + private leftDragArea: Selection<SVGGraphicsElement, undefined, SVGGraphicsElement, undefined>; + /** + * This is the rectangle element to represent the unselected time range on the right side of the selected time range + */ + private rightDragArea: Selection<SVGGraphicsElement, undefined, SVGGraphicsElement, undefined>; + /** + * This is a Date object holding the value of the first tick of the xAxis. It is a helper getter for the template. + */ + private get firstDateTick(): Date | undefined { + const ticks = this.xScale && this.xScale.ticks(); + return (ticks && ticks.length && ticks[0]) || undefined; + } + /** + * This is a Date object holding the value of the last tick of the xAxis. It is a helper getter for the template. + */ + private get lastDateTick(): Date | undefined { + const ticks = this.xScale && this.xScale.ticks(); + return (ticks && ticks.length && ticks[ticks.length-1]) || undefined; + } + + /** + * This will return the information about the used levels and the connected colors and labels. + * The goal is to provide an easy property to the template to display the legend of the levels. + * @returns {Array<{level: string; label: string; color: string}>} + */ + private get legends(): Array<{level: string, label: string, color: string}> { + return Object.keys(this.options.keysWithColors).map(level => Object.assign({},{ + level, + label: `levels.${level.toLowerCase()}`, + color: this.options.keysWithColors[level] + })); + } + private createHistogram(): void { if (this.host) { this.setup(); @@ -139,13 +206,21 @@ export class TimeHistogramComponent implements OnInit, AfterViewInit, OnChanges .attr('transform', `translate(${margin.left},${margin.top})`); } + /** + * It draws the svg representation of the x axis. The goal is to set the ticks here, add the axis to the svg element + * and set the position of the axis. + */ private drawXAxis(): void { this.xAxis = d3.axisBottom(this.xScale) - .tickFormat(tick => moment(tick).tz(this.timeZone).format(this.timeFormat)) + .tickFormat(tick => moment(tick).tz(this.timeZone).format(this.tickTimeFormat)) .tickPadding(this.options.tickPadding); - this.svg.append('g').attr('class', 'axis').attr('transform', `translate(0,${this.options.height})`).call(this.xAxis); + this.svg.append('g').attr('class', 'axis axis-x').attr('transform', `translate(0,${this.options.height})`).call(this.xAxis); } + /** + * It draws the svg representation of the y axis. The goal is to set the ticks here, add the axis to the svg element + * and set the position of the axis. + */ private drawYAxis(): void { this.yAxis = d3.axisLeft(this.yScale).tickFormat((tick: number): string | undefined => { if (Number.isInteger(tick)) { @@ -154,30 +229,248 @@ export class TimeHistogramComponent implements OnInit, AfterViewInit, OnChanges return; } }).tickPadding(this.options.tickPadding); - this.svg.append('g').attr('class', 'axis').call(this.yAxis).append('text'); + this.svg.append('g').attr('class', 'axis axis-y').call(this.yAxis).append('text'); + }; + + /** + * The goal is to handle the mouse over event on the rect svg elements so that we can populate the tooltip info object + * and set the initial position of the tooltip. So we call the corresponding methods. + * @param d The data for the currently "selected" bar + * @param {number} index The index of the current element in the selection + * @param elements The selection of the elements + */ + private handleRectMouseOver = (d: any, index: number, elements: any):void => { + this.setTooltipDataFromChartData(d); + this.setTooltipPosition(); + }; + + /** + * The goal is to handle the movement of the mouse over the rect svg elements, so that we can set the position of + * the tooltip by calling the @setTooltipPosition method. + */ + private handleRectMouseMove = ():void => { + this.setTooltipPosition(); + }; + + /** + * The goal is to reset the tooltipInfo object so that the tooltip will be hidden. + */ + private handleRectMouseOut = ():void => { + this.tooltipInfo = null; + }; + + /** + * The goal is set the tooltip + * @param d + */ + private setTooltipDataFromChartData(d: {data: any, [key: string]: any}): void { + let {timeStamp, ...data} = d.data; + let levelColors = this.options.keysWithColors; + this.tooltipInfo = { + data: Object.keys(levelColors).map(key => Object.assign({}, { + level: key, + levelLabel: `levels.${key.toLowerCase()}`, + value: data[key] + })), + timeStamp + }; + } + + /** + * The goal of this function is to set the tooltip position regarding the d3.mouse event relative to the @htmlElement. + * Onlty if we have @tooltipInfo + */ + private setTooltipPosition():void { + if (this.tooltipInfo) { + let tEl = this.tooltipElement; + let pos = d3.mouse(this.htmlElement); + let left = pos[0]; + let top = pos[1] - (tEl.offsetHeight / 2); + let tooltipWidth = tEl.offsetWidth; + let windowSize = window.innerWidth; + if (left + tooltipWidth > windowSize) { + left = pos[0] - (tooltipWidth + 25); + } + this.tooltipOnTheLeft = left < pos[0]; + this.tooltipPosition = {left, top}; + } + }; + + /** + * The goal is to calculate the time gap between the given dates. It will return an object representing the unit and + * the value in the given unit. Eg.: {unit: 'minute', value: 5} + * @param {Date} startDate + * @param {Date} endDate + * @returns {{value: number; unit: string, label: string}} + */ + private getTimeGap(startDate: Date, endDate: Date): {value: number, unit: string, label: string} { + const startDateMoment = moment(startDate); + const endDateMoment = moment(endDate); + const diffInWeek: number = endDateMoment.diff(startDateMoment, 'weeks'); + const diffInDay: number = endDateMoment.diff(startDateMoment, 'days'); + const diffInHour: number = endDateMoment.diff(startDateMoment, 'hours'); + const diffInMin: number = endDateMoment.diff(startDateMoment, 'minutes'); + const diffInSec: number = endDateMoment.diff(startDateMoment, 'seconds'); + const value = diffInWeek >= 1 ? diffInWeek : ( + diffInDay >= 1 ? diffInDay : ( + diffInHour >= 1 ? diffInHour : (diffInMin >= 1 ? diffInMin : diffInSec) + ) + ); + const unit: string = diffInWeek >= 1 ? 'week' : ( + diffInDay >= 1 ? `day` : ( + diffInHour >= 1 ? `hour` : (diffInMin >= 1 ? `minute` : `second`) + ) + ); + const label = `histogram.gap.${unit}${value>1 ? 's' : ''}`; + return { + value, + unit, + label + }; + } + + /** + * The goal is to have a simple function to set the time gap corresponding to the xScale ticks. + * It will reset the time gap if the xScale is not set or there are no ticks. + */ + private setChartTimeGapByXScale() { + let ticks = this.xScale && this.xScale.ticks(); + if (ticks && ticks.length) { + this.setChartTimeGap(ticks[0], ticks[1] || ticks[0]); + } else { + this.resetChartTimeGap(); + } + } + + /** + * Simply reset the time gap property to null. + */ + private resetChartTimeGap(): void { + this.chartTimeGap = null; + } + + /** + * The goal is to have a single point where we set the chartTimeGap property corresponding the given timerange. + * @param {Date} startDate + * @param {Date} endDate + */ + private setChartTimeGap(startDate: Date, endDate: Date): void { + this.chartTimeGap = this.getTimeGap(startDate, endDate); + } + + /** + * Set the domain for the y scale regarding the given data. The maximum value of the data is the sum of the log level + * values. + * An example data: [{timeStamp: 1233455677, WARN: 12, ERROR: 123}] + * @param {Array<{timeStamp: number; [p: string]: number}>} data + */ + private setYScaleDomain(data: Array<{timeStamp: number, [key: string]: number}>): void { + const keys = Object.keys(this.options.keysWithColors); + const maxYValue = d3.max(data, item => keys.reduce((sum: number, key: string): number => sum + item[key], 0)); + this.yScale.domain([0, maxYValue]); + } + + /** + * Set the domain values for the x scale regarding the given data. + * An example data: [{timeStamp: 1233455677, WARN: 12, ERROR: 123}] + * @param {Array<{timeStamp: number; [p: string]: any}>} data + */ + private setXScaleDomain(data: Array<{timeStamp: number, [key: string]: any}>): void { + this.xScale.domain(d3.extent(data, item => item.timeStamp)).nice(); } private populate(): void { - const keys = Object.keys(this.options.keysWithColors), - data = this.data, - timeStamps = Object.keys(data), - formattedData = timeStamps.map((timeStamp: string): {[key: string]: number} => Object.assign({ + const keys = Object.keys(this.options.keysWithColors); + const data = this.data; + const timeStamps = Object.keys(data); + // we create a more consumable data structure for d3 + const formattedData = timeStamps.map((timeStamp: string): {timeStamp: number, [key: string]: number} => Object.assign({ timeStamp: Number(timeStamp) - }, data[timeStamp])), - layers = (d3.stack().keys(keys)(formattedData)), - columnWidth = this.options.columnWidth; - this.xScale.domain(d3.extent(formattedData, item => item.timeStamp)); - this.yScale.domain([0, d3.max(formattedData, item => keys.reduce((sum: number, key: string): number => sum + item[key], 0))]); + }, data[timeStamp])); + const layers = (d3.stack().keys(keys)(formattedData)); + + // after we have the data we set the domain values both scales + this.setXScaleDomain(formattedData); + this.setYScaleDomain(formattedData); + + // Setting the timegap label above the chart + this.setChartTimeGapByXScale(); + + let unitD3TimeProp = this.chartTimeGap.unit.charAt(0).toUpperCase() + this.chartTimeGap.unit.slice(1); + this.xScale.nice(d3[`time${unitD3TimeProp}`], 2); + + let columnWidth = this.options.columnWidth[this.chartTimeGap.unit] || this.options.columnWidth.base; + + // drawing the axis this.drawXAxis(); this.drawYAxis(); - const layer = this.svg.selectAll().data(d3.transpose<any>(layers)).enter().append('g').attr('class', 'value'); - layer.selectAll().data(item => item).enter().append('rect') - .attr('x', item => this.xScale(item.data.timeStamp) - columnWidth / 2).attr('y', item => this.yScale(item[1])) - .attr('height', item => this.yScale(item[0]) - this.yScale(item[1])).attr('width', columnWidth.toString()) - .style('fill', (item, index) => this.colorScale(index)); + + // populate the data and drawing the bars + const layer = this.svg.selectAll('.value').data(d3.transpose<any>(layers)) + .attr('class', 'value') + .enter().append('g') + .attr('class', 'value'); + layer.selectAll('.value rect').data(item => item) + .attr('x', item => this.xScale(item.data.timeStamp) - columnWidth / 2) + .attr('y', item => this.yScale(item[1])) + .attr('height', item => this.yScale(item[0]) - this.yScale(item[1])) + .attr('width', columnWidth.toString()) + .style('fill', (item, index) => this.colorScale(index)) + .enter().append('rect') + .attr('x', item => this.xScale(item.data.timeStamp) - columnWidth / 2) + .attr('y', item => this.yScale(item[1])) + .attr('height', item => this.yScale(item[0]) - this.yScale(item[1])) + .attr('width', columnWidth.toString()) + .style('fill', (item, index) => this.colorScale(index)) + .on('mouseover', this.handleRectMouseOver) + .on('mousemove', this.handleRectMouseMove) + .on('mouseout', this.handleRectMouseOut); this.setDragBehavior(); } + private getTimeRangeByXRanges(startX: number, endX:number): [number, number] { + const xScaleInterval = this.xScale.domain().map((point: Date): number => point.valueOf()); + const xScaleLength = xScaleInterval[1] - xScaleInterval[0]; + const ratio = xScaleLength / this.width; + return [Math.round(xScaleInterval[0] + ratio * startX), Math.round(xScaleInterval[0] + ratio * endX)]; + } + + /** + * The goal is to create the two shadow rectangle beside the selected area. Actually we blurout the not selected + * timeranges + * @param {number} startX This is the starting position of the drag event withing the container + * @param {number} currentX This is the ending point of the drag within the container + */ + private createInvertDragArea(startX: number, currentX: number): void { + const height: number = this.options.height + this.options.margin.top + this.options.margin.bottom; + this.leftDragArea = this.svg.insert('rect').attr('height', height).attr('class', 'unselected-drag-area'); + this.rightDragArea = this.svg.insert('rect').attr('height', height).attr('class', 'unselected-drag-area'); + this.setInvertDragArea(startX, currentX); + } + + /** + * Set the position and the width of the blur/shadow rectangles of the unselected area(s). + * @param {number} startX The start point of the selected area. + * @param {number} currentX The end point of the selected area. + */ + private setInvertDragArea(startX: number, currentX: number): void { + const left: number = Math.min(startX, currentX); + const right: number = Math.max(startX, currentX); + let rightAreaWidth: number = this.width - right; + rightAreaWidth = rightAreaWidth > 0 ? rightAreaWidth : 0; + let leftAreaWidth: number = left > 0 ? left : 0; + this.leftDragArea.attr('x', 0).attr('width', leftAreaWidth); + this.rightDragArea.attr('x', right).attr('width', rightAreaWidth); + } + + /** + * The goal is to have a single point where we remove the rectangles of the blur/shadow, unselected time range(s) + */ + private clearInvertDragArea(): void { + this.leftDragArea.remove(); + this.rightDragArea.remove(); + } + private setDragBehavior(): void { this.minDragX = this.options.margin.left; this.maxDragX = this.htmlElement.clientWidth; @@ -188,25 +481,25 @@ export class TimeHistogramComponent implements OnInit, AfterViewInit, OnChanges } this.dragStartX = Math.max(0, this.getDragX(containers[0]) - this.options.margin.left); this.dragArea = this.svg.insert('rect', ':first-child').attr('x', this.dragStartX).attr('y', 0).attr('width', 0) - .attr('height', this.options.height).style('fill', this.options.dragAreaColor); + .attr('height', this.options.height).attr('class', 'drag-area'); }) .on('drag', (datum: undefined, index: number, containers: ContainerElement[]): void => { - const currentX = Math.max(this.getDragX(containers[0]), this.minDragX) - this.options.margin.left, - startX = Math.min(currentX, this.dragStartX), - currentWidth = Math.abs(currentX - this.dragStartX); + const mousePos = this.getDragX(containers[0]); + const currentX = Math.max(mousePos, this.minDragX) - this.options.margin.left; + const startX = Math.min(currentX, this.dragStartX); + const currentWidth = Math.abs(currentX - this.dragStartX); this.dragArea.attr('x', startX).attr('width', currentWidth); + let timeRange = this.getTimeRangeByXRanges(startX, startX + currentWidth); + this.setChartTimeGap(new Date(timeRange[0]), new Date(timeRange[1])); }) .on('end', (): void => { - const dragAreaDetails = this.dragArea.node().getBBox(), - startX = Math.max(0, dragAreaDetails.x), - endX = Math.min(this.width, dragAreaDetails.x + dragAreaDetails.width), - xScaleInterval = this.xScale.domain().map((point: Date): number => point.valueOf()), - xScaleLength = xScaleInterval[1] - xScaleInterval[0], - ratio = xScaleLength / this.width, - startTimeStamp = Math.round(xScaleInterval[0] + ratio * startX), - endTimeStamp = Math.round(xScaleInterval[0] + ratio * endX); - this.selectArea.emit([startTimeStamp, endTimeStamp]); + const dragAreaDetails = this.dragArea.node().getBBox(); + const startX = Math.max(0, dragAreaDetails.x); + const endX = Math.min(this.width, dragAreaDetails.x + dragAreaDetails.width); + const dateRange: [number, number] = this.getTimeRangeByXRanges(startX, endX); + this.selectArea.emit(dateRange); this.dragArea.remove(); + this.setChartTimeGap(new Date(dateRange[0]), new Date(dateRange[1])); }) ); d3.selectAll(`svg#${this.svgId} .value, svg#${this.svgId} .axis`).call(d3.drag().on('start', (): void => { http://git-wip-us.apache.org/repos/asf/ambari/blob/a6a8e220/ambari-logsearch/ambari-logsearch-web/src/app/services/logs-container.service.ts ---------------------------------------------------------------------- diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/services/logs-container.service.ts b/ambari-logsearch/ambari-logsearch-web/src/app/services/logs-container.service.ts index e754aa4..d719893 100644 --- a/ambari-logsearch/ambari-logsearch-web/src/app/services/logs-container.service.ts +++ b/ambari-logsearch/ambari-logsearch-web/src/app/services/logs-container.service.ts @@ -476,9 +476,9 @@ export class LogsContainerService { }; readonly colors = { - WARN: '#FF8916', - ERROR: '#E81D1D', FATAL: '#830A0A', + ERROR: '#E81D1D', + WARN: '#FF8916', INFO: '#2577B5', DEBUG: '#65E8FF', TRACE: '#888', http://git-wip-us.apache.org/repos/asf/ambari/blob/a6a8e220/ambari-logsearch/ambari-logsearch-web/src/assets/i18n/en.json ---------------------------------------------------------------------- diff --git a/ambari-logsearch/ambari-logsearch-web/src/assets/i18n/en.json b/ambari-logsearch/ambari-logsearch-web/src/assets/i18n/en.json index 6c916aa..3f4f5c8 100644 --- a/ambari-logsearch/ambari-logsearch-web/src/assets/i18n/en.json +++ b/ambari-logsearch/ambari-logsearch-web/src/assets/i18n/en.json @@ -158,5 +158,16 @@ "logs.totalEventFound": "{{totalCount}} events found", "logs.noEventFound": "No event found", "logs.hideGraph": "Hide Graph", - "logs.showGraph": "Show Graph" + "logs.showGraph": "Show Graph", + + "histogram.gap": "gap", + "histogram.gaps": "gaps", + "histogram.gap.second": "second", + "histogram.gap.seconds": "seconds", + "histogram.gap.minute": "minute", + "histogram.gap.minutes": "minutes", + "histogram.gap.hour": "hour", + "histogram.gap.hours": "hours", + "histogram.gap.day": "day", + "histogram.gap.days": "days" }