aboutsummaryrefslogtreecommitdiff
path: root/app/components
diff options
context:
space:
mode:
authorAlex Tatiyants <atatiyan@gmail.com>2016-01-03 17:17:48 -0800
committerAlex Tatiyants <atatiyan@gmail.com>2016-01-03 17:17:48 -0800
commit5310ac7d8eb1838a6297117bc7f9fca70291f46a (patch)
tree28f54b184cb85f04e6d6720dd03258f3728fedde /app/components
initial commit
Diffstat (limited to 'app/components')
-rw-r--r--app/components/app/app.html6
-rw-r--r--app/components/app/app.ts22
-rw-r--r--app/components/plan-list/plan-list.html38
-rw-r--r--app/components/plan-list/plan-list.ts47
-rw-r--r--app/components/plan-new/plan-new.html18
-rw-r--r--app/components/plan-new/plan-new.ts26
-rw-r--r--app/components/plan-node/plan-node.html50
-rw-r--r--app/components/plan-node/plan-node.ts176
-rw-r--r--app/components/plan-view/plan-view.html72
-rw-r--r--app/components/plan-view/plan-view.ts96
10 files changed, 551 insertions, 0 deletions
diff --git a/app/components/app/app.html b/app/components/app/app.html
new file mode 100644
index 0000000..a8a1969
--- /dev/null
+++ b/app/components/app/app.html
@@ -0,0 +1,6 @@
+<router-outlet></router-outlet>
+<footer>PEV is made by
+ <a href="http://tatiyants.com/">Alex Tatiyants</a>
+ <a href="https://twitter.com/AlexTatiyants"><i class="fa fa-twitter"></i></a>
+ <a href="https://github.com/AlexTatiyants"><i class="fa fa-github"></i></a>
+</footer>
diff --git a/app/components/app/app.ts b/app/components/app/app.ts
new file mode 100644
index 0000000..f9c83e0
--- /dev/null
+++ b/app/components/app/app.ts
@@ -0,0 +1,22 @@
+import {Component, ViewEncapsulation} from 'angular2/core';
+import {RouteConfig, ROUTER_DIRECTIVES} from 'angular2/router';
+
+import {PlanView} from '../plan-view/plan-view';
+import {PlanList} from '../plan-list/plan-list';
+import {PlanNew} from '../plan-new/plan-new';
+
+@Component({
+ selector: 'app',
+ templateUrl: './components/app/app.html',
+ encapsulation: ViewEncapsulation.None,
+ directives: [ROUTER_DIRECTIVES]
+})
+
+@RouteConfig([
+ { path: '/', redirectTo: ['/PlanList'] },
+ { path: '/plans', component: PlanList, name: 'PlanList' },
+ { path: '/plans/new', component: PlanNew, name: 'PlanNew' },
+ { path: '/plans/:id', component: PlanView, name: 'PlanView' }
+])
+
+export class App { }
diff --git a/app/components/plan-list/plan-list.html b/app/components/plan-list/plan-list.html
new file mode 100644
index 0000000..a2f1a9d
--- /dev/null
+++ b/app/components/plan-list/plan-list.html
@@ -0,0 +1,38 @@
+<nav>
+ <div class="nav-container">
+ <a class="btn btn-primary btn-lg pull-right" [routerLink]="['/PlanNew']">create</a>
+ <a [routerLink]="['PlanList']">plans</a>
+ </div>
+</nav>
+
+<div class="page">
+ <div class="hero-container" *ngIf="plans.length === 0">
+ Welcome to PEV! Please <a [routerLink]="['/PlanNew']">submit</a> a plan for visualization
+ </div>
+
+ <table class="table">
+ <tr *ngFor="#plan of plans">
+ <td><a [routerLink]="['/PlanView', {id: plan.id}]">{{plan.name}}</a></td>
+ <td>created on {{plan.createdOn | momentDate }}</td>
+ <td class="align-right"><button class="btn btn-danger" (click)="requestDelete()">
+ <i class="fa fa-trash"></i>delete</button>
+ <!-- this is a hack that should be converted to a proper dialog once that is available in angular 2-->
+ <div *ngIf="openDialog">
+ <div class="modal-backdrop"></div>
+
+ <div class="modal">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-body">You're about to delete this plan. Are you sure?</div>
+ <div class="modal-footer">
+ <button class="btn btn-primary" (click)="deletePlan(plan)">Yes</button>
+ <button class="btn btn-default" (click)="cancelDelete()">No</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </td>
+ </tr>
+ </table>
+</div>
diff --git a/app/components/plan-list/plan-list.ts b/app/components/plan-list/plan-list.ts
new file mode 100644
index 0000000..888e2e2
--- /dev/null
+++ b/app/components/plan-list/plan-list.ts
@@ -0,0 +1,47 @@
+import {Component, OnInit} from 'angular2/core';
+import {ROUTER_DIRECTIVES} from 'angular2/router';
+
+import {IPlan} from '../../interfaces/iplan';
+import {PlanService} from '../../services/plan-service';
+import {PlanNew} from '../plan-new/plan-new';
+
+import {MomentDatePipe} from '../../pipes';
+
+@Component({
+ selector: 'plan-list',
+ templateUrl: './components/plan-list/plan-list.html',
+ providers: [PlanService],
+ directives: [ROUTER_DIRECTIVES, PlanNew],
+ pipes: [MomentDatePipe]
+})
+export class PlanList {
+ plans: Array<IPlan>;
+ newPlanName: string;
+ newPlanContent: any;
+ newPlanId: string;
+ openDialog: boolean = false;
+
+ constructor(private _planService: PlanService) { }
+
+ ngOnInit() {
+ this.plans = this._planService.getPlans();
+ }
+
+ requestDelete() {
+ this.openDialog = true;
+ }
+
+ deletePlan(plan) {
+ this.openDialog = false;
+ this._planService.deletePlan(plan);
+ this.plans = this._planService.getPlans();
+ }
+
+ cancelDelete() {
+ this.openDialog = false;
+ }
+
+ deleteAllPlans() {
+ this._planService.deleteAllPlans();
+ }
+}
diff --git a/app/components/plan-new/plan-new.html b/app/components/plan-new/plan-new.html
new file mode 100644
index 0000000..f9babb6
--- /dev/null
+++ b/app/components/plan-new/plan-new.html
@@ -0,0 +1,18 @@
+<nav>
+ <div class="nav-container">
+ <a [routerLink]="['PlanList']">plans</a>
+ <span class="text-muted"> | </span>
+ create plan
+ </div>
+</nav>
+
+<div class="page">
+ <span class="text-muted">For best results, use EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON)</span>
+ <div>
+ <input placeholder="name (optional)" class="input-box input-box-main" type="text" [(ngModel)]="newPlanName">
+ <button class="btn btn-primary btn-lg pull-right" (click)="submitPlan()">submit</button>
+ </div>
+
+ <textarea placeholder="paste execution plan" class="input-box input-box-lg" [(ngModel)]="newPlanContent"></textarea>
+ <textarea placeholder="paste corresponding SQL query" class="input-box input-box-lg" [(ngModel)]="newPlanQuery"></textarea>
+</div>
diff --git a/app/components/plan-new/plan-new.ts b/app/components/plan-new/plan-new.ts
new file mode 100644
index 0000000..4a748ea
--- /dev/null
+++ b/app/components/plan-new/plan-new.ts
@@ -0,0 +1,26 @@
+import {Component, OnInit} from 'angular2/core';
+import {Router, ROUTER_DIRECTIVES} from 'angular2/router';
+import {IPlan} from '../../interfaces/iplan';
+
+import {PlanService} from '../../services/plan-service';
+
+@Component({
+ selector: 'plan-new',
+ templateUrl: './components/plan-new/plan-new.html',
+ providers: [PlanService],
+ directives: [ROUTER_DIRECTIVES]
+})
+export class PlanNew {
+ planIds: string[];
+ newPlanName: string;
+ newPlanContent: string;
+ newPlanQuery: string;
+ newPlan: IPlan;
+
+ constructor( private _router: Router, private _planService: PlanService) { }
+
+ submitPlan() {
+ this.newPlan = this._planService.createPlan(this.newPlanName, this.newPlanContent, this.newPlanQuery);
+ this._router.navigate( ['PlanView', { id: this.newPlan.id }] );
+ }
+}
diff --git a/app/components/plan-node/plan-node.html b/app/components/plan-node/plan-node.html
new file mode 100644
index 0000000..49380a2
--- /dev/null
+++ b/app/components/plan-node/plan-node.html
@@ -0,0 +1,50 @@
+<div class="plan-node">
+ <header (click)="showDetails = !showDetails">
+ <h4>{{node['Node Type'] | uppercase}}</h4>
+ <span class="node-duration">{{duration}}<span class="text-muted">{{durationUnit}} | </span>
+ <strong>{{executionTimePercent}}</strong><span class="text-muted">%</span>
+ </span>
+ </header>
+
+ <div class="relation-name" *ngIf="node['Relation Name']"><span class="text-muted">on </span>
+ <span *ngIf="node['Schema']">{{node['Schema']}}.</span>{{node['Relation Name']}}
+ <span *ngIf="node['Alias']"> ({{node['Alias']}})</span>
+ </div>
+
+ <div class="relation-name" *ngIf="node['Group Key']"><span class="text-muted">by</span> {{node['Group Key']}}</div>
+ <div class="relation-name" *ngIf="node['Sort Key']"><span class="text-muted">by</span> {{node['Sort Key']}}</div>
+ <div class="relation-name" *ngIf="node['Join Type']">{{node['Join Type']}} <span class="text-muted">join</span></div>
+ <div class="relation-name" *ngIf="node['Index Name']"><span class="text-muted">using</span> {{node['Index Name']}}</div>
+
+ <div class="tags" *ngIf="viewOptions.showTags && tags.length > 0">
+ <span *ngFor="#tag of tags">{{tag}}</span>
+ </div>
+
+ <div *ngIf="currentHighlightType !== highlightTypes.NONE">
+ <div class="node-bar-container" [style.width]="(MAX_WIDTH+3)+'px'">
+ <span class="node-bar" [style.width]="width+'px'" [style.backgroundColor]="backgroundColor"></span>
+ </div>
+ <span class="node-bar-label">
+ <span class="text-muted">{{viewOptions.highlightType}}:</span> {{highlightValue | number:'.0-2'}}
+ </span>
+ </div>
+
+ <div class="planner-estimate" *ngIf="viewOptions.showPlannerEstimate">
+ <span *ngIf="plannerRowEstimateDirection === estimateDirections.over"><strong>over</strong> estimated rows</span>
+ <span *ngIf="plannerRowEstimateDirection === estimateDirections.under"><strong>under</strong> estimated rows</span>
+ <span> by <strong>{{plannerRowEstimateValue}}</strong>x</span>
+ </div>
+
+ <table *ngIf="showDetails" class="table prop-list">
+ <tr *ngFor="#prop of props">
+ <td width="40%">{{prop.key}}</td>
+ <td>{{prop.value}}</td>
+ <tr>
+ </table>
+</div>
+
+<ul *ngIf="node.Plans">
+ <li *ngFor="#subNode of node.Plans">
+ <plan-node [node]="subNode" [viewOptions]="viewOptions" [planStats]="planStats"></plan-node>
+ </li>
+</ul>
diff --git a/app/components/plan-node/plan-node.ts b/app/components/plan-node/plan-node.ts
new file mode 100644
index 0000000..3562ea6
--- /dev/null
+++ b/app/components/plan-node/plan-node.ts
@@ -0,0 +1,176 @@
+import {Component, OnInit} from 'angular2/core';
+import {HighlightType, EstimateDirection} from '../../enums';
+import {PlanService} from '../../services/plan-service';
+/// <reference path="lodash.d.ts" />
+
+@Component({
+ selector: 'plan-node',
+ inputs: ['node', 'planStats', 'viewOptions'],
+ templateUrl: './components/plan-node/plan-node.html',
+ directives: [PlanNode],
+ providers: [PlanService]
+})
+
+export class PlanNode {
+ // consts
+ MAX_WIDTH: number = 220;
+ MIN_ESTIMATE_MISS: number = 100;
+ COSTLY_TAG: string = 'costliest';
+ SLOW_TAG: string = 'slowest';
+ LARGE_TAG: string = 'largest';
+ ESTIMATE_TAG: string = 'bad estimate';
+
+ // inputs
+ node: any;
+ planStats: any;
+ viewOptions: any;
+
+ // calculated properties
+ duration: string;
+ durationUnit: string;
+ executionTimePercent: number;
+ backgroundColor: string;
+ highlightValue: number;
+ width: number;
+ props: Array<any>;
+ tags: Array<string>;
+ plannerRowEstimateValue: number;
+ plannerRowEstimateDirection: EstimateDirection;
+ currentHighlightType: string;
+
+ // expose enum to view
+ estimateDirections = EstimateDirection;
+ highlightTypes = HighlightType;
+
+ constructor(private _planService: PlanService) { }
+
+ ngOnInit() {
+ this.currentHighlightType = this.viewOptions.highlightType;
+ this.calculateBar();
+ this.calculateProps();
+ this.calculateDuration();
+ this.calculateTags();
+
+ this.plannerRowEstimateDirection = this.node[this._planService.PLANNER_ESIMATE_DIRECTION];
+ this.plannerRowEstimateValue = _.round(this.node[this._planService.PLANNER_ESTIMATE_FACTOR]);
+ }
+
+ ngDoCheck() {
+ // console.log("check", this.currentHighlightType, this.viewOptions.highlightType);
+ if (this.currentHighlightType !== this.viewOptions.highlightType) {
+ this.currentHighlightType = this.viewOptions.highlightType;
+ this.calculateBar();
+ }
+ }
+
+ calculateBar() {
+ switch (this.currentHighlightType) {
+ case HighlightType.DURATION:
+ this.highlightValue = (this.node[this._planService.ACTUAL_DURATION_PROP]);
+ this.width = Math.round((this.highlightValue / this.planStats.maxDuration) * this.MAX_WIDTH);
+ break;
+ case HighlightType.ROWS:
+ this.highlightValue = (this.node[this._planService.ACTUAL_ROWS_PROP]);
+ this.width = Math.round((this.highlightValue / this.planStats.maxRows) * this.MAX_WIDTH);
+ break;
+ case HighlightType.COST:
+ this.highlightValue = (this.node[this._planService.ACTUAL_COST_PROP]);
+ this.width = Math.round((this.highlightValue / this.planStats.maxCost) * this.MAX_WIDTH);
+ break;
+ }
+
+ if (this.width < 1) { this.width = 1 }
+ this.backgroundColor = this.numberToColorHsl(1 - this.width / this.MAX_WIDTH);
+ }
+
+ calculateDuration() {
+ var dur: number = _.round(this.node[this._planService.ACTUAL_DURATION_PROP]);
+ // convert duration into approriate units
+ if (dur < 1) {
+ this.duration = "<1";
+ this.durationUnit = 'ms';
+ } else if (dur > 1 && dur < 1000) {
+ this.duration = dur.toString();
+ this.durationUnit = 'ms';
+ } else {
+ this.duration = _.round(dur / 1000, 2).toString();
+ this.durationUnit = 'mins';
+ }
+ this.executionTimePercent = (_.round((dur / this.planStats.executionTime) * 100));
+ }
+
+ // create an array of node propeties so that they can be displayed in the view
+ calculateProps() {
+ this.props = _.chain(this.node)
+ .omit('Plans')
+ .map((value, key) => {
+ return { key: key, value: value };
+ })
+ .value();
+ }
+
+ calculateTags() {
+ this.tags = [];
+ if (this.node[this._planService.SLOWEST_NODE_PROP]) {
+ this.tags.push(this.SLOW_TAG);
+ }
+ if (this.node[this._planService.COSTLIEST_NODE_PROP]) {
+ this.tags.push(this.COSTLY_TAG);
+ }
+ if (this.node[this._planService.LARGEST_NODE_PROP]) {
+ this.tags.push(this.LARGE_TAG);
+ }
+ if (this.node[this._planService.PLANNER_ESTIMATE_FACTOR] >= this.MIN_ESTIMATE_MISS) {
+ this.tags.push(this.ESTIMATE_TAG);
+ }
+ }
+
+ /**
+ * http://stackoverflow.com/questions/2353211/hsl-to-rgb-color-conversion
+ *
+ * Converts an HSL color value to RGB. Conversion formula
+ * adapted from http://en.wikipedia.org/wiki/HSL_color_space.
+ * Assumes h, s, and l are contained in the set [0, 1] and
+ * returns r, g, and b in the set [0, 255].
+ *
+ * @param Number h The hue
+ * @param Number s The saturation
+ * @param Number l The lightness
+ * @return Array The RGB representation
+ */
+ hslToRgb(h, s, l) {
+ var r, g, b;
+
+ if (s == 0) {
+ r = g = b = l; // achromatic
+ } else {
+ function hue2rgb(p, q, t) {
+ if (t < 0) t += 1;
+ if (t > 1) t -= 1;
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
+ if (t < 1 / 2) return q;
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
+ return p;
+ }
+
+ var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+ var p = 2 * l - q;
+ r = hue2rgb(p, q, h + 1 / 3);
+ g = hue2rgb(p, q, h);
+ b = hue2rgb(p, q, h - 1 / 3);
+ }
+
+ return [Math.floor(r * 255), Math.floor(g * 255), Math.floor(b * 255)];
+ }
+
+ // convert a number to a color using hsl
+ numberToColorHsl(i) {
+ // as the function expects a value between 0 and 1, and red = 0° and green = 120°
+ // we convert the input to the appropriate hue value
+ var hue = i * 100 * 1.2 / 360;
+ // we convert hsl to rgb (saturation 100%, lightness 50%)
+ var rgb = this.hslToRgb(hue, .9, .4);
+ // we format to css value and return
+ return 'rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ')';
+ }
+}
diff --git a/app/components/plan-view/plan-view.html b/app/components/plan-view/plan-view.html
new file mode 100644
index 0000000..ab9b7dc
--- /dev/null
+++ b/app/components/plan-view/plan-view.html
@@ -0,0 +1,72 @@
+<div class="menu" [class.menu-hidden]="hideMenu">
+ <header>
+ <i class="fa fa-cogs menu-toggle" (click)="hideMenu = !hideMenu"></i>
+ <h3>display options</h3>
+ </header>
+
+ <ul>
+ <li>
+ <input id="showPlanStats" type="checkbox" [(ngModel)]="viewOptions.showPlanStats">
+ <label class="clickable" for="showPlanStats"> show plan stats</label>
+ </li>
+
+ <li>
+ <input id="showPlannerEstimate" type="checkbox" [(ngModel)]="viewOptions.showPlannerEstimate">
+ <label class="clickable" for="showPlannerEstimate"> show planner estimate</label>
+ </li>
+
+ <li>
+ <input id="showTags" type="checkbox" [(ngModel)]="viewOptions.showTags">
+ <label class="clickable" for="showTags"> show analysis tags</label>
+ </li>
+
+ <li>
+ <label>graph metric: </label>
+ <select [(ngModel)]="viewOptions.highlightType">
+ <option value="{{highlightTypes.NONE}}">{{highlightTypes.NONE}}</option>
+ <option value="{{highlightTypes.DURATION}}">{{highlightTypes.DURATION}}</option>
+ <option value="{{highlightTypes.ROWS}}">{{highlightTypes.ROWS}}</option>
+ <option value="{{highlightTypes.COST}}">{{highlightTypes.COST}}</option>
+ </select>
+ </li>
+ </ul>
+</div>
+
+<nav>
+ <div class="nav-container">
+ <a [routerLink]="['PlanList']">plans</a><span class="text-muted"> / </span>{{plan.name}}
+ </div>
+</nav>
+
+<div class="page page-stretch">
+ <div *ngIf="viewOptions.showPlanStats" class="plan-stats">
+ <div>
+ <span class="stat-value">{{executionTime}}</span>
+ <span class="stat-label">execution time ({{executionTimeUnit}})</span>
+ </div>
+ <div *ngIf="planStats.planningTime">
+ <span class="stat-value">{{planStats.planningTime | number:'.0-2'}}</span>
+ <span class="stat-label">planning time (ms)</span>
+ </div>
+ <div *ngIf="planStats.maxDuration">
+ <span class="stat-value">{{planStats.maxDuration | number:'.0-2'}}</span>
+ <span class="stat-label">slowest node (ms)</span>
+ </div>
+ <div *ngIf="planStats.maxRows">
+ <span class="stat-value">{{planStats.maxRows | number:'.0-2'}}</span>
+ <span class="stat-label">largest node (rows)</span>
+ </div>
+ <div *ngIf="planStats.maxCost">
+ <span class="stat-value">{{planStats.maxCost | number:'.0-2'}}</span>
+ <span class="stat-label">costliest node</span>
+ </div>
+ </div>
+
+ <div class="plan">
+ <ul>
+ <li>
+ <plan-node [node]="rootContainer.Plan" [planStats]="planStats" [viewOptions]="viewOptions"></plan-node>
+ </li>
+ </ul>
+ </div>
+</div>
diff --git a/app/components/plan-view/plan-view.ts b/app/components/plan-view/plan-view.ts
new file mode 100644
index 0000000..ab4e6a3
--- /dev/null
+++ b/app/components/plan-view/plan-view.ts
@@ -0,0 +1,96 @@
+import {Component, OnInit} from 'angular2/core';
+import {RouteParams} from 'angular2/router';
+import {ROUTER_DIRECTIVES} from 'angular2/router';
+
+import {IPlan} from '../../interfaces/iplan';
+import {PlanService} from '../../services/plan-service';
+import {HighlightType} from '../../enums';
+import {PlanNode} from '../plan-node/plan-node';
+
+@Component({
+ selector: 'plan-view',
+ templateUrl: './components/plan-view/plan-view.html',
+ directives: [ROUTER_DIRECTIVES, PlanNode],
+ providers: [PlanService]
+})
+export class PlanView {
+ id: string;
+ plan: IPlan;
+ rootContainer: any;
+ executionTime: string;
+ executionTimeUnit: string;
+ hideMenu: boolean = true;
+
+ planStats: any = {
+ executionTime: 0,
+ maxRows: 0,
+ maxCost: 0,
+ maxDuration: 0
+ };
+
+ viewOptions: any = {
+ showPlanStats: true,
+ showHighlightBar: true,
+ showPlannerEstimate: false,
+ showTags: true,
+ highlightType: HighlightType.NONE
+ };
+
+ showPlannerEstimate: boolean = true;
+ showMenu: boolean = false;
+
+ highlightTypes = HighlightType; // exposing the enum to the view
+
+ constructor(private _planService: PlanService, routeParams: RouteParams) {
+ this.id = routeParams.get('id');
+ }
+
+ getPlan() {
+ if (!this.id) {
+ return;
+ }
+
+ this.plan = this._planService.getPlan(this.id);
+ this.rootContainer = this.plan.content;
+
+ var executionTime: number = this.rootContainer['Execution Time'] || this.rootContainer['Total Runtime'];
+ [this.executionTime, this.executionTimeUnit] = this.calculateDuration(executionTime);
+
+ this.planStats = {
+ executionTime: executionTime,
+ planningTime: this.rootContainer['Planning Time'],
+ maxRows: this.rootContainer[this._planService.MAXIMUM_ROWS_PROP],
+ maxCost: this.rootContainer[this._planService.MAXIMUM_COSTS_PROP],
+ maxDuration: this.rootContainer[this._planService.MAXIMUM_DURATION_PROP]
+ }
+ }
+
+ ngOnInit() {
+ this.getPlan();
+ }
+
+ toggleHighlight(type: HighlightType) {
+ this.viewOptions.highlightType = type;
+ }
+
+ analyzePlan() {
+ this._planService.analyzePlan(this.plan);
+ }
+
+ calculateDuration(originalValue: number) {
+ var duration: string = '';
+ var unit: string = '';
+
+ if (originalValue < 1) {
+ duration = "<1";
+ unit = 'ms';
+ } else if (originalValue > 1 && originalValue < 1000) {
+ duration = originalValue.toString();
+ unit = 'ms';
+ } else {
+ duration = _.round(originalValue / 1000, 2).toString();
+ unit = 'mins';
+ }
+ return [duration, unit];
+ }
+}