Merge pull request #21908 from GursheenK/virtual-doc-for-frappe-recorder

feat: virtual doctype for frappe recorder
This commit is contained in:
Ankush Menat 2023-08-10 21:48:52 +05:30 committed by GitHub
commit b2cc015f23
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 711 additions and 761 deletions

View file

@ -538,7 +538,6 @@
},
{
"default": "0",
"depends_on": "eval:!doc.istable",
"fieldname": "is_virtual",
"fieldtype": "Check",
"label": "Is Virtual"

View file

@ -1542,9 +1542,9 @@ def validate_fields(meta):
return
doctype = docfield.options
meta = frappe.get_meta(doctype)
child_doctype_meta = frappe.get_meta(doctype)
if not meta.istable:
if not child_doctype_meta.istable:
frappe.throw(
_("Option {0} for field {1} is not a child table").format(
frappe.bold(doctype), frappe.bold(docfield.fieldname)
@ -1552,6 +1552,15 @@ def validate_fields(meta):
title=_("Invalid Option"),
)
if not (meta.is_virtual == child_doctype_meta.is_virtual):
error_msg = " should be virtual." if meta.is_virtual else " cannot be virtual."
frappe.throw(
_("Child Table {0} for field {1}" + error_msg).format(
frappe.bold(doctype), frappe.bold(docfield.fieldname)
),
title=_("Invalid Option"),
)
def check_max_height(docfield):
if getattr(docfield, "max_height", None) and (docfield.max_height[-2:] not in ("px", "em")):
frappe.throw(f"Max for {frappe.bold(docfield.fieldname)} height must be in px, em, rem")

View file

@ -567,20 +567,10 @@ class TestDocType(FrappeTestCase):
"options": "Test Virtual DocType as Child Table",
},
)
self.assertRaises(frappe.exceptions.ValidationError, parent_doc.insert)
parent_doc.is_virtual = 1
parent_doc.insert(ignore_permissions=True)
# create entry for parent doctype
parent_doc_entry = frappe.get_doc(
{"doctype": "Test Parent Virtual DocType", "some_fieldname": "Test"}
)
parent_doc_entry.insert(ignore_permissions=True)
# update the parent doc (should not abort because of any DB query to a virtual child table, as there is none)
parent_doc_entry.some_fieldname = "Test update"
parent_doc_entry.save(ignore_permissions=True)
# delete the parent doc (should not abort because of any DB query to a virtual child table, as there is none)
parent_doc_entry.delete()
self.assertFalse(frappe.db.table_exists("Test Parent Virtual DocType"))
def test_default_fieldname(self):
fields = [

View file

@ -0,0 +1,47 @@
// Copyright (c) 2023, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on("Recorder Query", "form_render", function (frm, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
let stack = JSON.parse(row.stack);
render_html_field(stack, "stack_html", "Stack Trace");
let explain_result = JSON.parse(row.explain_result);
render_html_field(explain_result, "sql_explain_html", "SQL Explain");
function render_html_field(parsed_json, fieldname, label) {
let html =
"<div class='clearfix'><label class='control-label'>" + label + "</label></div>";
if (parsed_json.length == 0) {
html += "<label class='control-label'>None</label>";
} else {
html = create_html_table(parsed_json, html);
}
let field_wrapper =
frm.fields_dict[row.parentfield].grid.grid_rows_by_docname[cdn].grid_form.fields_dict[
fieldname
].wrapper;
$(html).appendTo(field_wrapper);
}
function create_html_table(table_content, html) {
html +=
"<div class='control-value like-disabled-input for-description' style='overflow:auto; padding:0px'>";
html += "<table class='table table-striped' style='margin:0px'><thead><tr>";
Object.keys(table_content[0]).forEach((key) => {
html += "<th>" + key + "</th>";
});
html += "</tr></thead><tbody>";
for (let row in table_content) {
html += "<tr>";
Object.values(table_content[row]).forEach((value) => {
html += "<td>" + value + "</td>";
});
html += "</tr>";
}
html += "</tbody></table></div>";
return html;
}
});

View file

@ -0,0 +1,126 @@
{
"actions": [],
"creation": "2023-08-01 12:06:49.630877",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"path",
"number_of_queries",
"time_in_queries",
"method",
"column_break_qo53",
"cmd",
"time",
"duration",
"section_break_1skt",
"request_headers",
"section_break_sgro",
"form_dict",
"section_break_9jhm",
"sql_queries"
],
"fields": [
{
"fieldname": "path",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Path"
},
{
"fieldname": "cmd",
"fieldtype": "Data",
"in_standard_filter": 1,
"label": "CMD"
},
{
"fieldname": "duration",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Duration"
},
{
"fieldname": "time",
"fieldtype": "Datetime",
"in_list_view": 1,
"label": "Time"
},
{
"fieldname": "number_of_queries",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Number of Queries"
},
{
"fieldname": "time_in_queries",
"fieldtype": "Float",
"label": "Time in Queries"
},
{
"fieldname": "column_break_qo53",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_1skt",
"fieldtype": "Section Break"
},
{
"fieldname": "request_headers",
"fieldtype": "Code",
"label": "Request Headers"
},
{
"fieldname": "section_break_sgro",
"fieldtype": "Section Break"
},
{
"fieldname": "form_dict",
"fieldtype": "Code",
"label": "Form Dict"
},
{
"fieldname": "method",
"fieldtype": "Select",
"in_standard_filter": 1,
"label": "Method",
"options": "GET\nPOST\nPUT\nDELETE\nPATCH\nHEAD\nOPTIONS"
},
{
"fieldname": "sql_queries",
"fieldtype": "Table",
"label": "SQL Queries",
"options": "Recorder Query"
},
{
"fieldname": "section_break_9jhm",
"fieldtype": "Section Break"
}
],
"hide_toolbar": 1,
"in_create": 1,
"index_web_pages_for_search": 1,
"is_virtual": 1,
"links": [],
"modified": "2023-08-10 12:01:03.456643",
"modified_by": "Administrator",
"module": "Core",
"name": "Recorder",
"owner": "Administrator",
"permissions": [
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Administrator",
"share": 1
}
],
"sort_field": "duration",
"sort_order": "DESC",
"states": [],
"title_field": "path"
}

View file

