Merge pull request #21908 from GursheenK/virtual-doc-for-frappe-recorder
feat: virtual doctype for frappe recorder
This commit is contained in:
commit
b2cc015f23
25 changed files with 711 additions and 761 deletions
|
|
@ -538,7 +538,6 @@
|
|||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:!doc.istable",
|
||||
"fieldname": "is_virtual",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Virtual"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
47
frappe/core/doctype/recorder/recorder.js
Normal file
47
frappe/core/doctype/recorder/recorder.js
Normal 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;
|
||||
}
|
||||
});
|
||||
126
frappe/core/doctype/recorder/recorder.json
Normal file
126
frappe/core/doctype/recorder/recorder.json
Normal 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"
|
||||
}
|
||||
119
frappe/core/doctype/recorder/recorder.py
Normal file
119
frappe/core/doctype/recorder/recorder.py
Normal 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
|
||||
110
frappe/core/doctype/recorder/recorder_list.js
Normal file
110
frappe/core/doctype/recorder/recorder_list.js
Normal 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");
|
||||
}
|
||||
},
|
||||
};
|
||||
74
frappe/core/doctype/recorder/test_recorder.py
Normal file
74
frappe/core/doctype/recorder/test_recorder.py
Normal 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)
|
||||
0
frappe/core/doctype/recorder_query/__init__.py
Normal file
0
frappe/core/doctype/recorder_query/__init__.py
Normal file
8
frappe/core/doctype/recorder_query/recorder_query.js
Normal file
8
frappe/core/doctype/recorder_query/recorder_query.js
Normal 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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
109
frappe/core/doctype/recorder_query/recorder_query.json
Normal file
109
frappe/core/doctype/recorder_query/recorder_query.json
Normal 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": []
|
||||
}
|
||||
53
frappe/core/doctype/recorder_query/recorder_query.py
Normal file
53
frappe/core/doctype/recorder_query/recorder_query.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue