From 12ef2cd3e5570ca046f993554bb6ec228e290984 Mon Sep 17 00:00:00 2001 From: Benjamin Walker Date: Thu, 12 Sep 2024 15:58:29 +1000 Subject: [PATCH] Add ability to import and export profiles #362 --- classes/flamed3_node.php | 25 +++++++++ classes/form/import_form.php | 106 +++++++++++++++++++++++++++++++++++ classes/helper.php | 3 + classes/profile.php | 84 +++++++++++++++++++++++++-- export.php | 40 +++++++++++++ import.php | 91 ++++++++++++++++++++++++++++++ lang/en/tool_excimer.php | 9 +++ profile.php | 4 ++ settings.php | 10 ++++ templates/profile.mustache | 2 +- version.php | 4 +- 11 files changed, 370 insertions(+), 8 deletions(-) create mode 100644 classes/form/import_form.php create mode 100644 export.php create mode 100644 import.php diff --git a/classes/flamed3_node.php b/classes/flamed3_node.php index 458f460..3e0dd91 100644 --- a/classes/flamed3_node.php +++ b/classes/flamed3_node.php @@ -86,6 +86,31 @@ public static function from_excimer_log_entries(iterable $entries): flamed3_node return $root; } + /** + * Loads data from an import. This will generally be a stdClass that has been loaded from json. + * + * @param \stdClass $data loaded from json + * @return flamed3_node|null flamed3_node if valid, null if any part of the import is invalid + */ + public static function from_import(\stdClass $data): ?flamed3_node { + // Validate whether the stdClass is a flamed3_node. + if (!isset($data->name) || !isset($data->value) || !isset($data->children)) { + return null; + } + + // Map children to flamenode instances. + $children = []; + foreach ($data->children as $child) { + $node = self::from_import($child); + if (empty($node)) { + return null; + } + $children[] = $node; + } + + return new flamed3_node($data->name, $data->value, $children); + } + /** * Returns a name to represent the call in the trace node. * diff --git a/classes/form/import_form.php b/classes/form/import_form.php new file mode 100644 index 0000000..35d5f19 --- /dev/null +++ b/classes/form/import_form.php @@ -0,0 +1,106 @@ +. + +namespace tool_excimer\form; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir.'/formslib.php'); + +/** + * Import excimer form class. + * + * @package tool_excimer + * @author Benjamin Walker + * @copyright 2024, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class import_form extends \moodleform { + + /** + * Build form for importing woekflows. + * + * {@inheritDoc} + * @see \moodleform::definition() + */ + public function definition() { + GLOBAL $CFG; + + $mform = $this->_form; + + // Profile file. + $mform->addElement( + 'filepicker', + 'userfile', + get_string('profile_file', 'tool_excimer'), + null, + ['maxbytes' => $CFG->maxbytes, 'accepted_types' => ['.json']] + ); + $mform->addRule('userfile', get_string('required'), 'required'); + + $this->add_action_buttons(); + } + + /** + * Validate uploaded json file. + * + * @param array $data array of ("fieldname"=>value) of submitted data + * @param array $files array of uploaded files "element_name"=>tmp_file_path + * @return array of "element_name"=>"error_description" if there are errors, + * or an empty array if everything is OK (true allowed for backwards compatibility too). + */ + public function validation($data, $files) { + global $USER; + + $validationerrors = []; + + // Get the file from the filestystem. $files will always be empty. + $fs = get_file_storage(); + + $context = \context_user::instance($USER->id); + $itemid = $data['userfile']; + + // This is how core gets files in this case. + if (!$files = $fs->get_area_files($context->id, 'user', 'draft', $itemid, 'id DESC', false)) { + $validationerrors['nofile'] = get_string('no_profile_file', 'tool_excimer'); + return $validationerrors; + } + $file = reset($files); + + // Check if file is valid json. + $content = $file->get_content(); + if (!empty($content)) { + json_decode($content); + if (json_last_error() !== JSON_ERROR_NONE) { + $validationerrors['userfile'] = json_last_error_msg(); + } + } + + return $validationerrors; + } + + /** + * Get the errors returned during form validation. + * + * @return array|mixed + */ + public function get_errors() { + $form = $this->_form; + $errors = $form->_errors; + + return $errors; + } +} diff --git a/classes/helper.php b/classes/helper.php index 0626f6c..a04f09a 100644 --- a/classes/helper.php +++ b/classes/helper.php @@ -84,6 +84,9 @@ public static function reason_display(int $reason): string { if ($reason & profile::REASON_STACK) { $reasonsmatched[] = get_string('reason_stack', 'tool_excimer'); } + if ($reason & profile::REASON_IMPORT) { + $reasonsmatched[] = get_string('reason_import', 'tool_excimer'); + } return implode(',', $reasonsmatched); } diff --git a/classes/profile.php b/classes/profile.php index 8f78010..6e718af 100644 --- a/classes/profile.php +++ b/classes/profile.php @@ -47,6 +47,8 @@ class profile extends persistent { /** Reason - STACK - Set when maxstackdepth exceeds a predefined limit. */ const REASON_STACK = 0b1000; + /** Reason - IMPORTED - Set when a profile is imported. */ + const REASON_IMPORT = 0b010000; /** Reasons for profiling (bitmask flags). NOTE: Excluding the NONE option intentionally. */ const REASONS = [ @@ -54,6 +56,7 @@ class profile extends persistent { self::REASON_SLOW, self::REASON_FLAMEALL, self::REASON_STACK, + self::REASON_IMPORT, ]; /** String map for profiling reasons. */ @@ -62,6 +65,7 @@ class profile extends persistent { self::REASON_SLOW => 'slowest', self::REASON_FLAMEALL => 'flameall', self::REASON_STACK => 'stackdepth', + self::REASON_IMPORT => 'import', ]; /** Ajax scripts */ @@ -89,9 +93,9 @@ protected function set_flamedatad3(flamed3_node $node): void { /** * Custom getter for flame data. * - * @return string + * @return array|\stdClass|null json decoded value */ - protected function get_flamedatad3(): string { + protected function get_flamedatad3() { return json_decode($this->get_flamedatad3json()); } @@ -101,7 +105,7 @@ protected function get_flamedatad3(): string { * @return string */ public function get_flamedatad3json(): string { - return gzuncompress($this->raw_get('flamedatad3')); + return $this->get_uncompressed_json('flamedatad3'); } /** @@ -117,9 +121,9 @@ protected function set_memoryusagedatad3(array $node): void { /** * Custom getter for memory usage data. * - * @return string + * @return array|\stdClass|null json decoded value */ - protected function get_memoryusagedatad3(): string { + protected function get_memoryusagedatad3() { return json_decode($this->get_uncompressed_json('memoryusagedatad3')); } @@ -137,6 +141,76 @@ public function get_uncompressed_json(string $fieldname): string { return json_encode(null); } + /** + * Gets the JSON for an export. + * + * @param mixed $removesitedata whether to remove site specific data + * @return string JSON + */ + protected function get_export_json($removesitedata = true): string { + $data = new \stdClass(); + + // Remove unneeded properties from the export. + $allproperties = array_keys(static::properties_definition()); + $removeproperties = ['id']; + if ($removesitedata) { + // Also remove data that is site specific or contains full urls. + $removeproperties += ['referer', 'userid', 'courseid', 'sessionid', 'lockreason', 'lockwaiturl']; + } + $properties = array_diff($allproperties, $removeproperties); + + // Load data the same way as to_record(), but use get() to transform d3 data. + foreach ($properties as $property) { + $data->$property = $this->get($property); + } + + return json_encode($data); + } + + /** + * Download the profile in JSON format. + */ + public function download(): void { + $id = $this->raw_get('id'); + $filename = "excimer-profile-$id.json"; + + header('Content-Type: application/json; charset: utf-8'); + header("Content-Disposition: attachment; filename=\"$filename\""); + + echo $this->get_export_json(); + exit(); + } + + /** + * Imports a prfile from JSON format. + * + * @param string $json + * @return bool|int the id of the imported profile, or false if unsuccessful + */ + public static function import(string $json) { + global $DB; + + $profile = new profile(); + $data = json_decode($json); + + // Don't mark this as slow on external sites. + $data->reason = self::REASON_IMPORT; + + // Convert flamedatad3 to flame_node. + if (isset($data->flamedatad3)) { + $data->flamedatad3 = flamed3_node::from_import($data->flamedatad3); + } + + foreach ($data as $property => $value) { + if (isset($value)) { + $profile->set($property, $value); + } + } + + // The normal profile->save() doesn't work with flamedatad3 and memoryuseagedatad3. + return $DB->insert_record(self::TABLE, $profile->to_record()); + } + /** * Convenience method to add environment data to the profile. * diff --git a/export.php b/export.php new file mode 100644 index 0000000..a6cdce6 --- /dev/null +++ b/export.php @@ -0,0 +1,40 @@ +. + +/** + * Export profiles. + * + * @package tool_excimer + * @author Benjamin Walker + * @copyright 2024, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use tool_excimer\profile; + +require_once(__DIR__ . '/../../../config.php'); +require_once($CFG->libdir.'/adminlib.php'); + +require_login(); +$context = context_system::instance(); + +// Check for caps. +require_capability('moodle/site:config', context_system::instance()); + +$exportid = required_param('exportid', PARAM_INT); + +$profile = new profile($exportid); +$profile->download(); diff --git a/import.php b/import.php new file mode 100644 index 0000000..3171626 --- /dev/null +++ b/import.php @@ -0,0 +1,91 @@ +. + +/** + * Import profiles page + * + * @package tool_excimer + * @author Benjamin Walker + * @copyright 2024, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use tool_excimer\profile; +use tool_excimer\form\import_form; + +require_once(dirname(__FILE__) . '/../../../config.php'); +require_once($CFG->libdir . '/adminlib.php'); + +defined(constant_name: 'MOODLE_INTERNAL') || die(); + +require_login(); +admin_externalpage_setup('tool_excimer_import_profile'); + +$context = context_system::instance(); + +// Check for caps. +require_capability('moodle/site:config', context_system::instance()); + +$overviewurl = new moodle_url('/admin/category.php?category=tool_excimer_reports'); +$url = new moodle_url('/admin/tool/excimer/import.php'); +$PAGE->set_url($url); + +$customdata = []; +$form = new import_form($PAGE->url->out(false), $customdata); +if ($form->is_cancelled()) { + redirect($overviewurl); +} + +if (($data = $form->get_data())) { + try { + // Prepare and import the profile. + $filecontent = $form->get_file_content('userfile'); + $id = profile::import($filecontent); + + if (empty($id)) { + // Failed to save the imported profile. + \core\notification::error(get_string('import_error', 'tool_excimer')); + redirect($overviewurl); + } + + // The import was a success, so redirect to the imported profile. + \core\notification::success(get_string('import_success', 'tool_excimer')); + $profile = new moodle_url('/admin/tool/excimer/profile.php', ['id' => $id]); + redirect($profile); + } catch (Exception $e) { + \core\notification::error($e->getMessage() . html_writer::empty_tag('br') . $e->debuginfo); + } +} + +// Display the mandatory header and footer. +$heading = get_string('import_profile', 'tool_excimer'); + +$title = implode(': ', array_filter([ + get_string('pluginname', 'tool_excimer'), + $heading, +])); +$PAGE->set_title($title); +$PAGE->set_heading(get_string('pluginname', 'tool_excimer')); +echo $OUTPUT->header(); + +// Output headings. +echo $OUTPUT->heading($heading); + +// And display the form, and its validation errors if there are any. +$form->display(); + +// Display footer. +echo $OUTPUT->footer(); diff --git a/lang/en/tool_excimer.php b/lang/en/tool_excimer.php index 97d2a08..8212360 100644 --- a/lang/en/tool_excimer.php +++ b/lang/en/tool_excimer.php @@ -185,6 +185,7 @@ $string['reason_slow'] = 'Slow'; $string['reason_flameall'] = 'Flame All'; $string['reason_stack'] = 'Recursion'; +$string['reason_import'] = 'Import'; // Lock reason form. $string['lock_profile'] = 'Lock Profile'; @@ -210,6 +211,14 @@ $string['months_to_display'] = 'Months to display'; $string['histogram_history'] = 'Histogram history'; +// Import/export profiles. +$string['export_profile'] = 'Export profile'; +$string['import_profile'] = 'Import profile'; +$string['import_success'] = 'Profile imported successfully.'; +$string['import_error'] = 'Error saving the imported file contents.'; +$string['no_profile_file'] = 'No profile file found.'; +$string['profile_file'] = 'Profile file'; + // Miscellaneous. $string['cachedef_request_metadata'] = 'Excimer request metadata cache'; $string['cachedef_page_group_metadata'] = 'Excimer page group metadata cache'; diff --git a/profile.php b/profile.php index 531f5c6..2d4f59e 100644 --- a/profile.php +++ b/profile.php @@ -115,6 +115,9 @@ $lockprofileurl = new \moodle_url('/admin/tool/excimer/lock_profile.php', ['profileid' => $profileid]); $lockprofilebutton = new \single_button($lockprofileurl, get_string('edit_lock', 'tool_excimer'), 'GET'); +$exporturl = new \moodle_url('/admin/tool/excimer/export.php', ['exportid' => $profileid]); +$exportbutton = new \single_button($exporturl, get_string('export_profile', 'tool_excimer'), 'GET'); + $data = (array) $profile->to_record(); // Totara doesn't like userdate being called within mustache. @@ -167,6 +170,7 @@ $data['delete_button'] = $output->render($deletebutton); $data['delete_all_button'] = $output->render($deleteallbutton); $data['profile_lock_button'] = $output->render($lockprofilebutton); +$data['export_button'] = $output->render($exportbutton); $data['responsecode'] = helper::status_display($profile->get('scripttype'), $profile->get('responsecode')); diff --git a/settings.php b/settings.php index 58ad5f4..a608278 100644 --- a/settings.php +++ b/settings.php @@ -295,4 +295,14 @@ function ($v) { 'moodle/site:config' ) ); + + $ADMIN->add( + 'tool_excimer_reports', + new admin_externalpage( + 'tool_excimer_import_profile', + get_string('import_profile', 'tool_excimer'), + new moodle_url('/admin/tool/excimer/import.php'), + 'moodle/site:config' + ) + ); } diff --git a/templates/profile.mustache b/templates/profile.mustache index d46dd23..e5da622 100644 --- a/templates/profile.mustache +++ b/templates/profile.mustache @@ -58,7 +58,7 @@ }}

{{{responsecode}}} {{method}} {{{request}}}

-

{{{delete_button}}} {{{delete_all_button}}} {{{profile_lock_button}}}

+

{{{delete_button}}} {{{delete_all_button}}} {{{export_button}}} {{{profile_lock_button}}}

{{#lockreason}}
diff --git a/version.php b/version.php index 2ae9ce0..470f98c 100644 --- a/version.php +++ b/version.php @@ -25,8 +25,8 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2024091000; -$plugin->release = 2024091000; +$plugin->version = 2024091200; +$plugin->release = 2024091200; $plugin->requires = 2017051500; // Moodle 3.3 for Totara support. $plugin->supported = [35, 401]; // Supports Moodle 3.5 or later. // TODO $plugin->incompatible = ; // Available as of Moodle 3.9.0 or later.