@ -0,0 +1,119 @@
# Copyright (c) 2023, Frappe Technologies and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
from frappe.recorder import get as get_recorder_data
from frappe.utils import cint, compare, make_filter_dict
class Recorder(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.core.doctype.recorder_query.recorder_query import RecorderQuery
from frappe.types import DF
cmd: DF.Data | None
duration: DF.Float
form_dict: DF.Code | None
method: DF.Literal["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]
number_of_queries: DF.Int
path: DF.Data | None
request_headers: DF.Code | None
sql_queries: DF.Table[RecorderQuery]
time: DF.Datetime | None
time_in_queries: DF.Float
# end: auto-generated types
def load_from_db(self):
request_data = get_recorder_data(self.name)
if not request_data:
raise frappe.DoesNotExistError
request = serialize_request(request_data)
super(Document, self).__init__(request)
@staticmethod
def get_list(args):
start = cint(args.get("start")) or 0
page_length = cint(args.get("page_length")) or 20
requests = Recorder.get_filtered_requests(args)[start : start + page_length]
if order_by_statment := args.get("order_by"):
if "." in order_by_statment:
order_by_statment = order_by_statment.split(".")[1]
if " " in order_by_statment:
sort_key, sort_order = order_by_statment.split(" ")
else:
sort_key = order_by_statment
sort_order = "desc"
sort_key = sort_key.replace("`", "")
return sorted(requests, key=lambda r: r.get(sort_key) or 0, reverse=bool(sort_order == "desc"))
return sorted(requests, key=lambda r: r.duration, reverse=1)
@staticmethod
def get_count(args):
return len(Recorder.get_filtered_requests(args))
@staticmethod
def get_filtered_requests(args):
filters = make_filter_dict(args.get("filters"))
requests = [serialize_request(request) for request in get_recorder_data()]
return [req for req in requests if _evaluate_filters(req, filters)]
@staticmethod
def get_stats(args):
pass
@staticmethod
def delete(self):
pass
def db_insert(self, *args, **kwargs):
pass
def db_update(self):
pass
def serialize_request(request):
request = frappe._dict(request)
if request.get("calls"):
for i in request.calls:
i["stack"] = frappe.as_json(i["stack"])
i["explain_result"] = frappe.as_json(i["explain_result"])
request.update(
name=request.get("uuid"),
number_of_queries=request.get("queries"),
time_in_queries=request.get("time_queries"),
request_headers=frappe.as_json(request.get("headers"), indent=4),
form_dict=frappe.as_json(request.get("form_dict"), indent=4),
sql_queries=request.get("calls"),
modified=request.get("time"),
creation=request.get("time"),
)
return request
def _evaluate_filters(row, filters) -> bool:
for field in filters:
value = row[field]
operand = filters[field][1]
operator = filters[field][0]
if operator == "like":
operator = "in" # python equivalent.
operand = operand.strip("%")
# Swap because like is "reverse IN"
value, operand = operand, value
if not compare(value, operator, operand):
return False
return True

View file

@ -0,0 +1,110 @@
frappe.listview_settings["Recorder"] = {
hide_name_column: true,
onload(listview) {
listview.page.sidebar.remove();
if (!has_common(frappe.user_roles, ["Administrator", "System Manager"])) return;
if (listview.list_view_settings) {
listview.list_view_settings.disable_comment_count = true;
}
listview.page.add_button(__("Clear"), () => {
frappe.call({
method: "frappe.recorder.delete",
callback: function () {
listview.refresh();
},
});
});
listview.page.add_menu_item(__("Import"), () => {
new frappe.ui.FileUploader({
folder: this.current_folder,
on_success: (file) => {
if (cur_list.data.length > 0) {
// don't replace existing capture
return;
}
frappe.call({
method: "frappe.recorder.import_data",
args: {
file: file.file_url,
},
callback: function () {
listview.refresh();
},
});
},
});
});
listview.page.add_menu_item(__("Export"), () => {
frappe.call({
method: "frappe.recorder.export_data",
callback: function (r) {
const data = r.message;
const filename = `${data[0]["uuid"]}..${data[data.length - 1]["uuid"]}.json`;
const el = document.createElement("a");
el.setAttribute(
"href",
"data:application/json," + encodeURIComponent(JSON.stringify(data))
);
el.setAttribute("download", filename);
el.click();
},
});
});
setInterval(() => {
if (listview.list_view_settings.disable_auto_refresh) {
return;
}
if (!listview.enabled) return;
const route = frappe.get_route() || [];
if (route[0] != "List" || "Recorder" != route[1]) {
return;
}
listview.refresh();
}, 5000);
},
refresh(listview) {
this.fetch_recorder_status(listview).then(() => this.refresh_controls(listview));
},
refresh_controls(listview) {
this.setup_recorder_controls(listview);
this.update_indicators(listview);
},
fetch_recorder_status(listview) {
return frappe.xcall("frappe.recorder.status").then((status) => {
listview.enabled = Boolean(status);
});
},
setup_recorder_controls(listview) {
listview.page.set_primary_action(listview.enabled ? __("Stop") : __("Start"), () => {
frappe.call({
method: listview.enabled ? "frappe.recorder.stop" : "frappe.recorder.start",
callback: function () {
listview.refresh();
},
});
listview.enabled = !listview.enabled;
this.refresh_controls(listview);
});
},
update_indicators(listview) {
if (listview.enabled) {
listview.page.set_indicator(__("Active"), "green");
} else {
listview.page.set_indicator(__("Inactive"), "red");
}
},
};

View file

@ -0,0 +1,74 @@
# Copyright (c) 2023, Frappe Technologies and Contributors
# See license.txt
import re
import frappe
import frappe.recorder
from frappe.core.doctype.recorder.recorder import serialize_request
from frappe.recorder import get as get_recorder_data
from frappe.tests.utils import FrappeTestCase
from frappe.utils import set_request
class TestRecorder(FrappeTestCase):
def setUp(self):
self.start_recoder()
def start_recoder(self):
frappe.recorder.stop()
frappe.recorder.delete()
set_request(path="/api/method/ping")
frappe.recorder.start()
frappe.recorder.record()
def stop_recorder(self):
frappe.recorder.dump()
def test_recorder_list(self):
frappe.get_all("User") # trigger one query
self.stop_recorder()
requests = frappe.get_all("Recorder")
self.assertGreaterEqual(len(requests), 1)
request = frappe.get_doc("Recorder", requests[0].name)
self.assertGreaterEqual(len(request.sql_queries), 1)
queries = [sql_query.query for sql_query in request.sql_queries]
match_flag = 0
for query in queries:
if bool(re.match("^[select.*from `tabUser`]", query, flags=re.IGNORECASE)):
match_flag = 1
break
self.assertEqual(match_flag, 1)
def test_recorder_list_filters(self):
user = frappe.qb.DocType("User")
frappe.qb.from_(user).select("name").run()
self.stop_recorder()
set_request(path="/api/method/abc")
frappe.recorder.start()
frappe.recorder.record()
frappe.get_all("User")
self.stop_recorder()
requests = frappe.get_list(
"Recorder", filters={"path": ("like", "/api/method/ping"), "number_of_queries": 1}
)
self.assertGreaterEqual(len(requests), 1)
requests = frappe.get_list("Recorder", filters={"path": ("like", "/api/method/test")})
self.assertEqual(len(requests), 0)
requests = frappe.get_list("Recorder", filters={"method": "GET"})
self.assertGreaterEqual(len(requests), 1)
requests = frappe.get_list("Recorder", filters={"method": "POST"})
self.assertEqual(len(requests), 0)
requests = frappe.get_list("Recorder", order_by="path desc")
self.assertEqual(requests[0].path, "/api/method/ping")
def test_recorder_serialization(self):
frappe.get_all("User") # trigger one query
self.stop_recorder()
requests = frappe.get_all("Recorder")
request_doc = get_recorder_data(requests[0].name)
self.assertIsInstance(serialize_request(request_doc), dict)

View file

@ -0,0 +1,8 @@
// Copyright (c) 2023, Frappe Technologies and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Recorder Query", {
// refresh(frm) {
// },
// });

View file

@ -0,0 +1,109 @@
{
"actions": [],
"creation": "2023-08-01 17:04:12.173774",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"index",
"query",
"duration",
"column_break_qmju",
"exact_copies",
"normalized_query",
"normalized_copies",
"section_break_dygy",
"stack_html",
"stack",
"section_break_kvkb",
"sql_explain_html",
"explain_result"
],
"fields": [
{
"fieldname": "query",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Query",
"length": 2,
"reqd": 1
},
{
"fieldname": "normalized_query",
"fieldtype": "Data",
"label": "Normalized Query"
},
{
"fieldname": "duration",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Duration",
"reqd": 1
},
{
"fieldname": "exact_copies",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Exact Copies",
"reqd": 1
},
{
"fieldname": "normalized_copies",
"fieldtype": "Int",
"label": "Normalized Copies"
},
{
"fieldname": "column_break_qmju",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_dygy",
"fieldtype": "Section Break"
},
{
"fieldname": "stack",
"fieldtype": "Text",
"hidden": 1,
"print_hide": 1
},
{
"fieldname": "stack_html",
"fieldtype": "HTML",
"label": "Stack Trace"
},
{
"fieldname": "section_break_kvkb",
"fieldtype": "Section Break"
},
{
"fieldname": "explain_result",
"fieldtype": "Text",
"hidden": 1,
"print_hide": 1
},
{
"fieldname": "sql_explain_html",
"fieldtype": "HTML",
"label": "SQL Explain"
},
{
"fieldname": "index",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Index"
}
],
"index_web_pages_for_search": 1,
"is_virtual": 1,
"istable": 1,
"links": [],
"modified": "2023-08-07 13:12:23.496002",
"modified_by": "Administrator",
"module": "Core",
"name": "Recorder Query",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View file

@ -0,0 +1,53 @@
# Copyright (c) 2023, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class RecorderQuery(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
duration: DF.Float
exact_copies: DF.Int
explain_result: DF.Text | None
index: DF.Int
normalized_copies: DF.Int
normalized_query: DF.Data | None
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
query: DF.Data
stack: DF.Text | None
# end: auto-generated types
pass
def db_insert(self, *args, **kwargs):
pass
def load_from_db(self):
pass
def db_update(self):
pass
@staticmethod
def get_list(args):
pass
@staticmethod
def get_count(args):
pass
@staticmethod
def get_stats(args):
pass
def delete(self):
pass

View file

@ -0,0 +1,9 @@
# Copyright (c) 2023, Frappe Technologies and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestRecorderQuery(FrappeTestCase):
pass

View file

@ -14,10 +14,6 @@ frappe.listview_settings["RQ Job"] = {
__("Actions")
);
if (listview.list_view_settings) {
listview.list_view_settings.disable_sidebar_stats = 1;
}
frappe.xcall("frappe.utils.scheduler.get_scheduler_status").then(({ status }) => {
if (status === "active") {
listview.page.set_indicator(__("Scheduler: Active"), "green");

View file

@ -1,28 +0,0 @@
frappe.pages["recorder"].on_page_load = function (wrapper) {
frappe.ui.make_app_page({
parent: wrapper,
title: __("Recorder"),
single_column: true,
card_layout: true,
});
frappe.recorder = new Recorder(wrapper);
$(wrapper).bind("show", function () {
frappe.recorder.show();
});
frappe.require("recorder.bundle.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() {
if (!this.route || this.route.name == "RecorderDetail") return;
this.router?.replace({ name: "RecorderDetail" });
}
}

View file

@ -1,23 +0,0 @@
{
"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"
}

View file

@ -193,6 +193,7 @@ frappe.patches.v14_0.delete_payment_gateways
frappe.patches.v15_0.remove_event_streaming
frappe.patches.v15_0.copy_disable_prepared_report_to_prepared_report
execute:frappe.reload_doc("desk", "doctype", "Form Tour")
execute:frappe.delete_doc('Page', 'recorder', ignore_missing=True, force=True)
[post_model_sync]
execute:frappe.get_doc('Role', 'Guest').save() # remove desk access

View file

@ -1,306 +0,0 @@
<template>
<div v-cloak @drop.prevent="import_data" @dragover.prevent>
<div class="page-form">
<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>
<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 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)">
<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" :title="request[columns[0].slug]">
{{ 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>
<button class="btn btn-primary btn-sm btn-new-doc" @click="start()">
{{ __("Start Recording") }}
</button>
</p>
<p>{{ __("Recorder is Inactive.") }}</p>
<p>{{ __("Start recording or drag & drop a previously exported data file to view it.") }}</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-else 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 setup>
import { ref, computed, onMounted } from "vue"
import { useRouter } from "vue-router"
// variables
let router = ref(useRouter());
let requests = ref([]);
let page = frappe.pages["recorder"].page;
let columns = [
{label: __("Path"), slug: "path"},
{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},
];
let query = ref({
sort: "duration",
order: "desc",
filters: {},
pagination: {
limit: 20,
page: 1,
total: 0,
}
});
let status = ref({
color: "grey",
status: "Unknown",
});
// Started
frappe.recorder.router = router.value;
let route = frappe.get_route();
if (route[2]) {
router.value.push({name: "RequestDetail", params: {id: route[2]}});
}
// Methods
function filtered(reqs) {
reqs = reqs.slice();
const filters = Object.entries(query.value.filters);
reqs = reqs.filter(
(r) => filters.map((f) => (r[f[0]] || "").match(f[1])).every(Boolean)
);
query.value.pagination.total = Math.ceil(reqs.length / query.value.pagination.limit);
return reqs;
}
function paginated(reqs) {
reqs = reqs.slice();
const begin = (query.value.pagination.page - 1) * (query.value.pagination.limit);
const end = begin + query.value.pagination.limit;
return reqs.slice(begin, end);
}
function sorted(reqs) {
reqs = reqs.slice();
const order = (query.value.order == "asc") ? 1 : -1;
const sort = query.value.sort;
return reqs.sort((a,b) => (a[sort] > b[sort]) ? order : -order);
}
function refresh() {
frappe.call("frappe.recorder.get").then( r => requests.value = r.message);
}
function update(message) {
requests.value.push(JSON.parse(message));
}
function clear() {
frappe.call("frappe.recorder.delete").then(r => refresh());
}
function start() {
frappe.call("frappe.recorder.start").then(r => fetch_status());
}
function stop() {
frappe.call("frappe.recorder.stop").then(r => fetch_status());
}
function fetch_status() {
frappe.call("frappe.recorder.status").then(r => update_status(r.message));
}
function update_status(result) {
if(result) {
status.value = {status: "Active", color: "green"}
} else {
status.value = {status: "Inactive", color: "red"}
}
page.set_indicator(status.value.status, status.value.color);
if(status.value.status == "Active") {
frappe.realtime.on("recorder-dump-event", update);
} else {
frappe.realtime.off("recorder-dump-event", update);
}
update_buttons();
}
function update_buttons() {
if(status.value.status == "Active") {
page.set_primary_action(__("Stop"), () => {
stop();
});
} else {
page.set_primary_action(__("Start"), () => {
start();
});
}
}
function route_to_request_detail(request) {
router.value.beforeEach(async to => {
if (to.meta.shouldFetch) {
to.meta.request = await request
}
});
router.value.push({name: "RequestDetail", params: {id: request.uuid}});
}
function export_data() {
if (!requests.value) {
return;
}
frappe.call("frappe.recorder.export_data")
.then((r) => {
const data = r.message;
const filename = `${data[0]["uuid"]}..${data[data.length -1]["uuid"]}.json`
const el = document.createElement("a");
el.setAttribute("href", "data:application/json," + encodeURIComponent(JSON.stringify(data)));
el.setAttribute("download", filename);
el.click();
});
}
function import_data(e) {
if (requests.value.length > 0) {
// don't replace existing capture
return;
}
const request_file = e.dataTransfer.files[0];
const file_reader = new FileReader();
file_reader.readAsText(request_file, "UTF-8");
file_reader.onload = ({target: {result}}) => {
requests.value = JSON.parse(result);
}
}
// Mounted
onMounted(() => {
fetch_status();
refresh();
page.set_secondary_action(__("Clear"), () => {
frappe.set_route("recorder");
clear();
});
page.add_menu_item("Export data", () => export_data());
});
// Computed
let pages = computed(() => {
const current_page = query.value.pagination.page;
const total_pages = query.value.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" : "",
}];
});
</script>
<style scoped>
.list-row .level-left {
flex: 8;
width: 100%;
}
</style>

View file

@ -1,20 +0,0 @@
<template>
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component"></component>
</keep-alive>
</router-view>
</template>
<script setup>
import { watch } from "vue"
import { useRoute } from "vue-router"
let route = useRoute();
watch(route, async () => {
frappe.router.current_route = await frappe.router.parse();
frappe.breadcrumbs.update();
frappe.recorder.route = route;
});
</script>

View file

@ -1,321 +0,0 @@
<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" @click="query.order = (query.order == 'asc') ? 'desc' : 'asc'">
<span class="octicon text-muted" :class="query.order == 'asc' ? 'octicon-arrow-down' : 'octicon-arrow-up'"></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-1">
<div class="static-area ellipsis text-right">{{ __("Exact Copies") }}</div>
</div>
<div class="col grid-static-col col-xs-1">
<div class="static-area ellipsis text-right">{{ __("Normalized 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" @click="showing = showing == call.index ? null : 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-1">
<div class="static-area ellipsis text-right">{{ call.exact_copies }}</div>
</div>
<div class="col grid-static-col col-xs-1">
<div class="static-area ellipsis text-right">{{ call.normalized_copies }}</div>
</div>
<div class="col col-xs-1"><a class="close btn-open-row">
<span class="octicon" :class="showing == call.index? 'octicon-triangle-up' : 'octicon-triangle-down'"></span></a>
</div>
</div>
<div class="recorder-form-in-grid" v-if="showing == call.index">
<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>
</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">
<div class="form-group">
<div class="clearfix"><label class="control-label">{{ __("Normalized Query") }}</label></div>
<div class="control-value like-disabled-input for-description"><pre>{{ call.normalized_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 input-max-width">
<div class="form-group">
<div
class="clearfix"><label class="control-label">{{ __("Normalized Copies") }}</label></div>
<div class="control-value like-disabled-input">{{ call.normalized_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" style="overflow:auto">
<table class="table table-striped">
<thead>
<tr>
<th v-for="key in ['filename', 'lineno', 'function']" :key="key">{{ key }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in call.stack" :key="index">
<td v-for="key in ['filename', 'lineno', 'function']" :key="key">{{ row[key] }}</td>
</tr>
</tbody>
</table>
</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 setup>
import { computed, onMounted, ref } from "vue"
import { useRoute } from "vue-router"
// variables
let route = ref(useRoute());
let 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"},
];
let table_columns = [
{label: __("Execution Order"), slug: "index", sortable: true},
{label: __("Duration (ms)"), slug: "duration", sortable: true},
{label: __("Exact Copies"), slug: "exact_copies", sortable: true},
];
let query = ref({
sort: "duration",
order: "desc",
pagination: {
limit: 20,
page: 1,
total: 0,
}
});
let group_duplicates = ref(false);
let showing = ref(null);
let request = ref({
calls: [],
});
// Methods
function paginated(calls) {
calls = calls.slice();
query.value.pagination.total = Math.ceil(calls.length / query.value.pagination.limit);
const begin = (query.value.pagination.page - 1) * (query.value.pagination.limit);
const end = begin + query.value.pagination.limit;
return calls.slice(begin, end);
}
function sorted(calls) {
calls = calls.slice();
const order = (query.value.order == "asc") ? 1 : -1;
const sort = query.value.sort;
return calls.sort((a,b) => (a[sort] > b[sort]) ? order : -order);
}
function grouped(calls) {
if(group_duplicates.value) {
calls = calls.slice();
return calls.uniqBy(call => call["query"]);
}
return calls
}
// Computed
let pages = computed(() => {
const current_page = query.value.pagination.page;
const total_pages = query.value.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" : "",
}];
});
// Mounted
onMounted(async () => {
frappe.breadcrumbs.add({
type: "Custom",
label: __("Recorder"),
route: "/app/recorder"
});
const req = route.value.meta.request;
const id = route.value.params.id;
if (req && (req.headers || req.form_dict || req.calls)) {
// complete request data passed as parameter.
request.value = req;
} else {
let r = await frappe.call({
method: "frappe.recorder.get",
args: {
uuid: req?.uuid || id,
}
});
request.value = r.message;
}
});
</script>

View file

@ -1,8 +0,0 @@
import { createApp } from "vue";
import RecorderRoot from "./RecorderRoot.vue";
import router from "./router.js";
let app = createApp(RecorderRoot).use(router);
SetVueGlobals(app);
app.mount(".recorder-container");
frappe.recorder.view = app;

View file

@ -1,28 +0,0 @@
import { createWebHistory, createRouter } from "vue-router";
import RecorderDetail from "./RecorderDetail.vue";
import RequestDetail from "./RequestDetail.vue";
const routes = [
{
path: "/detail",
name: "RecorderDetail",
component: RecorderDetail,
},
{
path: "/request/:id",
name: "RequestDetail",
component: RequestDetail,
meta: { shouldFetch: true },
},
{
path: "/",
redirect: "/detail",
},
];
const router = createRouter({
history: createWebHistory("/app/recorder/"),
routes,
});
export default router;

View file

@ -193,6 +193,7 @@ def _unpatch():
def do_not_record(function):
@functools.wraps(function)
def wrapper(*args, **kwargs):
if hasattr(frappe.local, "_recorder"):
del frappe.local._recorder
@ -203,6 +204,7 @@ def do_not_record(function):
def administrator_only(function):
@functools.wraps(function)
def wrapper(*args, **kwargs):
if frappe.session.user != "Administrator":
frappe.throw(_("Only Administrator is allowed to use Recorder"))
@ -274,3 +276,15 @@ def record_queries(func: Callable):
return ret
return wrapped
@frappe.whitelist()
@do_not_record
@administrator_only
def import_data(file: str) -> None:
file_doc = frappe.get_doc("File", {"file_url": file})
file_content = json.loads(file_doc.get_content())
for request in file_content:
frappe.cache.hset(RECORDER_REQUEST_SPARSE_HASH, request["uuid"], request)
frappe.cache.hset(RECORDER_REQUEST_HASH, request["uuid"], request)
file_doc.delete(delete_permanently=True)

View file

@ -11,6 +11,7 @@ from frappe.model.virtual_doctype import validate_controller
from frappe.tests.utils import FrappeTestCase
TEST_DOCTYPE_NAME = "VirtualDoctypeTest"
TEST_CHILD_DOCTYPE_NAME = "VirtualDoctypeTestChild"
class VirtualDoctypeTest(Document):
@ -87,8 +88,22 @@ class TestVirtualDoctypes(FrappeTestCase):
frappe.flags.allow_doctype_export = True
cls.addClassCleanup(frappe.flags.pop, "allow_doctype_export", None)
vdt = new_doctype(name=TEST_DOCTYPE_NAME, is_virtual=1, custom=0).insert()
cdt = new_doctype(name=TEST_CHILD_DOCTYPE_NAME, is_virtual=1, istable=1, custom=0).insert()
vdt = new_doctype(
name=TEST_DOCTYPE_NAME,
is_virtual=1,
custom=0,
fields=[
{
"label": "Child Table",
"fieldname": "child_table",
"fieldtype": "Table",
"options": TEST_CHILD_DOCTYPE_NAME,
}
],
).insert()
cls.addClassCleanup(vdt.delete, force=True)
cls.addClassCleanup(cdt.delete, force=True)
patch_virtual_doc = patch(
"frappe.controllers", new={frappe.local.site: {TEST_DOCTYPE_NAME: VirtualDoctypeTest}}
@ -120,16 +135,20 @@ class TestVirtualDoctypes(FrappeTestCase):
docname = frappe.response.docs[0]["name"]
doc = frappe.get_doc(TEST_DOCTYPE_NAME, docname)
doc.some_fieldname = "New Data"
doc.update({"child_table": [{"name": "child-1", "some_fieldname": "child1-field-value"}]})
savedocs(doc.as_json(), "Save")
doc.reload()
self.assertEqual(doc.some_fieldname, "New Data")
self.assertEqual(doc.child_table[0].some_fieldname, "child1-field-value")
def test_multiple_doc_insert_and_get_list(self):
doc1 = frappe.get_doc(doctype=TEST_DOCTYPE_NAME, some_fieldname="first").insert()
doc2 = frappe.get_doc(doctype=TEST_DOCTYPE_NAME, some_fieldname="second").insert()
doc1 = frappe.new_doc(doctype=TEST_DOCTYPE_NAME)
doc1.append("child_table", {"name": "first", "some_fieldname": "first-value"})
doc1.insert()
doc2 = frappe.new_doc(doctype=TEST_DOCTYPE_NAME)
doc2.append("child_table", {"name": "second", "some_fieldname": "second-value"})
doc2.insert()
docs = {doc1.name, doc2.name}
@ -146,7 +165,7 @@ class TestVirtualDoctypes(FrappeTestCase):
self.assertIsInstance(VirtualDoctypeTest.get_count(args), int)
def test_delete_doc(self):
doc = frappe.get_doc(doctype=TEST_DOCTYPE_NAME, some_fieldname="data").insert()
doc = frappe.get_doc(doctype=TEST_DOCTYPE_NAME).insert()
frappe.delete_doc(doc.doctype, doc.name)
@ -155,3 +174,4 @@ class TestVirtualDoctypes(FrappeTestCase):
def test_controller_validity(self):
validate_controller(TEST_DOCTYPE_NAME)
validate_controller(TEST_CHILD_DOCTYPE_NAME)