@@ -49,4 +49,8 @@ Cypress.Commands.add('fill_field', (fieldname, value, fieldtype='Data') => { | |||
} else { | |||
return cy.get('@input').type(value); | |||
} | |||
}); | |||
}); | |||
Cypress.Commands.add('awesomebar', (text) => { | |||
cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, { delay: 100 }); | |||
}); |
@@ -24,6 +24,7 @@ from frappe.middlewares import StaticDataMiddleware | |||
from frappe.utils.error import make_error_snapshot | |||
from frappe.core.doctype.comment.comment import update_comments_in_parent_after_request | |||
from frappe import _ | |||
import frappe.recorder | |||
local_manager = LocalManager([frappe.local]) | |||
@@ -41,7 +42,6 @@ class RequestContext(object): | |||
def __exit__(self, type, value, traceback): | |||
frappe.destroy() | |||
@Request.application | |||
def application(request): | |||
response = None | |||
@@ -51,6 +51,8 @@ def application(request): | |||
init_request(request) | |||
frappe.recorder.record() | |||
if frappe.local.form_dict.cmd: | |||
response = frappe.handler.handle() | |||
@@ -91,6 +93,8 @@ def application(request): | |||
if response and hasattr(frappe.local, 'cookie_manager'): | |||
frappe.local.cookie_manager.flush_cookies(response=response) | |||
frappe.recorder.dump() | |||
frappe.destroy() | |||
return response | |||
@@ -569,6 +569,23 @@ def browse(context, site): | |||
else: | |||
click.echo("\nSite named \033[1m{}\033[0m doesn't exist\n".format(site)) | |||
@click.command('start-recording') | |||
@pass_context | |||
def start_recording(context): | |||
for site in context.sites: | |||
frappe.init(site=site) | |||
frappe.recorder.start() | |||
@click.command('stop-recording') | |||
@pass_context | |||
def stop_recording(context): | |||
for site in context.sites: | |||
frappe.init(site=site) | |||
frappe.recorder.stop() | |||
commands = [ | |||
add_system_manager, | |||
backup, | |||
@@ -592,5 +609,7 @@ commands = [ | |||
_use, | |||
set_last_active_for_user, | |||
publish_realtime, | |||
browse | |||
browse, | |||
start_recording, | |||
stop_recording, | |||
] |
@@ -0,0 +1,26 @@ | |||
frappe.pages['recorder'].on_page_load = function(wrapper) { | |||
frappe.ui.make_app_page({ | |||
parent: wrapper, | |||
title: 'Recorder', | |||
single_column: true | |||
}); | |||
frappe.recorder = new Recorder(wrapper); | |||
$(wrapper).bind('show', function() { | |||
frappe.recorder.show(); | |||
}); | |||
frappe.require('/assets/js/frappe-recorder.min.js'); | |||
}; | |||
class Recorder { | |||
constructor(wrapper) { | |||
this.wrapper = $(wrapper); | |||
this.container = this.wrapper.find('.layout-main-section'); | |||
this.container.append($('<div class="recorder-container"></div>')); | |||
} | |||
show() { | |||
} | |||
} |
@@ -0,0 +1,23 @@ | |||
{ | |||
"content": null, | |||
"creation": "2019-02-08 08:17:45.392739", | |||
"docstatus": 0, | |||
"doctype": "Page", | |||
"idx": 0, | |||
"modified": "2019-02-08 08:23:04.416426", | |||
"modified_by": "Administrator", | |||
"module": "Core", | |||
"name": "recorder", | |||
"owner": "Administrator", | |||
"page_name": "Recorder", | |||
"roles": [ | |||
{ | |||
"role": "Administrator" | |||
} | |||
], | |||
"script": null, | |||
"standard": "Yes", | |||
"style": null, | |||
"system_page": 0, | |||
"title": "Recorder" | |||
} |
@@ -21,6 +21,9 @@ | |||
"js/frappe-vue.min.js": [ | |||
"public/js/frappe_vue.js" | |||
], | |||
"js/frappe-recorder.min.js": [ | |||
"public/js/frappe/recorder/recorder.js" | |||
], | |||
"js/frappe-web.min.js": [ | |||
"public/js/frappe/class.js", | |||
"public/js/frappe/polyfill.js", | |||
@@ -0,0 +1,251 @@ | |||
<template> | |||
<div> | |||
<div class="frappe-list"> | |||
<div class="list-filters"></div> | |||
<div style="margin-bottom:9px" class="list-toolbar-wrapper hide"> | |||
<div class="list-toolbar btn-group" style="display:inline-block; margin-right: 10px;"></div> | |||
</div> | |||
<div style="clear:both"></div> | |||
<div class="filter-list"> | |||
<div class="tag-filters-area"> | |||
<div class="active-tag-filters"> | |||
<button class="btn btn-default btn-xs add-filter text-muted"> | |||
Add Filter | |||
</button> | |||
</div> | |||
</div> | |||
<div class="filter-edit-area"></div> | |||
<div class="sort-selector"> | |||
<div class="dropdown"><a class="text-muted dropdown-toggle small" data-toggle="dropdown"><span class="dropdown-text">{{ columns.filter(c => c.slug == query.sort)[0].label }}</span></a> | |||
<ul class="dropdown-menu"> | |||
<li v-for="(column, index) in columns.filter(c => c.sortable)" :key="index" @click="query.sort = column.slug"><a class="option">{{ column.label }}</a></li> | |||
</ul> | |||
</div> | |||
<button class="btn btn-default btn-xs btn-order"> | |||
<span class="octicon text-muted" :class="query.order == 'asc' ? 'octicon-arrow-down' : 'octicon-arrow-up'" @click="query.order = (query.order == 'asc') ? 'desc' : 'asc'"></span> | |||
</button> | |||
</div> | |||
</div> | |||
<div v-if="requests.length != 0" class="result"> | |||
<div class="list-headers"> | |||
<header class="level list-row list-row-head text-muted small"> | |||
<div class="level-left list-header-subject"> | |||
<div class="list-row-col ellipsis list-subject level "> | |||
<span class="level-item">{{ columns[0].label }}</span> | |||
</div> | |||
<div class="list-row-col ellipsis hidden-xs" v-for="(column, index) in columns.slice(1)" :key="index" :class="{'text-right': column.number}"> | |||
<span>{{ column.label }}</span> | |||
</div> | |||
</div> | |||
<div class="level-right"> | |||
<span class="list-count"><span>{{ (query.pagination.page - 1) * (query.pagination.limit) + 1 }} - {{ Math.min(query.pagination.page * query.pagination.limit, requests.length) }} of {{ requests.length }}</span></span> | |||
</div> | |||
</header> | |||
</div> | |||
<div class="result-list"> | |||
<div class="list-row-container" v-for="(request, index) in paginated(sorted(filtered(requests)))" :key="index" @click="route_to_request_detail(request.uuid)"> | |||
<div class="level list-row small"> | |||
<div class="level-left ellipsis"> | |||
<div class="list-row-col ellipsis list-subject level "> | |||
<span class="level-item bold"> | |||
{{ request[columns[0].slug] }} | |||
</span> | |||
</div> | |||
<div class="list-row-col ellipsis" v-for="(column, index) in columns.slice(1)" :key="index" :class="{'text-right': column.number}"> | |||
<span class="ellipsis text-muted">{{ request[column.slug] }}</span> | |||
</div> | |||
</div> | |||
<div class="level-right ellipsis"> | |||
<div class="list-row-col ellipsis list-subject level "> | |||
<span class="level-item ellipsis text-muted"> | |||
</span> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
<div v-if="requests.length == 0" class="no-result text-muted flex justify-center align-center" style=""> | |||
<div class="msg-box no-border" v-if="status.status == 'Inactive'" > | |||
<p>Recorder is Inactive</p> | |||
<p><button class="btn btn-primary btn-sm btn-new-doc" @click="start()">Start Recording</button></p> | |||
</div> | |||
<div class="msg-box no-border" v-if="status.status == 'Active'" > | |||
<p>No Requests found</p> | |||
<p>Go make some noise</p> | |||
</div> | |||
</div> | |||
<div v-if="requests.length != 0" class="list-paging-area"> | |||
<div class="row"> | |||
<div class="col-xs-6"> | |||
<div class="btn-group btn-group-paging"> | |||
<button type="button" class="btn btn-default btn-sm" v-for="(limit, index) in [20, 100, 500]" :key="index" :class="query.pagination.limit == limit ? 'btn-info' : ''" @click="query.pagination.limit = limit"> | |||
{{ limit }} | |||
</button> | |||
</div> | |||
</div> | |||
<div class="col-xs-6 text-right"> | |||
<div class="btn-group btn-group-paging"> | |||
<button type="button" class="btn btn-default btn-sm" :class="page.status" v-for="(page, index) in pages" :key="index" @click="query.pagination.page = page.number"> | |||
{{ page.label }} | |||
</button> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</template> | |||
<script> | |||
export default { | |||
name: "RecorderDetail", | |||
data() { | |||
return { | |||
requests: [], | |||
columns: [ | |||
{label: "CMD", slug: "cmd"}, | |||
{label: "Duration (ms)", slug: "duration", sortable: true, number: true}, | |||
{label: "Time in Queries (ms)", slug: "time_queries", sortable: true, number: true}, | |||
{label: "Queries", slug: "queries", sortable: true, number: true}, | |||
{label: "Method", slug: "method"}, | |||
{label: "Time", slug: "time", sortable: true}, | |||
], | |||
query: { | |||
sort: "time", | |||
order: "asc", | |||
filters: {}, | |||
pagination: { | |||
limit: 20, | |||
page: 1, | |||
total: 0, | |||
} | |||
}, | |||
status: { | |||
color: "grey", | |||
status: "Unknown", | |||
}, | |||
}; | |||
}, | |||
created() { | |||
let route = frappe.get_route(); | |||
if (route[2]) { | |||
this.$router.push({name: 'request-detail', params: {id: route[2]}}); | |||
} | |||
}, | |||
mounted() { | |||
this.fetch_status(); | |||
this.refresh(); | |||
this.$root.page.set_secondary_action("Clear", () => { | |||
frappe.set_route("recorder"); | |||
this.clear(); | |||
}); | |||
}, | |||
computed: { | |||
pages: function() { | |||
const current_page = this.query.pagination.page; | |||
const total_pages = this.query.pagination.total; | |||
return [{ | |||
label: "First", | |||
number: 1, | |||
status: (current_page == 1) ? "disabled" : "", | |||
},{ | |||
label: "Previous", | |||
number: Math.max(current_page - 1, 1), | |||
status: (current_page == 1) ? "disabled" : "", | |||
}, { | |||
label: current_page, | |||
number: current_page, | |||
status: "btn-info", | |||
}, { | |||
label: "Next", | |||
number: Math.min(current_page + 1, total_pages), | |||
status: (current_page == total_pages) ? "disabled" : "", | |||
}, { | |||
label: "Last", | |||
number: total_pages, | |||
status: (current_page == total_pages) ? "disabled" : "", | |||
}]; | |||
} | |||
}, | |||
methods: { | |||
filtered: function(requests) { | |||
requests = requests.slice(); | |||
const filters = Object.entries(this.query.filters); | |||
requests = requests.filter( | |||
(r) => filters.map((f) => (r[f[0]] || "").match(f[1])).every(Boolean) | |||
); | |||
this.query.pagination.total = Math.ceil(requests.length / this.query.pagination.limit); | |||
return requests; | |||
}, | |||
paginated: function(requests) { | |||
requests = requests.slice(); | |||
const begin = (this.query.pagination.page - 1) * (this.query.pagination.limit); | |||
const end = begin + this.query.pagination.limit; | |||
return requests.slice(begin, end); | |||
}, | |||
sorted: function(requests) { | |||
requests = requests.slice(); | |||
const order = (this.query.order == "asc") ? 1 : -1; | |||
const sort = this.query.sort; | |||
return requests.sort((a,b) => (a[sort] > b[sort]) ? order : -order); | |||
}, | |||
refresh: function() { | |||
frappe.call("frappe.recorder.get").then( r => this.requests = r.message); | |||
}, | |||
update: function(message) { | |||
this.requests.push(JSON.parse(message)); | |||
}, | |||
clear: function() { | |||
frappe.call("frappe.recorder.delete").then(r => this.refresh()); | |||
}, | |||
start: function() { | |||
frappe.call("frappe.recorder.start").then(r => this.fetch_status()); | |||
}, | |||
stop: function() { | |||
frappe.call("frappe.recorder.stop").then(r => this.fetch_status()); | |||
}, | |||
fetch_status: function() { | |||
frappe.call("frappe.recorder.status").then(r => this.update_status(r.message)); | |||
}, | |||
update_status: function(result) { | |||
if(result) { | |||
this.status = {status: "Active", color: "green"} | |||
} else { | |||
this.status = {status: "Inactive", color: "red"} | |||
} | |||
this.$root.page.set_indicator(this.status.status, this.status.color); | |||
if(this.status.status == "Active") { | |||
frappe.realtime.on("recorder-dump-event", this.update); | |||
} else { | |||
frappe.realtime.off("recorder-dump-event", this.update); | |||
} | |||
this.update_buttons(); | |||
}, | |||
update_buttons: function() { | |||
if(this.status.status == "Active") { | |||
this.$root.page.set_primary_action("Stop", () => { | |||
this.stop(); | |||
}); | |||
} else { | |||
this.$root.page.set_primary_action("Start", () => { | |||
this.start(); | |||
}); | |||
} | |||
}, | |||
route_to_request_detail(id) { | |||
this.$router.push({name: 'request-detail', params: {id}}); | |||
} | |||
} | |||
}; | |||
</script> | |||
<style> | |||
.list-row .level-left { | |||
flex: 8; | |||
width: 100%; | |||
} | |||
</style> |
@@ -0,0 +1,11 @@ | |||
<template> | |||
<div> | |||
<router-view/> | |||
</div> | |||
</template> | |||
<script> | |||
export default { | |||
name: "RecorderRoot", | |||
}; | |||
</script> |
@@ -0,0 +1,284 @@ | |||
<template> | |||
<div> | |||
<div class="row form-section visible-section shaded-section"> | |||
<div class="section-body"> | |||
<div class="form-column col-sm-12"> | |||
<form> | |||
<div class="frappe-control" :data-fieldtype="column.type" v-for="(column, index) in columns" :key="index" :class="column.class"> | |||
<div class="form-group"> | |||
<div class="clearfix"><label class="control-label">{{ column.label }}</label></div> | |||
<div class="control-value like-disabled-input" v-html="column.formatter ? column.formatter(request[column.slug]) : request[column.slug]"></div> | |||
</div> | |||
</div> | |||
</form> | |||
</div> | |||
</div> | |||
</div> | |||
<div class="row form-section visible-section"> | |||
<div class="col-sm-10"> | |||
<h6 class="form-section-heading uppercase">SQL Queries</h6> | |||
</div> | |||
<div class="col-sm-2 filter-list"> | |||
<div class="sort-selector"> | |||
<div class="dropdown"><a class="text-muted dropdown-toggle small" data-toggle="dropdown"><span class="dropdown-text">{{ table_columns.filter(c => c.slug == query.sort)[0].label }}</span></a> | |||
<ul class="dropdown-menu"> | |||
<li v-for="(column, index) in table_columns.filter(c => c.sortable)" :key="index" @click="query.sort = column.slug"><a class="option">{{ column.label }}</a></li> | |||
</ul> | |||
</div> | |||
<button class="btn btn-default btn-xs btn-order"> | |||
<span class="octicon text-muted" :class="query.order == 'asc' ? 'octicon-arrow-down' : 'octicon-arrow-up'" @click="query.order = (query.order == 'asc') ? 'desc' : 'asc'"></span> | |||
</button> | |||
</div> | |||
</div> | |||
<div class="section-body"> | |||
<div class="form-column col-sm-12"> | |||
<form> | |||
<div class="form-group frappe-control input-max-width" data-fieldtype="Check"> | |||
<div class="checkbox"> | |||
<label> | |||
<span class="input-area"><input type="checkbox" class="input-with-feedback bold" data-fieldtype="Check" v-model="group_duplicates"></span> | |||
<span class="label-area small">Group Duplicate Queries</span> | |||
</label> | |||
</div> | |||
</div> | |||
<div class="frappe-control" data-fieldtype="Table"> | |||
<div> | |||
<div class="form-grid"> | |||
<div class="grid-heading-row"> | |||
<div class="grid-row"> | |||
<div class="data-row row"> | |||
<div class="row-index col col-xs-1"> | |||
<span>Index</span></div> | |||
<div class="col grid-static-col col-xs-6"> | |||
<div class="static-area ellipsis">Query</div> | |||
</div> | |||
<div class="col grid-static-col col-xs-2"> | |||
<div class="static-area ellipsis text-right">Duration (ms)</div> | |||
</div> | |||
<div class="col grid-static-col col-xs-2"> | |||
<div class="static-area ellipsis text-right">Exact Copies</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
<div class="grid-body"> | |||
<div class="rows"> | |||
<div class="grid-row" :class="showing == call.index ? 'grid-row-open' : ''" v-for="call in paginated(sorted(grouped(request.calls)))" :key="call.index"> | |||
<div class="data-row row" v-if="showing != call.index" style="display: block;" @click="showing = call.index" > | |||
<div class="row-index col col-xs-1"><span>{{ call.index }}</span></div> | |||
<div class="col grid-static-col col-xs-6" data-fieldtype="Code"> | |||
<div class="static-area"><span>{{ call.query }}</span></div> | |||
</div> | |||
<div class="col grid-static-col col-xs-2"> | |||
<div class="static-area ellipsis text-right">{{ call.duration }}</div> | |||
</div> | |||
<div class="col grid-static-col col-xs-2"> | |||
<div class="static-area ellipsis text-right">{{ call.exact_copies }}</div> | |||
</div> | |||
<div class="col col-xs-1"><a class="close btn-open-row"> | |||
<span class="octicon octicon-triangle-down"></span></a> | |||
</div> | |||
</div> | |||
<div class="form-in-grid"> | |||
<div class="grid-form-heading" @click="showing = null"> | |||
<div class="toolbar grid-header-toolbar"> | |||
<span class="panel-title">SQL Query #<span class="grid-form-row-index">{{ call.index }}</span></span> | |||
<div class="btn btn-default btn-xs pull-right" style="margin-left: 7px;"> | |||
<span class="hidden-xs octicon octicon-triangle-up"></span> | |||
</div> | |||
</div> | |||
</div> | |||
<div class="grid-form-body"> | |||
<div class="form-area"> | |||
<div class="form-layout"> | |||
<div class="form-page"> | |||
<div class="row form-section visible-section"> | |||
<div class="section-body"> | |||
<div class="form-column col-sm-12"> | |||
<form> | |||
<div class="frappe-control"> | |||
<div class="form-group"> | |||
<div class="clearfix"><label class="control-label">Query</label></div> | |||
<div class="control-value like-disabled-input for-description"><pre>{{ call.query }}</pre></div> | |||
</div> | |||
</div> | |||
<div class="frappe-control input-max-width"> | |||
<div class="form-group"> | |||
<div class="clearfix"><label class="control-label">Duration (ms)</label></div> | |||
<div class="control-value like-disabled-input">{{ call.duration }}</div> | |||
</div> | |||
</div> | |||
<div class="frappe-control input-max-width"> | |||
<div class="form-group"> | |||
<div class="clearfix"><label class="control-label">Exact Copies</label></div> | |||
<div class="control-value like-disabled-input">{{ call.exact_copies }}</div> | |||
</div> | |||
</div> | |||
<div class="frappe-control"> | |||
<div class="form-group"> | |||
<div class="clearfix"><label class="control-label">Stack Trace</label></div> | |||
<div class="control-value like-disabled-input for-description"><pre>{{ call.stack }}</pre></div> | |||
</div> | |||
</div> | |||
<div class="frappe-control" v-if="call.explain_result[0]"> | |||
<div class="form-group"> | |||
<div class="clearfix"><label class="control-label">SQL Explain</label></div> | |||
<div class="control-value like-disabled-input for-description" style="overflow:auto"> | |||
<table class="table table-striped"> | |||
<thead> | |||
<tr> | |||
<th v-for="key in Object.keys(call.explain_result[0])" :key="key">{{ key }}</th> | |||
</tr> | |||
</thead> | |||
<tbody> | |||
<tr v-for="(row, index) in call.explain_result" :key="index"> | |||
<td v-for="key in Object.keys(call.explain_result[0])" :key="key">{{ row[key] }}</td> | |||
</tr> | |||
</tbody> | |||
</table> | |||
</div> | |||
</div> | |||
</div> | |||
</form> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
<div v-if="request.calls.length == 0" class="grid-empty text-center">No Data</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</form> | |||
</div> | |||
</div> | |||
<div v-if="request.calls.length != 0" class="list-paging-area" style="border-top: none"> | |||
<div class="row"> | |||
<div class="col-xs-6"> | |||
<div class="btn-group btn-group-paging"> | |||
<button type="button" class="btn btn-default btn-sm" v-for="(limit, index) in [20, 50, 100]" :key="index" :class="query.pagination.limit == limit ? 'btn-info' : ''" @click="query.pagination.limit = limit"> | |||
{{ limit }} | |||
</button> | |||
</div> | |||
</div> | |||
<div class="col-xs-6 text-right"> | |||
<div class="btn-group btn-group-paging"> | |||
<button type="button" class="btn btn-default btn-sm" :class="page.status" v-for="(page, index) in pages" :key="index" @click="query.pagination.page = page.number"> | |||
{{ page.label }} | |||
</button> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</template> | |||
<script> | |||
export default { | |||
name: "RequestDetail", | |||
data() { | |||
return { | |||
columns: [ | |||
{label: "Path", slug: "path", type: "Data", class: "col-sm-6"}, | |||
{label: "CMD", slug: "cmd", type: "Data", class: "col-sm-6"}, | |||
{label: "Time", slug: "time", type: "Time", class: "col-sm-6"}, | |||
{label: "Duration (ms)", slug: "duration", type: "Float", class: "col-sm-6"}, | |||
{label: "Number of Queries", slug: "queries", type: "Int", class: "col-sm-6"}, | |||
{label: "Time in Queries (ms)", slug: "time_queries", type: "Float", class: "col-sm-6"}, | |||
{label: "Request Headers", slug: "headers", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"}, | |||
{label: "Form Dict", slug: "form_dict", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"}, | |||
], | |||
table_columns: [ | |||
{label: "Execution Order", slug: "index", sortable: true}, | |||
{label: "Duration (ms)", slug: "duration", sortable: true}, | |||
{label: "Exact Copies", slug: "exact_copies", sortable: true}, | |||
], | |||
query: { | |||
sort: "index", | |||
order: "asc", | |||
pagination: { | |||
limit: 20, | |||
page: 1, | |||
total: 0, | |||
} | |||
}, | |||
group_duplicates: false, | |||
showing: null, | |||
request: { | |||
calls: [], | |||
}, | |||
}; | |||
}, | |||
computed: { | |||
pages: function() { | |||
const current_page = this.query.pagination.page; | |||
const total_pages = this.query.pagination.total; | |||
return [{ | |||
label: "First", | |||
number: 1, | |||
status: (current_page == 1) ? "disabled" : "", | |||
},{ | |||
label: "Previous", | |||
number: Math.max(current_page - 1, 1), | |||
status: (current_page == 1) ? "disabled" : "", | |||
}, { | |||
label: current_page, | |||
number: current_page, | |||
status: "btn-info", | |||
}, { | |||
label: "Next", | |||
number: Math.min(current_page + 1, total_pages), | |||
status: (current_page == total_pages) ? "disabled" : "", | |||
}, { | |||
label: "Last", | |||
number: total_pages, | |||
status: (current_page == total_pages) ? "disabled" : "", | |||
}]; | |||
} | |||
}, | |||
methods: { | |||
paginated: function(calls) { | |||
calls = calls.slice(); | |||
this.query.pagination.total = Math.ceil(calls.length / this.query.pagination.limit); | |||
const begin = (this.query.pagination.page - 1) * (this.query.pagination.limit); | |||
const end = begin + this.query.pagination.limit; | |||
return calls.slice(begin, end); | |||
}, | |||
sorted: function(calls) { | |||
calls = calls.slice(); | |||
const order = (this.query.order == "asc") ? 1 : -1; | |||
const sort = this.query.sort; | |||
return calls.sort((a,b) => (a[sort] > b[sort]) ? order : -order); | |||
}, | |||
grouped: function(calls) { | |||
if(this.group_duplicates) { | |||
calls = calls.slice(); | |||
return calls.uniqBy(call => call["query"]); | |||
} | |||
return calls | |||
}, | |||
}, | |||
mounted() { | |||
frappe.breadcrumbs.add({ | |||
type: 'Custom', | |||
label: __('Recorder'), | |||
route: '#recorder' | |||
}); | |||
frappe.call({ | |||
method: "frappe.recorder.get", | |||
args: { | |||
uuid: this.$route.params.id | |||
} | |||
}).then( r => { | |||
this.request = r.message | |||
}); | |||
} | |||
}; | |||
</script> |
@@ -0,0 +1,39 @@ | |||
import Vue from 'vue/dist/vue.js'; | |||
import VueRouter from 'vue-router/dist/vue-router.js'; | |||
import RecorderRoot from "./RecorderRoot.vue"; | |||
import RecorderDetail from "./RecorderDetail.vue"; | |||
import RequestDetail from "./RequestDetail.vue"; | |||
Vue.use(VueRouter); | |||
const routes = [ | |||
{ | |||
name: "recorder-detail", | |||
path: '/desk', | |||
component: RecorderDetail, | |||
}, | |||
{ | |||
name: "request-detail", | |||
path: '/request/:id', | |||
component: RequestDetail, | |||
}, | |||
]; | |||
const router = new VueRouter({ | |||
mode: 'history', | |||
base: "/desk#recorder/", | |||
routes: routes, | |||
}); | |||
new Vue({ | |||
el: ".recorder-container", | |||
router: router, | |||
data: { | |||
page: cur_page.page.page | |||
}, | |||
template: "<recorder-root/>", | |||
components: { | |||
RecorderRoot, | |||
} | |||
}); |
@@ -0,0 +1,170 @@ | |||
# -*- coding: utf-8 -*- | |||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors | |||
# MIT License. See license.txt | |||
from __future__ import unicode_literals | |||
from collections import Counter | |||
import datetime | |||
import json | |||
import re | |||
import time | |||
import traceback | |||
import frappe | |||
import sqlparse | |||
RECORDER_INTERCEPT_FLAG = "recorder-intercept" | |||
RECORDER_REQUEST_SPARSE_HASH = "recorder-requests-sparse" | |||
RECORDER_REQUEST_HASH = "recorder-requests" | |||
def sql(*args, **kwargs): | |||
start_time = time.time() | |||
result = frappe.db._sql(*args, **kwargs) | |||
end_time = time.time() | |||
stack_frames = filter(lambda x: "/apps/" in x, traceback.format_stack()[:-1]) | |||
stack_frames = map(lambda x: re.sub("File \".*/apps/", "File \"", x), stack_frames) | |||
stack = "".join(stack_frames) | |||
if frappe.conf.db_type == 'postgres': | |||
query = frappe.db._cursor.query | |||
else: | |||
query = frappe.db._cursor._executed | |||
query = sqlparse.format(query.strip(), keyword_case="upper", reindent=True) | |||
# Collect EXPLAIN for executed query | |||
if query.lower().strip().split()[0] in ("select", "update", "delete"): | |||
# Only SELECT/UPDATE/DELETE queries can be "EXPLAIN"ed | |||
explain_result = frappe.db._sql("EXPLAIN {}".format(query), as_dict=True) | |||
else: | |||
explain_result = [] | |||
data = { | |||
"query": query, | |||
"stack": stack, | |||
"explain_result": explain_result, | |||
"time": start_time, | |||
"duration": float("{:.3f}".format((end_time - start_time) * 1000)), | |||
} | |||
frappe.local._recorder.register(data) | |||
return result | |||
def record(): | |||
if __debug__: | |||
if frappe.cache().get_value(RECORDER_INTERCEPT_FLAG): | |||
frappe.local._recorder = Recorder() | |||
def dump(): | |||
if __debug__: | |||
if hasattr(frappe.local, "_recorder"): | |||
frappe.local._recorder.dump() | |||
class Recorder(): | |||
def __init__(self): | |||
self.uuid = frappe.generate_hash(length=10) | |||
self.time = datetime.datetime.now() | |||
self.calls = [] | |||
self.path = frappe.request.path | |||
self.cmd = frappe.local.form_dict.cmd or "" | |||
self.method = frappe.request.method | |||
self.headers = dict(frappe.local.request.headers) | |||
self.form_dict = frappe.local.form_dict | |||
_patch() | |||
def register(self, data): | |||
self.calls.append(data) | |||
def dump(self): | |||
request_data = { | |||
"uuid": self.uuid, | |||
"path": self.path, | |||
"cmd": self.cmd, | |||
"time": self.time, | |||
"queries": len(self.calls), | |||
"time_queries": float("{:0.3f}".format(sum(call["duration"] for call in self.calls))), | |||
"duration": float("{:0.3f}".format((datetime.datetime.now() - self.time).total_seconds() * 1000)), | |||
"method": self.method, | |||
} | |||
frappe.cache().hset(RECORDER_REQUEST_SPARSE_HASH, self.uuid, request_data) | |||
frappe.publish_realtime(event="recorder-dump-event", message=json.dumps(request_data, default=str)) | |||
self.mark_duplicates() | |||
request_data["calls"] = self.calls | |||
request_data["headers"] = self.headers | |||
request_data["form_dict"] = self.form_dict | |||
frappe.cache().hset(RECORDER_REQUEST_HASH, self.uuid, request_data) | |||
def mark_duplicates(self): | |||
counts = Counter([call["query"] for call in self.calls]) | |||
for index, call in enumerate(self.calls): | |||
call["index"] = index | |||
call["exact_copies"] = counts[call["query"]] | |||
def _patch(): | |||
frappe.db._sql = frappe.db.sql | |||
frappe.db.sql = sql | |||
def do_not_record(function): | |||
def wrapper(*args, **kwargs): | |||
if hasattr(frappe.local, "_recorder"): | |||
del frappe.local._recorder | |||
frappe.db.sql = frappe.db._sql | |||
return function(*args, **kwargs) | |||
return wrapper | |||
def administrator_only(function): | |||
def wrapper(*args, **kwargs): | |||
if frappe.session.user != "Administrator": | |||
frappe.throw(_("Only Administrator is allowed to use Recorder")) | |||
return function(*args, **kwargs) | |||
return wrapper | |||
@frappe.whitelist() | |||
@do_not_record | |||
@administrator_only | |||
def status(*args, **kwargs): | |||
return bool(frappe.cache().get_value(RECORDER_INTERCEPT_FLAG)) | |||
@frappe.whitelist() | |||
@do_not_record | |||
@administrator_only | |||
def start(*args, **kwargs): | |||
frappe.cache().set_value(RECORDER_INTERCEPT_FLAG, 1) | |||
@frappe.whitelist() | |||
@do_not_record | |||
@administrator_only | |||
def stop(*args, **kwargs): | |||
frappe.cache().delete_value(RECORDER_INTERCEPT_FLAG) | |||
@frappe.whitelist() | |||
@do_not_record | |||
@administrator_only | |||
def get(uuid=None, *args, **kwargs): | |||
if uuid: | |||
result = frappe.cache().hget(RECORDER_REQUEST_HASH, uuid) | |||
else: | |||
result = list(frappe.cache().hgetall(RECORDER_REQUEST_SPARSE_HASH).values()) | |||
return result | |||
@frappe.whitelist() | |||
@do_not_record | |||
@administrator_only | |||
def delete(*args, **kwargs): | |||
frappe.cache().delete_value(RECORDER_REQUEST_SPARSE_HASH) | |||
frappe.cache().delete_value(RECORDER_REQUEST_HASH) |
@@ -0,0 +1,121 @@ | |||
# -*- coding: utf-8 -*- | |||
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors | |||
# MIT License. See license.txt | |||
from __future__ import unicode_literals | |||
import unittest | |||
import frappe | |||
import frappe.recorder | |||
from .test_website import set_request | |||
import sqlparse | |||
class TestRecorder(unittest.TestCase): | |||
def setUp(self): | |||
frappe.recorder.stop() | |||
frappe.recorder.delete() | |||
set_request() | |||
frappe.recorder.start() | |||
frappe.recorder.record() | |||
def test_start(self): | |||
frappe.recorder.dump() | |||
requests = frappe.recorder.get() | |||
self.assertEqual(len(requests), 1) | |||
def test_do_not_record(self): | |||
frappe.recorder.do_not_record(frappe.get_all)('DocType') | |||
frappe.recorder.dump() | |||
requests = frappe.recorder.get() | |||
self.assertEqual(len(requests), 0) | |||
def test_get(self): | |||
frappe.recorder.dump() | |||
requests = frappe.recorder.get() | |||
self.assertEqual(len(requests), 1) | |||
request = frappe.recorder.get(requests[0]['uuid']) | |||
self.assertTrue(request) | |||
def test_delete(self): | |||
frappe.recorder.dump() | |||
requests = frappe.recorder.get() | |||
self.assertEqual(len(requests), 1) | |||
frappe.recorder.delete() | |||
requests = frappe.recorder.get() | |||
self.assertEqual(len(requests), 0) | |||
def test_record_without_sql_queries(self): | |||
frappe.recorder.dump() | |||
requests = frappe.recorder.get() | |||
request = frappe.recorder.get(requests[0]['uuid']) | |||
self.assertEqual(len(request['calls']), 0) | |||
def test_record_with_sql_queries(self): | |||
frappe.get_all('DocType') | |||
frappe.recorder.dump() | |||
requests = frappe.recorder.get() | |||
request = frappe.recorder.get(requests[0]['uuid']) | |||
self.assertNotEqual(len(request['calls']), 0) | |||
def test_explain(self): | |||
frappe.db.sql('SELECT * FROM tabDocType') | |||
frappe.db.sql('COMMIT') | |||
frappe.recorder.dump() | |||
requests = frappe.recorder.get() | |||
request = frappe.recorder.get(requests[0]['uuid']) | |||
self.assertEqual(len(request['calls'][0]['explain_result']), 1) | |||
self.assertEqual(len(request['calls'][1]['explain_result']), 0) | |||
def test_multiple_queries(self): | |||
queries = [ | |||
{'mariadb': 'SELECT * FROM tabDocType', 'postgres': 'SELECT * FROM "tabDocType"'}, | |||
{'mariadb': 'SELECT COUNT(*) FROM tabDocType', 'postgres': 'SELECT COUNT(*) FROM "tabDocType"'}, | |||
{'mariadb': 'COMMIT', 'postgres': 'COMMIT'}, | |||
] | |||
sql_dialect = frappe.conf.db_type or 'mariadb' | |||
for query in queries: | |||
frappe.db.sql(query[sql_dialect]) | |||
frappe.recorder.dump() | |||
requests = frappe.recorder.get() | |||
request = frappe.recorder.get(requests[0]['uuid']) | |||
self.assertEqual(len(request['calls']), len(queries)) | |||
for query, call in zip(queries, request['calls']): | |||
self.assertEqual(call['query'], sqlparse.format(query[sql_dialect].strip(), keyword_case='upper', reindent=True)) | |||
def test_duplicate_queries(self): | |||
queries = [ | |||
('SELECT * FROM tabDocType', 2), | |||
('SELECT COUNT(*) FROM tabDocType', 1), | |||
('select * from tabDocType', 2), | |||
('COMMIT', 3), | |||
('COMMIT', 3), | |||
('COMMIT', 3), | |||
] | |||
for query in queries: | |||
frappe.db.sql(query[0]) | |||
frappe.recorder.dump() | |||
requests = frappe.recorder.get() | |||
request = frappe.recorder.get(requests[0]['uuid']) | |||
for query, call in zip(queries, request['calls']): | |||
self.assertEqual(call['exact_copies'], query[1]) |
@@ -35,7 +35,8 @@ | |||
"socket.io": "^2.0.4", | |||
"superagent": "^3.8.2", | |||
"touch": "^3.1.0", | |||
"vue": "^2.5.17" | |||
"vue": "^2.5.17", | |||
"vue-router": "^2.0.0" | |||
}, | |||
"devDependencies": { | |||
"babel-runtime": "^6.26.0", | |||
@@ -50,7 +51,7 @@ | |||
"rollup-plugin-node-resolve": "^3.0.2", | |||
"rollup-plugin-postcss": "^1.4.0", | |||
"rollup-plugin-uglify": "^3.0.0", | |||
"rollup-plugin-vue": "^4.3.2", | |||
"rollup-plugin-vue": "4.2.0", | |||
"vue-template-compiler": "^2.5.17" | |||
} | |||
} |
@@ -59,4 +59,5 @@ coverage | |||
urllib3 | |||
GitPython==2.1.11 | |||
psycopg2==2.7.5 | |||
psycopg2-binary==2.7.5 | |||
psycopg2-binary==2.7.5 | |||
sqlparse==0.2.4 |