From b47cd2f0103afb9456a6b05f7a49d3a95e1e4d8c Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Thu, 29 Jan 2026 21:05:14 +0100 Subject: [PATCH 01/26] Initial version for custom theme colors --- robotframework_dashboard/js/eventlisteners.js | 90 ++++++++++++++++++- robotframework_dashboard/js/theme.js | 34 ++++++- .../templates/dashboard.html | 61 ++++++++++++- 3 files changed, 182 insertions(+), 3 deletions(-) diff --git a/robotframework_dashboard/js/eventlisteners.js b/robotframework_dashboard/js/eventlisteners.js index ca4ea80..7d6bcec 100644 --- a/robotframework_dashboard/js/eventlisteners.js +++ b/robotframework_dashboard/js/eventlisteners.js @@ -14,7 +14,7 @@ import { import { arrowDown, arrowRight } from "./variables/svg.js"; import { fullscreenButtons, graphChangeButtons, compareRunIds } from "./variables/graphs.js"; import { add_alert } from "./common.js"; -import { toggle_theme } from "./theme.js"; +import { toggle_theme, apply_theme_colors } from "./theme.js"; import { setup_data_and_graphs, update_menu } from "./menu.js"; import { setup_run_amount_filter, @@ -269,6 +269,94 @@ function setup_settings_modal() { document.getElementById("themeLight").addEventListener("click", () => toggle_theme()); document.getElementById("themeDark").addEventListener("click", () => toggle_theme()); + // Theme color handlers + const defaultThemeColors = { + background: '#0f172a', + card: 'rgba(30, 41, 59, 0.9)', + menuText: '#ffffff', + text: '#eee', + passed: '#4ade80', + skipped: '#fbbf24', + failed: '#f87171' + }; + + function create_theme_color_handler(colorKey, elementId, resetButtonId) { + function load_color() { + const element = document.getElementById(elementId); + const storedColor = settings.theme_colors?.[colorKey]; + if (storedColor) { + element.value = storedColor; + } else { + element.value = defaultThemeColors[colorKey]; + } + } + + function update_color() { + const element = document.getElementById(elementId); + const newColor = element.value; + + if (!settings.theme_colors) { + settings.theme_colors = {}; + } + + settings.theme_colors[colorKey] = newColor; + set_local_storage_item('theme_colors.' + colorKey, newColor); + apply_theme_colors(); + } + + function reset_color() { + const element = document.getElementById(elementId); + element.value = defaultThemeColors[colorKey]; + + if (settings.theme_colors) { + delete settings.theme_colors[colorKey]; + set_local_storage_item('theme_colors', settings.theme_colors); + } + + apply_theme_colors(); + } + + return { load_color, update_color, reset_color }; + } + + const backgroundColorHandler = create_theme_color_handler('background', 'themeBackgroundColor', 'resetBackgroundColor'); + const cardColorHandler = create_theme_color_handler('card', 'themeCardColor', 'resetCardColor'); + const menuTextColorHandler = create_theme_color_handler('menuText', 'themeMenuTextColor', 'resetMenuTextColor'); + const textColorHandler = create_theme_color_handler('text', 'themeTextColor', 'resetTextColor'); + const passedColorHandler = create_theme_color_handler('passed', 'themePassedColor', 'resetPassedColor'); + const skippedColorHandler = create_theme_color_handler('skipped', 'themeSkippedColor', 'resetSkippedColor'); + const failedColorHandler = create_theme_color_handler('failed', 'themeFailedColor', 'resetFailedColor'); + + // Load colors on modal open + $("#settingsModal").on("shown.bs.modal", function () { + backgroundColorHandler.load_color(); + cardColorHandler.load_color(); + menuTextColorHandler.load_color(); + textColorHandler.load_color(); + passedColorHandler.load_color(); + skippedColorHandler.load_color(); + failedColorHandler.load_color(); + }); + + // Add event listeners for color inputs + document.getElementById('themeBackgroundColor').addEventListener('change', () => backgroundColorHandler.update_color()); + document.getElementById('themeCardColor').addEventListener('change', () => cardColorHandler.update_color()); + document.getElementById('themeMenuTextColor').addEventListener('change', () => menuTextColorHandler.update_color()); + document.getElementById('themeTextColor').addEventListener('change', () => textColorHandler.update_color()); + document.getElementById('themePassedColor').addEventListener('change', () => passedColorHandler.update_color()); + document.getElementById('themeSkippedColor').addEventListener('change', () => skippedColorHandler.update_color()); + document.getElementById('themeFailedColor').addEventListener('change', () => failedColorHandler.update_color()); + + // Add event listeners for reset buttons + document.getElementById('resetBackgroundColor').addEventListener('click', () => backgroundColorHandler.reset_color()); + document.getElementById('resetCardColor').addEventListener('click', () => cardColorHandler.reset_color()); + document.getElementById('resetMenuTextColor').addEventListener('click', () => menuTextColorHandler.reset_color()); + document.getElementById('resetTextColor').addEventListener('click', () => textColorHandler.reset_color()); + document.getElementById('resetPassedColor').addEventListener('click', () => passedColorHandler.reset_color()); + document.getElementById('resetSkippedColor').addEventListener('click', () => skippedColorHandler.reset_color()); + document.getElementById('resetFailedColor').addEventListener('click', () => failedColorHandler.reset_color()); + + function show_settings_in_textarea() { const textArea = document.getElementById("settingsTextArea"); textArea.value = JSON.stringify(settings, null, 2); diff --git a/robotframework_dashboard/js/theme.js b/robotframework_dashboard/js/theme.js index da0cae0..de10d8b 100644 --- a/robotframework_dashboard/js/theme.js +++ b/robotframework_dashboard/js/theme.js @@ -207,9 +207,41 @@ function setup_theme() { set_light_mode(); } } + + // Apply custom theme colors if set + apply_theme_colors(); +} + +// function to apply custom theme colors +function apply_theme_colors() { + const themeColors = settings.theme_colors || {}; + const root = document.documentElement; + + if (themeColors.background) { + root.style.setProperty('--custom-bg-color', themeColors.background); + } + if (themeColors.card) { + root.style.setProperty('--custom-card-color', themeColors.card); + } + if (themeColors.menuText) { + root.style.setProperty('--custom-menu-text-color', themeColors.menuText); + } + if (themeColors.text) { + root.style.setProperty('--custom-text-color', themeColors.text); + } + if (themeColors.passed) { + root.style.setProperty('--custom-passed-color', themeColors.passed); + } + if (themeColors.skipped) { + root.style.setProperty('--custom-skipped-color', themeColors.skipped); + } + if (themeColors.failed) { + root.style.setProperty('--custom-failed-color', themeColors.failed); + } } export { toggle_theme, - setup_theme + setup_theme, + apply_theme_colors }; \ No newline at end of file diff --git a/robotframework_dashboard/templates/dashboard.html b/robotframework_dashboard/templates/dashboard.html index eb78ad9..a44f3bb 100644 --- a/robotframework_dashboard/templates/dashboard.html +++ b/robotframework_dashboard/templates/dashboard.html @@ -633,11 +633,15 @@

Settings

+
+ aria-labelledíby="general-tab">

Configure graph display settings and dashboard behavior.

@@ -741,6 +745,61 @@

Settings

Enable or disable libraries used for the keyword graphs.

+ +
+

Customize the dashboard colors to your preference.

+
+
+ Background Color +
+ + +
+
+
+ Card Color +
+ + +
+
+
+ Menu Text Color +
+ + +
+
+
+ Text Color +
+ + +
+
+
+ Passed Color +
+ + +
+
+
+ Skipped Color +
+ + +
+
+
+ Failed Color +
+ + +
+
+
+
From 449995d0379be93924ef8f370349343badc6ed09 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Sun, 8 Feb 2026 19:56:30 +0100 Subject: [PATCH 02/26] Initial version of custom coloring --- robotframework_dashboard/css/styling.css | 42 ++++++++++---- robotframework_dashboard/js/eventlisteners.js | 52 +++++++++++------ robotframework_dashboard/js/theme.js | 49 +++++++++------- .../js/variables/chartconfig.js | 58 ++++++++++++++++--- .../js/variables/settings.js | 20 +++++++ scripts/example.bat | 30 +++++----- 6 files changed, 176 insertions(+), 75 deletions(-) diff --git a/robotframework_dashboard/css/styling.css b/robotframework_dashboard/css/styling.css index 8cdfba3..7d03e59 100644 --- a/robotframework_dashboard/css/styling.css +++ b/robotframework_dashboard/css/styling.css @@ -7,7 +7,7 @@ } /* LIGHT MODE STYLING */ body { - background-color: #eee; + background-color: var(--theme-bg-color, #eee); } .border-bottom { @@ -17,7 +17,7 @@ body { .fullscreen, .sticky-top, body { - background-color: #eee; + background-color: var(--theme-bg-color, #eee); } .grid-stack { @@ -25,7 +25,7 @@ body { } .grid-stack-item-content { - background-color: white; + background-color: var(--theme-card-color, #ffffff); } .form-switch .form-check-input:not(:checked) { @@ -150,11 +150,20 @@ body { /* DARK MODE STYLING */ .dark-mode :root { color-scheme: dark; + + /* Dark mode theme color variables */ + --theme-bg-color: #0f172a; + --theme-card-color: rgba(30, 41, 59, 0.9); + --theme-menu-text-color: #ffffff; + --theme-text-color: #eee; + --theme-passed-color: #97bd61; + --theme-skipped-color: #fed84f; + --theme-failed-color: #ce3e01; } .dark-mode .grid-stack-item-content, .dark-mode .overview-card .card { - background: rgba(30, 41, 59, 0.9); + background: var(--theme-card-color, rgba(30, 41, 59, 0.9)); } .dark-mode .fullscreen { @@ -162,7 +171,7 @@ body { } .dark-mode .modal-content { - background: #0f172a; + background: var(--theme-bg-color, #0f172a); } .dark-mode .border-bottom { @@ -173,15 +182,15 @@ body { .dark-mode .card, .dark-mode body, .dark-mode .modal-dialog { - background: #0f172a; - color: #eee; + background: var(--theme-bg-color, #0f172a); + color: var(--theme-text-color, #eee); } .dark-mode .list-group-item:not(.disabled), .dark-mode .form-label, .dark-mode .form-control, .dark-mode .form-select { - color: #eee; + color: var(--theme-text-color, #eee); } .dark-mode .list-group-item .disabled { @@ -194,11 +203,11 @@ body { } .dark-mode .table > :not(caption) > * > * { - color: #eee; + color: var(--theme-text-color, #eee); } .dark-mode .collapse-icon { - color: #eee; + color: var(--theme-text-color, #eee); } .dark-mode .stat-label { @@ -207,12 +216,12 @@ body { } .dark-mode .white-text { - color: #eee; + color: var(--theme-text-color, #eee); } /* Dark mode */ .dark-mode .nav-item { - color: white; + color: var(--theme-menu-text-color, white); } .dark-mode .nav-item.active, @@ -277,6 +286,15 @@ body { /* GENERAL STYLING */ :root { font-family: Helvetica, sans-serif; + + /* Theme color variables */ + --theme-bg-color: #eee; + --theme-card-color: #ffffff; + --theme-menu-text-color: #000000; + --theme-text-color: #000000; + --theme-passed-color: #97bd61; + --theme-skipped-color: #fed84f; + --theme-failed-color: #ce3e01; } .html-scroll { diff --git a/robotframework_dashboard/js/eventlisteners.js b/robotframework_dashboard/js/eventlisteners.js index 7d6bcec..7a9a359 100644 --- a/robotframework_dashboard/js/eventlisteners.js +++ b/robotframework_dashboard/js/eventlisteners.js @@ -270,47 +270,61 @@ function setup_settings_modal() { document.getElementById("themeDark").addEventListener("click", () => toggle_theme()); // Theme color handlers - const defaultThemeColors = { - background: '#0f172a', - card: 'rgba(30, 41, 59, 0.9)', - menuText: '#ffffff', - text: '#eee', - passed: '#4ade80', - skipped: '#fbbf24', - failed: '#f87171' - }; + function get_current_theme_defaults() { + const isDarkMode = document.documentElement.classList.contains("dark-mode"); + const themeMode = isDarkMode ? 'dark' : 'light'; + return settings.theme_colors[themeMode]; + } function create_theme_color_handler(colorKey, elementId, resetButtonId) { function load_color() { const element = document.getElementById(elementId); - const storedColor = settings.theme_colors?.[colorKey]; + const isDarkMode = document.documentElement.classList.contains("dark-mode"); + const themeMode = isDarkMode ? 'dark' : 'light'; + + // Check if user has custom colors for this theme mode + const customColors = settings.theme_colors?.custom?.[themeMode]; + const storedColor = customColors?.[colorKey]; + if (storedColor) { element.value = storedColor; } else { - element.value = defaultThemeColors[colorKey]; + // Use default from settings for current theme mode + const defaults = settings.theme_colors[themeMode]; + element.value = defaults[colorKey]; } } function update_color() { const element = document.getElementById(elementId); const newColor = element.value; + const isDarkMode = document.documentElement.classList.contains("dark-mode"); + const themeMode = isDarkMode ? 'dark' : 'light'; - if (!settings.theme_colors) { - settings.theme_colors = {}; + if (!settings.theme_colors.custom) { + settings.theme_colors.custom = { light: {}, dark: {} }; + } + if (!settings.theme_colors.custom[themeMode]) { + settings.theme_colors.custom[themeMode] = {}; } - settings.theme_colors[colorKey] = newColor; - set_local_storage_item('theme_colors.' + colorKey, newColor); + settings.theme_colors.custom[themeMode][colorKey] = newColor; + set_local_storage_item(`theme_colors.custom.${themeMode}.${colorKey}`, newColor); apply_theme_colors(); } function reset_color() { const element = document.getElementById(elementId); - element.value = defaultThemeColors[colorKey]; + const isDarkMode = document.documentElement.classList.contains("dark-mode"); + const themeMode = isDarkMode ? 'dark' : 'light'; + + // Reset to default from settings + const defaults = settings.theme_colors[themeMode]; + element.value = defaults[colorKey]; - if (settings.theme_colors) { - delete settings.theme_colors[colorKey]; - set_local_storage_item('theme_colors', settings.theme_colors); + if (settings.theme_colors?.custom?.[themeMode]) { + delete settings.theme_colors.custom[themeMode][colorKey]; + set_local_storage_item('theme_colors.custom', settings.theme_colors.custom); } apply_theme_colors(); diff --git a/robotframework_dashboard/js/theme.js b/robotframework_dashboard/js/theme.js index de10d8b..d96639f 100644 --- a/robotframework_dashboard/js/theme.js +++ b/robotframework_dashboard/js/theme.js @@ -214,30 +214,35 @@ function setup_theme() { // function to apply custom theme colors function apply_theme_colors() { - const themeColors = settings.theme_colors || {}; const root = document.documentElement; + const isDarkMode = root.classList.contains("dark-mode"); + const themeMode = isDarkMode ? 'dark' : 'light'; - if (themeColors.background) { - root.style.setProperty('--custom-bg-color', themeColors.background); - } - if (themeColors.card) { - root.style.setProperty('--custom-card-color', themeColors.card); - } - if (themeColors.menuText) { - root.style.setProperty('--custom-menu-text-color', themeColors.menuText); - } - if (themeColors.text) { - root.style.setProperty('--custom-text-color', themeColors.text); - } - if (themeColors.passed) { - root.style.setProperty('--custom-passed-color', themeColors.passed); - } - if (themeColors.skipped) { - root.style.setProperty('--custom-skipped-color', themeColors.skipped); - } - if (themeColors.failed) { - root.style.setProperty('--custom-failed-color', themeColors.failed); - } + // Get default colors for current theme mode + const defaultColors = settings.theme_colors[themeMode]; + + // Get custom colors if they exist + const customColors = settings.theme_colors?.custom?.[themeMode] || {}; + + // Apply colors (custom overrides default) + const finalColors = { + background: customColors.background || defaultColors.background, + card: customColors.card || defaultColors.card, + menuText: customColors.menuText || defaultColors.menuText, + text: customColors.text || defaultColors.text, + passed: customColors.passed || defaultColors.passed, + skipped: customColors.skipped || defaultColors.skipped, + failed: customColors.failed || defaultColors.failed, + }; + + // Set CSS custom properties + root.style.setProperty('--theme-bg-color', finalColors.background); + root.style.setProperty('--theme-card-color', finalColors.card); + root.style.setProperty('--theme-menu-text-color', finalColors.menuText); + root.style.setProperty('--theme-text-color', finalColors.text); + root.style.setProperty('--theme-passed-color', finalColors.passed); + root.style.setProperty('--theme-skipped-color', finalColors.skipped); + root.style.setProperty('--theme-failed-color', finalColors.failed); } export { diff --git a/robotframework_dashboard/js/variables/chartconfig.js b/robotframework_dashboard/js/variables/chartconfig.js index 5658634..c6ff8ae 100644 --- a/robotframework_dashboard/js/variables/chartconfig.js +++ b/robotframework_dashboard/js/variables/chartconfig.js @@ -1,12 +1,55 @@ import { settings } from "./settings.js"; +// Helper function to convert hex to rgba with opacity +function hexToRgba(hex, alpha) { + // Handle hex colors + if (hex.startsWith('#')) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + // If already rgba, return as is + return hex; +} + +// Function to get color values based on current theme +function getThemeBasedColors() { + const root = document.documentElement; + const isDarkMode = root.classList.contains("dark-mode"); + const themeMode = isDarkMode ? 'dark' : 'light'; + + // Get default colors for current theme mode + const defaultColors = settings.theme_colors[themeMode]; + + // Get custom colors if they exist + const customColors = settings.theme_colors?.custom?.[themeMode] || {}; + + // Return final colors (custom overrides default) + const passed = customColors.passed || defaultColors.passed; + const skipped = customColors.skipped || defaultColors.skipped; + const failed = customColors.failed || defaultColors.failed; + + return { + passedBackgroundBorderColor: passed, + passedBackgroundColor: hexToRgba(passed, 0.7), + skippedBackgroundBorderColor: skipped, + skippedBackgroundColor: hexToRgba(skipped, 0.7), + failedBackgroundBorderColor: failed, + failedBackgroundColor: hexToRgba(failed, 0.7), + }; +} + +// Get initial colors +const colors = getThemeBasedColors(); + // colors -const passedBackgroundBorderColor = "#97bd61"; -const passedBackgroundColor = "rgba(151, 189, 97, 0.7)"; -const skippedBackgroundBorderColor = "#fed84f"; -const skippedBackgroundColor = "rgba(254, 216, 79, 0.7)"; -const failedBackgroundBorderColor = "#ce3e01"; -const failedBackgroundColor = "rgba(206, 62, 1, 0.7)"; +const passedBackgroundBorderColor = colors.passedBackgroundBorderColor; +const passedBackgroundColor = colors.passedBackgroundColor; +const skippedBackgroundBorderColor = colors.skippedBackgroundBorderColor; +const skippedBackgroundColor = colors.skippedBackgroundColor; +const failedBackgroundBorderColor = colors.failedBackgroundBorderColor; +const failedBackgroundColor = colors.failedBackgroundColor; const greyBackgroundBorderColor = "#0f172a"; const greyBackgroundColor = "rgba(33, 37, 41, 0.7)"; const blueBackgroundBorderColor = "rgba(54, 162, 235)"; @@ -84,5 +127,6 @@ export { skippedConfig, blueConfig, lineConfig, - dataLabelConfig + dataLabelConfig, + getThemeBasedColors }; \ No newline at end of file diff --git a/robotframework_dashboard/js/variables/settings.js b/robotframework_dashboard/js/variables/settings.js index ff5baf8..2c0cd83 100644 --- a/robotframework_dashboard/js/variables/settings.js +++ b/robotframework_dashboard/js/variables/settings.js @@ -31,6 +31,26 @@ var settings = { duration: 1500, rounding: 6, }, + theme_colors: { + light: { + background: '#eee', + card: '#ffffff', + menuText: '#000000', + text: '#000000', + passed: '#97bd61', + skipped: '#fed84f', + failed: '#ce3e01', + }, + dark: { + background: '#0f172a', + card: 'rgba(30, 41, 59, 0.9)', + menuText: '#ffffff', + text: '#eee', + passed: '#97bd61', + skipped: '#fed84f', + failed: '#ce3e01', + } + }, menu: { overview: false, dashboard: true, diff --git a/scripts/example.bat b/scripts/example.bat index 029e144..951cfe6 100644 --- a/scripts/example.bat +++ b/scripts/example.bat @@ -1,15 +1,15 @@ -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002134.xml:prod:project_1 --projectversion 0.1 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002151.xml:dev:project_2 --projectversion 0.1 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002222.xml:prod:project_1 --projectversion 0.1 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002257.xml:dev:project_2 --projectversion 0.1 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002338.xml:prod:project_1 --projectversion 1.1 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002400.xml:dev:project_2 --projectversion 1.2 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002431.xml:prod:project_1 --projectversion 1.2 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002457.xml:dev:project_2 --projectversion 1.2 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002528.xml:prod:project_1 --projectversion 2.0 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002549.xml:dev:project_2 --projectversion 2.3 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002636.xml:prod:project_1 --projectversion 2.3 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002703.xml:dev:project_2 --projectversion 2.3 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002739.xml:prod:project_1 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002915.xml:prod:project_1 --projectversion 2.0 -robotdashboard -n robot_dashboard -o .\atest\resources\outputs\output-20250313-003006.xml:prod:project_1 --uselogs \ No newline at end of file +python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002134.xml:prod:project_1 --projectversion 0.1 +python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002151.xml:dev:project_2 --projectversion 0.1 +python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002222.xml:prod:project_1 --projectversion 0.1 +python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002257.xml:dev:project_2 --projectversion 0.1 +python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002338.xml:prod:project_1 --projectversion 1.1 +python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002400.xml:dev:project_2 --projectversion 1.2 +python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002431.xml:prod:project_1 --projectversion 1.2 +python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002457.xml:dev:project_2 --projectversion 1.2 +python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002528.xml:prod:project_1 --projectversion 2.0 +python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002549.xml:dev:project_2 --projectversion 2.3 +python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002636.xml:prod:project_1 --projectversion 2.3 +python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002703.xml:dev:project_2 --projectversion 2.3 +python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002739.xml:prod:project_1 +python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002915.xml:prod:project_1 --projectversion 2.0 +python -m robotframework_dashboard.main -n robot_dashboard -o .\atest\resources\outputs\output-20250313-003006.xml:prod:project_1 --uselogs \ No newline at end of file From 58dbb36f4815e7095b1eee1698768adeadcaead0 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Mon, 9 Feb 2026 17:27:20 +0100 Subject: [PATCH 03/26] Copilot instructions --- .github/copilot-instructions.md | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..764ddfe --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,45 @@ +# Copilot instructions for robotframework-dashboard + +## Role +- You are an expert developer in Python, JavaScript, HTML, and CSS with deep knowledge of the Robot Framework ecosystem and experience building complex data processing pipelines and dashboards. +- You understand the architecture of the robotframework-dashboard project, including its CLI, data processing flow, database interactions, and dashboard generation. +- You are familiar with the project's coding style, conventions, and common patterns, and you can apply this knowledge to maintain consistency across the codebase when implementing new features or fixing bugs. +- You can provide clear, concise, and context-aware code suggestions that align with the project's design principles and user experience goals. + +## Project architecture (big picture) +- CLI entry point is `robotdashboard` -> `robotframework_dashboard.main:main`, which orchestrates: init DB, process outputs, list runs, remove runs, generate HTML. +- Core workflow: output.xml -> `OutputProcessor` (Robot Result Visitor API) -> sqlite DB (`DatabaseProcessor`) -> HTML dashboard via `DashboardGenerator`. +- Dashboard HTML is a template with placeholders replaced at build time; data payloads are zlib-compressed and base64-encoded strings embedded in HTML. +- JS/CSS are merged and inlined by `DependencyProcessor` (topological import resolution for JS modules) and can be switched to CDN or fully offline assets. +- Optional server mode uses FastAPI to host admin + dashboard + API endpoints; the server uses the same `RobotDashboard` pipeline. + +## Key directories and files +- CLI + orchestration: `robotframework_dashboard/main.py`, `robotframework_dashboard/robotdashboard.py`, `robotframework_dashboard/arguments.py`. +- Data extraction: `robotframework_dashboard/processors.py` (visitors for runs/suites/tests/keywords). +- Database: `robotframework_dashboard/database.py` + schema in `robotframework_dashboard/queries.py`. +- HTML templates: `robotframework_dashboard/templates/dashboard.html` and `robotframework_dashboard/templates/admin.html` (placeholders are replaced in `dashboard.py`). +- Dependency inlining and CDN/offline switching: `robotframework_dashboard/dependencies.py`. +- Server: `robotframework_dashboard/server.py` (FastAPI endpoints + admin UI). +- Dashboard JS entry: `robotframework_dashboard/js/main.js` (imports modular setup files). + +## Project-specific conventions and gotchas +- Run identity is `run_start` from output.xml; duplicates are rejected. `run_alias` defaults to file name and may be auto-adjusted to avoid collisions. +- If you add log support, log names must mirror output names (output-XYZ.xml -> log-XYZ.html) for `uselogs` and server log linking. +- `--projectversion` and `version_` tags are mutually exclusive; version tags are parsed from output tags in `RobotDashboard._process_single_output`. +- Custom DB backends are supported via `--databaseclass`; the module must expose a `DatabaseProcessor` class compatible with `AbstractDatabaseProcessor`. +- Offline mode is handled by embedding dependency content into the HTML; do not assume external CDN availability when `--offlinedependencies` is used. + +## Common workflows +- CLI usage and flags: see `docs/basic-command-line-interface-cli.md` (output import, tags, remove runs, dashboard generation). +- Server mode: `robotdashboard --server` or `-s host:port:user:pass` (see `docs/dashboard-server.md` for endpoints and admin UI behavior). +- Docs site: `npm run docs:dev|docs:build|docs:preview` (VitePress in `docs/`). + +## Patterns and style expectations +- Data flow is always: parse outputs -> DB -> HTML. Reuse `RobotDashboard` methods instead of reimplementing this flow. +- When adding new JS modules, update imports so `DependencyProcessor` can resolve module order; all dashboard JS is bundled into one script at generation time. +- Template changes should keep placeholder keys intact (e.g. `placeholder_runs`, `placeholder_css`) because replacements are string-based. +- Python targets 3.8+; keep functions small, use clear exceptions, and follow existing snake_case. +- JS uses modern syntax (const/let, arrow functions) and camelCase; keep functions small to match existing modules. +- HTML should remain semantic and accessible; keep markup minimal and label form controls in templates. +- CSS should use existing class conventions (Bootstrap/Datatables) and keep selectors shallow; prefer variables for theme values. +- Update docs in `docs/` when user-facing behavior changes. From c945bdf60c8bb276abbdf7143840c2552a287610 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Mon, 9 Feb 2026 17:27:40 +0100 Subject: [PATCH 04/26] Add new CSS files for components and dark mode, remove old styling, and update theme handling - Introduced `components.css` for modular styling of UI components including tooltips, cards, and filters. - Added `dark.css` for dark mode styling to enhance user experience in low-light environments. - Removed `styling.css` to streamline CSS management and avoid redundancy. - Updated JavaScript files to accommodate new theme color settings, including highlight color. - Modified HTML to reflect changes in theme settings, consolidating color options for better usability. - Enhanced local storage handling for theme settings and added debug logging for easier troubleshooting. --- robotframework_dashboard/css/base.css | 217 +++++ robotframework_dashboard/css/colors.css | 58 ++ robotframework_dashboard/css/components.css | 397 +++++++++ robotframework_dashboard/css/dark.css | 28 + robotframework_dashboard/css/styling.css | 777 ------------------ robotframework_dashboard/dependencies.py | 2 + robotframework_dashboard/js/eventlisteners.js | 34 +- robotframework_dashboard/js/localstorage.js | 2 + robotframework_dashboard/js/theme.js | 8 +- .../js/variables/chartconfig.js | 56 +- .../js/variables/settings.js | 12 +- .../templates/dashboard.html | 34 +- 12 files changed, 737 insertions(+), 888 deletions(-) create mode 100644 robotframework_dashboard/css/base.css create mode 100644 robotframework_dashboard/css/colors.css create mode 100644 robotframework_dashboard/css/components.css create mode 100644 robotframework_dashboard/css/dark.css delete mode 100644 robotframework_dashboard/css/styling.css diff --git a/robotframework_dashboard/css/base.css b/robotframework_dashboard/css/base.css new file mode 100644 index 0000000..1526e53 --- /dev/null +++ b/robotframework_dashboard/css/base.css @@ -0,0 +1,217 @@ +:root { + font-family: Helvetica, sans-serif; +} + +#overview, +#dashboard, +#unified, +#compare, +#tables { + margin-bottom: 35vh; +} + +body { + background-color: var(--color-bg); + color: var(--color-text); +} + +.border-bottom { + border-color: var(--color-border) !important; +} + +.fullscreen, +.sticky-top, +body { + background-color: var(--color-bg); +} + +.grid-stack { + max-height: 10000px; +} + +.grid-stack-item-content { + background-color: var(--color-card); +} + +.form-switch .form-check-input:not(:checked) { + background-image: var(--switch-thumb-image); + border-color: var(--color-switch-border); +} + +.stat-label { + font-size: 1rem; + color: var(--color-text-muted); +} + +.white-text { + color: var(--color-text); +} + +.navbar .nav-item { + color: var(--color-menu-text); +} + +.navbar .nav-item.active, +.navbar .nav-item:hover, +.active.information-icon, +.active.bar-graph, +.active.line-graph, +.active.fullscreen-graph, +.active.close-graph, +.active.timeline-graph, +.active.radar-graph, +.active.heatmap-graph, +.active.pie-graph, +.active.percentage-graph, +.active.stats-graph, +.active.boxplot-graph, +.active.shown-graph, +.information-icon:hover, +.bar-graph:hover, +.line-graph:hover, +.fullscreen-graph:hover, +.close-graph:hover, +.timeline-graph:hover, +.radar-graph:hover, +.heatmap-graph:hover, +.pie-graph:hover, +.percentage-graph:hover, +.stats-graph:hover, +.boxplot-graph:hover, +.shown-graph:hover, +.collapse-icon:hover, +#rflogo:hover, +#filters:hover, +#customizeLayout:hover, +#saveLayout:hover, +#settings:hover, +#themeDark:hover, +#themeLight:hover, +#database:hover, +#versionInformation:hover, +#bug:hover, +#github:hover, +#docs:hover { + color: var(--color-highlight); +} + +.active.information-icon svg, +.active.bar-graph svg, +.active.line-graph svg, +.active.fullscreen-graph svg, +.active.close-graph svg, +.active.timeline-graph svg, +.active.radar-graph svg, +.active.heatmap-graph svg, +.active.pie-graph svg, +.active.percentage-graph svg, +.active.stats-graph svg, +.active.boxplot-graph svg, +.active.shown-graph svg, +.information-icon:hover svg, +.bar-graph:hover svg, +.line-graph:hover svg, +.fullscreen-graph:hover svg, +.close-graph:hover svg, +.timeline-graph:hover svg, +.radar-graph:hover svg, +.heatmap-graph:hover svg, +.pie-graph:hover svg, +.percentage-graph:hover svg, +.stats-graph:hover svg, +.boxplot-graph:hover svg, +.shown-graph:hover svg, +.hidden-graph:hover svg, +.shown-section:hover svg, +.hidden-section:hover svg, +.collapse-icon:hover svg, +.move-up-table:hover svg, +.move-down-table:hover svg, +.move-up-section:hover svg, +.move-down-section:hover svg, +#filters:hover svg, +#customizeLayout:hover svg, +#saveLayout:hover svg, +#settings:hover svg, +#themeDark:hover svg, +#themeLight:hover svg, +#database:hover svg, +#versionInformation:hover svg, +#bug:hover svg, +#github:hover svg, +#docs:hover svg { + stroke: var(--color-highlight) !important; + fill: none !important; +} + +#rflogo:hover svg path, +#github:hover svg path { + fill: var(--color-highlight) !important; +} + +.html-scroll { + overflow-y: scroll; +} + +.modal-open { + padding-right: 0px !important; +} + +h4, +h5, +h6 { + margin-bottom: 0rem; +} + +body.lock-scroll { + overflow: hidden; +} + +#settings, +#database, +.nav-item, +.information-icon, +.bar-graph, +.line-graph, +.fullscreen-graph, +.close-graph, +.timeline-graph, +.radar-graph, +.heatmap-graph, +.pie-graph, +.percentage-graph, +.stats-graph, +.boxplot-graph, +.shown-graph, +.hidden-graph, +.shown-section, +.hidden-section, +.move-up-table, +.move-down-table, +.move-up-section, +.move-down-section { + cursor: pointer; +} + +.navbar-disabled { + opacity: 0.5; + user-select: none; +} + +.navbar-disabled a { + pointer-events: none; +} + +.navbar-disabled a:hover { + pointer-events: auto; +} + +.navbar { + margin-bottom: 1rem; +} + +#navigation .nav-link svg { + width: 24px; + height: 24px; + display: block; +} diff --git a/robotframework_dashboard/css/colors.css b/robotframework_dashboard/css/colors.css new file mode 100644 index 0000000..a296280 --- /dev/null +++ b/robotframework_dashboard/css/colors.css @@ -0,0 +1,58 @@ +:root { + --color-bg: #eee; + --color-card: #ffffff; + --color-menu-text: #000000; + --color-highlight: #3451b2; + --color-text: #000000; + --color-text-muted: darkgrey; + --color-border: rgba(0, 0, 0, 0.175); + --color-shadow-strong: rgba(0, 0, 0, 0.5); + --color-shadow-soft: rgba(0, 0, 0, 0.3); + --color-tooltip-bg: #ffffff; + --color-tooltip-text: #000000; + --color-switch-border: #000000; + --switch-thumb-image: url("data:image/svg+xml,"); + --color-table-text: #000000; + --color-disabled-text: rgba(173, 181, 189, 0.75); + --color-version-dot: #ec5800; + --color-run-card-hover: #ec5800; + --color-info: dodgerblue; + --color-passed: rgba(151, 189, 97, 0.9); + --color-failed: rgba(206, 62, 1, 0.9); + --color-skipped: rgba(254, 216, 79, 0.9); + --color-modal-bg: #ffffff; + --color-section-card-bg: #ffffff; + --color-section-card-text: var(--color-text); + --color-section-card-border: transparent; + --color-fullscreen-bg: var(--color-bg); +} + +.dark-mode { + color-scheme: dark; + --color-bg: #0f172a; + --color-card: rgba(30, 41, 59, 0.9); + --color-menu-text: #ffffff; + --color-highlight: #a8b1ff; + --color-text: #eee; + --color-text-muted: #9ca3af; + --color-border: rgba(255, 255, 255, 0.15); + --color-shadow-strong: rgba(0, 0, 0, 0.5); + --color-shadow-soft: rgba(0, 0, 0, 0.3); + --color-tooltip-bg: #0f172a; + --color-tooltip-text: #eee; + --color-switch-border: #eee; + --switch-thumb-image: url("data:image/svg+xml,"); + --color-table-text: #eee; + --color-disabled-text: rgba(173, 181, 189, 0.75); + --color-version-dot: #ec5800; + --color-run-card-hover: #ec5800; + --color-info: dodgerblue; + --color-passed: rgba(151, 189, 97, 0.9); + --color-failed: rgba(206, 62, 1, 0.9); + --color-skipped: rgba(254, 216, 79, 0.9); + --color-modal-bg: #0f172a; + --color-section-card-bg: rgba(30, 41, 59, 0.9); + --color-section-card-text: var(--color-text); + --color-section-card-border: rgba(255, 255, 255, 0.1); + --color-fullscreen-bg: rgba(30, 41, 59, 1); +} diff --git a/robotframework_dashboard/css/components.css b/robotframework_dashboard/css/components.css new file mode 100644 index 0000000..aeddfc9 --- /dev/null +++ b/robotframework_dashboard/css/components.css @@ -0,0 +1,397 @@ +.tooltip-popup { + position: fixed; + max-width: 360px; + padding: 8px 12px; + border-radius: 8px; + background-color: var(--color-tooltip-bg); + color: var(--color-tooltip-text); + box-shadow: 0 2px 8px var(--color-shadow-soft); + font-size: 0.95rem; + white-space: pre-line; + pointer-events: none; + z-index: 9999; + box-sizing: border-box; +} + +.card { + margin-bottom: 1rem; + box-shadow: 0 4px 20px var(--color-shadow-strong); + background: var(--color-card); + color: var(--color-text); +} + +.stats { + float: right; + text-align: right; + font-size: 0.9em; + white-space: nowrap; +} + +.section-filters { + display: flex; + flex: 1 1 auto; + flex-wrap: wrap; + align-items: center; + column-gap: 0.75rem; + row-gap: 0.35rem; +} + +.section-filters .filter-group { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.container-fluid .form-check-input, +#sectionFiltersModal .form-check-input { + margin-top: 6px; + position: static; +} + +.fullscreen { + position: fixed !important; + width: 100% !important; + height: 100% !important; + left: 0 !important; + top: 0 !important; + z-index: 10 !important; + padding: 20px 20px 20px 20px !important; + border-radius: 0px !important; + background-color: var(--color-fullscreen-bg); +} + +.dropdown-menu { + width: max-content; +} + +.version-selected-dot { + display: inline-block; + width: 6px; + height: 6px; + background-color: var(--color-version-dot); + border-radius: 50%; + margin-left: 5px; + margin-bottom: 2px; + vertical-align: middle; +} + +.selectBox { + position: relative; +} + +.selectBox select { + width: 100%; +} + +.overSelect { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; +} + +.filterCheckBoxes { + display: none; + position: absolute; + z-index: 2; +} + +.filterCheckBoxes label { + display: block; +} + +.border { + min-height: 42px; +} + +.stat-value { + font-size: 1.5rem; + font-weight: 600; +} + +.fullscreen .stat-value { + font-size: 4rem; +} + +.fullscreen .stat-label { + font-size: 2rem; +} + +.green-text, +.text-passed { + color: var(--color-passed); +} + +.border-passed { + border-color: var(--color-passed); +} + +.red-text, +.text-failed { + color: var(--color-failed); +} + +.border-failed { + border-color: var(--color-failed); +} + +.yellow-text { + color: var(--color-skipped); +} + +.border-skipped { + border-color: var(--color-skipped); +} + +.blue-text { + color: var(--color-info); +} + +.overview-canvas { + height: 200px; +} + +.overview-card { + cursor: pointer; + min-width: 300px; +} + +.overview-card .card { + border-radius: 1rem; +} + +.project-run-cards-container { + display: flex; + flex-wrap: wrap; + column-gap: 24px; +} + +.project-run-cards-container .overview-card { + flex: 0 1 calc((100% - 48px) / 3); +} + +.run-card-version-title { + gap: 0.25rem; + width: fit-content; +} + +.run-card-version-title:hover h5 { + color: var(--color-run-card-hover) !important; +} + +.run-card-small-version { + display: flex; + gap: 0.25rem; + width: fit-content; +} + +.run-card-small-version:hover div { + color: var(--color-run-card-hover) !important; +} + +.grid-stack-item-content { + border-radius: 8px; + display: flex; + flex-direction: column; + padding: 20px; + box-shadow: 0 4px 20px var(--color-shadow-strong); +} + +.graph-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.graph-header h6 { + margin: 0; + font-weight: 600; +} + +.graph-controls { + display: flex; + gap: 8px; +} + +.grid-stack-item-content .graph-body { + flex: 1 1 auto; + display: flex; + overflow-x: hidden; + overflow-y: auto; + width: 100%; +} + +.graph-body .row { + flex-wrap: wrap; + margin-left: 0; + margin-right: 0; +} + +.grid-stack-item-content .graph-body .vertical { + overflow-y: auto; +} + +canvas { + display: block; +} + +.table > :not(caption) > * > * { + color: var(--color-table-text); +} + +#alertContainer { + z-index: 1100; + top: 7rem; + max-width: 80%; +} + +.alert-dismissible { + padding-right: 16px !important; +} + +.grid-stack-item-content:has(.hidden-graph:not([hidden])), +.table-section:has(.hidden-graph:not([hidden])), +.card:has(.hidden-section:not([hidden])) { + opacity: 0.5; +} + +.modal.dimmed { + pointer-events: none; + filter: brightness(0.5); +} + +#runTag { + max-height: 400px; + overflow: auto; +} + +.btn.collapse-icon:active { + border: none; + background-color: transparent; +} + +@media print { + table { + width: 100%; + page-break-inside: auto; + } + + .grid-stack-item-contentvas { + width: 100%; + overflow: hidden; + page-break-inside: avoid; + break-inside: avoid; + display: block; + } + + canvas { + height: auto !important; + max-width: 100% !important; + } +} + +.loader-wrapper { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: calc(100vh - 55px); + display: flex; + justify-content: center; + align-items: center; + background: transparent; +} + +.ball-grid-beat { + width: 120px; + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-gap: 12px; +} + +.ball-grid-beat div { + width: 32px; + height: 32px; + border-radius: 50%; + background-color: var(--color-highlight); + animation: ball-grid-beat 0.7s infinite linear; +} + +.ball-grid-beat div:nth-child(1) { + animation-delay: 0.15s; +} + +.ball-grid-beat div:nth-child(2) { + animation-delay: 0.1s; +} + +.ball-grid-beat div:nth-child(3) { + animation-delay: 0.05s; +} + +.ball-grid-beat div:nth-child(4) { + animation-delay: 0.2s; +} + +.ball-grid-beat div:nth-child(5) { + animation-delay: 0.15s; +} + +.ball-grid-beat div:nth-child(6) { + animation-delay: 0.1s; +} + +.ball-grid-beat div:nth-child(7) { + animation-delay: 0.25s; +} + +.ball-grid-beat div:nth-child(8) { + animation-delay: 0.2s; +} + +.ball-grid-beat div:nth-child(9) { + animation-delay: 0.15s; +} + +@keyframes ball-grid-beat { + 0% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(0.7); + opacity: 0.5; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +#sectionFiltersModal .modal-body { + padding: 1rem; +} + +#sectionFiltersModal .card { + background-color: var(--color-section-card-bg) !important; + border: none; + box-shadow: 0 0.125rem 0.25rem var(--color-shadow-soft); +} + +#sectionFiltersModal .card-header { + background-color: var(--color-section-card-bg) !important; + border-bottom: 1px solid var(--color-section-card-border); + color: var(--color-section-card-text); +} + +#sectionFiltersModal .card-body { + background-color: var(--color-section-card-bg) !important; + color: var(--color-section-card-text); + padding: 1.25rem; +} + +.list-group-item[hidden] { + display: none !important; +} diff --git a/robotframework_dashboard/css/dark.css b/robotframework_dashboard/css/dark.css new file mode 100644 index 0000000..5e25dc1 --- /dev/null +++ b/robotframework_dashboard/css/dark.css @@ -0,0 +1,28 @@ +.dark-mode .modal-dialog { + background: var(--color-modal-bg); + color: var(--color-text); +} + +.dark-mode .modal-content { + background: var(--color-modal-bg); +} + +.dark-mode .list-group-item:not(.disabled), +.dark-mode .form-label, +.dark-mode .form-control, +.dark-mode .form-select { + color: var(--color-text); +} + +.dark-mode .list-group-item .disabled { + color: var(--color-disabled-text); +} + +.dark-mode .collapse-icon { + color: var(--color-text); +} + +.dark-mode .stat-label { + font-size: 0.85rem; + color: var(--color-text-muted); +} diff --git a/robotframework_dashboard/css/styling.css b/robotframework_dashboard/css/styling.css deleted file mode 100644 index 7d03e59..0000000 --- a/robotframework_dashboard/css/styling.css +++ /dev/null @@ -1,777 +0,0 @@ -#overview, -#dashboard, -#unified, -#compare, -#tables { - margin-bottom: 35vh; -} -/* LIGHT MODE STYLING */ -body { - background-color: var(--theme-bg-color, #eee); -} - -.border-bottom { - border-color: rgba(0, 0, 0, 0.175) !important; -} - -.fullscreen, -.sticky-top, -body { - background-color: var(--theme-bg-color, #eee); -} - -.grid-stack { - max-height: 10000px; -} - -.grid-stack-item-content { - background-color: var(--theme-card-color, #ffffff); -} - -.form-switch .form-check-input:not(:checked) { - background-image: url("data:image/svg+xml,"); - border-color: black; -} - -.stat-label { - font-size: 1rem; - color: darkgrey; -} - -.white-text { - color: black; -} - -/* Dark mode */ -.navbar .nav-item { - color: black; -} - -.navbar .nav-item.active, -.navbar .nav-item:hover, -.active.information-icon, -.active.bar-graph, -.active.line-graph, -.active.fullscreen-graph, -.active.close-graph, -.active.timeline-graph, -.active.radar-graph, -.active.heatmap-graph, -.active.pie-graph, -.active.percentage-graph, -.active.stats-graph, -.active.boxplot-graph, -.active.shown-graph, -.information-icon:hover, -.bar-graph:hover, -.line-graph:hover, -.fullscreen-graph:hover, -.close-graph:hover, -.timeline-graph:hover, -.radar-graph:hover, -.heatmap-graph:hover, -.pie-graph:hover, -.percentage-graph:hover, -.stats-graph:hover, -.boxplot-graph:hover, -.shown-graph:hover, -.collapse-icon:hover, -#rflogo:hover, -#filters:hover, -#customizeLayout:hover, -#saveLayout:hover, -#settings:hover, -#themeDark:hover, -#themeLight:hover, -#database:hover, -#versionInformation:hover, -#bug:hover, -#github:hover, -#docs:hover { - color: #3451b2; -} - -/* Light mode SVG strokes */ -.active.information-icon svg, -.active.bar-graph svg, -.active.line-graph svg, -.active.fullscreen-graph svg, -.active.close-graph svg, -.active.timeline-graph svg, -.active.radar-graph svg, -.active.heatmap-graph svg, -.active.pie-graph svg, -.active.percentage-graph svg, -.active.stats-graph svg, -.active.boxplot-graph svg, -.active.shown-graph svg, -.information-icon:hover svg, -.bar-graph:hover svg, -.line-graph:hover svg, -.fullscreen-graph:hover svg, -.close-graph:hover svg, -.timeline-graph:hover svg, -.radar-graph:hover svg, -.heatmap-graph:hover svg, -.pie-graph:hover svg, -.percentage-graph:hover svg, -.stats-graph:hover svg, -.boxplot-graph:hover svg, -.shown-graph:hover svg, -.hidden-graph:hover svg, -.shown-section:hover svg, -.hidden-section:hover svg, -.collapse-icon:hover svg, -.move-up-table:hover svg, -.move-down-table:hover svg, -.move-up-section:hover svg, -.move-down-section:hover svg, -#filters:hover svg, -#customizeLayout:hover svg, -#saveLayout:hover svg, -#settings:hover svg, -#themeDark:hover svg, -#themeLight:hover svg, -#database:hover svg, -#versionInformation:hover svg, -#bug:hover svg, -#github:hover svg, -#docs:hover svg { - stroke: #3451b2 !important; - fill: none !important; -} - -/* Robot logo uses fill instead of stroke */ -#rflogo:hover svg path, -#github:hover svg path { - fill: #3451b2 !important; -} - -/* DARK MODE STYLING */ -.dark-mode :root { - color-scheme: dark; - - /* Dark mode theme color variables */ - --theme-bg-color: #0f172a; - --theme-card-color: rgba(30, 41, 59, 0.9); - --theme-menu-text-color: #ffffff; - --theme-text-color: #eee; - --theme-passed-color: #97bd61; - --theme-skipped-color: #fed84f; - --theme-failed-color: #ce3e01; -} - -.dark-mode .grid-stack-item-content, -.dark-mode .overview-card .card { - background: var(--theme-card-color, rgba(30, 41, 59, 0.9)); -} - -.dark-mode .fullscreen { - background: rgba(30, 41, 59, 1); -} - -.dark-mode .modal-content { - background: var(--theme-bg-color, #0f172a); -} - -.dark-mode .border-bottom { - border-color: rgba(255, 255, 255, 0.15) !important; -} - -.dark-mode .sticky-top, -.dark-mode .card, -.dark-mode body, -.dark-mode .modal-dialog { - background: var(--theme-bg-color, #0f172a); - color: var(--theme-text-color, #eee); -} - -.dark-mode .list-group-item:not(.disabled), -.dark-mode .form-label, -.dark-mode .form-control, -.dark-mode .form-select { - color: var(--theme-text-color, #eee); -} - -.dark-mode .list-group-item .disabled { - color: rgba(173, 181, 189, 0.75); -} - -.dark-mode .form-switch .form-check-input:not(:checked) { - background-image: url("data:image/svg+xml,"); - border-color: #eee; -} - -.dark-mode .table > :not(caption) > * > * { - color: var(--theme-text-color, #eee); -} - -.dark-mode .collapse-icon { - color: var(--theme-text-color, #eee); -} - -.dark-mode .stat-label { - font-size: 0.85rem; - color: #9ca3af; -} - -.dark-mode .white-text { - color: var(--theme-text-color, #eee); -} - -/* Dark mode */ -.dark-mode .nav-item { - color: var(--theme-menu-text-color, white); -} - -.dark-mode .nav-item.active, -.dark-mode .nav-item:hover { - color: #a8b1ff; -} - -/* Dark mode SVG strokes */ -.dark-mode .active.information-icon svg, -.dark-mode .active.bar-graph svg, -.dark-mode .active.line-graph svg, -.dark-mode .active.fullscreen-graph svg, -.dark-mode .active.close-graph svg, -.dark-mode .active.timeline-graph svg, -.dark-mode .active.radar-graph svg, -.dark-mode .active.heatmap-graph svg, -.dark-mode .active.pie-graph svg, -.dark-mode .active.percentage-graph svg, -.dark-mode .active.stats-graph svg, -.dark-mode .active.boxplot-graph svg, -.dark-mode .active.shown-graph svg, -.dark-mode .information-icon:hover svg, -.dark-mode .bar-graph:hover svg, -.dark-mode .line-graph:hover svg, -.dark-mode .fullscreen-graph:hover svg, -.dark-mode .close-graph:hover svg, -.dark-mode .timeline-graph:hover svg, -.dark-mode .radar-graph:hover svg, -.dark-mode .heatmap-graph:hover svg, -.dark-mode .pie-graph:hover svg, -.dark-mode .percentage-graph:hover svg, -.dark-mode .stats-graph:hover svg, -.dark-mode .boxplot-graph:hover svg, -.dark-mode .shown-graph:hover svg, -.dark-mode .hidden-graph:hover svg, -.dark-mode .shown-section:hover svg, -.dark-mode .hidden-section:hover svg, -.dark-mode .collapse-icon:hover svg, -.dark-mode .move-up-table:hover svg, -.dark-mode .move-down-table:hover svg, -.dark-mode .move-up-section:hover svg, -.dark-mode .move-down-section:hover svg, -.dark-mode #filters:hover svg, -.dark-mode #customizeLayout:hover svg, -.dark-mode #saveLayout:hover svg, -.dark-mode #settings:hover svg, -.dark-mode #themeDark:hover svg, -.dark-mode #themeLight:hover svg, -.dark-mode #database:hover svg, -.dark-mode #versionInformation:hover svg, -.dark-mode #bug:hover svg, -.dark-mode #docs:hover svg { - stroke: #a8b1ff !important; -} - -/* Robot logo uses fill instead of stroke */ -.dark-mode #github:hover svg path, -.dark-mode #rflogo:hover svg path { - fill: #a8b1ff !important; -} - -/* GENERAL STYLING */ -:root { - font-family: Helvetica, sans-serif; - - /* Theme color variables */ - --theme-bg-color: #eee; - --theme-card-color: #ffffff; - --theme-menu-text-color: #000000; - --theme-text-color: #000000; - --theme-passed-color: #97bd61; - --theme-skipped-color: #fed84f; - --theme-failed-color: #ce3e01; -} - -.html-scroll { - overflow-y: scroll; -} - -.modal-open { - padding-right: 0px !important; -} - -h4, -h5, -h6 { - margin-bottom: 0rem; -} - -body.lock-scroll { - overflow: hidden; -} - -#settings, -#database, -.nav-item, -.information-icon, -.bar-graph, -.line-graph, -.fullscreen-graph, -.close-graph, -.timeline-graph, -.radar-graph, -.heatmap-graph, -.pie-graph, -.percentage-graph, -.stats-graph, -.boxplot-graph, -.shown-graph, -.hidden-graph, -.shown-section, -.hidden-section, -.move-up-table, -.move-down-table, -.move-up-section, -.move-down-section { - cursor: pointer; -} - -.navbar-disabled { - opacity: 0.5; - user-select: none; -} - -.navbar-disabled a { - pointer-events: none; -} - -.navbar-disabled a:hover { - pointer-events: auto; -} - -.tooltip-popup { - position: fixed; - max-width: 360px; - padding: 8px 12px; - border-radius: 8px; - background-color: white; - color: black; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - font-size: 0.95rem; - white-space: pre-line; - pointer-events: none; - z-index: 9999; - box-sizing: border-box; -} - -.dark-mode .tooltip-popup { - background-color: #0f172a; - color: #eee; -} - -.navbar { - margin-bottom: 1rem; -} - -.card { - margin-bottom: 1rem; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); -} - -.stats { - float: right; - text-align: right; - font-size: 0.9em; - white-space: nowrap; -} - -.section-filters { - display: flex; - flex: 1 1 auto; - flex-wrap: wrap; - align-items: center; - column-gap: 0.75rem; - row-gap: 0.35rem; -} - -.section-filters .filter-group { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.container-fluid .form-check-input, -#sectionFiltersModal .form-check-input { - margin-top: 6px; - position: static; -} - -.fullscreen { - position: fixed !important; - width: 100% !important; - height: 100% !important; - left: 0 !important; - top: 0 !important; - z-index: 10 !important; - padding: 20px 20px 20px 20px !important; - border-radius: 0px !important; -} - -.dropdown-menu { - width: max-content; -} - -.version-selected-dot { - display: inline-block; - width: 6px; - height: 6px; - background-color: #ec5800; - border-radius: 50%; - margin-left: 5px; - margin-bottom: 2px; - vertical-align: middle; -} - -.selectBox { - position: relative; -} - -.selectBox select { - width: 100%; -} - -.overSelect { - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; -} - -.filterCheckBoxes { - display: none; - position: absolute; - z-index: 2; -} - -.filterCheckBoxes label { - display: block; -} - -.border { - min-height: 42px; -} - -.stat-value { - font-size: 1.5rem; - font-weight: 600; -} - -.fullscreen .stat-value { - font-size: 4rem; -} - -.fullscreen .stat-label { - font-size: 2rem; -} - -.green-text, -.text-passed { - color: rgba(151, 189, 97, 0.9); -} - -.border-passed { - border-color: rgba(151, 189, 97, 0.9); -} - -.red-text, -.text-failed { - color: rgba(206, 62, 1, 0.9); -} - -.border-failed { - border-color: rgba(206, 62, 1, 0.9); -} - -.yellow-text { - color: rgba(254, 216, 79, 0.9); -} - -.border-skipped { - border-color: rgba(254, 216, 79, 0.9); -} - -.blue-text { - color: dodgerblue; -} - -.overview-canvas { - height: 200px; -} - -.overview-card { - cursor: pointer; - min-width: 300px; -} - -.overview-card .card { - border-radius: 1rem; -} - -.project-run-cards-container { - display: flex; - flex-wrap: wrap; - column-gap: 24px; -} - -.project-run-cards-container .overview-card { - flex: 0 1 calc((100% - 48px) / 3); /* 3 per row, subtract total gap */ -} - -.run-card-version-title { - gap: 0.25rem; - width: fit-content; -} - -.run-card-version-title:hover h5 { - color: #ec5800 !important; -} - -.run-card-small-version { - display: flex; - gap: 0.25rem; - width: fit-content; -} - -.run-card-small-version:hover div { - color: #ec5800 !important; -} - -.grid-stack-item-content { - border-radius: 8px; - display: flex; - flex-direction: column; - padding: 20px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); -} - -.graph-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; -} - -.graph-header h6 { - margin: 0; - font-weight: 600; -} - -.graph-controls { - display: flex; - gap: 8px; -} - -.grid-stack-item-content .graph-body { - flex: 1 1 auto; - display: flex; - overflow-x: hidden; - overflow-y: auto; - width: 100%; -} - -.graph-body .row { - flex-wrap: wrap; - margin-left: 0; - margin-right: 0; -} - -.grid-stack-item-content .graph-body .vertical { - overflow-y: auto; -} - -canvas { - display: block; -} - -#alertContainer { - z-index: 1100; - top: 7rem; - max-width: 80%; -} - -.alert-dismissible { - padding-right: 16px !important; -} - -.grid-stack-item-content:has(.hidden-graph:not([hidden])), -.table-section:has(.hidden-graph:not([hidden])), -.card:has(.hidden-section:not([hidden])) { - opacity: 0.5; -} - -.modal.dimmed { - pointer-events: none; - filter: brightness(0.5); -} - -#runTag { - max-height: 400px; - overflow: auto; -} - -.btn.collapse-icon:active { - border: none; - background-color: none; -} - -@media print { - table { - width: 100%; - page-break-inside: auto; - } - - .grid-stack-item-contentvas { - width: 100%; - overflow: hidden; - page-break-inside: avoid; - break-inside: avoid; - display: block; - } - - canvas { - height: auto !important; - max-width: 100% !important; - } -} - -/* spinner styling */ -/* Fullscreen center wrapper */ -.loader-wrapper { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: calc(100vh - 55px); - - display: flex; - justify-content: center; - align-items: center; - - background: transparent; /* or any backdrop you prefer */ -} - -/* Make the grid larger */ -.ball-grid-beat { - width: 120px; /* previously 60px → 2× size */ - display: grid; - grid-template-columns: repeat(3, 1fr); - grid-gap: 12px; /* doubled from 6px */ -} - -.ball-grid-beat div { - width: 32px; /* doubled from 16px */ - height: 32px; - border-radius: 50%; - background-color: #3451b2; /* change color to match your theme */ - animation: ball-grid-beat 0.7s infinite linear; -} - -.dark-mode .ball-grid-beat div { - background-color: #a8b1ff; /* change color to match dark mode theme */ -} - -/* Animation timing (same pattern, reused) */ -.ball-grid-beat div:nth-child(1) { - animation-delay: 0.15s; -} -.ball-grid-beat div:nth-child(2) { - animation-delay: 0.1s; -} -.ball-grid-beat div:nth-child(3) { - animation-delay: 0.05s; -} -.ball-grid-beat div:nth-child(4) { - animation-delay: 0.2s; -} -.ball-grid-beat div:nth-child(5) { - animation-delay: 0.15s; -} -.ball-grid-beat div:nth-child(6) { - animation-delay: 0.1s; -} -.ball-grid-beat div:nth-child(7) { - animation-delay: 0.25s; -} -.ball-grid-beat div:nth-child(8) { - animation-delay: 0.2s; -} -.ball-grid-beat div:nth-child(9) { - animation-delay: 0.15s; -} - -@keyframes ball-grid-beat { - 0% { - transform: scale(1); - opacity: 1; - } - 50% { - transform: scale(0.7); - opacity: 0.5; - } - 100% { - transform: scale(1); - opacity: 1; - } -} - -/* Section Filters Modal Styling */ -#sectionFiltersModal .modal-body { - padding: 1rem; -} - -.dark-mode #sectionFiltersModal .card { - background-color: rgba(30, 41, 59, 0.9) !important; - border: none; - box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); -} - -.dark-mode #sectionFiltersModal .card-header { - background-color: rgba(30, 41, 59, 0.9) !important; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - color: white; -} - -.dark-mode #sectionFiltersModal .card-body { - background-color: rgba(30, 41, 59, 0.9) !important; - padding: 1.25rem; -} - -#sectionFiltersModal .card { - background-color: white !important; -} - -#sectionFiltersModal .card-header { - background-color: white !important; - color: black; -} - -#sectionFiltersModal .card-body { - background-color: white !important; -} - -/* Ensure hidden list-group items are properly hidden */ -.list-group-item[hidden] { - display: none !important; -} - -#navigation .nav-link svg { - width: 24px; - height: 24px; - display: block; -} diff --git a/robotframework_dashboard/dependencies.py b/robotframework_dashboard/dependencies.py index 992677c..f5d0d5a 100644 --- a/robotframework_dashboard/dependencies.py +++ b/robotframework_dashboard/dependencies.py @@ -219,4 +219,6 @@ def _gather_files(self, folder: str): files.append(str(relpath(p, base))) + print(files) + return files diff --git a/robotframework_dashboard/js/eventlisteners.js b/robotframework_dashboard/js/eventlisteners.js index 7a9a359..d8ed243 100644 --- a/robotframework_dashboard/js/eventlisteners.js +++ b/robotframework_dashboard/js/eventlisteners.js @@ -269,14 +269,7 @@ function setup_settings_modal() { document.getElementById("themeLight").addEventListener("click", () => toggle_theme()); document.getElementById("themeDark").addEventListener("click", () => toggle_theme()); - // Theme color handlers - function get_current_theme_defaults() { - const isDarkMode = document.documentElement.classList.contains("dark-mode"); - const themeMode = isDarkMode ? 'dark' : 'light'; - return settings.theme_colors[themeMode]; - } - - function create_theme_color_handler(colorKey, elementId, resetButtonId) { + function create_theme_color_handler(colorKey, elementId) { function load_color() { const element = document.getElementById(elementId); const isDarkMode = document.documentElement.classList.contains("dark-mode"); @@ -333,43 +326,34 @@ function setup_settings_modal() { return { load_color, update_color, reset_color }; } - const backgroundColorHandler = create_theme_color_handler('background', 'themeBackgroundColor', 'resetBackgroundColor'); - const cardColorHandler = create_theme_color_handler('card', 'themeCardColor', 'resetCardColor'); - const menuTextColorHandler = create_theme_color_handler('menuText', 'themeMenuTextColor', 'resetMenuTextColor'); - const textColorHandler = create_theme_color_handler('text', 'themeTextColor', 'resetTextColor'); - const passedColorHandler = create_theme_color_handler('passed', 'themePassedColor', 'resetPassedColor'); - const skippedColorHandler = create_theme_color_handler('skipped', 'themeSkippedColor', 'resetSkippedColor'); - const failedColorHandler = create_theme_color_handler('failed', 'themeFailedColor', 'resetFailedColor'); + const backgroundColorHandler = create_theme_color_handler('background', 'themeBackgroundColor'); + const cardColorHandler = create_theme_color_handler('card', 'themeCardColor'); + const menuTextColorHandler = create_theme_color_handler('menuText', 'themeMenuTextColor'); + const highlightColorHandler = create_theme_color_handler('highlight', 'themeHighlightColor'); + const textColorHandler = create_theme_color_handler('text', 'themeTextColor'); // Load colors on modal open $("#settingsModal").on("shown.bs.modal", function () { backgroundColorHandler.load_color(); cardColorHandler.load_color(); menuTextColorHandler.load_color(); + highlightColorHandler.load_color(); textColorHandler.load_color(); - passedColorHandler.load_color(); - skippedColorHandler.load_color(); - failedColorHandler.load_color(); }); // Add event listeners for color inputs document.getElementById('themeBackgroundColor').addEventListener('change', () => backgroundColorHandler.update_color()); document.getElementById('themeCardColor').addEventListener('change', () => cardColorHandler.update_color()); document.getElementById('themeMenuTextColor').addEventListener('change', () => menuTextColorHandler.update_color()); + document.getElementById('themeHighlightColor').addEventListener('change', () => highlightColorHandler.update_color()); document.getElementById('themeTextColor').addEventListener('change', () => textColorHandler.update_color()); - document.getElementById('themePassedColor').addEventListener('change', () => passedColorHandler.update_color()); - document.getElementById('themeSkippedColor').addEventListener('change', () => skippedColorHandler.update_color()); - document.getElementById('themeFailedColor').addEventListener('change', () => failedColorHandler.update_color()); // Add event listeners for reset buttons document.getElementById('resetBackgroundColor').addEventListener('click', () => backgroundColorHandler.reset_color()); document.getElementById('resetCardColor').addEventListener('click', () => cardColorHandler.reset_color()); document.getElementById('resetMenuTextColor').addEventListener('click', () => menuTextColorHandler.reset_color()); + document.getElementById('resetHighlightColor').addEventListener('click', () => highlightColorHandler.reset_color()); document.getElementById('resetTextColor').addEventListener('click', () => textColorHandler.reset_color()); - document.getElementById('resetPassedColor').addEventListener('click', () => passedColorHandler.reset_color()); - document.getElementById('resetSkippedColor').addEventListener('click', () => skippedColorHandler.reset_color()); - document.getElementById('resetFailedColor').addEventListener('click', () => failedColorHandler.reset_color()); - function show_settings_in_textarea() { const textArea = document.getElementById("settingsTextArea"); diff --git a/robotframework_dashboard/js/localstorage.js b/robotframework_dashboard/js/localstorage.js index ecd7388..09699dd 100644 --- a/robotframework_dashboard/js/localstorage.js +++ b/robotframework_dashboard/js/localstorage.js @@ -19,7 +19,9 @@ function setup_local_storage() { } else if (storedSettings) { // 2) Prefer existing localStorage when not forcing a config const parsedSettings = JSON.parse(storedSettings); + console.log(settings, parsedSettings) resolvedSettings = mergeWithDefaults(parsedSettings); + console.log(parsedSettings) } else if (!force_json_config && hasJsonConfig) { // 3) Use provided json_config when not forcing and no localStorage present resolvedSettings = mergeWithDefaults(json_config); diff --git a/robotframework_dashboard/js/theme.js b/robotframework_dashboard/js/theme.js index d96639f..db58c46 100644 --- a/robotframework_dashboard/js/theme.js +++ b/robotframework_dashboard/js/theme.js @@ -229,20 +229,16 @@ function apply_theme_colors() { background: customColors.background || defaultColors.background, card: customColors.card || defaultColors.card, menuText: customColors.menuText || defaultColors.menuText, + highlight: customColors.highlight || defaultColors.highlight, text: customColors.text || defaultColors.text, - passed: customColors.passed || defaultColors.passed, - skipped: customColors.skipped || defaultColors.skipped, - failed: customColors.failed || defaultColors.failed, }; // Set CSS custom properties root.style.setProperty('--theme-bg-color', finalColors.background); root.style.setProperty('--theme-card-color', finalColors.card); root.style.setProperty('--theme-menu-text-color', finalColors.menuText); + root.style.setProperty('--theme-highlight-color', finalColors.highlight); root.style.setProperty('--theme-text-color', finalColors.text); - root.style.setProperty('--theme-passed-color', finalColors.passed); - root.style.setProperty('--theme-skipped-color', finalColors.skipped); - root.style.setProperty('--theme-failed-color', finalColors.failed); } export { diff --git a/robotframework_dashboard/js/variables/chartconfig.js b/robotframework_dashboard/js/variables/chartconfig.js index c6ff8ae..991e936 100644 --- a/robotframework_dashboard/js/variables/chartconfig.js +++ b/robotframework_dashboard/js/variables/chartconfig.js @@ -1,55 +1,12 @@ import { settings } from "./settings.js"; -// Helper function to convert hex to rgba with opacity -function hexToRgba(hex, alpha) { - // Handle hex colors - if (hex.startsWith('#')) { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `rgba(${r}, ${g}, ${b}, ${alpha})`; - } - // If already rgba, return as is - return hex; -} - -// Function to get color values based on current theme -function getThemeBasedColors() { - const root = document.documentElement; - const isDarkMode = root.classList.contains("dark-mode"); - const themeMode = isDarkMode ? 'dark' : 'light'; - - // Get default colors for current theme mode - const defaultColors = settings.theme_colors[themeMode]; - - // Get custom colors if they exist - const customColors = settings.theme_colors?.custom?.[themeMode] || {}; - - // Return final colors (custom overrides default) - const passed = customColors.passed || defaultColors.passed; - const skipped = customColors.skipped || defaultColors.skipped; - const failed = customColors.failed || defaultColors.failed; - - return { - passedBackgroundBorderColor: passed, - passedBackgroundColor: hexToRgba(passed, 0.7), - skippedBackgroundBorderColor: skipped, - skippedBackgroundColor: hexToRgba(skipped, 0.7), - failedBackgroundBorderColor: failed, - failedBackgroundColor: hexToRgba(failed, 0.7), - }; -} - -// Get initial colors -const colors = getThemeBasedColors(); - // colors -const passedBackgroundBorderColor = colors.passedBackgroundBorderColor; -const passedBackgroundColor = colors.passedBackgroundColor; -const skippedBackgroundBorderColor = colors.skippedBackgroundBorderColor; -const skippedBackgroundColor = colors.skippedBackgroundColor; -const failedBackgroundBorderColor = colors.failedBackgroundBorderColor; -const failedBackgroundColor = colors.failedBackgroundColor; +const passedBackgroundBorderColor = '#97bd61'; +const passedBackgroundColor = '#97bd61'; +const skippedBackgroundBorderColor = '#fed84f'; +const skippedBackgroundColor = '#fed84f'; +const failedBackgroundBorderColor = '#ce3e01'; +const failedBackgroundColor = '#ce3e01'; const greyBackgroundBorderColor = "#0f172a"; const greyBackgroundColor = "rgba(33, 37, 41, 0.7)"; const blueBackgroundBorderColor = "rgba(54, 162, 235)"; @@ -128,5 +85,4 @@ export { blueConfig, lineConfig, dataLabelConfig, - getThemeBasedColors }; \ No newline at end of file diff --git a/robotframework_dashboard/js/variables/settings.js b/robotframework_dashboard/js/variables/settings.js index 2c0cd83..6cab51f 100644 --- a/robotframework_dashboard/js/variables/settings.js +++ b/robotframework_dashboard/js/variables/settings.js @@ -36,19 +36,19 @@ var settings = { background: '#eee', card: '#ffffff', menuText: '#000000', + highlight: '#3451b2', text: '#000000', - passed: '#97bd61', - skipped: '#fed84f', - failed: '#ce3e01', }, dark: { background: '#0f172a', card: 'rgba(30, 41, 59, 0.9)', + highlight: '#a8b1ff', menuText: '#ffffff', text: '#eee', - passed: '#97bd61', - skipped: '#fed84f', - failed: '#ce3e01', + }, + custom: { + light: {}, + dark: {}, } }, menu: { diff --git a/robotframework_dashboard/templates/dashboard.html b/robotframework_dashboard/templates/dashboard.html index a44f3bb..89f9161 100644 --- a/robotframework_dashboard/templates/dashboard.html +++ b/robotframework_dashboard/templates/dashboard.html @@ -629,14 +629,14 @@

Settings

data-bs-target="#keywords" type="button" role="tab" aria-controls="keywords" aria-selected="false">Keywords - +
@@ -771,31 +771,17 @@

Settings

- Text Color -
- - -
-
-
- Passed Color + Menu Highlight Color
- - + +
- Skipped Color -
- - -
-
-
- Failed Color + Text Color
- - + +
From d2bea24168bbccb35cd9706dddcf7fd7e8223f91 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Mon, 9 Feb 2026 17:28:56 +0100 Subject: [PATCH 05/26] Remove debug print statement from DependencyProcessor class --- robotframework_dashboard/dependencies.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/robotframework_dashboard/dependencies.py b/robotframework_dashboard/dependencies.py index f5d0d5a..992677c 100644 --- a/robotframework_dashboard/dependencies.py +++ b/robotframework_dashboard/dependencies.py @@ -219,6 +219,4 @@ def _gather_files(self, folder: str): files.append(str(relpath(p, base))) - print(files) - return files From 4237de2f78c504cd4064c627f840512701a2ac6e Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Mon, 9 Feb 2026 21:50:34 +0100 Subject: [PATCH 06/26] Fixed wrong colors --- robotframework_dashboard/css/components.css | 3 ++- robotframework_dashboard/js/variables/chartconfig.js | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/robotframework_dashboard/css/components.css b/robotframework_dashboard/css/components.css index aeddfc9..cf1e6a6 100644 --- a/robotframework_dashboard/css/components.css +++ b/robotframework_dashboard/css/components.css @@ -16,7 +16,7 @@ .card { margin-bottom: 1rem; box-shadow: 0 4px 20px var(--color-shadow-strong); - background: var(--color-card); + background: var(--color-bg); color: var(--color-text); } @@ -196,6 +196,7 @@ flex-direction: column; padding: 20px; box-shadow: 0 4px 20px var(--color-shadow-strong); + background-color: var(--color-section-card-bg) } .graph-header { diff --git a/robotframework_dashboard/js/variables/chartconfig.js b/robotframework_dashboard/js/variables/chartconfig.js index 991e936..d883f04 100644 --- a/robotframework_dashboard/js/variables/chartconfig.js +++ b/robotframework_dashboard/js/variables/chartconfig.js @@ -1,12 +1,12 @@ import { settings } from "./settings.js"; // colors -const passedBackgroundBorderColor = '#97bd61'; -const passedBackgroundColor = '#97bd61'; -const skippedBackgroundBorderColor = '#fed84f'; -const skippedBackgroundColor = '#fed84f'; -const failedBackgroundBorderColor = '#ce3e01'; -const failedBackgroundColor = '#ce3e01'; +const passedBackgroundBorderColor = "#97bd61"; +const passedBackgroundColor = "rgba(151, 189, 97, 0.7)"; +const skippedBackgroundBorderColor = "#fed84f"; +const skippedBackgroundColor = "rgba(254, 216, 79, 0.7)"; +const failedBackgroundBorderColor = "#ce3e01"; +const failedBackgroundColor = "rgba(206, 62, 1, 0.7)"; const greyBackgroundBorderColor = "#0f172a"; const greyBackgroundColor = "rgba(33, 37, 41, 0.7)"; const blueBackgroundBorderColor = "rgba(54, 162, 235)"; From b579cf6d1133cd14a549f131026cd2fe534f0d00 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Thu, 19 Feb 2026 00:26:30 +0100 Subject: [PATCH 07/26] Refactor graph creation functions to separate configuration building and chart creation; add update functions for dynamic chart updates; enhance loading overlay functionality in menu. --- robotframework_dashboard/css/styling.css | 48 ++ robotframework_dashboard/js/common.js | 36 +- robotframework_dashboard/js/eventlisteners.js | 414 +++++++++++------- .../js/graph_creation/all.js | 112 ++++- .../js/graph_creation/compare.js | 80 +++- .../js/graph_creation/keyword.js | 245 ++++++++--- .../js/graph_creation/run.js | 144 ++++-- .../js/graph_creation/suite.js | 191 ++++++-- .../js/graph_creation/tables.js | 106 +++-- .../js/graph_creation/test.js | 243 +++++++--- robotframework_dashboard/js/menu.js | 31 +- robotframework_dashboard/js/theme.js | 4 +- 12 files changed, 1261 insertions(+), 393 deletions(-) diff --git a/robotframework_dashboard/css/styling.css b/robotframework_dashboard/css/styling.css index 8cdfba3..dabf9c5 100644 --- a/robotframework_dashboard/css/styling.css +++ b/robotframework_dashboard/css/styling.css @@ -712,6 +712,54 @@ canvas { } } +/* Individual graph loading overlay */ +.graph-loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + background: rgba(238, 238, 238, 0.6); + border-radius: 8px; + z-index: 10; +} + +.dark-mode .graph-loading-overlay { + background: rgba(30, 41, 59, 0.6); +} + +/* Smaller ball-grid-beat for individual graph overlays */ +.ball-grid-beat-sm { + width: 60px; + grid-gap: 6px; +} + +.ball-grid-beat-sm div { + width: 16px; + height: 16px; +} + +/* Full-page loading overlay for filter updates */ +.filter-loading-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(238, 238, 238, 0.7); + z-index: 1040; + display: flex; + justify-content: center; + align-items: center; +} + +.dark-mode .filter-loading-overlay { + background: rgba(30, 41, 59, 0.7); +} + /* Section Filters Modal Styling */ #sectionFiltersModal .modal-body { padding: 1rem; diff --git a/robotframework_dashboard/js/common.js b/robotframework_dashboard/js/common.js index 3733742..9a6d130 100644 --- a/robotframework_dashboard/js/common.js +++ b/robotframework_dashboard/js/common.js @@ -153,6 +153,37 @@ function debounce(func, delay) { }; } +// Show a loading overlay on an individual graph's container +function show_graph_loading(elementId) { + const el = document.getElementById(elementId); + if (!el) return; + const container = el.closest('.grid-stack-item-content') || el.closest('.table-section'); + if (!container || container.querySelector('.graph-loading-overlay')) return; + const overlay = document.createElement('div'); + overlay.className = 'graph-loading-overlay'; + overlay.innerHTML = '
'; + container.appendChild(overlay); +} + +// Hide the loading overlay from an individual graph's container +function hide_graph_loading(elementId) { + const el = document.getElementById(elementId); + if (!el) return; + const container = el.closest('.grid-stack-item-content') || el.closest('.table-section'); + if (!container) return; + const overlay = container.querySelector('.graph-loading-overlay'); + if (overlay) overlay.remove(); +} + +// Show loading overlays on multiple graphs, run updateFn, then hide overlays +function update_graphs_with_loading(elementIds, updateFn) { + elementIds.forEach(id => show_graph_loading(id)); + setTimeout(() => { + updateFn(); + elementIds.forEach(id => hide_graph_loading(id)); + }, 0); +} + export { camelcase_to_underscore, get_next_folder_level, @@ -165,5 +196,8 @@ export { combine_paths, add_alert, close_alert, - debounce + debounce, + show_graph_loading, + hide_graph_loading, + update_graphs_with_loading }; \ No newline at end of file diff --git a/robotframework_dashboard/js/eventlisteners.js b/robotframework_dashboard/js/eventlisteners.js index 751c216..57f87df 100644 --- a/robotframework_dashboard/js/eventlisteners.js +++ b/robotframework_dashboard/js/eventlisteners.js @@ -14,10 +14,12 @@ import { } from "./variables/globals.js"; import { arrowDown, arrowRight } from "./variables/svg.js"; import { fullscreenButtons, graphChangeButtons, compareRunIds } from "./variables/graphs.js"; -import { add_alert } from "./common.js"; +import { add_alert, show_graph_loading, hide_graph_loading, update_graphs_with_loading } from "./common.js"; import { toggle_theme } from "./theme.js"; -import { setup_data_and_graphs, update_menu } from "./menu.js"; +import { setup_data_and_graphs, show_loading_overlay, hide_loading_overlay, update_menu } from "./menu.js"; +import { update_dashboard_graphs } from "./graph_creation/all.js"; import { + setup_filtered_data_and_filters, setup_run_amount_filter, setup_lowest_highest_dates, clear_all_filters, @@ -42,48 +44,56 @@ import { set_filter_show_current_version, update_overview_filter_visibility, } from "./graph_creation/overview.js"; -import { create_run_donut_total_graph, create_run_heatmap_graph } from "./graph_creation/run.js"; +import { update_run_donut_total_graph, update_run_heatmap_graph } from "./graph_creation/run.js"; import { - create_suite_duration_graph, - create_suite_statistics_graph, - create_suite_most_failed_graph, - create_suite_most_time_consuming_graph, - create_suite_folder_donut_graph, - create_suite_folder_fail_donut_graph, + update_suite_duration_graph, + update_suite_statistics_graph, + update_suite_most_failed_graph, + update_suite_most_time_consuming_graph, + update_suite_folder_donut_graph, + update_suite_folder_fail_donut_graph, } from "./graph_creation/suite.js"; import { - create_test_statistics_graph, - create_test_duration_graph, - create_test_duration_deviation_graph, - create_test_messages_graph, - create_test_most_flaky_graph, - create_test_recent_most_flaky_graph, - create_test_most_failed_graph, - create_test_recent_most_failed_graph, - create_test_most_time_consuming_graph, + update_test_statistics_graph, + update_test_duration_graph, + update_test_duration_deviation_graph, + update_test_messages_graph, + update_test_most_flaky_graph, + update_test_recent_most_flaky_graph, + update_test_most_failed_graph, + update_test_recent_most_failed_graph, + update_test_most_time_consuming_graph, } from "./graph_creation/test.js"; import { - create_keyword_statistics_graph, - create_keyword_times_run_graph, - create_keyword_total_duration_graph, - create_keyword_average_duration_graph, - create_keyword_min_duration_graph, - create_keyword_max_duration_graph, - create_keyword_most_failed_graph, - create_keyword_most_time_consuming_graph, - create_keyword_most_used_graph, + update_keyword_statistics_graph, + update_keyword_times_run_graph, + update_keyword_total_duration_graph, + update_keyword_average_duration_graph, + update_keyword_min_duration_graph, + update_keyword_max_duration_graph, + update_keyword_most_failed_graph, + update_keyword_most_time_consuming_graph, + update_keyword_most_used_graph, } from "./graph_creation/keyword.js"; import { - create_compare_statistics_graph, - create_compare_suite_duration_graph, - create_compare_tests_graph, + update_compare_statistics_graph, + update_compare_suite_duration_graph, + update_compare_tests_graph, } from "./graph_creation/compare.js"; // function to setup filter modal eventlisteners function setup_filter_modal() { // eventlistener to catch the closing of the filter modal + // Only recompute filtered data and update graphs in-place (no layout rebuild needed) $("#filtersModal").on("hidden.bs.modal", function () { - setup_data_and_graphs(); + show_loading_overlay(); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setup_filtered_data_and_filters(); + update_dashboard_graphs(); + hide_loading_overlay(); + }); + }); }); // eventlistener to reset the filters document.getElementById("resetFilters").addEventListener("click", function () { @@ -382,28 +392,36 @@ function setup_sections_filters() { document.getElementById("switchRunTags").addEventListener("click", function () { settings.switch.runTags = !settings.switch.runTags update_switch_local_storage("switch.runTags", settings.switch.runTags); - // create latest and total bars and set visibility - create_overview_latest_graphs(); - update_overview_latest_heading(); - create_overview_total_graphs(); - update_overview_total_heading(); - update_overview_sections_visibility(); - // update all tagged bars - update_overview_version_select_list(); - update_projectbar_visibility(); + show_loading_overlay(); + requestAnimationFrame(() => { + // create latest and total bars and set visibility + create_overview_latest_graphs(); + update_overview_latest_heading(); + create_overview_total_graphs(); + update_overview_total_heading(); + update_overview_sections_visibility(); + // update all tagged bars + update_overview_version_select_list(); + update_projectbar_visibility(); + hide_loading_overlay(); + }); }); document.getElementById("switchRunName").addEventListener("click", function () { settings.switch.runName = !settings.switch.runName update_switch_local_storage("switch.runName", settings.switch.runName); - // create latest and total bars and set visibility - create_overview_latest_graphs(); - update_overview_latest_heading(); - create_overview_total_graphs(); - update_overview_total_heading(); - update_overview_sections_visibility(); - // update all named project bars - update_overview_version_select_list(); - update_projectbar_visibility(); + show_loading_overlay(); + requestAnimationFrame(() => { + // create latest and total bars and set visibility + create_overview_latest_graphs(); + update_overview_latest_heading(); + create_overview_total_graphs(); + update_overview_total_heading(); + update_overview_sections_visibility(); + // update all named project bars + update_overview_version_select_list(); + update_projectbar_visibility(); + hide_loading_overlay(); + }); }); document.getElementById("switchLatestRuns").addEventListener("click", function () { settings.switch.latestRuns = !settings.switch.latestRuns @@ -431,86 +449,135 @@ function setup_sections_filters() { update_overview_filter_visibility(); }); document.getElementById("suiteSelectSuites").addEventListener("change", () => { - create_suite_duration_graph(); - create_suite_statistics_graph(); + update_graphs_with_loading(["suiteStatisticsGraph", "suiteDurationGraph"], () => { + update_suite_duration_graph(); + update_suite_statistics_graph(); + }); }); update_switch_local_storage("switch.suitePathsSuiteSection", settings.switch.suitePathsSuiteSection, true); document.getElementById("switchSuitePathsSuiteSection").addEventListener("change", (e) => { settings.switch.suitePathsSuiteSection = !settings.switch.suitePathsSuiteSection; update_switch_local_storage("switch.suitePathsSuiteSection", settings.switch.suitePathsSuiteSection); - setup_suites_in_suite_select(); - create_suite_statistics_graph(); - create_suite_duration_graph(); - create_suite_most_failed_graph(); - create_suite_most_time_consuming_graph(); + update_graphs_with_loading( + ["suiteStatisticsGraph", "suiteDurationGraph", "suiteMostFailedGraph", "suiteMostTimeConsumingGraph"], + () => { + setup_suites_in_suite_select(); + update_suite_statistics_graph(); + update_suite_duration_graph(); + update_suite_most_failed_graph(); + update_suite_most_time_consuming_graph(); + } + ); }); document.getElementById("resetSuiteFolder").addEventListener("click", () => { - create_suite_folder_donut_graph(""); + update_graphs_with_loading(["suiteFolderDonutGraph"], () => { + update_suite_folder_donut_graph(""); + }); }); document.getElementById("suiteSelectTests").addEventListener("change", () => { - setup_testtags_in_select(); - setup_tests_in_select(); - create_test_statistics_graph(); - create_test_duration_graph(); - create_test_duration_deviation_graph(); + update_graphs_with_loading( + ["testStatisticsGraph", "testDurationGraph", "testDurationDeviationGraph"], + () => { + setup_testtags_in_select(); + setup_tests_in_select(); + update_test_statistics_graph(); + update_test_duration_graph(); + update_test_duration_deviation_graph(); + } + ); }); update_switch_local_storage("switch.suitePathsTestSection", settings.switch.suitePathsTestSection, true); document.getElementById("switchSuitePathsTestSection").addEventListener("change", () => { settings.switch.suitePathsTestSection = !settings.switch.suitePathsTestSection; update_switch_local_storage("switch.suitePathsTestSection", settings.switch.suitePathsTestSection); - setup_suites_in_test_select(); - create_test_statistics_graph(); - create_test_duration_graph(); - create_test_duration_deviation_graph(); - create_test_messages_graph(); - create_test_most_flaky_graph(); - create_test_recent_most_flaky_graph(); - create_test_most_failed_graph(); - create_test_recent_most_failed_graph(); - create_test_most_time_consuming_graph(); + update_graphs_with_loading( + ["testStatisticsGraph", "testDurationGraph", "testDurationDeviationGraph", "testMessagesGraph", + "testMostFlakyGraph", "testRecentMostFlakyGraph", "testMostFailedGraph", + "testRecentMostFailedGraph", "testMostTimeConsumingGraph"], + () => { + setup_suites_in_test_select(); + update_test_statistics_graph(); + update_test_duration_graph(); + update_test_duration_deviation_graph(); + update_test_messages_graph(); + update_test_most_flaky_graph(); + update_test_recent_most_flaky_graph(); + update_test_most_failed_graph(); + update_test_recent_most_failed_graph(); + update_test_most_time_consuming_graph(); + } + ); }); document.getElementById("testTagsSelect").addEventListener("change", () => { - setup_tests_in_select(); - create_test_statistics_graph(); - create_test_duration_graph(); - create_test_duration_deviation_graph(); + update_graphs_with_loading( + ["testStatisticsGraph", "testDurationGraph", "testDurationDeviationGraph"], + () => { + setup_tests_in_select(); + update_test_statistics_graph(); + update_test_duration_graph(); + update_test_duration_deviation_graph(); + } + ); }); document.getElementById("testSelect").addEventListener("change", () => { - create_test_statistics_graph(); - create_test_duration_graph(); - create_test_duration_deviation_graph(); + update_graphs_with_loading( + ["testStatisticsGraph", "testDurationGraph", "testDurationDeviationGraph"], + () => { + update_test_statistics_graph(); + update_test_duration_graph(); + update_test_duration_deviation_graph(); + } + ); }); document.getElementById("keywordSelect").addEventListener("change", () => { - create_keyword_statistics_graph(); - create_keyword_times_run_graph(); - create_keyword_total_duration_graph(); - create_keyword_average_duration_graph(); - create_keyword_min_duration_graph(); - create_keyword_max_duration_graph(); + update_graphs_with_loading( + ["keywordStatisticsGraph", "keywordTimesRunGraph", "keywordTotalDurationGraph", + "keywordAverageDurationGraph", "keywordMinDurationGraph", "keywordMaxDurationGraph"], + () => { + update_keyword_statistics_graph(); + update_keyword_times_run_graph(); + update_keyword_total_duration_graph(); + update_keyword_average_duration_graph(); + update_keyword_min_duration_graph(); + update_keyword_max_duration_graph(); + } + ); }); update_switch_local_storage("switch.useLibraryNames", settings.switch.useLibraryNames, true); document.getElementById("switchUseLibraryNames").addEventListener("change", () => { settings.switch.useLibraryNames = !settings.switch.useLibraryNames; update_switch_local_storage("switch.useLibraryNames", settings.switch.useLibraryNames); - setup_keywords_in_select(); - create_keyword_statistics_graph(); - create_keyword_times_run_graph(); - create_keyword_total_duration_graph(); - create_keyword_average_duration_graph(); - create_keyword_min_duration_graph(); - create_keyword_max_duration_graph(); - create_keyword_most_failed_graph(); - create_keyword_most_time_consuming_graph(); - create_keyword_most_used_graph(); + update_graphs_with_loading( + ["keywordStatisticsGraph", "keywordTimesRunGraph", "keywordTotalDurationGraph", + "keywordAverageDurationGraph", "keywordMinDurationGraph", "keywordMaxDurationGraph", + "keywordMostFailedGraph", "keywordMostTimeConsumingGraph", "keywordMostUsedGraph"], + () => { + setup_keywords_in_select(); + update_keyword_statistics_graph(); + update_keyword_times_run_graph(); + update_keyword_total_duration_graph(); + update_keyword_average_duration_graph(); + update_keyword_min_duration_graph(); + update_keyword_max_duration_graph(); + update_keyword_most_failed_graph(); + update_keyword_most_time_consuming_graph(); + update_keyword_most_used_graph(); + } + ); }); // compare filters compareRunIds.forEach(id => { const element = document.getElementById(id); if (element) { element.addEventListener('change', () => { - create_compare_statistics_graph(); - create_compare_suite_duration_graph(); - create_compare_tests_graph(); + update_graphs_with_loading( + ["compareStatisticsGraph", "compareSuiteDurationGraph", "compareTestsGraph"], + () => { + update_compare_statistics_graph(); + update_compare_suite_duration_graph(); + update_compare_tests_graph(); + } + ); }); } }); @@ -518,9 +585,14 @@ function setup_sections_filters() { document.getElementById("switchSuitePathsCompareSection").addEventListener("change", (e) => { settings.switch.suitePathsCompareSection = !settings.switch.suitePathsCompareSection; update_switch_local_storage("switch.suitePathsCompareSection", settings.switch.suitePathsCompareSection); - create_compare_statistics_graph(); - create_compare_suite_duration_graph(); - create_compare_tests_graph(); + update_graphs_with_loading( + ["compareStatisticsGraph", "compareSuiteDurationGraph", "compareTestsGraph"], + () => { + update_compare_statistics_graph(); + update_compare_suite_duration_graph(); + update_compare_tests_graph(); + } + ); }); } @@ -530,13 +602,15 @@ function setup_graph_view_buttons() { for (let fullscreenButton of fullscreenButtons) { const fullscreenId = `${fullscreenButton}Fullscreen`; const closeId = `${fullscreenButton}Close`; - const graphFunctionName = `create_${camelcase_to_underscore(fullscreenButton)}_graph`; + const graphFunctionName = `update_${camelcase_to_underscore(fullscreenButton)}_graph`; const toggleFullscreen = (entering) => { const fullscreen = document.getElementById(fullscreenId); const close = document.getElementById(closeId); const content = fullscreen.closest(".grid-stack-item-content"); + const canvasId = `${fullscreenButton}Graph`; + show_graph_loading(canvasId); inFullscreen = entering; fullscreen.hidden = entering; close.hidden = !entering; @@ -544,36 +618,39 @@ function setup_graph_view_buttons() { document.body.classList.toggle("lock-scroll", entering); document.documentElement.classList.toggle("html-scroll", !entering) - if (typeof window[graphFunctionName] === "function") { - window[graphFunctionName](); - } - - if (fullscreenButton === "runDonut") { - create_run_donut_total_graph(); - } else if (fullscreenButton === "suiteFolderDonut") { - create_suite_folder_fail_donut_graph(); - } + setTimeout(() => { + if (typeof window[graphFunctionName] === "function") { + window[graphFunctionName](); + } - let section = null; - if (fullscreenButton.includes("suite")) { - section = "suite"; - } else if (fullscreenButton.includes("test")) { - section = "test"; - } else if (fullscreenButton.includes("keyword")) { - section = "keyword"; - } else if (fullscreenButton.includes("compare")) { - section = "compare"; - } - if (section) { - const filters = document.getElementById(`${section}SectionFilters`); - const originalContainer = document.getElementById(`${section}SectionFiltersContainer`); - if (entering) { - const fullscreenHeader = document.querySelector('.grid-stack-item-content.fullscreen'); - fullscreenHeader.insertBefore(filters, fullscreenHeader.firstChild); - } else { - originalContainer.insertBefore(filters, originalContainer.firstChild); + if (fullscreenButton === "runDonut") { + update_run_donut_total_graph(); + } else if (fullscreenButton === "suiteFolderDonut") { + update_suite_folder_fail_donut_graph(); } - } + hide_graph_loading(canvasId); + + let section = null; + if (fullscreenButton.includes("suite")) { + section = "suite"; + } else if (fullscreenButton.includes("test")) { + section = "test"; + } else if (fullscreenButton.includes("keyword")) { + section = "keyword"; + } else if (fullscreenButton.includes("compare")) { + section = "compare"; + } + if (section) { + const filters = document.getElementById(`${section}SectionFilters`); + const originalContainer = document.getElementById(`${section}SectionFiltersContainer`); + if (entering) { + const fullscreenHeader = document.querySelector('.grid-stack-item-content.fullscreen'); + fullscreenHeader.insertBefore(filters, fullscreenHeader.firstChild); + } else { + originalContainer.insertBefore(filters, originalContainer.firstChild); + } + } + }, 0); }; document.getElementById(fullscreenId).addEventListener("click", () => { @@ -599,52 +676,80 @@ function setup_graph_view_buttons() { } const folder = remove_last_folder(previousFolder) if (previousFolder == "" && folder == "") { return } - create_suite_folder_donut_graph(folder) + update_graphs_with_loading(["suiteFolderDonutGraph"], () => { + update_suite_folder_donut_graph(folder) + }); }); // ignore skip button eventlisteners document.getElementById("ignoreSkips").addEventListener("change", () => { ignoreSkips = !ignoreSkips; - create_test_most_flaky_graph(); + update_graphs_with_loading(["testMostFlakyGraph"], () => { + update_test_most_flaky_graph(); + }); }); document.getElementById("ignoreSkipsRecent").addEventListener("change", () => { ignoreSkipsRecent = !ignoreSkipsRecent; - create_test_recent_most_flaky_graph(); + update_graphs_with_loading(["testRecentMostFlakyGraph"], () => { + update_test_recent_most_flaky_graph(); + }); }); document.getElementById("onlyFailedFolders").addEventListener("change", () => { onlyFailedFolders = !onlyFailedFolders; - create_suite_folder_donut_graph(""); + update_graphs_with_loading(["suiteFolderDonutGraph"], () => { + update_suite_folder_donut_graph(""); + }); }); document.getElementById("heatMapTestType").addEventListener("change", () => { - create_run_heatmap_graph(); + update_graphs_with_loading(["runHeatmapGraph"], () => { + update_run_heatmap_graph(); + }); }); document.getElementById("heatMapHour").addEventListener("change", () => { heatMapHourAll = document.getElementById("heatMapHour").value == "All" ? true : false; - create_run_heatmap_graph(); + update_graphs_with_loading(["runHeatmapGraph"], () => { + update_run_heatmap_graph(); + }); }); document.getElementById("testOnlyChanges").addEventListener("change", () => { - create_test_statistics_graph(); + update_graphs_with_loading(["testStatisticsGraph"], () => { + update_test_statistics_graph(); + }); }); document.getElementById("testNoChanges").addEventListener("change", () => { - create_test_statistics_graph(); + update_graphs_with_loading(["testStatisticsGraph"], () => { + update_test_statistics_graph(); + }); }); document.getElementById("compareOnlyChanges").addEventListener("change", () => { - create_compare_tests_graph(); + update_graphs_with_loading(["compareTestsGraph"], () => { + update_compare_tests_graph(); + }); }); document.getElementById("compareNoChanges").addEventListener("change", () => { - create_compare_tests_graph(); + update_graphs_with_loading(["compareTestsGraph"], () => { + update_compare_tests_graph(); + }); }); // most time consuming only latest run switch event listeners document.getElementById("onlyLastRunSuite").addEventListener("change", () => { - create_suite_most_time_consuming_graph(); + update_graphs_with_loading(["suiteMostTimeConsumingGraph"], () => { + update_suite_most_time_consuming_graph(); + }); }); document.getElementById("onlyLastRunTest").addEventListener("change", () => { - create_test_most_time_consuming_graph(); + update_graphs_with_loading(["testMostTimeConsumingGraph"], () => { + update_test_most_time_consuming_graph(); + }); }); document.getElementById("onlyLastRunKeyword").addEventListener("change", () => { - create_keyword_most_time_consuming_graph(); + update_graphs_with_loading(["keywordMostTimeConsumingGraph"], () => { + update_keyword_most_time_consuming_graph(); + }); }); document.getElementById("onlyLastRunKeywordMostUsed").addEventListener("change", () => { - create_keyword_most_used_graph(); + update_graphs_with_loading(["keywordMostUsedGraph"], () => { + update_keyword_most_used_graph(); + }); }); // graph layout changes document.querySelectorAll(".shown-graph").forEach(btn => { @@ -691,11 +796,16 @@ function setup_graph_view_buttons() { }); } function handle_graph_change_type_button_click(graphChangeButton, graphType, camelButtonName) { - update_graph_type(`${camelButtonName}GraphType`, graphType) - window[`create_${graphChangeButton}_graph`](); - update_active_graph_type_buttons(graphChangeButton, graphType); - if (graphChangeButton == 'run_donut') { create_run_donut_total_graph(); } - if (graphChangeButton == 'suite_folder_donut') { create_suite_folder_fail_donut_graph(); } + const canvasId = `${camelButtonName}Graph`; + show_graph_loading(canvasId); + setTimeout(() => { + update_graph_type(`${camelButtonName}GraphType`, graphType) + window[`create_${graphChangeButton}_graph`](); + update_active_graph_type_buttons(graphChangeButton, graphType); + if (graphChangeButton == 'run_donut') { update_run_donut_total_graph(); } + if (graphChangeButton == 'suite_folder_donut') { update_suite_folder_fail_donut_graph(); } + hide_graph_loading(canvasId); + }, 0); } function add_graph_eventlisteners(graphChangeButton, buttonTypes) { const camelButtonName = underscore_to_camelcase(graphChangeButton); @@ -853,7 +963,11 @@ function setup_overview_order_filters() { const selectId = select.id; if (selectId === "overviewLatestSectionOrder") { select.addEventListener('change', (e) => { - create_overview_latest_graphs(); + show_loading_overlay(); + requestAnimationFrame(() => { + create_overview_latest_graphs(); + hide_loading_overlay(); + }); }); } else { const projectId = parseProjectId(selectId); diff --git a/robotframework_dashboard/js/graph_creation/all.js b/robotframework_dashboard/js/graph_creation/all.js index 6551a1a..f915037 100644 --- a/robotframework_dashboard/js/graph_creation/all.js +++ b/robotframework_dashboard/js/graph_creation/all.js @@ -11,7 +11,13 @@ import { create_run_donut_total_graph, create_run_stats_graph, create_run_duration_graph, - create_run_heatmap_graph + create_run_heatmap_graph, + update_run_statistics_graph, + update_run_donut_graph, + update_run_donut_total_graph, + update_run_stats_graph, + update_run_duration_graph, + update_run_heatmap_graph } from "./run.js"; import { create_suite_statistics_graph, @@ -19,7 +25,13 @@ import { create_suite_folder_fail_donut_graph, create_suite_duration_graph, create_suite_most_failed_graph, - create_suite_most_time_consuming_graph + create_suite_most_time_consuming_graph, + update_suite_statistics_graph, + update_suite_folder_donut_graph, + update_suite_folder_fail_donut_graph, + update_suite_duration_graph, + update_suite_most_failed_graph, + update_suite_most_time_consuming_graph } from "./suite.js"; import { create_test_statistics_graph, @@ -30,7 +42,16 @@ import { create_test_recent_most_flaky_graph, create_test_most_failed_graph, create_test_recent_most_failed_graph, - create_test_most_time_consuming_graph + create_test_most_time_consuming_graph, + update_test_statistics_graph, + update_test_duration_graph, + update_test_duration_deviation_graph, + update_test_messages_graph, + update_test_most_flaky_graph, + update_test_recent_most_flaky_graph, + update_test_most_failed_graph, + update_test_recent_most_failed_graph, + update_test_most_time_consuming_graph } from "./test.js"; import { create_keyword_statistics_graph, @@ -41,22 +62,38 @@ import { create_keyword_max_duration_graph, create_keyword_most_failed_graph, create_keyword_most_time_consuming_graph, - create_keyword_most_used_graph + create_keyword_most_used_graph, + update_keyword_statistics_graph, + update_keyword_times_run_graph, + update_keyword_total_duration_graph, + update_keyword_average_duration_graph, + update_keyword_min_duration_graph, + update_keyword_max_duration_graph, + update_keyword_most_failed_graph, + update_keyword_most_time_consuming_graph, + update_keyword_most_used_graph } from "./keyword.js"; import { create_compare_statistics_graph, create_compare_suite_duration_graph, - create_compare_tests_graph + create_compare_tests_graph, + update_compare_statistics_graph, + update_compare_suite_duration_graph, + update_compare_tests_graph } from "./compare.js"; import { create_run_table, create_suite_table, create_test_table, - create_keyword_table + create_keyword_table, + update_run_table, + update_suite_table, + update_test_table, + update_keyword_table } from "./tables.js"; -// function that updates all graphs based on the new filtered data and hidden choices -function setup_dashboard_graphs() { +// function that creates all graphs from scratch - used on first load of each tab +function create_dashboard_graphs() { if (settings.menu.overview) { create_overview_latest_graphs(); create_overview_total_graphs(); @@ -104,6 +141,63 @@ function setup_dashboard_graphs() { } } +// function that updates existing graphs in-place with new data - avoids costly destroy/recreate cycle +// each update function falls back to create if the chart doesn't exist yet +function update_dashboard_graphs() { + if (settings.menu.overview) { + create_overview_latest_graphs(); + create_overview_total_graphs(); + update_donut_charts(); + } else if (settings.menu.dashboard) { + update_run_statistics_graph(); + update_run_donut_graph(); + update_run_donut_total_graph(); + update_run_stats_graph(); + update_run_duration_graph(); + update_run_heatmap_graph(); + update_suite_statistics_graph(); + update_suite_folder_donut_graph(); + update_suite_folder_fail_donut_graph(); + update_suite_duration_graph(); + update_suite_most_failed_graph(); + update_suite_most_time_consuming_graph(); + update_test_statistics_graph(); + update_test_duration_graph(); + update_test_duration_deviation_graph(); + update_test_messages_graph(); + update_test_most_flaky_graph(); + update_test_recent_most_flaky_graph(); + update_test_most_failed_graph(); + update_test_recent_most_failed_graph(); + update_test_most_time_consuming_graph(); + update_keyword_statistics_graph(); + update_keyword_times_run_graph(); + update_keyword_total_duration_graph(); + update_keyword_average_duration_graph(); + update_keyword_min_duration_graph(); + update_keyword_max_duration_graph(); + update_keyword_most_failed_graph(); + update_keyword_most_time_consuming_graph(); + update_keyword_most_used_graph(); + } else if (settings.menu.compare) { + update_compare_statistics_graph(); + update_compare_suite_duration_graph(); + update_compare_tests_graph(); + } else if (settings.menu.tables) { + update_run_table(); + update_suite_table(); + update_test_table(); + update_keyword_table(); + } +} + +// backward-compatible alias - always creates from scratch +function setup_dashboard_graphs() { + create_dashboard_graphs(); +} + export { - setup_dashboard_graphs + setup_dashboard_graphs, + create_dashboard_graphs, + update_dashboard_graphs }; \ No newline at end of file diff --git a/robotframework_dashboard/js/graph_creation/compare.js b/robotframework_dashboard/js/graph_creation/compare.js index 92b894a..9733493 100644 --- a/robotframework_dashboard/js/graph_creation/compare.js +++ b/robotframework_dashboard/js/graph_creation/compare.js @@ -6,32 +6,36 @@ import { open_log_file, open_log_from_label } from "../log.js"; import { filteredRuns, filteredSuites, filteredTests } from "../variables/globals.js"; import { settings } from "../variables/settings.js"; -// function to create the compare statistics in the compare section -function create_compare_statistics_graph() { - if (compareStatisticsGraph) { - compareStatisticsGraph.destroy(); - } +// build config for compare statistics graph +function _build_compare_statistics_config() { const graphData = get_compare_statistics_graph_data(filteredRuns); const config = get_graph_config("bar", graphData, "", "Run", "Amount"); config.options.scales.y.stacked = false; - compareStatisticsGraph = new Chart("compareStatisticsGraph", config); + return config; } // function to create the compare statistics in the compare section -function create_compare_suite_duration_graph() { - if (compareSuiteDurationGraph) { - compareSuiteDurationGraph.destroy(); - } +function create_compare_statistics_graph() { + console.log("creating_compare_statistics_graph"); + if (compareStatisticsGraph) { compareStatisticsGraph.destroy(); } + compareStatisticsGraph = new Chart("compareStatisticsGraph", _build_compare_statistics_config()); +} + +// build config for compare suite duration graph +function _build_compare_suite_duration_config() { const graphData = get_compare_suite_duration_data(filteredSuites); - const config = get_graph_config("radar", graphData, ""); - compareSuiteDurationGraph = new Chart("compareSuiteDurationGraph", config); + return get_graph_config("radar", graphData, ""); } // function to create the compare statistics in the compare section -function create_compare_tests_graph() { - if (compareTestsGraph) { - compareTestsGraph.destroy(); - } +function create_compare_suite_duration_graph() { + console.log("creating_compare_suite_duration_graph"); + if (compareSuiteDurationGraph) { compareSuiteDurationGraph.destroy(); } + compareSuiteDurationGraph = new Chart("compareSuiteDurationGraph", _build_compare_suite_duration_config()); +} + +// build config for compare tests graph +function _build_compare_tests_config() { const data = get_test_statistics_data(filteredTests); const graphData = data[0] const runStarts = data[1] @@ -64,14 +68,54 @@ function create_compare_tests_graph() { }; if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } update_height("compareTestsVertical", config.data.labels.length, "timeline"); - compareTestsGraph = new Chart("compareTestsGraph", config); + return config; +} + +// function to create the compare statistics in the compare section +function create_compare_tests_graph() { + console.log("creating_compare_tests_graph"); + if (compareTestsGraph) { compareTestsGraph.destroy(); } + compareTestsGraph = new Chart("compareTestsGraph", _build_compare_tests_config()); compareTestsGraph.canvas.addEventListener("click", (event) => { open_log_from_label(compareTestsGraph, event) }); } +// update function for compare statistics graph - updates existing chart in-place +function update_compare_statistics_graph() { + console.log("updating_compare_statistics_graph"); + if (!compareStatisticsGraph) { create_compare_statistics_graph(); return; } + const config = _build_compare_statistics_config(); + compareStatisticsGraph.data = config.data; + compareStatisticsGraph.options = config.options; + compareStatisticsGraph.update(); +} + +// update function for compare suite duration graph - updates existing chart in-place +function update_compare_suite_duration_graph() { + console.log("updating_compare_suite_duration_graph"); + if (!compareSuiteDurationGraph) { create_compare_suite_duration_graph(); return; } + const config = _build_compare_suite_duration_config(); + compareSuiteDurationGraph.data = config.data; + compareSuiteDurationGraph.options = config.options; + compareSuiteDurationGraph.update(); +} + +// update function for compare tests graph - updates existing chart in-place +function update_compare_tests_graph() { + console.log("updating_compare_tests_graph"); + if (!compareTestsGraph) { create_compare_tests_graph(); return; } + const config = _build_compare_tests_config(); + compareTestsGraph.data = config.data; + compareTestsGraph.options = config.options; + compareTestsGraph.update(); +} + export { create_compare_statistics_graph, create_compare_suite_duration_graph, - create_compare_tests_graph + create_compare_tests_graph, + update_compare_statistics_graph, + update_compare_suite_duration_graph, + update_compare_tests_graph }; \ No newline at end of file diff --git a/robotframework_dashboard/js/graph_creation/keyword.js b/robotframework_dashboard/js/graph_creation/keyword.js index 1802410..8564cdf 100644 --- a/robotframework_dashboard/js/graph_creation/keyword.js +++ b/robotframework_dashboard/js/graph_creation/keyword.js @@ -9,11 +9,8 @@ import { open_log_from_label, open_log_file } from "../log.js"; import { format_duration } from "../common.js"; import { update_height } from "../graph_data/helpers.js"; -// function to keyword statistics graph in the keyword section -function create_keyword_statistics_graph() { - if (keywordStatisticsGraph) { - keywordStatisticsGraph.destroy(); - } +// build config for keyword statistics graph +function _build_keyword_statistics_config() { const data = get_statistics_graph_data("keyword", settings.graphTypes.keywordStatisticsGraphType, filteredKeywords); const graphData = data[0] var config; @@ -25,17 +22,21 @@ function create_keyword_statistics_graph() { config = get_graph_config("bar", graphData, "", "Run", "Percentage"); } if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - keywordStatisticsGraph = new Chart("keywordStatisticsGraph", config); + return config; +} + +// function to keyword statistics graph in the keyword section +function create_keyword_statistics_graph() { + console.log("creating_keyword_statistics_graph"); + if (keywordStatisticsGraph) { keywordStatisticsGraph.destroy(); } + keywordStatisticsGraph = new Chart("keywordStatisticsGraph", _build_keyword_statistics_config()); keywordStatisticsGraph.canvas.addEventListener("click", (event) => { open_log_from_label(keywordStatisticsGraph, event) }); } -// function to keyword times run graph in the keyword section -function create_keyword_times_run_graph() { - if (keywordTimesRunGraph) { - keywordTimesRunGraph.destroy(); - } +// build config for keyword times run graph +function _build_keyword_times_run_config() { const graphData = get_duration_graph_data("keyword", settings.graphTypes.keywordTimesRunGraphType, "times_run", filteredKeywords); var config; if (settings.graphTypes.keywordTimesRunGraphType == "bar") { @@ -45,17 +46,21 @@ function create_keyword_times_run_graph() { config = get_graph_config("line", graphData, "", "Date", "Times Run"); } if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - keywordTimesRunGraph = new Chart("keywordTimesRunGraph", config); + return config; +} + +// function to keyword times run graph in the keyword section +function create_keyword_times_run_graph() { + console.log("creating_keyword_times_run_graph"); + if (keywordTimesRunGraph) { keywordTimesRunGraph.destroy(); } + keywordTimesRunGraph = new Chart("keywordTimesRunGraph", _build_keyword_times_run_config()); keywordTimesRunGraph.canvas.addEventListener("click", (event) => { open_log_from_label(keywordTimesRunGraph, event) }); } -// function to keyword total time graph in the keyword section -function create_keyword_total_duration_graph() { - if (keywordTotalDurationGraph) { - keywordTotalDurationGraph.destroy(); - } +// build config for keyword total duration graph +function _build_keyword_total_duration_config() { const graphData = get_duration_graph_data("keyword", settings.graphTypes.keywordTotalDurationGraphType, "total_time_s", filteredKeywords); var config; if (settings.graphTypes.keywordTotalDurationGraphType == "bar") { @@ -65,17 +70,21 @@ function create_keyword_total_duration_graph() { config = get_graph_config("line", graphData, "", "Date", "Duration"); } if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - keywordTotalDurationGraph = new Chart("keywordTotalDurationGraph", config); + return config; +} + +// function to keyword total time graph in the keyword section +function create_keyword_total_duration_graph() { + console.log("creating_keyword_total_duration_graph"); + if (keywordTotalDurationGraph) { keywordTotalDurationGraph.destroy(); } + keywordTotalDurationGraph = new Chart("keywordTotalDurationGraph", _build_keyword_total_duration_config()); keywordTotalDurationGraph.canvas.addEventListener("click", (event) => { open_log_from_label(keywordTotalDurationGraph, event) }); } -// function to keyword average time graph in the keyword section -function create_keyword_average_duration_graph() { - if (keywordAverageDurationGraph) { - keywordAverageDurationGraph.destroy(); - } +// build config for keyword average duration graph +function _build_keyword_average_duration_config() { const graphData = get_duration_graph_data("keyword", settings.graphTypes.keywordAverageDurationGraphType, "average_time_s", filteredKeywords); var config; if (settings.graphTypes.keywordAverageDurationGraphType == "bar") { @@ -85,17 +94,21 @@ function create_keyword_average_duration_graph() { config = get_graph_config("line", graphData, "", "Date", "Duration"); } if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - keywordAverageDurationGraph = new Chart("keywordAverageDurationGraph", config); + return config; +} + +// function to keyword average time graph in the keyword section +function create_keyword_average_duration_graph() { + console.log("creating_keyword_average_duration_graph"); + if (keywordAverageDurationGraph) { keywordAverageDurationGraph.destroy(); } + keywordAverageDurationGraph = new Chart("keywordAverageDurationGraph", _build_keyword_average_duration_config()); keywordAverageDurationGraph.canvas.addEventListener("click", (event) => { open_log_from_label(keywordAverageDurationGraph, event) }); } -// function to keyword min time graph in the keyword section -function create_keyword_min_duration_graph() { - if (keywordMinDurationGraph) { - keywordMinDurationGraph.destroy(); - } +// build config for keyword min duration graph +function _build_keyword_min_duration_config() { const graphData = get_duration_graph_data("keyword", settings.graphTypes.keywordMinDurationGraphType, "min_time_s", filteredKeywords); var config; if (settings.graphTypes.keywordMinDurationGraphType == "bar") { @@ -105,17 +118,21 @@ function create_keyword_min_duration_graph() { config = get_graph_config("line", graphData, "", "Date", "Duration"); } if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - keywordMinDurationGraph = new Chart("keywordMinDurationGraph", config); + return config; +} + +// function to keyword min time graph in the keyword section +function create_keyword_min_duration_graph() { + console.log("creating_keyword_min_duration_graph"); + if (keywordMinDurationGraph) { keywordMinDurationGraph.destroy(); } + keywordMinDurationGraph = new Chart("keywordMinDurationGraph", _build_keyword_min_duration_config()); keywordMinDurationGraph.canvas.addEventListener("click", (event) => { open_log_from_label(keywordMinDurationGraph, event) }); } -// function to keyword max time graph in the keyword section -function create_keyword_max_duration_graph() { - if (keywordMaxDurationGraph) { - keywordMaxDurationGraph.destroy(); - } +// build config for keyword max duration graph +function _build_keyword_max_duration_config() { const graphData = get_duration_graph_data("keyword", settings.graphTypes.keywordMaxDurationGraphType, "max_time_s", filteredKeywords); var config; if (settings.graphTypes.keywordMaxDurationGraphType == "bar") { @@ -125,17 +142,21 @@ function create_keyword_max_duration_graph() { config = get_graph_config("line", graphData, "", "Date", "Duration"); } if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - keywordMaxDurationGraph = new Chart("keywordMaxDurationGraph", config); + return config; +} + +// function to keyword max time graph in the keyword section +function create_keyword_max_duration_graph() { + console.log("creating_keyword_max_duration_graph"); + if (keywordMaxDurationGraph) { keywordMaxDurationGraph.destroy(); } + keywordMaxDurationGraph = new Chart("keywordMaxDurationGraph", _build_keyword_max_duration_config()); keywordMaxDurationGraph.canvas.addEventListener("click", (event) => { open_log_from_label(keywordMaxDurationGraph, event) }); } -// function to create test most failed graph in the keyword section -function create_keyword_most_failed_graph() { - if (keywordMostFailedGraph) { - keywordMostFailedGraph.destroy(); - } +// build config for keyword most failed graph +function _build_keyword_most_failed_config() { const data = get_most_failed_data("keyword", settings.graphTypes.keywordMostFailedGraphType, filteredKeywords, false); const graphData = data[0] const callbackData = data[1]; @@ -183,17 +204,21 @@ function create_keyword_most_failed_graph() { if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } } update_height("keywordMostFailedVertical", config.data.labels.length, settings.graphTypes.keywordMostFailedGraphType); - keywordMostFailedGraph = new Chart("keywordMostFailedGraph", config); + return config; +} + +// function to create test most failed graph in the keyword section +function create_keyword_most_failed_graph() { + console.log("creating_keyword_most_failed_graph"); + if (keywordMostFailedGraph) { keywordMostFailedGraph.destroy(); } + keywordMostFailedGraph = new Chart("keywordMostFailedGraph", _build_keyword_most_failed_config()); keywordMostFailedGraph.canvas.addEventListener("click", (event) => { open_log_from_label(keywordMostFailedGraph, event) }); } -// function to create the most time consuming keyword graph in the keyword section -function create_keyword_most_time_consuming_graph() { - if (keywordMostTimeConsumingGraph) { - keywordMostTimeConsumingGraph.destroy(); - } +// build config for keyword most time consuming graph +function _build_keyword_most_time_consuming_config() { const onlyLastRun = document.getElementById("onlyLastRunKeyword").checked; const data = get_most_time_consuming_or_most_used_data("keyword", settings.graphTypes.keywordMostTimeConsumingGraphType, filteredKeywords, onlyLastRun); const graphData = data[0] @@ -262,17 +287,21 @@ function create_keyword_most_time_consuming_graph() { if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } } update_height("keywordMostTimeConsumingVertical", config.data.labels.length, settings.graphTypes.keywordMostTimeConsumingGraphType); - keywordMostTimeConsumingGraph = new Chart("keywordMostTimeConsumingGraph", config); + return config; +} + +// function to create the most time consuming keyword graph in the keyword section +function create_keyword_most_time_consuming_graph() { + console.log("creating_keyword_most_time_consuming_graph"); + if (keywordMostTimeConsumingGraph) { keywordMostTimeConsumingGraph.destroy(); } + keywordMostTimeConsumingGraph = new Chart("keywordMostTimeConsumingGraph", _build_keyword_most_time_consuming_config()); keywordMostTimeConsumingGraph.canvas.addEventListener("click", (event) => { open_log_from_label(keywordMostTimeConsumingGraph, event) }); } -// function to create the most used keyword graph in the keyword section -function create_keyword_most_used_graph() { - if (keywordMostUsedGraph) { - keywordMostUsedGraph.destroy(); - } +// build config for keyword most used graph +function _build_keyword_most_used_config() { const onlyLastRun = document.getElementById("onlyLastRunKeywordMostUsed").checked; const data = get_most_time_consuming_or_most_used_data("keyword", settings.graphTypes.keywordMostUsedGraphType, filteredKeywords, onlyLastRun, true); const graphData = data[0] @@ -341,12 +370,109 @@ function create_keyword_most_used_graph() { if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } } update_height("keywordMostUsedVertical", config.data.labels.length, settings.graphTypes.keywordMostUsedGraphType); - keywordMostUsedGraph = new Chart("keywordMostUsedGraph", config); + return config; +} + +// function to create the most used keyword graph in the keyword section +function create_keyword_most_used_graph() { + console.log("creating_keyword_most_used_graph"); + if (keywordMostUsedGraph) { keywordMostUsedGraph.destroy(); } + keywordMostUsedGraph = new Chart("keywordMostUsedGraph", _build_keyword_most_used_config()); keywordMostUsedGraph.canvas.addEventListener("click", (event) => { open_log_from_label(keywordMostUsedGraph, event) }); } +// update function for keyword statistics graph - updates existing chart in-place +function update_keyword_statistics_graph() { + console.log("updating_keyword_statistics_graph"); + if (!keywordStatisticsGraph) { create_keyword_statistics_graph(); return; } + const config = _build_keyword_statistics_config(); + keywordStatisticsGraph.data = config.data; + keywordStatisticsGraph.options = config.options; + keywordStatisticsGraph.update(); +} + +// update function for keyword times run graph - updates existing chart in-place +function update_keyword_times_run_graph() { + console.log("updating_keyword_times_run_graph"); + if (!keywordTimesRunGraph) { create_keyword_times_run_graph(); return; } + const config = _build_keyword_times_run_config(); + keywordTimesRunGraph.data = config.data; + keywordTimesRunGraph.options = config.options; + keywordTimesRunGraph.update(); +} + +// update function for keyword total duration graph - updates existing chart in-place +function update_keyword_total_duration_graph() { + console.log("updating_keyword_total_duration_graph"); + if (!keywordTotalDurationGraph) { create_keyword_total_duration_graph(); return; } + const config = _build_keyword_total_duration_config(); + keywordTotalDurationGraph.data = config.data; + keywordTotalDurationGraph.options = config.options; + keywordTotalDurationGraph.update(); +} + +// update function for keyword average duration graph - updates existing chart in-place +function update_keyword_average_duration_graph() { + console.log("updating_keyword_average_duration_graph"); + if (!keywordAverageDurationGraph) { create_keyword_average_duration_graph(); return; } + const config = _build_keyword_average_duration_config(); + keywordAverageDurationGraph.data = config.data; + keywordAverageDurationGraph.options = config.options; + keywordAverageDurationGraph.update(); +} + +// update function for keyword min duration graph - updates existing chart in-place +function update_keyword_min_duration_graph() { + console.log("updating_keyword_min_duration_graph"); + if (!keywordMinDurationGraph) { create_keyword_min_duration_graph(); return; } + const config = _build_keyword_min_duration_config(); + keywordMinDurationGraph.data = config.data; + keywordMinDurationGraph.options = config.options; + keywordMinDurationGraph.update(); +} + +// update function for keyword max duration graph - updates existing chart in-place +function update_keyword_max_duration_graph() { + console.log("updating_keyword_max_duration_graph"); + if (!keywordMaxDurationGraph) { create_keyword_max_duration_graph(); return; } + const config = _build_keyword_max_duration_config(); + keywordMaxDurationGraph.data = config.data; + keywordMaxDurationGraph.options = config.options; + keywordMaxDurationGraph.update(); +} + +// update function for keyword most failed graph - updates existing chart in-place +function update_keyword_most_failed_graph() { + console.log("updating_keyword_most_failed_graph"); + if (!keywordMostFailedGraph) { create_keyword_most_failed_graph(); return; } + const config = _build_keyword_most_failed_config(); + keywordMostFailedGraph.data = config.data; + keywordMostFailedGraph.options = config.options; + keywordMostFailedGraph.update(); +} + +// update function for keyword most time consuming graph - updates existing chart in-place +function update_keyword_most_time_consuming_graph() { + console.log("updating_keyword_most_time_consuming_graph"); + if (!keywordMostTimeConsumingGraph) { create_keyword_most_time_consuming_graph(); return; } + const config = _build_keyword_most_time_consuming_config(); + keywordMostTimeConsumingGraph.data = config.data; + keywordMostTimeConsumingGraph.options = config.options; + keywordMostTimeConsumingGraph.update(); +} + +// update function for keyword most used graph - updates existing chart in-place +function update_keyword_most_used_graph() { + console.log("updating_keyword_most_used_graph"); + if (!keywordMostUsedGraph) { create_keyword_most_used_graph(); return; } + const config = _build_keyword_most_used_config(); + keywordMostUsedGraph.data = config.data; + keywordMostUsedGraph.options = config.options; + keywordMostUsedGraph.update(); +} + export { create_keyword_statistics_graph, create_keyword_times_run_graph, @@ -356,5 +482,14 @@ export { create_keyword_max_duration_graph, create_keyword_most_failed_graph, create_keyword_most_time_consuming_graph, - create_keyword_most_used_graph + create_keyword_most_used_graph, + update_keyword_statistics_graph, + update_keyword_times_run_graph, + update_keyword_total_duration_graph, + update_keyword_average_duration_graph, + update_keyword_min_duration_graph, + update_keyword_max_duration_graph, + update_keyword_most_failed_graph, + update_keyword_most_time_consuming_graph, + update_keyword_most_used_graph }; \ No newline at end of file diff --git a/robotframework_dashboard/js/graph_creation/run.js b/robotframework_dashboard/js/graph_creation/run.js index a6f99a8..abd55a2 100644 --- a/robotframework_dashboard/js/graph_creation/run.js +++ b/robotframework_dashboard/js/graph_creation/run.js @@ -17,11 +17,8 @@ import { filteredTests } from '../variables/globals.js'; -// function to create run statistics graph in the run section -function create_run_statistics_graph() { - if (runStatisticsGraph) { - runStatisticsGraph.destroy(); - } +// build config for run statistics graph +function _build_run_statistics_config() { const data = get_statistics_graph_data("run", settings.graphTypes.runStatisticsGraphType, filteredRuns); const graphData = data[0] var config; @@ -33,17 +30,21 @@ function create_run_statistics_graph() { config = get_graph_config("bar", graphData, "", "Run", "Percentage"); } if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - runStatisticsGraph = new Chart("runStatisticsGraph", config); + return config; +} + +// function to create run statistics graph in the run section +function create_run_statistics_graph() { + console.log("creating_run_statistics_graph"); + if (runStatisticsGraph) { runStatisticsGraph.destroy(); } + runStatisticsGraph = new Chart("runStatisticsGraph", _build_run_statistics_config()); runStatisticsGraph.canvas.addEventListener("click", (event) => { open_log_from_label(runStatisticsGraph, event) }); } -// function to create run donut graph in the run section -function create_run_donut_graph() { - if (runDonutGraph) { - runDonutGraph.destroy(); - } +// build config for run donut graph +function _build_run_donut_config() { const data = get_donut_graph_data("run", filteredRuns); const graphData = data[0] const callbackData = data[1] @@ -61,24 +62,35 @@ function create_run_donut_graph() { targetCanvas.style.cursor = 'default'; } }; - runDonutGraph = new Chart("runDonutGraph", config); + return config; } // function to create run donut graph in the run section -function create_run_donut_total_graph() { - if (runDonutTotalGraph) { - runDonutTotalGraph.destroy(); - } +function create_run_donut_graph() { + console.log("creating_run_donut_graph"); + if (runDonutGraph) { runDonutGraph.destroy(); } + runDonutGraph = new Chart("runDonutGraph", _build_run_donut_config()); +} + +// build config for run donut total graph +function _build_run_donut_total_config() { const data = get_donut_total_graph_data("run", filteredRuns); const graphData = data[0] - const callbackData = data[1] var config = get_graph_config("donut", graphData, `Total Status`); delete config.options.onClick; - runDonutTotalGraph = new Chart("runDonutTotalGraph", config); + return config; +} + +// function to create run donut total graph in the run section +function create_run_donut_total_graph() { + console.log("creating_run_donut_total_graph"); + if (runDonutTotalGraph) { runDonutTotalGraph.destroy(); } + runDonutTotalGraph = new Chart("runDonutTotalGraph", _build_run_donut_total_config()); } // function to create the run stats section in the run section function create_run_stats_graph() { + console.log("creating_run_stats_graph"); const data = get_stats_data(filteredRuns, filteredSuites, filteredTests, filteredKeywords); document.getElementById('totalRuns').innerText = data.totalRuns document.getElementById('totalSuites').innerText = data.totalSuites @@ -94,11 +106,8 @@ function create_run_stats_graph() { document.getElementById('averagePassRate').innerText = data.averagePassRate } -// function to create run duration graph in the run section -function create_run_duration_graph() { - if (runDurationGraph) { - runDurationGraph.destroy(); - } +// build config for run duration graph +function _build_run_duration_config() { var graphData = get_duration_graph_data("run", settings.graphTypes.runDurationGraphType, "elapsed_s", filteredRuns); var config; if (settings.graphTypes.runDurationGraphType == "bar") { @@ -108,17 +117,21 @@ function create_run_duration_graph() { config = get_graph_config("line", graphData, "", "Date", "Duration"); } if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - runDurationGraph = new Chart("runDurationGraph", config); + return config; +} + +// function to create run duration graph in the run section +function create_run_duration_graph() { + console.log("creating_run_duration_graph"); + if (runDurationGraph) { runDurationGraph.destroy(); } + runDurationGraph = new Chart("runDurationGraph", _build_run_duration_config()); runDurationGraph.canvas.addEventListener("click", (event) => { open_log_from_label(runDurationGraph, event) }); } -// function to create the run heatmap -function create_run_heatmap_graph() { - if (runHeatmapGraph) { - runHeatmapGraph.destroy(); - } +// build config for run heatmap graph +function _build_run_heatmap_config() { const data = get_heatmap_graph_data(filteredTests); const graphData = data[0] const callbackData = data[1] @@ -141,7 +154,70 @@ function create_run_heatmap_graph() { stepSize: 1, callback: val => callbackData[val] || '' } - runHeatmapGraph = new Chart("runHeatmapGraph", config); + return config; +} + +// function to create the run heatmap +function create_run_heatmap_graph() { + console.log("creating_run_heatmap_graph"); + if (runHeatmapGraph) { runHeatmapGraph.destroy(); } + runHeatmapGraph = new Chart("runHeatmapGraph", _build_run_heatmap_config()); +} + +// update function for run statistics graph - updates existing chart in-place +function update_run_statistics_graph() { + console.log("updating_run_statistics_graph"); + if (!runStatisticsGraph) { create_run_statistics_graph(); return; } + const config = _build_run_statistics_config(); + runStatisticsGraph.data = config.data; + runStatisticsGraph.options = config.options; + runStatisticsGraph.update(); +} + +// update function for run donut graph - updates existing chart in-place +function update_run_donut_graph() { + console.log("updating_run_donut_graph"); + if (!runDonutGraph) { create_run_donut_graph(); return; } + const config = _build_run_donut_config(); + runDonutGraph.data = config.data; + runDonutGraph.options = config.options; + runDonutGraph.update(); +} + +// update function for run donut total graph - updates existing chart in-place +function update_run_donut_total_graph() { + console.log("updating_run_donut_total_graph"); + if (!runDonutTotalGraph) { create_run_donut_total_graph(); return; } + const config = _build_run_donut_total_config(); + runDonutTotalGraph.data = config.data; + runDonutTotalGraph.options = config.options; + runDonutTotalGraph.update(); +} + +// update function for run stats - same as create since it only updates DOM text +function update_run_stats_graph() { + console.log("updating_run_stats_graph"); + create_run_stats_graph(); +} + +// update function for run duration graph - updates existing chart in-place +function update_run_duration_graph() { + console.log("updating_run_duration_graph"); + if (!runDurationGraph) { create_run_duration_graph(); return; } + const config = _build_run_duration_config(); + runDurationGraph.data = config.data; + runDurationGraph.options = config.options; + runDurationGraph.update(); +} + +// update function for run heatmap graph - updates existing chart in-place +function update_run_heatmap_graph() { + console.log("updating_run_heatmap_graph"); + if (!runHeatmapGraph) { create_run_heatmap_graph(); return; } + const config = _build_run_heatmap_config(); + runHeatmapGraph.data = config.data; + runHeatmapGraph.options = config.options; + runHeatmapGraph.update(); } export { @@ -150,5 +226,11 @@ export { create_run_donut_total_graph, create_run_stats_graph, create_run_duration_graph, - create_run_heatmap_graph + create_run_heatmap_graph, + update_run_statistics_graph, + update_run_donut_graph, + update_run_donut_total_graph, + update_run_stats_graph, + update_run_duration_graph, + update_run_heatmap_graph }; \ No newline at end of file diff --git a/robotframework_dashboard/js/graph_creation/suite.js b/robotframework_dashboard/js/graph_creation/suite.js index f35b820..768c0b0 100644 --- a/robotframework_dashboard/js/graph_creation/suite.js +++ b/robotframework_dashboard/js/graph_creation/suite.js @@ -12,19 +12,8 @@ import { dataLabelConfig } from '../variables/chartconfig.js'; import { settings } from '../variables/settings.js'; import { inFullscreen, inFullscreenGraph, filteredSuites } from '../variables/globals.js'; -// function to create suite folder donut -function create_suite_folder_donut_graph(folder) { - const suiteFolder = document.getElementById("suiteFolder") - suiteFolder.innerText = folder == "" || folder == undefined ? "All" : folder; - if (folder || folder == "") { // not first load so update the graphs accordingly as well - setup_suites_in_suite_select(); - create_suite_folder_fail_donut_graph(); - create_suite_statistics_graph(); - create_suite_duration_graph(); - } - if (suiteFolderDonutGraph) { - suiteFolderDonutGraph.destroy(); - } +// build config for suite folder donut graph +function _build_suite_folder_donut_config(folder) { const data = get_donut_folder_graph_data("suite", filteredSuites, folder); const graphData = data[0] const callbackData = data[1] @@ -48,7 +37,7 @@ function create_suite_folder_donut_graph(folder) { config.options.onClick = (event) => { if (event.chart.tooltip.title) { setTimeout(() => { - create_suite_folder_donut_graph(event.chart.tooltip.title.join('')); + update_suite_folder_donut_graph(event.chart.tooltip.title.join('')); }, 0); } }; @@ -60,14 +49,26 @@ function create_suite_folder_donut_graph(folder) { targetCanvas.style.cursor = 'default'; } }; - suiteFolderDonutGraph = new Chart("suiteFolderDonutGraph", config); + return config; } -// function to create suite last failed donut -function create_suite_folder_fail_donut_graph() { - if (suiteFolderFailDonutGraph) { - suiteFolderFailDonutGraph.destroy(); +// function to create suite folder donut +function create_suite_folder_donut_graph(folder) { + console.log("creating_suite_folder_donut_graph"); + const suiteFolder = document.getElementById("suiteFolder") + suiteFolder.innerText = folder == "" || folder == undefined ? "All" : folder; + if (folder || folder == "") { // not first load so update the graphs accordingly as well + setup_suites_in_suite_select(); + update_suite_folder_fail_donut_graph(); + update_suite_statistics_graph(); + update_suite_duration_graph(); } + if (suiteFolderDonutGraph) { suiteFolderDonutGraph.destroy(); } + suiteFolderDonutGraph = new Chart("suiteFolderDonutGraph", _build_suite_folder_donut_config(folder)); +} + +// build config for suite folder fail donut graph +function _build_suite_folder_fail_donut_config() { const data = get_donut_folder_fail_graph_data("suite", filteredSuites); const graphData = data[0] const callbackData = data[1] @@ -107,7 +108,7 @@ function create_suite_folder_fail_donut_graph() { config.options.onClick = (event) => { if (event.chart.tooltip.title) { setTimeout(() => { - create_suite_folder_donut_graph(event.chart.tooltip.title.join('')); + update_suite_folder_donut_graph(event.chart.tooltip.title.join('')); }, 0); } }; @@ -119,14 +120,18 @@ function create_suite_folder_fail_donut_graph() { targetCanvas.style.cursor = 'default'; } }; - suiteFolderFailDonutGraph = new Chart("suiteFolderFailDonutGraph", config); + return config; } -// function to create suite statistics graph in the suite section -function create_suite_statistics_graph() { - if (suiteStatisticsGraph) { - suiteStatisticsGraph.destroy(); - } +// function to create suite last failed donut +function create_suite_folder_fail_donut_graph() { + console.log("creating_suite_folder_fail_donut_graph"); + if (suiteFolderFailDonutGraph) { suiteFolderFailDonutGraph.destroy(); } + suiteFolderFailDonutGraph = new Chart("suiteFolderFailDonutGraph", _build_suite_folder_fail_donut_config()); +} + +// build config for suite statistics graph +function _build_suite_statistics_config() { const data = get_statistics_graph_data("suite", settings.graphTypes.suiteStatisticsGraphType, filteredSuites); const graphData = data[0] const callbackData = data[1] @@ -164,17 +169,21 @@ function create_suite_statistics_graph() { } } if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - suiteStatisticsGraph = new Chart("suiteStatisticsGraph", config); + return config; +} + +// function to create suite statistics graph in the suite section +function create_suite_statistics_graph() { + console.log("creating_suite_statistics_graph"); + if (suiteStatisticsGraph) { suiteStatisticsGraph.destroy(); } + suiteStatisticsGraph = new Chart("suiteStatisticsGraph", _build_suite_statistics_config()); suiteStatisticsGraph.canvas.addEventListener("click", (event) => { open_log_from_label(suiteStatisticsGraph, event) }); } -// function to create suite duration graph in the suite section -function create_suite_duration_graph() { - if (suiteDurationGraph) { - suiteDurationGraph.destroy(); - } +// build config for suite duration graph +function _build_suite_duration_config() { const graphData = get_duration_graph_data("suite", settings.graphTypes.suiteDurationGraphType, "elapsed_s", filteredSuites); var config; if (settings.graphTypes.suiteDurationGraphType == "bar") { @@ -184,17 +193,21 @@ function create_suite_duration_graph() { config = get_graph_config("line", graphData, "", "Date", "Duration"); } if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - suiteDurationGraph = new Chart("suiteDurationGraph", config); + return config; +} + +// function to create suite duration graph in the suite section +function create_suite_duration_graph() { + console.log("creating_suite_duration_graph"); + if (suiteDurationGraph) { suiteDurationGraph.destroy(); } + suiteDurationGraph = new Chart("suiteDurationGraph", _build_suite_duration_config()); suiteDurationGraph.canvas.addEventListener("click", (event) => { open_log_from_label(suiteDurationGraph, event) }); } -// function to create suite most failed graph in the suite section -function create_suite_most_failed_graph() { - if (suiteMostFailedGraph) { - suiteMostFailedGraph.destroy(); - } +// build config for suite most failed graph +function _build_suite_most_failed_config() { const data = get_most_failed_data("suite", settings.graphTypes.suiteMostFailedGraphType, filteredSuites, false); const graphData = data[0]; const callbackData = data[1]; @@ -242,17 +255,21 @@ function create_suite_most_failed_graph() { if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } } update_height("suiteMostFailedVertical", config.data.labels.length, settings.graphTypes.suiteMostFailedGraphType); - suiteMostFailedGraph = new Chart("suiteMostFailedGraph", config); + return config; +} + +// function to create suite most failed graph in the suite section +function create_suite_most_failed_graph() { + console.log("creating_suite_most_failed_graph"); + if (suiteMostFailedGraph) { suiteMostFailedGraph.destroy(); } + suiteMostFailedGraph = new Chart("suiteMostFailedGraph", _build_suite_most_failed_config()); suiteMostFailedGraph.canvas.addEventListener("click", (event) => { open_log_from_label(suiteMostFailedGraph, event) }); } -// function to create the most time consuming suite graph in the suite section -function create_suite_most_time_consuming_graph() { - if (suiteMostTimeConsumingGraph) { - suiteMostTimeConsumingGraph.destroy(); - } +// build config for suite most time consuming graph +function _build_suite_most_time_consuming_config() { const onlyLastRun = document.getElementById("onlyLastRunSuite").checked; const data = get_most_time_consuming_or_most_used_data("suite", settings.graphTypes.suiteMostTimeConsumingGraphType, filteredSuites, onlyLastRun); const graphData = data[0] @@ -321,18 +338,100 @@ function create_suite_most_time_consuming_graph() { if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } } update_height("suiteMostTimeConsumingVertical", config.data.labels.length, settings.graphTypes.suiteMostTimeConsumingGraphType); - suiteMostTimeConsumingGraph = new Chart("suiteMostTimeConsumingGraph", config); + return config; +} + +// function to create the most time consuming suite graph in the suite section +function create_suite_most_time_consuming_graph() { + console.log("creating_suite_most_time_consuming_graph"); + if (suiteMostTimeConsumingGraph) { suiteMostTimeConsumingGraph.destroy(); } + suiteMostTimeConsumingGraph = new Chart("suiteMostTimeConsumingGraph", _build_suite_most_time_consuming_config()); suiteMostTimeConsumingGraph.canvas.addEventListener("click", (event) => { open_log_from_label(suiteMostTimeConsumingGraph, event) }); } +// update function for suite folder donut graph - updates existing chart in-place +function update_suite_folder_donut_graph(folder) { + console.log("updating_suite_folder_donut_graph"); + const suiteFolder = document.getElementById("suiteFolder") + suiteFolder.innerText = folder == "" || folder == undefined ? "All" : folder; + if (folder || folder == "") { + setup_suites_in_suite_select(); + update_suite_folder_fail_donut_graph(); + update_suite_statistics_graph(); + update_suite_duration_graph(); + } + if (!suiteFolderDonutGraph) { create_suite_folder_donut_graph(folder); return; } + const config = _build_suite_folder_donut_config(folder); + suiteFolderDonutGraph.data = config.data; + suiteFolderDonutGraph.options = config.options; + suiteFolderDonutGraph.update(); +} + +// update function for suite folder fail donut graph - updates existing chart in-place +function update_suite_folder_fail_donut_graph() { + console.log("updating_suite_folder_fail_donut_graph"); + if (!suiteFolderFailDonutGraph) { create_suite_folder_fail_donut_graph(); return; } + const config = _build_suite_folder_fail_donut_config(); + suiteFolderFailDonutGraph.data = config.data; + suiteFolderFailDonutGraph.options = config.options; + suiteFolderFailDonutGraph.update(); +} + +// update function for suite statistics graph - updates existing chart in-place +function update_suite_statistics_graph() { + console.log("updating_suite_statistics_graph"); + if (!suiteStatisticsGraph) { create_suite_statistics_graph(); return; } + const config = _build_suite_statistics_config(); + suiteStatisticsGraph.data = config.data; + suiteStatisticsGraph.options = config.options; + suiteStatisticsGraph.update(); +} + +// update function for suite duration graph - updates existing chart in-place +function update_suite_duration_graph() { + console.log("updating_suite_duration_graph"); + if (!suiteDurationGraph) { create_suite_duration_graph(); return; } + const config = _build_suite_duration_config(); + suiteDurationGraph.data = config.data; + suiteDurationGraph.options = config.options; + suiteDurationGraph.update(); +} + +// update function for suite most failed graph - updates existing chart in-place +function update_suite_most_failed_graph() { + console.log("updating_suite_most_failed_graph"); + if (!suiteMostFailedGraph) { create_suite_most_failed_graph(); return; } + const config = _build_suite_most_failed_config(); + suiteMostFailedGraph.data = config.data; + suiteMostFailedGraph.options = config.options; + suiteMostFailedGraph.update(); +} + +// update function for suite most time consuming graph - updates existing chart in-place +function update_suite_most_time_consuming_graph() { + console.log("updating_suite_most_time_consuming_graph"); + if (!suiteMostTimeConsumingGraph) { create_suite_most_time_consuming_graph(); return; } + const config = _build_suite_most_time_consuming_config(); + suiteMostTimeConsumingGraph.data = config.data; + suiteMostTimeConsumingGraph.options = config.options; + suiteMostTimeConsumingGraph.update(); +} + + export { create_suite_statistics_graph, create_suite_folder_donut_graph, create_suite_folder_fail_donut_graph, create_suite_duration_graph, create_suite_most_failed_graph, - create_suite_most_time_consuming_graph + create_suite_most_time_consuming_graph, + update_suite_statistics_graph, + update_suite_folder_donut_graph, + update_suite_folder_fail_donut_graph, + update_suite_duration_graph, + update_suite_most_failed_graph, + update_suite_most_time_consuming_graph }; \ No newline at end of file diff --git a/robotframework_dashboard/js/graph_creation/tables.js b/robotframework_dashboard/js/graph_creation/tables.js index b5dab2c..ea9b390 100644 --- a/robotframework_dashboard/js/graph_creation/tables.js +++ b/robotframework_dashboard/js/graph_creation/tables.js @@ -1,10 +1,7 @@ import { filteredRuns, filteredSuites, filteredTests, filteredKeywords } from "../variables/globals.js"; -// function to create run table in the run section -function create_run_table() { - if (runTable) { - runTable.destroy(); - } +// build table data for run table +function _get_run_table_data() { const data = []; for (const run of filteredRuns) { data.push([ @@ -23,6 +20,13 @@ function create_run_table() { run.metadata, ]); } + return data; +} + +// function to create run table in the run section +function create_run_table() { + console.log("creating_run_table"); + if (runTable) { runTable.destroy(); } runTable = new DataTable("#runTable", { layout: { topStart: "info", @@ -43,15 +47,12 @@ function create_run_table() { { title: "alias" }, { title: "metadata" }, ], - data: data, + data: _get_run_table_data(), }); } -// function to create suite table in the suite section -function create_suite_table() { - if (suiteTable) { - suiteTable.destroy(); - } +// build table data for suite table +function _get_suite_table_data() { const data = []; for (const suite of filteredSuites) { data.push([ @@ -68,6 +69,13 @@ function create_suite_table() { suite.id, ]); } + return data; +} + +// function to create suite table in the suite section +function create_suite_table() { + console.log("creating_suite_table"); + if (suiteTable) { suiteTable.destroy(); } suiteTable = new DataTable("#suiteTable", { layout: { topStart: "info", @@ -86,15 +94,12 @@ function create_suite_table() { { title: "alias" }, { title: "id" }, ], - data: data, + data: _get_suite_table_data(), }); } -// function to create test table in the test section -function create_test_table() { - if (testTable) { - testTable.destroy(); - } +// build table data for test table +function _get_test_table_data() { const data = []; for (const test of filteredTests) { data.push([ @@ -112,6 +117,13 @@ function create_test_table() { test.id ]); } + return data; +} + +// function to create test table in the test section +function create_test_table() { + console.log("creating_test_table"); + if (testTable) { testTable.destroy(); } testTable = new DataTable("#testTable", { layout: { topStart: "info", @@ -131,15 +143,12 @@ function create_test_table() { { title: "alias" }, { title: "id" }, ], - data: data, + data: _get_test_table_data(), }); } -// function to create keyword table in the tables tab -function create_keyword_table() { - if (keywordTable) { - keywordTable.destroy(); - } +// build table data for keyword table +function _get_keyword_table_data() { const data = []; for (const keyword of filteredKeywords) { data.push([ @@ -157,6 +166,13 @@ function create_keyword_table() { keyword.owner, ]); } + return data; +} + +// function to create keyword table in the tables tab +function create_keyword_table() { + console.log("creating_keyword_table"); + if (keywordTable) { keywordTable.destroy(); } keywordTable = new DataTable("#keywordTable", { layout: { topStart: "info", @@ -176,13 +192,53 @@ function create_keyword_table() { { title: "alias" }, { title: "owner" }, ], - data: data, + data: _get_keyword_table_data(), }); } +// update function for run table - clears and redraws with new data +function update_run_table() { + console.log("updating_run_table"); + if (!runTable) { create_run_table(); return; } + runTable.clear(); + runTable.rows.add(_get_run_table_data()); + runTable.draw(); +} + +// update function for suite table - clears and redraws with new data +function update_suite_table() { + console.log("updating_suite_table"); + if (!suiteTable) { create_suite_table(); return; } + suiteTable.clear(); + suiteTable.rows.add(_get_suite_table_data()); + suiteTable.draw(); +} + +// update function for test table - clears and redraws with new data +function update_test_table() { + console.log("updating_test_table"); + if (!testTable) { create_test_table(); return; } + testTable.clear(); + testTable.rows.add(_get_test_table_data()); + testTable.draw(); +} + +// update function for keyword table - clears and redraws with new data +function update_keyword_table() { + console.log("updating_keyword_table"); + if (!keywordTable) { create_keyword_table(); return; } + keywordTable.clear(); + keywordTable.rows.add(_get_keyword_table_data()); + keywordTable.draw(); +} + export { create_run_table, create_suite_table, create_test_table, - create_keyword_table + create_keyword_table, + update_run_table, + update_suite_table, + update_test_table, + update_keyword_table }; \ No newline at end of file diff --git a/robotframework_dashboard/js/graph_creation/test.js b/robotframework_dashboard/js/graph_creation/test.js index 06f571d..943ae7c 100644 --- a/robotframework_dashboard/js/graph_creation/test.js +++ b/robotframework_dashboard/js/graph_creation/test.js @@ -12,11 +12,8 @@ import { format_duration } from "../common.js"; import { inFullscreen, inFullscreenGraph, ignoreSkips, ignoreSkipsRecent, filteredTests } from "../variables/globals.js"; import { settings } from "../variables/settings.js"; -// function to create test statistics graph in the test section -function create_test_statistics_graph() { - if (testStatisticsGraph) { - testStatisticsGraph.destroy(); - } +// build config for test statistics graph +function _build_test_statistics_config() { const data = get_test_statistics_data(filteredTests); const graphData = data[0] const runStarts = data[1] @@ -49,17 +46,21 @@ function create_test_statistics_graph() { }; if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } update_height("testStatisticsVertical", config.data.labels.length, "timeline"); - testStatisticsGraph = new Chart("testStatisticsGraph", config); + return config; +} + +// function to create test statistics graph in the test section +function create_test_statistics_graph() { + console.log("creating_test_statistics_graph"); + if (testStatisticsGraph) { testStatisticsGraph.destroy(); } + testStatisticsGraph = new Chart("testStatisticsGraph", _build_test_statistics_config()); testStatisticsGraph.canvas.addEventListener("click", (event) => { open_log_from_label(testStatisticsGraph, event) }); } -// function to create test duration graph in the test section -function create_test_duration_graph() { - if (testDurationGraph) { - testDurationGraph.destroy(); - } +// build config for test duration graph +function _build_test_duration_config() { var graphData = get_duration_graph_data("test", settings.graphTypes.testDurationGraphType, "elapsed_s", filteredTests); var config; if (settings.graphTypes.testDurationGraphType == "bar") { @@ -69,17 +70,21 @@ function create_test_duration_graph() { config = get_graph_config("line", graphData, "", "Date", "Duration"); } if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - testDurationGraph = new Chart("testDurationGraph", config); + return config; +} + +// function to create test duration graph in the test section +function create_test_duration_graph() { + console.log("creating_test_duration_graph"); + if (testDurationGraph) { testDurationGraph.destroy(); } + testDurationGraph = new Chart("testDurationGraph", _build_test_duration_config()); testDurationGraph.canvas.addEventListener("click", (event) => { open_log_from_label(testDurationGraph, event) }); } -// function to create test messages graph in the test section -function create_test_messages_graph() { - if (testMessagesGraph) { - testMessagesGraph.destroy(); - } +// build config for test messages graph +function _build_test_messages_config() { const data = get_messages_data("test", settings.graphTypes.testMessagesGraphType, filteredTests); const graphData = data[0]; const callbackData = data[1]; @@ -145,31 +150,39 @@ function create_test_messages_graph() { if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } } update_height("testMessagesVertical", config.data.labels.length, settings.graphTypes.testMessagesGraphType); - testMessagesGraph = new Chart("testMessagesGraph", config); + return config; +} + +// function to create test messages graph in the test section +function create_test_messages_graph() { + console.log("creating_test_messages_graph"); + if (testMessagesGraph) { testMessagesGraph.destroy(); } + testMessagesGraph = new Chart("testMessagesGraph", _build_test_messages_config()); testMessagesGraph.canvas.addEventListener("click", (event) => { open_log_from_label(testMessagesGraph, event) }); } -// function to create test duration deviation graph in test section -function create_test_duration_deviation_graph() { - if (testDurationDeviationGraph) { - testDurationDeviationGraph.destroy(); - } +// build config for test duration deviation graph +function _build_test_duration_deviation_config() { const graphData = get_duration_deviation_data("test", settings.graphTypes.testDurationDeviationGraphType, filteredTests) const config = get_graph_config("boxplot", graphData, "", "Test", "Duration"); delete config.options.onClick - testDurationDeviationGraph = new Chart("testDurationDeviationGraph", config); + return config; +} + +// function to create test duration deviation graph in test section +function create_test_duration_deviation_graph() { + console.log("creating_test_duration_deviation_graph"); + if (testDurationDeviationGraph) { testDurationDeviationGraph.destroy(); } + testDurationDeviationGraph = new Chart("testDurationDeviationGraph", _build_test_duration_deviation_config()); testDurationDeviationGraph.canvas.addEventListener("click", (event) => { open_log_from_label(testDurationDeviationGraph, event) }); } -// function to create test most flaky graph in test section -function create_test_most_flaky_graph() { - if (testMostFlakyGraph) { - testMostFlakyGraph.destroy(); - } +// build config for test most flaky graph +function _build_test_most_flaky_config() { const data = get_most_flaky_data("test", settings.graphTypes.testMostFlakyGraphType, filteredTests, ignoreSkips, false); const graphData = data[0] const callbackData = data[1]; @@ -210,17 +223,21 @@ function create_test_most_flaky_graph() { if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } } update_height("testMostFlakyVertical", config.data.labels.length, settings.graphTypes.testMostFlakyGraphType); - testMostFlakyGraph = new Chart("testMostFlakyGraph", config); + return config; +} + +// function to create test most flaky graph in test section +function create_test_most_flaky_graph() { + console.log("creating_test_most_flaky_graph"); + if (testMostFlakyGraph) { testMostFlakyGraph.destroy(); } + testMostFlakyGraph = new Chart("testMostFlakyGraph", _build_test_most_flaky_config()); testMostFlakyGraph.canvas.addEventListener("click", (event) => { open_log_from_label(testMostFlakyGraph, event) }); } -// function to create test recent most flaky graph in test section -function create_test_recent_most_flaky_graph() { - if (testRecentMostFlakyGraph) { - testRecentMostFlakyGraph.destroy(); - } +// build config for test recent most flaky graph +function _build_test_recent_most_flaky_config() { const data = get_most_flaky_data("test", settings.graphTypes.testRecentMostFlakyGraphType, filteredTests, ignoreSkipsRecent, true); const graphData = data[0]; const callbackData = data[1]; @@ -261,17 +278,21 @@ function create_test_recent_most_flaky_graph() { if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } } update_height("testRecentMostFlakyVertical", config.data.labels.length, settings.graphTypes.testRecentMostFlakyGraphType); - testRecentMostFlakyGraph = new Chart("testRecentMostFlakyGraph", config); + return config; +} + +// function to create test recent most flaky graph in test section +function create_test_recent_most_flaky_graph() { + console.log("creating_test_recent_most_flaky_graph"); + if (testRecentMostFlakyGraph) { testRecentMostFlakyGraph.destroy(); } + testRecentMostFlakyGraph = new Chart("testRecentMostFlakyGraph", _build_test_recent_most_flaky_config()); testRecentMostFlakyGraph.canvas.addEventListener("click", (event) => { open_log_from_label(testRecentMostFlakyGraph, event) }); } -// function to create test most failed graph in the test section -function create_test_most_failed_graph() { - if (testMostFailedGraph) { - testMostFailedGraph.destroy(); - } +// build config for test most failed graph +function _build_test_most_failed_config() { const data = get_most_failed_data("test", settings.graphTypes.testMostFailedGraphType, filteredTests, false); const graphData = data[0] const callbackData = data[1]; @@ -319,17 +340,21 @@ function create_test_most_failed_graph() { if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } } update_height("testMostFailedVertical", config.data.labels.length, settings.graphTypes.testMostFailedGraphType); - testMostFailedGraph = new Chart("testMostFailedGraph", config); + return config; +} + +// function to create test most failed graph in the test section +function create_test_most_failed_graph() { + console.log("creating_test_most_failed_graph"); + if (testMostFailedGraph) { testMostFailedGraph.destroy(); } + testMostFailedGraph = new Chart("testMostFailedGraph", _build_test_most_failed_config()); testMostFailedGraph.canvas.addEventListener("click", (event) => { open_log_from_label(testMostFailedGraph, event) }); } -// function to create test recent most failed graph in the test section -function create_test_recent_most_failed_graph() { - if (testRecentMostFailedGraph) { - testRecentMostFailedGraph.destroy(); - } +// build config for test recent most failed graph +function _build_test_recent_most_failed_config() { const data = get_most_failed_data("test", settings.graphTypes.testRecentMostFailedGraphType, filteredTests, true); const graphData = data[0] const callbackData = data[1]; @@ -377,17 +402,21 @@ function create_test_recent_most_failed_graph() { if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } } update_height("testRecentMostFailedVertical", config.data.labels.length, settings.graphTypes.testRecentMostFailedGraphType); - testRecentMostFailedGraph = new Chart("testRecentMostFailedGraph", config); + return config; +} + +// function to create test recent most failed graph in the test section +function create_test_recent_most_failed_graph() { + console.log("creating_test_recent_most_failed_graph"); + if (testRecentMostFailedGraph) { testRecentMostFailedGraph.destroy(); } + testRecentMostFailedGraph = new Chart("testRecentMostFailedGraph", _build_test_recent_most_failed_config()); testRecentMostFailedGraph.canvas.addEventListener("click", (event) => { open_log_from_label(testRecentMostFailedGraph, event) }); } -// function to create the most time consuming test graph in the test section -function create_test_most_time_consuming_graph() { - if (testMostTimeConsumingGraph) { - testMostTimeConsumingGraph.destroy(); - } +// build config for test most time consuming graph +function _build_test_most_time_consuming_config() { const onlyLastRun = document.getElementById("onlyLastRunTest").checked; const data = get_most_time_consuming_or_most_used_data("test", settings.graphTypes.testMostTimeConsumingGraphType, filteredTests, onlyLastRun); const graphData = data[0] @@ -456,12 +485,109 @@ function create_test_most_time_consuming_graph() { if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } } update_height("testMostTimeConsumingVertical", config.data.labels.length, settings.graphTypes.testMostTimeConsumingGraphType); - testMostTimeConsumingGraph = new Chart("testMostTimeConsumingGraph", config); + return config; +} + +// function to create the most time consuming test graph in the test section +function create_test_most_time_consuming_graph() { + console.log("creating_test_most_time_consuming_graph"); + if (testMostTimeConsumingGraph) { testMostTimeConsumingGraph.destroy(); } + testMostTimeConsumingGraph = new Chart("testMostTimeConsumingGraph", _build_test_most_time_consuming_config()); testMostTimeConsumingGraph.canvas.addEventListener("click", (event) => { open_log_from_label(testMostTimeConsumingGraph, event) }); } +// update function for test statistics graph - updates existing chart in-place +function update_test_statistics_graph() { + console.log("updating_test_statistics_graph"); + if (!testStatisticsGraph) { create_test_statistics_graph(); return; } + const config = _build_test_statistics_config(); + testStatisticsGraph.data = config.data; + testStatisticsGraph.options = config.options; + testStatisticsGraph.update(); +} + +// update function for test duration graph - updates existing chart in-place +function update_test_duration_graph() { + console.log("updating_test_duration_graph"); + if (!testDurationGraph) { create_test_duration_graph(); return; } + const config = _build_test_duration_config(); + testDurationGraph.data = config.data; + testDurationGraph.options = config.options; + testDurationGraph.update(); +} + +// update function for test messages graph - updates existing chart in-place +function update_test_messages_graph() { + console.log("updating_test_messages_graph"); + if (!testMessagesGraph) { create_test_messages_graph(); return; } + const config = _build_test_messages_config(); + testMessagesGraph.data = config.data; + testMessagesGraph.options = config.options; + testMessagesGraph.update(); +} + +// update function for test duration deviation graph - updates existing chart in-place +function update_test_duration_deviation_graph() { + console.log("updating_test_duration_deviation_graph"); + if (!testDurationDeviationGraph) { create_test_duration_deviation_graph(); return; } + const config = _build_test_duration_deviation_config(); + testDurationDeviationGraph.data = config.data; + testDurationDeviationGraph.options = config.options; + testDurationDeviationGraph.update(); +} + +// update function for test most flaky graph - updates existing chart in-place +function update_test_most_flaky_graph() { + console.log("updating_test_most_flaky_graph"); + if (!testMostFlakyGraph) { create_test_most_flaky_graph(); return; } + const config = _build_test_most_flaky_config(); + testMostFlakyGraph.data = config.data; + testMostFlakyGraph.options = config.options; + testMostFlakyGraph.update(); +} + +// update function for test recent most flaky graph - updates existing chart in-place +function update_test_recent_most_flaky_graph() { + console.log("updating_test_recent_most_flaky_graph"); + if (!testRecentMostFlakyGraph) { create_test_recent_most_flaky_graph(); return; } + const config = _build_test_recent_most_flaky_config(); + testRecentMostFlakyGraph.data = config.data; + testRecentMostFlakyGraph.options = config.options; + testRecentMostFlakyGraph.update(); +} + +// update function for test most failed graph - updates existing chart in-place +function update_test_most_failed_graph() { + console.log("updating_test_most_failed_graph"); + if (!testMostFailedGraph) { create_test_most_failed_graph(); return; } + const config = _build_test_most_failed_config(); + testMostFailedGraph.data = config.data; + testMostFailedGraph.options = config.options; + testMostFailedGraph.update(); +} + +// update function for test recent most failed graph - updates existing chart in-place +function update_test_recent_most_failed_graph() { + console.log("updating_test_recent_most_failed_graph"); + if (!testRecentMostFailedGraph) { create_test_recent_most_failed_graph(); return; } + const config = _build_test_recent_most_failed_config(); + testRecentMostFailedGraph.data = config.data; + testRecentMostFailedGraph.options = config.options; + testRecentMostFailedGraph.update(); +} + +// update function for test most time consuming graph - updates existing chart in-place +function update_test_most_time_consuming_graph() { + console.log("updating_test_most_time_consuming_graph"); + if (!testMostTimeConsumingGraph) { create_test_most_time_consuming_graph(); return; } + const config = _build_test_most_time_consuming_config(); + testMostTimeConsumingGraph.data = config.data; + testMostTimeConsumingGraph.options = config.options; + testMostTimeConsumingGraph.update(); +} + export { create_test_statistics_graph, create_test_duration_graph, @@ -472,4 +598,13 @@ export { create_test_most_failed_graph, create_test_recent_most_failed_graph, create_test_most_time_consuming_graph, + update_test_statistics_graph, + update_test_duration_graph, + update_test_duration_deviation_graph, + update_test_messages_graph, + update_test_most_flaky_graph, + update_test_recent_most_flaky_graph, + update_test_most_failed_graph, + update_test_recent_most_failed_graph, + update_test_most_time_consuming_graph, }; \ No newline at end of file diff --git a/robotframework_dashboard/js/menu.js b/robotframework_dashboard/js/menu.js index 895d516..9a561a8 100644 --- a/robotframework_dashboard/js/menu.js +++ b/robotframework_dashboard/js/menu.js @@ -2,7 +2,7 @@ import { setup_filtered_data_and_filters, update_overview_version_select_list } import { areGroupedProjectsPrepared } from "./variables/globals.js"; import { space_to_camelcase } from "./common.js"; import { set_local_storage_item, setup_overview_localstorage } from "./localstorage.js"; -import { setup_dashboard_graphs } from "./graph_creation/all.js"; +import { create_dashboard_graphs } from "./graph_creation/all.js"; import { settings } from "./variables/settings.js"; import { setup_theme } from "./theme.js"; import { setup_graph_view_buttons, setup_overview_order_filters } from "./eventlisteners.js"; @@ -419,7 +419,10 @@ function setup_data_and_graphs(menuUpdate = false, prepareOverviewProjectData = setup_spinner(true); setup_dashboard_section_menu_buttons(); setup_overview_section_menu_buttons(); - setup_dashboard_graphs(); + + // Always create graphs from scratch because setup_graph_order() + // rebuilds all GridStack grids and canvas DOM elements above + create_dashboard_graphs(); // Ensure overview titles reflect current prefix setting update_overview_prefix_display(); @@ -463,10 +466,34 @@ function setup_spinner(hide) { } } +// Show a semi-transparent loading overlay for filter/update operations +// Unlike setup_spinner, this does NOT hide sections - it overlays on top of existing content +function show_loading_overlay() { + let overlay = document.getElementById("filterLoadingOverlay"); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = "filterLoadingOverlay"; + overlay.className = "filter-loading-overlay"; + overlay.innerHTML = '
'; + document.body.appendChild(overlay); + } + overlay.style.display = "flex"; +} + +// Hide the filter loading overlay +function hide_loading_overlay() { + const overlay = document.getElementById("filterLoadingOverlay"); + if (overlay) { + $(overlay).fadeOut(200); + } +} + export { setup_menu, setup_data_and_graphs, setup_spinner, + show_loading_overlay, + hide_loading_overlay, update_menu, setup_overview_section_menu_buttons }; \ No newline at end of file diff --git a/robotframework_dashboard/js/theme.js b/robotframework_dashboard/js/theme.js index da0cae0..d17d5ee 100644 --- a/robotframework_dashboard/js/theme.js +++ b/robotframework_dashboard/js/theme.js @@ -1,5 +1,5 @@ import { set_local_storage_item } from "./localstorage.js"; -import { setup_dashboard_graphs } from "./graph_creation/all.js"; +import { update_dashboard_graphs } from "./graph_creation/all.js"; import { settings } from "./variables/settings.js"; import { graphFontSize } from "./variables/chartconfig.js"; import { @@ -40,7 +40,7 @@ function toggle_theme() { set_local_storage_item("theme", "dark"); } setup_theme() - setup_dashboard_graphs() + update_dashboard_graphs() } // theme function based on browser/machine color scheme From 6878b1463473a61777d65c0fc31343dbcaaf4a50 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Thu, 19 Feb 2026 01:20:47 +0100 Subject: [PATCH 08/26] Refactor event listener setup for toggle handlers and graph updates; streamline modal filter management --- robotframework_dashboard/js/eventlisteners.js | 202 ++++-------------- 1 file changed, 42 insertions(+), 160 deletions(-) diff --git a/robotframework_dashboard/js/eventlisteners.js b/robotframework_dashboard/js/eventlisteners.js index 57f87df..99b9f2a 100644 --- a/robotframework_dashboard/js/eventlisteners.js +++ b/robotframework_dashboard/js/eventlisteners.js @@ -213,81 +213,23 @@ function setup_settings_modal() { }; } - const toggle_unified = create_toggle_handler({ - key: "show.unified", - elementId: "toggleUnified" + // Data-driven toggle handlers: create handler, load initial value, attach event listener + [ + { key: "show.unified", elementId: "toggleUnified" }, + { key: "show.dateLabels", elementId: "toggleLabels" }, + { key: "show.legends", elementId: "toggleLegends" }, + { key: "show.aliases", elementId: "toggleAliases" }, + { key: "show.milliseconds", elementId: "toggleMilliseconds" }, + { key: "show.axisTitles", elementId: "toggleAxisTitles" }, + { key: "show.animation", elementId: "toggleAnimations" }, + { key: "show.duration", elementId: "toggleAnimationDuration", isNumber: true, event: "change" }, + { key: "show.rounding", elementId: "toggleBarRounding", isNumber: true, event: "change" }, + { key: "show.prefixes", elementId: "togglePrefixes" }, + ].forEach(def => { + const handler = create_toggle_handler(def); + handler(true); + document.getElementById(def.elementId).addEventListener(def.event || "click", () => handler()); }); - - const toggle_labels = create_toggle_handler({ - key: "show.dateLabels", - elementId: "toggleLabels" - }); - - const toggle_legends = create_toggle_handler({ - key: "show.legends", - elementId: "toggleLegends" - }); - - const toggle_aliases = create_toggle_handler({ - key: "show.aliases", - elementId: "toggleAliases" - }); - - const toggle_milliseconds = create_toggle_handler({ - key: "show.milliseconds", - elementId: "toggleMilliseconds" - }); - - const toggle_axis_titles = create_toggle_handler({ - key: "show.axisTitles", - elementId: "toggleAxisTitles" - }); - - const toggle_animations = create_toggle_handler({ - key: "show.animation", - elementId: "toggleAnimations" - }); - - const toggle_animation_duration = create_toggle_handler({ - key: "show.duration", - elementId: "toggleAnimationDuration", - isNumber: true - }); - - const toggle_bar_rounding = create_toggle_handler({ - key: "show.rounding", - elementId: "toggleBarRounding", - isNumber: true - }); - - const toggle_prefixes = create_toggle_handler({ - key: "show.prefixes", - elementId: "togglePrefixes" - }); - - // Initial load - toggle_unified(true); - toggle_labels(true); - toggle_legends(true); - toggle_aliases(true); - toggle_milliseconds(true); - toggle_axis_titles(true); - toggle_animations(true); - toggle_animation_duration(true); - toggle_bar_rounding(true); - toggle_prefixes(true); - - // Add event listeners - document.getElementById("toggleUnified").addEventListener("click", () => toggle_unified()); - document.getElementById("toggleLabels").addEventListener("click", () => toggle_labels()); - document.getElementById("toggleLegends").addEventListener("click", () => toggle_legends()); - document.getElementById("toggleAliases").addEventListener("click", () => toggle_aliases()); - document.getElementById("toggleMilliseconds").addEventListener("click", () => toggle_milliseconds()); - document.getElementById("toggleAxisTitles").addEventListener("click", () => toggle_axis_titles()); - document.getElementById("toggleAnimations").addEventListener("click", () => toggle_animations()); - document.getElementById("toggleAnimationDuration").addEventListener("change", () => toggle_animation_duration()); - document.getElementById("toggleBarRounding").addEventListener("change", () => toggle_bar_rounding()); - document.getElementById("togglePrefixes").addEventListener("click", () => toggle_prefixes()); document.getElementById("themeLight").addEventListener("click", () => toggle_theme()); document.getElementById("themeDark").addEventListener("click", () => toggle_theme()); @@ -699,9 +641,20 @@ function setup_graph_view_buttons() { update_suite_folder_donut_graph(""); }); }); - document.getElementById("heatMapTestType").addEventListener("change", () => { - update_graphs_with_loading(["runHeatmapGraph"], () => { - update_run_heatmap_graph(); + // Simple graph update listeners: element change triggers single graph update + [ + ["heatMapTestType", "runHeatmapGraph", update_run_heatmap_graph], + ["testOnlyChanges", "testStatisticsGraph", update_test_statistics_graph], + ["testNoChanges", "testStatisticsGraph", update_test_statistics_graph], + ["compareOnlyChanges", "compareTestsGraph", update_compare_tests_graph], + ["compareNoChanges", "compareTestsGraph", update_compare_tests_graph], + ["onlyLastRunSuite", "suiteMostTimeConsumingGraph", update_suite_most_time_consuming_graph], + ["onlyLastRunTest", "testMostTimeConsumingGraph", update_test_most_time_consuming_graph], + ["onlyLastRunKeyword", "keywordMostTimeConsumingGraph", update_keyword_most_time_consuming_graph], + ["onlyLastRunKeywordMostUsed", "keywordMostUsedGraph", update_keyword_most_used_graph], + ].forEach(([elementId, graphId, updateFn]) => { + document.getElementById(elementId).addEventListener("change", () => { + update_graphs_with_loading([graphId], updateFn); }); }); document.getElementById("heatMapHour").addEventListener("change", () => { @@ -710,47 +663,6 @@ function setup_graph_view_buttons() { update_run_heatmap_graph(); }); }); - document.getElementById("testOnlyChanges").addEventListener("change", () => { - update_graphs_with_loading(["testStatisticsGraph"], () => { - update_test_statistics_graph(); - }); - }); - document.getElementById("testNoChanges").addEventListener("change", () => { - update_graphs_with_loading(["testStatisticsGraph"], () => { - update_test_statistics_graph(); - }); - }); - document.getElementById("compareOnlyChanges").addEventListener("change", () => { - update_graphs_with_loading(["compareTestsGraph"], () => { - update_compare_tests_graph(); - }); - }); - document.getElementById("compareNoChanges").addEventListener("change", () => { - update_graphs_with_loading(["compareTestsGraph"], () => { - update_compare_tests_graph(); - }); - }); - // most time consuming only latest run switch event listeners - document.getElementById("onlyLastRunSuite").addEventListener("change", () => { - update_graphs_with_loading(["suiteMostTimeConsumingGraph"], () => { - update_suite_most_time_consuming_graph(); - }); - }); - document.getElementById("onlyLastRunTest").addEventListener("change", () => { - update_graphs_with_loading(["testMostTimeConsumingGraph"], () => { - update_test_most_time_consuming_graph(); - }); - }); - document.getElementById("onlyLastRunKeyword").addEventListener("change", () => { - update_graphs_with_loading(["keywordMostTimeConsumingGraph"], () => { - update_keyword_most_time_consuming_graph(); - }); - }); - document.getElementById("onlyLastRunKeywordMostUsed").addEventListener("change", () => { - update_graphs_with_loading(["keywordMostUsedGraph"], () => { - update_keyword_most_used_graph(); - }); - }); // graph layout changes document.querySelectorAll(".shown-graph").forEach(btn => { btn.addEventListener("click", () => { @@ -832,52 +744,22 @@ function setup_graph_view_buttons() { update_active_graph_type_buttons(graphChangeButton, activeGraphType); }); - // Handle modal show event - move filters to modal + // Handle modal show event - move section filters into modal card bodies $("#sectionFiltersModal").on("show.bs.modal", function () { - // Move suite filters - const suiteFilters = document.getElementById('suiteSectionFilters'); - const suiteCardBody = document.getElementById('suiteSectionFiltersCardBody'); - if (suiteFilters && suiteCardBody) { - suiteCardBody.appendChild(suiteFilters); - } - - // Move test filters - const testFilters = document.getElementById('testSectionFilters'); - const testCardBody = document.getElementById('testSectionFiltersCardBody'); - if (testFilters && testCardBody) { - testCardBody.appendChild(testFilters); - } - - // Move keyword filters - const keywordFilters = document.getElementById('keywordSectionFilters'); - const keywordCardBody = document.getElementById('keywordSectionFiltersCardBody'); - if (keywordFilters && keywordCardBody) { - keywordCardBody.appendChild(keywordFilters); - } + ["suite", "test", "keyword"].forEach(section => { + const filters = document.getElementById(`${section}SectionFilters`); + const cardBody = document.getElementById(`${section}SectionFiltersCardBody`); + if (filters && cardBody) cardBody.appendChild(filters); + }); }); - // Handle modal hide event - return filters to original positions + // Handle modal hide event - return section filters to original containers $("#sectionFiltersModal").on("hide.bs.modal", function () { - // Return suite filters - const suiteFilters = document.getElementById('suiteSectionFilters'); - const suiteOriginalContainer = document.getElementById('suiteSectionFiltersContainer'); - if (suiteFilters && suiteOriginalContainer) { - suiteOriginalContainer.insertBefore(suiteFilters, suiteOriginalContainer.firstChild); - } - - // Return test filters - const testFilters = document.getElementById('testSectionFilters'); - const testOriginalContainer = document.getElementById('testSectionFiltersContainer'); - if (testFilters && testOriginalContainer) { - testOriginalContainer.insertBefore(testFilters, testOriginalContainer.firstChild); - } - - // Return keyword filters - const keywordFilters = document.getElementById('keywordSectionFilters'); - const keywordOriginalContainer = document.getElementById('keywordSectionFiltersContainer'); - if (keywordFilters && keywordOriginalContainer) { - keywordOriginalContainer.insertBefore(keywordFilters, keywordOriginalContainer.firstChild); - } + ["suite", "test", "keyword"].forEach(section => { + const filters = document.getElementById(`${section}SectionFilters`); + const container = document.getElementById(`${section}SectionFiltersContainer`); + if (filters && container) container.insertBefore(filters, container.firstChild); + }); }); } From 36ae4d32096fd0b5c08e1c21197e6e0014e4cddc Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Thu, 19 Feb 2026 01:20:53 +0100 Subject: [PATCH 09/26] Refactor theme management functions to consolidate light and dark mode logic; enhance SVG handling and chart settings based on theme --- robotframework_dashboard/js/theme.js | 167 +++++++++------------------ 1 file changed, 54 insertions(+), 113 deletions(-) diff --git a/robotframework_dashboard/js/theme.js b/robotframework_dashboard/js/theme.js index d17d5ee..247e3ab 100644 --- a/robotframework_dashboard/js/theme.js +++ b/robotframework_dashboard/js/theme.js @@ -60,121 +60,62 @@ function setup_theme() { }); } - function set_light_mode() { + function apply_theme(isDark) { + const color = isDark ? "white" : "black"; // menu theme - document.getElementById("navigation").classList.remove("navbar-dark") - document.getElementById("navigation").classList.add("navbar-light") - document.getElementById("themeLight").hidden = false; - document.getElementById("themeDark").hidden = true; + document.getElementById("navigation").classList.remove(isDark ? "navbar-light" : "navbar-dark"); + document.getElementById("navigation").classList.add(isDark ? "navbar-dark" : "navbar-light"); + document.getElementById("themeLight").hidden = isDark; + document.getElementById("themeDark").hidden = !isDark; // bootstrap related settings - document.getElementsByTagName("html")[0].setAttribute("data-bs-theme", "light"); - html.style.setProperty("--bs-body-bg", "#fff"); - swap_button_classes(".btn-outline-light", "btn-outline-dark", ".btn-light", "btn-dark"); - // chartjs default graph settings - Chart.defaults.color = "#666"; - Chart.defaults.borderColor = "rgba(0,0,0,0.1)"; - Chart.defaults.backgroundColor = "rgba(0,0,0,0.1)"; - Chart.defaults.elements.line.borderColor = "rgba(0,0,0,0.1)"; - // svgs - const svgMap = { - ids: { - "github": githubSVG("black"), - "docs": docsSVG("black"), - "settings": settingsSVG("black"), - "database": databaseSVG("black"), - "filters": filterSVG("black"), - "rflogo": getRflogoLightSVG(), - "themeLight": moonSVG, - "bug": bugSVG("black"), - "customizeLayout": customizeViewSVG("black"), - "saveLayout": saveSVG("black"), - }, - classes: { - ".percentage-graph": percentageSVG("black"), - ".bar-graph": barSVG("black"), - ".line-graph": lineSVG("black"), - ".pie-graph": pieSVG("black"), - ".boxplot-graph": boxplotSVG("black"), - ".heatmap-graph": heatmapSVG("black"), - ".stats-graph": statsSVG("black"), - ".timeline-graph": timelineSVG("black"), - ".radar-graph": radarSVG("black"), - ".fullscreen-graph": fullscreenSVG("black"), - ".close-graph": closeSVG("black"), - ".information-icon": informationSVG("black"), - ".shown-graph": eyeSVG("black"), - ".hidden-graph": eyeOffSVG("black"), - ".shown-section": eyeSVG("black"), - ".hidden-section": eyeOffSVG("black"), - ".move-up-table": moveUpSVG("black"), - ".move-down-table": moveDownSVG("black"), - ".move-up-section": moveUpSVG("black"), - ".move-down-section": moveDownSVG("black"), - ".clock-icon": clockSVG("black"), - } - }; - for (const [id, svg] of Object.entries(svgMap.ids)) { - const el = document.getElementById(id); - if (el) el.innerHTML = svg; - } - for (const [selector, svg] of Object.entries(svgMap.classes)) { - document.querySelectorAll(selector).forEach(el => { - el.innerHTML = svg; - }); + document.getElementsByTagName("html")[0].setAttribute("data-bs-theme", isDark ? "dark" : "light"); + html.style.setProperty("--bs-body-bg", isDark ? "rgba(30, 41, 59, 0.9)" : "#fff"); + if (isDark) { + swap_button_classes(".btn-outline-dark", "btn-outline-light", ".btn-dark", "btn-light"); + } else { + swap_button_classes(".btn-outline-light", "btn-outline-dark", ".btn-light", "btn-dark"); } - } - - function set_dark_mode() { - // menu theme - document.getElementById("themeLight").hidden = true; - document.getElementById("themeDark").hidden = false; - document.getElementById("navigation").classList.remove("navbar-light") - document.getElementById("navigation").classList.add("navbar-dark") - // bootstrap related settings - document.getElementsByTagName("html")[0].setAttribute("data-bs-theme", "dark"); - html.style.setProperty("--bs-body-bg", "rgba(30, 41, 59, 0.9)"); - swap_button_classes(".btn-outline-dark", "btn-outline-light", ".btn-dark", "btn-light"); // chartjs default graph settings - Chart.defaults.color = "#eee"; - Chart.defaults.borderColor = "rgba(255,255,255,0.1)"; - Chart.defaults.backgroundColor = "rgba(255,255,0,0.1)"; - Chart.defaults.elements.line.borderColor = "rgba(255,255,0,0.4)"; + Chart.defaults.color = isDark ? "#eee" : "#666"; + Chart.defaults.borderColor = isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)"; + Chart.defaults.backgroundColor = isDark ? "rgba(255,255,0,0.1)" : "rgba(0,0,0,0.1)"; + Chart.defaults.elements.line.borderColor = isDark ? "rgba(255,255,0,0.4)" : "rgba(0,0,0,0.1)"; // svgs const svgMap = { ids: { - "github": githubSVG("white"), - "docs": docsSVG("white"), - "settings": settingsSVG("white"), - "database": databaseSVG("white"), - "filters": filterSVG("white"), - "rflogo": getRflogoDarkSVG(), - "themeDark": sunSVG, - "bug": bugSVG("white"), - "customizeLayout": customizeViewSVG("white"), - "saveLayout": saveSVG("white"), + "github": githubSVG(color), + "docs": docsSVG(color), + "settings": settingsSVG(color), + "database": databaseSVG(color), + "filters": filterSVG(color), + "rflogo": isDark ? getRflogoDarkSVG() : getRflogoLightSVG(), + [isDark ? "themeDark" : "themeLight"]: isDark ? sunSVG : moonSVG, + "bug": bugSVG(color), + "customizeLayout": customizeViewSVG(color), + "saveLayout": saveSVG(color), }, classes: { - ".percentage-graph": percentageSVG("white"), - ".bar-graph": barSVG("white"), - ".line-graph": lineSVG("white"), - ".pie-graph": pieSVG("white"), - ".boxplot-graph": boxplotSVG("white"), - ".heatmap-graph": heatmapSVG("white"), - ".stats-graph": statsSVG("white"), - ".timeline-graph": timelineSVG("white"), - ".radar-graph": radarSVG("white"), - ".fullscreen-graph": fullscreenSVG("white"), - ".close-graph": closeSVG("white"), - ".information-icon": informationSVG("white"), - ".shown-graph": eyeSVG("white"), - ".hidden-graph": eyeOffSVG("white"), - ".shown-section": eyeSVG("white"), - ".hidden-section": eyeOffSVG("white"), - ".move-up-table": moveUpSVG("white"), - ".move-down-table": moveDownSVG("white"), - ".move-up-section": moveUpSVG("white"), - ".move-down-section": moveDownSVG("white"), - ".clock-icon": clockSVG("white"), + ".percentage-graph": percentageSVG(color), + ".bar-graph": barSVG(color), + ".line-graph": lineSVG(color), + ".pie-graph": pieSVG(color), + ".boxplot-graph": boxplotSVG(color), + ".heatmap-graph": heatmapSVG(color), + ".stats-graph": statsSVG(color), + ".timeline-graph": timelineSVG(color), + ".radar-graph": radarSVG(color), + ".fullscreen-graph": fullscreenSVG(color), + ".close-graph": closeSVG(color), + ".information-icon": informationSVG(color), + ".shown-graph": eyeSVG(color), + ".hidden-graph": eyeOffSVG(color), + ".shown-section": eyeSVG(color), + ".hidden-section": eyeOffSVG(color), + ".move-up-table": moveUpSVG(color), + ".move-down-table": moveDownSVG(color), + ".move-up-section": moveUpSVG(color), + ".move-down-section": moveDownSVG(color), + ".clock-icon": clockSVG(color), } }; for (const [id, svg] of Object.entries(svgMap.ids)) { @@ -189,22 +130,22 @@ function setup_theme() { } // detect theme preference - const isDark = html.classList.contains("dark-mode"); + const currentlyDark = html.classList.contains("dark-mode"); if (settings.theme === "light") { - if (isDark) html.classList.remove("dark-mode"); - set_light_mode(); + if (currentlyDark) html.classList.remove("dark-mode"); + apply_theme(false); } else if (settings.theme === "dark") { - if (!isDark) html.classList.add("dark-mode"); - set_dark_mode(); + if (!currentlyDark) html.classList.add("dark-mode"); + apply_theme(true); } else { // No theme in localStorage, fall back to system preference if (window.matchMedia("(prefers-color-scheme: dark)").matches) { html.classList.add("dark-mode"); - set_dark_mode(); + apply_theme(true); } else { html.classList.remove("dark-mode"); - set_light_mode(); + apply_theme(false); } } } From 2e2fe68927d5235c3ff23aa691a0a29d609b6ec7 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Thu, 19 Feb 2026 01:21:01 +0100 Subject: [PATCH 10/26] Refactor graph and table HTML generation; utilize helper functions for improved maintainability and readability --- .../js/variables/graphmetadata.js | 401 ++++-------------- 1 file changed, 77 insertions(+), 324 deletions(-) diff --git a/robotframework_dashboard/js/variables/graphmetadata.js b/robotframework_dashboard/js/variables/graphmetadata.js index 827154b..209d6d0 100644 --- a/robotframework_dashboard/js/variables/graphmetadata.js +++ b/robotframework_dashboard/js/variables/graphmetadata.js @@ -1,3 +1,57 @@ +// View option to CSS class mapping +const viewOptionClassMap = { + "Percentages": "percentage-graph", + "Amount": "bar-graph", + "Bar": "bar-graph", + "Line": "line-graph", + "Timeline": "timeline-graph", + "Donut": "pie-graph", + "Heatmap": "heatmap-graph", + "Stats": "stats-graph", + "Radar": "radar-graph", +}; + +// Generate standard graph HTML template +function _graphHtml(key, title, viewOptions, { hasVertical = false, titleId = true, viewClassOverrides = {} } = {}) { + const controls = viewOptions.map(opt => { + const cls = viewClassOverrides[opt] || viewOptionClassMap[opt]; + return ``; + }).join('\n '); + const titleTag = titleId ? `
${title}
` : `
${title}
`; + const canvas = hasVertical + ? `
` + : ``; + return `
+ ${titleTag} +
+ ${controls} + + + + +
+
+
+ ${canvas} +
`; +} + +// Generate standard table HTML template +function _tableHtml(key, displayName) { + return `
+
+
${displayName} Table
+
+ + + + +
+
+
+
`; +} + const graphMetadata = [ { key: "runStatistics", @@ -5,21 +59,7 @@ const graphMetadata = [ defaultType: "percentages", viewOptions: ["Percentages", "Line", "Amount"], hasFullscreenButton: true, - html: `
-
Statistics
-
- - - - - - - -
-
-
- -
`, + html: _graphHtml("runStatistics", "Statistics", ["Percentages", "Line", "Amount"]), }, { key: "runDonut", @@ -139,20 +179,7 @@ const graphMetadata = [ defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, - html: `
-
Duration
-
- - - - - - -
-
-
- -
`, + html: _graphHtml("runDuration", "Duration", ["Bar", "Line"]), }, { key: "runHeatmap", @@ -265,21 +292,7 @@ const graphMetadata = [ defaultType: "percentages", viewOptions: ["Percentages", "Line", "Amount"], hasFullscreenButton: true, - html: `
-
Statistics
-
- - - - - - - -
-
-
- -
`, + html: _graphHtml("suiteStatistics", "Statistics", ["Percentages", "Line", "Amount"]), }, { key: "suiteDuration", @@ -287,20 +300,7 @@ const graphMetadata = [ defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, - html: `
-
Duration
-
- - - - - - -
-
-
- -
`, + html: _graphHtml("suiteDuration", "Duration", ["Bar", "Line"]), }, { key: "suiteMostFailed", @@ -308,22 +308,7 @@ const graphMetadata = [ defaultType: "bar", viewOptions: ["Bar", "Timeline"], hasFullscreenButton: true, - html: `
-
Most Failed
-
- - - - - - -
-
-
-
- -
-
`, + html: _graphHtml("suiteMostFailed", "Most Failed", ["Bar", "Timeline"], { hasVertical: true }), }, { key: "suiteMostTimeConsuming", @@ -399,20 +384,7 @@ const graphMetadata = [ defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, - html: `
-
Duration
-
- - - - - - -
-
-
- -
`, + html: _graphHtml("testDuration", "Duration", ["Bar", "Line"]), }, { key: "testDurationDeviation", @@ -420,19 +392,7 @@ const graphMetadata = [ defaultType: "bar", viewOptions: ["Bar"], hasFullscreenButton: true, - html: `
-
Duration Deviation
-
- - - - - -
-
-
- -
`, + html: _graphHtml("testDurationDeviation", "Duration Deviation", ["Bar"], { viewClassOverrides: { "Bar": "boxplot-graph" } }), }, { key: "testMessages", @@ -440,22 +400,7 @@ const graphMetadata = [ defaultType: "timeline", viewOptions: ["Bar", "Timeline"], hasFullscreenButton: true, - html: `
-
Messages
-
- - - - - - -
-
-
-
- -
-
`, + html: _graphHtml("testMessages", "Messages", ["Bar", "Timeline"], { hasVertical: true }), }, { key: "testMostFlaky", @@ -521,22 +466,7 @@ const graphMetadata = [ defaultType: "timeline", viewOptions: ["Bar", "Timeline"], hasFullscreenButton: true, - html: `
-
Most Failed
-
- - - - - - -
-
-
-
- -
-
`, + html: _graphHtml("testMostFailed", "Most Failed", ["Bar", "Timeline"], { hasVertical: true }), }, { key: "testRecentMostFailed", @@ -544,22 +474,7 @@ const graphMetadata = [ defaultType: "timeline", viewOptions: ["Bar", "Timeline"], hasFullscreenButton: true, - html: `
-
Recent Most Failed
-
- - - - - - -
-
-
-
- -
-
`, + html: _graphHtml("testRecentMostFailed", "Recent Most Failed", ["Bar", "Timeline"], { hasVertical: true }), }, { key: "testMostTimeConsuming", @@ -596,21 +511,7 @@ const graphMetadata = [ defaultType: "percentages", viewOptions: ["Percentages", "Line", "Amount"], hasFullscreenButton: true, - html: `
-
Statistics
-
- - - - - - - -
-
-
- -
`, + html: _graphHtml("keywordStatistics", "Statistics", ["Percentages", "Line", "Amount"]), }, { key: "keywordTimesRun", @@ -618,20 +519,7 @@ const graphMetadata = [ defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, - html: `
-
Times Run
-
- - - - - - -
-
-
- -
`, + html: _graphHtml("keywordTimesRun", "Times Run", ["Bar", "Line"]), }, { key: "keywordTotalDuration", @@ -639,20 +527,7 @@ const graphMetadata = [ defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, - html: `
-
Total Duration
-
- - - - - - -
-
-
- -
`, + html: _graphHtml("keywordTotalDuration", "Total Duration", ["Bar", "Line"]), }, { key: "keywordAverageDuration", @@ -660,20 +535,7 @@ const graphMetadata = [ defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, - html: `
-
Average Duration
-
- - - - - - -
-
-
- -
`, + html: _graphHtml("keywordAverageDuration", "Average Duration", ["Bar", "Line"]), }, { key: "keywordMinDuration", @@ -681,20 +543,7 @@ const graphMetadata = [ defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, - html: `
-
Min Duration
-
- - - - - - -
-
-
- -
`, + html: _graphHtml("keywordMinDuration", "Min Duration", ["Bar", "Line"]), }, { key: "keywordMaxDuration", @@ -702,20 +551,7 @@ const graphMetadata = [ defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, - html: `
-
Max Duration
-
- - - - - - -
-
-
- -
>`, + html: _graphHtml("keywordMaxDuration", "Max Duration", ["Bar", "Line"]), }, { key: "keywordMostFailed", @@ -723,22 +559,7 @@ const graphMetadata = [ defaultType: "timeline", viewOptions: ["Bar", "Timeline"], hasFullscreenButton: true, - html: `
-
Most Failed
-
- - - - - - -
-
-
-
- -
`, + html: _graphHtml("keywordMostFailed", "Most Failed", ["Bar", "Timeline"], { hasVertical: true }), }, { key: "keywordMostTimeConsuming", @@ -804,19 +625,7 @@ const graphMetadata = [ defaultType: "bar", viewOptions: ["Bar"], hasFullscreenButton: true, - html: `
-
Statistics
-
- - - - - -
-
-
- -
`, + html: _graphHtml("compareStatistics", "Statistics", ["Bar"], { titleId: false }), }, { key: "compareSuiteDuration", @@ -824,19 +633,7 @@ const graphMetadata = [ defaultType: "radar", viewOptions: ["Radar"], hasFullscreenButton: true, - html: `
-
Suite Duration
-
- - - - - -
-
-
- -
`, + html: _graphHtml("compareSuiteDuration", "Suite Duration", ["Radar"], { titleId: false }), }, { key: "compareTests", @@ -884,18 +681,7 @@ const graphMetadata = [ viewOptions: ["Table"], hasFullscreenButton: false, information: null, - html: `
-
-
Run Table
-
- - - - -
-
-
-
`, + html: _tableHtml("runTable", "Run"), }, { key: "suiteTable", @@ -904,18 +690,7 @@ const graphMetadata = [ viewOptions: ["Table"], hasFullscreenButton: false, information: null, - html: `
-
-
Suite Table
-
- - - - -
-
-
-
`, + html: _tableHtml("suiteTable", "Suite"), }, { key: "testTable", @@ -924,18 +699,7 @@ const graphMetadata = [ viewOptions: ["Table"], hasFullscreenButton: false, information: null, - html: `
-
-
Test Table
-
- - - - -
-
-
-
`, + html: _tableHtml("testTable", "Test"), }, { key: "keywordTable", @@ -944,18 +708,7 @@ const graphMetadata = [ viewOptions: ["Table"], hasFullscreenButton: false, information: null, - html: `
-
-
Keyword Table
-
- - - - -
-
-
-
`, + html: _tableHtml("keywordTable", "Keyword"), }, ]; From af6092e4ba45aacb19fd80bc83f964a821022520 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Thu, 19 Feb 2026 01:21:08 +0100 Subject: [PATCH 11/26] Refactor informationMap to dynamically generate control entries for graphs, tables, and sections; improve maintainability and reduce redundancy --- .../js/variables/information.js | 189 +++--------------- 1 file changed, 32 insertions(+), 157 deletions(-) diff --git a/robotframework_dashboard/js/variables/information.js b/robotframework_dashboard/js/variables/information.js index e335bda..c10c7e4 100644 --- a/robotframework_dashboard/js/variables/information.js +++ b/robotframework_dashboard/js/variables/information.js @@ -45,41 +45,21 @@ const informationMap = { "runStatisticsGraphPercentages": "Percentages: Displays the distribution of passed, failed, skipped tests per run, where 100% equals all tests combined", "runStatisticsGraphAmount": "Amount: Displays the actual number of passed, failed, skipped tests per run", "runStatisticsGraphLine": "Line: Displays the same data but over a time axis, useful for spotting failure patterns on specific dates or times", - "runStatisticsFullscreen": "Fullscreen", - "runStatisticsClose": "Close", - "runStatisticsShown": "Hide Graph", - "runStatisticsHidden": "Show Graph", "runDonutGraphDonut": `This graph contains two donut charts: - The first donut displays the percentage of passed, failed, and skipped tests for the most recent run.. - The second donut displays the total percentage of passed, failed, and skipped tests across all runs`, - "runDonutFullscreen": "Fullscreen", - "runDonutClose": "Close", - "runDonutShown": "Hide Graph", - "runDonutHidden": "Show Graph", "runStatsGraphStats": `This section provides key statistics: - Executed: Total counts of Runs, Suites, Tests, and Keywords that have been executed. - Unique Tests: Displays the number of distinct test cases across all runs. - Outcomes: Total Passed, Failed, and Skipped tests, including their percentages relative to the full test set. - Duration: Displays the cumulative runtime of all runs, the average runtime per run, and the average duration of individual tests. - Pass Rate: Displays the average run-level pass rate, helping evaluate overall reliability over time.`, - "runStatsFullscreen": "Fullscreen", - "runStatsClose": "Close", - "runStatsShown": "Hide Graph", - "runStatsHidden": "Show Graph", "runDurationGraphBar": "Bar: Displays total run durations represented as vertical bars", "runDurationGraphLine": "Displays the same data but over a time axis for clearer trend analysis", - "runDurationFullscreen": "Fullscreen", - "runDurationClose": "Close", - "runDurationShown": "Hide Graph", - "runDurationHidden": "Show Graph", "runHeatmapGraphHeatmap": `This graph visualizes a heatmap of when tests are executed the most: - All: Displays how many tests ran during the hours or minutes of the week days. - Status: Displays only tests of the selected status. - Hour: Displays only that hour so you get insights per minute.`, - "runHeatmapFullscreen": "Fullscreen", - "runHeatmapClose": "Close", - "runHeatmapShown": "Hide Graph", - "runHeatmapHidden": "Show Graph", "suiteFolderDonutGraphDonut": `This graph contains two donut charts: - The first donut displays the top-level folders of the suites and the amount of tests each folder contains. - The second donut displays the same folder structure but only for the most recent run and only includes failed tests. @@ -87,208 +67,103 @@ const informationMap = { - Navigating folders also updates Suite Statistics and Suite Duration. - Go Up: navigates to the parent folder level. - Only Failed: filters to show only folders with failing tests.`, - "suiteFolderDonutFullscreen": "Fullscreen", - "suiteFolderDonutClose": "Close", - "suiteFolderDonutShown": "Hide Graph", - "suiteFolderDonutHidden": "Show Graph", "suiteStatisticsGraphPercentages": "Percentages: Displays the passed, failed, skipped rate of test suites per run", "suiteStatisticsGraphAmount": "Amount: Displays the actual number of passed, failed, skipped suites per run", "suiteStatisticsGraphLine": "Line: Displays the same data but over a time axis, useful for spotting failure patterns on specific dates or times", - "suiteStatisticsFullscreen": "Fullscreen", - "suiteStatisticsClose": "Close", - "suiteStatisticsShown": "Hide Graph", - "suiteStatisticsHidden": "Show Graph", "suiteDurationGraphBar": "Bar: Displays total suite durations represented as vertical bars", "suiteDurationGraphLine": "Line: Displays the same data but over a time axis for clearer trend analysis", - "suiteDurationFullscreen": "Fullscreen", - "suiteDurationClose": "Close", - "suiteDurationShown": "Hide Graph", - "suiteDurationHidden": "Show Graph", "suiteMostFailedGraphBar": "Bar: Displays suites ranked by number of failures represented as vertical bars. The default view shows the Top 10 most failed suites; fullscreen expands this to the Top 50.", "suiteMostFailedGraphTimeline": "Timeline: Displays when failures occurred to identify clustering over time. The default view shows the Top 10 most failed suites; fullscreen expands this to the Top 50", - "suiteMostFailedFullscreen": "Fullscreen", - "suiteMostFailedClose": "Close", - "suiteMostFailedShown": "Hide Graph", - "suiteMostFailedHidden": "Show Graph", "suiteMostTimeConsumingGraphBar": "Bar: Displays suites ranked by how often they were the slowest (most time-consuming) suite in a run. Each bar represents how many times a suite was the single slowest one across all runs. The regular view shows the Top 10; fullscreen mode expands the list to the Top 50. When 'Only Last Run' is enabled, this graph instead shows the Top 10 (or Top 50 in fullscreen) most time-consuming suites *within the latest run only*, ranked by duration.", "suiteMostTimeConsumingGraphTimeline": "Timeline: Displays the slowest suite for each run on a timeline. For every run, only the single most time-consuming suite is shown. The regular view shows the Top 10 most frequently slowest suites; fullscreen mode expands the list to the Top 50. When 'Only Last Run' is enabled, the timeline shows only the latest run, highlighting its Top 10 (or Top 50 in fullscreen) most time-consuming suites by duration.", - "suiteMostTimeConsumingFullscreen": "Fullscreen", - "suiteMostTimeConsumingClose": "Close", - "suiteMostTimeConsumingShown": "Hide Graph", - "suiteMostTimeConsumingHidden": "Show Graph", "testStatisticsGraphTimeline": `This graph displays the statistics of the tests in a timeline format Status: Displays only tests don't have any status changes and have the selected status Only Changes: Displays only tests that have changed statuses at some point in time Tip: Don't use Status and Only Changes at the same time as it will result in an empty graph`, - "testStatisticsFullscreen": "Fullscreen", - "testStatisticsClose": "Close", - "testStatisticsShown": "Hide Graph", - "testStatisticsHidden": "Show Graph", "testDurationGraphBar": "Bar: Displays test durations represented as vertical bars", "testDurationGraphLine": "Line: Displays the same data but over a time axis for clearer trend analysis", - "testDurationFullscreen": "Fullscreen", - "testDurationClose": "Close", - "testDurationShown": "Hide Graph", - "testDurationHidden": "Show Graph", "testDurationDeviationGraphBar": `This boxplot chart displays how much test durations deviate from the average, represented as vertical bars. It helps identify tests with inconsistent execution times, which might be flaky or worth investigating`, - "testDurationDeviationFullscreen": "Fullscreen", - "testDurationDeviationClose": "Close", - "testDurationDeviationShown": "Hide Graph", - "testDurationDeviationHidden": "Show Graph", "testMessagesGraphBar": `Bar: Displays messages ranked by number of occurrences represented as vertical bars - The regular view shows the Top 10 most frequent messages; fullscreen mode expands this to the Top 50. - To generalize messages (e.g., group similar messages), use the -m/--messageconfig option in the CLI (--help or README).`, "testMessagesGraphTimeline": `Timeline: Displays when those messages occurred to reveal problem spikes - The regular view shows the Top 10 most frequent messages; fullscreen mode expands this to the Top 50. - To generalize messages (e.g., group similar messages), use the -m/--messageconfig option in the CLI (--help or README).`, - "testMessagesFullscreen": "Fullscreen", - "testMessagesClose": "Close", - "testMessagesShown": "Hide Graph", - "testMessagesHidden": "Show Graph", "testMostFlakyGraphBar": `Bar: Displays tests ranked by frequency of status changes represented as vertical bars - The regular view shows the Top 10 flaky tests; fullscreen mode expands the list to the Top 50. - Ignore Skips: filters to only count passed/failed as status flips and not skips.`, "testMostFlakyGraphTimeline": `Timeline: Displays when the status changes occurred across runs - The regular view shows the Top 10 flaky tests; fullscreen mode expands the list to the Top 50. - Ignore Skips: filters to only count passed/failed as status flips and not skips.`, - "testMostFlakyFullscreen": "Fullscreen", - "testMostFlakyClose": "Close", - "testMostFlakyShown": "Hide Graph", - "testMostFlakyHidden": "Show Graph", "testRecentMostFlakyGraphBar": `Bar: Displays tests ranked by frequency of recent status changes represented as vertical bars - The regular view shows the Top 10 flaky tests; fullscreen mode expands the list to the Top 50. - Ignore Skips: filters to only count passed/failed as status flips and not skips.`, "testRecentMostFlakyGraphTimeline": `Timeline: Displays when the status changes occurred across runs - The regular view shows the Top 10 flaky tests; fullscreen mode expands the list to the Top 50. - Ignore Skips: filters to only count passed/failed as status flips and not skips.`, - "testRecentMostFlakyFullscreen": "Fullscreen", - "testRecentMostFlakyClose": "Close", - "testRecentMostFlakyShown": "Hide Graph", - "testRecentMostFlakyHidden": "Show Graph", "testMostFailedGraphBar": `Bar: Displays tests ranked by total number of failures represented as vertical bars. The regular view shows the Top 10 most failed tests; fullscreen mode expands the list to the Top 50.`, "testMostFailedGraphTimeline": `Displays when failures occurred across runs. The regular view shows the Top 10 most failed tests; fullscreen mode expands the list to the Top 50.`, - "testMostFailedFullscreen": "Fullscreen", - "testMostFailedClose": "Close", - "testMostFailedShown": "Hide Graph", - "testMostFailedHidden": "Show Graph", "testRecentMostFailedGraphBar": `Bar: Displays recent tests ranked by total number of failures represented as vertical bars. The regular view shows the Top 10 most failed tests; fullscreen mode expands the list to the Top 50.`, "testRecentMostFailedGraphTimeline": `Displays when most recent failures occurred across runs. The regular view shows the Top 10 most failed tests; fullscreen mode expands the list to the Top 50.`, - "testRecentMostFailedFullscreen": "Fullscreen", - "testRecentMostFailedClose": "Close", - "testRecentMostFailedShown": "Hide Graph", - "testRecentMostFailedHidden": "Show Graph", "testMostTimeConsumingGraphBar": "Bar: Displays tests ranked by how often they were the slowest (most time-consuming) test in a run. Each bar represents how many times a test was the single slowest one across all runs. The regular view shows the Top 10; fullscreen mode expands the list to the Top 50. When 'Only Last Run' is enabled, this graph instead shows the Top 10 (or Top 50 in fullscreen) most time-consuming tests *within the latest run only*, ranked by duration.", "testMostTimeConsumingGraphTimeline": "Timeline: Displays the slowest test for each run on a timeline. For every run, only the single most time-consuming test is shown. The regular view shows the Top 10 most frequently slowest tests; fullscreen mode expands the list to the Top 50. When 'Only Last Run' is enabled, the timeline shows only the latest run, highlighting its Top 10 (or Top 50 in fullscreen) most time-consuming tests by duration.", - "testMostTimeConsumingFullscreen": "Fullscreen", - "testMostTimeConsumingClose": "Close", - "testMostTimeConsumingShown": "Hide Graph", - "testMostTimeConsumingHidden": "Show Graph", "keywordStatisticsGraphPercentages": "Percentages: Displays the distribution of passed, failed, skipped statuses for each keyword per run", "keywordStatisticsGraphAmount": "Amount: Displays raw counts of each status per run", "keywordStatisticsGraphLine": "Line: Displays the same data but over a time axis", - "keywordStatisticsFullscreen": "Fullscreen", - "keywordStatisticsClose": "Close", - "keywordStatisticsShown": "Hide Graph", - "keywordStatisticsHidden": "Show Graph", "keywordTimesRunGraphBar": "Bar: Displays times run per keyword represented as vertical bars", "keywordTimesRunGraphLine": "Line: Displays the same data but over a time axis", - "keywordTimesRunFullscreen": "Fullscreen", - "keywordTimesRunClose": "Close", - "keywordTimesRunShown": "Hide Graph", - "keywordTimesRunHidden": "Show Graph", "keywordTotalDurationGraphBar": "Bar: Displays the cumulative time each keyword ran during each run represented as vertical bars", "keywordTotalDurationGraphLine": "Line: Displays the same data but over a time axis", - "keywordTotalDurationFullscreen": "Fullscreen", - "keywordTotalDurationClose": "Close", - "keywordTotalDurationShown": "Hide Graph", - "keywordTotalDurationHidden": "Show Graph", "keywordAverageDurationGraphBar": "Bar: Displays the average duration for each keyword represented as vertical bars", "keywordAverageDurationGraphLine": "Line: Displays the same data but over a time axis", - "keywordAverageDurationFullscreen": "Fullscreen", - "keywordAverageDurationClose": "Close", - "keywordAverageDurationShown": "Hide Graph", - "keywordAverageDurationHidden": "Show Graph", "keywordMinDurationGraphBar": "Bar: Displays minimum durations represented as vertical bars", "keywordMinDurationGraphLine": "Line: Displays the same data but over a time axis", - "keywordMinDurationFullscreen": "Fullscreen", - "keywordMinDurationClose": "Close", - "keywordMinDurationShown": "Hide Graph", - "keywordMinDurationHidden": "Show Graph", "keywordMaxDurationGraphBar": "Bar: Displays maximum durations represented as vertical bars", "keywordMaxDurationGraphLine": "Line: Displays the same data but over a time axis", - "keywordMaxDurationFullscreen": "Fullscreen", - "keywordMaxDurationClose": "Close", - "keywordMaxDurationShown": "Hide Graph", - "keywordMaxDurationHidden": "Show Graph", "keywordMostFailedGraphBar": "Bar: Displays keywords ranked by total number of failures represented as vertical bars. The regular view shows the Top 10 most failed keywords; fullscreen mode expands the list to the Top 50.", "keywordMostFailedGraphTimeline": "Timeline: Displays when failures occurred across runs. The regular view shows the Top 10 most failed keywords; fullscreen mode expands the list to the Top 50.", - "keywordMostFailedFullscreen": "Fullscreen", - "keywordMostFailedClose": "Close", - "keywordMostFailedShown": "Hide Graph", - "keywordMostFailedHidden": "Show Graph", "keywordMostTimeConsumingGraphBar": "Bar: Displays keywords ranked by how often they were the slowest (most time-consuming) keyword in a run. Each bar represents how many times a keyword was the single slowest one across all runs. The regular view shows the Top 10; fullscreen mode expands the list to the Top 50. When 'Only Last Run' is enabled, this graph instead shows the Top 10 (or Top 50 in fullscreen) most time-consuming keywords *within the latest run only*, ranked by duration.", "keywordMostTimeConsumingGraphTimeline": "Timeline: Displays the slowest keyword for each run on a timeline. For every run, only the single most time-consuming keyword is shown. The regular view shows the Top 10 most frequently slowest keywords; fullscreen mode expands the list to the Top 50. When 'Only Last Run' is enabled, the timeline shows only the latest run, highlighting its Top 10 (or Top 50 in fullscreen) most time-consuming keywords by duration.", - "keywordMostTimeConsumingFullscreen": "Fullscreen", - "keywordMostTimeConsumingClose": "Close", - "keywordMostTimeConsumingShown": "Hide Graph", - "keywordMostTimeConsumingHidden": "Show Graph", "keywordMostUsedGraphBar": "Bar: Displays keywords ranked by how frequently they were used across all runs. Each bar represents how many times a keyword appeared in total. The regular view shows the Top 10 most used keywords; fullscreen mode expands the list to the Top 50. When 'Only Last Run' is enabled, this graph instead shows the Top 10 (or Top 50 in fullscreen) most used keywords *within the latest run only*, ranked by occurrence count.", "keywordMostUsedGraphTimeline": "Timeline: Displays keyword usage trends over time. For each run, the most frequently used keyword (or keywords) is shown, illustrating how keyword usage changes across runs. The regular view highlights the Top 10 most frequently used keywords overall; fullscreen mode expands the list to the Top 50. When 'Only Last Run' is enabled, the timeline shows only the latest run, highlighting its Top 10 (or Top 50 in fullscreen) most used keywords by frequency.", - "keywordMostUsedFullscreen": "Fullscreen", - "keywordMostUsedClose": "Close", - "keywordMostUsedShown": "Hide Graph", - "keywordMostUsedHidden": "Show Graph", "compareStatisticsGraphBar": "This graph displays the overall statistics of the selected runs", - "compareStatisticsFullscreen": "Fullscreen", - "compareStatisticsClose": "Close", - "compareStatisticsShown": "Hide Graph", - "compareStatisticsHidden": "Show Graph", "compareSuiteDurationGraphRadar": "This graph displays the duration per suite in a radar format", - "compareSuiteDurationFullscreen": "Fullscreen", - "compareSuiteDurationClose": "Close", - "compareSuiteDurationShown": "Hide Graph", - "compareSuiteDurationHidden": "Show Graph", "compareTestsGraphTimeline": `This graph displays the statistics of the tests in a timeline format Status: Displays only tests don't have any status changes and have the selected status Only Changes: Displays only tests that have changed statuses at some point in time Tip: Don't use Status and Only Changes at the same time as it will result in an empty graph`, - "compareTestsFullscreen": "Fullscreen", - "compareTestsClose": "Close", - "compareTestsShown": "Hide Graph", - "compareTestsHidden": "Show Graph", - "runTableMoveUp": "Move Up", - "runTableMoveDown": "Move Down", - "runTableShown": "Hide Table", - "runTableHidden": "Show Table", - "suiteTableMoveUp": "Move Up", - "suiteTableMoveDown": "Move Down", - "suiteTableShown": "Hide Table", - "suiteTableHidden": "Show Table", - "testTableMoveUp": "Move Up", - "testTableMoveDown": "Move Down", - "testTableShown": "Hide Table", - "testTableHidden": "Show Table", - "keywordTableMoveUp": "Move Up", - "keywordTableMoveDown": "Move Down", - "keywordTableShown": "Hide Table", - "keywordTableHidden": "Show Table", - "runSectionMoveUp": "Move Up", - "runSectionMoveDown": "Move Down", - "runSectionShown": "Hide Section", - "runSectionHidden": "Show Section", - "suiteSectionMoveUp": "Move Up", - "suiteSectionMoveDown": "Move Down", - "suiteSectionShown": "Hide Section", - "suiteSectionHidden": "Show Section", - "testSectionMoveUp": "Move Up", - "testSectionMoveDown": "Move Down", - "testSectionShown": "Hide Section", - "testSectionHidden": "Show Section", - "keywordSectionMoveUp": "Move Up", - "keywordSectionMoveDown": "Move Down", - "keywordSectionShown": "Hide Section", - "keywordSectionHidden": "Show Section", -} +}; + +// Generate standard control entries for all graphs +const graphKeys = [ + "runStatistics", "runDonut", "runStats", "runDuration", "runHeatmap", + "suiteFolderDonut", "suiteStatistics", "suiteDuration", "suiteMostFailed", "suiteMostTimeConsuming", + "testStatistics", "testDuration", "testDurationDeviation", "testMessages", + "testMostFlaky", "testRecentMostFlaky", "testMostFailed", "testRecentMostFailed", "testMostTimeConsuming", + "keywordStatistics", "keywordTimesRun", "keywordTotalDuration", "keywordAverageDuration", + "keywordMinDuration", "keywordMaxDuration", "keywordMostFailed", "keywordMostTimeConsuming", "keywordMostUsed", + "compareStatistics", "compareSuiteDuration", "compareTests", +]; +graphKeys.forEach(key => { + informationMap[`${key}Fullscreen`] = "Fullscreen"; + informationMap[`${key}Close`] = "Close"; + informationMap[`${key}Shown`] = "Hide Graph"; + informationMap[`${key}Hidden`] = "Show Graph"; +}); + +["runTable", "suiteTable", "testTable", "keywordTable"].forEach(key => { + informationMap[`${key}MoveUp`] = "Move Up"; + informationMap[`${key}MoveDown`] = "Move Down"; + informationMap[`${key}Shown`] = "Hide Table"; + informationMap[`${key}Hidden`] = "Show Table"; +}); + +["runSection", "suiteSection", "testSection", "keywordSection"].forEach(key => { + informationMap[`${key}MoveUp`] = "Move Up"; + informationMap[`${key}MoveDown`] = "Move Down"; + informationMap[`${key}Shown`] = "Hide Section"; + informationMap[`${key}Hidden`] = "Show Section"; +}); export { informationMap }; \ No newline at end of file From 0da1ce88350918bcdcf6cf74821a8aa32bede4dc Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Thu, 19 Feb 2026 01:21:22 +0100 Subject: [PATCH 12/26] Refactor graph and table creation functions for improved modularity - Consolidated chart creation and update logic into reusable functions in suite.js and test.js. - Removed redundant code for creating and updating graphs and tables, enhancing maintainability. - Introduced generic data table creation and update functions in tables.js to streamline table management. - Updated graph configuration functions to utilize new helper functions for building configurations. - Improved readability and organization of the codebase by reducing duplication and enhancing function clarity. --- .../js/graph_creation/chart_factory.js | 25 + .../js/graph_creation/compare.js | 51 +- .../js/graph_creation/config_helpers.js | 165 ++++++ .../js/graph_creation/keyword.js | 481 ++---------------- .../js/graph_creation/overview.js | 22 +- .../js/graph_creation/run.js | 84 +-- .../js/graph_creation/suite.js | 234 +-------- .../js/graph_creation/tables.js | 281 +++------- .../js/graph_creation/test.js | 422 +-------------- 9 files changed, 368 insertions(+), 1397 deletions(-) create mode 100644 robotframework_dashboard/js/graph_creation/chart_factory.js create mode 100644 robotframework_dashboard/js/graph_creation/config_helpers.js diff --git a/robotframework_dashboard/js/graph_creation/chart_factory.js b/robotframework_dashboard/js/graph_creation/chart_factory.js new file mode 100644 index 0000000..466cf7d --- /dev/null +++ b/robotframework_dashboard/js/graph_creation/chart_factory.js @@ -0,0 +1,25 @@ +import { open_log_from_label } from "../log.js"; + +// Generic chart create function - replaces boilerplate create_X_graph() pattern +function create_chart(chartId, buildConfigFn, addLogClickHandler = true) { + console.log(`creating_${chartId}`); + if (window[chartId]) window[chartId].destroy(); + window[chartId] = new Chart(chartId, buildConfigFn()); + if (addLogClickHandler) { + window[chartId].canvas.addEventListener("click", (event) => { + open_log_from_label(window[chartId], event); + }); + } +} + +// Generic chart update function - replaces boilerplate update_X_graph() pattern +function update_chart(chartId, buildConfigFn, addLogClickHandler = true) { + console.log(`updating_${chartId}`); + if (!window[chartId]) { create_chart(chartId, buildConfigFn, addLogClickHandler); return; } + const config = buildConfigFn(); + window[chartId].data = config.data; + window[chartId].options = config.options; + window[chartId].update(); +} + +export { create_chart, update_chart }; diff --git a/robotframework_dashboard/js/graph_creation/compare.js b/robotframework_dashboard/js/graph_creation/compare.js index 9733493..f96f444 100644 --- a/robotframework_dashboard/js/graph_creation/compare.js +++ b/robotframework_dashboard/js/graph_creation/compare.js @@ -2,9 +2,10 @@ import { get_test_statistics_data, get_compare_statistics_graph_data } from "../ import { get_compare_suite_duration_data } from "../graph_data/duration.js"; import { get_graph_config } from "../graph_data/graph_config.js"; import { update_height } from "../graph_data/helpers.js"; -import { open_log_file, open_log_from_label } from "../log.js"; +import { open_log_file } from "../log.js"; import { filteredRuns, filteredSuites, filteredTests } from "../variables/globals.js"; import { settings } from "../variables/settings.js"; +import { create_chart, update_chart } from "./chart_factory.js"; // build config for compare statistics graph function _build_compare_statistics_config() { @@ -15,11 +16,7 @@ function _build_compare_statistics_config() { } // function to create the compare statistics in the compare section -function create_compare_statistics_graph() { - console.log("creating_compare_statistics_graph"); - if (compareStatisticsGraph) { compareStatisticsGraph.destroy(); } - compareStatisticsGraph = new Chart("compareStatisticsGraph", _build_compare_statistics_config()); -} +function create_compare_statistics_graph() { create_chart("compareStatisticsGraph", _build_compare_statistics_config, false); } // build config for compare suite duration graph function _build_compare_suite_duration_config() { @@ -28,11 +25,7 @@ function _build_compare_suite_duration_config() { } // function to create the compare statistics in the compare section -function create_compare_suite_duration_graph() { - console.log("creating_compare_suite_duration_graph"); - if (compareSuiteDurationGraph) { compareSuiteDurationGraph.destroy(); } - compareSuiteDurationGraph = new Chart("compareSuiteDurationGraph", _build_compare_suite_duration_config()); -} +function create_compare_suite_duration_graph() { create_chart("compareSuiteDurationGraph", _build_compare_suite_duration_config, false); } // build config for compare tests graph function _build_compare_tests_config() { @@ -72,44 +65,16 @@ function _build_compare_tests_config() { } // function to create the compare statistics in the compare section -function create_compare_tests_graph() { - console.log("creating_compare_tests_graph"); - if (compareTestsGraph) { compareTestsGraph.destroy(); } - compareTestsGraph = new Chart("compareTestsGraph", _build_compare_tests_config()); - compareTestsGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(compareTestsGraph, event) - }); -} +function create_compare_tests_graph() { create_chart("compareTestsGraph", _build_compare_tests_config); } // update function for compare statistics graph - updates existing chart in-place -function update_compare_statistics_graph() { - console.log("updating_compare_statistics_graph"); - if (!compareStatisticsGraph) { create_compare_statistics_graph(); return; } - const config = _build_compare_statistics_config(); - compareStatisticsGraph.data = config.data; - compareStatisticsGraph.options = config.options; - compareStatisticsGraph.update(); -} +function update_compare_statistics_graph() { update_chart("compareStatisticsGraph", _build_compare_statistics_config, false); } // update function for compare suite duration graph - updates existing chart in-place -function update_compare_suite_duration_graph() { - console.log("updating_compare_suite_duration_graph"); - if (!compareSuiteDurationGraph) { create_compare_suite_duration_graph(); return; } - const config = _build_compare_suite_duration_config(); - compareSuiteDurationGraph.data = config.data; - compareSuiteDurationGraph.options = config.options; - compareSuiteDurationGraph.update(); -} +function update_compare_suite_duration_graph() { update_chart("compareSuiteDurationGraph", _build_compare_suite_duration_config, false); } // update function for compare tests graph - updates existing chart in-place -function update_compare_tests_graph() { - console.log("updating_compare_tests_graph"); - if (!compareTestsGraph) { create_compare_tests_graph(); return; } - const config = _build_compare_tests_config(); - compareTestsGraph.data = config.data; - compareTestsGraph.options = config.options; - compareTestsGraph.update(); -} +function update_compare_tests_graph() { update_chart("compareTestsGraph", _build_compare_tests_config); } export { create_compare_statistics_graph, diff --git a/robotframework_dashboard/js/graph_creation/config_helpers.js b/robotframework_dashboard/js/graph_creation/config_helpers.js new file mode 100644 index 0000000..b4c3556 --- /dev/null +++ b/robotframework_dashboard/js/graph_creation/config_helpers.js @@ -0,0 +1,165 @@ +import { get_most_failed_data } from "../graph_data/failed.js"; +import { get_most_flaky_data } from "../graph_data/flaky.js"; +import { get_most_time_consuming_or_most_used_data } from "../graph_data/time_consuming.js"; +import { get_graph_config } from "../graph_data/graph_config.js"; +import { update_height } from "../graph_data/helpers.js"; +import { open_log_file } from "../log.js"; +import { format_duration } from "../common.js"; +import { settings } from "../variables/settings.js"; +import { inFullscreen, inFullscreenGraph } from "../variables/globals.js"; + +// Shared timeline scale/tooltip config used by most failed, flaky, and time consuming graphs +function _apply_timeline_defaults(config, callbackData, callbackLookup = null) { + const lookupFn = callbackLookup || ((val) => callbackData[val]); + config.options.plugins.tooltip = { + callbacks: { + label: function (context) { + return lookupFn(context.raw.x[0]); + }, + }, + }; + config.options.scales.x = { + ticks: { + minRotation: 45, + maxRotation: 45, + stepSize: 1, + callback: function (value) { + return lookupFn(this.getLabelForValue(value)); + }, + }, + title: { + display: settings.show.axisTitles, + text: "Run", + }, + }; + config.options.onClick = (event, chartElement) => { + if (chartElement.length) { + open_log_file(event, chartElement, callbackData); + } + }; + if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false; } +} + +// Build config for "most failed" graphs (test/suite/keyword, regular and recent) +function build_most_failed_config(graphKey, dataType, dataLabel, filteredData, isRecent) { + const graphType = settings.graphTypes[`${graphKey}GraphType`]; + const data = get_most_failed_data(dataType, graphType, filteredData, isRecent); + const graphData = data[0]; + const callbackData = data[1]; + const limit = inFullscreen && inFullscreenGraph.includes(graphKey) ? 50 : 10; + var config; + if (graphType == "bar") { + config = get_graph_config("bar", graphData, `Top ${limit}`, dataLabel, "Fails"); + config.options.plugins.legend = { display: false }; + config.options.plugins.tooltip = { + callbacks: { + label: function (tooltipItem) { + return callbackData[tooltipItem.label]; + }, + }, + }; + delete config.options.onClick; + } else if (graphType == "timeline") { + config = get_graph_config("timeline", graphData, `Top ${limit}`, "Run", dataLabel); + _apply_timeline_defaults(config, callbackData); + } + update_height(`${graphKey}Vertical`, config.data.labels.length, graphType); + return config; +} + +// Build config for "most flaky" graphs (test regular and recent) +function build_most_flaky_config(graphKey, dataType, filteredData, ignoreSkipsVal, isRecent) { + const graphType = settings.graphTypes[`${graphKey}GraphType`]; + const data = get_most_flaky_data(dataType, graphType, filteredData, ignoreSkipsVal, isRecent); + const graphData = data[0]; + const callbackData = data[1]; + const limit = inFullscreen && inFullscreenGraph.includes(graphKey) ? 50 : 10; + var config; + if (graphType == "bar") { + config = get_graph_config("bar", graphData, `Top ${limit}`, "Test", "Status Flips"); + config.options.plugins.legend = false; + delete config.options.onClick; + } else if (graphType == "timeline") { + config = get_graph_config("timeline", graphData, `Top ${limit}`, "Run", "Test"); + _apply_timeline_defaults(config, callbackData); + } + update_height(`${graphKey}Vertical`, config.data.labels.length, graphType); + return config; +} + +// Build config for "most time consuming" / "most used" graphs (test/suite/keyword) +function build_most_time_consuming_config(graphKey, dataType, dataLabel, filteredData, checkboxId, barYLabel = "Most Time Consuming", isMostUsed = false, formatDetail = null) { + const onlyLastRun = document.getElementById(checkboxId).checked; + const graphType = settings.graphTypes[`${graphKey}GraphType`]; + const data = get_most_time_consuming_or_most_used_data(dataType, graphType, filteredData, onlyLastRun, isMostUsed); + const graphData = data[0]; + const callbackData = data[1]; + const limit = inFullscreen && inFullscreenGraph.includes(graphKey) ? 50 : 10; + const detailFormatter = formatDetail || ((info, displayName) => `${displayName}: ${format_duration(info.duration)}`); + var config; + if (graphType == "bar") { + config = get_graph_config("bar", graphData, `Top ${limit}`, dataLabel, barYLabel); + config.options.plugins.legend = { display: false }; + config.options.plugins.tooltip = { + callbacks: { + label: function (tooltipItem) { + const key = tooltipItem.label; + const cb = callbackData; + const runStarts = cb.run_starts[key] || []; + const namesToShow = settings.show.aliases ? cb.aliases[key] : runStarts; + return runStarts.map((runStart, idx) => { + const info = cb.details[key][runStart]; + const displayName = namesToShow[idx]; + if (!info) return `${displayName}: (no data)`; + return detailFormatter(info, displayName); + }); + } + }, + }; + delete config.options.onClick; + } else if (graphType == "timeline") { + config = get_graph_config("timeline", graphData, `Top ${limit}`, "Run", dataLabel); + config.options.plugins.tooltip = { + callbacks: { + label: function (context) { + const key = context.dataset.label; + const runIndex = context.raw.x[0]; + const runStart = callbackData.runs[runIndex]; + const info = callbackData.details[key][runStart]; + const displayName = settings.show.aliases + ? callbackData.aliases[runIndex] + : runStart; + if (!info) return `${displayName}: (no data)`; + return detailFormatter(info, displayName); + } + }, + }; + config.options.scales.x = { + ticks: { + minRotation: 45, + maxRotation: 45, + stepSize: 1, + callback: function (value) { + const displayName = settings.show.aliases + ? callbackData.aliases[this.getLabelForValue(value)] + : callbackData.runs[this.getLabelForValue(value)]; + return displayName; + }, + }, + title: { + display: settings.show.axisTitles, + text: "Run", + }, + }; + config.options.onClick = (event, chartElement) => { + if (chartElement.length) { + open_log_file(event, chartElement, callbackData.runs); + } + }; + if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false; } + } + update_height(`${graphKey}Vertical`, config.data.labels.length, graphType); + return config; +} + +export { build_most_failed_config, build_most_flaky_config, build_most_time_consuming_config }; diff --git a/robotframework_dashboard/js/graph_creation/keyword.js b/robotframework_dashboard/js/graph_creation/keyword.js index 8564cdf..ab46361 100644 --- a/robotframework_dashboard/js/graph_creation/keyword.js +++ b/robotframework_dashboard/js/graph_creation/keyword.js @@ -2,12 +2,9 @@ import { settings } from "../variables/settings.js"; import { inFullscreen, inFullscreenGraph } from "../variables/globals.js"; import { get_statistics_graph_data } from "../graph_data/statistics.js"; import { get_duration_graph_data } from "../graph_data/duration.js"; -import { get_most_failed_data } from "../graph_data/failed.js"; -import { get_most_time_consuming_or_most_used_data } from "../graph_data/time_consuming.js"; import { get_graph_config } from "../graph_data/graph_config.js"; -import { open_log_from_label, open_log_file } from "../log.js"; -import { format_duration } from "../common.js"; -import { update_height } from "../graph_data/helpers.js"; +import { create_chart, update_chart } from "./chart_factory.js"; +import { build_most_failed_config, build_most_time_consuming_config } from "./config_helpers.js"; // build config for keyword statistics graph function _build_keyword_statistics_config() { @@ -25,453 +22,57 @@ function _build_keyword_statistics_config() { return config; } -// function to keyword statistics graph in the keyword section -function create_keyword_statistics_graph() { - console.log("creating_keyword_statistics_graph"); - if (keywordStatisticsGraph) { keywordStatisticsGraph.destroy(); } - keywordStatisticsGraph = new Chart("keywordStatisticsGraph", _build_keyword_statistics_config()); - keywordStatisticsGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordStatisticsGraph, event) - }); -} - -// build config for keyword times run graph -function _build_keyword_times_run_config() { - const graphData = get_duration_graph_data("keyword", settings.graphTypes.keywordTimesRunGraphType, "times_run", filteredKeywords); - var config; - if (settings.graphTypes.keywordTimesRunGraphType == "bar") { - const limit = inFullscreen && inFullscreenGraph.includes("keywordTimesRun") ? 100 : 30; - config = get_graph_config("bar", graphData, `Max ${limit} Bars`, "Run", "Times Run"); - } else if (settings.graphTypes.keywordTimesRunGraphType == "line") { - config = get_graph_config("line", graphData, "", "Date", "Times Run"); - } - if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - return config; -} - -// function to keyword times run graph in the keyword section -function create_keyword_times_run_graph() { - console.log("creating_keyword_times_run_graph"); - if (keywordTimesRunGraph) { keywordTimesRunGraph.destroy(); } - keywordTimesRunGraph = new Chart("keywordTimesRunGraph", _build_keyword_times_run_config()); - keywordTimesRunGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordTimesRunGraph, event) - }); -} - -// build config for keyword total duration graph -function _build_keyword_total_duration_config() { - const graphData = get_duration_graph_data("keyword", settings.graphTypes.keywordTotalDurationGraphType, "total_time_s", filteredKeywords); +// build config for keyword duration graphs (times run, total, average, min, max) +function _build_keyword_duration_config(graphKey, field, yLabel) { + const graphData = get_duration_graph_data("keyword", settings.graphTypes[`${graphKey}GraphType`], field, filteredKeywords); var config; - if (settings.graphTypes.keywordTotalDurationGraphType == "bar") { - const limit = inFullscreen && inFullscreenGraph.includes("keywordTotalDuration") ? 100 : 30; - config = get_graph_config("bar", graphData, `Max ${limit} Bars`, "Run", "Duration"); - } else if (settings.graphTypes.keywordTotalDurationGraphType == "line") { - config = get_graph_config("line", graphData, "", "Date", "Duration"); + if (settings.graphTypes[`${graphKey}GraphType`] == "bar") { + const limit = inFullscreen && inFullscreenGraph.includes(graphKey) ? 100 : 30; + config = get_graph_config("bar", graphData, `Max ${limit} Bars`, "Run", yLabel); + } else if (settings.graphTypes[`${graphKey}GraphType`] == "line") { + config = get_graph_config("line", graphData, "", "Date", yLabel); } if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } return config; } -// function to keyword total time graph in the keyword section -function create_keyword_total_duration_graph() { - console.log("creating_keyword_total_duration_graph"); - if (keywordTotalDurationGraph) { keywordTotalDurationGraph.destroy(); } - keywordTotalDurationGraph = new Chart("keywordTotalDurationGraph", _build_keyword_total_duration_config()); - keywordTotalDurationGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordTotalDurationGraph, event) - }); -} - -// build config for keyword average duration graph -function _build_keyword_average_duration_config() { - const graphData = get_duration_graph_data("keyword", settings.graphTypes.keywordAverageDurationGraphType, "average_time_s", filteredKeywords); - var config; - if (settings.graphTypes.keywordAverageDurationGraphType == "bar") { - const limit = inFullscreen && inFullscreenGraph.includes("keywordAverageDuration") ? 100 : 30; - config = get_graph_config("bar", graphData, `Max ${limit} Bars`, "Run", "Duration"); - } else if (settings.graphTypes.keywordAverageDurationGraphType == "line") { - config = get_graph_config("line", graphData, "", "Date", "Duration"); - } - if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - return config; -} - -// function to keyword average time graph in the keyword section -function create_keyword_average_duration_graph() { - console.log("creating_keyword_average_duration_graph"); - if (keywordAverageDurationGraph) { keywordAverageDurationGraph.destroy(); } - keywordAverageDurationGraph = new Chart("keywordAverageDurationGraph", _build_keyword_average_duration_config()); - keywordAverageDurationGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordAverageDurationGraph, event) - }); -} +function _build_keyword_times_run_config() { return _build_keyword_duration_config("keywordTimesRun", "times_run", "Times Run"); } +function _build_keyword_total_duration_config() { return _build_keyword_duration_config("keywordTotalDuration", "total_time_s", "Duration"); } +function _build_keyword_average_duration_config() { return _build_keyword_duration_config("keywordAverageDuration", "average_time_s", "Duration"); } +function _build_keyword_min_duration_config() { return _build_keyword_duration_config("keywordMinDuration", "min_time_s", "Duration"); } +function _build_keyword_max_duration_config() { return _build_keyword_duration_config("keywordMaxDuration", "max_time_s", "Duration"); } -// build config for keyword min duration graph -function _build_keyword_min_duration_config() { - const graphData = get_duration_graph_data("keyword", settings.graphTypes.keywordMinDurationGraphType, "min_time_s", filteredKeywords); - var config; - if (settings.graphTypes.keywordMinDurationGraphType == "bar") { - const limit = inFullscreen && inFullscreenGraph.includes("keywordMinDuration") ? 100 : 30; - config = get_graph_config("bar", graphData, `Max ${limit} Bars`, "Run", "Duration"); - } else if (settings.graphTypes.keywordMinDurationGraphType == "line") { - config = get_graph_config("line", graphData, "", "Date", "Duration"); - } - if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - return config; -} - -// function to keyword min time graph in the keyword section -function create_keyword_min_duration_graph() { - console.log("creating_keyword_min_duration_graph"); - if (keywordMinDurationGraph) { keywordMinDurationGraph.destroy(); } - keywordMinDurationGraph = new Chart("keywordMinDurationGraph", _build_keyword_min_duration_config()); - keywordMinDurationGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordMinDurationGraph, event) - }); -} - -// build config for keyword max duration graph -function _build_keyword_max_duration_config() { - const graphData = get_duration_graph_data("keyword", settings.graphTypes.keywordMaxDurationGraphType, "max_time_s", filteredKeywords); - var config; - if (settings.graphTypes.keywordMaxDurationGraphType == "bar") { - const limit = inFullscreen && inFullscreenGraph.includes("keywordMaxDuration") ? 100 : 30; - config = get_graph_config("bar", graphData, `Max ${limit} Bars`, "Run", "Duration"); - } else if (settings.graphTypes.keywordMaxDurationGraphType == "line") { - config = get_graph_config("line", graphData, "", "Date", "Duration"); - } - if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - return config; -} - -// function to keyword max time graph in the keyword section -function create_keyword_max_duration_graph() { - console.log("creating_keyword_max_duration_graph"); - if (keywordMaxDurationGraph) { keywordMaxDurationGraph.destroy(); } - keywordMaxDurationGraph = new Chart("keywordMaxDurationGraph", _build_keyword_max_duration_config()); - keywordMaxDurationGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordMaxDurationGraph, event) - }); -} - -// build config for keyword most failed graph function _build_keyword_most_failed_config() { - const data = get_most_failed_data("keyword", settings.graphTypes.keywordMostFailedGraphType, filteredKeywords, false); - const graphData = data[0] - const callbackData = data[1]; - var config; - const limit = inFullscreen && inFullscreenGraph.includes("keywordMostFailed") ? 50 : 10; - if (settings.graphTypes.keywordMostFailedGraphType == "bar") { - config = get_graph_config("bar", graphData, `Top ${limit}`, "Keyword", "Fails"); - config.options.plugins.legend = { display: false }; - config.options.plugins.tooltip = { - callbacks: { - label: function (tooltipItem) { - return callbackData[tooltipItem.label]; - }, - }, - }; - delete config.options.onClick - } else if (settings.graphTypes.keywordMostFailedGraphType == "timeline") { - config = get_graph_config("timeline", graphData, `Top ${limit}`, "Run", "Keyword"); - config.options.plugins.tooltip = { - callbacks: { - label: function (context) { - return callbackData[context.raw.x[0]]; - }, - }, - }; - config.options.scales.x = { - ticks: { - minRotation: 45, - maxRotation: 45, - stepSize: 1, - callback: function (value, index, ticks) { - return callbackData[this.getLabelForValue(value)]; - }, - }, - title: { - display: settings.show.axisTitles, - text: "Run", - }, - }; - config.options.onClick = (event, chartElement) => { - if (chartElement.length) { - open_log_file(event, chartElement, callbackData) - } - }; - if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - } - update_height("keywordMostFailedVertical", config.data.labels.length, settings.graphTypes.keywordMostFailedGraphType); - return config; + return build_most_failed_config("keywordMostFailed", "keyword", "Keyword", filteredKeywords, false); } - -// function to create test most failed graph in the keyword section -function create_keyword_most_failed_graph() { - console.log("creating_keyword_most_failed_graph"); - if (keywordMostFailedGraph) { keywordMostFailedGraph.destroy(); } - keywordMostFailedGraph = new Chart("keywordMostFailedGraph", _build_keyword_most_failed_config()); - keywordMostFailedGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordMostFailedGraph, event) - }); -} - -// build config for keyword most time consuming graph function _build_keyword_most_time_consuming_config() { - const onlyLastRun = document.getElementById("onlyLastRunKeyword").checked; - const data = get_most_time_consuming_or_most_used_data("keyword", settings.graphTypes.keywordMostTimeConsumingGraphType, filteredKeywords, onlyLastRun); - const graphData = data[0] - const callbackData = data[1]; - var config; - const limit = inFullscreen && inFullscreenGraph.includes("keywordMostTimeConsuming") ? 50 : 10; - if (settings.graphTypes.keywordMostTimeConsumingGraphType == "bar") { - config = get_graph_config("bar", graphData, `Top ${limit}`, "Keyword", "Most Time Consuming"); - config.options.plugins.legend = { display: false }; - config.options.plugins.tooltip = { - callbacks: { - label: function (tooltipItem) { - const key = tooltipItem.label; - const cb = callbackData; - const runStarts = cb.run_starts[key] || []; - const namesToShow = settings.show.aliases ? cb.aliases[key] : runStarts; - return runStarts.map((runStart, idx) => { - const info = cb.details[key][runStart]; - const displayName = namesToShow[idx]; - if (!info) return `${displayName}: (no data)`; - return `${displayName}: ${format_duration(info.duration)}`; - }); - } - }, - }; - delete config.options.onClick - } else if (settings.graphTypes.keywordMostTimeConsumingGraphType == "timeline") { - config = get_graph_config("timeline", graphData, `Top ${limit}`, "Run", "Keyword"); - config.options.plugins.tooltip = { - callbacks: { - label: function (context) { - const key = context.dataset.label; - const runIndex = context.raw.x[0]; - const runStart = callbackData.runs[runIndex]; - const info = callbackData.details[key][runStart]; - const displayName = settings.show.aliases - ? callbackData.aliases[runIndex] - : runStart; - if (!info) return `${displayName}: (no data)`; - return `${displayName}: ${format_duration(info.duration)}`; - } - }, - }; - config.options.scales.x = { - ticks: { - minRotation: 45, - maxRotation: 45, - stepSize: 1, - callback: function (value, index, ticks) { - const displayName = settings.show.aliases - ? callbackData.aliases[this.getLabelForValue(value)] - : callbackData.runs[this.getLabelForValue(value)]; - return displayName; - }, - }, - title: { - display: settings.show.axisTitles, - text: "Run", - }, - }; - config.options.onClick = (event, chartElement) => { - if (chartElement.length) { - open_log_file(event, chartElement, callbackData.runs) - } - }; - if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - } - update_height("keywordMostTimeConsumingVertical", config.data.labels.length, settings.graphTypes.keywordMostTimeConsumingGraphType); - return config; + return build_most_time_consuming_config("keywordMostTimeConsuming", "keyword", "Keyword", filteredKeywords, "onlyLastRunKeyword"); } - -// function to create the most time consuming keyword graph in the keyword section -function create_keyword_most_time_consuming_graph() { - console.log("creating_keyword_most_time_consuming_graph"); - if (keywordMostTimeConsumingGraph) { keywordMostTimeConsumingGraph.destroy(); } - keywordMostTimeConsumingGraph = new Chart("keywordMostTimeConsumingGraph", _build_keyword_most_time_consuming_config()); - keywordMostTimeConsumingGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordMostTimeConsumingGraph, event) - }); -} - -// build config for keyword most used graph function _build_keyword_most_used_config() { - const onlyLastRun = document.getElementById("onlyLastRunKeywordMostUsed").checked; - const data = get_most_time_consuming_or_most_used_data("keyword", settings.graphTypes.keywordMostUsedGraphType, filteredKeywords, onlyLastRun, true); - const graphData = data[0] - const callbackData = data[1]; - var config; - const limit = inFullscreen && inFullscreenGraph.includes("keywordMostUsed") ? 50 : 10; - if (settings.graphTypes.keywordMostUsedGraphType == "bar") { - config = get_graph_config("bar", graphData, `Top ${limit}`, "Keyword", "Most Used"); - config.options.plugins.legend = { display: false }; - config.options.plugins.tooltip = { - callbacks: { - label: function (tooltipItem) { - const key = tooltipItem.label; - const cb = callbackData; - const runStarts = cb.run_starts[key] || []; - const namesToShow = settings.show.aliases ? cb.aliases[key] : runStarts; - return runStarts.map((runStart, idx) => { - const info = cb.details[key][runStart]; - const displayName = namesToShow[idx]; - if (!info) return `${displayName}: (no data)`; - return `${displayName}: ran ${info.timesRun} times`; - }); - } - }, - }; - delete config.options.onClick - } else if (settings.graphTypes.keywordMostUsedGraphType == "timeline") { - config = get_graph_config("timeline", graphData, `Top ${limit}`, "Run", "Keyword"); - config.options.plugins.tooltip = { - callbacks: { - label: function (context) { - const key = context.dataset.label; - const runIndex = context.raw.x[0]; - const runStart = callbackData.runs[runIndex]; - const info = callbackData.details[key][runStart]; - const displayName = settings.show.aliases - ? callbackData.aliases[runIndex] - : runStart; - if (!info) return `${displayName}: (no data)`; - return `${displayName}: ran ${info.timesRun} times`; - } - }, - }; - config.options.scales.x = { - ticks: { - minRotation: 45, - maxRotation: 45, - stepSize: 1, - callback: function (value, index, ticks) { - const displayName = settings.show.aliases - ? callbackData.aliases[this.getLabelForValue(value)] - : callbackData.runs[this.getLabelForValue(value)]; - return displayName; - }, - }, - title: { - display: settings.show.axisTitles, - text: "Run", - }, - }; - config.options.onClick = (event, chartElement) => { - if (chartElement.length) { - open_log_file(event, chartElement, callbackData.runs) - } - }; - if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - } - update_height("keywordMostUsedVertical", config.data.labels.length, settings.graphTypes.keywordMostUsedGraphType); - return config; -} - -// function to create the most used keyword graph in the keyword section -function create_keyword_most_used_graph() { - console.log("creating_keyword_most_used_graph"); - if (keywordMostUsedGraph) { keywordMostUsedGraph.destroy(); } - keywordMostUsedGraph = new Chart("keywordMostUsedGraph", _build_keyword_most_used_config()); - keywordMostUsedGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordMostUsedGraph, event) - }); -} - -// update function for keyword statistics graph - updates existing chart in-place -function update_keyword_statistics_graph() { - console.log("updating_keyword_statistics_graph"); - if (!keywordStatisticsGraph) { create_keyword_statistics_graph(); return; } - const config = _build_keyword_statistics_config(); - keywordStatisticsGraph.data = config.data; - keywordStatisticsGraph.options = config.options; - keywordStatisticsGraph.update(); -} - -// update function for keyword times run graph - updates existing chart in-place -function update_keyword_times_run_graph() { - console.log("updating_keyword_times_run_graph"); - if (!keywordTimesRunGraph) { create_keyword_times_run_graph(); return; } - const config = _build_keyword_times_run_config(); - keywordTimesRunGraph.data = config.data; - keywordTimesRunGraph.options = config.options; - keywordTimesRunGraph.update(); -} - -// update function for keyword total duration graph - updates existing chart in-place -function update_keyword_total_duration_graph() { - console.log("updating_keyword_total_duration_graph"); - if (!keywordTotalDurationGraph) { create_keyword_total_duration_graph(); return; } - const config = _build_keyword_total_duration_config(); - keywordTotalDurationGraph.data = config.data; - keywordTotalDurationGraph.options = config.options; - keywordTotalDurationGraph.update(); -} - -// update function for keyword average duration graph - updates existing chart in-place -function update_keyword_average_duration_graph() { - console.log("updating_keyword_average_duration_graph"); - if (!keywordAverageDurationGraph) { create_keyword_average_duration_graph(); return; } - const config = _build_keyword_average_duration_config(); - keywordAverageDurationGraph.data = config.data; - keywordAverageDurationGraph.options = config.options; - keywordAverageDurationGraph.update(); -} - -// update function for keyword min duration graph - updates existing chart in-place -function update_keyword_min_duration_graph() { - console.log("updating_keyword_min_duration_graph"); - if (!keywordMinDurationGraph) { create_keyword_min_duration_graph(); return; } - const config = _build_keyword_min_duration_config(); - keywordMinDurationGraph.data = config.data; - keywordMinDurationGraph.options = config.options; - keywordMinDurationGraph.update(); -} - -// update function for keyword max duration graph - updates existing chart in-place -function update_keyword_max_duration_graph() { - console.log("updating_keyword_max_duration_graph"); - if (!keywordMaxDurationGraph) { create_keyword_max_duration_graph(); return; } - const config = _build_keyword_max_duration_config(); - keywordMaxDurationGraph.data = config.data; - keywordMaxDurationGraph.options = config.options; - keywordMaxDurationGraph.update(); -} - -// update function for keyword most failed graph - updates existing chart in-place -function update_keyword_most_failed_graph() { - console.log("updating_keyword_most_failed_graph"); - if (!keywordMostFailedGraph) { create_keyword_most_failed_graph(); return; } - const config = _build_keyword_most_failed_config(); - keywordMostFailedGraph.data = config.data; - keywordMostFailedGraph.options = config.options; - keywordMostFailedGraph.update(); -} - -// update function for keyword most time consuming graph - updates existing chart in-place -function update_keyword_most_time_consuming_graph() { - console.log("updating_keyword_most_time_consuming_graph"); - if (!keywordMostTimeConsumingGraph) { create_keyword_most_time_consuming_graph(); return; } - const config = _build_keyword_most_time_consuming_config(); - keywordMostTimeConsumingGraph.data = config.data; - keywordMostTimeConsumingGraph.options = config.options; - keywordMostTimeConsumingGraph.update(); -} - -// update function for keyword most used graph - updates existing chart in-place -function update_keyword_most_used_graph() { - console.log("updating_keyword_most_used_graph"); - if (!keywordMostUsedGraph) { create_keyword_most_used_graph(); return; } - const config = _build_keyword_most_used_config(); - keywordMostUsedGraph.data = config.data; - keywordMostUsedGraph.options = config.options; - keywordMostUsedGraph.update(); -} + return build_most_time_consuming_config("keywordMostUsed", "keyword", "Keyword", filteredKeywords, "onlyLastRunKeywordMostUsed", "Most Used", true, (info, name) => `${name}: ran ${info.timesRun} times`); +} + +// create functions +function create_keyword_statistics_graph() { create_chart("keywordStatisticsGraph", _build_keyword_statistics_config); } +function create_keyword_times_run_graph() { create_chart("keywordTimesRunGraph", _build_keyword_times_run_config); } +function create_keyword_total_duration_graph() { create_chart("keywordTotalDurationGraph", _build_keyword_total_duration_config); } +function create_keyword_average_duration_graph() { create_chart("keywordAverageDurationGraph", _build_keyword_average_duration_config); } +function create_keyword_min_duration_graph() { create_chart("keywordMinDurationGraph", _build_keyword_min_duration_config); } +function create_keyword_max_duration_graph() { create_chart("keywordMaxDurationGraph", _build_keyword_max_duration_config); } +function create_keyword_most_failed_graph() { create_chart("keywordMostFailedGraph", _build_keyword_most_failed_config); } +function create_keyword_most_time_consuming_graph() { create_chart("keywordMostTimeConsumingGraph", _build_keyword_most_time_consuming_config); } +function create_keyword_most_used_graph() { create_chart("keywordMostUsedGraph", _build_keyword_most_used_config); } + +// update functions +function update_keyword_statistics_graph() { update_chart("keywordStatisticsGraph", _build_keyword_statistics_config); } +function update_keyword_times_run_graph() { update_chart("keywordTimesRunGraph", _build_keyword_times_run_config); } +function update_keyword_total_duration_graph() { update_chart("keywordTotalDurationGraph", _build_keyword_total_duration_config); } +function update_keyword_average_duration_graph() { update_chart("keywordAverageDurationGraph", _build_keyword_average_duration_config); } +function update_keyword_min_duration_graph() { update_chart("keywordMinDurationGraph", _build_keyword_min_duration_config); } +function update_keyword_max_duration_graph() { update_chart("keywordMaxDurationGraph", _build_keyword_max_duration_config); } +function update_keyword_most_failed_graph() { update_chart("keywordMostFailedGraph", _build_keyword_most_failed_config); } +function update_keyword_most_time_consuming_graph() { update_chart("keywordMostTimeConsumingGraph", _build_keyword_most_time_consuming_config); } +function update_keyword_most_used_graph() { update_chart("keywordMostUsedGraph", _build_keyword_most_used_config); } export { create_keyword_statistics_graph, diff --git a/robotframework_dashboard/js/graph_creation/overview.js b/robotframework_dashboard/js/graph_creation/overview.js index 830cf55..52a33d4 100644 --- a/robotframework_dashboard/js/graph_creation/overview.js +++ b/robotframework_dashboard/js/graph_creation/overview.js @@ -810,28 +810,24 @@ function set_filter_show_current_project(projectName) { } } -function update_overview_latest_heading() { - const overviewCardsContainer = document.getElementById("overviewLatestRunCardsContainer"); +function _update_overview_heading(containerId, titleId, titleText) { + const overviewCardsContainer = document.getElementById(containerId); if (!overviewCardsContainer) return; const amountOfProjectsShown = overviewCardsContainer.querySelectorAll(".overview-card").length; const pluralPostFix = amountOfProjectsShown !== 1 ? 's' : ''; const headerContent = `
showing ${amountOfProjectsShown} project${pluralPostFix}
`; - document.getElementById("overviewLatestTitle").innerHTML = ` - Latest Runs + document.getElementById(titleId).innerHTML = ` + ${titleText} ${headerContent} `; } +function update_overview_latest_heading() { + _update_overview_heading("overviewLatestRunCardsContainer", "overviewLatestTitle", "Latest Runs"); +} + function update_overview_total_heading() { - const overviewCardsContainer = document.getElementById("overviewTotalRunCardsContainer"); - if (!overviewCardsContainer) return; - const amountOfProjectsShown = overviewCardsContainer.querySelectorAll(".overview-card").length; - const pluralPostFix = amountOfProjectsShown !== 1 ? 's' : ''; - const headerContent = `
showing ${amountOfProjectsShown} project${pluralPostFix}
`; - document.getElementById("overviewTotalTitle").innerHTML = ` - Total Statistics - ${headerContent} - `; + _update_overview_heading("overviewTotalRunCardsContainer", "overviewTotalTitle", "Total Statistics"); } function update_overview_sections_visibility() { diff --git a/robotframework_dashboard/js/graph_creation/run.js b/robotframework_dashboard/js/graph_creation/run.js index abd55a2..b936ea0 100644 --- a/robotframework_dashboard/js/graph_creation/run.js +++ b/robotframework_dashboard/js/graph_creation/run.js @@ -5,7 +5,7 @@ import { get_duration_graph_data } from '../graph_data/duration.js'; import { get_heatmap_graph_data } from '../graph_data/heatmap.js'; import { get_stats_data } from '../graph_data/stats.js'; import { format_duration } from '../common.js'; -import { open_log_file, open_log_from_label } from '../log.js'; +import { open_log_file } from '../log.js'; import { settings } from '../variables/settings.js'; import { inFullscreen, @@ -16,6 +16,7 @@ import { filteredSuites, filteredTests } from '../variables/globals.js'; +import { create_chart, update_chart } from './chart_factory.js'; // build config for run statistics graph function _build_run_statistics_config() { @@ -34,14 +35,7 @@ function _build_run_statistics_config() { } // function to create run statistics graph in the run section -function create_run_statistics_graph() { - console.log("creating_run_statistics_graph"); - if (runStatisticsGraph) { runStatisticsGraph.destroy(); } - runStatisticsGraph = new Chart("runStatisticsGraph", _build_run_statistics_config()); - runStatisticsGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(runStatisticsGraph, event) - }); -} +function create_run_statistics_graph() { create_chart("runStatisticsGraph", _build_run_statistics_config); } // build config for run donut graph function _build_run_donut_config() { @@ -66,11 +60,7 @@ function _build_run_donut_config() { } // function to create run donut graph in the run section -function create_run_donut_graph() { - console.log("creating_run_donut_graph"); - if (runDonutGraph) { runDonutGraph.destroy(); } - runDonutGraph = new Chart("runDonutGraph", _build_run_donut_config()); -} +function create_run_donut_graph() { create_chart("runDonutGraph", _build_run_donut_config, false); } // build config for run donut total graph function _build_run_donut_total_config() { @@ -82,11 +72,7 @@ function _build_run_donut_total_config() { } // function to create run donut total graph in the run section -function create_run_donut_total_graph() { - console.log("creating_run_donut_total_graph"); - if (runDonutTotalGraph) { runDonutTotalGraph.destroy(); } - runDonutTotalGraph = new Chart("runDonutTotalGraph", _build_run_donut_total_config()); -} +function create_run_donut_total_graph() { create_chart("runDonutTotalGraph", _build_run_donut_total_config, false); } // function to create the run stats section in the run section function create_run_stats_graph() { @@ -121,14 +107,7 @@ function _build_run_duration_config() { } // function to create run duration graph in the run section -function create_run_duration_graph() { - console.log("creating_run_duration_graph"); - if (runDurationGraph) { runDurationGraph.destroy(); } - runDurationGraph = new Chart("runDurationGraph", _build_run_duration_config()); - runDurationGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(runDurationGraph, event) - }); -} +function create_run_duration_graph() { create_chart("runDurationGraph", _build_run_duration_config); } // build config for run heatmap graph function _build_run_heatmap_config() { @@ -158,41 +137,16 @@ function _build_run_heatmap_config() { } // function to create the run heatmap -function create_run_heatmap_graph() { - console.log("creating_run_heatmap_graph"); - if (runHeatmapGraph) { runHeatmapGraph.destroy(); } - runHeatmapGraph = new Chart("runHeatmapGraph", _build_run_heatmap_config()); -} +function create_run_heatmap_graph() { create_chart("runHeatmapGraph", _build_run_heatmap_config, false); } // update function for run statistics graph - updates existing chart in-place -function update_run_statistics_graph() { - console.log("updating_run_statistics_graph"); - if (!runStatisticsGraph) { create_run_statistics_graph(); return; } - const config = _build_run_statistics_config(); - runStatisticsGraph.data = config.data; - runStatisticsGraph.options = config.options; - runStatisticsGraph.update(); -} +function update_run_statistics_graph() { update_chart("runStatisticsGraph", _build_run_statistics_config); } // update function for run donut graph - updates existing chart in-place -function update_run_donut_graph() { - console.log("updating_run_donut_graph"); - if (!runDonutGraph) { create_run_donut_graph(); return; } - const config = _build_run_donut_config(); - runDonutGraph.data = config.data; - runDonutGraph.options = config.options; - runDonutGraph.update(); -} +function update_run_donut_graph() { update_chart("runDonutGraph", _build_run_donut_config, false); } // update function for run donut total graph - updates existing chart in-place -function update_run_donut_total_graph() { - console.log("updating_run_donut_total_graph"); - if (!runDonutTotalGraph) { create_run_donut_total_graph(); return; } - const config = _build_run_donut_total_config(); - runDonutTotalGraph.data = config.data; - runDonutTotalGraph.options = config.options; - runDonutTotalGraph.update(); -} +function update_run_donut_total_graph() { update_chart("runDonutTotalGraph", _build_run_donut_total_config, false); } // update function for run stats - same as create since it only updates DOM text function update_run_stats_graph() { @@ -201,24 +155,10 @@ function update_run_stats_graph() { } // update function for run duration graph - updates existing chart in-place -function update_run_duration_graph() { - console.log("updating_run_duration_graph"); - if (!runDurationGraph) { create_run_duration_graph(); return; } - const config = _build_run_duration_config(); - runDurationGraph.data = config.data; - runDurationGraph.options = config.options; - runDurationGraph.update(); -} +function update_run_duration_graph() { update_chart("runDurationGraph", _build_run_duration_config); } // update function for run heatmap graph - updates existing chart in-place -function update_run_heatmap_graph() { - console.log("updating_run_heatmap_graph"); - if (!runHeatmapGraph) { create_run_heatmap_graph(); return; } - const config = _build_run_heatmap_config(); - runHeatmapGraph.data = config.data; - runHeatmapGraph.options = config.options; - runHeatmapGraph.update(); -} +function update_run_heatmap_graph() { update_chart("runHeatmapGraph", _build_run_heatmap_config, false); } export { create_run_statistics_graph, diff --git a/robotframework_dashboard/js/graph_creation/suite.js b/robotframework_dashboard/js/graph_creation/suite.js index 768c0b0..47ea992 100644 --- a/robotframework_dashboard/js/graph_creation/suite.js +++ b/robotframework_dashboard/js/graph_creation/suite.js @@ -1,16 +1,13 @@ import { get_donut_folder_graph_data, get_donut_folder_fail_graph_data } from '../graph_data/donut.js'; import { get_statistics_graph_data } from '../graph_data/statistics.js'; import { get_duration_graph_data } from '../graph_data/duration.js'; -import { get_most_failed_data } from '../graph_data/failed.js'; -import { get_most_time_consuming_or_most_used_data } from '../graph_data/time_consuming.js'; import { get_graph_config } from '../graph_data/graph_config.js'; -import { open_log_from_label, open_log_file } from '../log.js'; -import { format_duration } from '../common.js'; -import { update_height } from '../graph_data/helpers.js'; import { setup_suites_in_suite_select } from '../filter.js'; import { dataLabelConfig } from '../variables/chartconfig.js'; import { settings } from '../variables/settings.js'; import { inFullscreen, inFullscreenGraph, filteredSuites } from '../variables/globals.js'; +import { create_chart, update_chart } from './chart_factory.js'; +import { build_most_failed_config, build_most_time_consuming_config } from './config_helpers.js'; // build config for suite folder donut graph function _build_suite_folder_donut_config(folder) { @@ -123,13 +120,6 @@ function _build_suite_folder_fail_donut_config() { return config; } -// function to create suite last failed donut -function create_suite_folder_fail_donut_graph() { - console.log("creating_suite_folder_fail_donut_graph"); - if (suiteFolderFailDonutGraph) { suiteFolderFailDonutGraph.destroy(); } - suiteFolderFailDonutGraph = new Chart("suiteFolderFailDonutGraph", _build_suite_folder_fail_donut_config()); -} - // build config for suite statistics graph function _build_suite_statistics_config() { const data = get_statistics_graph_data("suite", settings.graphTypes.suiteStatisticsGraphType, filteredSuites); @@ -172,16 +162,6 @@ function _build_suite_statistics_config() { return config; } -// function to create suite statistics graph in the suite section -function create_suite_statistics_graph() { - console.log("creating_suite_statistics_graph"); - if (suiteStatisticsGraph) { suiteStatisticsGraph.destroy(); } - suiteStatisticsGraph = new Chart("suiteStatisticsGraph", _build_suite_statistics_config()); - suiteStatisticsGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(suiteStatisticsGraph, event) - }); -} - // build config for suite duration graph function _build_suite_duration_config() { const graphData = get_duration_graph_data("suite", settings.graphTypes.suiteDurationGraphType, "elapsed_s", filteredSuites); @@ -196,161 +176,22 @@ function _build_suite_duration_config() { return config; } -// function to create suite duration graph in the suite section -function create_suite_duration_graph() { - console.log("creating_suite_duration_graph"); - if (suiteDurationGraph) { suiteDurationGraph.destroy(); } - suiteDurationGraph = new Chart("suiteDurationGraph", _build_suite_duration_config()); - suiteDurationGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(suiteDurationGraph, event) - }); -} - // build config for suite most failed graph function _build_suite_most_failed_config() { - const data = get_most_failed_data("suite", settings.graphTypes.suiteMostFailedGraphType, filteredSuites, false); - const graphData = data[0]; - const callbackData = data[1]; - var config; - const limit = inFullscreen && inFullscreenGraph.includes("suiteMostFailed") ? 50 : 10; - if (settings.graphTypes.suiteMostFailedGraphType == "bar") { - config = get_graph_config("bar", graphData, `Top ${limit}`, "Suite", "Fails"); - config.options.plugins.legend = { display: false }; - config.options.plugins.tooltip = { - callbacks: { - label: function (tooltipItem) { - return callbackData[tooltipItem.label]; - }, - }, - }; - delete config.options.onClick - } else if (settings.graphTypes.suiteMostFailedGraphType == "timeline") { - config = get_graph_config("timeline", graphData, `Top ${limit}`, "Run", "Suite"); - config.options.plugins.tooltip = { - callbacks: { - label: function (context) { - return callbackData[context.raw.x[0]]; - }, - }, - }; - config.options.scales.x = { - ticks: { - minRotation: 45, - maxRotation: 45, - stepSize: 1, - callback: function (value, index, ticks) { - return callbackData[this.getLabelForValue(value)]; - }, - }, - title: { - display: settings.show.axisTitles, - text: "Run", - }, - }; - config.options.onClick = (event, chartElement) => { - if (chartElement.length) { - open_log_file(event, chartElement, callbackData) - } - }; - if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - } - update_height("suiteMostFailedVertical", config.data.labels.length, settings.graphTypes.suiteMostFailedGraphType); - return config; -} - -// function to create suite most failed graph in the suite section -function create_suite_most_failed_graph() { - console.log("creating_suite_most_failed_graph"); - if (suiteMostFailedGraph) { suiteMostFailedGraph.destroy(); } - suiteMostFailedGraph = new Chart("suiteMostFailedGraph", _build_suite_most_failed_config()); - suiteMostFailedGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(suiteMostFailedGraph, event) - }); + return build_most_failed_config("suiteMostFailed", "suite", "Suite", filteredSuites, false); } // build config for suite most time consuming graph function _build_suite_most_time_consuming_config() { - const onlyLastRun = document.getElementById("onlyLastRunSuite").checked; - const data = get_most_time_consuming_or_most_used_data("suite", settings.graphTypes.suiteMostTimeConsumingGraphType, filteredSuites, onlyLastRun); - const graphData = data[0] - const callbackData = data[1]; - var config; - const limit = inFullscreen && inFullscreenGraph.includes("suiteMostTimeConsuming") ? 50 : 10; - if (settings.graphTypes.suiteMostTimeConsumingGraphType == "bar") { - config = get_graph_config("bar", graphData, `Top ${limit}`, "Suite", "Most Time Consuming"); - config.options.plugins.legend = { display: false }; - config.options.plugins.tooltip = { - callbacks: { - label: function (tooltipItem) { - const key = tooltipItem.label; - const cb = callbackData; - const runStarts = cb.run_starts[key] || []; - const namesToShow = settings.show.aliases ? cb.aliases[key] : runStarts; - return runStarts.map((runStart, idx) => { - const info = cb.details[key][runStart]; - const displayName = namesToShow[idx]; - if (!info) return `${displayName}: (no data)`; - return `${displayName}: ${format_duration(info.duration)}`; - }); - } - }, - }; - delete config.options.onClick - } else if (settings.graphTypes.suiteMostTimeConsumingGraphType == "timeline") { - config = get_graph_config("timeline", graphData, `Top ${limit}`, "Run", "Suite"); - config.options.plugins.tooltip = { - callbacks: { - label: function (context) { - const key = context.dataset.label; - const runIndex = context.raw.x[0]; - const runStart = callbackData.runs[runIndex]; - const info = callbackData.details[key][runStart]; - const displayName = settings.show.aliases - ? callbackData.aliases[runIndex] - : runStart; - if (!info) return `${displayName}: (no data)`; - return `${displayName}: ${format_duration(info.duration)}`; - } - }, - }; - config.options.scales.x = { - ticks: { - minRotation: 45, - maxRotation: 45, - stepSize: 1, - callback: function (value, index, ticks) { - const displayName = settings.show.aliases - ? callbackData.aliases[this.getLabelForValue(value)] - : callbackData.runs[this.getLabelForValue(value)]; - return displayName; - }, - }, - title: { - display: settings.show.axisTitles, - text: "Run", - }, - }; - config.options.onClick = (event, chartElement) => { - if (chartElement.length) { - open_log_file(event, chartElement, callbackData.runs) - } - }; - if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - } - update_height("suiteMostTimeConsumingVertical", config.data.labels.length, settings.graphTypes.suiteMostTimeConsumingGraphType); - return config; -} - -// function to create the most time consuming suite graph in the suite section -function create_suite_most_time_consuming_graph() { - console.log("creating_suite_most_time_consuming_graph"); - if (suiteMostTimeConsumingGraph) { suiteMostTimeConsumingGraph.destroy(); } - suiteMostTimeConsumingGraph = new Chart("suiteMostTimeConsumingGraph", _build_suite_most_time_consuming_config()); - suiteMostTimeConsumingGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(suiteMostTimeConsumingGraph, event) - }); + return build_most_time_consuming_config("suiteMostTimeConsuming", "suite", "Suite", filteredSuites, "onlyLastRunSuite"); } +// create functions +function create_suite_statistics_graph() { create_chart("suiteStatisticsGraph", _build_suite_statistics_config); } +function create_suite_duration_graph() { create_chart("suiteDurationGraph", _build_suite_duration_config); } +function create_suite_most_failed_graph() { create_chart("suiteMostFailedGraph", _build_suite_most_failed_config); } +function create_suite_most_time_consuming_graph() { create_chart("suiteMostTimeConsumingGraph", _build_suite_most_time_consuming_config); } +function create_suite_folder_fail_donut_graph() { create_chart("suiteFolderFailDonutGraph", _build_suite_folder_fail_donut_config, false); } // update function for suite folder donut graph - updates existing chart in-place function update_suite_folder_donut_graph(folder) { @@ -370,55 +211,12 @@ function update_suite_folder_donut_graph(folder) { suiteFolderDonutGraph.update(); } -// update function for suite folder fail donut graph - updates existing chart in-place -function update_suite_folder_fail_donut_graph() { - console.log("updating_suite_folder_fail_donut_graph"); - if (!suiteFolderFailDonutGraph) { create_suite_folder_fail_donut_graph(); return; } - const config = _build_suite_folder_fail_donut_config(); - suiteFolderFailDonutGraph.data = config.data; - suiteFolderFailDonutGraph.options = config.options; - suiteFolderFailDonutGraph.update(); -} - -// update function for suite statistics graph - updates existing chart in-place -function update_suite_statistics_graph() { - console.log("updating_suite_statistics_graph"); - if (!suiteStatisticsGraph) { create_suite_statistics_graph(); return; } - const config = _build_suite_statistics_config(); - suiteStatisticsGraph.data = config.data; - suiteStatisticsGraph.options = config.options; - suiteStatisticsGraph.update(); -} - -// update function for suite duration graph - updates existing chart in-place -function update_suite_duration_graph() { - console.log("updating_suite_duration_graph"); - if (!suiteDurationGraph) { create_suite_duration_graph(); return; } - const config = _build_suite_duration_config(); - suiteDurationGraph.data = config.data; - suiteDurationGraph.options = config.options; - suiteDurationGraph.update(); -} - -// update function for suite most failed graph - updates existing chart in-place -function update_suite_most_failed_graph() { - console.log("updating_suite_most_failed_graph"); - if (!suiteMostFailedGraph) { create_suite_most_failed_graph(); return; } - const config = _build_suite_most_failed_config(); - suiteMostFailedGraph.data = config.data; - suiteMostFailedGraph.options = config.options; - suiteMostFailedGraph.update(); -} - -// update function for suite most time consuming graph - updates existing chart in-place -function update_suite_most_time_consuming_graph() { - console.log("updating_suite_most_time_consuming_graph"); - if (!suiteMostTimeConsumingGraph) { create_suite_most_time_consuming_graph(); return; } - const config = _build_suite_most_time_consuming_config(); - suiteMostTimeConsumingGraph.data = config.data; - suiteMostTimeConsumingGraph.options = config.options; - suiteMostTimeConsumingGraph.update(); -} +// update functions +function update_suite_folder_fail_donut_graph() { update_chart("suiteFolderFailDonutGraph", _build_suite_folder_fail_donut_config, false); } +function update_suite_statistics_graph() { update_chart("suiteStatisticsGraph", _build_suite_statistics_config); } +function update_suite_duration_graph() { update_chart("suiteDurationGraph", _build_suite_duration_config); } +function update_suite_most_failed_graph() { update_chart("suiteMostFailedGraph", _build_suite_most_failed_config); } +function update_suite_most_time_consuming_graph() { update_chart("suiteMostTimeConsumingGraph", _build_suite_most_time_consuming_config); } export { diff --git a/robotframework_dashboard/js/graph_creation/tables.js b/robotframework_dashboard/js/graph_creation/tables.js index ea9b390..189d27e 100644 --- a/robotframework_dashboard/js/graph_creation/tables.js +++ b/robotframework_dashboard/js/graph_creation/tables.js @@ -1,236 +1,87 @@ import { filteredRuns, filteredSuites, filteredTests, filteredKeywords } from "../variables/globals.js"; -// build table data for run table -function _get_run_table_data() { - const data = []; - for (const run of filteredRuns) { - data.push([ - run.run_start, - run.full_name, - run.name, - run.total, - run.passed, - run.failed, - run.skipped, - run.elapsed_s, - run.start_time, - run.project_version, - run.tags, - run.run_alias, - run.metadata, - ]); - } - return data; -} - -// function to create run table in the run section -function create_run_table() { - console.log("creating_run_table"); - if (runTable) { runTable.destroy(); } - runTable = new DataTable("#runTable", { - layout: { - topStart: "info", - bottomStart: null, - }, - columns: [ - { title: "run" }, - { title: "full_name" }, - { title: "name" }, - { title: "total" }, - { title: "passed" }, - { title: "failed" }, - { title: "skipped" }, - { title: "elapsed_s" }, - { title: "start_time" }, - { title: "version" }, - { title: "tags" }, - { title: "alias" }, - { title: "metadata" }, - ], - data: _get_run_table_data(), +// Generic table factory functions +function create_data_table(tableId, columns, getDataFn) { + console.log(`creating_${tableId}`); + if (window[tableId]) window[tableId].destroy(); + window[tableId] = new DataTable(`#${tableId}`, { + layout: { topStart: "info", bottomStart: null }, + columns, + data: getDataFn(), }); } -// build table data for suite table -function _get_suite_table_data() { - const data = []; - for (const suite of filteredSuites) { - data.push([ - suite.run_start, - suite.full_name, - suite.name, - suite.total, - suite.passed, - suite.failed, - suite.skipped, - suite.elapsed_s, - suite.start_time, - suite.run_alias, - suite.id, - ]); - } - return data; +function update_data_table(tableId, columns, getDataFn) { + console.log(`updating_${tableId}`); + if (!window[tableId]) { create_data_table(tableId, columns, getDataFn); return; } + window[tableId].clear(); + window[tableId].rows.add(getDataFn()); + window[tableId].draw(); } -// function to create suite table in the suite section -function create_suite_table() { - console.log("creating_suite_table"); - if (suiteTable) { suiteTable.destroy(); } - suiteTable = new DataTable("#suiteTable", { - layout: { - topStart: "info", - bottomStart: null, - }, - columns: [ - { title: "run" }, - { title: "full_name" }, - { title: "name" }, - { title: "total" }, - { title: "passed" }, - { title: "failed" }, - { title: "skipped" }, - { title: "elapsed_s" }, - { title: "start_time" }, - { title: "alias" }, - { title: "id" }, - ], - data: _get_suite_table_data(), - }); +// data builder functions +function _get_run_table_data() { + return filteredRuns.map(run => [ + run.run_start, run.full_name, run.name, run.total, run.passed, run.failed, + run.skipped, run.elapsed_s, run.start_time, run.project_version, run.tags, run.run_alias, run.metadata, + ]); } -// build table data for test table -function _get_test_table_data() { - const data = []; - for (const test of filteredTests) { - data.push([ - test.run_start, - test.full_name, - test.name, - test.passed, - test.failed, - test.skipped, - test.elapsed_s, - test.start_time, - test.message, - test.tags, - test.run_alias, - test.id - ]); - } - return data; +function _get_suite_table_data() { + return filteredSuites.map(suite => [ + suite.run_start, suite.full_name, suite.name, suite.total, suite.passed, suite.failed, + suite.skipped, suite.elapsed_s, suite.start_time, suite.run_alias, suite.id, + ]); } -// function to create test table in the test section -function create_test_table() { - console.log("creating_test_table"); - if (testTable) { testTable.destroy(); } - testTable = new DataTable("#testTable", { - layout: { - topStart: "info", - bottomStart: null, - }, - columns: [ - { title: "run" }, - { title: "full_name" }, - { title: "name" }, - { title: "passed" }, - { title: "failed" }, - { title: "skipped" }, - { title: "elapsed_s" }, - { title: "start_time" }, - { title: "message" }, - { title: "tags" }, - { title: "alias" }, - { title: "id" }, - ], - data: _get_test_table_data(), - }); +function _get_test_table_data() { + return filteredTests.map(test => [ + test.run_start, test.full_name, test.name, test.passed, test.failed, test.skipped, + test.elapsed_s, test.start_time, test.message, test.tags, test.run_alias, test.id, + ]); } -// build table data for keyword table function _get_keyword_table_data() { - const data = []; - for (const keyword of filteredKeywords) { - data.push([ - keyword.run_start, - keyword.name, - keyword.passed, - keyword.failed, - keyword.skipped, - keyword.times_run, - keyword.total_time_s, - keyword.average_time_s, - keyword.min_time_s, - keyword.max_time_s, - keyword.run_alias, - keyword.owner, - ]); - } - return data; + return filteredKeywords.map(keyword => [ + keyword.run_start, keyword.name, keyword.passed, keyword.failed, keyword.skipped, + keyword.times_run, keyword.total_time_s, keyword.average_time_s, keyword.min_time_s, + keyword.max_time_s, keyword.run_alias, keyword.owner, + ]); } -// function to create keyword table in the tables tab -function create_keyword_table() { - console.log("creating_keyword_table"); - if (keywordTable) { keywordTable.destroy(); } - keywordTable = new DataTable("#keywordTable", { - layout: { - topStart: "info", - bottomStart: null, - }, - columns: [ - { title: "run" }, - { title: "name" }, - { title: "passed" }, - { title: "failed" }, - { title: "skipped" }, - { title: "times_run" }, - { title: "total_execution_time" }, - { title: "average_execution_time" }, - { title: "min_execution_time" }, - { title: "max_execution_time" }, - { title: "alias" }, - { title: "owner" }, - ], - data: _get_keyword_table_data(), - }); -} +// column definitions +const runColumns = [ + { title: "run" }, { title: "full_name" }, { title: "name" }, { title: "total" }, + { title: "passed" }, { title: "failed" }, { title: "skipped" }, { title: "elapsed_s" }, + { title: "start_time" }, { title: "version" }, { title: "tags" }, { title: "alias" }, { title: "metadata" }, +]; +const suiteColumns = [ + { title: "run" }, { title: "full_name" }, { title: "name" }, { title: "total" }, + { title: "passed" }, { title: "failed" }, { title: "skipped" }, { title: "elapsed_s" }, + { title: "start_time" }, { title: "alias" }, { title: "id" }, +]; +const testColumns = [ + { title: "run" }, { title: "full_name" }, { title: "name" }, + { title: "passed" }, { title: "failed" }, { title: "skipped" }, { title: "elapsed_s" }, + { title: "start_time" }, { title: "message" }, { title: "tags" }, { title: "alias" }, { title: "id" }, +]; +const keywordColumns = [ + { title: "run" }, { title: "name" }, { title: "passed" }, { title: "failed" }, + { title: "skipped" }, { title: "times_run" }, { title: "total_execution_time" }, + { title: "average_execution_time" }, { title: "min_execution_time" }, + { title: "max_execution_time" }, { title: "alias" }, { title: "owner" }, +]; -// update function for run table - clears and redraws with new data -function update_run_table() { - console.log("updating_run_table"); - if (!runTable) { create_run_table(); return; } - runTable.clear(); - runTable.rows.add(_get_run_table_data()); - runTable.draw(); -} +// create/update functions +function create_run_table() { create_data_table("runTable", runColumns, _get_run_table_data); } +function create_suite_table() { create_data_table("suiteTable", suiteColumns, _get_suite_table_data); } +function create_test_table() { create_data_table("testTable", testColumns, _get_test_table_data); } +function create_keyword_table() { create_data_table("keywordTable", keywordColumns, _get_keyword_table_data); } -// update function for suite table - clears and redraws with new data -function update_suite_table() { - console.log("updating_suite_table"); - if (!suiteTable) { create_suite_table(); return; } - suiteTable.clear(); - suiteTable.rows.add(_get_suite_table_data()); - suiteTable.draw(); -} - -// update function for test table - clears and redraws with new data -function update_test_table() { - console.log("updating_test_table"); - if (!testTable) { create_test_table(); return; } - testTable.clear(); - testTable.rows.add(_get_test_table_data()); - testTable.draw(); -} - -// update function for keyword table - clears and redraws with new data -function update_keyword_table() { - console.log("updating_keyword_table"); - if (!keywordTable) { create_keyword_table(); return; } - keywordTable.clear(); - keywordTable.rows.add(_get_keyword_table_data()); - keywordTable.draw(); -} +function update_run_table() { update_data_table("runTable", runColumns, _get_run_table_data); } +function update_suite_table() { update_data_table("suiteTable", suiteColumns, _get_suite_table_data); } +function update_test_table() { update_data_table("testTable", testColumns, _get_test_table_data); } +function update_keyword_table() { update_data_table("keywordTable", keywordColumns, _get_keyword_table_data); } export { create_run_table, diff --git a/robotframework_dashboard/js/graph_creation/test.js b/robotframework_dashboard/js/graph_creation/test.js index 943ae7c..7a91ad3 100644 --- a/robotframework_dashboard/js/graph_creation/test.js +++ b/robotframework_dashboard/js/graph_creation/test.js @@ -2,15 +2,13 @@ import { get_test_statistics_data } from "../graph_data/statistics.js"; import { get_duration_graph_data } from "../graph_data/duration.js"; import { get_messages_data } from "../graph_data/messages.js"; import { get_duration_deviation_data } from "../graph_data/duration_deviation.js"; -import { get_most_flaky_data } from "../graph_data/flaky.js"; -import { get_most_failed_data } from "../graph_data/failed.js"; -import { get_most_time_consuming_or_most_used_data } from "../graph_data/time_consuming.js"; import { get_graph_config } from "../graph_data/graph_config.js"; import { update_height } from "../graph_data/helpers.js"; -import { open_log_file, open_log_from_label } from "../log.js"; -import { format_duration } from "../common.js"; +import { open_log_file } from "../log.js"; import { inFullscreen, inFullscreenGraph, ignoreSkips, ignoreSkipsRecent, filteredTests } from "../variables/globals.js"; import { settings } from "../variables/settings.js"; +import { create_chart, update_chart } from "./chart_factory.js"; +import { build_most_failed_config, build_most_flaky_config, build_most_time_consuming_config } from "./config_helpers.js"; // build config for test statistics graph function _build_test_statistics_config() { @@ -50,14 +48,7 @@ function _build_test_statistics_config() { } // function to create test statistics graph in the test section -function create_test_statistics_graph() { - console.log("creating_test_statistics_graph"); - if (testStatisticsGraph) { testStatisticsGraph.destroy(); } - testStatisticsGraph = new Chart("testStatisticsGraph", _build_test_statistics_config()); - testStatisticsGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testStatisticsGraph, event) - }); -} +function create_test_statistics_graph() { create_chart("testStatisticsGraph", _build_test_statistics_config); } // build config for test duration graph function _build_test_duration_config() { @@ -74,14 +65,7 @@ function _build_test_duration_config() { } // function to create test duration graph in the test section -function create_test_duration_graph() { - console.log("creating_test_duration_graph"); - if (testDurationGraph) { testDurationGraph.destroy(); } - testDurationGraph = new Chart("testDurationGraph", _build_test_duration_config()); - testDurationGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testDurationGraph, event) - }); -} +function create_test_duration_graph() { create_chart("testDurationGraph", _build_test_duration_config); } // build config for test messages graph function _build_test_messages_config() { @@ -154,14 +138,7 @@ function _build_test_messages_config() { } // function to create test messages graph in the test section -function create_test_messages_graph() { - console.log("creating_test_messages_graph"); - if (testMessagesGraph) { testMessagesGraph.destroy(); } - testMessagesGraph = new Chart("testMessagesGraph", _build_test_messages_config()); - testMessagesGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testMessagesGraph, event) - }); -} +function create_test_messages_graph() { create_chart("testMessagesGraph", _build_test_messages_config); } // build config for test duration deviation graph function _build_test_duration_deviation_config() { @@ -172,421 +149,74 @@ function _build_test_duration_deviation_config() { } // function to create test duration deviation graph in test section -function create_test_duration_deviation_graph() { - console.log("creating_test_duration_deviation_graph"); - if (testDurationDeviationGraph) { testDurationDeviationGraph.destroy(); } - testDurationDeviationGraph = new Chart("testDurationDeviationGraph", _build_test_duration_deviation_config()); - testDurationDeviationGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testDurationDeviationGraph, event) - }); -} +function create_test_duration_deviation_graph() { create_chart("testDurationDeviationGraph", _build_test_duration_deviation_config); } // build config for test most flaky graph function _build_test_most_flaky_config() { - const data = get_most_flaky_data("test", settings.graphTypes.testMostFlakyGraphType, filteredTests, ignoreSkips, false); - const graphData = data[0] - const callbackData = data[1]; - var config; - const limit = inFullscreen && inFullscreenGraph.includes("testMostFlaky") ? 50 : 10; - if (settings.graphTypes.testMostFlakyGraphType == "bar") { - config = get_graph_config("bar", graphData, `Top ${limit}`, "Test", "Status Flips"); - config.options.plugins.legend = false - delete config.options.onClick - } else if (settings.graphTypes.testMostFlakyGraphType == "timeline") { - config = get_graph_config("timeline", graphData, `Top ${limit}`, "Run", "Test"); - config.options.plugins.tooltip = { - callbacks: { - label: function (context) { - return callbackData[context.raw.x[0]]; - }, - }, - }; - config.options.scales.x = { - ticks: { - minRotation: 45, - maxRotation: 45, - stepSize: 1, - callback: function (value, index, ticks) { - return callbackData[this.getLabelForValue(value)]; - }, - }, - title: { - display: settings.show.axisTitles, - text: "Run", - }, - }; - config.options.onClick = (event, chartElement) => { - if (chartElement.length) { - open_log_file(event, chartElement, callbackData) - } - }; - if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - } - update_height("testMostFlakyVertical", config.data.labels.length, settings.graphTypes.testMostFlakyGraphType); - return config; + return build_most_flaky_config("testMostFlaky", "test", filteredTests, ignoreSkips, false); } // function to create test most flaky graph in test section -function create_test_most_flaky_graph() { - console.log("creating_test_most_flaky_graph"); - if (testMostFlakyGraph) { testMostFlakyGraph.destroy(); } - testMostFlakyGraph = new Chart("testMostFlakyGraph", _build_test_most_flaky_config()); - testMostFlakyGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testMostFlakyGraph, event) - }); -} +function create_test_most_flaky_graph() { create_chart("testMostFlakyGraph", _build_test_most_flaky_config); } // build config for test recent most flaky graph function _build_test_recent_most_flaky_config() { - const data = get_most_flaky_data("test", settings.graphTypes.testRecentMostFlakyGraphType, filteredTests, ignoreSkipsRecent, true); - const graphData = data[0]; - const callbackData = data[1]; - var config; - const limit = inFullscreen && inFullscreenGraph.includes("testRecentMostFlaky") ? 50 : 10; - if (settings.graphTypes.testRecentMostFlakyGraphType == "bar") { - config = get_graph_config("bar", graphData, `Top ${limit}`, "Test", "Status Flips"); - config.options.plugins.legend = false - delete config.options.onClick - } else if (settings.graphTypes.testRecentMostFlakyGraphType == "timeline") { - config = get_graph_config("timeline", graphData, `Top ${limit}`, "Run", "Test"); - config.options.plugins.tooltip = { - callbacks: { - label: function (context) { - return callbackData[context.raw.x[0]]; - }, - }, - }; - config.options.scales.x = { - ticks: { - minRotation: 45, - maxRotation: 45, - stepSize: 1, - callback: function (value, index, ticks) { - return callbackData[this.getLabelForValue(value)]; - }, - }, - title: { - display: settings.show.axisTitles, - text: "Run", - }, - }; - config.options.onClick = (event, chartElement) => { - if (chartElement.length) { - open_log_file(event, chartElement, callbackData) - } - }; - if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - } - update_height("testRecentMostFlakyVertical", config.data.labels.length, settings.graphTypes.testRecentMostFlakyGraphType); - return config; + return build_most_flaky_config("testRecentMostFlaky", "test", filteredTests, ignoreSkipsRecent, true); } // function to create test recent most flaky graph in test section -function create_test_recent_most_flaky_graph() { - console.log("creating_test_recent_most_flaky_graph"); - if (testRecentMostFlakyGraph) { testRecentMostFlakyGraph.destroy(); } - testRecentMostFlakyGraph = new Chart("testRecentMostFlakyGraph", _build_test_recent_most_flaky_config()); - testRecentMostFlakyGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testRecentMostFlakyGraph, event) - }); -} +function create_test_recent_most_flaky_graph() { create_chart("testRecentMostFlakyGraph", _build_test_recent_most_flaky_config); } // build config for test most failed graph function _build_test_most_failed_config() { - const data = get_most_failed_data("test", settings.graphTypes.testMostFailedGraphType, filteredTests, false); - const graphData = data[0] - const callbackData = data[1]; - var config; - const limit = inFullscreen && inFullscreenGraph.includes("testMostFailed") ? 50 : 10; - if (settings.graphTypes.testMostFailedGraphType == "bar") { - config = get_graph_config("bar", graphData, `Top ${limit}`, "Test", "Fails"); - config.options.plugins.legend = { display: false }; - config.options.plugins.tooltip = { - callbacks: { - label: function (tooltipItem) { - return callbackData[tooltipItem.label]; - }, - }, - }; - delete config.options.onClick - } else if (settings.graphTypes.testMostFailedGraphType == "timeline") { - config = get_graph_config("timeline", graphData, `Top ${limit}`, "Run", "Test"); - config.options.plugins.tooltip = { - callbacks: { - label: function (context) { - return callbackData[context.raw.x[0]]; - }, - }, - }; - config.options.scales.x = { - ticks: { - minRotation: 45, - maxRotation: 45, - stepSize: 1, - callback: function (value, index, ticks) { - return callbackData[this.getLabelForValue(value)]; - }, - }, - title: { - display: settings.show.axisTitles, - text: "Run", - }, - }; - config.options.onClick = (event, chartElement) => { - if (chartElement.length) { - open_log_file(event, chartElement, callbackData) - } - }; - if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - } - update_height("testMostFailedVertical", config.data.labels.length, settings.graphTypes.testMostFailedGraphType); - return config; + return build_most_failed_config("testMostFailed", "test", "Test", filteredTests, false); } // function to create test most failed graph in the test section -function create_test_most_failed_graph() { - console.log("creating_test_most_failed_graph"); - if (testMostFailedGraph) { testMostFailedGraph.destroy(); } - testMostFailedGraph = new Chart("testMostFailedGraph", _build_test_most_failed_config()); - testMostFailedGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testMostFailedGraph, event) - }); -} +function create_test_most_failed_graph() { create_chart("testMostFailedGraph", _build_test_most_failed_config); } // build config for test recent most failed graph function _build_test_recent_most_failed_config() { - const data = get_most_failed_data("test", settings.graphTypes.testRecentMostFailedGraphType, filteredTests, true); - const graphData = data[0] - const callbackData = data[1]; - var config; - const limit = inFullscreen && inFullscreenGraph.includes("testRecentMostFailed") ? 50 : 10; - if (settings.graphTypes.testRecentMostFailedGraphType == "bar") { - config = get_graph_config("bar", graphData, `Top ${limit}`, "Test", "Fails"); - config.options.plugins.legend = { display: false }; - config.options.plugins.tooltip = { - callbacks: { - label: function (tooltipItem) { - return callbackData[tooltipItem.label]; - }, - }, - }; - delete config.options.onClick - } else if (settings.graphTypes.testRecentMostFailedGraphType == "timeline") { - config = get_graph_config("timeline", graphData, `Top ${limit}`, "Run", "Test"); - config.options.plugins.tooltip = { - callbacks: { - label: function (context) { - return callbackData[context.raw.x[0]]; - }, - }, - }; - config.options.scales.x = { - ticks: { - minRotation: 45, - maxRotation: 45, - stepSize: 1, - callback: function (value, index, ticks) { - return callbackData[this.getLabelForValue(value)]; - }, - }, - title: { - display: settings.show.axisTitles, - text: "Run", - }, - }; - config.options.onClick = (event, chartElement) => { - if (chartElement.length) { - open_log_file(event, chartElement, callbackData) - } - }; - if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - } - update_height("testRecentMostFailedVertical", config.data.labels.length, settings.graphTypes.testRecentMostFailedGraphType); - return config; + return build_most_failed_config("testRecentMostFailed", "test", "Test", filteredTests, true); } // function to create test recent most failed graph in the test section -function create_test_recent_most_failed_graph() { - console.log("creating_test_recent_most_failed_graph"); - if (testRecentMostFailedGraph) { testRecentMostFailedGraph.destroy(); } - testRecentMostFailedGraph = new Chart("testRecentMostFailedGraph", _build_test_recent_most_failed_config()); - testRecentMostFailedGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testRecentMostFailedGraph, event) - }); -} +function create_test_recent_most_failed_graph() { create_chart("testRecentMostFailedGraph", _build_test_recent_most_failed_config); } // build config for test most time consuming graph function _build_test_most_time_consuming_config() { - const onlyLastRun = document.getElementById("onlyLastRunTest").checked; - const data = get_most_time_consuming_or_most_used_data("test", settings.graphTypes.testMostTimeConsumingGraphType, filteredTests, onlyLastRun); - const graphData = data[0] - const callbackData = data[1]; - var config; - const limit = inFullscreen && inFullscreenGraph.includes("testMostTimeConsuming") ? 50 : 10; - if (settings.graphTypes.testMostTimeConsumingGraphType == "bar") { - config = get_graph_config("bar", graphData, `Top ${limit}`, "Test", "Most Time Consuming"); - config.options.plugins.legend = { display: false }; - config.options.plugins.tooltip = { - callbacks: { - label: function (tooltipItem) { - const key = tooltipItem.label; - const cb = callbackData; - const runStarts = cb.run_starts[key] || []; - const namesToShow = settings.show.aliases ? cb.aliases[key] : runStarts; - return runStarts.map((runStart, idx) => { - const info = cb.details[key][runStart]; - const displayName = namesToShow[idx]; - if (!info) return `${displayName}: (no data)`; - return `${displayName}: ${format_duration(info.duration)}`; - }); - } - }, - }; - delete config.options.onClick - } else if (settings.graphTypes.testMostTimeConsumingGraphType == "timeline") { - config = get_graph_config("timeline", graphData, `Top ${limit}`, "Run", "Test"); - config.options.plugins.tooltip = { - callbacks: { - label: function (context) { - const key = context.dataset.label; - const runIndex = context.raw.x[0]; - const runStart = callbackData.runs[runIndex]; - const info = callbackData.details[key][runStart]; - const displayName = settings.show.aliases - ? callbackData.aliases[runIndex] - : runStart; - if (!info) return `${displayName}: (no data)`; - return `${displayName}: ${format_duration(info.duration)}`; - } - }, - }; - config.options.scales.x = { - ticks: { - minRotation: 45, - maxRotation: 45, - stepSize: 1, - callback: function (value, index, ticks) { - const displayName = settings.show.aliases - ? callbackData.aliases[this.getLabelForValue(value)] - : callbackData.runs[this.getLabelForValue(value)]; - return displayName; - }, - }, - title: { - display: settings.show.axisTitles, - text: "Run", - }, - }; - config.options.onClick = (event, chartElement) => { - if (chartElement.length) { - open_log_file(event, chartElement, callbackData.runs) - } - }; - if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - } - update_height("testMostTimeConsumingVertical", config.data.labels.length, settings.graphTypes.testMostTimeConsumingGraphType); - return config; + return build_most_time_consuming_config("testMostTimeConsuming", "test", "Test", filteredTests, "onlyLastRunTest"); } // function to create the most time consuming test graph in the test section -function create_test_most_time_consuming_graph() { - console.log("creating_test_most_time_consuming_graph"); - if (testMostTimeConsumingGraph) { testMostTimeConsumingGraph.destroy(); } - testMostTimeConsumingGraph = new Chart("testMostTimeConsumingGraph", _build_test_most_time_consuming_config()); - testMostTimeConsumingGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testMostTimeConsumingGraph, event) - }); -} +function create_test_most_time_consuming_graph() { create_chart("testMostTimeConsumingGraph", _build_test_most_time_consuming_config); } // update function for test statistics graph - updates existing chart in-place -function update_test_statistics_graph() { - console.log("updating_test_statistics_graph"); - if (!testStatisticsGraph) { create_test_statistics_graph(); return; } - const config = _build_test_statistics_config(); - testStatisticsGraph.data = config.data; - testStatisticsGraph.options = config.options; - testStatisticsGraph.update(); -} +function update_test_statistics_graph() { update_chart("testStatisticsGraph", _build_test_statistics_config); } // update function for test duration graph - updates existing chart in-place -function update_test_duration_graph() { - console.log("updating_test_duration_graph"); - if (!testDurationGraph) { create_test_duration_graph(); return; } - const config = _build_test_duration_config(); - testDurationGraph.data = config.data; - testDurationGraph.options = config.options; - testDurationGraph.update(); -} +function update_test_duration_graph() { update_chart("testDurationGraph", _build_test_duration_config); } // update function for test messages graph - updates existing chart in-place -function update_test_messages_graph() { - console.log("updating_test_messages_graph"); - if (!testMessagesGraph) { create_test_messages_graph(); return; } - const config = _build_test_messages_config(); - testMessagesGraph.data = config.data; - testMessagesGraph.options = config.options; - testMessagesGraph.update(); -} +function update_test_messages_graph() { update_chart("testMessagesGraph", _build_test_messages_config); } // update function for test duration deviation graph - updates existing chart in-place -function update_test_duration_deviation_graph() { - console.log("updating_test_duration_deviation_graph"); - if (!testDurationDeviationGraph) { create_test_duration_deviation_graph(); return; } - const config = _build_test_duration_deviation_config(); - testDurationDeviationGraph.data = config.data; - testDurationDeviationGraph.options = config.options; - testDurationDeviationGraph.update(); -} +function update_test_duration_deviation_graph() { update_chart("testDurationDeviationGraph", _build_test_duration_deviation_config); } // update function for test most flaky graph - updates existing chart in-place -function update_test_most_flaky_graph() { - console.log("updating_test_most_flaky_graph"); - if (!testMostFlakyGraph) { create_test_most_flaky_graph(); return; } - const config = _build_test_most_flaky_config(); - testMostFlakyGraph.data = config.data; - testMostFlakyGraph.options = config.options; - testMostFlakyGraph.update(); -} +function update_test_most_flaky_graph() { update_chart("testMostFlakyGraph", _build_test_most_flaky_config); } // update function for test recent most flaky graph - updates existing chart in-place -function update_test_recent_most_flaky_graph() { - console.log("updating_test_recent_most_flaky_graph"); - if (!testRecentMostFlakyGraph) { create_test_recent_most_flaky_graph(); return; } - const config = _build_test_recent_most_flaky_config(); - testRecentMostFlakyGraph.data = config.data; - testRecentMostFlakyGraph.options = config.options; - testRecentMostFlakyGraph.update(); -} +function update_test_recent_most_flaky_graph() { update_chart("testRecentMostFlakyGraph", _build_test_recent_most_flaky_config); } // update function for test most failed graph - updates existing chart in-place -function update_test_most_failed_graph() { - console.log("updating_test_most_failed_graph"); - if (!testMostFailedGraph) { create_test_most_failed_graph(); return; } - const config = _build_test_most_failed_config(); - testMostFailedGraph.data = config.data; - testMostFailedGraph.options = config.options; - testMostFailedGraph.update(); -} +function update_test_most_failed_graph() { update_chart("testMostFailedGraph", _build_test_most_failed_config); } // update function for test recent most failed graph - updates existing chart in-place -function update_test_recent_most_failed_graph() { - console.log("updating_test_recent_most_failed_graph"); - if (!testRecentMostFailedGraph) { create_test_recent_most_failed_graph(); return; } - const config = _build_test_recent_most_failed_config(); - testRecentMostFailedGraph.data = config.data; - testRecentMostFailedGraph.options = config.options; - testRecentMostFailedGraph.update(); -} +function update_test_recent_most_failed_graph() { update_chart("testRecentMostFailedGraph", _build_test_recent_most_failed_config); } // update function for test most time consuming graph - updates existing chart in-place -function update_test_most_time_consuming_graph() { - console.log("updating_test_most_time_consuming_graph"); - if (!testMostTimeConsumingGraph) { create_test_most_time_consuming_graph(); return; } - const config = _build_test_most_time_consuming_config(); - testMostTimeConsumingGraph.data = config.data; - testMostTimeConsumingGraph.options = config.options; - testMostTimeConsumingGraph.update(); -} +function update_test_most_time_consuming_graph() { update_chart("testMostTimeConsumingGraph", _build_test_most_time_consuming_config); } export { create_test_statistics_graph, From ee34c6a39f14ed0ae6831b6bd47277b00a3a7242 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Thu, 19 Feb 2026 01:22:30 +0100 Subject: [PATCH 13/26] Remove console log statements from chart and table creation functions for cleaner output --- robotframework_dashboard/js/graph_creation/chart_factory.js | 2 -- robotframework_dashboard/js/graph_creation/run.js | 2 -- robotframework_dashboard/js/graph_creation/suite.js | 2 -- robotframework_dashboard/js/graph_creation/tables.js | 2 -- 4 files changed, 8 deletions(-) diff --git a/robotframework_dashboard/js/graph_creation/chart_factory.js b/robotframework_dashboard/js/graph_creation/chart_factory.js index 466cf7d..ed29f9c 100644 --- a/robotframework_dashboard/js/graph_creation/chart_factory.js +++ b/robotframework_dashboard/js/graph_creation/chart_factory.js @@ -2,7 +2,6 @@ import { open_log_from_label } from "../log.js"; // Generic chart create function - replaces boilerplate create_X_graph() pattern function create_chart(chartId, buildConfigFn, addLogClickHandler = true) { - console.log(`creating_${chartId}`); if (window[chartId]) window[chartId].destroy(); window[chartId] = new Chart(chartId, buildConfigFn()); if (addLogClickHandler) { @@ -14,7 +13,6 @@ function create_chart(chartId, buildConfigFn, addLogClickHandler = true) { // Generic chart update function - replaces boilerplate update_X_graph() pattern function update_chart(chartId, buildConfigFn, addLogClickHandler = true) { - console.log(`updating_${chartId}`); if (!window[chartId]) { create_chart(chartId, buildConfigFn, addLogClickHandler); return; } const config = buildConfigFn(); window[chartId].data = config.data; diff --git a/robotframework_dashboard/js/graph_creation/run.js b/robotframework_dashboard/js/graph_creation/run.js index b936ea0..f07083c 100644 --- a/robotframework_dashboard/js/graph_creation/run.js +++ b/robotframework_dashboard/js/graph_creation/run.js @@ -76,7 +76,6 @@ function create_run_donut_total_graph() { create_chart("runDonutTotalGraph", _bu // function to create the run stats section in the run section function create_run_stats_graph() { - console.log("creating_run_stats_graph"); const data = get_stats_data(filteredRuns, filteredSuites, filteredTests, filteredKeywords); document.getElementById('totalRuns').innerText = data.totalRuns document.getElementById('totalSuites').innerText = data.totalSuites @@ -150,7 +149,6 @@ function update_run_donut_total_graph() { update_chart("runDonutTotalGraph", _bu // update function for run stats - same as create since it only updates DOM text function update_run_stats_graph() { - console.log("updating_run_stats_graph"); create_run_stats_graph(); } diff --git a/robotframework_dashboard/js/graph_creation/suite.js b/robotframework_dashboard/js/graph_creation/suite.js index 47ea992..8178e26 100644 --- a/robotframework_dashboard/js/graph_creation/suite.js +++ b/robotframework_dashboard/js/graph_creation/suite.js @@ -51,7 +51,6 @@ function _build_suite_folder_donut_config(folder) { // function to create suite folder donut function create_suite_folder_donut_graph(folder) { - console.log("creating_suite_folder_donut_graph"); const suiteFolder = document.getElementById("suiteFolder") suiteFolder.innerText = folder == "" || folder == undefined ? "All" : folder; if (folder || folder == "") { // not first load so update the graphs accordingly as well @@ -195,7 +194,6 @@ function create_suite_folder_fail_donut_graph() { create_chart("suiteFolderFailD // update function for suite folder donut graph - updates existing chart in-place function update_suite_folder_donut_graph(folder) { - console.log("updating_suite_folder_donut_graph"); const suiteFolder = document.getElementById("suiteFolder") suiteFolder.innerText = folder == "" || folder == undefined ? "All" : folder; if (folder || folder == "") { diff --git a/robotframework_dashboard/js/graph_creation/tables.js b/robotframework_dashboard/js/graph_creation/tables.js index 189d27e..41bb1f8 100644 --- a/robotframework_dashboard/js/graph_creation/tables.js +++ b/robotframework_dashboard/js/graph_creation/tables.js @@ -2,7 +2,6 @@ import { filteredRuns, filteredSuites, filteredTests, filteredKeywords } from ". // Generic table factory functions function create_data_table(tableId, columns, getDataFn) { - console.log(`creating_${tableId}`); if (window[tableId]) window[tableId].destroy(); window[tableId] = new DataTable(`#${tableId}`, { layout: { topStart: "info", bottomStart: null }, @@ -12,7 +11,6 @@ function create_data_table(tableId, columns, getDataFn) { } function update_data_table(tableId, columns, getDataFn) { - console.log(`updating_${tableId}`); if (!window[tableId]) { create_data_table(tableId, columns, getDataFn); return; } window[tableId].clear(); window[tableId].rows.add(getDataFn()); From d2402e53b09253aa3c65681965d8b89d3e3f5490 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Sun, 22 Feb 2026 20:20:39 +0100 Subject: [PATCH 14/26] Enhance most flaky graph data retrieval by adding limit parameter for improved data handling in fullscreen mode and fix ignoreskips --- .../js/graph_creation/config_helpers.js | 4 ++-- robotframework_dashboard/js/graph_data/flaky.js | 14 ++++++-------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/robotframework_dashboard/js/graph_creation/config_helpers.js b/robotframework_dashboard/js/graph_creation/config_helpers.js index b4c3556..fc81102 100644 --- a/robotframework_dashboard/js/graph_creation/config_helpers.js +++ b/robotframework_dashboard/js/graph_creation/config_helpers.js @@ -70,10 +70,10 @@ function build_most_failed_config(graphKey, dataType, dataLabel, filteredData, i // Build config for "most flaky" graphs (test regular and recent) function build_most_flaky_config(graphKey, dataType, filteredData, ignoreSkipsVal, isRecent) { const graphType = settings.graphTypes[`${graphKey}GraphType`]; - const data = get_most_flaky_data(dataType, graphType, filteredData, ignoreSkipsVal, isRecent); + const limit = inFullscreen && inFullscreenGraph === `${graphKey}Fullscreen` ? 50 : 10; + const data = get_most_flaky_data(dataType, graphType, filteredData, ignoreSkipsVal, isRecent, limit); const graphData = data[0]; const callbackData = data[1]; - const limit = inFullscreen && inFullscreenGraph.includes(graphKey) ? 50 : 10; var config; if (graphType == "bar") { config = get_graph_config("bar", graphData, `Top ${limit}`, "Test", "Status Flips"); diff --git a/robotframework_dashboard/js/graph_data/flaky.js b/robotframework_dashboard/js/graph_data/flaky.js index 33d5d42..ae45827 100644 --- a/robotframework_dashboard/js/graph_data/flaky.js +++ b/robotframework_dashboard/js/graph_data/flaky.js @@ -1,12 +1,15 @@ import { settings } from "../variables/settings.js"; -import { inFullscreen, inFullscreenGraph } from "../variables/globals.js"; + import { passedConfig, failedConfig, skippedConfig } from "../variables/chartconfig.js"; import { convert_timeline_data } from "./helpers.js"; // function to prepare the data in the correct format for (recent) most flaky test graph -function get_most_flaky_data(dataType, graphType, filteredData, ignore, recent) { +function get_most_flaky_data(dataType, graphType, filteredData, ignore, recent, limit) { var data = {}; for (const value of filteredData) { + if (ignore && value.skipped == 1) { + continue; + } const key = settings.switch.suitePathsTestSection ? value.full_name : value.name; if (data[key]) { data[key]["run_starts"].push(value.run_start); @@ -61,12 +64,7 @@ function get_most_flaky_data(dataType, graphType, filteredData, ignore, recent) return new Date(b[1].failed_run_starts[b[1].failed_run_starts.length - 1]).getTime() - new Date(a[1].failed_run_starts[a[1].failed_run_starts.length - 1]).getTime() }) } - var limit - if (recent) { - limit = inFullscreen && inFullscreenGraph.includes("testRecentMostFlaky") ? 50 : 10; - } else { - limit = inFullscreen && inFullscreenGraph.includes("testMostFlaky") ? 50 : 10; - } + if (graphType == "bar") { var [datasets, labels, count] = [[], [], 0]; for (const key in sortedData) { From ffb531ee81bc12902092fbbc01b9b3788a66faa2 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Sun, 22 Feb 2026 20:22:56 +0100 Subject: [PATCH 15/26] Remove unnecessary whitespace in flaky.js import statements for cleaner code --- robotframework_dashboard/js/graph_data/flaky.js | 1 - 1 file changed, 1 deletion(-) diff --git a/robotframework_dashboard/js/graph_data/flaky.js b/robotframework_dashboard/js/graph_data/flaky.js index ae45827..e72fb82 100644 --- a/robotframework_dashboard/js/graph_data/flaky.js +++ b/robotframework_dashboard/js/graph_data/flaky.js @@ -1,5 +1,4 @@ import { settings } from "../variables/settings.js"; - import { passedConfig, failedConfig, skippedConfig } from "../variables/chartconfig.js"; import { convert_timeline_data } from "./helpers.js"; From 35d5d4fdfa2907216a219c53046c7bd840bac696 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Sun, 22 Feb 2026 21:33:04 +0100 Subject: [PATCH 16/26] Refactor color settings and theme handling for improved consistency and usability --- robotframework_dashboard/css/colors.css | 8 +++--- robotframework_dashboard/css/components.css | 10 ++++++- robotframework_dashboard/css/dark.css | 4 +-- robotframework_dashboard/js/eventlisteners.js | 24 ++++++++++++----- robotframework_dashboard/js/localstorage.js | 16 +++++++++-- robotframework_dashboard/js/theme.js | 27 ++++++++++++++----- .../js/variables/settings.js | 2 -- .../templates/dashboard.html | 12 ++------- 8 files changed, 68 insertions(+), 35 deletions(-) diff --git a/robotframework_dashboard/css/colors.css b/robotframework_dashboard/css/colors.css index a296280..0eb2e15 100644 --- a/robotframework_dashboard/css/colors.css +++ b/robotframework_dashboard/css/colors.css @@ -1,7 +1,7 @@ :root { --color-bg: #eee; --color-card: #ffffff; - --color-menu-text: #000000; + --color-menu-text: var(--color-text); --color-highlight: #3451b2; --color-text: #000000; --color-text-muted: darkgrey; @@ -20,7 +20,7 @@ --color-passed: rgba(151, 189, 97, 0.9); --color-failed: rgba(206, 62, 1, 0.9); --color-skipped: rgba(254, 216, 79, 0.9); - --color-modal-bg: #ffffff; + --color-modal-bg: var(--color-bg); --color-section-card-bg: #ffffff; --color-section-card-text: var(--color-text); --color-section-card-border: transparent; @@ -31,7 +31,7 @@ color-scheme: dark; --color-bg: #0f172a; --color-card: rgba(30, 41, 59, 0.9); - --color-menu-text: #ffffff; + --color-menu-text: var(--color-text); --color-highlight: #a8b1ff; --color-text: #eee; --color-text-muted: #9ca3af; @@ -50,7 +50,7 @@ --color-passed: rgba(151, 189, 97, 0.9); --color-failed: rgba(206, 62, 1, 0.9); --color-skipped: rgba(254, 216, 79, 0.9); - --color-modal-bg: #0f172a; + --color-modal-bg: var(--color-bg); --color-section-card-bg: rgba(30, 41, 59, 0.9); --color-section-card-text: var(--color-text); --color-section-card-border: rgba(255, 255, 255, 0.1); diff --git a/robotframework_dashboard/css/components.css b/robotframework_dashboard/css/components.css index cf1e6a6..09e304b 100644 --- a/robotframework_dashboard/css/components.css +++ b/robotframework_dashboard/css/components.css @@ -16,8 +16,12 @@ .card { margin-bottom: 1rem; box-shadow: 0 4px 20px var(--color-shadow-strong); - background: var(--color-bg); color: var(--color-text); + background: var(--color-bg); +} + +.overview-card > .card { + background: var(--color-card) !important; } .stats { @@ -396,3 +400,7 @@ canvas { .list-group-item[hidden] { display: none !important; } + +.list-group-item { + background-color: var(--color-card); +} diff --git a/robotframework_dashboard/css/dark.css b/robotframework_dashboard/css/dark.css index 5e25dc1..da7d8cc 100644 --- a/robotframework_dashboard/css/dark.css +++ b/robotframework_dashboard/css/dark.css @@ -1,9 +1,9 @@ -.dark-mode .modal-dialog { +.modal-dialog { background: var(--color-modal-bg); color: var(--color-text); } -.dark-mode .modal-content { +.modal-content { background: var(--color-modal-bg); } diff --git a/robotframework_dashboard/js/eventlisteners.js b/robotframework_dashboard/js/eventlisteners.js index f8f0600..b928e31 100644 --- a/robotframework_dashboard/js/eventlisteners.js +++ b/robotframework_dashboard/js/eventlisteners.js @@ -281,6 +281,20 @@ function setup_settings_modal() { document.getElementById("themeLight").addEventListener("click", () => toggle_theme()); document.getElementById("themeDark").addEventListener("click", () => toggle_theme()); + // Convert any CSS color string to #rrggbb hex for + function to_hex_color(color) { + // Handle rgba/rgb strings by parsing components directly + const rgbaMatch = color.match(/^rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)/); + if (rgbaMatch) { + const [, r, g, b] = rgbaMatch.map(Number); + return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join(''); + } + // For hex shorthand (#eee) and other CSS colors, use canvas normalization + const ctx = document.createElement('canvas').getContext('2d'); + ctx.fillStyle = color; + return ctx.fillStyle; + } + function create_theme_color_handler(colorKey, elementId) { function load_color() { const element = document.getElementById(elementId); @@ -292,11 +306,11 @@ function setup_settings_modal() { const storedColor = customColors?.[colorKey]; if (storedColor) { - element.value = storedColor; + element.value = to_hex_color(storedColor); } else { // Use default from settings for current theme mode const defaults = settings.theme_colors[themeMode]; - element.value = defaults[colorKey]; + element.value = to_hex_color(defaults[colorKey]); } } @@ -325,7 +339,7 @@ function setup_settings_modal() { // Reset to default from settings const defaults = settings.theme_colors[themeMode]; - element.value = defaults[colorKey]; + element.value = to_hex_color(defaults[colorKey]); if (settings.theme_colors?.custom?.[themeMode]) { delete settings.theme_colors.custom[themeMode][colorKey]; @@ -340,7 +354,6 @@ function setup_settings_modal() { const backgroundColorHandler = create_theme_color_handler('background', 'themeBackgroundColor'); const cardColorHandler = create_theme_color_handler('card', 'themeCardColor'); - const menuTextColorHandler = create_theme_color_handler('menuText', 'themeMenuTextColor'); const highlightColorHandler = create_theme_color_handler('highlight', 'themeHighlightColor'); const textColorHandler = create_theme_color_handler('text', 'themeTextColor'); @@ -348,7 +361,6 @@ function setup_settings_modal() { $("#settingsModal").on("shown.bs.modal", function () { backgroundColorHandler.load_color(); cardColorHandler.load_color(); - menuTextColorHandler.load_color(); highlightColorHandler.load_color(); textColorHandler.load_color(); }); @@ -356,14 +368,12 @@ function setup_settings_modal() { // Add event listeners for color inputs document.getElementById('themeBackgroundColor').addEventListener('change', () => backgroundColorHandler.update_color()); document.getElementById('themeCardColor').addEventListener('change', () => cardColorHandler.update_color()); - document.getElementById('themeMenuTextColor').addEventListener('change', () => menuTextColorHandler.update_color()); document.getElementById('themeHighlightColor').addEventListener('change', () => highlightColorHandler.update_color()); document.getElementById('themeTextColor').addEventListener('change', () => textColorHandler.update_color()); // Add event listeners for reset buttons document.getElementById('resetBackgroundColor').addEventListener('click', () => backgroundColorHandler.reset_color()); document.getElementById('resetCardColor').addEventListener('click', () => cardColorHandler.reset_color()); - document.getElementById('resetMenuTextColor').addEventListener('click', () => menuTextColorHandler.reset_color()); document.getElementById('resetHighlightColor').addEventListener('click', () => highlightColorHandler.reset_color()); document.getElementById('resetTextColor').addEventListener('click', () => textColorHandler.reset_color()); diff --git a/robotframework_dashboard/js/localstorage.js b/robotframework_dashboard/js/localstorage.js index 09699dd..efc0b6c 100644 --- a/robotframework_dashboard/js/localstorage.js +++ b/robotframework_dashboard/js/localstorage.js @@ -19,9 +19,7 @@ function setup_local_storage() { } else if (storedSettings) { // 2) Prefer existing localStorage when not forcing a config const parsedSettings = JSON.parse(storedSettings); - console.log(settings, parsedSettings) resolvedSettings = mergeWithDefaults(parsedSettings); - console.log(parsedSettings) } else if (!force_json_config && hasJsonConfig) { // 3) Use provided json_config when not forcing and no localStorage present resolvedSettings = mergeWithDefaults(json_config); @@ -80,6 +78,9 @@ function merge_deep(local, defaults) { else if (key === "layouts") { result[key] = merge_layout(localVal, defaults); } + else if (key === "theme_colors") { + result[key] = merge_theme_colors(localVal, defaultVal); + } else if (isObject(localVal) && isObject(defaultVal)) { result[key] = merge_objects_base(localVal, defaultVal); } @@ -171,6 +172,17 @@ function merge_view_section_or_graph(local, defaults, page = null) { return result; } +// function to merge theme_colors from localstorage with defaults, preserving custom colors +function merge_theme_colors(local, defaults) { + const result = merge_objects_base(local, defaults); + // Preserve the custom key from local since its sub-keys (user-chosen colors) + // won't exist in the empty defaults and would be stripped by merge_objects_base + if (local.custom) { + result.custom = structuredClone(local.custom); + } + return result; +} + // function to merge layout from localstorage with allowed graphs from settings function merge_layout(localLayout, mergedDefaults) { if (!localLayout) return localLayout; diff --git a/robotframework_dashboard/js/theme.js b/robotframework_dashboard/js/theme.js index db58c46..e79923f 100644 --- a/robotframework_dashboard/js/theme.js +++ b/robotframework_dashboard/js/theme.js @@ -228,17 +228,30 @@ function apply_theme_colors() { const finalColors = { background: customColors.background || defaultColors.background, card: customColors.card || defaultColors.card, - menuText: customColors.menuText || defaultColors.menuText, highlight: customColors.highlight || defaultColors.highlight, text: customColors.text || defaultColors.text, }; - // Set CSS custom properties - root.style.setProperty('--theme-bg-color', finalColors.background); - root.style.setProperty('--theme-card-color', finalColors.card); - root.style.setProperty('--theme-menu-text-color', finalColors.menuText); - root.style.setProperty('--theme-highlight-color', finalColors.highlight); - root.style.setProperty('--theme-text-color', finalColors.text); + // Set CSS custom properties - background color + root.style.setProperty('--color-bg', finalColors.background); + root.style.setProperty('--color-fullscreen-bg', finalColors.background); + root.style.setProperty('--color-modal-bg', finalColors.background); + + // Set CSS custom properties - card color (propagate to all card-like surfaces) + root.style.setProperty('--color-card', finalColors.card); + // In light mode, section cards match background; in dark mode they use card color + root.style.setProperty('--color-section-card-bg', finalColors.card); + root.style.setProperty('--color-tooltip-bg', finalColors.card); + + // Set CSS custom properties - highlight color + root.style.setProperty('--color-highlight', finalColors.highlight); + + // Set CSS custom properties - text color (propagate to all text) + root.style.setProperty('--color-text', finalColors.text); + root.style.setProperty('--color-menu-text', finalColors.text); + root.style.setProperty('--color-table-text', finalColors.text); + root.style.setProperty('--color-tooltip-text', finalColors.text); + root.style.setProperty('--color-section-card-text', finalColors.text); } export { diff --git a/robotframework_dashboard/js/variables/settings.js b/robotframework_dashboard/js/variables/settings.js index f9a9812..610d9c3 100644 --- a/robotframework_dashboard/js/variables/settings.js +++ b/robotframework_dashboard/js/variables/settings.js @@ -41,7 +41,6 @@ var settings = { light: { background: '#eee', card: '#ffffff', - menuText: '#000000', highlight: '#3451b2', text: '#000000', }, @@ -49,7 +48,6 @@ var settings = { background: '#0f172a', card: 'rgba(30, 41, 59, 0.9)', highlight: '#a8b1ff', - menuText: '#ffffff', text: '#eee', }, custom: { diff --git a/robotframework_dashboard/templates/dashboard.html b/robotframework_dashboard/templates/dashboard.html index 00fca69..823dde7 100644 --- a/robotframework_dashboard/templates/dashboard.html +++ b/robotframework_dashboard/templates/dashboard.html @@ -739,17 +739,9 @@

Settings

id="resetCardColor">Reset
+
- Menu Text Color -
- - -
-
-
- Menu Highlight Color + Highlight Color
From 83002102bfb8f0e1c5c44324c6e5ce01a9891fb7 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Sun, 22 Feb 2026 21:35:39 +0100 Subject: [PATCH 17/26] Add loading overlays for graphs and filters with dark mode support --- robotframework_dashboard/css/components.css | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/robotframework_dashboard/css/components.css b/robotframework_dashboard/css/components.css index 09e304b..a23c568 100644 --- a/robotframework_dashboard/css/components.css +++ b/robotframework_dashboard/css/components.css @@ -309,6 +309,54 @@ canvas { background: transparent; } +/* Individual graph loading overlay */ +.graph-loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + background: rgba(238, 238, 238, 0.6); + border-radius: 8px; + z-index: 10; +} + +.dark-mode .graph-loading-overlay { + background: rgba(30, 41, 59, 0.6); +} + +/* Smaller ball-grid-beat for individual graph overlays */ +.ball-grid-beat-sm { + width: 60px; + grid-gap: 6px; +} + +.ball-grid-beat-sm div { + width: 16px; + height: 16px; +} + +/* Full-page loading overlay for filter updates */ +.filter-loading-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(238, 238, 238, 0.7); + z-index: 1040; + display: flex; + justify-content: center; + align-items: center; +} + +.dark-mode .filter-loading-overlay { + background: rgba(30, 41, 59, 0.7); +} + .ball-grid-beat { width: 120px; display: grid; From 9a446594ad031ccf2b6ab00c58b4264cb786f65d Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Sun, 22 Feb 2026 21:47:38 +0100 Subject: [PATCH 18/26] Implement loading overlays for filter operations and update graph loading behavior --- robotframework_dashboard/js/common.js | 36 ++++++++++++++++--- robotframework_dashboard/js/eventlisteners.js | 4 +-- robotframework_dashboard/js/filter.js | 33 ++++++++++------- .../js/graph_creation/overview.js | 17 ++++----- robotframework_dashboard/js/menu.js | 24 ------------- 5 files changed, 62 insertions(+), 52 deletions(-) diff --git a/robotframework_dashboard/js/common.js b/robotframework_dashboard/js/common.js index 9a6d130..9e30e5a 100644 --- a/robotframework_dashboard/js/common.js +++ b/robotframework_dashboard/js/common.js @@ -178,10 +178,34 @@ function hide_graph_loading(elementId) { // Show loading overlays on multiple graphs, run updateFn, then hide overlays function update_graphs_with_loading(elementIds, updateFn) { elementIds.forEach(id => show_graph_loading(id)); - setTimeout(() => { - updateFn(); - elementIds.forEach(id => hide_graph_loading(id)); - }, 0); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + updateFn(); + elementIds.forEach(id => hide_graph_loading(id)); + }); + }); +} + +// Show a semi-transparent loading overlay for filter/update operations +// Unlike setup_spinner, this does NOT hide sections - it overlays on top of existing content +function show_loading_overlay() { + let overlay = document.getElementById("filterLoadingOverlay"); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = "filterLoadingOverlay"; + overlay.className = "filter-loading-overlay"; + overlay.innerHTML = '
'; + document.body.appendChild(overlay); + } + overlay.style.display = "flex"; +} + +// Hide the filter loading overlay +function hide_loading_overlay() { + const overlay = document.getElementById("filterLoadingOverlay"); + if (overlay) { + $(overlay).fadeOut(200); + } } export { @@ -199,5 +223,7 @@ export { debounce, show_graph_loading, hide_graph_loading, - update_graphs_with_loading + update_graphs_with_loading, + show_loading_overlay, + hide_loading_overlay }; \ No newline at end of file diff --git a/robotframework_dashboard/js/eventlisteners.js b/robotframework_dashboard/js/eventlisteners.js index 53097f7..d1d6a56 100644 --- a/robotframework_dashboard/js/eventlisteners.js +++ b/robotframework_dashboard/js/eventlisteners.js @@ -15,8 +15,8 @@ import { import { arrowDown, arrowRight } from "./variables/svg.js"; import { fullscreenButtons, graphChangeButtons, compareRunIds } from "./variables/graphs.js"; import { toggle_theme, apply_theme_colors } from "./theme.js"; -import { add_alert, show_graph_loading, hide_graph_loading, update_graphs_with_loading } from "./common.js"; -import { setup_data_and_graphs, show_loading_overlay, hide_loading_overlay, update_menu } from "./menu.js"; +import { add_alert, show_graph_loading, hide_graph_loading, update_graphs_with_loading, show_loading_overlay, hide_loading_overlay } from "./common.js"; +import { setup_data_and_graphs, update_menu } from "./menu.js"; import { update_dashboard_graphs } from "./graph_creation/all.js"; import { setup_filtered_data_and_filters, diff --git a/robotframework_dashboard/js/filter.js b/robotframework_dashboard/js/filter.js index 222d0e4..bd6eb7c 100644 --- a/robotframework_dashboard/js/filter.js +++ b/robotframework_dashboard/js/filter.js @@ -1,6 +1,7 @@ import { settings } from './variables/settings.js'; import { compareRunIds } from './variables/graphs.js'; import { runs, suites, tests, keywords, unified_dashboard_title } from './variables/data.js'; +import { show_loading_overlay, hide_loading_overlay } from './common.js'; import { filteredAmount, filteredRuns, @@ -497,19 +498,25 @@ function unselect_checkboxes(checkBoxesToUnselect) { } function handle_overview_latest_version_selection(overviewVersionSelectorList, latestRunByProject) { - const selectedOptions = Array.from( - overviewVersionSelectorList.querySelectorAll("input:checked") - ).map(inputElement => inputElement.value); - if (selectedOptions.includes("All")) { - create_overview_latest_graphs(latestRunByProject); - } else { - const filteredLatestRunByProject = Object.fromEntries( - Object.entries(latestRunByProject) - .filter(([projectName, run]) => selectedOptions.includes(run.project_version ?? "None")) - ); - create_overview_latest_graphs(filteredLatestRunByProject); - } - update_overview_latest_heading(); + show_loading_overlay(); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const selectedOptions = Array.from( + overviewVersionSelectorList.querySelectorAll("input:checked") + ).map(inputElement => inputElement.value); + if (selectedOptions.includes("All")) { + create_overview_latest_graphs(latestRunByProject); + } else { + const filteredLatestRunByProject = Object.fromEntries( + Object.entries(latestRunByProject) + .filter(([projectName, run]) => selectedOptions.includes(run.project_version ?? "None")) + ); + create_overview_latest_graphs(filteredLatestRunByProject); + } + update_overview_latest_heading(); + hide_loading_overlay(); + }); + }); } // this function updates the version select list in the latest runs bar diff --git a/robotframework_dashboard/js/graph_creation/overview.js b/robotframework_dashboard/js/graph_creation/overview.js index 52a33d4..f9a162e 100644 --- a/robotframework_dashboard/js/graph_creation/overview.js +++ b/robotframework_dashboard/js/graph_creation/overview.js @@ -4,6 +4,8 @@ import { transform_file_path, format_duration, debounce, + show_loading_overlay, + hide_loading_overlay, } from '../common.js'; import { update_menu } from '../menu.js'; import { @@ -180,14 +182,13 @@ function create_overview_latest_runs_section() { const percentageSelector = document.getElementById("overviewLatestDurationPercentage"); if (percentageSelector) { percentageSelector.addEventListener('change', () => { - create_overview_latest_graphs(); - }); - } - - const sortSelector = document.getElementById("overviewLatestSectionOrder"); - if (sortSelector) { - sortSelector.addEventListener('change', () => { - create_overview_latest_graphs(); + show_loading_overlay(); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + create_overview_latest_graphs(); + hide_loading_overlay(); + }); + }); }); } diff --git a/robotframework_dashboard/js/menu.js b/robotframework_dashboard/js/menu.js index 9a561a8..2622ace 100644 --- a/robotframework_dashboard/js/menu.js +++ b/robotframework_dashboard/js/menu.js @@ -466,34 +466,10 @@ function setup_spinner(hide) { } } -// Show a semi-transparent loading overlay for filter/update operations -// Unlike setup_spinner, this does NOT hide sections - it overlays on top of existing content -function show_loading_overlay() { - let overlay = document.getElementById("filterLoadingOverlay"); - if (!overlay) { - overlay = document.createElement('div'); - overlay.id = "filterLoadingOverlay"; - overlay.className = "filter-loading-overlay"; - overlay.innerHTML = '
'; - document.body.appendChild(overlay); - } - overlay.style.display = "flex"; -} - -// Hide the filter loading overlay -function hide_loading_overlay() { - const overlay = document.getElementById("filterLoadingOverlay"); - if (overlay) { - $(overlay).fadeOut(200); - } -} - export { setup_menu, setup_data_and_graphs, setup_spinner, - show_loading_overlay, - hide_loading_overlay, update_menu, setup_overview_section_menu_buttons }; \ No newline at end of file From cea366061b2c035c68b98cd30a18493a20c397f5 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Mon, 23 Feb 2026 00:29:09 +0100 Subject: [PATCH 19/26] Refactor fullscreen background color handling and improve loading overlays for graphs --- robotframework_dashboard/css/colors.css | 2 +- robotframework_dashboard/css/components.css | 18 ++-- robotframework_dashboard/js/eventlisteners.js | 95 +++++++++++-------- .../js/graph_creation/suite.js | 11 ++- robotframework_dashboard/js/theme.js | 4 +- 5 files changed, 83 insertions(+), 47 deletions(-) diff --git a/robotframework_dashboard/css/colors.css b/robotframework_dashboard/css/colors.css index 0eb2e15..c24109b 100644 --- a/robotframework_dashboard/css/colors.css +++ b/robotframework_dashboard/css/colors.css @@ -24,7 +24,7 @@ --color-section-card-bg: #ffffff; --color-section-card-text: var(--color-text); --color-section-card-border: transparent; - --color-fullscreen-bg: var(--color-bg); + --color-fullscreen-bg: #ffffff; } .dark-mode { diff --git a/robotframework_dashboard/css/components.css b/robotframework_dashboard/css/components.css index a23c568..703f4fd 100644 --- a/robotframework_dashboard/css/components.css +++ b/robotframework_dashboard/css/components.css @@ -61,7 +61,17 @@ z-index: 10 !important; padding: 20px 20px 20px 20px !important; border-radius: 0px !important; - background-color: var(--color-fullscreen-bg); + background-color: var(--color-fullscreen-bg) !important; +} + +.fullscreen .section-filters { + flex: 0 0 auto; +} + +.fullscreen .graph-body { + flex: 1 1 0; + min-height: 0; + overflow: hidden; } .dropdown-menu { @@ -319,15 +329,11 @@ canvas { display: flex; justify-content: center; align-items: center; - background: rgba(238, 238, 238, 0.6); + background: var(--color-fullscreen-bg); border-radius: 8px; z-index: 10; } -.dark-mode .graph-loading-overlay { - background: rgba(30, 41, 59, 0.6); -} - /* Smaller ball-grid-beat for individual graph overlays */ .ball-grid-beat-sm { width: 60px; diff --git a/robotframework_dashboard/js/eventlisteners.js b/robotframework_dashboard/js/eventlisteners.js index d1d6a56..90c867d 100644 --- a/robotframework_dashboard/js/eventlisteners.js +++ b/robotframework_dashboard/js/eventlisteners.js @@ -432,16 +432,18 @@ function setup_sections_filters() { update_switch_local_storage("switch.runTags", settings.switch.runTags); show_loading_overlay(); requestAnimationFrame(() => { - // create latest and total bars and set visibility - create_overview_latest_graphs(); - update_overview_latest_heading(); - create_overview_total_graphs(); - update_overview_total_heading(); - update_overview_sections_visibility(); - // update all tagged bars - update_overview_version_select_list(); - update_projectbar_visibility(); - hide_loading_overlay(); + requestAnimationFrame(() => { + // create latest and total bars and set visibility + create_overview_latest_graphs(); + update_overview_latest_heading(); + create_overview_total_graphs(); + update_overview_total_heading(); + update_overview_sections_visibility(); + // update all tagged bars + update_overview_version_select_list(); + update_projectbar_visibility(); + hide_loading_overlay(); + }); }); }); document.getElementById("switchRunName").addEventListener("click", function () { @@ -449,16 +451,18 @@ function setup_sections_filters() { update_switch_local_storage("switch.runName", settings.switch.runName); show_loading_overlay(); requestAnimationFrame(() => { - // create latest and total bars and set visibility - create_overview_latest_graphs(); - update_overview_latest_heading(); - create_overview_total_graphs(); - update_overview_total_heading(); - update_overview_sections_visibility(); - // update all named project bars - update_overview_version_select_list(); - update_projectbar_visibility(); - hide_loading_overlay(); + requestAnimationFrame(() => { + // create latest and total bars and set visibility + create_overview_latest_graphs(); + update_overview_latest_heading(); + create_overview_total_graphs(); + update_overview_total_heading(); + update_overview_sections_visibility(); + // update all named project bars + update_overview_version_select_list(); + update_projectbar_visibility(); + hide_loading_overlay(); + }); }); }); document.getElementById("switchLatestRuns").addEventListener("click", function () { @@ -508,7 +512,7 @@ function setup_sections_filters() { ); }); document.getElementById("resetSuiteFolder").addEventListener("click", () => { - update_graphs_with_loading(["suiteFolderDonutGraph"], () => { + update_graphs_with_loading(["suiteFolderDonutGraph", "suiteFolderFailDonutGraph", "suiteStatisticsGraph", "suiteDurationGraph"], () => { update_suite_folder_donut_graph(""); }); }); @@ -654,20 +658,10 @@ function setup_graph_view_buttons() { close.hidden = !entering; content.classList.toggle("fullscreen", entering); document.body.classList.toggle("lock-scroll", entering); - document.documentElement.classList.toggle("html-scroll", !entering) + document.documentElement.classList.toggle("html-scroll", !entering); setTimeout(() => { - if (typeof window[graphFunctionName] === "function") { - window[graphFunctionName](); - } - - if (fullscreenButton === "runDonut") { - update_run_donut_total_graph(); - } else if (fullscreenButton === "suiteFolderDonut") { - update_suite_folder_fail_donut_graph(); - } - hide_graph_loading(canvasId); - + const graphBody = content.querySelector('.graph-body'); let section = null; if (fullscreenButton.includes("suite")) { section = "suite"; @@ -688,6 +682,24 @@ function setup_graph_view_buttons() { originalContainer.insertBefore(filters, originalContainer.firstChild); } } + + // Lock graph-body height to prevent Chart.js resize feedback loop + if (entering && graphBody) { + graphBody.style.height = graphBody.clientHeight + 'px'; + } else if (graphBody) { + graphBody.style.height = ''; + } + + if (typeof window[graphFunctionName] === "function") { + window[graphFunctionName](); + } + + if (fullscreenButton === "runDonut") { + update_run_donut_total_graph(); + } else if (fullscreenButton === "suiteFolderDonut") { + update_suite_folder_fail_donut_graph(); + } + hide_graph_loading(canvasId); }, 0); }; @@ -705,6 +717,13 @@ function setup_graph_view_buttons() { window.scrollTo({ top: lastScrollY, behavior: "auto" }); }); } + // close fullscreen on Escape key + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && inFullscreen) { + const closeBtn = document.querySelector(`[id$="Close"]:not([hidden])`); + if (closeBtn) closeBtn.click(); + } + }); // has to be added after the creation of the sections and graphs document.getElementById("suiteFolderDonutGoUp").addEventListener("click", function () { function remove_last_folder(path) { @@ -714,7 +733,7 @@ function setup_graph_view_buttons() { } const folder = remove_last_folder(previousFolder) if (previousFolder == "" && folder == "") { return } - update_graphs_with_loading(["suiteFolderDonutGraph"], () => { + update_graphs_with_loading(["suiteFolderDonutGraph", "suiteFolderFailDonutGraph", "suiteStatisticsGraph", "suiteDurationGraph"], () => { update_suite_folder_donut_graph(folder) }); }); @@ -733,7 +752,7 @@ function setup_graph_view_buttons() { }); document.getElementById("onlyFailedFolders").addEventListener("change", () => { onlyFailedFolders = !onlyFailedFolders; - update_graphs_with_loading(["suiteFolderDonutGraph"], () => { + update_graphs_with_loading(["suiteFolderDonutGraph", "suiteFolderFailDonutGraph", "suiteStatisticsGraph", "suiteDurationGraph"], () => { update_suite_folder_donut_graph(""); }); }); @@ -943,8 +962,10 @@ function setup_overview_order_filters() { select.addEventListener('change', (e) => { show_loading_overlay(); requestAnimationFrame(() => { - create_overview_latest_graphs(); - hide_loading_overlay(); + requestAnimationFrame(() => { + create_overview_latest_graphs(); + hide_loading_overlay(); + }); }); }); } else { diff --git a/robotframework_dashboard/js/graph_creation/suite.js b/robotframework_dashboard/js/graph_creation/suite.js index 8178e26..55a28ee 100644 --- a/robotframework_dashboard/js/graph_creation/suite.js +++ b/robotframework_dashboard/js/graph_creation/suite.js @@ -8,6 +8,7 @@ import { settings } from '../variables/settings.js'; import { inFullscreen, inFullscreenGraph, filteredSuites } from '../variables/globals.js'; import { create_chart, update_chart } from './chart_factory.js'; import { build_most_failed_config, build_most_time_consuming_config } from './config_helpers.js'; +import { update_graphs_with_loading } from '../common.js'; // build config for suite folder donut graph function _build_suite_folder_donut_config(folder) { @@ -34,7 +35,10 @@ function _build_suite_folder_donut_config(folder) { config.options.onClick = (event) => { if (event.chart.tooltip.title) { setTimeout(() => { - update_suite_folder_donut_graph(event.chart.tooltip.title.join('')); + update_graphs_with_loading( + ["suiteFolderDonutGraph", "suiteFolderFailDonutGraph", "suiteStatisticsGraph", "suiteDurationGraph"], + () => { update_suite_folder_donut_graph(event.chart.tooltip.title.join('')); } + ); }, 0); } }; @@ -104,7 +108,10 @@ function _build_suite_folder_fail_donut_config() { config.options.onClick = (event) => { if (event.chart.tooltip.title) { setTimeout(() => { - update_suite_folder_donut_graph(event.chart.tooltip.title.join('')); + update_graphs_with_loading( + ["suiteFolderDonutGraph", "suiteFolderFailDonutGraph", "suiteStatisticsGraph", "suiteDurationGraph"], + () => { update_suite_folder_donut_graph(event.chart.tooltip.title.join('')); } + ); }, 0); } }; diff --git a/robotframework_dashboard/js/theme.js b/robotframework_dashboard/js/theme.js index 69e3ece..8c2a947 100644 --- a/robotframework_dashboard/js/theme.js +++ b/robotframework_dashboard/js/theme.js @@ -175,7 +175,9 @@ function apply_theme_colors() { // Set CSS custom properties - background color root.style.setProperty('--color-bg', finalColors.background); - root.style.setProperty('--color-fullscreen-bg', finalColors.background); + // Use an opaque version of the card color for fullscreen background + const opaqueCard = finalColors.card.replace(/rgba\(([^,]+),([^,]+),([^,]+),[^)]+\)/, 'rgba($1,$2,$3, 1)'); + root.style.setProperty('--color-fullscreen-bg', opaqueCard); root.style.setProperty('--color-modal-bg', finalColors.background); // Set CSS custom properties - card color (propagate to all card-like surfaces) From 4d5347c3bca8cd7ac38b4a88ff4b35a408df7021 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Tue, 24 Feb 2026 01:24:23 +0100 Subject: [PATCH 20/26] Enhance tooltip functionality across various graphs - Implemented detailed tooltips for test statistics, including status, duration, and messages. - Added support for line graph view in test statistics with appropriate tooltips. - Refactored tooltip metadata handling for failed, flaky, and time-consuming data. - Introduced utility functions for building and looking up tooltip metadata. - Updated graph configurations to utilize new tooltip features and improve user experience. - Enhanced filtering logic for test statistics to support new visualization options. --- .../js/graph_creation/compare.js | 17 +- .../js/graph_creation/config_helpers.js | 36 ++- .../js/graph_creation/run.js | 15 ++ .../js/graph_creation/suite.js | 32 +++ .../js/graph_creation/test.js | 155 ++++++++++++- .../js/graph_data/failed.js | 11 +- .../js/graph_data/flaky.js | 9 +- .../js/graph_data/messages.js | 8 +- .../js/graph_data/statistics.js | 208 +++++++++++++----- .../js/graph_data/time_consuming.js | 20 +- .../js/graph_data/tooltip_helpers.js | 67 ++++++ .../js/variables/graphmetadata.js | 3 +- .../js/variables/information.js | 6 + 13 files changed, 518 insertions(+), 69 deletions(-) create mode 100644 robotframework_dashboard/js/graph_data/tooltip_helpers.js diff --git a/robotframework_dashboard/js/graph_creation/compare.js b/robotframework_dashboard/js/graph_creation/compare.js index f96f444..d8b6bc4 100644 --- a/robotframework_dashboard/js/graph_creation/compare.js +++ b/robotframework_dashboard/js/graph_creation/compare.js @@ -3,6 +3,7 @@ import { get_compare_suite_duration_data } from "../graph_data/duration.js"; import { get_graph_config } from "../graph_data/graph_config.js"; import { update_height } from "../graph_data/helpers.js"; import { open_log_file } from "../log.js"; +import { format_duration } from "../common.js"; import { filteredRuns, filteredSuites, filteredTests } from "../variables/globals.js"; import { settings } from "../variables/settings.js"; import { create_chart, update_chart } from "./chart_factory.js"; @@ -32,11 +33,25 @@ function _build_compare_tests_config() { const data = get_test_statistics_data(filteredTests); const graphData = data[0] const runStarts = data[1] + const testMetaMap = data[2] var config = get_graph_config("timeline", graphData, "", "Run", "Test"); config.options.plugins.tooltip = { callbacks: { label: function (context) { - return runStarts[context.raw.x[0]]; + const runLabel = runStarts[context.raw.x[0]]; + const testLabel = context.raw.y; + const key = `${testLabel}::${context.raw.x[0]}`; + const meta = testMetaMap[key]; + const lines = [`Run: ${runLabel}`]; + if (meta) { + lines.push(`Status: ${meta.status}`); + lines.push(`Duration: ${format_duration(parseFloat(meta.elapsed_s))}`); + if (meta.message) { + const truncated = meta.message.length > 120 ? meta.message.substring(0, 120) + "..." : meta.message; + lines.push(`Message: ${truncated}`); + } + } + return lines; }, }, }; diff --git a/robotframework_dashboard/js/graph_creation/config_helpers.js b/robotframework_dashboard/js/graph_creation/config_helpers.js index fc81102..f1a7859 100644 --- a/robotframework_dashboard/js/graph_creation/config_helpers.js +++ b/robotframework_dashboard/js/graph_creation/config_helpers.js @@ -9,12 +9,29 @@ import { settings } from "../variables/settings.js"; import { inFullscreen, inFullscreenGraph } from "../variables/globals.js"; // Shared timeline scale/tooltip config used by most failed, flaky, and time consuming graphs -function _apply_timeline_defaults(config, callbackData, callbackLookup = null) { +function _apply_timeline_defaults(config, callbackData, pointMeta = null, dataType = null, callbackLookup = null) { const lookupFn = callbackLookup || ((val) => callbackData[val]); config.options.plugins.tooltip = { callbacks: { label: function (context) { - return lookupFn(context.raw.x[0]); + const runLabel = lookupFn(context.raw.x[0]); + if (!pointMeta) return runLabel; + const testLabel = context.raw.y || context.dataset.label; + const key = `${testLabel}::${context.raw.x[0]}`; + const meta = pointMeta[key]; + if (!meta) return `Run: ${runLabel}`; + const lines = [`Run: ${runLabel}`]; + if (dataType === "test") { + lines.push(`Status: ${meta.status}`); + } else if (dataType === "suite") { + lines.push(`Passed: ${meta.passed}, Failed: ${meta.failed}, Skipped: ${meta.skipped}`); + } + lines.push(`Duration: ${format_duration(parseFloat(meta.elapsed_s))}`); + if (dataType === "test" && meta.message) { + const truncated = meta.message.length > 120 ? meta.message.substring(0, 120) + "..." : meta.message; + lines.push(`Message: ${truncated}`); + } + return lines; }, }, }; @@ -46,6 +63,7 @@ function build_most_failed_config(graphKey, dataType, dataLabel, filteredData, i const data = get_most_failed_data(dataType, graphType, filteredData, isRecent); const graphData = data[0]; const callbackData = data[1]; + const pointMeta = data[2] || null; const limit = inFullscreen && inFullscreenGraph.includes(graphKey) ? 50 : 10; var config; if (graphType == "bar") { @@ -61,7 +79,7 @@ function build_most_failed_config(graphKey, dataType, dataLabel, filteredData, i delete config.options.onClick; } else if (graphType == "timeline") { config = get_graph_config("timeline", graphData, `Top ${limit}`, "Run", dataLabel); - _apply_timeline_defaults(config, callbackData); + _apply_timeline_defaults(config, callbackData, pointMeta, dataType); } update_height(`${graphKey}Vertical`, config.data.labels.length, graphType); return config; @@ -74,6 +92,7 @@ function build_most_flaky_config(graphKey, dataType, filteredData, ignoreSkipsVa const data = get_most_flaky_data(dataType, graphType, filteredData, ignoreSkipsVal, isRecent, limit); const graphData = data[0]; const callbackData = data[1]; + const pointMeta = data[2] || null; var config; if (graphType == "bar") { config = get_graph_config("bar", graphData, `Top ${limit}`, "Test", "Status Flips"); @@ -81,7 +100,7 @@ function build_most_flaky_config(graphKey, dataType, filteredData, ignoreSkipsVa delete config.options.onClick; } else if (graphType == "timeline") { config = get_graph_config("timeline", graphData, `Top ${limit}`, "Run", "Test"); - _apply_timeline_defaults(config, callbackData); + _apply_timeline_defaults(config, callbackData, pointMeta, dataType); } update_height(`${graphKey}Vertical`, config.data.labels.length, graphType); return config; @@ -130,7 +149,14 @@ function build_most_time_consuming_config(graphKey, dataType, dataLabel, filtere ? callbackData.aliases[runIndex] : runStart; if (!info) return `${displayName}: (no data)`; - return detailFormatter(info, displayName); + const lines = [ + `Run: ${displayName}`, + `Duration: ${format_duration(info.duration)}`, + ]; + if (info.passed !== undefined) { + lines.push(`Passed: ${info.passed}, Failed: ${info.failed}, Skipped: ${info.skipped}`); + } + return lines; } }, }; diff --git a/robotframework_dashboard/js/graph_creation/run.js b/robotframework_dashboard/js/graph_creation/run.js index f07083c..a274332 100644 --- a/robotframework_dashboard/js/graph_creation/run.js +++ b/robotframework_dashboard/js/graph_creation/run.js @@ -4,6 +4,7 @@ import { get_donut_graph_data, get_donut_total_graph_data } from '../graph_data/ import { get_duration_graph_data } from '../graph_data/duration.js'; import { get_heatmap_graph_data } from '../graph_data/heatmap.js'; import { get_stats_data } from '../graph_data/stats.js'; +import { build_tooltip_meta, lookup_tooltip_meta, format_status } from '../graph_data/tooltip_helpers.js'; import { format_duration } from '../common.js'; import { open_log_file } from '../log.js'; import { settings } from '../variables/settings.js'; @@ -22,6 +23,7 @@ import { create_chart, update_chart } from './chart_factory.js'; function _build_run_statistics_config() { const data = get_statistics_graph_data("run", settings.graphTypes.runStatisticsGraphType, filteredRuns); const graphData = data[0] + const tooltipMeta = build_tooltip_meta(filteredRuns); var config; if (settings.graphTypes.runStatisticsGraphType == "line") { config = get_graph_config("line", graphData, "", "Date", "Amount", false); @@ -30,6 +32,13 @@ function _build_run_statistics_config() { } else if (settings.graphTypes.runStatisticsGraphType == "percentages") { config = get_graph_config("bar", graphData, "", "Run", "Percentage"); } + config.options.plugins.tooltip = config.options.plugins.tooltip || {}; + config.options.plugins.tooltip.callbacks = config.options.plugins.tooltip.callbacks || {}; + config.options.plugins.tooltip.callbacks.footer = function(tooltipItems) { + const meta = lookup_tooltip_meta(tooltipMeta, tooltipItems); + if (meta) return `Duration: ${format_duration(meta.elapsed_s)}`; + return ''; + }; if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } return config; } @@ -94,6 +103,7 @@ function create_run_stats_graph() { // build config for run duration graph function _build_run_duration_config() { var graphData = get_duration_graph_data("run", settings.graphTypes.runDurationGraphType, "elapsed_s", filteredRuns); + const tooltipMeta = build_tooltip_meta(filteredRuns); var config; if (settings.graphTypes.runDurationGraphType == "bar") { const limit = inFullscreen && inFullscreenGraph.includes("runDuration") ? 100 : 30; @@ -101,6 +111,11 @@ function _build_run_duration_config() { } else if (settings.graphTypes.runDurationGraphType == "line") { config = get_graph_config("line", graphData, "", "Date", "Duration"); } + config.options.plugins.tooltip.callbacks.footer = function(tooltipItems) { + const meta = lookup_tooltip_meta(tooltipMeta, tooltipItems); + if (meta) return format_status(meta); + return ''; + }; if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } return config; } diff --git a/robotframework_dashboard/js/graph_creation/suite.js b/robotframework_dashboard/js/graph_creation/suite.js index 55a28ee..90a89d0 100644 --- a/robotframework_dashboard/js/graph_creation/suite.js +++ b/robotframework_dashboard/js/graph_creation/suite.js @@ -2,7 +2,10 @@ import { get_donut_folder_graph_data, get_donut_folder_fail_graph_data } from '. import { get_statistics_graph_data } from '../graph_data/statistics.js'; import { get_duration_graph_data } from '../graph_data/duration.js'; import { get_graph_config } from '../graph_data/graph_config.js'; +import { build_tooltip_meta, lookup_tooltip_meta, format_status } from '../graph_data/tooltip_helpers.js'; +import { exclude_from_suite_data } from '../graph_data/helpers.js'; import { setup_suites_in_suite_select } from '../filter.js'; +import { format_duration } from '../common.js'; import { dataLabelConfig } from '../variables/chartconfig.js'; import { settings } from '../variables/settings.js'; import { inFullscreen, inFullscreenGraph, filteredSuites } from '../variables/globals.js'; @@ -131,6 +134,10 @@ function _build_suite_statistics_config() { const data = get_statistics_graph_data("suite", settings.graphTypes.suiteStatisticsGraphType, filteredSuites); const graphData = data[0] const callbackData = data[1] + const suiteSelectSuites = document.getElementById("suiteSelectSuites").value; + const isCombined = suiteSelectSuites === "All Suites Combined"; + const relevantSuites = filteredSuites.filter(s => !exclude_from_suite_data("suite", s)); + const tooltipMeta = build_tooltip_meta(relevantSuites, 'elapsed_s', isCombined); var config; if (settings.graphTypes.suiteStatisticsGraphType == "line") { config = get_graph_config("line", graphData, "", "Date", "amount", false); @@ -138,6 +145,11 @@ function _build_suite_statistics_config() { callbacks: { title: function (tooltipItem) { return `${tooltipItem[0].label}: ${callbackData[tooltipItem[0].dataIndex]}` + }, + footer: function(tooltipItems) { + const meta = lookup_tooltip_meta(tooltipMeta, tooltipItems); + if (meta) return `Duration: ${format_duration(meta.elapsed_s)}`; + return ''; } } } @@ -149,6 +161,11 @@ function _build_suite_statistics_config() { callbacks: { title: function (tooltipItem) { return `${tooltipItem[0].label}: ${callbackData[tooltipItem[0].dataIndex]}` + }, + footer: function(tooltipItems) { + const meta = lookup_tooltip_meta(tooltipMeta, tooltipItems); + if (meta) return `Duration: ${format_duration(meta.elapsed_s)}`; + return ''; } } } @@ -160,6 +177,11 @@ function _build_suite_statistics_config() { callbacks: { title: function (tooltipItem) { return `${tooltipItem[0].label}: ${callbackData[tooltipItem[0].dataIndex]}` + }, + footer: function(tooltipItems) { + const meta = lookup_tooltip_meta(tooltipMeta, tooltipItems); + if (meta) return `Duration: ${format_duration(meta.elapsed_s)}`; + return ''; } } } @@ -171,6 +193,11 @@ function _build_suite_statistics_config() { // build config for suite duration graph function _build_suite_duration_config() { const graphData = get_duration_graph_data("suite", settings.graphTypes.suiteDurationGraphType, "elapsed_s", filteredSuites); + const suiteSelectSuites = document.getElementById("suiteSelectSuites").value; + const isCombined = suiteSelectSuites === "All Suites Combined"; + // Filter suites the same way get_duration_graph_data does, so tooltip meta matches + const relevantSuites = filteredSuites.filter(s => !exclude_from_suite_data("suite", s)); + const tooltipMeta = build_tooltip_meta(relevantSuites, 'elapsed_s', isCombined); var config; if (settings.graphTypes.suiteDurationGraphType == "bar") { const limit = inFullscreen && inFullscreenGraph.includes("suiteDuration") ? 100 : 30; @@ -178,6 +205,11 @@ function _build_suite_duration_config() { } else if (settings.graphTypes.suiteDurationGraphType == "line") { config = get_graph_config("line", graphData, "", "Date", "Duration"); } + config.options.plugins.tooltip.callbacks.footer = function(tooltipItems) { + const meta = lookup_tooltip_meta(tooltipMeta, tooltipItems); + if (meta) return format_status(meta); + return ''; + }; if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } return config; } diff --git a/robotframework_dashboard/js/graph_creation/test.js b/robotframework_dashboard/js/graph_creation/test.js index 7a91ad3..52fad20 100644 --- a/robotframework_dashboard/js/graph_creation/test.js +++ b/robotframework_dashboard/js/graph_creation/test.js @@ -1,10 +1,12 @@ -import { get_test_statistics_data } from "../graph_data/statistics.js"; +import { get_test_statistics_data, get_test_statistics_line_data } from "../graph_data/statistics.js"; import { get_duration_graph_data } from "../graph_data/duration.js"; import { get_messages_data } from "../graph_data/messages.js"; import { get_duration_deviation_data } from "../graph_data/duration_deviation.js"; import { get_graph_config } from "../graph_data/graph_config.js"; +import { build_tooltip_meta, lookup_tooltip_meta, format_status } from "../graph_data/tooltip_helpers.js"; import { update_height } from "../graph_data/helpers.js"; import { open_log_file } from "../log.js"; +import { format_duration } from "../common.js"; import { inFullscreen, inFullscreenGraph, ignoreSkips, ignoreSkipsRecent, filteredTests } from "../variables/globals.js"; import { settings } from "../variables/settings.js"; import { create_chart, update_chart } from "./chart_factory.js"; @@ -12,14 +14,38 @@ import { build_most_failed_config, build_most_flaky_config, build_most_time_cons // build config for test statistics graph function _build_test_statistics_config() { + const graphType = settings.graphTypes.testStatisticsGraphType || "timeline"; + + if (graphType === "line") { + return _build_test_statistics_line_config(); + } + return _build_test_statistics_timeline_config(); +} + +// build config for test statistics timeline view (existing behavior + rich tooltip) +function _build_test_statistics_timeline_config() { const data = get_test_statistics_data(filteredTests); const graphData = data[0] const runStarts = data[1] + const testMetaMap = data[2] var config = get_graph_config("timeline", graphData, "", "Run", "Test"); config.options.plugins.tooltip = { callbacks: { label: function (context) { - return runStarts[context.raw.x[0]]; + const runLabel = runStarts[context.raw.x[0]]; + const testLabel = context.raw.y; + const key = `${testLabel}::${context.raw.x[0]}`; + const meta = testMetaMap[key]; + const lines = [`Run: ${runLabel}`]; + if (meta) { + lines.push(`Status: ${meta.status}`); + lines.push(`Duration: ${format_duration(parseFloat(meta.elapsed_s))}`); + if (meta.message) { + const truncated = meta.message.length > 120 ? meta.message.substring(0, 120) + "..." : meta.message; + lines.push(`Message: ${truncated}`); + } + } + return lines; }, }, }; @@ -47,12 +73,110 @@ function _build_test_statistics_config() { return config; } +// build config for test statistics scatter view (timestamp-based x-axis, one row per test) +function _build_test_statistics_line_config() { + const result = get_test_statistics_line_data(filteredTests); + const testLabels = result.labels; + const pointMeta = result.datasets.length > 0 ? result.datasets[0]._pointMeta : []; + // Remove _pointMeta from dataset to avoid Chart.js issues + if (result.datasets.length > 0) { + delete result.datasets[0]._pointMeta; + } + const config = { + type: "scatter", + data: { datasets: result.datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: settings.show.animation + ? { + delay: (ctx) => { + const dataLength = ctx.chart.data.datasets.reduce( + (a, b) => (b.data.length > a.data.length ? b : a) + ).data.length; + return ctx.dataIndex * (settings.show.duration / dataLength); + }, + } + : false, + scales: { + x: { + type: "time", + time: { tooltipFormat: "dd.MM.yyyy HH:mm:ss" }, + ticks: { + minRotation: 45, + maxRotation: 45, + maxTicksLimit: 10, + display: settings.show.dateLabels, + }, + title: { + display: settings.show.axisTitles, + text: "Date", + }, + }, + y: { + title: { + display: settings.show.axisTitles, + text: "Test", + }, + min: -0.5, + max: testLabels.length - 0.5, + reverse: true, + afterBuildTicks: function (axis) { + axis.ticks = testLabels.map((_, i) => ({ value: i })); + }, + ticks: { + autoSkip: false, + callback: function (value) { + return testLabels[value] ? testLabels[value].slice(0, 40) : ""; + }, + }, + }, + }, + plugins: { + legend: { display: false }, + datalabels: { display: false }, + tooltip: { + enabled: true, + mode: "nearest", + intersect: true, + callbacks: { + title: function (tooltipItems) { + const idx = tooltipItems[0].dataIndex; + if (!pointMeta[idx]) return ""; + return pointMeta[idx].testLabel; + }, + label: function (context) { + const idx = context.dataIndex; + if (!pointMeta[idx]) return ""; + const point = pointMeta[idx]; + const runLabel = settings.show.aliases ? point.runAlias : point.runStart; + const lines = [ + `Status: ${point.status}`, + `Run: ${runLabel}`, + `Duration: ${format_duration(parseFloat(point.elapsed))}`, + ]; + if (point.message) { + const truncated = point.message.length > 120 ? point.message.substring(0, 120) + "..." : point.message; + lines.push(`Message: ${truncated}`); + } + return lines; + }, + }, + }, + }, + }, + }; + update_height("testStatisticsVertical", testLabels.length, "timeline"); + return config; +} + // function to create test statistics graph in the test section function create_test_statistics_graph() { create_chart("testStatisticsGraph", _build_test_statistics_config); } // build config for test duration graph function _build_test_duration_config() { var graphData = get_duration_graph_data("test", settings.graphTypes.testDurationGraphType, "elapsed_s", filteredTests); + const tooltipMeta = build_tooltip_meta(filteredTests); var config; if (settings.graphTypes.testDurationGraphType == "bar") { const limit = inFullscreen && inFullscreenGraph.includes("testDuration") ? 100 : 30; @@ -60,6 +184,16 @@ function _build_test_duration_config() { } else if (settings.graphTypes.testDurationGraphType == "line") { config = get_graph_config("line", graphData, "", "Date", "Duration"); } + config.options.plugins.tooltip.callbacks.footer = function(tooltipItems) { + const meta = lookup_tooltip_meta(tooltipMeta, tooltipItems); + if (!meta) return ''; + const lines = [`Status: ${format_status(meta)}`]; + if (meta.message) { + const truncated = meta.message.length > 120 ? meta.message.substring(0, 120) + '...' : meta.message; + lines.push(`Message: ${truncated}`); + } + return lines; + }; if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } return config; } @@ -72,6 +206,7 @@ function _build_test_messages_config() { const data = get_messages_data("test", settings.graphTypes.testMessagesGraphType, filteredTests); const graphData = data[0]; const callbackData = data[1]; + const pointMeta = data[2] || null; var config; const limit = inFullscreen && inFullscreenGraph.includes("testMessages") ? 50 : 10; if (settings.graphTypes.testMessagesGraphType == "bar") { @@ -103,7 +238,21 @@ function _build_test_messages_config() { config.options.plugins.tooltip = { callbacks: { label: function (context) { - return callbackData[context.raw.x[0]]; + const runLabel = callbackData[context.raw.x[0]]; + const testLabel = context.raw.y; + const key = `${testLabel}::${context.raw.x[0]}`; + const meta = pointMeta ? pointMeta[key] : null; + if (!meta) return `Run: ${runLabel}`; + const lines = [ + `Run: ${runLabel}`, + `Status: ${meta.status}`, + `Duration: ${format_duration(parseFloat(meta.elapsed_s))}`, + ]; + if (meta.message) { + const truncated = meta.message.length > 120 ? meta.message.substring(0, 120) + "..." : meta.message; + lines.push(`Message: ${truncated}`); + } + return lines; }, }, }; diff --git a/robotframework_dashboard/js/graph_data/failed.js b/robotframework_dashboard/js/graph_data/failed.js index fc90c54..1b16929 100644 --- a/robotframework_dashboard/js/graph_data/failed.js +++ b/robotframework_dashboard/js/graph_data/failed.js @@ -84,6 +84,7 @@ function get_most_failed_data(dataType, graphType, filteredData, recent) { const runStarts = Array.from(runStartsSet).sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); let datasets = []; let runAxis = 0; + const pointMeta = {}; for (const runStart of runStarts) { for (const label of labels) { const foundValues = filteredData.filter(value => @@ -93,6 +94,14 @@ function get_most_failed_data(dataType, graphType, filteredData, recent) { ); if (foundValues.length > 0) { const value = foundValues[0]; + pointMeta[`${label}::${runAxis}`] = { + status: "FAIL", + elapsed_s: value.elapsed_s || 0, + message: value.message || '', + passed: value.passed || 0, + failed: value.failed || 0, + skipped: value.skipped || 0, + }; datasets.push({ label: label, data: [{ x: [runAxis, runAxis + 1], y: label }], @@ -109,7 +118,7 @@ function get_most_failed_data(dataType, graphType, filteredData, recent) { labels, datasets, }; - return [graphData, runStartsArray]; + return [graphData, runStartsArray, pointMeta]; } } diff --git a/robotframework_dashboard/js/graph_data/flaky.js b/robotframework_dashboard/js/graph_data/flaky.js index e72fb82..07baa90 100644 --- a/robotframework_dashboard/js/graph_data/flaky.js +++ b/robotframework_dashboard/js/graph_data/flaky.js @@ -98,6 +98,7 @@ function get_most_flaky_data(dataType, graphType, filteredData, ignore, recent, } var datasets = []; var runAxis = 0; + const pointMeta = {}; runStarts = runStarts.sort((a, b) => new Date(a).getTime() - new Date(b).getTime()) for (const runStart of runStarts) { for (const label of labels) { @@ -112,6 +113,12 @@ function get_most_flaky_data(dataType, graphType, filteredData, ignore, recent, } if (foundValues.length > 0) { var value = foundValues[0]; + const statusName = value.passed == 1 ? "PASS" : value.failed == 1 ? "FAIL" : "SKIP"; + pointMeta[`${label}::${runAxis}`] = { + status: statusName, + elapsed_s: value.elapsed_s || 0, + message: value.message || '', + }; if (value.passed == 1) { datasets.push({ label: label, @@ -143,7 +150,7 @@ function get_most_flaky_data(dataType, graphType, filteredData, ignore, recent, labels: labels, datasets: datasets, }; - return [graphData, runStarts]; + return [graphData, runStarts, pointMeta]; } } diff --git a/robotframework_dashboard/js/graph_data/messages.js b/robotframework_dashboard/js/graph_data/messages.js index 771a044..117a7b9 100644 --- a/robotframework_dashboard/js/graph_data/messages.js +++ b/robotframework_dashboard/js/graph_data/messages.js @@ -81,6 +81,7 @@ function get_messages_data(dataType, graphType, filteredData) { const runStarts = Array.from(runStartsSet).sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); var datasets = []; let runAxis = 0; + const pointMeta = {}; function check_label(message, label) { return !message_config.includes("placeholder_message_config") ? matches_message_config(message, label) @@ -91,6 +92,11 @@ function get_messages_data(dataType, graphType, filteredData) { const foundValues = filteredData.filter(value => check_label(value.message, label) && value.run_start === runStart); if (foundValues.length > 0) { const value = foundValues[0]; + pointMeta[`${label}::${runAxis}`] = { + status: value.passed == 1 ? "PASS" : value.failed == 1 ? "FAIL" : "SKIP", + elapsed_s: value.elapsed_s || 0, + message: value.message || '', + }; datasets.push({ label: label, data: [{ x: [runAxis, runAxis + 1], y: label }], @@ -107,7 +113,7 @@ function get_messages_data(dataType, graphType, filteredData) { labels, datasets, }; - return [graphData, runStartsArray]; + return [graphData, runStartsArray, pointMeta]; } } diff --git a/robotframework_dashboard/js/graph_data/statistics.js b/robotframework_dashboard/js/graph_data/statistics.js index dedfa6e..f3f9bd8 100644 --- a/robotframework_dashboard/js/graph_data/statistics.js +++ b/robotframework_dashboard/js/graph_data/statistics.js @@ -108,49 +108,61 @@ function get_statistics_graph_data(dataType, graphType, filteredData) { return [statisticsData, names]; } +function _get_test_filters() { + return { + suiteSelectTests: document.getElementById("suiteSelectTests").value, + testSelect: document.getElementById("testSelect").value, + testTagsSelect: document.getElementById("testTagsSelect").value, + testOnlyChanges: document.getElementById("testOnlyChanges").checked, + testNoChanges: document.getElementById("testNoChanges").value, + compareOnlyChanges: document.getElementById("compareOnlyChanges").checked, + compareNoChanges: document.getElementById("compareNoChanges").value, + selectedRuns: [...new Set( + compareRunIds + .map(id => document.getElementById(id).value) + .filter(val => val !== "None") + )], + }; +} + +function _get_test_label(test) { + if (settings.menu.dashboard) { + return settings.switch.suitePathsTestSection ? test.full_name : test.name; + } else if (settings.menu.compare) { + return settings.switch.suitePathsCompareSection ? test.full_name : test.name; + } + return test.name; +} + +function _should_skip_test(test, filters) { + if (settings.menu.dashboard) { + const testBaseName = test.name; + if (filters.suiteSelectTests !== "All") { + const expectedFull = `${filters.suiteSelectTests}.${testBaseName}`; + const isMatch = settings.switch.suitePathsTestSection + ? test.full_name === expectedFull + : test.full_name.includes(`.${filters.suiteSelectTests}.${testBaseName}`) || test.full_name === expectedFull; + if (!isMatch) return true; + } + if (filters.testSelect !== "All" && testBaseName !== filters.testSelect) return true; + if (filters.testTagsSelect !== "All") { + const tagList = test.tags.replace(/\[|\]/g, "").split(","); + if (!tagList.includes(filters.testTagsSelect)) return true; + } + } else if (settings.menu.compare) { + if (!(filters.selectedRuns.includes(test.run_start) || filters.selectedRuns.includes(test.run_alias))) return true; + } + return false; +} + function get_test_statistics_data(filteredTests) { - const suiteSelectTests = document.getElementById("suiteSelectTests").value; - const testSelect = document.getElementById("testSelect").value; - const testTagsSelect = document.getElementById("testTagsSelect").value; - const testOnlyChanges = document.getElementById("testOnlyChanges").checked; - const testNoChanges = document.getElementById("testNoChanges").value; - const compareOnlyChanges = document.getElementById("compareOnlyChanges").checked; - const compareNoChanges = document.getElementById("compareNoChanges").value; - const selectedRuns = [...new Set( - compareRunIds - .map(id => document.getElementById(id).value) - .filter(val => val !== "None") - )]; + const filters = _get_test_filters(); const [runStarts, datasets] = [[], []]; + const testMetaMap = {}; var labels = []; - function getTestLabel(test) { - if (settings.menu.dashboard) { - return settings.switch.suitePathsTestSection ? test.full_name : test.name; - } else if (settings.menu.compare) { - return settings.switch.suitePathsCompareSection ? test.full_name : test.name; - } - return test.name; - } for (const test of filteredTests) { - if (settings.menu.dashboard) { - const testBaseName = test.name; - if (suiteSelectTests !== "All") { - const expectedFull = `${suiteSelectTests}.${testBaseName}`; - const isMatch = settings.switch.suitePathsTestSection - ? test.full_name === expectedFull - : test.full_name.includes(`.${suiteSelectTests}.${testBaseName}`) || test.full_name === expectedFull; - if (!isMatch) continue; - } - if (testSelect !== "All" && testBaseName !== testSelect) continue; - - if (testTagsSelect !== "All") { - const tagList = test.tags.replace(/\[|\]/g, "").split(","); - if (!tagList.includes(testTagsSelect)) continue; - } - } else if (settings.menu.compare) { - if (!(selectedRuns.includes(test.run_start) || selectedRuns.includes(test.run_alias))) continue; - } - const testLabel = getTestLabel(test); + if (_should_skip_test(test, filters)) continue; + const testLabel = _get_test_label(test); if (!labels.includes(testLabel)) { labels.push(testLabel); } @@ -160,6 +172,7 @@ function get_test_statistics_data(filteredTests) { runStarts.push(runId); } const runAxis = runStarts.indexOf(runId); + const statusName = test.passed == 1 ? "PASS" : test.failed == 1 ? "FAIL" : "SKIP"; const config = test.passed == 1 ? passedConfig : test.failed == 1 ? failedConfig : @@ -171,22 +184,27 @@ function get_test_statistics_data(filteredTests) { ...config, }); } + testMetaMap[`${testLabel}::${runAxis}`] = { + message: test.message || '', + elapsed_s: test.elapsed_s || 0, + status: statusName, + }; } let finalDatasets = convert_timeline_data(datasets); - if ((testOnlyChanges && testNoChanges !== "All") || (compareOnlyChanges && compareNoChanges !== "All")) { + if ((filters.testOnlyChanges && filters.testNoChanges !== "All") || (filters.compareOnlyChanges && filters.compareNoChanges !== "All")) { // If both filters are set, return empty data, as nothing can match this - return [{ labels: [], datasets: [] }, []]; + return [{ labels: [], datasets: [] }, [], {}]; } - if (testOnlyChanges || compareOnlyChanges || testNoChanges !== "All" || compareNoChanges !== "All") { + if (filters.testOnlyChanges || filters.compareOnlyChanges || filters.testNoChanges !== "All" || filters.compareNoChanges !== "All") { const countMap = {}; for (const ds of finalDatasets) { countMap[ds.label] = (countMap[ds.label] || 0) + 1; } let labelsToKeep = new Set(); - if (testOnlyChanges || compareOnlyChanges) { + if (filters.testOnlyChanges || filters.compareOnlyChanges) { // Only keep the tests that have more than 1 status change labelsToKeep = new Set(Object.keys(countMap).filter(label => countMap[label] > 1)); - } else if (testNoChanges !== "All" || compareNoChanges !== "All") { + } else if (filters.testNoChanges !== "All" || filters.compareNoChanges !== "All") { const countMap = {}; for (const ds of finalDatasets) { countMap[ds.label] = (countMap[ds.label] || 0) + 1; @@ -198,17 +216,16 @@ function get_test_statistics_data(filteredTests) { const dataset = finalDatasets.find(ds => ds.label === label); if (!dataset) return false; // Check if the dataset's status matches testNoChanges - // Assuming the dataset has a property or can be determined from config const isPassedTest = dataset.backgroundColor === passedBackgroundColor; const isFailedTest = dataset.backgroundColor === failedBackgroundColor; const isSkippedTest = dataset.backgroundColor === skippedBackgroundColor; return ( - (testNoChanges === "Passed" && isPassedTest) || - (testNoChanges === "Failed" && isFailedTest) || - (testNoChanges === "Skipped" && isSkippedTest) || - (compareNoChanges === "Passed" && isPassedTest) || - (compareNoChanges === "Failed" && isFailedTest) || - (compareNoChanges === "Skipped" && isSkippedTest) + (filters.testNoChanges === "Passed" && isPassedTest) || + (filters.testNoChanges === "Failed" && isFailedTest) || + (filters.testNoChanges === "Skipped" && isSkippedTest) || + (filters.compareNoChanges === "Passed" && isPassedTest) || + (filters.compareNoChanges === "Failed" && isFailedTest) || + (filters.compareNoChanges === "Skipped" && isSkippedTest) ); })); } @@ -219,7 +236,93 @@ function get_test_statistics_data(filteredTests) { labels, datasets: finalDatasets, }; - return [graphData, runStarts]; + return [graphData, runStarts, testMetaMap]; +} + +// function to prepare the data for scatter view of test statistics (timestamp-based x-axis, one row per test) +function get_test_statistics_line_data(filteredTests) { + const filters = _get_test_filters(); + const testDataMap = new Map(); + + for (const test of filteredTests) { + if (_should_skip_test(test, filters)) continue; + const testLabel = _get_test_label(test); + const statusName = test.passed == 1 ? "Passed" : test.failed == 1 ? "Failed" : "Skipped"; + + if (!testDataMap.has(testLabel)) { + testDataMap.set(testLabel, []); + } + testDataMap.get(testLabel).push({ + x: new Date(test.start_time), + message: test.message || "", + status: statusName, + runStart: test.run_start, + runAlias: test.run_alias, + elapsed: test.elapsed_s, + testLabel: testLabel, + }); + } + + // Apply "Only Changes" and "Status" filters + if ((filters.testOnlyChanges && filters.testNoChanges !== "All") || + (filters.compareOnlyChanges && filters.compareNoChanges !== "All")) { + return { datasets: [], labels: [] }; + } + if (filters.testOnlyChanges || filters.compareOnlyChanges) { + for (const [label, points] of testDataMap) { + const statuses = new Set(points.map(p => p.status)); + if (statuses.size <= 1) testDataMap.delete(label); + } + } else if (filters.testNoChanges !== "All" || filters.compareNoChanges !== "All") { + const noChanges = filters.testNoChanges !== "All" ? filters.testNoChanges : filters.compareNoChanges; + for (const [label, points] of testDataMap) { + const statuses = new Set(points.map(p => p.status)); + if (statuses.size !== 1 || !statuses.has(noChanges)) testDataMap.delete(label); + } + } + + // Assign each test a Y-axis row index + const testLabels = [...testDataMap.keys()]; + const testIndexMap = {}; + testLabels.forEach((label, i) => { testIndexMap[label] = i; }); + + // Build a single scatter dataset with all points, colored by status + const allPoints = []; + const allColors = []; + const allBorderColors = []; + const allMeta = []; + + for (const [testLabel, points] of testDataMap) { + points.sort((a, b) => a.x.getTime() - b.x.getTime()); + const yIndex = testIndexMap[testLabel]; + for (const p of points) { + allPoints.push({ x: p.x, y: yIndex }); + allColors.push( + p.status === "Passed" ? passedBackgroundColor : + p.status === "Failed" ? failedBackgroundColor : + skippedBackgroundColor + ); + allBorderColors.push( + p.status === "Passed" ? passedBackgroundBorderColor : + p.status === "Failed" ? failedBackgroundBorderColor : + skippedBackgroundBorderColor + ); + allMeta.push(p); + } + } + + const datasets = [{ + label: "Test Results", + data: allPoints, + pointBackgroundColor: allColors, + pointBorderColor: allBorderColors, + pointRadius: 6, + pointHoverRadius: 9, + showLine: false, + _pointMeta: allMeta, + }]; + + return { datasets, labels: testLabels }; } // function to get the compare statistics data @@ -248,5 +351,6 @@ function get_compare_statistics_graph_data(filteredData) { export { get_statistics_graph_data, get_test_statistics_data, + get_test_statistics_line_data, get_compare_statistics_graph_data }; \ No newline at end of file diff --git a/robotframework_dashboard/js/graph_data/time_consuming.js b/robotframework_dashboard/js/graph_data/time_consuming.js index 546b1e2..6be8244 100644 --- a/robotframework_dashboard/js/graph_data/time_consuming.js +++ b/robotframework_dashboard/js/graph_data/time_consuming.js @@ -60,7 +60,10 @@ function get_most_time_consuming_or_most_used_data(dataType, graphType, filtered metric, alias: value.run_alias, runStart: run, - timesRun: Number(value.times_run) + timesRun: Number(value.times_run), + passed: value.passed || 0, + failed: value.failed || 0, + skipped: value.skipped || 0, }); } else { const existing = perRunMap.get(run); @@ -70,7 +73,10 @@ function get_most_time_consuming_or_most_used_data(dataType, graphType, filtered metric, alias: value.run_alias, runStart: run, - timesRun: Number(value.times_run) + timesRun: Number(value.times_run), + passed: value.passed || 0, + failed: value.failed || 0, + skipped: value.skipped || 0, }); } } @@ -80,7 +86,10 @@ function get_most_time_consuming_or_most_used_data(dataType, graphType, filtered metric, alias: value.run_alias, runStart: run, - timesRun: Number(value.times_run) + timesRun: Number(value.times_run), + passed: value.passed || 0, + failed: value.failed || 0, + skipped: value.skipped || 0, }); } } @@ -96,7 +105,10 @@ function get_most_time_consuming_or_most_used_data(dataType, graphType, filtered details.get(entry.key)[entry.runStart] = { duration: entry.metric, - timesRun: entry.timesRun || entry.metricRunCount || 0 + timesRun: entry.timesRun || entry.metricRunCount || 0, + passed: entry.passed || 0, + failed: entry.failed || 0, + skipped: entry.skipped || 0, }; } diff --git a/robotframework_dashboard/js/graph_data/tooltip_helpers.js b/robotframework_dashboard/js/graph_data/tooltip_helpers.js new file mode 100644 index 0000000..5b6cc93 --- /dev/null +++ b/robotframework_dashboard/js/graph_data/tooltip_helpers.js @@ -0,0 +1,67 @@ +// Build a metadata lookup for enhanced tooltips from filtered data arrays. +// Returns { byLabel: {label -> meta}, byTime: {timestamp -> meta} }. +// When aggregate=true, entries with the same run_start are summed (use for suites/keywords combined). +function build_tooltip_meta(filteredData, durationField = 'elapsed_s', aggregate = false) { + const byLabel = {}; + const byTime = {}; + for (const item of filteredData) { + const elapsed = parseFloat(item[durationField]) || 0; + const p = item.passed || 0; + const f = item.failed || 0; + const s = item.skipped || 0; + const msg = item.message || ''; + const keys = [item.run_start, item.run_alias]; + const timeKey = new Date(item.run_start).getTime(); + const meta = { elapsed_s: elapsed, passed: p, failed: f, skipped: s, message: msg }; + for (const key of keys) { + if (aggregate && byLabel[key]) { + byLabel[key].elapsed_s += elapsed; + byLabel[key].passed += p; + byLabel[key].failed += f; + byLabel[key].skipped += s; + } else if (!byLabel[key]) { + byLabel[key] = { ...meta }; + } + } + if (aggregate && byTime[timeKey]) { + byTime[timeKey].elapsed_s += elapsed; + byTime[timeKey].passed += p; + byTime[timeKey].failed += f; + byTime[timeKey].skipped += s; + } else if (!byTime[timeKey]) { + byTime[timeKey] = { ...meta }; + } + } + return { byLabel, byTime }; +} + +// Look up metadata from Chart.js tooltip items (works for bar, line, scatter charts) +function lookup_tooltip_meta(meta, tooltipItems) { + if (!tooltipItems || !tooltipItems.length) return null; + const item = tooltipItems[0]; + // Try chart data labels array (bar/timeline charts) + const labels = item.chart?.data?.labels; + if (labels && labels[item.dataIndex] != null) { + const found = meta.byLabel[labels[item.dataIndex]]; + if (found) return found; + } + // Try raw x value (line/scatter charts with time axis) + if (item.raw && typeof item.raw === 'object' && item.raw.x != null) { + const t = item.raw.x instanceof Date ? item.raw.x.getTime() : new Date(item.raw.x).getTime(); + const found = meta.byTime[t]; + if (found) return found; + } + // Fallback: tooltip label text + return meta.byLabel[item.label] || null; +} + +// Format status as a single string for tooltip display +// Returns "PASS"/"FAIL"/"SKIP" for individual items, or "Passed: X, Failed: Y, Skipped: Z" for aggregates +function format_status(meta) { + if (meta.passed === 1 && meta.failed === 0 && meta.skipped === 0) return 'PASS'; + if (meta.failed === 1 && meta.passed === 0 && meta.skipped === 0) return 'FAIL'; + if (meta.skipped === 1 && meta.passed === 0 && meta.failed === 0) return 'SKIP'; + return `Passed: ${meta.passed}, Failed: ${meta.failed}, Skipped: ${meta.skipped}`; +} + +export { build_tooltip_meta, lookup_tooltip_meta, format_status }; diff --git a/robotframework_dashboard/js/variables/graphmetadata.js b/robotframework_dashboard/js/variables/graphmetadata.js index 209d6d0..97019c3 100644 --- a/robotframework_dashboard/js/variables/graphmetadata.js +++ b/robotframework_dashboard/js/variables/graphmetadata.js @@ -343,7 +343,7 @@ const graphMetadata = [ key: "testStatistics", label: "Test Statistics", defaultType: "timeline", - viewOptions: ["Timeline"], + viewOptions: ["Timeline", "Line"], hasFullscreenButton: true, html: `
Statistics
@@ -366,6 +366,7 @@ const graphMetadata = [
+ diff --git a/robotframework_dashboard/js/variables/information.js b/robotframework_dashboard/js/variables/information.js index c10c7e4..a9d70bb 100644 --- a/robotframework_dashboard/js/variables/information.js +++ b/robotframework_dashboard/js/variables/information.js @@ -80,6 +80,12 @@ const informationMap = { Status: Displays only tests don't have any status changes and have the selected status Only Changes: Displays only tests that have changed statuses at some point in time Tip: Don't use Status and Only Changes at the same time as it will result in an empty graph`, + "testStatisticsGraphLine": `Scatter: Displays test results as dots on a time axis, with each row representing a different test +- Green dots indicate passed, red dots indicate failed, and yellow dots indicate skipped tests +- The horizontal spacing between dots is proportional to the actual time between executions +- Hover over a dot to see the test name, status, run, duration and failure message +- Useful for spotting environmental issues where multiple tests fail at the same timestamp +- Status and Only Changes filters apply to this view as well`, "testDurationGraphBar": "Bar: Displays test durations represented as vertical bars", "testDurationGraphLine": "Line: Displays the same data but over a time axis for clearer trend analysis", "testDurationDeviationGraphBar": `This boxplot chart displays how much test durations deviate from the average, represented as vertical bars. From 0709a312d5bdb53b69cc6a4b33f5ffa03050df2a Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Sat, 28 Feb 2026 19:42:29 +0100 Subject: [PATCH 21/26] Documentation improvements for log linking --- .github/copilot-instructions.md | 45 ----------- .github/skills/coding-style.md | 24 ++++++ .github/skills/conventions-and-gotchas.md | 13 ++++ .github/skills/dashboard.md | 66 ++++++++++++++++ .github/skills/project-architecture.md | 21 ++++++ .github/skills/workflows.md | 16 ++++ .gitignore | 1 + README.md | 1 + docs/.vitepress/config.mts | 1 + docs/.vitepress/theme/vars.css | 6 ++ docs/advanced-cli-examples.md | 38 +--------- docs/basic-command-line-interface-cli.md | 4 +- docs/dashboard-server.md | 4 +- docs/graphs-tables.md | 17 ++++- docs/index.md | 3 + docs/log-linking.md | 92 +++++++++++++++++++++++ setup.py | 1 + 17 files changed, 268 insertions(+), 85 deletions(-) delete mode 100644 .github/copilot-instructions.md create mode 100644 .github/skills/coding-style.md create mode 100644 .github/skills/conventions-and-gotchas.md create mode 100644 .github/skills/dashboard.md create mode 100644 .github/skills/project-architecture.md create mode 100644 .github/skills/workflows.md create mode 100644 docs/log-linking.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 764ddfe..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,45 +0,0 @@ -# Copilot instructions for robotframework-dashboard - -## Role -- You are an expert developer in Python, JavaScript, HTML, and CSS with deep knowledge of the Robot Framework ecosystem and experience building complex data processing pipelines and dashboards. -- You understand the architecture of the robotframework-dashboard project, including its CLI, data processing flow, database interactions, and dashboard generation. -- You are familiar with the project's coding style, conventions, and common patterns, and you can apply this knowledge to maintain consistency across the codebase when implementing new features or fixing bugs. -- You can provide clear, concise, and context-aware code suggestions that align with the project's design principles and user experience goals. - -## Project architecture (big picture) -- CLI entry point is `robotdashboard` -> `robotframework_dashboard.main:main`, which orchestrates: init DB, process outputs, list runs, remove runs, generate HTML. -- Core workflow: output.xml -> `OutputProcessor` (Robot Result Visitor API) -> sqlite DB (`DatabaseProcessor`) -> HTML dashboard via `DashboardGenerator`. -- Dashboard HTML is a template with placeholders replaced at build time; data payloads are zlib-compressed and base64-encoded strings embedded in HTML. -- JS/CSS are merged and inlined by `DependencyProcessor` (topological import resolution for JS modules) and can be switched to CDN or fully offline assets. -- Optional server mode uses FastAPI to host admin + dashboard + API endpoints; the server uses the same `RobotDashboard` pipeline. - -## Key directories and files -- CLI + orchestration: `robotframework_dashboard/main.py`, `robotframework_dashboard/robotdashboard.py`, `robotframework_dashboard/arguments.py`. -- Data extraction: `robotframework_dashboard/processors.py` (visitors for runs/suites/tests/keywords). -- Database: `robotframework_dashboard/database.py` + schema in `robotframework_dashboard/queries.py`. -- HTML templates: `robotframework_dashboard/templates/dashboard.html` and `robotframework_dashboard/templates/admin.html` (placeholders are replaced in `dashboard.py`). -- Dependency inlining and CDN/offline switching: `robotframework_dashboard/dependencies.py`. -- Server: `robotframework_dashboard/server.py` (FastAPI endpoints + admin UI). -- Dashboard JS entry: `robotframework_dashboard/js/main.js` (imports modular setup files). - -## Project-specific conventions and gotchas -- Run identity is `run_start` from output.xml; duplicates are rejected. `run_alias` defaults to file name and may be auto-adjusted to avoid collisions. -- If you add log support, log names must mirror output names (output-XYZ.xml -> log-XYZ.html) for `uselogs` and server log linking. -- `--projectversion` and `version_` tags are mutually exclusive; version tags are parsed from output tags in `RobotDashboard._process_single_output`. -- Custom DB backends are supported via `--databaseclass`; the module must expose a `DatabaseProcessor` class compatible with `AbstractDatabaseProcessor`. -- Offline mode is handled by embedding dependency content into the HTML; do not assume external CDN availability when `--offlinedependencies` is used. - -## Common workflows -- CLI usage and flags: see `docs/basic-command-line-interface-cli.md` (output import, tags, remove runs, dashboard generation). -- Server mode: `robotdashboard --server` or `-s host:port:user:pass` (see `docs/dashboard-server.md` for endpoints and admin UI behavior). -- Docs site: `npm run docs:dev|docs:build|docs:preview` (VitePress in `docs/`). - -## Patterns and style expectations -- Data flow is always: parse outputs -> DB -> HTML. Reuse `RobotDashboard` methods instead of reimplementing this flow. -- When adding new JS modules, update imports so `DependencyProcessor` can resolve module order; all dashboard JS is bundled into one script at generation time. -- Template changes should keep placeholder keys intact (e.g. `placeholder_runs`, `placeholder_css`) because replacements are string-based. -- Python targets 3.8+; keep functions small, use clear exceptions, and follow existing snake_case. -- JS uses modern syntax (const/let, arrow functions) and camelCase; keep functions small to match existing modules. -- HTML should remain semantic and accessible; keep markup minimal and label form controls in templates. -- CSS should use existing class conventions (Bootstrap/Datatables) and keep selectors shallow; prefer variables for theme values. -- Update docs in `docs/` when user-facing behavior changes. diff --git a/.github/skills/coding-style.md b/.github/skills/coding-style.md new file mode 100644 index 0000000..422b5f7 --- /dev/null +++ b/.github/skills/coding-style.md @@ -0,0 +1,24 @@ +--- +description: Use when writing or reviewing Python, JavaScript, HTML, or CSS code in this project. +--- + +# Coding Style + +## Python +- Targets Python 3.8+. +- Keep functions small, use clear exceptions, and follow existing snake_case naming. + +## JavaScript +- Uses modern syntax (const/let, arrow functions) and camelCase naming. +- Keep functions small to match existing modules. +- When adding new JS modules, update imports so `DependencyProcessor` can resolve module order; all dashboard JS is bundled into one script at generation time. + +## HTML +- Keep markup semantic and accessible; keep it minimal and label form controls in templates. + +## CSS +- Use existing class conventions (Bootstrap/Datatables) and keep selectors shallow. +- Prefer CSS variables for theme values. + +## General +- Update docs in `docs/` when user-facing behavior changes. diff --git a/.github/skills/conventions-and-gotchas.md b/.github/skills/conventions-and-gotchas.md new file mode 100644 index 0000000..615169b --- /dev/null +++ b/.github/skills/conventions-and-gotchas.md @@ -0,0 +1,13 @@ +--- +description: Use when modifying core logic, adding features, or debugging issues related to runs, logs, versions, database backends, or offline mode. +--- + +# Project Conventions and Gotchas + +- Run identity is `run_start` from output.xml; duplicates are rejected. `run_alias` defaults to file name and may be auto-adjusted to avoid collisions. +- If you add log support, log names must mirror output names (output-XYZ.xml -> log-XYZ.html) for `uselogs` and server log linking. +- `--projectversion` and `version_` tags are mutually exclusive; version tags are parsed from output tags in `RobotDashboard._process_single_output`. +- Custom DB backends are supported via `--databaseclass`; the module must expose a `DatabaseProcessor` class compatible with `AbstractDatabaseProcessor`. +- Offline mode is handled by embedding dependency content into the HTML; do not assume external CDN availability when `--offlinedependencies` is used. +- Data flow is always: parse outputs -> DB -> HTML. Reuse `RobotDashboard` methods instead of reimplementing this flow. +- Template changes should keep placeholder keys intact (e.g. `placeholder_runs`, `placeholder_css`) because replacements are string-based. diff --git a/.github/skills/dashboard.md b/.github/skills/dashboard.md new file mode 100644 index 0000000..808c2a6 --- /dev/null +++ b/.github/skills/dashboard.md @@ -0,0 +1,66 @@ +--- +description: Use when working on dashboard pages, tabs, graphs, Chart.js configurations, or the HTML template. +--- + +# Dashboard Pages and Charts + +## Pages / Tabs + +### Overview Page +High-level summary of all test runs. Shows the latest results per project with pass/fail/skip counts, recent trends, and overall performance. Special sections: "Latest Runs" (latest run per project) and "Total Stats" (aggregated stats by project/tag). Projects can be grouped by custom `project_` tags. + +### Dashboard Page +Interactive visualizations across four sections — Runs, Suites, Tests, Keywords. Layout is fully customizable via drag-and-drop. Most graphs support multiple display modes. Graphs can be expanded to fullscreen (increases data limits, e.g. Top 10 → Top 50). + +### Compare Page +Side-by-side comparison of up to four test runs with statistics, charts (bar, radar, timeline), and summaries to identify regressions or improvements. + +### Tables Page +Raw database data in DataTables for runs, suites, tests, and keywords. Useful for debugging and ad-hoc analysis. + +## Chart.js Architecture + +### Central Config: `graph_config.js` +`get_graph_config(graphType, graphData, graphTitle, xTitle, yTitle)` is the single factory function that returns a complete Chart.js config object. All graphs route through it. + +### Supported Chart Types +| Type | Chart.js `type` | Usage | +|---|---|---| +| `line` | `line` | Time-series trends (statistics, durations over time) | +| `bar` | `bar` | Stacked bars (statistics amounts, durations, rankings) | +| `timeline` | `bar` (indexAxis: y) | Horizontal bars for timeline views (test status, most-failed) | +| `boxplot` | `boxplot` | Duration deviation / flaky test detection | +| `donut` | `doughnut` | Run/suite distribution charts | +| `heatmap` | `matrix` | Test execution activity by hour/minute per weekday | +| `radar` | `radar` | Compare suite durations across runs | + +### Chart Factory: `chart_factory.js` +- `create_chart(chartId, buildConfigFn)` — destroys existing chart, creates new `Chart` instance, attaches log-click handler. +- `update_chart(chartId, buildConfigFn)` — updates data/options in-place for smooth transitions; falls back to `create_chart` if the chart doesn't exist yet. + +### Graph Data Modules (in `js/graph_data/`) +Each module transforms filtered DB data into Chart.js-compatible datasets: +- `statistics.js` — pass/fail/skip counts and percentages for runs, suites, tests, keywords. +- `duration.js` — elapsed time data (total, average, min, max). +- `duration_deviation.js` — boxplot quartile calculations for test duration spread. +- `donut.js` — aggregated donut/doughnut data, including folder-level drill-down for suites. +- `heatmap.js` — matrix data (day × hour/minute) for execution activity. +- `messages.js` — failure message frequency data. +- `tooltip_helpers.js` — rich tooltip metadata (duration, status, message). +- `helpers.js` — shared utilities (height updates, data exclusions). + +### Graph Creation Modules (in `js/graph_creation/`) +Each section has its own module that wires data modules to chart factory calls: +- `overview.js` — Overview page project cards and grouped stats. +- `run.js` — Run statistics, donut, duration, heatmap, stats graphs. +- `suite.js` — Suite folder donut, statistics, duration, most-failed, most-time-consuming. +- `test.js` — Test statistics (timeline), duration, deviation (boxplot), messages, most-flaky, most-failed, most-time-consuming. +- `keyword.js` — Keyword statistics, times-run, duration variants, most-failed, most-time-consuming, most-used. +- `compare.js` — Compare page statistics bar, radar, and timeline graphs. + +### Common Patterns +- All graphs use the `settings` object (`js/variables/settings.js`) for display preferences (animation, graph types, date labels, legends, axis titles). +- Graph type switching (e.g. bar ↔ line ↔ percentages) is driven by `settings.graphTypes.GraphType`. +- Fullscreen mode changes data limits (e.g. top-N from 10/30 to 50/100) via `inFullscreen` and `inFullscreenGraph` globals. +- Clicking chart data points opens the corresponding Robot Framework log via `open_log_file` / `open_log_from_label`. +- Chart color constants (passed/failed/skipped backgrounds and borders) live in `js/variables/chartconfig.js`. diff --git a/.github/skills/project-architecture.md b/.github/skills/project-architecture.md new file mode 100644 index 0000000..ebc0733 --- /dev/null +++ b/.github/skills/project-architecture.md @@ -0,0 +1,21 @@ +--- +description: Use when working on project structure, understanding how components connect, or navigating the codebase. +--- + +# Project Architecture + +## Big Picture +- CLI entry point is `robotdashboard` -> `robotframework_dashboard.main:main`, which orchestrates: init DB, process outputs, list runs, remove runs, generate HTML. +- Core workflow: output.xml -> `OutputProcessor` (Robot Result Visitor API) -> SQLite DB (`DatabaseProcessor`) -> HTML dashboard via `DashboardGenerator`. +- Dashboard HTML is a template with placeholders replaced at build time; data payloads are zlib-compressed and base64-encoded strings embedded in HTML. +- JS/CSS are merged and inlined by `DependencyProcessor` (topological import resolution for JS modules) and can be switched to CDN or fully offline assets. +- Optional server mode uses FastAPI to host admin + dashboard + API endpoints; the server uses the same `RobotDashboard` pipeline. + +## Key Directories and Files +- CLI + orchestration: `robotframework_dashboard/main.py`, `robotframework_dashboard/robotdashboard.py`, `robotframework_dashboard/arguments.py`. +- Data extraction: `robotframework_dashboard/processors.py` (visitors for runs/suites/tests/keywords). +- Database: `robotframework_dashboard/database.py` + schema in `robotframework_dashboard/queries.py`. +- HTML templates: `robotframework_dashboard/templates/dashboard.html` and `robotframework_dashboard/templates/admin.html` (placeholders are replaced in `dashboard.py`). +- Dependency inlining and CDN/offline switching: `robotframework_dashboard/dependencies.py`. +- Server: `robotframework_dashboard/server.py` (FastAPI endpoints + admin UI). +- Dashboard JS entry: `robotframework_dashboard/js/main.js` (imports modular setup files). diff --git a/.github/skills/workflows.md b/.github/skills/workflows.md new file mode 100644 index 0000000..1a89944 --- /dev/null +++ b/.github/skills/workflows.md @@ -0,0 +1,16 @@ +--- +description: Use when running the CLI, starting the server, or building/previewing the documentation site. +--- + +# Common Workflows + +## CLI +- Usage and flags: see `docs/basic-command-line-interface-cli.md` (output import, tags, remove runs, dashboard generation). + +## Server Mode +- Start with `robotdashboard --server` or `-s host:port:user:pass`. +- See `docs/dashboard-server.md` for endpoints and admin UI behavior. + +## Documentation Site +- Run `npm run docs:dev` for local dev, `npm run docs:build` to build, `npm run docs:preview` to preview. +- Docs use VitePress and live in `docs/`. diff --git a/.gitignore b/.gitignore index 66874cb..f8fa56a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ index.txt .pabotsuitenames *.db *.vscode +.github/*.md # docs entries node_modules diff --git a/README.md b/README.md index 0745d67..4b8e4af 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ For detailed usage instructions, advanced examples, and full documentation, visi - 🖥️ [**Dashboard Server**](https://marketsquare.github.io/robotframework-dashboard/dashboard-server.html) - Host the dashboard for multi-user access, programmatic updates, and remote server integration. - 🗄️ [**Custom Database Class**](https://marketsquare.github.io/robotframework-dashboard/custom-database-class.html) - Extend or replace the default database backend to suit your storage needs, including SQLite, MySQL, or custom implementations. - 🔔 [**Listener Integration**](https://marketsquare.github.io/robotframework-dashboard/listener-integration.html) - Use a listener to automatically push test results to the dashboard for every executed run, integrating seamlessly into CI/CD pipelines. +- 📂 [**Log Linking**](https://marketsquare.github.io/robotframework-dashboard/log-linking.html) - Enable clickable log navigation from dashboard graphs, covering file naming conventions, local and server usage, and remote log uploads. ## 🛠️ Contributions diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 56b2030..6666776 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -86,6 +86,7 @@ export default defineConfig({ { text: '🖥️ Dashboard Server', link: '/dashboard-server.md' }, { text: '🗄️ Custom Database Class', link: '/custom-database-class.md' }, { text: '🔔 Listener Integration', link: '/listener-integration.md' }, + { text: '📂 Log Linking', link: '/log-linking.md' }, ] }, { text: '🤝 Contributions', link: '/contributions.md' } diff --git a/docs/.vitepress/theme/vars.css b/docs/.vitepress/theme/vars.css index e299b82..2cef932 100644 --- a/docs/.vitepress/theme/vars.css +++ b/docs/.vitepress/theme/vars.css @@ -114,6 +114,12 @@ html.dark table { background-color: var(--vp-c-content-bg); } +/* Ensure tables always fill the available width */ +.vp-doc table { + width: 100%; + display: table; +} + /* Table header row */ html.dark table th { background-color: #0d1b3f; /* slightly darker than table rows */ diff --git a/docs/advanced-cli-examples.md b/docs/advanced-cli-examples.md index c8b5fad..8f866f7 100644 --- a/docs/advanced-cli-examples.md +++ b/docs/advanced-cli-examples.md @@ -94,45 +94,15 @@ robotdashboard -o output_my_alias2.xml robotdashboard -f ./nightly_runs ``` -## Advanced UseLogs Information +## Log Linking -Enable interactive log navigation directly from dashboard graphs. - -By default, graphs are not clickable, so opening log files must be done manually. -When UseLogs is enabled, you can open log files directly from run, suite, test, and keyword graphs. - -### How it works - -- Graph items become clickable when they point to exactly one run, suite, test, or keyword. -- If a graph point refers to multiple runs or multiple suites/tests across different runs, no log file will open. -- For logs to open correctly, the corresponding log.html file must exist in the same directory as the output.xml. -- The expected log filename is automatically derived by: - - Replacing `output` with `log` - - Replacing `.xml` with `.html` -- When clicking a suite or test node that maps to exactly one suite or test, the log file will open automatically at the correct suite or test location. -- For server behavior and storing logs on the server, see [Dashboard Server](/dashboard-server.md). - -### Turning on clickable logs +The `--uselogs` (`-u`) flag enables interactive log navigation directly from dashboard graphs. When enabled, clicking on a graph element opens the corresponding `log.html` file. ```bash robotdashboard -u true ``` -Expected filename behavior is applied when clicking graphs -```bash -robotdashboard -u true -o path/to/output12345.xml -``` -Log file that should exist: path/to/log12345.html -```bash -robotdashboard -u true -o some_test_output_file.xml -``` -Log file that should exist: some_test_log_file.html - -#### Reports -Robotframework report .htmls can be accessed through the log html: -- Name the report html the same as the log html, following the logic explained above - - Ensure the log and report are in the same directory - - Make sure the filenames match, except `log` being `report` -- Then, the link to the report inside the log html at the top right corner should work + +For the full guide covering file naming conventions, local vs. server usage, and remote log uploads, see the dedicated [Log Linking](/log-linking.md) page. ## Message Config Details diff --git a/docs/basic-command-line-interface-cli.md b/docs/basic-command-line-interface-cli.md index e3c2862..c77c2d5 100644 --- a/docs/basic-command-line-interface-cli.md +++ b/docs/basic-command-line-interface-cli.md @@ -137,14 +137,14 @@ robotdashboard --quantity 50 ## Advanced Options -### Enable clickable log files in the dashboard +### Enable Log Linking in the dashboard ```bash robotdashboard -u true robotdashboard --uselogs True ``` - Optional: `-u` or `--uselogs` enables clickable graphs in the dashboard that open corresponding log.html files. - Requirements: log files must be in the same folder as their respective output.xml files, with `output` replaced by `log` and `.xml` replaced by `.html`. -- See [Advanced CLI & Examples](/advanced-cli-examples#advanced-uselogs-information) for more details regarding the log linking! +- See [Log Linking](/log-linking.md) for the full guide on file naming, local vs. server usage, and remote log uploads. ### Add messages config for bundling test messages ```bash diff --git a/docs/dashboard-server.md b/docs/dashboard-server.md index 8d01dff..e318f58 100644 --- a/docs/dashboard-server.md +++ b/docs/dashboard-server.md @@ -72,8 +72,8 @@ The built-in server exposes several HTTP endpoints to manage and serve dashboard | `/add-output-file` | Accepts new output data via file input, callable | | `/remove-outputs` | Deletes runs by index, alias, `run_start`, tags, limit or 'all=true' for all outputs, callable | | `/get-logs` | Returns a JSON list of stored logs on the server (`log_name`), callable | -| `/add-log` | Upload HTML a log file and associate them with runs (for `uselogs`), callable | -| `/add-log-file` | Upload a HTML log file (for `uselogs`), callable | +| `/add-log` | Upload HTML a log file and associate them with runs (for [Log Linking](/log-linking.md)), callable | +| `/add-log-file` | Upload a HTML log file (for [Log Linking](/log-linking.md)), callable | | `/remove-log` | Remove previously uploaded log files or provide 'all=true' for all logs, callable | All API endpoints are documented and described in the server’s own OpenAPI schema, accessible via the admin interface under “Swagger API Docs” or "Redoc API Docs", after starting the server. diff --git a/docs/graphs-tables.md b/docs/graphs-tables.md index bc65cec..0bc15f3 100644 --- a/docs/graphs-tables.md +++ b/docs/graphs-tables.md @@ -38,7 +38,7 @@ Discover the graphs and tables included in the Dashboard. This page explains how | Graph Name | Views | Views Description | Notes | | ------------------------ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| Test Statistics | Timeline | Timeline: Displays statistics of tests in timeline format. | Status: Displays only tests don't have any status changes and have the selected status
Only Changes: Displays only tests that changed status at some point.
Tip: Don't use Status and Only Changes at the same time as it will result in an empty graph | +| Test Statistics | Timeline
Line | Timeline: Displays statistics of tests in timeline format.
Line: Displays test results as a scatter plot with a timestamp-based x-axis and one row per test, colored by status (pass/fail/skip). Useful for spotting patterns across many runs. | Status: Displays only tests that don't have any status changes and have the selected status.
Only Changes: Displays only tests that changed status at some point.
Tip: Don't use Status and Only Changes at the same time as it will result in an empty graph. | | Test Duration | Bar
Line | Bar: Displays test durations represented as vertical bars.
Line: Displays test durations over a time axis. | - | | Test Duration Deviation | Boxplot | Shows deviations of test durations from average, highlighting flaky tests. | - | | Test Messages | Bar
Timeline | Bar: Displays messages ranked by frequency.
Timeline: Displays when messages occurred to reveal spikes. | Top 10 default, Top 50 fullscreen | @@ -68,9 +68,22 @@ Discover the graphs and tables included in the Dashboard. This page explains how | ---------------------- | ------------------------ | ----------------------------------------------------------------------| -------------------------------------- | | Compare Statistics | Bar | Displays overall statistics of the selected runs. | - | | Compare Suite Duration | Radar | Shows suite durations in radar format for multiple runs. | - | -| Compare Tests | Timeline | Timeline: Displays test statistics over time. | Status: Displays only tests don't have any status changes and have the selected status
Only Changes: Displays only tests that changed status at some point.
Tip: Don't use Status and Only Changes at the same time as it will result in an empty graph | +| Compare Tests | Timeline | Timeline: Displays test statistics over time. | Status: Displays only tests that don't have any status changes and have the selected status.
Only Changes: Displays only tests that changed status at some point.
Tip: Don't use Status and Only Changes at the same time as it will result in an empty graph. | +## Tooltips + +Many graphs include enhanced tooltips that display additional information when hovering over data points: + +- **Run Statistics & Run Duration**: Tooltips show total run duration and pass/fail/skip status. +- **Suite Statistics & Suite Duration**: Tooltips show suite duration and pass/fail/skip status. +- **Test Statistics (Timeline)**: Tooltips show the run label, test status, duration, and failure messages (if any). +- **Test Statistics (Line)**: Tooltips show the test name, status, run start, duration, and failure messages. +- **Test Duration**: Tooltips show the test status and failure messages. +- **Compare Tests**: Tooltips show the run label, test status, duration, and failure messages. + +These enhanced tooltips make it easier to understand test results without needing to navigate to individual log files. + ## Tables Tab | Table Name | Columns | Description | Notes | | ---------- | --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----- | diff --git a/docs/index.md b/docs/index.md index bde061e..fc1acd8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -60,6 +60,9 @@ features: - title: 🔔 Listener Integration details: Use a listener to automatically push test results to the dashboard for every executed run, integrating seamlessly into CI/CD pipelines. link: /listener-integration.md + - title: 📂 Log Linking + details: Enable clickable log navigation from dashboard graphs. Covers file naming conventions, local and server usage, and remote log uploads. + link: /log-linking.md --- diff --git a/docs/log-linking.md b/docs/log-linking.md new file mode 100644 index 0000000..d08db2f --- /dev/null +++ b/docs/log-linking.md @@ -0,0 +1,92 @@ +--- +outline: deep +--- + +# Log Linking + +Enable interactive log navigation directly from dashboard graphs. When enabled, clicking on a graph element (run, suite, test, or keyword) opens the corresponding `log.html` file. And in the case of suites or tests the log opens at the correct suite or test within the log file. + +## Enabling Log Linking + +Add the `--uselogs` (or `-u`) flag when generating your dashboard: + +```bash +robotdashboard -u true +``` + +This can be combined with other options: + +```bash +robotdashboard -u true -o output.xml -n robot_dashboard.html +``` + +> For the full list of CLI options, see [Basic CLI](/basic-command-line-interface-cli.md#enable-log-linking-in-the-dashboard). + +## File Naming Convention + +The dashboard derives the log path from the output path by replacing `output` with `log` and `.xml` with `.html`: + +| Output File | Expected Log File | +| --------------------------------- | ---------------------------------- | +| `path/to/output.xml` | `path/to/log.html` | +| `path/to/my_output_123.xml` | `path/to/my_log_123.html` | +| `some_test_output_file.xml` | `some_test_log_file.html` | +| `output_nightly.xml` | `log_nightly.html` | + +::: warning +If the log file does not follow this naming convention, it will not be found when clicking a graph element. +::: + +## Usage Scenarios + +### Local (No Server) + +The simplest setup. The dashboard opens log files directly from the filesystem. + +**Requirements:** +- The `log.html` must be in the **same directory** as the `output.xml`. +- The filename must follow the naming convention above. + +```bash +robotdashboard -u true -o path/to/output_nightly.xml +``` + +Clicking a graph element will open `path/to/log_nightly.html` in a new browser tab. + +### Local Server + +When running the dashboard as a local server (`--server`), the behavior is the same as the local setup. The only difference is that log files are **served by the server** instead of opened directly from the filesystem. The same naming convention and directory requirements apply. + +```bash +robotdashboard -u true --server +``` + +### Remote Server + +When the dashboard runs on a remote machine (e.g., in a container), log files are not available on the filesystem. You must **upload** them to the server. + +**Upload methods:** +- The server's **admin GUI** (manual upload) +- The **`/add-log-file`** API endpoint (programmatic upload) +- The **`robotdashboardlistener`** (automatic upload after test execution) + +Uploaded logs are stored in a `robot_logs` folder in the server's working directory. The naming convention still applies — the server matches logs to runs using the filename. + +::: tip +Make sure to start the server with the `--uselogs` flag so that graph elements become clickable. Without this flag, no log linking will occur even if logs have been uploaded. +::: + +For more details about the server and its API, see [Dashboard Server](/dashboard-server.md). + +## Accessing Reports + +Robot Framework `report.html` files can also be accessed through the log file: + +1. Name the report file the same as the log file, but replace `log` with `report`. +2. Place the report in the **same directory** as the log. +3. The link to the report inside the `log.html` (top-right corner) will then work correctly. + +| Log File | Expected Report File | +| --------------------------------- | ---------------------------------- | +| `log_nightly.html` | `report_nightly.html` | +| `my_log_123.html` | `my_report_123.html` | diff --git a/setup.py b/setup.py index 797cc72..2a938b8 100644 --- a/setup.py +++ b/setup.py @@ -72,6 +72,7 @@ - 🖥️ [**Dashboard Server**](https://marketsquare.github.io/robotframework-dashboard/dashboard-server.html) - Host the dashboard for multi-user access, programmatic updates, and remote server integration. - 🗄️ [**Custom Database Class**](https://marketsquare.github.io/robotframework-dashboard/custom-database-class.html) - Extend or replace the default database backend to suit your storage needs, including SQLite, MySQL, or custom implementations. - 🔔 [**Listener Integration**](https://marketsquare.github.io/robotframework-dashboard/listener-integration.html) - Use a listener to automatically push test results to the dashboard for every executed run, integrating seamlessly into CI/CD pipelines. +- 📂 [**Log Linking**](https://marketsquare.github.io/robotframework-dashboard/log-linking.html) - Enable clickable log navigation from dashboard graphs, covering file naming conventions, local and server usage, and remote log uploads. ## 🛠️ Contributions From c0575250ad980f80df3137bb8d6b45981dca22b8 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Sat, 28 Feb 2026 20:13:40 +0100 Subject: [PATCH 22/26] Documentation improvements and log linking for test statistics scatter graph --- docs/.vitepress/config.mts | 13 +- docs/advanced-cli-examples.md | 7 + docs/basic-command-line-interface-cli.md | 7 +- docs/custom-database-class.md | 23 +- docs/customization.md | 17 +- docs/dashboard-server.md | 64 +++++- docs/filtering.md | 10 +- docs/graphs-tables.md | 24 ++ docs/installation-version-info.md | 26 +++ docs/log-linking.md | 14 ++ docs/settings.md | 59 ++++- docs/tabs-pages.md | 9 + example/database/abstractdb.py | 2 +- example/database/sqlite3.py | 207 ++++++++++++------ .../js/graph_creation/test.js | 9 + robotframework_dashboard/js/log.js | 14 +- 16 files changed, 416 insertions(+), 89 deletions(-) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 6666776..12dfc3c 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -5,6 +5,11 @@ import { resolve } from 'node:path'; const python_svg = readFileSync("docs/public/python.svg", "utf-8"); const slack_svg = readFileSync("docs/public/slack.svg", "utf-8"); +// Read version from version.py +const versionFile = readFileSync("robotframework_dashboard/version.py", "utf-8"); +const versionMatch = versionFile.match(/Robotdashboard\s+([\d.]+)/); +const version = versionMatch ? versionMatch[1] : "unknown"; + export default defineConfig({ title: "RobotDashboard", description: "Robot Framework Dashboard and Result Database command line tool", @@ -53,7 +58,13 @@ export default defineConfig({ nav: [ { text: 'Home', link: '/' }, { text: 'Documentation', link: '/getting-started.md' }, - { text: 'Example Dashboard', link: '/example/robot_dashboard.html', target: '_self' } + { text: 'Example Dashboard', link: '/example/robot_dashboard.html', target: '_self' }, + { + text: `v${version}`, + items: [ + { text: 'Changelog', link: 'https://github.com/marketsquare/robotframework-dashboard/releases' }, + ] + } ], sidebar: [ { diff --git a/docs/advanced-cli-examples.md b/docs/advanced-cli-examples.md index 8f866f7..422c46c 100644 --- a/docs/advanced-cli-examples.md +++ b/docs/advanced-cli-examples.md @@ -56,6 +56,13 @@ robotdashboard -o output.xml:project_custom_name robotdashboard -f ./results:project_1 ``` +When using `project_` tags: +- Each unique `project_` tag creates a **separate project section** on the Overview page +- The Overview will group and display runs under the tag name instead of the run name +- You can toggle between project-by-name and project-by-tag views in [Settings - Overview Tab](/settings#overview-settings-overview-tab) +- The `project_` prefix text can be shown or hidden using the **Prefixes** toggle in Settings +- Multiple `project_` tags on different outputs allow comparing different projects side by side on the Overview page + ## Aliases for Clean Dashboard Identification Aliases help replace long timestamps with clean, readable names. They also significantly improve clarity in comparison views and general dashboard readability. diff --git a/docs/basic-command-line-interface-cli.md b/docs/basic-command-line-interface-cli.md index c77c2d5..d514288 100644 --- a/docs/basic-command-line-interface-cli.md +++ b/docs/basic-command-line-interface-cli.md @@ -80,7 +80,12 @@ If you want to supply versions for each output, use: robotdashboard -o output.xml:version_1.2.1 -o output2.xml:version_2.3.4 robotdashboard -f ./results:version_1.1 ./results2:version_2.3.4 ``` ---projectversion and version_ are mutually exclusive + +::: warning Version Constraints +- `--projectversion` and `version_` tags are **mutually exclusive** — using both will produce an error. +- Each output file can have at most **one** `version_` tag. Multiple `version_` tags on the same output will produce an error. +::: + > Added in RobotDashboard v1.3.0 > version_ tag support added in v1.4.0 diff --git a/docs/custom-database-class.md b/docs/custom-database-class.md index 52c289d..4dc5e86 100644 --- a/docs/custom-database-class.md +++ b/docs/custom-database-class.md @@ -69,7 +69,7 @@ Your custom database class must implement the following methods: --- -### `insert_output_data(self, output_data: dict, tags: list, run_alias: str, path: Path)` +### `insert_output_data(self, output_data: dict, tags: list, run_alias: str, path: Path, project_version: str)` This method handles the actual insertion of all run-related data. You must process: @@ -78,6 +78,7 @@ You must process: - `tags` — list of tags associated with the run - `run_alias` — a human-friendly alias chosen by the user or system - `path` — path to `output.xml` +- `project_version` — version string associated with this run (from `--projectversion` or `version_` tags), may be `None` You can inspect the example implementations for the exact structure of `output_data` and how each record is inserted. @@ -102,7 +103,8 @@ Must return **all data** in this dictionary format: "tags": "", "run_alias": "output-20241013-223319", "path": "results/output-20241013-223319.xml", - "metadata": "[]" + "metadata": "[]", + "project_version": "1.2.1" }, {...etc} ], @@ -177,15 +179,24 @@ Each type must be a list of dictionaries matching what RobotDashboard expects. ### `remove_runs(self, remove_runs: list)` `remove_runs` may contain any of the following: -- `index=` -- `run_start=` -- `alias=` -- `tag=` +- `index=` — remove by index (supports ranges with `:` and lists with `;`) +- `run_start=` — remove by exact run_start timestamp +- `alias=` — remove by run alias +- `tag=` — remove all runs matching the given tag +- `limit=` — keep only the N most recent runs, removing all older ones You must correctly interpret and remove runs accordingly. If you only want to support removing based on run_start or index you could only implement those usages. --- +### *(Optional)* `vacuum_database(self)` +Called after run removal when the `--novacuum` flag is **not** set. +In the default SQLite implementation, this runs `VACUUM` to reclaim disk space after deletions. + +If your database backend does not need compaction, you can safely omit this method or implement it as a no-op. Make sure you then provide the --novacuum flag when running the dashboard to avoid errors. + +--- + ### *(Optional)* `update_output_path(self, log_path: str)` This is only required when using: diff --git a/docs/customization.md b/docs/customization.md index 34e3684..ea8f1e8 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -47,7 +47,22 @@ These examples illustrate how flexible the configuration system is, letting you At the end of the video, you’ll see how you can easily **reset all customizations** by going to the **Settings** page and restoring the defaults. This quickly brings the dashboard back to its original configuration. -### 5. Viewing (and Editing) the JSON Configuration +### 5. Theme Colors + +The dashboard supports custom color overrides for both light and dark modes. In the Settings modal's **Theme** tab, you can customize: + +| Color | Purpose | +|-------|---------| +| **Background** | Main page background color | +| **Card** | Background color for graph cards and content panels | +| **Highlight** | Accent color for hover states and interactive elements | +| **Text** | Primary text color across the dashboard | + +Each color has a **Reset** button to restore its default value. Light and dark mode colors are configured independently, allowing different color schemes per theme. + +See [Settings - Theme Tab](/settings#theme-settings-theme-tab) for more details. + +### 6. Viewing (and Editing) the JSON Configuration You can directly inspect the full configuration—exactly as the UI generates it—by opening the `view` key in the JSON output. This layout metadata is produced using **[GridStack](https://www.npmjs.com/package/gridstack/v/12.2.1)**. diff --git a/docs/dashboard-server.md b/docs/dashboard-server.md index e318f58..62c5687 100644 --- a/docs/dashboard-server.md +++ b/docs/dashboard-server.md @@ -103,7 +103,65 @@ These scripts demonstrate how to: > **Tip:** to implement your server into your test runs look at the example [listener](/listener-integration.md) integration! -### Important Notes +## Admin Page -- This setting **cannot be applied via API endpoints**—it must be set through the Admin Page. -- It ensures a consistent dashboard layout and settings across multiple machines when the above conditions are met. +The admin page (`/admin`) provides a web-based interface for managing the dashboard without writing code. It includes sections for adding and removing outputs, managing log files, and viewing the current database contents. + +### Adding Outputs + +The admin page supports four methods for adding test results: + +| Method | Description | +|--------|-------------| +| **By Absolute Path** | Provide the full path to an `output.xml` on the server filesystem. Optionally add run tags and a version label. | +| **By XML Data** | Paste raw `output.xml` content directly into a text area. Supports run tags, alias, and version label. | +| **By Folder Path** | Provide a folder path; the server recursively scans for `*output*.xml` files. Supports run tags and version label. | +| **By File Upload** | Upload an `output.xml` file directly. Supports run tags and version label. Gzip-compressed files (`.gz`/`.gzip`) are automatically decompressed. | + +### Removing Outputs + +| Method | Description | +|--------|-------------| +| **By Run Start** | Comma-separated `run_start` timestamps (e.g., `2024-07-30 15:27:20.184407`). | +| **By Index** | Supports single values, colon-separated ranges, and semicolon-separated lists (e.g., `1:3;9;13`). | +| **By Alias** | Comma-separated alias names. | +| **By Tag** | Comma-separated tags — removes all runs matching any of the specified tags. | +| **By Limit** | Keep only the N most recent runs; all older runs are deleted. | +| **Remove All** | Irreversibly deletes all runs from the database. | + +### Managing Logs + +| Action | Description | +|--------|-------------| +| **Add Log (Data)** | Paste `log.html` content and provide a log name. | +| **Add Log (File Upload)** | Upload a `log.html` file. Gzip-compressed files are automatically decompressed. | +| **Remove Log by Name** | Remove a specific log file (e.g., `log-20250219-172535.html`). | +| **Remove All Logs** | Irreversibly deletes all uploaded log files. | + +### Database & Log Tables + +The admin page displays two tables: +- **Runs in Database** — all runs currently stored, with run_start, name, alias, and tags +- **Logs on Server** — all log files in the `robot_logs/` directory + +### Navigation + +The admin page menu includes links to: +- **Swagger API Docs** — interactive OpenAPI documentation +- **Redoc API Docs** — alternative API documentation +- **Dashboard** — the main dashboard view + +A confirmation modal is shown before any destructive action (removing outputs or logs). + +## Additional Server Endpoints + +Beyond the main API endpoints listed above, the server exposes two additional routes: + +| Endpoint | Purpose | +|----------|---------| +| `GET /log?path=` | Serves a stored `log.html` file by its path. Used internally by log linking to render uploaded logs in the browser. | +| `GET /{full_path:path}` | Catch-all route that serves static resources (screenshots, images, etc.) relative to the last opened log directory. This allows embedded screenshots in log files to display correctly. | + +## Gzip Upload Support + +Both `/add-output-file` and `/add-log-file` endpoints support gzip-compressed uploads. If the uploaded filename ends with `.gz` or `.gzip`, the server automatically decompresses the file before processing. This is used by the [listener integration](/listener-integration.md) to reduce upload bandwidth. diff --git a/docs/filtering.md b/docs/filtering.md index e29803c..1b0cd2d 100644 --- a/docs/filtering.md +++ b/docs/filtering.md @@ -49,7 +49,15 @@ Global filters are applied to the entire dashboard, affecting all sections and g 5. **Metadata** - Filter runs by **run-level or suite-level metadata**. - - Metadata filters are applied across the entire run. + - Metadata is automatically collected from the `[Metadata]` setting in your Robot Framework test suites. + - Displayed as `key:value` pairs in a dropdown (e.g., `Browser:Chrome`, `Environment:Staging`). + - Selecting a metadata filter shows only runs whose suites contain that metadata entry. + - To define metadata in your `.robot` files, use the `Metadata` setting in the `*** Settings ***` section: + ```robot + *** Settings *** + Metadata Browser Chrome + Metadata Environment Staging + ``` ### Section Filters on Dashboard diff --git a/docs/graphs-tables.md b/docs/graphs-tables.md index 0bc15f3..d343508 100644 --- a/docs/graphs-tables.md +++ b/docs/graphs-tables.md @@ -8,10 +8,34 @@ Discover the graphs and tables included in the Dashboard. This page explains how ## Overview Tab +The Overview tab provides a high-level summary of all test runs across projects. It consists of special sections and a statistics graph. The visibility of each element can be controlled via [Settings - Overview Tab](/settings#overview-settings-overview-tab). + +### Sections + +| Section | Description | +|---------|-------------| +| **Latest Runs** | Displays the most recent run for each project as a card. Each card shows pass/fail/skip counts and duration, color-coded to indicate performance relative to previous runs. Clicking a project card filters the Overview to that project. | +| **Total Stats** | Shows aggregate statistics across all runs grouped by project: total passed, failed, skipped runs, average duration, and average pass rate. | + +### Graphs + | Graph Name | Views | Views Description | Notes | | ------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | ----- | | Run Donut | Percentages | Displays the distribution of passed, failed, skipped tests per run. | - | +### Display Toggles + +The Overview page supports several display toggles (configured in [Settings - Overview Tab](/settings#overview-settings-overview-tab)): + +| Toggle | Description | +|--------|-------------| +| **Projects by Name** | Groups runs by their project name. | +| **Projects by Tag** | Groups runs by `project_` tags instead of name. See [Project Tagging](/advanced-cli-examples#project-tagging). | +| **Prefixes** | Shows or hides the `project_` prefix text on tag-based names. | +| **Percentage Filters** | Enables the duration percentage threshold control for color-coding. | +| **Version Filters** | Enables per-project version filtering with checkbox selectors. | +| **Sort Filters** | Enables sort controls on the Overview page. | + ## Dashboard Tab ### Run Section diff --git a/docs/installation-version-info.md b/docs/installation-version-info.md index fd57ddc..30bf090 100644 --- a/docs/installation-version-info.md +++ b/docs/installation-version-info.md @@ -35,6 +35,8 @@ pip install robotframework-dashboard[server] pip install robotframework-dashboard[all] ``` +> **Note:** `[server]` and `[all]` currently install the same extras (fastapi-offline, uvicorn, python-multipart). Use either one. + ### Dependencies This will automatically install the required dependencies: - robotframework>=6.0 – the core testing framework @@ -61,3 +63,27 @@ robot --version robotdashboard --help ``` This ensures both Robot Framework and the dashboard are installed correctly and are in your PATH. + +## Upgrading & Database Migration + +When upgrading RobotDashboard to a newer version, your existing SQLite database is **automatically migrated**. The tool detects older schemas by checking table column counts and adds any missing columns. + +The following schema changes are applied automatically: + +| Version | Change | +|---------|--------| +| v0.4.3 | Added `tags` column to tests table | +| v0.6.0 | Added `run_alias` column to all tables | +| v0.8.1 | Added `path` column to runs table | +| v0.8.4 | Added `id` column to suites and tests tables | +| v1.0.0 | Added `metadata` column to runs table | +| v1.2.0 | Added `owner` column to keywords table | +| v1.3.0 | Added `project_version` column to runs table | + +No manual intervention is required — simply run `robotdashboard` with your existing database and it will be upgraded in place. Existing data is preserved. + +::: warning Upgrade Considerations +- **Backup first** — once migrated to a newer schema, the database may not be compatible with older versions of RobotDashboard. +- **New columns are empty for existing records** — when new columns are added during migration, they will have empty values for runs that were already in the database. Features that depend on these columns (e.g., metadata filtering, project versioning, keyword library names) will not work for those older runs until they are re-processed. +- **Re-adding runs populates new columns** — to enable new features for older runs, remove them from the database and re-add their `output.xml` files. This will populate all columns with the correct data. +::: diff --git a/docs/log-linking.md b/docs/log-linking.md index d08db2f..ac6e9a7 100644 --- a/docs/log-linking.md +++ b/docs/log-linking.md @@ -78,6 +78,20 @@ Make sure to start the server with the `--uselogs` flag so that graph elements b For more details about the server and its API, see [Dashboard Server](/dashboard-server.md). +## Deep Linking + +When clicking a data point on a **suite** or **test** graph, the dashboard doesn't just open `log.html` — it navigates directly to the corresponding suite or test within the log file by appending the element's ID as a URL fragment (e.g., `log.html#s1-s1-t2`). + +This means you land exactly on the relevant suite or test in the log, without needing to manually search for it. + +### Label Clicks + +Clicking on **X-axis or Y-axis run labels** (run_start or alias) on any graph also opens the corresponding log file for that run. + +### Missing Log Behavior + +If no log path is stored in the database for a run, clicking a graph element will show a **ERR_FILE_NOT_FOUND** error. + ## Accessing Reports Robot Framework `report.html` files can also be accessed through the log file: diff --git a/docs/settings.md b/docs/settings.md index fc08bad..152cad2 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -8,13 +8,15 @@ RobotFramework Dashboard includes a fully customizable configuration system that ## General -The settings are divided into **three tabs**: +The settings modal is divided into **five tabs**: 1. **Graphs** – general dashboard and chart behavior 2. **Keywords** – which keyword libraries appear in keyword graphs -3. **JSON** – direct editing of the full JSON config for advanced users +3. **Overview** – controls for the Overview page layout and toggles +4. **Theme** – custom color overrides for light and dark mode +5. **JSON** – direct editing of the full JSON config for advanced users -## Theme +## Theme Toggle The dashboard can be displayed in **light mode** or **dark mode**. This setting is applied globally across all dashboard pages and graphs. @@ -33,10 +35,11 @@ The **Graphs** tab contains the core configuration options for all charts in the | **Display Axis Titles** | Shows axis labels (e.g., *Run Time*, *Pass/Fail Count*). Disable for a cleaner look. | | **Display Run Start/Alias Labels On Axes** | Enables labels directly on graph axes. Disable for a cleaner look. | | **Display Alias Labels** | Labels graphs using **aliases** instead of the default *run_start*. | +| **Display Prefixes** | Shows or hides the `project_` prefix text on Overview page tags. | | **Display Milliseconds Run Start Labels** | Adds millisecond precision to run_start timestamps. | | **Display Drawing Animations** | Enables animated graph rendering. | | **Animation Duration (Milliseconds)** | Length of animation, e.g. `1500` ms. | -| **Bar Gdraph Edge Rouning (Pixels)** | Controls rounding of bar edges (e.g., `0` = square, `8` = softer). | +| **Bar Graph Edge Rounding (Pixels)** | Controls rounding of bar edges (e.g., `0` = square, `8` = softer). | ### Saving Settings @@ -60,6 +63,54 @@ This allows you to include or exclude specific libraries based on your dashboard - Closing the modal **automatically saves** your keyword selections - No need to press additional buttons in this tab +## Overview Settings (Overview Tab) + +The **Overview** tab controls which sections and filters are visible on the Overview page. These toggles let you tailor the Overview layout to your needs. + +### Details + +| Setting | Default | Description | +|---------|---------|-------------| +| **Latest Runs** | On | Show the Latest Runs bar displaying the most recent run per project with color-coded durations. | +| **Total Stats** | On | Show the Total Stats bar with aggregate pass/fail/skip counts and average pass rates across all runs per project. | +| **Projects by Name** | On | Group and display projects by their run name on the Overview. | +| **Projects by Tag** | Off | Group and display projects by custom `project_` tags. See [Project Tagging](/advanced-cli-examples#project-tagging). | +| **Display Prefixes** | On | Show the `project_` prefix text on tag-based project names. | +| **Percentage Filters** | On | Show the duration percentage threshold filter for color-coding run durations. | +| **Version Filters** | On | Show the version filter allowing per-project version selection. | +| **Sort Filters** | On | Show the sort filter controls on the Overview. | + +### Saving Overview Settings + +- Closing the modal **automatically saves** your overview selections +- No need to press additional buttons in this tab + +## Theme Settings (Theme Tab) + +The **Theme** tab allows you to override the default colors used by the dashboard in both light and dark modes. Each mode has independent color customization. + +### Details + +| Color | Description | +|-------|-------------| +| **Background** | The main page background color. | +| **Card** | The background color for graph cards and content panels. | +| **Highlight** | The accent color used for hover states and interactive elements. | +| **Text** | The primary text color across the dashboard. | + +### Usage + +- Select **Light** or **Dark** mode to edit the colors for that theme +- Use the color pickers to set custom values +- Each color has a **Reset** button to restore its default value +- Changes apply immediately when closing the modal + +### Saving Theme Settings + +- Closing the modal **automatically saves** your theme selections +- Theme colors are stored in localStorage alongside other settings +- Export via the JSON tab to share custom themes with your team + ## JSON Settings (JSON Tab) For advanced use cases, you can directly edit the internal settings JSON. diff --git a/docs/tabs-pages.md b/docs/tabs-pages.md index 85c44bd..948dc18 100644 --- a/docs/tabs-pages.md +++ b/docs/tabs-pages.md @@ -18,6 +18,15 @@ For a more in depth explanation, hover over the "i" icons in the Overview Statis ## Dashboard Page The Dashboard page offers rich, interactive visualizations for a detailed analysis of test results. Graphs are available at four levels—runs, suites, tests, and keywords—allowing teams to track performance, detect flaky tests, and monitor trends over time. The layout is fully customizable (see [Customization](customization.md)). You can drag and drop graphs and sections to create your preferred view. Most graphs support multiple display modes, including timeline, percentage, bar, donut, and advanced types like boxplots and heatmaps. Each graph also provides detailed popups to explain what the view represents and how the data is calculated (see [Graphs & Tables](graphs-tables.md)). +### Unified Mode +The Dashboard supports a **Unified Mode** that combines all four sections (run, suite, test, keyword) into a single scrollable view instead of separate tabbed sections. This can be enabled via [Settings - Graphs Tab](/settings#general-settings-graphs-tab) using the **Unified Dashboard Sections** toggle. + +In Unified Mode: +- All graphs from all sections are shown on one page +- A **Section Filters** button opens a modal for cross-section filtering (suite, test, and keyword filters) +- The page title defaults to "Dashboard Statistics" or uses the custom `--dashboardtitle` value if provided +- Layout customization and persistence works independently from the standard view + ## Compare Page The Compare page enables side-by-side comparison of up to four test runs. It presents comprehensive statistics, charts, and summaries for each run, making it simple to identify differences, trends, regressions, or improvements between builds or environments. diff --git a/example/database/abstractdb.py b/example/database/abstractdb.py index b3cf1a6..5b4c019 100644 --- a/example/database/abstractdb.py +++ b/example/database/abstractdb.py @@ -35,7 +35,7 @@ def run_start_exists(self, run_start: str) -> bool: @abstractmethod def insert_output_data( - self, output_data: dict, tags: list, run_alias: str, path: Path + self, output_data: dict, tags: list, run_alias: str, path: Path, project_version: str ) -> None: """Mandatory: This function inserts the data of an output file into the database""" pass diff --git a/example/database/sqlite3.py b/example/database/sqlite3.py index 3aa5464..1f2eb64 100644 --- a/example/database/sqlite3.py +++ b/example/database/sqlite3.py @@ -1,11 +1,12 @@ import sqlite3 from pathlib import Path +from time import time from robotframework_dashboard.abstractdb import AbstractDatabaseProcessor -CREATE_RUNS = """ CREATE TABLE IF NOT EXISTS runs ("run_start" TEXT, "full_name" TEXT, "name" TEXT, "total" INTEGER, "passed" INTEGER, "failed" INTEGER, "skipped" INTEGER, "elapsed_s" TEXT, "start_time" TEXT, "tags" TEXT, "run_alias" TEXT, "path" TEXT, unique(run_start, full_name)); """ +CREATE_RUNS = """ CREATE TABLE IF NOT EXISTS runs ("run_start" TEXT, "full_name" TEXT, "name" TEXT, "total" INTEGER, "passed" INTEGER, "failed" INTEGER, "skipped" INTEGER, "elapsed_s" TEXT, "start_time" TEXT, "tags" TEXT, "run_alias" TEXT, "path" TEXT, "metadata" TEXT, "project_version" TEXT, unique(run_start, full_name)); """ CREATE_SUITES = """ CREATE TABLE IF NOT EXISTS suites ("run_start" TEXT, "full_name" TEXT, "name" TEXT, "total" INTEGER, "passed" INTEGER, "failed" INTEGER, "skipped" INTEGER, "elapsed_s" TEXT, "start_time" TEXT, "run_alias" TEXT, "id" TEXT); """ CREATE_TESTS = """ CREATE TABLE IF NOT EXISTS tests ("run_start" TEXT, "full_name" TEXT, "name" TEXT, "passed" INTEGER, "failed" INTEGER, "skipped" INTEGER, "elapsed_s" TEXT, "start_time" TEXT, "message" TEXT, "tags" TEXT, "run_alias" TEXT, "id" TEXT); """ -CREATE_KEYWORDS = """ CREATE TABLE IF NOT EXISTS keywords ("run_start" TEXT, "name" TEXT, "passed" INTEGER, "failed" INTEGER, "skipped" INTEGER, "times_run" TEXT, "total_time_s" TEXT, "average_time_s" TEXT, "min_time_s" TEXT, "max_time_s" TEXT, "run_alias" TEXT); """ +CREATE_KEYWORDS = """ CREATE TABLE IF NOT EXISTS keywords ("run_start" TEXT, "name" TEXT, "passed" INTEGER, "failed" INTEGER, "skipped" INTEGER, "times_run" TEXT, "total_time_s" TEXT, "average_time_s" TEXT, "min_time_s" TEXT, "max_time_s" TEXT, "run_alias" TEXT, "owner" TEXT); """ RUN_TABLE_EXISTS = ( """SELECT name FROM sqlite_master WHERE type='table' AND name='runs';""" @@ -13,6 +14,8 @@ RUN_TABLE_LENGTH = """PRAGMA table_info(runs);""" RUN_TABLE_UPDATE_ALIAS = """ALTER TABLE runs ADD COLUMN run_alias TEXT;""" RUN_TABLE_UPDATE_PATH = """ALTER TABLE runs ADD COLUMN path TEXT;""" +RUN_TABLE_UPDATE_METADATA = """ALTER TABLE runs ADD COLUMN metadata TEXT;""" +RUN_TABLE_UPDATE_PROJECT_VERSION = """ALTER TABLE runs ADD COLUMN project_version TEXT;""" SUITE_TABLE_LENGTH = """PRAGMA table_info(suites);""" SUITE_TABLE_UPDATE_ALIAS = """ALTER TABLE suites ADD COLUMN run_alias TEXT;""" @@ -25,11 +28,12 @@ KEYWORD_TABLE_LENGTH = """PRAGMA table_info(keywords);""" KEYWORD_TABLE_UPDATE_ALIAS = """ALTER TABLE keywords ADD COLUMN run_alias TEXT;""" +KEYWORD_TABLE_UPDATE_OWNER = """ALTER TABLE keywords ADD COLUMN owner TEXT;""" -INSERT_INTO_RUNS = """ INSERT INTO runs VALUES (?,?,?,?,?,?,?,?,?,?,?,?) """ +INSERT_INTO_RUNS = """ INSERT INTO runs VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) """ INSERT_INTO_SUITES = """ INSERT INTO suites VALUES (?,?,?,?,?,?,?,?,?,?,?) """ INSERT_INTO_TESTS = """ INSERT INTO tests VALUES (?,?,?,?,?,?,?,?,?,?,?,?) """ -INSERT_INTO_KEYWORDS = """ INSERT INTO keywords VALUES (?,?,?,?,?,?,?,?,?,?,?) """ +INSERT_INTO_KEYWORDS = """ INSERT INTO keywords VALUES (?,?,?,?,?,?,?,?,?,?,?,?) """ SELECT_FROM_RUNS = """ SELECT * FROM runs """ SELECT_RUN_STARTS_FROM_RUNS = """ SELECT run_start FROM runs """ @@ -45,6 +49,8 @@ UPDATE_RUN_PATH = """ UPDATE runs SET path="{path}" WHERE run_start="{run_start}" """ +VACUUM_DATABASE = """ VACUUM """ + class DatabaseProcessor(AbstractDatabaseProcessor): def __init__(self, database_path: Path): @@ -97,15 +103,25 @@ def get_keywords_length(): # run/suite/test/keyword: run_alias added in 0.6.0 # run: path added in 0.8.1 # suite/test: id was added in 0.8.4 + # run: metadata was added in 1.0.0 + # keyword: owner was added in 1.2.0 + # run: project_version was added in 1.3.0 run_table_length = get_runs_length() - if run_table_length == 10: + if run_table_length == 10: # -> column alias not present self.connection.cursor().execute(RUN_TABLE_UPDATE_ALIAS) self.connection.commit() run_table_length = get_runs_length() - if run_table_length == 11: + if run_table_length == 11: # -> column path not present self.connection.cursor().execute(RUN_TABLE_UPDATE_PATH) self.connection.commit() run_table_length = get_runs_length() + if run_table_length == 12: # -> column metadata not present + self.connection.cursor().execute(RUN_TABLE_UPDATE_METADATA) + self.connection.commit() + run_table_length = get_runs_length() + if run_table_length == 13: # -> column project_version not present + self.connection.cursor().execute(RUN_TABLE_UPDATE_PROJECT_VERSION) + self.connection.commit() suite_table_length = get_suites_length() if suite_table_length == 9: @@ -136,6 +152,10 @@ def get_keywords_length(): self.connection.cursor().execute(KEYWORD_TABLE_UPDATE_ALIAS) self.connection.commit() keyword_table_length = get_keywords_length() + if keyword_table_length == 11: + self.connection.cursor().execute(KEYWORD_TABLE_UPDATE_OWNER) + self.connection.commit() + keyword_table_length = get_keywords_length() else: self.connection.cursor().execute(CREATE_RUNS) self.connection.cursor().execute(CREATE_SUITES) @@ -148,11 +168,11 @@ def close_database(self): self.connection.close() def insert_output_data( - self, output_data: dict, tags: list, run_alias: str, path: Path + self, output_data: dict, tags: list, run_alias: str, path: Path, project_version: str ): """This function inserts the data of an output file into the database""" try: - self._insert_runs(output_data["runs"], tags, run_alias, path) + self._insert_runs(output_data["runs"], tags, run_alias, path, project_version) self._insert_suites(output_data["suites"], run_alias) self._insert_tests(output_data["tests"], run_alias) self._insert_keywords(output_data["keywords"], run_alias) @@ -161,14 +181,20 @@ def insert_output_data( f" ERROR: something went wrong with the database: {error}" ) - def _insert_runs(self, runs: list, tags: list, run_alias: str, path: Path): + def _insert_runs(self, runs: list, tags: list, run_alias: str, path: Path, project_version): """Helper function to insert the run data with the run tags""" full_runs = [] for run in runs: - run += (",".join(tags),) - run += (run_alias,) - run += (str(path),) - full_runs.append(run) + *rest, metadata = run + new_run = ( + *rest, + ",".join(tags), + run_alias, + str(path), + metadata, + project_version, + ) + full_runs.append(new_run) self.connection.executemany(INSERT_INTO_RUNS, full_runs) self.connection.commit() @@ -198,7 +224,9 @@ def _insert_keywords(self, keywords: list, run_alias: str): """Helper function to insert the keyword data""" full_keywords = [] for keyword in keywords: - keyword += (run_alias,) + keyword = list(keyword) + keyword.insert(10, run_alias) + keyword = tuple(keyword) full_keywords.append(keyword) self.connection.executemany(INSERT_INTO_KEYWORDS, full_keywords) self.connection.commit() @@ -292,67 +320,104 @@ def remove_runs(self, remove_runs: list): for run in remove_runs: try: if "run_start=" in run: - run_start = run.replace("run_start=", "") - if not run_start in run_starts: - print( - f" ERROR: Could not find run to remove from the database: run_start={run_start}" - ) - console += f" ERROR: Could not find run to remove from the database: run_start={run_start}\n" - continue - self._remove_run(run_start) - print(f" Removed run from the database: run_start={run_start}") - console += ( - f" Removed run from the database: run_start={run_start}\n" - ) + console += self._remove_by_run_start(run, run_starts) elif "index=" in run: - runs = run.replace("index=", "").split(";") - indexes = [] - for run in runs: - if ":" in run: - start, stop = run.split(":") - for i in range(int(start), int(stop) + 1): - indexes.append(i) - else: - indexes.append(int(run)) - for index in indexes: - self._remove_run(run_starts[index]) - print( - f" Removed run from the database: index={index}, run_start={run_starts[index]}" - ) - console += f" Removed run from the database: index={index}, run_start={run_starts[index]}\n" + console += self._remove_by_index(run, run_starts) elif "alias=" in run: - alias = run.replace("alias=", "") - self._remove_run(run_starts[run_aliases.index(alias)]) - print( - f" Removed run from the database: alias={alias}, run_start={run_starts[run_aliases.index(alias)]}" - ) - console += f" Removed run from the database: alias={alias}, run_start={run_starts[run_aliases.index(alias)]}\n" + console += self._remove_by_alias(run, run_starts, run_aliases) elif "tag=" in run: - tag = run.replace("tag=", "") - removed = 0 - for index, run_tag in enumerate(run_tags): - if tag in run_tag: - self._remove_run(run_starts[index]) - print( - f" Removed run from the database: tag={tag}, run_start={run_starts[index]}" - ) - console += f" Removed run from the database: tag={tag}, run_start={run_starts[index]}\n" - removed += 1 - if removed == 0: - print( - f" WARNING: no runs were removed as no runs were found with tag: {tag}" - ) - console += f" WARNING: no runs were removed as no runs were found with tag: {tag}\n" + console += self._remove_by_tag(run, run_starts, run_tags) + elif "limit=" in run: + console += self._remove_by_limit(run, run_starts) else: print( f" ERROR: incorrect usage of the remove_run feature ({run}), check out robotdashboard --help for instructions" ) console += f" ERROR: incorrect usage of the remove_run feature ({run}), check out robotdashboard --help for instructions\n" except: - print(f" ERROR: Could not find run to remove from the database: {run}") - console += ( - f" ERROR: Could not find run to remove from the database: {run}\n" + print( + f" ERROR: Could not find run to remove from the database: {run}, check out robotdashboard --help for instructions" + ) + console += f" ERROR: Could not find run to remove from the database: {run}, check out robotdashboard --help for instructions\n" + return console + + def _remove_by_run_start(self, run: str, run_starts: list): + console = "" + run_start = run.replace("run_start=", "") + if not run_start in run_starts: + print( + f" ERROR: Could not find run to remove from the database: run_start={run_start}" + ) + console += f" ERROR: Could not find run to remove from the database: run_start={run_start}\n" + return console + self._remove_run(run_start) + print(f" Removed run from the database: run_start={run_start}") + console += f" Removed run from the database: run_start={run_start}\n" + return console + + def _remove_by_index(self, run: str, run_starts: list): + console = "" + runs = run.replace("index=", "").split(";") + indexes = [] + for run in runs: + if ":" in run: + start, stop = run.split(":") + for i in range(int(start), int(stop) + 1): + indexes.append(i) + else: + indexes.append(int(run)) + for index in indexes: + self._remove_run(run_starts[index]) + print( + f" Removed run from the database: index={index}, run_start={run_starts[index]}" + ) + console += f" Removed run from the database: index={index}, run_start={run_starts[index]}\n" + return console + + def _remove_by_alias(self, run: str, run_starts: list, run_aliases: list): + console = "" + alias = run.replace("alias=", "") + self._remove_run(run_starts[run_aliases.index(alias)]) + print( + f" Removed run from the database: alias={alias}, run_start={run_starts[run_aliases.index(alias)]}" + ) + console += f" Removed run from the database: alias={alias}, run_start={run_starts[run_aliases.index(alias)]}\n" + return console + + def _remove_by_tag(self, run: str, run_starts: list, run_tags: list): + console = "" + tag = run.replace("tag=", "") + removed = 0 + for index, run_tag in enumerate(run_tags): + if tag in run_tag: + self._remove_run(run_starts[index]) + print( + f" Removed run from the database: tag={tag}, run_start={run_starts[index]}" ) + console += f" Removed run from the database: tag={tag}, run_start={run_starts[index]}\n" + removed += 1 + if removed == 0: + print( + f" WARNING: no runs were removed as no runs were found with tag: {tag}" + ) + console += f" WARNING: no runs were removed as no runs were found with tag: {tag}\n" + return console + + def _remove_by_limit(self, run: str, run_starts: list): + console = "" + limit = int(run.replace("limit=", "")) + if limit >= len(run_starts): + print( + f" WARNING: no runs were removed as the provided limit ({limit}) is higher than the total number of runs ({len(run_starts)})" + ) + console += f" WARNING: no runs were removed as the provided limit ({limit}) is higher than the total number of runs ({len(run_starts)})\n" + return console + for index in range(len(run_starts) - limit): + self._remove_run(run_starts[index]) + print( + f" Removed run from the database: index={index}, run_start={run_starts[index]}" + ) + console += f" Removed run from the database: index={index}, run_start={run_starts[index]}\n" return console def _remove_run(self, run_start: str): @@ -365,10 +430,20 @@ def _remove_run(self, run_start: str): ) self.connection.commit() + def vacuum_database(self): + """This function vacuums the database to reduce the size after removing runs""" + start = time() + self.connection.cursor().execute(VACUUM_DATABASE) + self.connection.commit() + end = time() + console = f" Vacuumed the database in {round(end - start, 2)} seconds\n" + print(f" Vacuumed the database in {round(end - start, 2)} seconds") + return console + def update_output_path(self, log_path: str): """Function to update the output_path using the log path that the server has used""" console = "" - log_name = log_path[11:] + log_name = Path(log_path).name output_name = log_name.replace("log", "output").replace(".html", ".xml") data = self.connection.cursor().execute(SELECT_FROM_RUNS).fetchall() for entry in data: diff --git a/robotframework_dashboard/js/graph_creation/test.js b/robotframework_dashboard/js/graph_creation/test.js index 52fad20..5c33393 100644 --- a/robotframework_dashboard/js/graph_creation/test.js +++ b/robotframework_dashboard/js/graph_creation/test.js @@ -166,6 +166,15 @@ function _build_test_statistics_line_config() { }, }, }; + config.options.onClick = (event, chartElement) => { + if (chartElement.length) { + const idx = chartElement[0].index; + const meta = pointMeta[idx]; + if (meta) { + open_log_file(event, chartElement, undefined, meta.runStart, meta.testLabel); + } + } + }; update_height("testStatisticsVertical", testLabels.length, "timeline"); return config; } diff --git a/robotframework_dashboard/js/log.js b/robotframework_dashboard/js/log.js index 53683ba..99341db 100644 --- a/robotframework_dashboard/js/log.js +++ b/robotframework_dashboard/js/log.js @@ -4,12 +4,14 @@ import { settings } from './variables/settings.js'; import { filteredRuns } from './variables/globals.js'; // function to open the log files through the graphs -function open_log_file(event, chartElement, callbackData = undefined) { +function open_log_file(event, chartElement, callbackData = undefined, directRunStart = undefined, directTestName = undefined) { if (!use_logs) { return } const graphType = event.chart.config._config.type const graphId = event.chart.canvas.id var runStart = "" - if (graphType == "doughnut") { + if (directRunStart) { + runStart = directRunStart + } else if (graphType == "doughnut") { runStart = callbackData } else if (callbackData) { const index = chartElement[0].element.$context.raw.x[0] @@ -29,7 +31,7 @@ function open_log_file(event, chartElement, callbackData = undefined) { alert("Log file error: this output didn't have a path in the database so the log file cannot be found!"); return } - path = update_log_path_with_id(path, graphId, chartElement, event) + path = update_log_path_with_id(path, graphId, chartElement, event, directTestName) open_log_from_path(path) } @@ -77,7 +79,7 @@ function open_log_from_label(chart, click) { } // function to add the suite or test id to the log path url -function update_log_path_with_id(path, graphId, chartElement, event) { +function update_log_path_with_id(path, graphId, chartElement, event, directTestName = undefined) { if (graphId.includes("run") || graphId.includes("keyword")) { return transform_file_path(path) } // can"t select a run or keyword in the suite/log log.html @@ -95,7 +97,9 @@ function update_log_path_with_id(path, graphId, chartElement, event) { } id = suites.find(suite => suite.name === name && suite.run_start === runStart) } else { // it contains a test - if (graphId == "testStatisticsGraph" || graphId == "testMostFlakyGraph" || graphId == "testRecentMostFlakyGraph" || graphId == "testMostFailedGraph" || graphId == "testRecentMostFailedGraph" || graphId == "testMostTimeConsumingGraph" || graphId == "compareTestsGraph") { + if (directTestName) { + name = directTestName + } else if (graphId == "testStatisticsGraph" || graphId == "testMostFlakyGraph" || graphId == "testRecentMostFlakyGraph" || graphId == "testMostFailedGraph" || graphId == "testRecentMostFailedGraph" || graphId == "testMostTimeConsumingGraph" || graphId == "compareTestsGraph") { name = chartElement[0].element.$context.raw.y } else if (graphId == "testDurationGraph") { if (graphType == "bar") { From 63a1cb82f713c86f7d0ea5c2b7dd06cbd13da332 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Sat, 28 Feb 2026 20:37:48 +0100 Subject: [PATCH 23/26] Accidental example script fix --- scripts/example.bat | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/scripts/example.bat b/scripts/example.bat index 951cfe6..029e144 100644 --- a/scripts/example.bat +++ b/scripts/example.bat @@ -1,15 +1,15 @@ -python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002134.xml:prod:project_1 --projectversion 0.1 -python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002151.xml:dev:project_2 --projectversion 0.1 -python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002222.xml:prod:project_1 --projectversion 0.1 -python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002257.xml:dev:project_2 --projectversion 0.1 -python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002338.xml:prod:project_1 --projectversion 1.1 -python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002400.xml:dev:project_2 --projectversion 1.2 -python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002431.xml:prod:project_1 --projectversion 1.2 -python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002457.xml:dev:project_2 --projectversion 1.2 -python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002528.xml:prod:project_1 --projectversion 2.0 -python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002549.xml:dev:project_2 --projectversion 2.3 -python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002636.xml:prod:project_1 --projectversion 2.3 -python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002703.xml:dev:project_2 --projectversion 2.3 -python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002739.xml:prod:project_1 -python -m robotframework_dashboard.main -g -l -o .\atest\resources\outputs\output-20250313-002915.xml:prod:project_1 --projectversion 2.0 -python -m robotframework_dashboard.main -n robot_dashboard -o .\atest\resources\outputs\output-20250313-003006.xml:prod:project_1 --uselogs \ No newline at end of file +robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002134.xml:prod:project_1 --projectversion 0.1 +robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002151.xml:dev:project_2 --projectversion 0.1 +robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002222.xml:prod:project_1 --projectversion 0.1 +robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002257.xml:dev:project_2 --projectversion 0.1 +robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002338.xml:prod:project_1 --projectversion 1.1 +robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002400.xml:dev:project_2 --projectversion 1.2 +robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002431.xml:prod:project_1 --projectversion 1.2 +robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002457.xml:dev:project_2 --projectversion 1.2 +robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002528.xml:prod:project_1 --projectversion 2.0 +robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002549.xml:dev:project_2 --projectversion 2.3 +robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002636.xml:prod:project_1 --projectversion 2.3 +robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002703.xml:dev:project_2 --projectversion 2.3 +robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002739.xml:prod:project_1 +robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002915.xml:prod:project_1 --projectversion 2.0 +robotdashboard -n robot_dashboard -o .\atest\resources\outputs\output-20250313-003006.xml:prod:project_1 --uselogs \ No newline at end of file From 849772906c3519336bb0f1531d6168f031bcb948 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Sat, 28 Feb 2026 20:52:48 +0100 Subject: [PATCH 24/26] Code cleanup --- .../js/graph_creation/compare.js | 20 +- .../js/graph_creation/keyword.js | 3 +- .../js/graph_creation/overview.js | 338 +++++++++--------- .../js/graph_creation/run.js | 68 ++-- .../js/graph_creation/suite.js | 40 +-- .../js/graph_creation/tables.js | 34 +- .../js/graph_creation/test.js | 68 +--- 7 files changed, 239 insertions(+), 332 deletions(-) diff --git a/robotframework_dashboard/js/graph_creation/compare.js b/robotframework_dashboard/js/graph_creation/compare.js index d8b6bc4..8820b86 100644 --- a/robotframework_dashboard/js/graph_creation/compare.js +++ b/robotframework_dashboard/js/graph_creation/compare.js @@ -8,7 +8,7 @@ import { filteredRuns, filteredSuites, filteredTests } from "../variables/global import { settings } from "../variables/settings.js"; import { create_chart, update_chart } from "./chart_factory.js"; -// build config for compare statistics graph +// build functions function _build_compare_statistics_config() { const graphData = get_compare_statistics_graph_data(filteredRuns); const config = get_graph_config("bar", graphData, "", "Run", "Amount"); @@ -16,19 +16,11 @@ function _build_compare_statistics_config() { return config; } -// function to create the compare statistics in the compare section -function create_compare_statistics_graph() { create_chart("compareStatisticsGraph", _build_compare_statistics_config, false); } - -// build config for compare suite duration graph function _build_compare_suite_duration_config() { const graphData = get_compare_suite_duration_data(filteredSuites); return get_graph_config("radar", graphData, ""); } -// function to create the compare statistics in the compare section -function create_compare_suite_duration_graph() { create_chart("compareSuiteDurationGraph", _build_compare_suite_duration_config, false); } - -// build config for compare tests graph function _build_compare_tests_config() { const data = get_test_statistics_data(filteredTests); const graphData = data[0] @@ -79,16 +71,14 @@ function _build_compare_tests_config() { return config; } -// function to create the compare statistics in the compare section +// create functions +function create_compare_statistics_graph() { create_chart("compareStatisticsGraph", _build_compare_statistics_config, false); } +function create_compare_suite_duration_graph() { create_chart("compareSuiteDurationGraph", _build_compare_suite_duration_config, false); } function create_compare_tests_graph() { create_chart("compareTestsGraph", _build_compare_tests_config); } -// update function for compare statistics graph - updates existing chart in-place +// update functions function update_compare_statistics_graph() { update_chart("compareStatisticsGraph", _build_compare_statistics_config, false); } - -// update function for compare suite duration graph - updates existing chart in-place function update_compare_suite_duration_graph() { update_chart("compareSuiteDurationGraph", _build_compare_suite_duration_config, false); } - -// update function for compare tests graph - updates existing chart in-place function update_compare_tests_graph() { update_chart("compareTestsGraph", _build_compare_tests_config); } export { diff --git a/robotframework_dashboard/js/graph_creation/keyword.js b/robotframework_dashboard/js/graph_creation/keyword.js index ab46361..3df62be 100644 --- a/robotframework_dashboard/js/graph_creation/keyword.js +++ b/robotframework_dashboard/js/graph_creation/keyword.js @@ -6,7 +6,7 @@ import { get_graph_config } from "../graph_data/graph_config.js"; import { create_chart, update_chart } from "./chart_factory.js"; import { build_most_failed_config, build_most_time_consuming_config } from "./config_helpers.js"; -// build config for keyword statistics graph +// build functions function _build_keyword_statistics_config() { const data = get_statistics_graph_data("keyword", settings.graphTypes.keywordStatisticsGraphType, filteredKeywords); const graphData = data[0] @@ -22,7 +22,6 @@ function _build_keyword_statistics_config() { return config; } -// build config for keyword duration graphs (times run, total, average, min, max) function _build_keyword_duration_config(graphKey, field, yLabel) { const graphData = get_duration_graph_data("keyword", settings.graphTypes[`${graphKey}GraphType`], field, filteredKeywords); var config; diff --git a/robotframework_dashboard/js/graph_creation/overview.js b/robotframework_dashboard/js/graph_creation/overview.js index f9a162e..6fb435b 100644 --- a/robotframework_dashboard/js/graph_creation/overview.js +++ b/robotframework_dashboard/js/graph_creation/overview.js @@ -118,6 +118,175 @@ function generate_overview_section_html(sectionId, prefix, filtersHtml = '') { `; } +function generate_overview_card_html( + projectName, + stats, + rounded_duration, + status, + runNumber, + compares, + passed_runs, + log_path, + log_name, + svg, + idPostfix, + projectVersion = null, + isForOverview = false, + isTotalStats = false, + sectionPrefix = 'overview', +) { + const normalizedProjectVersion = projectVersion ?? "None"; + // ensure overview stats and project bar card ids unique + const projectNameForElementId = isForOverview ? `${sectionPrefix}${projectName}` : projectName; + const showRunNumber = !(isForOverview && isTotalStats); + const runNumberHtml = showRunNumber ? `
#${runNumber}
` : ''; + let smallVersionHtml = ` +
+
Version:
+
${normalizedProjectVersion}
+
`; + if (isTotalStats) { + smallVersionHtml = ''; + compares = ''; + } + // for project bars + const versionsForProject = Object.keys(versionsByProject[projectName]); + const projectHasVersions = !(versionsForProject.length === 1 && versionsForProject[0] === "None"); + // for overview statistics + // Preserve the original project name (used for logic like tag-detection), + // but compute a display name that omits the 'project_' prefix when prefixes are hidden. + const originalProjectName = projectName; + const displayProjectName = settings.show.prefixes ? projectName : projectName.replace(/^project_/, ''); + projectName = displayProjectName; + let cardTitle = ` +
${displayProjectName}
+ `; + if (!isForOverview) { + // Project bar cards: customize based on project type + if (originalProjectName.startsWith('project_')) { + // Tagged projects: display name with inline version + cardTitle = ` +
${stats[5]}, Version: ${normalizedProjectVersion}
+ `; + } else if (projectHasVersions) { + // Non-tagged projects with versions: interactive version title + cardTitle = ` +
+
Version:
+
+ ${normalizedProjectVersion} +
+
+ `; + } else { + // Non-tagged projects without versions: empty title placeholder + cardTitle = ` +
+ `; + } + smallVersionHtml = ''; + } + const totalStatsHeader = isTotalStats ? `
Run Stats
` : ''; + const totalStatsAverage = isTotalStats ? `
Average Run Duration
` : ''; + const logLinkHtml = log_name ? `${log_name}` : ''; + return ` +
+
+
+
+
+ ${cardTitle} +
+ ${runNumberHtml} +
+
+
+ +
+
+
+ ${totalStatsHeader} +
Passed: ${stats[0]}
+
Failed: ${stats[1]}
+
Skipped: ${stats[2]}
+
+
+
+
+ ${totalStatsAverage} +
+ + ${svg} + + + ${rounded_duration} + +
+
Passed Runs: ${passed_runs}%
+ ${smallVersionHtml} + ${logLinkHtml} +
+
+
+
+
+
`; +} + +function apply_overview_latest_version_text_filter() { + const versionFilterInput = document.getElementById("overviewLatestVersionFilterSearch"); + const cardsContainer = document.getElementById("overviewLatestRunCardsContainer"); + if (!versionFilterInput || !cardsContainer) return; + const filterValue = versionFilterInput.value.toLowerCase(); + const runCards = Array.from(cardsContainer.querySelectorAll("div.overview-card")); + runCards.forEach(card => { + const version = (card.dataset.projectVersion ?? "").toLowerCase(); + card.style.display = version.includes(filterValue) ? "" : "none"; + }); +} + +function clear_project_filter() { + document.getElementById("runs").value = "All"; + document.getElementById("runTagCheckBoxesFilter").value = ""; + const tagElements = document.getElementById("runTag").getElementsByTagName("input"); + for (const input of tagElements) { + input.checked = false; + input.parentElement.classList.remove("d-none"); //show filtered rows + if (input.id == "All") input.checked = true; + } + update_filter_active_indicator("All", "filterRunTagSelectedIndicator"); +} + +function set_filter_show_current_project(projectName) { + if (projectName.startsWith("project_")) { + selectedTagSetting = projectName; + setTimeout(() => { // hack to prevent update_menu calls from hinderance + update_filter_active_indicator("All", "filterRunTagSelectedIndicator"); + }, 500); + + } else { + selectedRunSetting = projectName; + } +} + +function _update_overview_heading(containerId, titleId, titleText) { + const overviewCardsContainer = document.getElementById(containerId); + if (!overviewCardsContainer) return; + const amountOfProjectsShown = overviewCardsContainer.querySelectorAll(".overview-card").length; + const pluralPostFix = amountOfProjectsShown !== 1 ? 's' : ''; + const headerContent = `
showing ${amountOfProjectsShown} project${pluralPostFix}
`; + document.getElementById(titleId).innerHTML = ` + ${titleText} + ${headerContent} + `; +} + // create overview latest runs section dynamically function create_overview_latest_runs_section() { const percentageSelectHtml = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100].map(val => @@ -621,126 +790,6 @@ function create_overview_run_donut(run, chartElementPostfix, projectName) { el.chartInstance = new Chart(el, config); } -function generate_overview_card_html( - projectName, - stats, - rounded_duration, - status, - runNumber, - compares, - passed_runs, - log_path, - log_name, - svg, - idPostfix, - projectVersion = null, - isForOverview = false, - isTotalStats = false, - sectionPrefix = 'overview', -) { - const normalizedProjectVersion = projectVersion ?? "None"; - // ensure overview stats and project bar card ids unique - const projectNameForElementId = isForOverview ? `${sectionPrefix}${projectName}` : projectName; - const showRunNumber = !(isForOverview && isTotalStats); - const runNumberHtml = showRunNumber ? `
#${runNumber}
` : ''; - let smallVersionHtml = ` -
-
Version:
-
${normalizedProjectVersion}
-
`; - if (isTotalStats) { - smallVersionHtml = ''; - compares = ''; - } - // for project bars - const versionsForProject = Object.keys(versionsByProject[projectName]); - const projectHasVersions = !(versionsForProject.length === 1 && versionsForProject[0] === "None"); - // for overview statistics - // Preserve the original project name (used for logic like tag-detection), - // but compute a display name that omits the 'project_' prefix when prefixes are hidden. - const originalProjectName = projectName; - const displayProjectName = settings.show.prefixes ? projectName : projectName.replace(/^project_/, ''); - projectName = displayProjectName; - let cardTitle = ` -
${displayProjectName}
- `; - if (!isForOverview) { - // Project bar cards: customize based on project type - if (originalProjectName.startsWith('project_')) { - // Tagged projects: display name with inline version - cardTitle = ` -
${stats[5]}, Version: ${normalizedProjectVersion}
- `; - } else if (projectHasVersions) { - // Non-tagged projects with versions: interactive version title - cardTitle = ` -
-
Version:
-
- ${normalizedProjectVersion} -
-
- `; - } else { - // Non-tagged projects without versions: empty title placeholder - cardTitle = ` -
- `; - } - smallVersionHtml = ''; - } - const totalStatsHeader = isTotalStats ? `
Run Stats
` : ''; - const totalStatsAverage = isTotalStats ? `
Average Run Duration
` : ''; - const logLinkHtml = log_name ? `${log_name}` : ''; - return ` -
-
-
-
-
- ${cardTitle} -
- ${runNumberHtml} -
-
-
- -
-
-
- ${totalStatsHeader} -
Passed: ${stats[0]}
-
Failed: ${stats[1]}
-
Skipped: ${stats[2]}
-
-
-
-
- ${totalStatsAverage} -
- - ${svg} - - - ${rounded_duration} - -
-
Passed Runs: ${passed_runs}%
- ${smallVersionHtml} - ${logLinkHtml} -
-
-
-
-
-
`; -} // apply version select checkbox and version textinput filter function update_project_version_filter_run_card_visibility({ cardsContainerId, versionDropDownFilterId, versionStringFilterId }) { @@ -774,55 +823,6 @@ function update_project_version_filter_run_card_visibility({ cardsContainerId, v const scrollOffsetAfter = cardsContainerElement.getBoundingClientRect().top; window.scrollBy(0, scrollOffsetAfter - scrollOffsetBefore); } - -function apply_overview_latest_version_text_filter() { - const versionFilterInput = document.getElementById("overviewLatestVersionFilterSearch"); - const cardsContainer = document.getElementById("overviewLatestRunCardsContainer"); - if (!versionFilterInput || !cardsContainer) return; - const filterValue = versionFilterInput.value.toLowerCase(); - const runCards = Array.from(cardsContainer.querySelectorAll("div.overview-card")); - runCards.forEach(card => { - const version = (card.dataset.projectVersion ?? "").toLowerCase(); - card.style.display = version.includes(filterValue) ? "" : "none"; - }); -} - -function clear_project_filter() { - document.getElementById("runs").value = "All"; - document.getElementById("runTagCheckBoxesFilter").value = ""; - const tagElements = document.getElementById("runTag").getElementsByTagName("input"); - for (const input of tagElements) { - input.checked = false; - input.parentElement.classList.remove("d-none"); //show filtered rows - if (input.id == "All") input.checked = true; - } - update_filter_active_indicator("All", "filterRunTagSelectedIndicator"); -} - -function set_filter_show_current_project(projectName) { - if (projectName.startsWith("project_")) { - selectedTagSetting = projectName; - setTimeout(() => { // hack to prevent update_menu calls from hinderance - update_filter_active_indicator("All", "filterRunTagSelectedIndicator"); - }, 500); - - } else { - selectedRunSetting = projectName; - } -} - -function _update_overview_heading(containerId, titleId, titleText) { - const overviewCardsContainer = document.getElementById(containerId); - if (!overviewCardsContainer) return; - const amountOfProjectsShown = overviewCardsContainer.querySelectorAll(".overview-card").length; - const pluralPostFix = amountOfProjectsShown !== 1 ? 's' : ''; - const headerContent = `
showing ${amountOfProjectsShown} project${pluralPostFix}
`; - document.getElementById(titleId).innerHTML = ` - ${titleText} - ${headerContent} - `; -} - function update_overview_latest_heading() { _update_overview_heading("overviewLatestRunCardsContainer", "overviewLatestTitle", "Latest Runs"); } diff --git a/robotframework_dashboard/js/graph_creation/run.js b/robotframework_dashboard/js/graph_creation/run.js index a274332..b4a2f11 100644 --- a/robotframework_dashboard/js/graph_creation/run.js +++ b/robotframework_dashboard/js/graph_creation/run.js @@ -19,7 +19,7 @@ import { } from '../variables/globals.js'; import { create_chart, update_chart } from './chart_factory.js'; -// build config for run statistics graph +// build functions function _build_run_statistics_config() { const data = get_statistics_graph_data("run", settings.graphTypes.runStatisticsGraphType, filteredRuns); const graphData = data[0] @@ -43,10 +43,6 @@ function _build_run_statistics_config() { return config; } -// function to create run statistics graph in the run section -function create_run_statistics_graph() { create_chart("runStatisticsGraph", _build_run_statistics_config); } - -// build config for run donut graph function _build_run_donut_config() { const data = get_donut_graph_data("run", filteredRuns); const graphData = data[0] @@ -68,10 +64,6 @@ function _build_run_donut_config() { return config; } -// function to create run donut graph in the run section -function create_run_donut_graph() { create_chart("runDonutGraph", _build_run_donut_config, false); } - -// build config for run donut total graph function _build_run_donut_total_config() { const data = get_donut_total_graph_data("run", filteredRuns); const graphData = data[0] @@ -80,27 +72,6 @@ function _build_run_donut_total_config() { return config; } -// function to create run donut total graph in the run section -function create_run_donut_total_graph() { create_chart("runDonutTotalGraph", _build_run_donut_total_config, false); } - -// function to create the run stats section in the run section -function create_run_stats_graph() { - const data = get_stats_data(filteredRuns, filteredSuites, filteredTests, filteredKeywords); - document.getElementById('totalRuns').innerText = data.totalRuns - document.getElementById('totalSuites').innerText = data.totalSuites - document.getElementById('totalTests').innerText = data.totalTests - document.getElementById('totalKeywords').innerText = data.totalKeywords - document.getElementById('totalUniqueTests').innerText = data.totalUniqueTests - document.getElementById('totalPassed').innerText = data.totalPassed - document.getElementById('totalFailed').innerText = data.totalFailed - document.getElementById('totalSkipped').innerText = data.totalSkipped - document.getElementById('totalRunTime').innerText = format_duration(data.totalRunTime) - document.getElementById('averageRunTime').innerText = format_duration(data.averageRunTime) - document.getElementById('averageTestTime').innerText = format_duration(data.averageTestTime) - document.getElementById('averagePassRate').innerText = data.averagePassRate -} - -// build config for run duration graph function _build_run_duration_config() { var graphData = get_duration_graph_data("run", settings.graphTypes.runDurationGraphType, "elapsed_s", filteredRuns); const tooltipMeta = build_tooltip_meta(filteredRuns); @@ -120,10 +91,6 @@ function _build_run_duration_config() { return config; } -// function to create run duration graph in the run section -function create_run_duration_graph() { create_chart("runDurationGraph", _build_run_duration_config); } - -// build config for run heatmap graph function _build_run_heatmap_config() { const data = get_heatmap_graph_data(filteredTests); const graphData = data[0] @@ -150,27 +117,36 @@ function _build_run_heatmap_config() { return config; } -// function to create the run heatmap +// create functions +function create_run_statistics_graph() { create_chart("runStatisticsGraph", _build_run_statistics_config); } +function create_run_donut_graph() { create_chart("runDonutGraph", _build_run_donut_config, false); } +function create_run_donut_total_graph() { create_chart("runDonutTotalGraph", _build_run_donut_total_config, false); } +function create_run_stats_graph() { + const data = get_stats_data(filteredRuns, filteredSuites, filteredTests, filteredKeywords); + document.getElementById('totalRuns').innerText = data.totalRuns + document.getElementById('totalSuites').innerText = data.totalSuites + document.getElementById('totalTests').innerText = data.totalTests + document.getElementById('totalKeywords').innerText = data.totalKeywords + document.getElementById('totalUniqueTests').innerText = data.totalUniqueTests + document.getElementById('totalPassed').innerText = data.totalPassed + document.getElementById('totalFailed').innerText = data.totalFailed + document.getElementById('totalSkipped').innerText = data.totalSkipped + document.getElementById('totalRunTime').innerText = format_duration(data.totalRunTime) + document.getElementById('averageRunTime').innerText = format_duration(data.averageRunTime) + document.getElementById('averageTestTime').innerText = format_duration(data.averageTestTime) + document.getElementById('averagePassRate').innerText = data.averagePassRate +} +function create_run_duration_graph() { create_chart("runDurationGraph", _build_run_duration_config); } function create_run_heatmap_graph() { create_chart("runHeatmapGraph", _build_run_heatmap_config, false); } -// update function for run statistics graph - updates existing chart in-place +// update functions function update_run_statistics_graph() { update_chart("runStatisticsGraph", _build_run_statistics_config); } - -// update function for run donut graph - updates existing chart in-place function update_run_donut_graph() { update_chart("runDonutGraph", _build_run_donut_config, false); } - -// update function for run donut total graph - updates existing chart in-place function update_run_donut_total_graph() { update_chart("runDonutTotalGraph", _build_run_donut_total_config, false); } - -// update function for run stats - same as create since it only updates DOM text function update_run_stats_graph() { create_run_stats_graph(); } - -// update function for run duration graph - updates existing chart in-place function update_run_duration_graph() { update_chart("runDurationGraph", _build_run_duration_config); } - -// update function for run heatmap graph - updates existing chart in-place function update_run_heatmap_graph() { update_chart("runHeatmapGraph", _build_run_heatmap_config, false); } export { diff --git a/robotframework_dashboard/js/graph_creation/suite.js b/robotframework_dashboard/js/graph_creation/suite.js index 90a89d0..e070b07 100644 --- a/robotframework_dashboard/js/graph_creation/suite.js +++ b/robotframework_dashboard/js/graph_creation/suite.js @@ -13,7 +13,7 @@ import { create_chart, update_chart } from './chart_factory.js'; import { build_most_failed_config, build_most_time_consuming_config } from './config_helpers.js'; import { update_graphs_with_loading } from '../common.js'; -// build config for suite folder donut graph +// build functions function _build_suite_folder_donut_config(folder) { const data = get_donut_folder_graph_data("suite", filteredSuites, folder); const graphData = data[0] @@ -56,21 +56,6 @@ function _build_suite_folder_donut_config(folder) { return config; } -// function to create suite folder donut -function create_suite_folder_donut_graph(folder) { - const suiteFolder = document.getElementById("suiteFolder") - suiteFolder.innerText = folder == "" || folder == undefined ? "All" : folder; - if (folder || folder == "") { // not first load so update the graphs accordingly as well - setup_suites_in_suite_select(); - update_suite_folder_fail_donut_graph(); - update_suite_statistics_graph(); - update_suite_duration_graph(); - } - if (suiteFolderDonutGraph) { suiteFolderDonutGraph.destroy(); } - suiteFolderDonutGraph = new Chart("suiteFolderDonutGraph", _build_suite_folder_donut_config(folder)); -} - -// build config for suite folder fail donut graph function _build_suite_folder_fail_donut_config() { const data = get_donut_folder_fail_graph_data("suite", filteredSuites); const graphData = data[0] @@ -129,7 +114,6 @@ function _build_suite_folder_fail_donut_config() { return config; } -// build config for suite statistics graph function _build_suite_statistics_config() { const data = get_statistics_graph_data("suite", settings.graphTypes.suiteStatisticsGraphType, filteredSuites); const graphData = data[0] @@ -190,7 +174,6 @@ function _build_suite_statistics_config() { return config; } -// build config for suite duration graph function _build_suite_duration_config() { const graphData = get_duration_graph_data("suite", settings.graphTypes.suiteDurationGraphType, "elapsed_s", filteredSuites); const suiteSelectSuites = document.getElementById("suiteSelectSuites").value; @@ -214,24 +197,33 @@ function _build_suite_duration_config() { return config; } -// build config for suite most failed graph function _build_suite_most_failed_config() { return build_most_failed_config("suiteMostFailed", "suite", "Suite", filteredSuites, false); } - -// build config for suite most time consuming graph function _build_suite_most_time_consuming_config() { return build_most_time_consuming_config("suiteMostTimeConsuming", "suite", "Suite", filteredSuites, "onlyLastRunSuite"); } // create functions +function create_suite_folder_donut_graph(folder) { + const suiteFolder = document.getElementById("suiteFolder") + suiteFolder.innerText = folder == "" || folder == undefined ? "All" : folder; + if (folder || folder == "") { // not first load so update the graphs accordingly as well + setup_suites_in_suite_select(); + update_suite_folder_fail_donut_graph(); + update_suite_statistics_graph(); + update_suite_duration_graph(); + } + if (suiteFolderDonutGraph) { suiteFolderDonutGraph.destroy(); } + suiteFolderDonutGraph = new Chart("suiteFolderDonutGraph", _build_suite_folder_donut_config(folder)); +} +function create_suite_folder_fail_donut_graph() { create_chart("suiteFolderFailDonutGraph", _build_suite_folder_fail_donut_config, false); } function create_suite_statistics_graph() { create_chart("suiteStatisticsGraph", _build_suite_statistics_config); } function create_suite_duration_graph() { create_chart("suiteDurationGraph", _build_suite_duration_config); } function create_suite_most_failed_graph() { create_chart("suiteMostFailedGraph", _build_suite_most_failed_config); } function create_suite_most_time_consuming_graph() { create_chart("suiteMostTimeConsumingGraph", _build_suite_most_time_consuming_config); } -function create_suite_folder_fail_donut_graph() { create_chart("suiteFolderFailDonutGraph", _build_suite_folder_fail_donut_config, false); } -// update function for suite folder donut graph - updates existing chart in-place +// update functions function update_suite_folder_donut_graph(folder) { const suiteFolder = document.getElementById("suiteFolder") suiteFolder.innerText = folder == "" || folder == undefined ? "All" : folder; @@ -247,8 +239,6 @@ function update_suite_folder_donut_graph(folder) { suiteFolderDonutGraph.options = config.options; suiteFolderDonutGraph.update(); } - -// update functions function update_suite_folder_fail_donut_graph() { update_chart("suiteFolderFailDonutGraph", _build_suite_folder_fail_donut_config, false); } function update_suite_statistics_graph() { update_chart("suiteStatisticsGraph", _build_suite_statistics_config); } function update_suite_duration_graph() { update_chart("suiteDurationGraph", _build_suite_duration_config); } diff --git a/robotframework_dashboard/js/graph_creation/tables.js b/robotframework_dashboard/js/graph_creation/tables.js index 41bb1f8..e6e3116 100644 --- a/robotframework_dashboard/js/graph_creation/tables.js +++ b/robotframework_dashboard/js/graph_creation/tables.js @@ -1,22 +1,5 @@ import { filteredRuns, filteredSuites, filteredTests, filteredKeywords } from "../variables/globals.js"; -// Generic table factory functions -function create_data_table(tableId, columns, getDataFn) { - if (window[tableId]) window[tableId].destroy(); - window[tableId] = new DataTable(`#${tableId}`, { - layout: { topStart: "info", bottomStart: null }, - columns, - data: getDataFn(), - }); -} - -function update_data_table(tableId, columns, getDataFn) { - if (!window[tableId]) { create_data_table(tableId, columns, getDataFn); return; } - window[tableId].clear(); - window[tableId].rows.add(getDataFn()); - window[tableId].draw(); -} - // data builder functions function _get_run_table_data() { return filteredRuns.map(run => [ @@ -70,12 +53,27 @@ const keywordColumns = [ { title: "max_execution_time" }, { title: "alias" }, { title: "owner" }, ]; -// create/update functions +// create functions +function create_data_table(tableId, columns, getDataFn) { + if (window[tableId]) window[tableId].destroy(); + window[tableId] = new DataTable(`#${tableId}`, { + layout: { topStart: "info", bottomStart: null }, + columns, + data: getDataFn(), + }); +} function create_run_table() { create_data_table("runTable", runColumns, _get_run_table_data); } function create_suite_table() { create_data_table("suiteTable", suiteColumns, _get_suite_table_data); } function create_test_table() { create_data_table("testTable", testColumns, _get_test_table_data); } function create_keyword_table() { create_data_table("keywordTable", keywordColumns, _get_keyword_table_data); } +// update functions +function update_data_table(tableId, columns, getDataFn) { + if (!window[tableId]) { create_data_table(tableId, columns, getDataFn); return; } + window[tableId].clear(); + window[tableId].rows.add(getDataFn()); + window[tableId].draw(); +} function update_run_table() { update_data_table("runTable", runColumns, _get_run_table_data); } function update_suite_table() { update_data_table("suiteTable", suiteColumns, _get_suite_table_data); } function update_test_table() { update_data_table("testTable", testColumns, _get_test_table_data); } diff --git a/robotframework_dashboard/js/graph_creation/test.js b/robotframework_dashboard/js/graph_creation/test.js index 5c33393..0879c4a 100644 --- a/robotframework_dashboard/js/graph_creation/test.js +++ b/robotframework_dashboard/js/graph_creation/test.js @@ -12,7 +12,7 @@ import { settings } from "../variables/settings.js"; import { create_chart, update_chart } from "./chart_factory.js"; import { build_most_failed_config, build_most_flaky_config, build_most_time_consuming_config } from "./config_helpers.js"; -// build config for test statistics graph +// build functions function _build_test_statistics_config() { const graphType = settings.graphTypes.testStatisticsGraphType || "timeline"; @@ -22,7 +22,6 @@ function _build_test_statistics_config() { return _build_test_statistics_timeline_config(); } -// build config for test statistics timeline view (existing behavior + rich tooltip) function _build_test_statistics_timeline_config() { const data = get_test_statistics_data(filteredTests); const graphData = data[0] @@ -73,7 +72,6 @@ function _build_test_statistics_timeline_config() { return config; } -// build config for test statistics scatter view (timestamp-based x-axis, one row per test) function _build_test_statistics_line_config() { const result = get_test_statistics_line_data(filteredTests); const testLabels = result.labels; @@ -179,10 +177,6 @@ function _build_test_statistics_line_config() { return config; } -// function to create test statistics graph in the test section -function create_test_statistics_graph() { create_chart("testStatisticsGraph", _build_test_statistics_config); } - -// build config for test duration graph function _build_test_duration_config() { var graphData = get_duration_graph_data("test", settings.graphTypes.testDurationGraphType, "elapsed_s", filteredTests); const tooltipMeta = build_tooltip_meta(filteredTests); @@ -207,10 +201,6 @@ function _build_test_duration_config() { return config; } -// function to create test duration graph in the test section -function create_test_duration_graph() { create_chart("testDurationGraph", _build_test_duration_config); } - -// build config for test messages graph function _build_test_messages_config() { const data = get_messages_data("test", settings.graphTypes.testMessagesGraphType, filteredTests); const graphData = data[0]; @@ -295,10 +285,6 @@ function _build_test_messages_config() { return config; } -// function to create test messages graph in the test section -function create_test_messages_graph() { create_chart("testMessagesGraph", _build_test_messages_config); } - -// build config for test duration deviation graph function _build_test_duration_deviation_config() { const graphData = get_duration_deviation_data("test", settings.graphTypes.testDurationDeviationGraphType, filteredTests) const config = get_graph_config("boxplot", graphData, "", "Test", "Duration"); @@ -306,74 +292,42 @@ function _build_test_duration_deviation_config() { return config; } -// function to create test duration deviation graph in test section -function create_test_duration_deviation_graph() { create_chart("testDurationDeviationGraph", _build_test_duration_deviation_config); } - -// build config for test most flaky graph function _build_test_most_flaky_config() { return build_most_flaky_config("testMostFlaky", "test", filteredTests, ignoreSkips, false); } - -// function to create test most flaky graph in test section -function create_test_most_flaky_graph() { create_chart("testMostFlakyGraph", _build_test_most_flaky_config); } - -// build config for test recent most flaky graph function _build_test_recent_most_flaky_config() { return build_most_flaky_config("testRecentMostFlaky", "test", filteredTests, ignoreSkipsRecent, true); } - -// function to create test recent most flaky graph in test section -function create_test_recent_most_flaky_graph() { create_chart("testRecentMostFlakyGraph", _build_test_recent_most_flaky_config); } - -// build config for test most failed graph function _build_test_most_failed_config() { return build_most_failed_config("testMostFailed", "test", "Test", filteredTests, false); } - -// function to create test most failed graph in the test section -function create_test_most_failed_graph() { create_chart("testMostFailedGraph", _build_test_most_failed_config); } - -// build config for test recent most failed graph function _build_test_recent_most_failed_config() { return build_most_failed_config("testRecentMostFailed", "test", "Test", filteredTests, true); } - -// function to create test recent most failed graph in the test section -function create_test_recent_most_failed_graph() { create_chart("testRecentMostFailedGraph", _build_test_recent_most_failed_config); } - -// build config for test most time consuming graph function _build_test_most_time_consuming_config() { return build_most_time_consuming_config("testMostTimeConsuming", "test", "Test", filteredTests, "onlyLastRunTest"); } -// function to create the most time consuming test graph in the test section +// create functions +function create_test_statistics_graph() { create_chart("testStatisticsGraph", _build_test_statistics_config); } +function create_test_duration_graph() { create_chart("testDurationGraph", _build_test_duration_config); } +function create_test_messages_graph() { create_chart("testMessagesGraph", _build_test_messages_config); } +function create_test_duration_deviation_graph() { create_chart("testDurationDeviationGraph", _build_test_duration_deviation_config); } +function create_test_most_flaky_graph() { create_chart("testMostFlakyGraph", _build_test_most_flaky_config); } +function create_test_recent_most_flaky_graph() { create_chart("testRecentMostFlakyGraph", _build_test_recent_most_flaky_config); } +function create_test_most_failed_graph() { create_chart("testMostFailedGraph", _build_test_most_failed_config); } +function create_test_recent_most_failed_graph() { create_chart("testRecentMostFailedGraph", _build_test_recent_most_failed_config); } function create_test_most_time_consuming_graph() { create_chart("testMostTimeConsumingGraph", _build_test_most_time_consuming_config); } -// update function for test statistics graph - updates existing chart in-place +// update functions function update_test_statistics_graph() { update_chart("testStatisticsGraph", _build_test_statistics_config); } - -// update function for test duration graph - updates existing chart in-place function update_test_duration_graph() { update_chart("testDurationGraph", _build_test_duration_config); } - -// update function for test messages graph - updates existing chart in-place function update_test_messages_graph() { update_chart("testMessagesGraph", _build_test_messages_config); } - -// update function for test duration deviation graph - updates existing chart in-place function update_test_duration_deviation_graph() { update_chart("testDurationDeviationGraph", _build_test_duration_deviation_config); } - -// update function for test most flaky graph - updates existing chart in-place function update_test_most_flaky_graph() { update_chart("testMostFlakyGraph", _build_test_most_flaky_config); } - -// update function for test recent most flaky graph - updates existing chart in-place function update_test_recent_most_flaky_graph() { update_chart("testRecentMostFlakyGraph", _build_test_recent_most_flaky_config); } - -// update function for test most failed graph - updates existing chart in-place function update_test_most_failed_graph() { update_chart("testMostFailedGraph", _build_test_most_failed_config); } - -// update function for test recent most failed graph - updates existing chart in-place function update_test_recent_most_failed_graph() { update_chart("testRecentMostFailedGraph", _build_test_recent_most_failed_config); } - -// update function for test most time consuming graph - updates existing chart in-place function update_test_most_time_consuming_graph() { update_chart("testMostTimeConsumingGraph", _build_test_most_time_consuming_config); } export { From 9d786b856345ca15319a83229b23f9bc08f75bce Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Sat, 28 Feb 2026 20:58:51 +0100 Subject: [PATCH 25/26] Test update --- .../keyword/baseKeywordSection.png | Bin 252879 -> 241679 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/atest/resources/dashboard_output/keyword/baseKeywordSection.png b/atest/resources/dashboard_output/keyword/baseKeywordSection.png index 9d26a83138db6cce9abbdb5453998840bbbdcae1..a62ec11490182020ab55bf12559fdcefcdeac6c2 100644 GIT binary patch literal 241679 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 z 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

R+JeGxRe_~P+@(=Q4o{{Bv{?C@I zM{`rZo@m}UUci_H^W{empsTPCcAkHUIOex-oK3C7Sm_jBE=BQQ%xzdCk1BbziN&QF>pmD4d0 zxtX32ZrUD`%uZ_P4dncg1wMV*dM(Z6zCzcdzqw0|J-vDAFgoUEbso4MBsePw3JOZd z$1A4m0lnY;!zli#DZBYlk_kIW&UlRj5ar(ZIYZP4`p3xVsCgZP)3!Sn9HdO+;Cle` zTS$y4w@X4-fne={LDuil;ovX9 zU8xjj#yynvvA6@XQ*I1)e>xbsY)!2)_ZaL&%{?@B1D-f_5K&e}Mgiz1*TdxpGGt31 z)JTKHgD%~mS=6G`02c&+LgK)y*R33tIoE`TL82lO@_KM>qr;xmfB*hwugC?boI3CL zh|JG>rRo?XrV|pW&HvLrqW`>0nh_&~H(Kkd!8%a{wi|CeCkquSJ}(RSwaLty3nLxY z0CK~+CkpV#Q?pxGTPNEov(>kn@bh{Pu&YchPn^xnmG)L=?9}av((S4whnJRYVZK+MQfUghbOED=ak8{2`(BbUwKe(acJ;T82=X76wFm+F= zi9BTi^DzuVb$XWr)3M6k1?P_>yfl!f?hz$LS)ClZ@3sg_Yq_5Z5jZ(j0&3mlESiOi z%NgJA<4HscRoDBUp%*vfr0EOFMy){i5I61fh#pDbJEX4eWSn2)YKr1skUVbVW^g^HW*GuaQ1usYMfq0di03JNY~uJ zdBwfA@IFaAEdM&D?*(%G-%bLIfPK$LLZf_Mm$@rNvR^TGdCP1xWv%)r!UfWv%}AY6KWaJ)N@W0&A>I?{}1|W1>6Q56hm8j|0_P zC>BQdo!_>anSgBwR!%>7FAC<`_7+;`lX40Q8aga?`}870UkR=+>)gvOi{c41GF0Ts zd(BLgvW2!%4wIr(0b!*F`By@GeC@k1GkZ2quZb%2Uq6-zGVA1=Q8({Fi6wvs#?sm< zP#YE7oXRr{4-c9S6=k}><2x~!7j%EG`%X%d>;$XHHW3onp+u$p(9>0w74-buj}deL zfVO?Y#TC;Fj*a?oANDx*-|CL94nfQ7;|4$#0sg9N*Ov$TPPtyq^W8BkLSxAPUI$f8 z+y0OFk3L%a_-sUWgB9^7N|1@57N$DenTS(_gCzkb-dM?kj=lZJHoR5LEajVALhoArj;`PU?ao8izTdb36^Ex))K zX1@QfwV4FoEu!lKubgI%?6V47 zM#NWf0<-dc6G?pTyFl0JVu$w76r^U9lrN%*R19#7`UnFtpRTkms`~P1^Z%?(Ote)0 z8&7SpD!aFzN*LBvJ3~?m&t`?ubzop1y+`$1WH0rh=SX6?E3{(D*NFENrh6|7Khl@o zDNLnf-^tF*lrFbRJNXfG7O+t{y0J4K2LA%tbjpEGohCQF=u1CTj(!1^Cc zJ^*VnzW;CF@K*cy4;tJUg=C>C-}$U8%ww>R$^zv>ZI#`}u?G~sIzi0-2s8of*b#6; zY@P;vmtvkmWAAT83WoXN!nsq3*%$*Z&&!uX2YE6^?@TSh<*>r11t3pYInT?ggz(qb zJkBuH?C-*mZQM4*bfb`6itNGfO<;2eo3R=vUGZDt)CW1~4P~%M%X|&KmeWul<#MUn z1?L?K8X8Ta-2c%Ca_;1onM|9WEH9OB^Ln}xJ?GwU|3gc6(|Ze(XQDYJ;gyxMN5_O! zBf2>i^-)^-a^x4n3kmBhO8!WjsE&}IHP;tD*==otkwsuWa^4D#Pwme@tg$bP~>W({?ppo8gGB9U}BAW zX|xZ8Ab?y#@$n&Ywlz|29J7Qa&Qg@m`hbw%`GxJh6Jp-3&d&AUrEW)A5!DVxC`PGs z7Rf$DPp=nWI!w$JvbbGbFlyEgrYT}}?KMwNX{V*8v1TZPicbyNxpt{8j^+X4?>qmt z>4V>52@LO)|LYeS#rYbeNG4f-KX5@DOA5Twu(63LAAbgQKyK$kQzu%UbXHc4nTtoa ztlL>zEhkE36vvmlpk;#?3pCk~LUJSlVd`nYkdpsk0hP!NjqxTrJ(p8d331_U`em4e zwW+BOrXG!rgnHIO8m=VoN<<&`Co-`E8|zPG&kIVOm-mXKbB#A58IzQ*X? zz#J7wNOd#AF1`>nP5O}&=YV~dcS-#es(W;8=I2MLmVsz!bAvM@(<1j{Efp2u^~?DG zi2KU0s=lvV3_xi?MH&P|8brEOL`pyjkr1T2yDdtjyFpsIOG4>R>5%R&={pbli~oDy z``+idU#=e%j_2&N_S$>THP@J9j5!NC(X4sqsQV@_A(o4+8ELDK;`d(>re43R*EoH8 zhKUMGseMYKy|%3Aam`rC`bEP29Dd`D&#;tTH8kTAH!%1DZl=wZx6<3+-FTLZcp5!g zp^G>?O3LL1%kODOKS%=P`DfSp>EnN?l9@#_Dn&q#*Ufi|CTlr+#5|Y>p)UNq{N}C! zk)be$4hz4KPhAHpg15eh0XkI}!EHX;cQ7unJ~f3)$exDj{PEe{kDoqq+pj5&_a=>s zzvaD+b0ufY>A@>aG%a0SiLwyT$&4^7ZvV_fy=;{tMQp$1)NzzrQtFPqq^)73x14+N zo^`{MLoji;5LZP-gYW{_nHfR{*Z=FOr7e#u*<@81(rv#tslNjy^IHVs)e^epa7PXq zFEK#qXuSOBadf~1Tku$Tnc*%BG~_M~*sJaI#aO2X)n3!$wZzq80OQ?B6FDm*5JZ6t zA0Fjhr-Qkr_M<-ETvV-3KQS9=sGPyVq{58&oBFJtuJz91PLi@pfD%n4_`)nS493wWvkSd&pgdf7>%TmCEUpE5qwIZ{Y`3mD%18@woOqm9!g zUPdSv>Vrmqx&19%P&d+oZK~7-brLRe*xtM@1I4g^!=|%~bEA9m`lX9kozX@Ezz=)Y ztu_gDH!Csm21!ss2q1Rm!Ph5B)p#xKG9CwtX_^zj(mO9fM@!3L@=SN>!!>3yH82=^ zJHCxc@SkYDTg23(WNoml+s`4bbY6T0ea~&?J9P6++$fp%pSqd>ZNPr^Bh@}7gSrP; z$UqC7Kb&7Ed%A4=I+$bk{xVY~hBR@ZjXvW0qemLJ?3NPgz1{u8MfwNcBKQM2R>5Im z2%0gZ;gi$sy9=qE-_(R^zeGlTv#>JaQ%40ir+@gr;5xL_lUD?q^gbc~2FJZ)YNy;g zMg}+jnan5kAO1a);lQfAsTP4jtXAc+K0P0;)e2D@*uuL69NNt-l6#gZ2#eoHyQ8nw zA&w7e79@uYjq>}fB_OHIFK7k>rz_3?V>P9&a@&1mZt^0KEFrEuTNp|qLE(2k?HSiW zOXvinJJo})Zf=km-l0kD$h9QpF?n&s(e7YlEWIFW@~_g!`DA|nuatIp9zoj*Dj(AS zun#ymWK_q*(|n)oYbh05p;K)qxxPj+IhXGcRnl3J=-3Y~zBgi7S`9`GC-y%`_UD-W zx^?URa1RzwUW9C;YLz)z6^48i^&z#V-b4VD zAu)*t={W=@mwZs|1#y~2u0aPc6MF3XPoO;(EmZTOF@sK*aFU_K!B;Xm=<6# zwX(L@wzJ-yyTqhWZ!qNv{lztT!Fg5_&qx)(UTyd>q-c+HMp07hNk zw_pbgvFjCV!a&|a5|-_)-d=Xg3@1xPgY)%_74*2T$EZTWAPy-Q{VH~8Figj1`I~Z) zTK?YZk>3aQ*fDN4tx<+A0MGGqIOH>0)q^T`gE2#MWDf1)M=(2n#EZCA=ae_7Bx@>y zcoOqJd^1}MCs2msQogWoW~7O>T=d)*!u<(_uWr^&P|IL}Y~!3Ag;6L>XT+*|_yHt6 zw27i-IFOYo71%T152keNUl%Z&+VJwGqG<`TROPtdk1*gd8J9>Za9BKM{+4j$7YkgA znS*Iw;;`&}yGfyS-vsGpq^-3Xd8Oa?vsc;tpIq~x7{q%)^~3Hu95z8(VnHhN6~~W7Gr8sO7pyn2~>i+9n1 z06xCzYEn|tb2s9f*Jfw(W1&6bZ_owM^5?Ao>F|u?Ti}{(blDfY;lM(Bc@drS_0V+v zGVqqL)>PQ^-Gzi@rzNw|g92si2ncYLZubxjmQ?(RcEgE-RG@kF!21W&9x-*tIJ6Io znO|Jed#GlKs11Z2(_O&Ryu=l))A0(e72wDyE}ad~a?UYX-Ov@)8w~&mMD44?z*sMl ziQgC>XSPzQb=G#Ky#$RP5l_4{Czi+}Xb~AJd~s;67gK_nDO1eo)lC8DDe0*y#yu%j zmSgMSjo>Y~T^|5TcwmeutS!fkPdzd+JKhOFST^0GB9-YqpxXbsWlo8jyG_{#oMC6k zO#Xk}H<;tu0JQwCUeeU{^u|Pte zVBWpoCm$4Ok)1XQK8N0PI{4YSo6DfsKgY8EhiRp_^sJy9xrpJq7fJNM>QMgop>Bti zm~DUaf-j2U_FwHNPq=3QwgV?%Z%?Nyt(}!luj!QsxU0A90l9h(y^|?#CkoZx_Oj~f z`E}?}jwAK%7y2l_#a7@w5&MJN6&EN|yGto)(-(nQUJZ(GhFZs1{?1vjLIz9mLFbLW zX6~X?D6bK2%d0|14KjgEFeZV$6s64j=~S?e1)lKu*X@^q5n>i2hGD*01^M|2Z+nU; zq01>~Fx^BRgO&N)ibbbyCH}dzcp}j}wv0>y0ik7%9|r?Ks_5>7jjyYPlYWA}E2Vi3 z4sJ}9Z`WWhpWq|r@TH1&Bny9$r+&g;`;r%j?~UG~>@D8JxwQh_smcZnX=&utpR}f6 zvauCB!B|X8%;GkzLPzf;I}oOporbNrzM=jnGIqW4)j;NN6d!i)d?F>@SZqBE@y*~^ zb2o|0CH?0%)4tHC$Wile)+v+a$MYruijl7mkn7ZdYzW39`fElK29g#Q1`GRFKu-NA z4g@--j+NuZ<7J?5?2FvL5Xa80cbb+5Zsyupx+90l{{8X&mz|C;l3YmWgdC*{tduhy zt(PBU*{{y4-Lyq8v{?Qs#h=3noKQ+6n%|qKut)+I@oGUrys+G1?NjJR8f8OFwbBLV z;IbYZg4M1jp+5#E$b5W#hy)${p;EtM&*W6~CihKMYzG)E@{>hR_6iSlxY0r1(FTqLI_R)tY0)J|J9DPZ^E>Uwdf(=d#;G<)J|1RnyDJNGt+9P__P zi^S87qRlaYBMaF}Q7lKc=E%)19FM*28W*1Qc`1F*pRK!Sm}1KT~DrQ=;T z{A{4v;|?D)&#OS&)b9gGVe%vKz2qyo#$$o7e1-iO;VCjzEO4)9^HVq=lavyZRhZ5i z$rrrO@Gkd~Q~f)WmSGoeswKFMc8+dnez?5}((SmpBX*6&?|Qj2l}Byb^;<4fQcb4^%27DF-@16Gx6PKjW;1N@kfPnW9ABj2eqN_=RmSmz-oy{ zs~t~k^P$-Q^dx;@JbH52e#F5@2>xSs6O(K}N}{CRJ@Q4$5TlIZ)c=`l6~jfDsGM4S zb1vldfwCSKZMuWCL8kJg*=)nfS9Upb`)Yevkee&9XY4KXtic+9AUe^O@^rd|wNI=T zLu*@`{-pGN^u^(T?;kUu{eW;QXJ~>04e|{I`!BYQ_H5voc*M#gn&KgLkY+oKjif#~o3F3h79Z#jesPtD4V`hh z4TF=H$fxr)CpJBs!eKK`75J1%ftv3&8tE6+A5Nfw!H%^Xb)1Qf;AO-hx}T|*Vd~cb z06EniYVSINIh_=+Q(`sW#PxbCCbrqMX03iL0Em%^oIX_4cb~KlNr8K{>A1_nM1`T; z(`Zqu&jQNK)`i(B%{x18%bAf~#Q&fK$>7Eeaq?e;k}yG64B~>dDZAg5<*!z&CeWU` z$Q`od=**TJq1 zkwA52P}aMo&svUX7WdAiFtg9`ZN2>I5=m%6rj~Pf@UBDZ35z;F{txSg9V;nApUs+2 zPW-#@x(ZwVu&nX9Bc_^64dm|vKBpQP*B|;T|W_FHS8d6 zu5D#h2R{zh+9L%P)-TEx`1k^m3^mZ>nQll`G&fDbC@))5Z-e^;l#6g8Pjp;{l`vovUBWwZikqABI{ zXu_hn`0Z8ok8i8i0OB_pwRZx(ZmFS$QG7RAZT(~R1pO*;$$nxUx+MlKQE`C(`SQmxklw# z_UHg_B?8Y5dh{^^{9W#^iI#0@mbQg}fsbV%2d^$SukMAl@7orqaRpB?i-)N8)t1Oe zgyv9&j=^becXwnzxbMEM$53pJQ-TZhpURFK;~ehsrN&D;xj0U0bq)MofFi0=k%DaP zGZ%!)IZ=ADvExScA>`-a+sEEzio!tPw2XOyCOm~)EDN7s#m}M0?E7L**%@Z~TK&vs zK%Qu?X1#BJQ^#vSH?_KebB;{Qi$ z+yzWTLtbNBcGi2MqED-=R++4S*32(PSjME&037tNT4YeG`P~p$)?M>rTN5w?!{zHm z7NEQ|pb%iC75W|;XDRbP;#WLeKrzq>!6As4q6=%O8}-%+p=*m{L%v7c#X!`bV{FQ{3(it~)*MPDdeIaC zuv}dE;XFHc={uogNqP2osqR>)bC)nfv>=}NZqd49An&4$k<(`oomm^mBShdI*ztZa zsuzQT*~K{dJDt^M7s z32Dx6Qn0IS=fK9$F!c2bOwY>;_y$D~XhL&z>(=AJ3;z^$lCc^U3BLV1n5e9U_;^o) zJ(Ba=_C7GCL`C)(FW*nH4??L$*~>S7c`eSL3g?REG%j$Gt>-?=RWrO9i{;Sovub_5 zWbg&Zc~$Ed!RqyqrJ(gG58oj_Lq3?m>n|RItA*Efax@)Y%PAlZfniFA%2!%%k_-G5 z#U3eYm0u1(8Vc;4_GligM{?j}Co;^BQ#gJu@KV1m@-trIa_LS(7%d2NVqz9^u zmbSJF)?u+zKvwV9#YYKxZ?}i*lCA@MlihDJgsvD06+g3@M|aY#z3)H@9>c2LwpDNQ z<4bs1=J+8(9Z3kK6UQnigfsF^CFhy|ECPqYUX&&v+fh>@*X+wtm#x(Y$QGvBrF{(;Q>y4b%@!#+_B_zsj`g_&8ui-o5Un%wf1Oy4<)MrlaV? z-Zx-rGxjLy=W9cGAPjn##DWb>nbpZS0NRo4*{J%cOTpHXlDp`Qwn`mV{&Q1)AcY5` zUuaAF&_Hj-zsmpNJ0b5qlEwZq#=0*ceC?^04%@Lhm0K1DLLvCh+NTne4(2U^w^mrr z#CghOSGi?8P)@L$h~1U1SnIZ}vqQ%1Bbq=wP{DA{*hfYPFx6XZD|=N{W@RFx9P<=| z4wplDF-Y0)WU%@OD-?r11`!Z6zf?`K~CI7Bb(-l&zLdDRzWHCV6qHQ3B0QPMC$w-E}N zTLMXbDdof9d#Yx4WG*lFMIqxN1(k#C<&~kYPT+!UH(P-n`%UUqxeccP#npR)(9q@< zchW@gW-ahI6M89RemRRk4gj95W@U8V_-#x5>FwZ2_Op8-oC~lSmG%6)Oejm0H3$;O z(}Qf=VegDldOY1n!vDd3uJRFDj^Q&&+mEt^5c$CwO8%uP+D$QhPE%sZtW|FA1DUGA zU>kYF^d=XVH~~x}!51J+!nkInqpihYpnNgr$mBx^5K;szTW-A~FrO@LHE?g_NBYs`Y}%iX9fg7ABH^`+p^+w6r>2 zDG&LC(H!Yh(WzQ4mXw(Nie!s(Wv?>Gl^oUUO=DGa0Wr{H%?|pqfgd}$u(#(d9kWs+1-UiYRL^%tSeWT{ z{2&kE7Kha?^JU5V+*?#iJqH(4?R#P8jYE3vM}o1p;C}hLH?&AmHDCcZp+#;PTysoXPeu=(uAklODX@g4HOH=g)B~6 zR=*k=OA-h=opl#LpDy6R1XEftLl@BInHvKh=d`nCjdL1S?JiRVLRhl>5gqMg`4^&Z z4$QG`W<-?RSq1g$UwaMmN&V3@7>8G3U)j3MO$rUNhbDEY_Bl{4oSxoGrEgy)dYZ&4m3z3RW|T3A7@Xd_i! z-{;G~+vb}{`5&x8Aj#z_R`b~h&n7*{^C5;&W#;`CutIp;`VMV#bGm<&{lTywf0$sb zHv3H(hW@=)*cQQL?6ejoP2#xcpqdrlJh|pA2Bf~?l02nCBg*?I@IXukSsl9C(*`;g zV`X}95+7F1@ZYNZD?rxkv`KMwh+`v^D}S}T+pPBTc{>d?4Eq10R+sA(b!ROyB!u5_ zT;{D6_vgQ*x{rdFY8yMZ=bz5rHCH{VQjnkZAsDY#*vF#pU$fh&doTK}Zt(SJ+Qa_s zu==`&iSqsHzMt#&3N8vbACTTu1h{uaC+LZF>JUT6k-AGW^o7~qNS_}=HE~(LNbbv~ zAsV-5tTbA_f0^`M*8a-yX8L60`@uoR_=P{|gzdwiV_nz9@~cfy3Fg98EN&~hkL?yhzdY%xK-;I_GY zSp@A420#(&6Q%5bVFCU>o&hl~-|^MU$5bom>UjSnB+=)m9j0X-$veGLPt@gc`7#Pv zrfz}6eSspq+3awCO@rNh281w(vO09AI|p~$l(hAtwRL3~KEKy6mSxCJ!sDWTaGRH1K6YeV;dY+h(ux~} z%vl%jA2G3o-pd)l9dFLvK)9RQl2y!J4liEi7EgNd(!9GIG3yEcn)S^6i$DTR>w7-H zeg9tHdUx^|Y%O2i?BI6PilXyF&*}ppA}`!J%pIC=ZsdT$loqp6iIE2A>URr{Kui%3 zKmk}a9lMY8Ak}`s^=>e2cTJTh)qc^fH3!rg=`}*`B<9Sw(rKGlciPaUnSYEvS{uw3 z_A!{V)Ri78F`ku4<<4yYZ4~c(@;4m@*}+c{&!MdaUCMWZ&?3paT&#~Vl6ju+H$NRR z;+{t!d7rx1!+EDmfm8)84d2|wrIK&(A-v+z&W3HeM8VZG-(LLntmy>cv?yD$uwP-w zT^hd$zd!umegl#kltRk*U9TS8wY$r}hem2Vo^%RK2?di;>;u)Un`+Pl7Q6xd8-Ee+ zUtvuGH3zfySEnd#~2d7>Z-ABBKgJO}F${9RNsoQj^+Zt5;@*GTt9rd+c9~I{=Pn z?rP-sb2fXzqVPXt_BbBbv8HD^0El!c{Q7k`|B5=0A0F4Y*onEXxn07u5|_a!GoCW? zHy@~KkohFX!B99IA=7(S72r1w>GmJkTU)UwO{H6F=yBM zwc+8&$7nc1+=Ygc6)z-dd`T<-182iGE&m6_#~Vj=>hJU4vlIefF|f(Lm!o11LJVJ< zULgEtwM`l+SIfz6H`1dz9e>VBewcJgWDeK?N*tzklfSS$KV(moPYiu4S17^iOUyYr zPW~e#LkNMH|-esn9qx?$7rm$4BflM{Q*teK|ORLA^h_?ie9V2u>?2p0&m@ zPdq~hUck2zceh`4Nf=j^=lT+#60_OquQBr>C2gi1_iq*PuFapevHbBfVL;Fze#=FF z$|awjCTZNp8QbHZO;hNX z{dpsPLmOLaF6CDG25IdP0ttY@gIfQf6NmMFSB92>k&zbAvQ8kmO~cM%$ospquhY`$ zy|Y8m{OXEQ0h!7L&Ki8-Kd}FUF{#{vW)&Czqn6-#Lzh^AgY5>|hrVyeI|uYowtZq&$%+ z=8Vdl5jBtfLF92+_Jr`@<$o#w|2x}LagG*u=>1a(8Hd_AL65zQh<&*pZY)(EZ&) z$)2Q>GgVbv3(FwY9xAmC+l~{gcEzr^{@axIJx<#Fs&jrNyuicG%2ttJVZj)g+qR<% z87_DJX1I=r%56XQF2~PS$>GkC@=WM1f$b`PzDe!PsVkddIlY^Ai`(bTv!oSFRNr2j z$YZiASJ}4BSy=o&QK_a_2wC$$^GBee0BM3r_j>%*lAwkC zBxXSe%lcGPLH>2?!;d%6+^o`s)myaOj+1ZFuCgXj}Dkn-I8!*R@-bP zT6cLe+F9Q^K#kknQR%_0vZkEVmK>+AY_pO$!f8BmG%{T6b(G+`zwUbS-q*2vKGY?T zV*zvB^^kGQg>ABD2BiO!V*2jHiNx^;1 z`+kb|&(Z}?haVGbZSOhv%Tnp^?{$?3W+lWSuQMwSmI*D(;2jwgA|%o_bmH zjQR3=fc8burq92&QOY%M1(QeWr#rprGlGbr6~a3r($QDZFv10O=MoZAbp#JYJ55OT zN%@|PVmp`gFsBy=m@Rk2%=FcJ$4Zn%jpy6L}+A`haoE;v0XLlTIOM!Z(jdg}{={NzW1 z#^NMgvF8D|skr=ob-Gj~puy?x2Rz#wC_Ji1m}q6TC)(;l{bPrTBfix%w@W9-uSr)zt)6O~F!y^WN86v;BX zEAQ&n7?qFrBTY}oOP#0t$Ldy{w`TozTAN$eL%U5Kfs%5^#E)@xYcHd79{+lWO$X{b z%`x$(4oe3E)vr!F(i~BkUKLN77@(M&+7VPBsWx=Xc`<9|q6);TAdx-ma1YBv|EaxV zRus>-e~; zwBT8Jka${`D?+1`Dn+=zCZe`F>KM-DfgZ~gb}|OncXHiC@OUgES-_d^(U=AX3TfQ_ zNF*k`%W;g|6&@B#iqoZ0{E4mLKrM$D0ZKNyImK?I(Uif8Pgl=ySlZlgUB2AY&hx^2h8fH?1~o_p4sU&?Tw+vgxyT& zP%C}fd%Gak@khG;wL{YYQEJPZ4CdpF{SlU%P;+p!T(p=^uJx3NWg0jhd$s-7K#%B` z+F|0e8dIf*T^Bx}I=e3QCp%loCF-hfgu5|()#@RTU3Z--Q{Yb~de9!nZ@Jh=&j&xk zI0RsfHPfmMuW6hfa1$YB7Y2+NBaZH?_q|oiIGTqwUrlf|RlS>k1sSuQQe3M`c=!I| zy#fo*AU71$6kQ!WU9^pTdIq{41M9korS{(0NLNpE7xdG$AMdIii~6Ff9fe+3SgD&d zj0I{Z+|rC<i9c%pVyEDGe1z_W}}09v1pFg9-xQ&1PjD+;;;{S)=Fat#I^5vPn z-!*X1*-N0L5j&Y2w7`*ObpO${ePI#MjM! zZd^sY&E`QgvKgMrMI_805CV(V*&|i&@a@Qnigs;xwvoddUdQbVs6NzO=xdxmtFWJc zkN2eNbZX_}9&ule$UIgSG^s-x#5BKIL-M+T70Lg0*;H4zY@$1M(rTQ7Q?C8dxHVDn zV6oqd%KjL$A>rDJWIR<)^*NW}lUK?@!XGman2w(=1(gUKY>8Z|ZHwbCP!6*p z{$A!V-49W{zeu*iYu~5eph$&jy*I}ta)0I11!qSyE-GsF=^xZ&^K-L!aHKS(DTW7Q z-#=+0kzY7K4)M?GK=BFBHe6 z*JESiphHHYbah{$*mNExDjAp4JKqkI;bMQx*>KJeYIi$_O$Xh@vxcC96ph;P%jOQ=ebN*`JQJE5%{T6QMaKl4f9dC3(QCxl zb0@1;&rHVaP0DPhZ4x)0jW|14FP9olOeO`(4WCX3D$_{a5=M*>%d-H<`l6TS8+XIe znN$<%JHk&soa`Qb6y!9by_;#I%~zyU%W=zna?=hmkw%x12}R`f0|kp+f~PFCF?G#5{QGE zAV!YN?t9@)R}Pn%jIy=5m+efNwr_clVnodQ=^M&&;V(3sHM^ z9q!4wW9s3k)t)?KSelTXrnz~PvR;tmbT#;np{^73bPhCn7n8->tO@2ZZn72ugYov2a{jR>(^o4T>Qq6u%$Ez&#f-FZZ`7%?Of=3J>i zqB^Wv;W9lX-04HWDw@@OTxu~E#C8WdKrAkJ6_{2YI;r%AD_W{~fBf|FU@U*7d8DXzVNFy*i8CDu^T>vPN9_?BzFaf9GxSu8j`aTX7WpwUkiDa|-m!EQpI z1PT7z_#=!6KX>8jfmuUtxvZ#R5~U>hIg#bxQAYg1)W2fR|MPD@abetM)QG_$={}5x z?={0Yk8&!PNEG3<`QNXx7*5@Kju=iJSMn)@JtW`X(T{5GXG@j=*=2RObS>sBUZK%+ z*_YsF=kw4R{Xy3L%5115DtsV~TdK$u{zY=6=zsvjCARpog$D8apFbR>A7q_ckSiCkxB9lTL@-mSw>FK?N}j zAs?ftvqK3>X65V<0Vo*#&sfAR66IKM-xxEZeWA{B3mSJI1vlbSbI;y%{&KkHa2XEt z2JO$dLLP?Jc5r3C_V178?q9Y-Jjg9ViCXEUik~!PnZC>r6!tSZ@V{e#LAw1J8foHKj&jV#sp3i+;m2$`mg;yTH8j6^lI$!eE$xzFJ-H)_mna`UE z+|S>RTYIaIuB`nrjc>a&QGLm9N9es%1s`&_*3T~dIZN`U&;~|kl(4iH>Rrq7XvlD| zH-W)u$cY0{k;zi4d8Z7Ps>aQ|`j`&yWtb4x_8H0)jPXs-;598D!8SJER{D1;e2Xus znY$8R!=3g9Ge$Kv6hHJ<0^^t^Bk#6X@aT_Uy|=W?Z!^MZ#o$>&LXy%@@MoV49a1rP zm#>fKp&TBS{yAMQdJb+fOi~~B`*&{6X<4-0h!dBzrK`BhNXu_$$o4>jEQy>`g1WH` zu}(kveH@uF@>ZJSl4}h*g6=FSjKo;Zat^O(nY#>vZ|4QKP7^V!Af5p|77Ubz_<_Y; z9v|_9UW4{)A+jlc;-cwI5^WTrnne1HlA1NiXR>r?MM^gi^MC*4pQFc+D}QdE52qJm zooiAWoiXFW(Ci=~;_NwQb=HgF=q!v|JhN&mt}A7fU8*jI(?jBXvG-UZO4f|)$(-Se zwIOOqE@x0+L9{qOvFsK;YrhMFrZL_-#Psjt|DHrS-X`3mcMO#soTjfcv73Sq+dr|U z;7+>^ZM2L(<6|G%`GvviF*7x-VT29QyavxqT$NTp+9S`MT~j1f-Lc@)Vj;2AfnV)) z!C32-AbW=X!;OX@$6F1rc}hIUn=T?faP~M)XsCXmJZPOXaQ*4#D3@*HBdwY)OIkmgKLk6l#{bdZb&?h_^ce2+ZtIeK2#a~fpr+A9okS8GY<+?9of@0BrKVeIBO%@Dn zzLLnXMr4(B+ycaV#ALm`bvkzaccuaZ9}e7Y6=v8&rmxTa!@aE(d^^$;|R*E`VO6B8|4`{Ak?N zj!68O$=ir~9D1vqqorMD~ z3TaNCCqrtVWdFB*+{z)`F-gN6t+U$TkyOOm_6FtZ?@P9FB|qgXxQQ^#^G!q4FOGd? zLEER9lc9r8R5TjJC)&qZ@AIXbDBjn%&d!GWKU2No5?rc0QLh zzvt5Yr9b0*02j5md~h2{kPP)_PkV5_&Sj82ByaOm)ZvAQLqp4H!CGINK1b3fQJ}mg zx74O6713C@*lU5Kbpa{<+oSUpFwo$#Vid1Aodb?bL|MyIuZjqKJo0)*ji4$WBft1) zkwzqVg6&YGMTd47%#e*(V&p;r3Yqp9%0#{)7U^dC#ijC{S)H@?x2z`ghL8rN*65FKfF^0mR&EZ4CI zU+mh23S_-)omE1X1X>bI*ve9U^ql4@>G#+Z7$}RPWky&LmzXqZWsz6= z`yEYHg+}{I(etlP{qxoK4~s|ZSY@~N?-kb;^hZ8hVKHew5nHUKx>5$;zSrUvqkWq! zwJ)9j)4|M(-X&gQtT0+%uMiK7Rx6t6S8dCuzr&3C?^~$|J)e5Lb5QonyYv~tyqh2L z4_45r)~8j3f}BP^X-j_-^}dX~Zgi@z*5De)$Rl3{3)Mrh}} z{C+$2W5V#g7=;E4L9FwGsr@-v2D#yQVm^86MyvfZ86HCw4KGD^*@r7yEdm%1!(rXB zZ4AaLjgbg_Puu7j|*`0)~Tpr|-=9b`#eEM}6P z56E%6euyW_Fr{vmjGDDJh`ZBrU-;;6(HX6M>ry2>VnFxJ{!A4|lLgn@f)bCdijFy- zvUWkkr^Rmjdun7n91`$-gnYxR*CmoKCgk1~jPo6d)Syp&T*}VGUBjAnX2P=C$!=Ph zOAG&ze7PDO;e=1o)>pZz5+u@Xqw|wG6|{x#OC0xS3~2} z=@)Lho6JvMV`fGN$|riZlh`@E75#Xg>tOxt-9Gj!sN+=*gBFSC;6VlM~pPKt&baS+9o83Ck z6ziDgI%YQ`woDjKP0b4)Q{UaYV0Q)9U!>XJ`T3VG?-|BC@-8c<*k#!B+;eonPHi)Q z90>_}M!0F*ids<2pJ)jt2re4)d3cU(T%Ll<_$CNKfzP&=@t&@DA%Tp|#e47xoaLWi z+T8=qgJ{{Mlr8e!-cvvv=65ES5iEHTzoaaAkAZbN9A^fBhJ2BsYYQDw&=x;_Yz5Co z?qAu;HLbFG#}G&6$LC40qK8Vvy(r^EGw$y5eMCInpBqRFk=;eB8>O45IB>!jr9Do+ zrm)=EGk+X>rTGdX1GM>lhJhd7Wp1LD%$QK;1p9o4*Pju}cHA}MzVSI6P4@{57O9-UV}dus73kF8)Y3wFP??2l$m6-@Jg> z;4u9e*qx?($tF{wg4ZI*O)&uOsLn?(rF4f2&4u=7>TYQfXk?qW1v^^WBQ_lmXp^UO zyfSV?_sj0xfdk+DYbF9ObEwX~C>DG;4jZ<3x*P5t7Md)w9k1X$@wfkcn>=Z_@;A)8 za&Vm2%qe}Y5-Rj1a&q=Za0wsS|4H#-AL7u!Dm4%9NegMw;bEmdMO@(@ZolKH_Irks zk1_C{a0*fI`X79X>IRCU3Hv@g+;7n2I2~>e!zm16|Gro0u0$S-XULNGTtvEm1zVa} z#P!saNd_Jh93-*F59Lnh1?#vSc31>X^E5jgoD6n6zX5`SaTQG~$7Hayaz)VNlntPp zj$*d_d`{De3jJl1say9oEWTJ?G7!3ebf4U_py)i<&kFfx6`{Rnz)VGiD9rI+-L(sD zT$1xxh=1B)+wQbI#0y<*3(lCVbRELP_WdZ}1f>2f6j$=4>X{3Wh2Um*=G)~TpGp?j zlcH@o>|1SNT#ud63&^kIExve8{&3RwcjT&wNQ#R{%J|F9CMYgBoaMPtn&-CY2*bR< zPOKk+K_ETjzmji5Poh;Ozp@hllh?38Xx`7r;WR?Ti)OIW{06_01GnQsHObj=`El)l za$P;H$5MaUtKK_4L&c(Zld6{!P+17EkxW*HXhG(J&w;4XGNIh0px zkLGwz{u8h9>QyX&-com$TDNtKM>g0lAnA$XXwf&wkv=-8KjN0H9)GpHjefMdwl_3T zjWh81V2Jb;?{U>ywZWL-NcJV=;`KyKHMMon$Mp&vEU!D2>yMC5ku$f&+E4AcNmm^Q z6k9u-m>Ql{SunmuLMmmAe8*?8vQxKMpnue8tyFN;mn=%M=as2AqKO{YpzD>FIVVOQT7?MA%Mq!hVFk4s1_dQ?c}bT~$j9-E>+8f7!nO(Y`x z!ToZK08!4svcwo5OgJ|QohVMf%mzedurDclHQUOv8QG#^qk9=#kBtZ^*s9QOrq%DS z%d3tv^)9`$vu5FzA9zVrvURlb?8?aa#>AfA#H*d}D~)@kTuKL(ndP>NX0yYRhuV)A zaQ9Zmy-dW!r6fyZ>R2%N-+(_sYf|7w?oSXO)T@=n#s=(mZ><}zORfLNmh|Qov+ZRo zkl?@PQM)*nh{ylwSL*=DuH;3eno!=j`svAVnKSDe%6lK&-_Ny2KE1_zd?H2Vt=J+Mo0Tvy}TdDX(% zEPwgC>~uci%s$=hEiC+WkW&SX;wnVu_rn;7S+*wqQJ~r}}Jn z`^&DC2P(~{uW-*ndC&1Qg22N!HnPC1Iaq8uHi|tfUNkl!f`s6j!(t2Op&puB;GVFc z(@}EQ&-mMJ?;5H@3^^E%j#2kYz5 z;FCRK5S96T3T%5HpP%8Np#bJ2C*i`jwdSD{QWvg3^wL3sorz1#|N@60K z85eD_x_(4ir;BKg(LsRA`h4Iu{kpBcHFm!|=5#zdns3-GW&g+0&ZloIx|7Pq+pS1Q#8Ze!wAu7t#4nB_ zrNFL~cntt@aTUX^8d@~_Z+Uoou?$=LDI`gU1~$yLLPvH{CF>;YPCrrOTw3?`hl@<4 zczx8IOFrK59ddTCTK#o`zrIJr$}L!(%h6pIt0yXA%z3izK{NODu-P$Z!HS1)Xz{hd zkltXPv*F&x6O84FYH7}4zhpmmy(>tV7p5|GhqT3sCv4aGjY@()v)UeIUxL^^{qp03 z&=EFyI{D=nE)Priw!cSN#c>y;Sg-e-2GHQk9T4THRFpaIfAhk7(p_iXIwyH{yt{24 z6ju(VfT!9qQY#}hv6-^P?~nHkw-@rpBqdX6r%QDtU6czZy-gK*82QU>ep-|j;zb4r zKDor?lMBqJD%T~poeNR*PkL{SF>u^rrKPT!*l!G3*4vK=PnqkUID%4J^{?ZglJ%sz zhR4|l6Ru<~d;AxW%uKK3V>MEDaOSFYo_P56*4VGq4P9_9FCemA5$GyWP{Lp9yhsF`o2-X;;c`%k$v|rPMLyjcj;lOMlwDh%C^@Rrw_Kp? zb;*>wZ9knnWOIs#YeJK(t;Oz@Ud}TErHcpeIr+Z;3!y#S;Vfc1Oi#pl>r>gjwEdHG z8UB0rPRFy$vZStWpG+|F?)5f6f!BC_yhJxlk&X25S1Z|i1WV$Xrw1Auy6dkke&1R8 zX7B;>BNpT}8r^xmFZiUlJ!p+c7;Z2he((8j`kg)K#$4+Rq=e570w{C!t%4=&4#YG<6kkG|_*Paxm14{mFiJReI$(IkmIF!wp3uIk3gHV3oYY zbYv?w;e^cI;&f0}QWQebuo^h-ywMk($ePt&HDBgM_7?U8U~# zL_AU#hcyY)PJ4sl$5CmAHNURmrS`6EvoSL$9d=#P%xVS1yp-{>qKBo!R5(fjma($Q zi2a-5ePXWRFT%ZPPbLd1)u*xEKBz%?O_%|lhw|)?s!CH>-yiNCPep~G^>XJP96+sbQ6^{V_)(V{BcKW!W-1I#POO&q6A?FO1(1Jne{;JKFimnJJsM zaG}@yXIhhI%esL2%7!IAQ?C=JV+htkkE=EK+YDt4aofi^9Z;Uk_qM|Qv$|M*38|EZ zBi@&UO?SpKRos~DzS+_mKmGp7E6ExGsK})xh7>M~KBWk;dB$mym5E_){Mjp}+id?d zam0q?b?-M%cO;~253c10QN^qJ*YHPD_NVus^w|47zY5SGw7b8^5Y_?P7mv6Q? zYcywc=KPwpbqI>7-ZY8K8x(#z{Ul^vXXK%P>!$erTRf&$W#7gN?T=J_UX0x5C=aaY zwX$j4%c}adSJIYA?6A9X&|0|P?h^C*B>#}PxPxl7sh9Z{mKrN5QjHLM8u{{$%neG9 zUFp`Q&jjiY&oh49AIBi;S#Jz+?Md3`4$}3&Tssz{ z+tu3ZC!K&lf@*sGnoVz25&ETz$Jyg)hfiM~vVR}m+ue=j(N~EIHNS5uS3&lyCYB52fF4R&5!|Z}_vm~|p#yKKm!`(jER*W}uss?m-q|+B3e^=p zU_9YA>kEz^{hE(0?C~_q*dfPMz>$}o-?DYjc~c=<4OGn@%%S-)^PN?vv#r{udkg(} zxuV%W^zg%5?s|V{h^Wu0+HH1Kwh35DRJ-t*&@HQo;AJr`8gjA1kI1R_PV+_hN_Ff9 zmbE$#cJoZC5eH50FCE(C?Cj%!19^~GmVz}8Vf?OvPh_RR-LjVt%V@}9+RGkZB1^bn zy&pTlVXF8r$CvP6=jX%TrvSQ@2o)Ve(c4m5y4tp{YtL9Ym!b%l-S^I(h!78{kud}? zq*C0e%vx5A0`11}JWBV>BS-Xhth0i=$BND34k>a_LVVz==JlA*r7gFbb6D9g-!W_t zDv)TcGKKvQXl{masr47XA5C{&?i8>yZd`kBIAB_Ccc3&C6Flw6DMxx6`J(2^-u`xT zd#f(!FfxklpH_~)t`NM)g!JGNMt+ARv14=6E1n{f?Vou1ixDfKv_TDw;cRU8x%^SS zuU24vy>HqNyD*f!<%2;sX1rFbu@{UQSs||QBs`hk`9?`2!EZ4U`>OWQd$MSbs8^$* zr=M@OYvSPs>Am|EGcaGd-L0V8@#=O;f$h*q(0WR@*c4td91aNN!Is3q&!%v}7R>jTx&B?_TVZA@E2MW!P?4jC^prY%~% zDh5nOQz{LYe>|^6?U|6OcEUk=bNDDx-gsp1#P^k8fx=Kzi~Go!;|mQex%Mu-(zBnN zjv|=8SFoLb<=NL=B__r6X?|T9+en{ka1)YSM5T|X*8R-pY0Ul8)&IraTL#6|bz7rA zAcPPk5G+6lZo%C{2p(vh;K8+l#vOt?2^u_j(8k?0!QG*e;O@}Sa5v9$-g8dX_tpJ- ztM2~SO|$o2HrJeEj5*g#M!}ed?VqhmUnwG`sxi|Os({v~D8)5A)>pzZ_(o+aDt=sLD$x<6&BH?LF7IXRp}Uc_VZz>Ixd|^T^QSh3=Gx=AAH7i}0o6%mx<~Vcf^sl|s5lPS*n`NiHvL|`AMZe7DA~6fZ7+yxS-CfoJ(Wq>} z1lx}@w+0-=E~=>7a|hV$YQ&~k3%n1s#%6E}_cZn%JRjT#`ZJ1L;D^h%CMTQQJFXZe zx%|@7?ccUJ$KQs@=SN*)%E&EWn4a~V?*e+JY9V1SdzgwVAU!$X3LbLI%usyN2*mqi zX}#MNzPE7z5`*^o%Qxr4$aQgq2bl<@*98-+xL{bHc($&upNDjNie=#nc$`V;P&Le* zt?hPIh|TWdmS|ox*YGqX!8cKe@%p%8VvpCx-SBcSeKpfa!>5m^WG2x-!?g>mk-w)o<GEm>tc?utBrMy{Zo19U0lSFMKi2 zRTLf$0Pe0r@=~x}54SabBkj-AXjcw8O_y)-8J7;4aB%w|F5JSdtDI56K#t{d?Or<( ztgL$ZaZuYUaLujl_M#EWFIR;ICIf&0eZ5pawmv~WtX2N8bZm40h(+WBHUR@pA?92* zNp0V-2pGPoAByrVP8)&>+z;?ugJ~UK{hE6q|Mm!e#FwCbHa8a^->9wpvzxCx|IZ^_yl-lGC~VzL6W9NGB{1;GPVz$O-M*JCtFa1 z@l5=Jk$f?jhx2m4RaC15e9&egzo)T{x8>xpAi<`tZvyn1!nen@D$H^AOBl z7vDN-lNUC^l1bt>@j_&UICLBl`M_5@_hkTg0(2?;-hv!e3%$scJ2#-aREKx{%#HiC z_LDl6j}l0~C~(b-2iDhY<}wP6o%a@5X?YN~R#F>HJmB%qObN4Pu6^003pKXi<5D#b zEoHes)!7obuC66$Uw%hM+C}xZ0moxhb}#bQSXem1*W7p{gc2`bIGdSmj7A&~epec0 zpxVDbq2>iI4P7cO)LNBITO84(-llPX%jEa|I;m}|?~{Nqkt*fH~ zQ~RNJh%`2Zo6Sh@E@9kKIiyMC;+oAsV6kkeU6Tm>`%mHWdZuU-Vz#1)qHX^q$@2?< z)A9knTkR4*6BRd&#Y}4#Fm>Di{0kDJicc4Q<9~{TROy1~{m@`E7}_C@eBX@`ojLnU zg;}tgVxQKre|=`<8gO>ooVQvPbX@U)2GL9W!+YYPY89+i9|``|Fv8=2Y7hKKg7W;c z07A{#uCW0Q&Hhc@{3DHu{x2r~l~X+N$3nhF+&07D<3s=J^27V*75@MI=@+GseRZy> zor)`Wa@a0&2r#_wTLK=roRvov*cW<0MP(J*t9^8j^Sv=A6c+Ot_|pa6BYWm|_I4P> zFCFNlUt&ha*>94CTjl#|vna*+@bCcT^(BJMcl02{)n%_Hap+E@qm5d!rHsV_uh2ObdP&|yjmtP=SgNni@BY<*pwL*iQ zF1>!OBK%~ru_-6Ju`!NkrvU*d`d;^cHiwMdbeFS?3t+cyXiEpz%UIUah?xrS;^Wbk zp@l1-%L}&0`I+~~um3B|+V7-AQZm>Vv9}pkoiP8@(q=cpih}$AA6j@GH14qnfOYVT zLtgWaSlNlpvy00V9%Ig>>+v(tcqWbr?@=<*GBJQ%jf#73tG6iju?O!|5sIEH76Z$I z2tXZyc5>VN!;C%hA1fLEbh5_X^=)HY-HZL1vz8vrE8BYn0NBysRa{K#F9GJSZN?J||T)5}vT;7(Yi|2;(i4D&7qTk?4aMvjmEh1lh8#uMrFlpZG=P2tv*^wg^j z*Ea8~GwlV>jg1X9FGK(k+b4N&+u1pK<$bL!{}uY!6<_|RD{7UBP=018S#zt>E#CrS z^ts~J)@7h#SF6`WzHnUFLr@g z4k=%FRG6USAs#TC7VZB%s_kGyK=Ue+HMOhnX1bHgCU$k&taWZ>rQI9+Q2Wqq)#4li zxCl~`QYCk`S{_ooE%0G!gR#s{dyAwLT<`qghk-oX85 zQd?TDJ5+>UBiw!2s1@zqwi+3^4A^}*0bg!#b({v+E29ctA!PqwgqRHI{_oyD0i;nY zYQVB?XTmoLYXDpEghd0IV!zj1zUdqNUV%{yFEv+xd&%AV0IM60{z$gBKhk#XtTJRz zCW%83I0l>2Y~2_9z*6{`@u&|#vVQ~YA?28WI9y5lC<_Mwm5}UF{%1P9do&+1Ny(q< zeX$CKQa;7spaXZt_@6r~KHm8o$28SEFcAF6|2=R1u`9sM{=NM0>btwJNEclZMbMa| zo4s4MWh_iAR_DFQ!YioGO;5$rUfb>65)kw}ZgdPWpK0#4)01!xt{n#q#1zuG%l5I! z7wYEkuh@>7@BN(zegk35vwU$EAPJ|Z_v1f%VJi^+mLc443K0)B`VYo{JNvlPEgbK6 zLja%+kkPXE*tuVcU+FbEtVbo8EiBKMIyUvaNxoh+heb8}Zbk(zK-;e00j=;m9rT7P ze^;6{p9Od&U{!DD0&EHJ4*>2@l<9lgy>m9r5=Gr*;A_c?)gkf}<|}%?8>_kb*mu8L zw=$CSJwoSH&}SdZBG{3&r=><_p0Fxhi2FvP|rT z`iFHcZ?5PS4SpVophsg-EXMF^clU^%b-(g8FlhC=I?C4rB5xYtPFTx+J|fFE$ISD; ze-Gx1pJh-YhSSCht8m*z z22QH;3UmGJ1HeF-@lj3nyYQLN4W(x3L6{?P)=e*8cg z)0bly-%>Pf(M|O|{pw(*Y2nGbG%)G{|4I$$3=A?8SyIZb4b@{huIv_S9)=_xpRFozto_+dQQEH|u8)LHah$3cAr-8VZOK?-86u3d`- z1%Sii_cX>V{KBI}&FYO;f?PLK&3uMXGzrJ`1yQE=P81F&XVnfMs(b8sj%_U2KZQ2l zJ+Oy6ws$o35EK4?o{TY421$!|sFP^wuo2><`a~B;wniS1u4Cisd|HXed5@Z4{8opY47n`($gB_SK3(1vBo) zM)ph~G3^FjWg1Of25KIWD)%Znn|nT9Iwk?y(*C9wB%^y3(rl&)u7m@LW{|tv{ z24PMHJjve~YHmhZ)6PieU-mt|#vBoy2C@>K0M#sc&Q>L)L1XkB@3`G|b0Gtw50qt= zgC%`$1YJ&Te8k59W`cl_F;+#z_u646#rfd?I3K>h1~pxg9`^_RJL{Up^T?9QHo(LD zwRpg@Ig6G3Zsb2$fV3koS*hykj`N#l;z8y(0^NEs&PUB#Vf_U$|?b7AX1aL<>->bt%nkk#=O z@Z}AvO-PoA$+pEz*IKwC%>V%ZTAc6xCP3z0c$*jao>|_qF9CajRsVc(a0g$9J3_C{ z@a#l&3vjL82VC9MF~hu#?;E;EfeIt7cP<-2*Tqij-55DQ3@nG=2Uxz&mYm>n&+*6f zv~@Ovf`)Y* z_2rftcVpV*!LsrrAOBJ9Bd?f%2jn{>?Q5gHFCBSm8O6)imNu%kS8 z5P;k9wEaBx^|eFn)H$mZ{@pHn&~oK>xEm~KfWAcfHA4f2b2R+vm0I}fmBLT=G0sKR zn_hp~kQmv|>NRiuk!p+x*%ZV6V$T4y?MzGCBmbZj@N0XvGSm1CCq?kh;v0dT5o!Oc zA~Rt6xIifo;j0~K$q$B^hCrCFechcC2b6LJd+yx{#6TJrJ8}w!-Qam4Z^#zfK(1m2 zb}2@-Fl%m4ain0uKAbQ@-W{36m|v!LWv;k|6mG)VQ^#FEMQ0A$7uHOB{EM?|fN*S< zg`400Bzudnt*-g@=Pf{{ob<|9$sZig-9k-G%t&86RWTk*ck?m4JQ}{hdRenAndV1* z^0IDU-ffs%X)CnlV=3I>`oo&`Iev!EaNK<8&fo!`~$=05oL$-6q`e+n;i8I?pb=iDTXfddCo_DT?>a1c&vfMh*>Z2Cr zC<>OIkI>jk=47a@y%gitAYO{*&#YIc0O_F%aVz33N7jqHhW8BLEUSB-x2K&CGM8!N+G5iJPAfTf z>pY;Tk&4Us?tC#n1n_XlT5}E!*^>d)p%LDjVT|{!tzO7SFomY+GMEff+DP$3mx}RW ztYLf*w|sW1d$_>DDov8Ky4*q-!B=UD`Yl(|}2-ZrP3Ttp#68G9@s)lH0>J@NNsI zi_diT3U`i37f0qrKw$!*!s3uHSxI!&89nT&X zvD+QdK$^(W@1k1tyb_K%fZ?b>5&>Tp~ zWJ+Tdb^1TCRwugprkykA2!ou_Dl}PC#<_FT|0aG_Q2UA;9yb|!=3kQA#SU%7IsK^e zS^?$ISJrz^*c-*NzIvZ}8NX3$ zxia%my4(E^f#A+G|0S_vXy)5!sfTzU96e0_6M_3kH@Z^_NfG@*RT#a7^ zIh92CbSdkL#yBPzoA&$mO4Vo-KV;1V{W``| z{MC}a^H%oJ@3}~-%rr!Zi4CP~OmrYgOtThWbWwX|*VeO-{nrSzg*NI#=|z`spte7{ z6?MF~(GQ5;Bb>pnnHUiGRU(sCTe3wg^cs!VWrZ(Ju+JXt{;K7j)x2aw|P0sLQ8#n9VH_>I`wPR=C9$g3^+Zte{uHT0ZJM7)D=la6z{A#N7OQ+pUTLMMuH z01s+ox72$3b^$jckR^o=0of2_V@v-04!$~&2iEQ#OFK0;c<=(`TheqfZC)F_d}5_1 z7Z*NP7euxDi%FRVzYw#b_R(^VAKOlYlNttNcf2zx9bJJgjl32sMv>ZsVw$ zsv99KBg@vT2_texjzTG5Rd_I>8!HS8I7XocmV+vN8kerfhffhMrzli3Ih&5QVm-gY zLnlJl8a;7-6t#LM=3hp(T}=!4$b32;+}If?n7er<9^)-{wj+r?-EX3Da}lQtMLP0{ z3zO2i{j0Y^eKcmT>U2kuJ%i`c>zaxjsn0Cd_*E?qSOWFxVn_N{#daAyzoS4u;g$Rh?y4$e#W;hqESq+y#iK%e&4TGky5rw zj3sU9PdP8rBZ6*y>3cylXHIIL#t(dyrBVb}UqI}Tuh_CFOvAYrOg}`cl&yrCoFn!e zM@sy`Fg#Z(+vT`m&-K0BP{h6cG=IchYtLUU&o(jsn2S^l4)PyQ+L9*)hPQ)sYvg=R z4d4ksh+&b-u`UvUQQ8(&vGphz(lq%LwNB;0OO5d*?G$`F4$}R4!gTwLJ6Q%+kndWS z(rO;2Z#+862MxDSbHXGaHsrRFQva#i?+pj#A0>s0qPJtAi8OvnO44>Z_azGumoQ-A z6w|7!I8UuQT~u82dK1S_h!G_G22{!mB2csDHv0Z+-&Up2WUeWK_pS~xcq0#F+$zW^ zBJPvK@Z(f6+`amBphKkYFk_~KM$iZ=z(WfFzQsP8OAouBcC0g5S+hsHGU2!(75uvb zHS)pxaF6A_@7IDRjk-g?j2&zLIZ&_g$$+Y6j*+|Sr+gdZo?vedeqZmq$ZS71^l?gW zZ~ew!rW8K!bF$+|X-jWUTAl<_j-5A|E$6eTC1K+t{fhsu2#*_8o;J4s$O!a}fZIwo z#Kp-KN(%ZxBp3KqVaIo;PABtg%rWA_Kubh!7jA&uADmE{a}_TyBUr`R3wO(DEVAUu zror6O{;)LR3JX>Ko$wyI6X9sQX|)Pq2z%+T44<|KY2W@qX_pdhQ<~aSMDEOm3mK z&!VSubqRQ_ESG*;s5`m&G2_Nr#a?B$kmWx%x>G=#pq9K5od6lX&)o z^!xi%dZl+~L7-@Z8biwRbA;QN(uUSsu;jEdd;hDEHB)_C-+8D(42AYL+L3+slY zasSnP!4T|@;A;`_`O_q4?Hvlqq___c@4L?tnOpdS2{&Un%PVtY4vZ;{HNSG3vAzF{ zfw!ra9p}$cFR<2cs>1O} zpA@u8OsF`e@qPiSmPoN+x_E(*ej2?ljSkk^QxE%GqNKrr)sgX**^*DmT7*E6fhSR% zJ&T;_=zatAHJ9(idtiS18>2pZ&BN9- zRy^Z&`;ffBDe`q=5)|)3tg=C^d2Uy-?X16)WLtyA!&D@x{d5JrrG~s0aJ=0TRZ9sr|Or@ zta_W|vIC;dykea>^?=J|JFjl}di}dgbI6veF8Pl>qWOofe+b5op7uh~^9XSaBbg(* zV>wBChMI)(YXuQeDh8Jqy*2UIuLAoR6~ShwhvD-vZx0p5i=e~jh!E<_HSPmi%HG!s zkww4Xju1)JjM4Pq*HMP=v@32W){+j!-4sdU0gfcyRax$mU!ww<`~vo<8L?3L-En?w z{hOyzDiD_t(Y@&Ehpz6fu!Zx25q9vi?D<(Afxo!YEuP^OFr1wqSbQ|vxOk;WTa0Xn zQ>L`C+UHVhvm&w!IMb$dV^2rKbPc}A_Kk%>Vs%sMP3@wU*8JVqGSrHw{zZ9!f;!dY zr1*dw%FMI)eY?SZ#~vWHMSu@r`-%AquQ7vAE_1cBlw@EI@gK5sAxZl4)#11M0W((= zf*pi%`K~y}Y`)$xz`-P?=6J7&-wUHhPjhtU2<(1H{?u&Q#u$z^2m zWlHBd?dZvf9M!P(Mu)q|79+t7#sl{F1`j{wzabyTj1w0Q_r%$rDjuC|_6C6~_wr?B zH|e!TY`ii_TE4AQh(q0+DVVXI8@rUgwM9DO*<$pp1|p;1j`1&!sets`E7zVl>M`)>E)T3?5(9q2dPJVS zT>958{83B^-*MI#2HnKI&l`-SS$!b3AD`SqcPqRqyS^ZAtEC|7QT7jb)Z!A2#^0{n~-xhQ>e|OhJ6`gTOA+c9}WtA4b zXX+*5c5DC1dUd?5HF8r2x}S$mw#c7Y%v6E$)%-#$)VKUw>%O;_z*y-VR%E5$A6dFq z!@(f(PrV9o9KIIDZ5;{o=Itmkfq6W04Gu5LgDL!V5VFqiPW~Z-x0L5Y%-=@&IK9zd zRuI)`u?352a{5LWHd2E&&C_6sZiz3S7u{Z6l+({S;><3@NH~3HdvDt$9;sj4;Hl@h z_im|#ZE)4&X3y~(e{BVzhf1D!BkcCqB%*#6D2F0^akuBR4(Q33D!mN=6!WIaT)!dX z%g}4pQk`Crv+j6+V`FA`=V7%ev6HOzSa1c19RLEa&KmZec?Qo(F#Y}8FRs&8&N~~W z$`UYv9c;ZOI+2`1=Kcj7={+P8bcqnr;=k_h$;0Km444ggcH_ClQaSze#|@$d!_is} zhHaXe=%`9rw4zo{Iy#fil0iQC<(aL2h` z!R9G40`@Z6q1AZ7o>ZUMZN~|2S5`c901i>8_o`L;nZVAj8<+IQ=fDw9wr}<6{GL)G zL(U*hxJrTUSOKi)^ssrw0&e*yks(hOE2>ZpP5Rw>{~eRTpK>|fqyT_p)=hQH1X6Ur= z;quOP#wbPucOv5mKbwyLdz>g)Z1<*PtZ{M=DA|&uovx9fol_)PFeP@w^c~GH?|xsF zWOwA^MvS%9un@iWJx1@8SGy=iJdxsYupI= zj&J3s6hVtf(({Zs6`osLt0>5f8~)O)t0*d#b`urPH&w>qD@p$Fy&mF40>?#VM#*63V-e~E)H;zg& zSZ=K;5=7{;w3d%FjRbhWt=1#Xi>NYYBdE_NK31>3`fIYpSa2PSet~jb;DTDW4`uxw zXCGzf0t5jLtxC`nK7ftbtOpcg6 zEB7^IOwiFW`9+ENzPwnYWn*UPT2BJnXpD9X`P7!xNE}{Yrf<{f65nQ~P;3x&?3?ObF2Us#dOjHd~>%NK5)Ym)ZSW*lD z6R~;NZk~s+r1X?6l;2zzI-<7!+2$j)Hl@0mBaf|paip!VOkyP zpEZ~#3f|!L#V-A{WK1dRU#d@ILEgbV|BdJsh03<7rAe$}24#)8r;0*3A3l?MxMx)F zF+?4l(ZxO|tsB;R5Dnp&J$v>NdSBf9K%wsze4JzMalV=I5DOhjk{1^@;Cf|5gH)DDRyt$Q0^_Z%6OTp;cIFZOX zH*FHjb&_5a6fnk+AWY3 zwp;4NvCJNj*UXrcBMb3CxsLVGoqbqvV$1e*izv>(?|19ojz0e~K>A_;Y`WPKWFt-wY@Q5zM*u=eqLp%fx-L!W8_~Lo2JZ$=o{z8`a4egzaO)|CvoeQWituy7 znxh7$G|8L0!>jj*Gbv-nM@YbC*>7_(MubM~kCP&EbVB3Yu`*Vd9GiL5FR>2CkCMn* z8O#wX;L4V>PdlpQ>?h7r&u5w6K_Gc3*T&v%5s2_0j=)c{Z%)1T@_d@KlknvmSdt3YoyH|OCR?P-l}s*5V_ zXeb4qp4;&VmFnD(RiT}s{9BBadrBC+Un_Kno>Hv7f|Z`db7Np5I!P$?!eZxJ?8CMI zJX;s+f#NND{GkfivaHcK9 zwiX^1-(jRW<+E|YQ8ZwJ183OThM^ooBurDDv>BJHprG!Wb&yxzwYU`Vx_O~5^~Hgs zQ#p*Jz*eRyDi0>_Ea2*#EPH-S3FG^Lh|F8(I2W-l_w-5a6Zw5SJ`UHPa``+v%hed8|!Y8iG*)Sr#@dn_LbnFxY^gKQ=VH-ACjoRV;M z5&L2Mxq*2CTeytgF}aBJG=v!Z*Y-Yz{%pdOzL1sTdCE=UX!X43a-!XGE-!G&Dbm6lCm8L}oWAB(v5=qH#e zc=1Wt^JnQlR_$p^;m1#?)c6#4$88grvUq)Mm8)#eJ-Nt7W)>l9yWH8c!~VZDcDsS| zgfPpzn1Q|fB`Qz;2*J#C5lsoeGwL}pT_l&jEENu;}H2{mu z{PX>K^lkBotn=TAfsHVo%hlVEV%xNGE>jzM+&A=A^FdgZ1PALWKr7rq`W7Xd(n<7y zxwug*S+Kry0#8el%}5a2?l-?Wd}vX8UaJ*HKQ%KY$J??FsOAnwZaNv!<5bn^M*fQ#OsEy_8;_;65fHueu&iYt@mv? zThba;Z{lQzCkNA(kHn#J6D2E2EUU_sm{W>3G&`eEl_i+20>}DRZQ1r^6#Cm|Fi{VzTCeRm z#%_c>NRxe49vGO$c6kw??`$bn$e28uWRuWBL$R+AWenwjh~Em6UC3}#zZXd6IA5YahE5Lm|UbSsH(R*i@z$$$1clP6<)cDDtQ+0dav^En;@;# za=^VBy&fwkq5TUpjW#9pq;?3*cS^R|wVy8IYY#w+VE{=ESCxpX%4dxgWm8Wz)g90rw|!fv3fMXu32W8L zL66=nAWq?#-g@1q+@ODdUH~9w;u+Kj?2>!G>(S4hUIH?af)0{wT{ z#-y{gfcw)O6gYN(hP+yxCEHRqqR7}J(NSy1lb=Jtx3eaS?#)%+%=DnyQs#|sYj%PF&2uu(F~0&3!}M&S0(D2F%}AQ zc=OIRW{D!v^@TUpnim)AxrY{UY+H9XgbRc{)n~rP&M3}9#WSe2lw$hDwC2`N!i-(% zZI@M(;;3xK#iwt@wYI)<3-x}7?^edrlyWeRKatVCG$hT-C#?#PzRTk)HUF?4SZ+zQ z#xx$?^-j75n(giI0QV!eIp5)voz2CE*k#w-3Kz0*Or6fq=uu5c_c>SjcuLx7>G%4c z+d%cN9>tS@q_Zqx$G|Gjb1*j5nxTD|pdheBH%+7W(f|S1~c_ zMP!@AZ6w?)nw_1w`#TYBRSGae))c3~mw$4>LsNxJS_jgZsfleDq_qcP_h?eP(u+TG zTihEfc02V|RL^gvr=e`Sn1g#xH!pl9Jy)n7)lEo0^>G9FOi5F!J>vQE8xjUQ=YmVR zl%kbBEXK0;3zKLhnZ@zfnqbhfc;H>Vjpi|D=lRnUW;|5NWg{`(h*K!^VIt6IXo)nh zmU;NVB;qW5z3~K)g7m!%EsZNw=NpM_%x`tzziQjC(V4Gp8(G;w-F;TKPF$<(U zMCFVcW_;DNis>CPxH917X2j+aq5pk^_v6c<1{J$#Q=xi#LEFKnNP)k#|hdUAiqY1Xp)7+RGYHZ>KzW+ z-i|rBZ;qVtkc924ABp0?quo*-+l~Z48Z!8`S;Yon#wy>+8l9uT+9Rb-eAA{FHN4Ir zCiz&VnASXSuii%TmaP;F$n(A0oVDend7Ww4YQP}Ioj8f+qhwt6o1f{6km0Q~K2=iR6CiK>Hsqdw|K$#{|UGhzwxX(F|r0|X3 z<W&dz0Td6B6!U?8O_hrK>bioT|Jb56 z`52NeKY8QO@vI@Y)EV!*mC1E@;k1U8EsD23O56P+U1l+MlCxAN-AUF?aDJZs)rJ|= zOAE}n17Hl}c6T5CvFR(T;hMejE#kXG4W>g3T11+IJ%z}Iffb6OK;5M=%M#KH0oS=R za!HPZ(D$EqUyEf6Xnql{(u(tHm{j<5Dp^Zusm~{1E1pYxW~-$)N;s5Qj%C@@p&J&W zP+m2BszE!6a584TS$!bTvZB7mPGtl(3qNc33+@Umen_{YjJh9qMqotLNA4q>!H7>B*u1s@1}f5t8WcHBy( z3~!=aA6`H|+LQ|rr8`Z-44nEZjUpqv-s>Uv;_w9UJlBq?in>)3GlZEP#u z4`yB^)9HM09xFTJnL3Wb3_S;N$2Q$1R~z?cAQ;DanpLwy)XL`Gfhg`uG*`jISf z%+lfBP2RBZ6HD|SA^(ORRmttY9fHL3_8AWmKP%sn1`dOwojGZx;uS8RaoWA9%H^Lr zjbdCL$0eg3f|tCtbkXu7{F|@LwkxA(34>N4WMOO&0r4LVAEg?&q6@RvH)&6E^`!7e zIudTQZmV{Mc(gW`3ISuRWa=LFR8jw3xtcDr`PnXKEID{Qm(C(~O*Z|*+H914%M*XI zcX8F6GpU;TLWw8q!g~1)i$%L@hn-+p5tKX!98k%yH$#-5Imji|;wiT6`!ImI7*?&| zTbtGBP~M+j^Cu}mGo})p`E??6LS|lPPi?>EL0=O6fjgH3WWIj;^ft!xYdwD9g@Mz~ z+nhxFkp5e9t+B!(8=Y4Ztbmgl-Thdn3os8UYb1S^bu!*GV#Z|}l$J|L>t@WJn4Rmh z!Mq5U!qmyIt-0WO_oXBOLaCa3F)XVf9!x{|bo+2{Xlj$!I0xuA9W3gPHTdQ%(`g4| zSE%>yHB9Ke4dcl6{YQ?2xyRrt{NB=;*?dVwp`WLgoc%@tFeoU)^an0%3*RViBPMx( zjUO$Fwu4KoDw>CY>~m_Ogc z*)!&aZ?y*_IF9}PPn_Jo0X#aXhw1lum5>g_!9%-kqGv`ap3FPk{`FYaUyUW5RaP&V zXW ze7`K*wTJ%Pp*XqgdQ9&^^4luckY{k^v_!lW+smI~Ld~Z)fHDB)G`?jj<%p2)Kx#Mo zw@SeWJ@K{AaTfX%BZ9Kirh(If1SAPVNt${>K(NJg0E@@46Bquf<*^b~p*&+J+HDs+d=IUUCr$ug^J*`XrBp0eZMO!N5%tmMk_nm9I-X^^sa(lZ|ax zy%z;4oNO?fL(u^GVe_NJnW;!!?uSoE9K}+5t#|MzdTdFqG2yU^*sw>)RHdux%W0iY z@xTa8FV~V$bBM6iZlcmV+p!2RQ@UzFXCfqUGp25i7qp9i{#!-dg!$i{@$$NZ!zL$9 z1>+)Ot;F3wV-ie9j@eZzI|8(RmlN+I-=>wllD1FnUAAIq_MiYN-~tgG0P)4G5y3ZW1&OS;i3_8}$IndfFW~RqhZ7S5V*i0@ybV#D6wHNof z0q$;9RIw^P-^xkYx?#X=nZd;cP;L_T>`FR*e?4%=6-#Q@U;?e80vQlcx0BZXI=j#eUP{hk`(k4B03;b!t!1`36 zd6zHbbcSGz&Ox+jXA*+xEp6i_s4GKk z=!G41wo$xqU>*Nlz6TF_XdLE=&%--Ys00%Q34i6i@Bl*Ea}O7AIO; z>{cF{AmCIA*%g{kj(q==5^`AccPGZT>{{{cs{PoRM7y0SS2*&)fm~w?-byeK@^FQ? znBM8DV9x&?KtrFd1V4CgL1;vCqh;O|+UFGlr$|^FcAFzE!}XQlFX(2`Y_Ha#n0t{4 z`bN2ChP{N|k+W}reF9Zm_ny?ZFBVV`U<7#wE2N29uj0oj?oPHkZ`ARIMSGmw&>MKH zcik3XH!$Td9S**)vW)efL%xx{+4r%sGDIjHD!y$g|80z_$h4#?81l4bI zrd;Nqv!9}PINzTRlhWNQ6>PcB8EG}P69LF_C*ySoeU>$&B;RXzzg-gIexe}mh(co5 zKX?83XGoGg3x(m0kk7l>OBM*~Yq}~Q_c1uQ-FK1;U~zHnS!9_V*Xp&=qD$k=Yptqc zLkoAC2Tu!?7bE~+COxfKwyAJLk+Md$`oT5IGMH^-0Oo766xnA_)9Mr$2c9W0ag5p- z$FJ0A&1GZHBzp_!Q@0ANFBQiq*EW6NyWJ$7R%rvYa9g;y^J zM+OWGco}=b;lR!mXxdv*n54<-+^KaoJhV#(d1qyl!SPd}%qWa^>H>eWsh+7%Pj`jC ztE{^Mz#7~Sh&dF`Up6gphSk=$QtqpL2+&a7m^jpUwdF#1_>d0vyz-TqH)KGTUhzPa zVd>J747sPV?W)56zh$`{<(t{eT11loD#-@79YL@#r~BOoGhhCY_AyYp3Vyk}wzsFC zJK#A=eV-FyE@ck&HSoEkSmTf66g!(qnv^}+SBHsDcm)sBtT6d+y9g&!aHy9|Z!OaD zsFdFl{XbN_Wmr{TxW)TdK_vt!=~B9pZV~B{ZjkQo7LYFK?r!N0sg1O>q--QNA#64s zcj7tcx%YnegU@EJwca`2-x#B5eHQj8oszoIOz%~VHjG96^?6P#VO8xSc*$fmj7UM9 z;+wV-llganh7#TKO1{hdj#Dwg@@|V3JsPi>cr@;6w?4tOwM-vF=KM*E=0EOwE1WId zUAebUjG-F=dbk1}`Rx-nKDkEl`VI58my8EJc)KB%D|Nqm?u5E8kKJLfLLn-K9wbYX zW~-Elti3%YIJUlM+~X0Ej_SXUU-Lf3vMoK*uQB0~VCGRA!y4K9gDYX!P_$4@o$xUZ zT6B}%WgmOxn_E=Qe)OCT2*;G?fI>jyZ5J?(t#u965Ax1l@jw5{S|@D zZTxZlZ(r`HSo#T13*C>N{c6%z`g%F-gGlP@U*{a&pS5RT-V6m=6r5Bj^s_8bLClC7 zFGtF2az0WMJ>e#OgE$n3o57IWH)T#L3Uil(YCebqF#M(i4ig%Yp;_d9YTIYEFYuWn zHCTcK6?4*tSn#A!Zu&afV&WUfjV3JzTwVMK-`R$Jc~2HA*+=KCR`oMXJb&086sP%V zb^0MQ$Qmd0=svftRapbFE@#AlQ_YSkcpOEB^6ddB|byOuX|h z*PL)*Lm!)R2~u9IjaW+d$IEQ6K(!Hro{*5t3xs7|m5;QJ&{cTUmf0J8A~w<>rCmEA zu{vN>6Sc&i;NTm?vGo)deJzW*vhuv3D%?1>OvRA#Xif2j^w!kq-Fdh2b`WQFOdtMm zTa?+7xFpX@_g>m;xq{hWbH!i722hLYW`vC;(EUKv9sE^iMx3Q!;|*{zdKq2yZ)c}> z-SqJQ=g~ukOIxn@>v2e;#Y0;&XA5r~g{eQkJj{QL?_U9T; zx1{arvm5APymC33GF_Cru^QMtsARh~4J1(abCIaPGOZlRaVe zOouR)R8gzSqSja6C=a~8rBXuEJN^vwEfyMI{pso$;?@md+xad&dc_k~!L~|y^W0hU zWVq#z-j=7^&&cmQpA(#Z=0542xtPlKFeO~us5@a39P7JL!9)%qYMnM0$9hv>Z1a>NzzqZL%%HHUj9H#3Py zzrz0GcqA}N2aoxx6ma3U>7>{r|<7%0+J5Xm|wsZf{ zw4au~{v$``2?Y|`qUD3haotB$|7$E|P~x^2`m7%-bkg&OaANz;$Ds^=G=~S`W zGRP6_x}OZ^*pA*tws^2zI=$K{%*oiat9cdE&toxcJ+~iEb15^PV^5eku6>oeKrZVm zA-Fj^pt%@MJS{C`qkq=s9wuRV|77NF?NG+mufb)MZKy#rljYiOaMoE3Y)~GVn_TKG zGujG#NXaU`(uFuJ0*PQdaRvZS>NKfjQ z&@bHv6guWJA@wm$7)8bA-1c){NKK-n!(|ktD{kh1hgi48BiJl@HyKnPoMIw*=Y@ZaEQ@Ggd!y;~y#-v=T8{*KJ~J#rDf4wp-Vds|kX?D82R zHN-|)Wg5B8jY76l!DN?68l-!Sqb^X{9>Wao{*xh;wj z&k&BfUJ1I^_W&@`vg!&{YPUllEOAauDU0*psH}sEbRtqsHDWH)u_1f8W|t-BFU_Hs z!NKWPzQ9%RwgOQn*qAi!M5VDe7Htt{_*GT;Q=5W=sh^?nV4K6!Dw^9s&*dQ;5hV^k z9b9nb5q-z2!Nd{eLkZtTe(Fi)Xl!>)6)H#eof{RiN=5ZfYuzbZuiWVV`^H$t;3>>j z@Wp*u8YVfvSA^Lbh+e}NJm+1(#v-e8I7|R}Og4bp6Y*(HG)u7D_+O{)^RR^D$Gt)1 zI%Y!R;-0&ofb!b*+S*3>8zQ4OXe)ma7`a4MQk@9Jw3Y@IT|DDLS;D^kkN&cfoO z;c6Ir(=P#3guze0Ut+8bC0u%*`?A@bVLnP&eaUkq{ogX#i!zQ9!btkYu*jmUg(`vb z=`(;I7Fj}_>^nUfd!TlaW3Y9AoRFw6|A5uXC7o$oHtRB3L(j5tJO}7Nox!i07 z@sJ?jUg)XcvO3rl@mlL2N-dVLRH!{?LIx$RYNBQKsTJ~Vl>mT~NZT&7PN-R^_jgR& zc-0w}fb!nxK{(+qqq($%^X1l&*)>0p{w7V^lqX*c<8#-Es$=s;Sc7f<;?{KtlHd(D zRv3zELLrcKbffgMOL&`uTGHCm9Oc@xa0W5#SMmvCH8NYXPcG03)2BG<*L2%%qEoiN zJbjkU>+1J2-qgj|A@@Ep{G0`#hal5=dil8Ng^12Mx$vD5zfC)cq2ee}wE4;8wh}V7 zZzMea=DGbVHcxg%E8(1>m)#uaM-$=Jc!xTsA75QyaL`Qe=2}lSVAlKluY3&q^(cn6 zDN#{b-}PO@+}9i>50fDG8kB|u4jZ$x;S_1aPfQA+M0utw@%rs3;&p5MYHRLKBtVeD zb+_E>36q?kiSKwv#F$Toh*o{nNj|N@3CZv1$P&4V^^!=*%9ro_ju!RjnFK=($(-qO zyenxR{KVWHhX*FZ3P$hCvZ~>F9X)Ofq!(Iaf&1Iwu6-VTxs74ZL`QdZvXq1mjb?0+ z`yYM{bh8ex%CLeUgFCJ3D&@Qxi$)NOY)~}U!u#NkZo!mvb)tt1s)&B`6vDPG_LgsR zVKBWB&dG~)M1HTNO055`P?$EY1$>#m(k5eV*!;qkC@FLDckLyVhIKVi0-v1gC57XQ zJWT>@`KT4fqAZx>?pth2uSi<>#wF76O)tW;tltu8fN|rO&hG_a2NF5z8uY{jtQo5N z{k}RWyON7QQ(o&v9CC~;A?jQ3f2bruB{=t(U|i_~Ta6-8_^2U26;5PV>yv<5cWPM-+o7 zGoj&Kdo>0MMf@NwYTwNfcGpWo+Hm~LiDlm`anMUlJakT)A04shnL|MrzmgEsb%l|= zwPIg)GUZ2v?y@_3xBBE$cRI3G@O!?aO?MgFheT}|yW6vEu2ZA-V6z0hR}YWnvdn=% zt1+$64{j1-#i}wMZ<@|I;wbIvHm_9XTiOG*28Ri-EJrew{}qxYtvFj%ZzHx`DJ$%V zrDW`k=W0K3{eN13nd=Gl^-)oqpYohz>G7ew0HcEKYlXh%E-wzd1gp10aNOf(g~e)N zV0ktBG}X4|3i;(Zz{(A?-vDXBZ{_^ zhb4B{5Rjem5X3#B5*W4xd@dhqvu?>ff@2)&h@E*@fSg6`ysGS>rj)((!y*wZ9zQH` zewPdla+>QXlkZgM5hc+n2^7CKL4J1sm)I_o`H;gz!i)hznit`$pga1VKXkE8-6=~&Z&8u8h2 zW>?@}9ioW=ruP;_XsYae*CQ+&orL`uwDZ}|G{-*U2ISueX|FP2@M*O3N~%xKYO-+_ zLZJI@<~coj9Ih%GExEZv^UYmqi+CTJP*YFbE*S4G(7gq1R`))Kt~QuO*Wdf3m_@|nnYI2!G^D8zWDna+seR_qOqqj$NrWWApsQbLAqRA3F!2oVM6*2R{n&|K2Yx)eaiNVG1QM^i4#_BTLpZ*R=iq>!CFXnF`REps zwYM9ZDTel_npqAZwqfaQrK7c6w7Cl9>d*y#zBWcjvaT>7Ro`1rmRhOXB$)Mal?~G7 zJU|93IW5l&jh&S&U8jopMZ)I-EC%V<;+f74Z$B4K3n7*FV#d7ES=|qY+)>(<*_n}; zZ*Scm!?!`>WfQTr`rQxt$;#Tb9lpgV9bg~*3t-{bI(17gwB^C2MPbf~JiAGTcA-fx3b7A2E7oTV%b|9u9igx!k@tXz;mr(30GU;<;CvczfbA z+gO0aur)g(2*8NTTuPCkmZCOkl99~_y=#mFuYQ1Rw^a&URZ>;E=N76NxV`U&uJNlFfpB6-7tEqz}aJkmNCZ$$ttZZoGM%q@o> zZ#KW?zjPoA!>KTusMfb$)3aba!JIz)C?(FJJtVa zg?JWRN)>p=Xkvh6j+fEV=0^wYtyR~1^aKA(jI19?UTA&B$m@=Wle5@bW}epAFdb;> z@Y*Tz1g#XwQaGS&e5m0m!Hc-hnC#VA9Xp?Zr>_1W|BXMEyBbgUMr^XbqQ8X17jf|r zTIv#-HTgGW3NwW&>+7z%;VBI(6d19R%>?M$D=SE%nkq#cu+F(CVYnNZCW)LO#r1bj zXuHzekDYl06)<1h{fe}utKlz7bXaY&e{bd|5w)Ro4t|RI?Tk>E^&{An-K%}Q1CGgy zv8cPkH7XL~qyc8Hx=n)yEorjO77|h^re!#BXPEX`vMY(55Jxa${1E?&80+GwzzN5a z8oqn`>~QyQ(LIBI8Cm{^g#MQS(Z3{~fRT?7)mgQ9JUqjNVvyhO3HZWNSHBLk80Rj} z;8Ix?q_$iP=C;_Acb(NjoBdXa9K$tB?=Y&oCMYi_?Wt5*Uv7nq&s%*r?9mjVV1KqN z5YBC~0@=ESqm=2quXXJlwqlmr5p}vd*Z+Hpx!`ZsQ^*5kKBb%lxmTts}t$5Qwpsx{$AByxF?K z**SnfrLdFE$&y>^1a|qe^83{7N>(k=g5-W2WXMLeKJRU&)n$ArD{=<_FLQR`U6r zD-8oxAN2x?mfE$ykUss375m=E@oTcD2u&fhhnFVVckt45t+KHVk|R+WhHs%LPbs_! zLqmB#h8)v4tGl7#JMC&3gFFzvVP($NST_GlTo*|+4(wH+NP#b%0~?dWjR&ORC%#in zPg;wAIJ-*W+E(k7O1JOM1c&X#t0#e7n{PlZ6PF?sk8|^Y-+yr>Zc?))-gY>)?a0Mg zStEQS#lXV~1XgBUtuA%VTUOIDg-0*QwcsAKQf8hVZ^9M!^xluQb{4&oB#d<; zx#L)Jf^~sYb|?i&c#Z2ivT27-VzCphF?GSTe+g?&_U-o4ur;+Xdtue}Q{e_)le1X; zTXMIlX7bJM^)tn2{Ha`p z8FZlzDQdi$BPM(-kDnegh8b>1+;+ND_zEn<#BUxoF~jz!j)UsH#BJqI zr3)aO+t?$8w>jNMyAU420RfoI>(8l?Y=+vRww{0J=PggE>{{Cyq= z@s=5PH^3w7f_7}enLr)Ij?b$>tH=JQ1jksYY;ZWzPKayjV`>5dCaVqCx{soulgV(E zOz;r(v!m76>m!A~T|Wu)M$0^@reIQcMX=Fc8m;Gv5JvasW;d4`?Y6(*3_cyKCpZixIT9iab9}xqid@ z5k#;K9LvzWezi)pcFPK_9F3f;N)CiO;A`fvXUBWsXy(=$QwEY8PYSQ1W*Ou%9h0e8 zDFT^QE%pi3>hGMiP7;WJg*3pWluJJR-0H98FB7H({fUAK-~(N63q0rd;r1`Tt-IzVfTfP@Anh*Cj zP7F?CG??4^InE|Y=L9YY!(is zl&n9q?ynv3s_nzrmU$g)b%VySd9w<4ChuK}!&od=^$2E*dvB49q|s!@HqpGpN9ZRG zRQ0)Pvi5k)s>Kv!mPEg@zRFgA#)`cEsA0M$cPvKr(v=V7w<)L*{v13M`86UQe0&? z<8?3MEQ8r;v9wEwY4s?%lm+<~ftkFvGWfH#R!Q#-#gCNeB1#M4#Sw$4IdRsYx*aqz z3%##@6@+vNYjCgZM=!TknwVqkHhAnFJ;IR=rQH7(4Q|;E*NDMAGsli90nD4bjS>+1 zuF#q>NB5ek*s*}?{yNr!3FI2-SY;`%@Qx9jf;GZvVf`+bOumY>vkLOv`e`~FQ}Q-( zew)wNKdVVihc$woNiB@hO9A}Tdp!WWTmH+Nre4+NBbDL_0b5Tf!yIna-Xc5HhTnC) zI_CyLEMf~h71@aC4yW^cOVhK&XRgW}tDKiBh!bu<7VuR$$M`Mn>jN1d(`)}S^OzJJ zpZl!_g_6Ewdt-UlhXh!L!KMqwpP1qcn~ApQrg<-g=H+^(WAP#n>J( zA(e@Lr&!VfoMKGQ30}Kz$T3=ri4|=foLvS4ico61+0Zk>@B}SuBMbQ-!+XkW{BK~K znW<(c{Pvw;R<*i$=>1)3BxT{@(BxA=^7 z<7@&?pu)=d7s-{i+>F6Ws+~|@q1?6>+BMoq_eoA8LH4%pJhu8z=QV}j;d__=Ezvox zM`UrSgW&+=5h`>fEEgxS~Zd$kD7<~tJGwnw7Fsirv=k5 zrvsRsK0GO}gg+M>HtpTy*jF`a?nhuu;%&+BqnlR9IwQZfy#7ASDCyGb-g<`>vfSQ( z3BJZz+9kaa7*bO0QN{i=;MMM!5m0{7X?l*;Ox(|nyA7*UMlE0%zCNnY4PDEBSfK8) z6yTL`$EC-+5R7sI;{R>&Tj>{J64E*RUhAPBWChR@8pz+{K=zqAlb9k>VH~H9o0lsm z(;3!mDUbFly#C!_1p|o(4XmM>Yu|7niO^H^obR#N#uG-RAa#?wqD`$v0eOUfLvw!= zar^+Nm90k^%ecZhNVL*cMRm^0j8})r4aB%sY%>pjA zR#?e~767vSKR?Xv4!YMQXirjQn-ll`Ee3p?Z+7?_hYxSN?AdGyI2nBP8600;mdUSn zM)L_+E%SX($z#{D1Uc7IATSyx0@sU^PU zx#QKoiM&}p3#%AKA~s8kSUA<)27Z{%>FCl;qt1&Ej~A)0VQi_z;e;|;{Yh6uE<{8c zE$`XaUuvbqp&Oz$`H5wQn=SkbK~r$AQ~P$#<2bVFbM#+MS3=^p+u~C}X&u?DhBv$o4KO%h%l^R4 z9-U}E4?4}4?s_fKtm3)*xi*ZClK{c=@K^9a0)K@Y3hRIk0VIp5zc(!0AP$9d8LU#d z1tct0tW%R`sWK^xgA@D5+GcVw)u>@J8n!Yo&`5N&I8O45v(zy7#Z+Hn_i>Y`{Z2Kd z8&cI50N?PerXoE_`~w|%-7xaGL`Jr3b~%XCsi$ROfT2tJK9~Jdkt<3Oia`Arsi$Uh z6RbPV#LI9Bm@S!+s_ud@-6&;t4|soQ*06XCo|Q|{Jp}ZIKdV#cNzPd;KmVI#1?TZk zyBwPyW(?&MjDy~cHz<}6x%m`nq=ps)gVa-z%c6RPgfe02rAPaLUr6|!5a)HhVzB3- zl9C5+{WCMV&8gdCHByv>`^ab&#DEjxr)_u{TlC%_JyeFs^m`2Xjz8byxbx5Sb7LK! zpS$U5XLv$ds?&)lO_zsxNEtSPkC3f+5q2P>eQr#A8kF>oaF3XF_qXk$`Sb~h9UfuH zHf4&k*%k3}TYz_i^tHUFiUJ-DUO4}h|FHG}1HcAmg}SSfiu-PU)3?lgK5&j!pM35b z0)=*@aVh?q?yCCEc`x=7cL0Gw6+_c7&Z|)E8DN|YESgIWpTy0&s2wZETr{&#lc6(a zVC(M*To~4M`mQi0%dZ5cqH{;~1QYl?%$0b&o22QglrK=m7Nm626?Itop@h@X;V^R& z5&q3CNQvUf@fp8=p+^x@m3p(UJJ_!cyj};<#P33F|KF@g`bIGjyC&=dE7~X-?>;-D zJuO#d;A^Y!pg0~n=BVH5k|2>>G%TfPVQ1fbfrfdbAebYXO)Jutci{!y9hHULN=z6+ zFo8>36q7YBXCfr<=*A6hd+Kpo$Ry(Cf#tr zcHV2uG(_^w@$UV0Kk66-#abe+dNXyH8dTK9Z8nQD#7GREyx`R(_=c*)(%0Vsue_W4 zn7x{SKCy{Z>R}Bqa47`5fJDE4yU~NhHD9k66NE1Q<7jo8tBYN>BN|&Gr1P9KLLAm0 zVU^XO$}>O^Tr(%sGA?8v`shm6pAbt*WVU;l{@NDNZC6StZ!yKAjUm1n_D~;2^X_OP zj$E4R^6_)BHD6T!@Y0UiLAT;4b8l&zddzz=gts~rdTIv!2t=EOkm9+CAcpm*nlj3_ zRgD`j2asb(fu#jc-kDg#12ycoOeZyI2*bnyRkgA%S>gI@*|)cU%n7zh6|XgoC2`e@1S2tZ9q=ULaM z1unqd@-3Y5djL$`56l1ZDh)nx#Iu-@B?OBDd7V1V7nfesceZQm31J+6zvw?Hu9N5g z`1=aVTP=F8$LHV0yvw`nz9851KDlz^WBQ}f+anGz`WLb|6#6~F?C8yg=5;u=?EEG- zJ*OA^7V-v;*fSz|e+pQNpLCh*sc-Bo%Tyc(iU2)#!tIZDGBbaAgw2T{jgiV9Y3ak{ zuCmWnr(@ZtKBNL%LXf$y{4>1Wzd`q%@SVuvaRHg77be6J@OG31(T7>p#YGaN=rr`b zw7#SwO7gvu&&Nk(c??|Y4UaqvFV6d74s7Zh91i7l)uAls)fBwizHpcI)wR>h-9kS{ z{jbdDbN?Hzy{+#9yK{V#?Q68Rk1YF;p4+FAak2Ejf!@9O?sls(UOrbrAkcgNjUo$0 z%%=q2!6;*nfy*o#sOD6g{oR~>7U#7jfp!Hz!Ddj*$HF(v^hh#zK|X=8z|3N%s)Ux5 zm1K^yMoNxoJ8?t1B>6AOY2OH0HIr4E7f=;%I3OZmr#o%NAL7>l>X7(z61U-;y8IkmK9^}HOpBVZSlqR z)d_NkKhJ@7dshe!hv~}TiF{=3-lk_Tg!`+Pyr*0H)tuIS?L>VNA-JuJs;q){b$U09 zb{_Ku^71|Q1Z^0%i>IaMJ#&pkb70k)gZSGr%cs>7za*Bdv zK60A%dcIf}2T2kcC>*e6afH*t1fgiteifhRB`AK26@p{0v_PLZ2lGn)Q;=g->Mt^q zvYPkdM~azU@0K`wG01Hyr?$R&4D(cdl;8bkRu{~2n@sa2ZmYcIJ<1pWd)91sT%#sq0wL9rFjeatUiX2=KB=1g&n7ia zBE2~KN;u{+vpt>74i^6(u zt%-a4g*H%-EA->sh&MQPl-wMY=S-2JQ9U!d95!c+a|{(VHQtPB-3zN&4n%eM3vW%b zA{7sV=EQ76Rq^r>w=5VpBwW_fvZvz zmq^j6cYMR`CofA1Y9@=l<=koDhQlJu`5XM9VrI2-5KG!*<%qdKRlG(q6U$^|b+Ji} zME>>tDdL%}Hg|hc>dmf(2)Tgg9OeJeSF=oat1@!=_)We?LzMd`UKn8|kJ*95ZJV#0 z#=MzU)$47BrP7E>OC5upmrMk)Y%GVGX8n-uZi_S$(2+%%*LMuqC|rESYX$^{p@n{+ zQ{)Hv$1kp5h7xLYluW!6O}-V!IDnMmftQ%6o&@$rR1bT#Gzr?9v2AkBKm2%2U=BR- z4g7^&(2!z9H_Ehd%UIS8RQ9pFTA+Hv!X(Qw29As6$?h-q#X+GbKRvbUej%;M#pzO= z<(l%p%zBlIvF-R$E}~i~)k{5eFEM4Oz4PuHKEq*-)8jQSTe6i><+AJ3{izCXS|TrV zR!!x4jH=4>psluw)-nuM?my$WwA^NXJUUq>)q(xzXej9?O-HHQyv62yzo=}VeWQJ) zgHx!naX!;h>Pmr5eH3VFm{0DiizzM z^+SrQgU-{-8BC0RQrnUL% z>Z-x!rwb@h_d@UTU#h;fXL*~P?Jo6C%lyC<)~zn9pXPlxRBV^EAdt^B9S`aOy(kRI#+j0L@(m1!Yvnj2NU37RP%$gPK=i$-(`$v5tLOyA zXt}Mlr5KXgUd}K7diLGQD9G*>FPR{m_9Vdwy}N;RAKX{po`o7P5~t>`sCMeJCdov| z&C>ph2^`>@P%&p!@HG-%(r?>`1qs~Qydu8+p&=r~?h6G)0tR>M#1;1!-j}HG)R1>Q z53y@P5YQ}-J#Ts^ID+ncxZ5qerd^iX^BTS7655fSEAX%Dout+5w3@p4e~;4ZM|oQu@%YfdWZ^>iNBO}omZ+gf zd+ojGlOOE|I$86dlb-0K-zmwBlhRMT!&dm(vg#5f8b>?SDOTtg_?6BwI;Q+JhWGB_ zZL*xMOjN}a9#qn6Cg+`I)N^#3Q8e}>8+E+PKg)lsy6rik&?gE)Vys>{9~I!jnXi2L zl;PXiK%eNmh)t{Lc8pr)m5FB%Oz3%&txr=(NZY|jn~z7!#FENt^1P3d{|4$1@ElG5Wd|N?m1-o5Z{^}4&Y^{4IR60-KbVUx;Os>Pu+!w$Tg3t2)wz;aB z<&{-huFSZoG-fb)TlZg61Zja9@>}Hot`x~6h>cYH_>0Jb=&u;JQbtuYlFm#u`gwu{ zg9f-2{GWm;H{UkL9pfXP`+p{Jze4}0ASckYPTdVtTNN4JSat8Gg!mXmQ?exzoBGUO zM0+e|RMc!nmN>ayP&s=?8xv<<9GrERkC3q)q-<{Dy__%4=y&vznA52dzP~G38^#+3 zv13Qe-coe9>Yg<-r=y@t#l(bY0uuW`yp=(tGX5v|&n?dV>Vm`=X&YpJ6S?m`bj5*& zJKy^}9Ge%CRqB$l<@-UC-4_9UR^_5@T3t z!gnqDcP9Pth)8d=CV%sf{CB$8{1${azo+++)b#YV2Mx#*P9ju6z(QtH4YJ79BXG3@ z21D=ci7QV-X2tn_#4QF(bvF100mF$wjdNpsbx&(fV^3HZliMziiLljag6 zKbv+g{ADD&r%2#aV?*&ax(f9P4L@v!=&n!L0UW$}xHD}Sm{JA|B?WBENva9voj=21 zn5lPd#K$IZ_hRf=;QwIQIA>%zq<6RK77*@yA5K??NBRRlJ%Tr- zD~Tp-%+uKDH|oMu5nk<_9IgAh!G6An$pfH87lLHoj46;=;?oADYYH7%1l3R*wNEua z-v3l)#6mg=GRw`5oAqE@>xPsMw9f8lAZccE89Nn(eIzm&i7sJilrS|AL|E-YXi_)b z?eWy^Cse!Fw(@awp2&wA7i%3wRf%ASp4j0*ZLR3yYw*;4RNctfXpcT!IBcBKXZvfW zr&|wAP&Crmt)!2MTPjzZX>5B|+@y9^lby3u$EbZDgDcw5HvT@s8&=RB@Ahrw?pz$k z)b#NX>j^4GcZK`y5^DgCP|BzGws*m8uqYv_`UWG6)-Qh!q@H(ot&yN**X41*XVqt{6v}3NzFnUbeXDzvseSFV&-OF`Ml-MTc7XF7R}g0H2=nXz z=>rj-X!aT3x~n%&5~6g(-W6$Kk~2>bty^sL>@4!v^M8{-O>ash4Jg zagqjKO)Hyhyv!~{qm%H@U%D3wHp|*+wI`c>^-S)zp^tY90z`^SW2kwLA2>ic%eNLr zz=b#49H~h3+w;;JL4f}jUjLi6I}zd6!$s?$hA+7XN(YwA7jQsqH&N~1SO5cfpJ!=u zTg!!?us8A#|$_oQRJAhST{KC(o3EyTeTN{VXbwUG%Lr9 z8(L-6q(xa4i@&8is7OO`%(1m#s#JQ_bk@tCe$rBTk73)!cvfqIMYP5Hpcz(up(A5r zamXDtSt%e-AQF;eS}k9=LD4h&r#~Q00|vL(=(--faSr8qg^TNZc`%H2Jc#^`R)N6v z4ywjabv+JaT^1PDoloNCwq0tsIF?-DZ@#{TC=yGGXLfsR&Qg<4XY71C0DVnknZmW= z^$V`V+~gu^oxv*|&xCrpXPQ}CoRoN*#S=c4ThoF7WMwODNE9s`E%n>SUuK`~=4wb9 z)6*K3I;WC57u@cubACCGR>6; z(w=6FWn8dfkC?1^fToFd2TUuFOwfKM$1NI>dCk>_4}259iiP3aj>Y*n89t$MB`*N3 z%P=u#mF%*KG?zamt&k}TYX!2`c=~`ShcHyy*TYDwr_bK>_9n0ZTgFuZMCw!YT*Ma> zQni{_$33in&R?;TcpY&z7JI#6Mh$ja9XU!|w0QBOrt=o1GqDX3?JmOD7<}e>lyk*Y zh9&BeDgP8s`x;u`zzuq@*(*q3*+z(AB#CN<)>iPnd zBHHja1IQI!+VBx=@?fm;D zU3LlmYXfn=+23%>A~Y|`f^2rGtd#7fk`AXZrS|+V$_XCTm#<{I1JU|2P>C8F$P~5O zI?&~nJ7_M;Y54S0470O8RZ*z7m>1n#bPr~F6m0E%arV6TDV|WIMI>?rf{7Uv1%A=D zX`Z3^rxZ7&4;X|Pf@VR1&IP+lU%^e}RN6;7b=LZtHDoFPG^B|sI(WKmd)w4j(xCo@ z5pF#4jCclBQr=&QHk0~E^z?kcJb(NmPS|dW75t4r8^!0VElW$C4WGA4HrqtB;M&A) zBbKnGosM%O%8VRVM_O!BNOZQ1K8luJ(yE0;jy+eg)Aiq;qw)_+5F$0fR@Ht%kwfA?#IJAQVr zdEe4s2-kZ7wTNmk=6 z@RTd-f#<}lFdzMG-}+=XO&JY%*4gTkDDqp`DhWw(%cxZ3ER}H@ejHn+Gbsq#vt>DH zqO=9wEUXni`aAirmGkE^?VXda*n+)XTQ5nz8WvqpF&9@E&oA8aLN)TDpy3ph%IWA4 z9hyS327tb#b?C-@Aehxoa0#CYWXo^8G@QmE$?j98)x?2h{ef7w?B(_-7r#q!%-|&~ zU`otc0oI(vCV*Z7c54&|l~^nL%w&|e8cfy_XNv;?3HUfF3?hH?sI|J8D998R3KQ5y z1yxmCW>nhqT@!c@7k_9?v5_R z$J1+DM_TlN##8pa!+BMwk{a$9xaW%5|#8fsj$~igT;h5$Y!*LD~7?so?=PeFbQ@ilP zEDtpo{XnE}0)mGB>OHH;zV2q@#&D#!>b!!cdAlq>dXgK#>*x04>&Lr1Y?od#b8Z zz+doy%aKD%`)lR1P(!I>q<^G!G|H3c?5UP!edG`YOWB97rzf8oEXvsS$`ngQ+&d}MY zQeiqvADyUZZx3s;NbDw8%5pmn5Y3#;b66WhF43LOgnO~1cfY-87YSwkb0zsakmzUL zV{QZ8ANIv*Mz&YX@?MW`4@+g+MW5xPs1D%#vm}-{=Uj|5E~=w z*$LgdXwWp!7_i?VR{l*Qfqvz~3RMjLxNrWxgu8I0I_|s}E*7K-jU8OeaxARppNVy){B!TXqn3y2zWKpf$ zx|+iej#&|r*Ux$1ulnbp7%#$o19>0E6{^|$>c2`c*X2(CNf$iUX2n*uG)A!2o#JM8 zpf>`?OlTmauoVa+x6rT8U`zycK*jhWhAw<%y5ab=Ewx>2k|{YqCLa}2a(pqAx5Q2( zfB8M(QteJ>r_5s5@%g&n0Gv~1YzQ9MTSqWTF0kYk-hR!$ z#o(>b)D+xUh)ZHt;~AB$L+jh%eXV7S+O3_Cy7z~%A4QdCA{7TBZ=L#24=2{%PAUI{ zm$hJ_K;qs3Aq+P*G#+uz{?cEo7lCmrdYuaRld$91w{kf-`YwMJp#dG&3TDI+7f(WN z(4n>9i0jJK9A??AwuvpLp7)g51o;(voUSb;xuk__b$8Jn2o3{ltzd1GU0f3>MGOph zHqBxEeNF%#y&X)m{xx}{^nK1g4Cl}7ExY~jQa;^A89(T6GOPHb_LRHY{&~BI{1-Lr zGfveQF<$US?Q;K55h}*oe}QgpF>MdB_dh@vYU$w5ZKY&R#4yQGCXz3M|8 z`sR7mOHJ%8lusT1rv(f%r3;vqT&BooBo)fHTk6iUY@#v@31nVBOjn<;N-#F;9m^s} zGU;^)!ImF<59hvvhC2)ci!7x)=bc`=sJCm{acO?X)yJM`r``e@RCzBt^LwN%Uo4iK z9r}_3VVP!DQst3uUTP|X7*T{%m~3ec$rS$PNyiO{fRYZ)!^ml*iL&bB=KKMG+6S{I zpE9|z-iP--Pvy99x9CW2-5t60-?bEqD>lh{kM$?a%0pXbS}JN{=e;8hsO-YyE~FRT zUxs{`ZW67a=l>J;gw$0Epg?J-#W=XQ-%7pF%NRRJPFWp=Xo8OzTxs#Z+16e&q=;Q5_j|sly$wV=;uL< zg_L*I)-?%||@(GOvE?l#a=@>kY+W!TOyE2!O9ygMY5>yWW;2L(%5hn3bUe~_HD zL1(>RFMV7QtQtAlmlm{R-{9@erDN-fZNUlYL+r6DOqu=EViR!fKPT+zcb07?Vnnk>1ZSAYsgJnRJ03>t}o_-v6bA^(v$TlGm_J$3QjD2T!wpWOe zi(UiaxyzCv{2vp~KkEWQpA;Z0_H0X&+a)-qiGbQ#Jf* zv+yz`q%!)rqMlj~^}%{&JrT+nm;^DDh7@nxO%>MlST?{oWwz`S{e+z<4kYmRU~efm zhG1ulN852$BvQF}%@2Jz&4D)R<2T`s91c0TPWL~+y=E2bNE_7#a!XI0qOaMyzr$GQ=vic^NdkEX@CPv z^hlayNxl^fU=b4aG{hXkL)~Z#@7*8>S$E)V_3u0ibh_XylXr%EkM$c(@`6+8gAJ^> zGh~w`st42deL>hUdAyrS%I*+H?B_c~(Lv~B2{__v*1y0}9bLPjexxXNA2T=*=&Eh2 z+<~Z(eo{igE8(|q|CnX{?qLCud*_&NVXdblmk=N>CR^*M+ z>?(O-hK;VvIndKro%!#k3<+)-`r@uoGhBduk5QyiKF2xb^RwaGHDyq&lb`GhvUCPf zhpMin7tx1qVNV{ret+>W5WMTu2tA(HHq*$7ehg8YHl?;yj`q|~PmXUml(qV9Hdw0? z5A5>5&NWj=lMus)Z6DBmtZNmdUI1>SnG&X2{H0G53NDL@^7=OczD%$3otui;+Zn`h z28Dsi^#y{IDP9!^l=2`hAbBQS$QONsc z0(}pxF8f@OLB$`aq#HPZ_TH^hl@OyhVtMp=IAS? z#-s4Z?qcW@gU5^PFu!{RJ^t}j;QEO*Oa=d+Sq%&v2TC5y2T~pppV4pN_l|05($p9` zL@#mM{du1`mT|1TlS&O`1?y?TN1h@=K94Tm5wTo(VC| ze{<{OTj%U_M75@O3+V>sm(+Ga)VMt2T`&46RjgJF(yVdUj7Q9 z<=U#lJAOu>hV_VcvS@`#s$Dv_>!Tk3J60s!@!Nmr>UKl;IECCqpHMN95OSuZy8!c! zWe1TW%OPiC*6IZ5_#}Nm2Kf=CAePzVB~wjTOo|m&p`%vNV)njn+L{KW^%b! z`Do&~EE`HErq4xZH$Uh}hwg6-|U zyH)4=Qw7h0S!_wF1Xsd=rbvuxYD8d#tQx2N+@EXKWHKR7xA9);APGfJ&=Os<)^hv9 zy9S+Hx1#<%iL5Uy%C+Guu30(D;ZGWa^)OS;2ny@{cOjX$7%3%Po)Ugu6<6cpdiv?m zUNXJK7ls~{G-VhJkZGw*c=!226T7>hYZ>rqisfAMIV^^PNr?^JkU+xGP2kag4jN=w z|Guz<%@N%l|2abmiWv6$OE-sQLE1*)#z*Bh?*Rl=A|K3-Yf?umfy3>tE8hz>YFAJ+ z(so{kE-0L>jkLYZ9^V8ZfgoPna7Qc2Sj3T0t5?kdh85DFvjvJEXgt1=1zmAqYr!HwZ{~cXutiYfpT?_xpZ(U;CWv z{Cl4N9#|}9jJU@=#vJo}o<4t)_v4xS3h;UtC(klO&DZ?@3=O_|hML)6_l*eMoU&(l zj;ENbSuHh5l(U?d_`dQ-tjgEn}d|}I~Ecv_m%i5$&aCh23OiC;Tto3Xe+n4ojZS! z%)IYqLxl}C8+S^6m&V+#34*FFbDCTha8$TT1}#>|_IZ#kClcyyOBG1PT`pdbSThqe zNl(nHXQyj>a3@%ZtEaL%4K(M$o+ z5I}MCeol?3N3}jCy=`|pNo>xdXlJ>EqY7UMeQ zxBCpr=#$=!;M7|(XtL`=PYO;Wb~8T~dVjiG0rC&19*X*HV-HceMp<~2gpG~&jk(Wz zE>K^pDl+F|?V72CFk^I5?TRndSd7;aNi21gqUcI0W{?hKDPYpC8(yG01E%w&anv&E zV>)}|xVFpc3orc1y%_EG7^hNd1w5s9cJ5_Oo|NFa2%Q9dO5^d#PI zAD5a2g328vC5q6iXYPUq4!I#zm43apXA$y}&Mu}KLD?a2=K4?J^>v^d^voXdy94Xh z+%b|r8jsr>Jb1^k^^PF?pyj2ZJFk%Y8og{b=vLfK|3>@N<*Ry)l+k^S5_QEk8wVf4 zB09Jad@dyW?=|b!e;_OLi=e?VxXkY7$Jep?N59_qh3}LPY>smkGA z>|)ukIHynUB9%L%TRwXteGu@aHEhB8YABSq`u1#eOk{Ph+5UeziUG*{Z3iFpOWPkI zoNaV(&$evu5QtDM0aXpyuFr4Y59}M@uV6KXhz%+{A7qPu_j1?|^i7qa4q(o;NY=v# zSL&TFis`9jO6-^YDYdt;D=#|V8;rOvm19A{Q|O=;T+^pAYlO#<&4_M3;XmeV#qt*F=AgfP$*tuvH5# zW546O9d{laa9Yir9&9+w+DA=oe3+R=ibH{id76U?>P;e-%hxFGSAou3y8=HiCZ=7( zrK1;2(7T28`6+;t$3b)7CC|P|j`>uWP3X~JPk$8l^21DP&U_B#FRY70WG8V+gCeV`IF1%f!{l>k> z4mMDc*$X@gf^3gPs8aD_I&o~3(%sQu6-x6EVF90vdf8_Ffs#g@&1yntwsQ6V^}^_$ zA=2b^m*=li024S{mqQKGmk<~g-6vLBl?Y{^W0%urTd~o*cg(JXp1K#v+lEQ(MOzab zQaok47cImvF6H&afDroxBcF7cKf23%z4x#;52uYWN%Z^>+gvV_R)G=CaH_9>k2xmjU0!6`xWu11Xc<&N%c zZh_BpatbvYR#!_{_ESD``~PwQsG*x|2j71Go-3m3kouWtU0}IAQ#w9sSX_68KkjiE zji=D7rlvddG$3`*vwZ_I={=HswCap(_f&kX4#{*wNfwfL$NFO?qe!BU2o%`x|vTd7=KTb zFGOVF=8WBI_eX-t3W9Eqd+@`xg}hppi5Jkm5CyU6JLvU4;mhdYC2jBd${OReK)(5^-`%#{QF2xOMnI}}KVL26P9EaNul`&s&Kc@xR@ zCtuCGbiy8|u<*=8Q7exQk41y~?HyC`YdvB~r`Q%Yk5BzFE9FGuJb#q zYX-BMT^f4jXw0=jES;(wT047*U}1_6EtoS0wYZTmyL$JFH+olh&bP%@!tUiWY_6Ed zkSCwbR9SadaP*!NaCwp@+5P9=mH%|%RoEY|)aXoUYk;H&y+PQo0Q=%ko-8F%a6Gep z;OFXoDmkA@zFV|G7)M}Y7iDN0nN#3KYvbEkd(w8m(4885Gu1YybxnF@{%Z%tHQC;n zEBQWVrq&%*-V`Gz!kUyyY}2gnm2X$cQn8fB?Jg+^+`;tiw!JB2nf*`Ew}*XZ1a1jv zd|VQV#Qr+;sQpJ~guI~d`VZY$1vNQ-jhd>-!qM(a$%K*iT65J+rK%43D5SRk!yvPohVt_MeUXmh#^x`sMTWw>Yor4V0(>6OVTJ26L3ePvj$XH_2-tJ zE1_|6&J3!Tcwx=VPTN^5df~}`&RRbui|Ur#y9vfC;z%~0a`===H_Jv_Ywa+26+CG< zC%J(8*fcm{dELXALvLQX=1%xwS2ts(6EEM zs9F0=`k4?36`q*cl^!SK-_q=gn1#fQj1S8vF_JRAtB3T($}S?;_M5Lcf~-QUswrsZ zbGa8xyzJhQ*l=R!#z6R_9`tPz%-9w%IdKgba0UUiSXD&rQ!mg?11R@LQjf72IhJx=hRY^WobPg(kU zSzoZ-wL@#S2DESM9b5>xQ z)$KM}?~AlWv{1>C4)b{EyVlh=7?CfkvzJpft<1}hB_c01$ZONHpF4-OTDu@MlyEQg zc&N{Q@TfH_6l7k=5uH$&+9H1ubh&|be2CoJAkF@`mf(5GR%<^d|0LpRjgd~C4|jdR zFp*lGT@SruUs4w{^O}{eK*%^m0_&C|8AUB z&qPShE(!DbA_K8Ai)@GV_(R*P&K>$~4R%xyS9&I{XgtxlXXr8Z4TD!aEu~F%Niuv| z^J{oVnmvQk?5GbULyTeHsOxL}zEoE6x|NNvc@yEYOoo{90d>slf*W)7~Zjj_{Dgacd;^&bYHuNY3k1592cz6d`PkK1KLzR-2?`Ebsj%cUF6yKh{L4814C2$DwJI4Q0@Ks% zasD0LA_slNI(NQ78TK`uxtOD84a#*$5a%DJ@f}L+vC|vTi7K-1`tHa^iIS3KK4@CZ znW2eh-^!ktl@hM=@MlpIH0$$WI+@c?G8E5!jL7bOb0R7sPqV{}rV~Ew<}ip9(j<^( zf45K|Y#!^vl&N1Bsy=!!CKwC%(+c^Y(Y*a?Q=juLbew&KJp>ltAiqmX(jO|cy3DMm)DJRACI$+wkFoB9Oz6>}VvYe&x zuNTsA8O~~F7)@tU78Qx63f|eTty)ZIR-X9Q%J|Q^d{Z`F7ftX*VIgVrs%&XO9tlWO zG*WD`o)c=8vf4ZUt{+~qHBBXNZICZrhZXD+`XtmH#ltbRK3c25I|FEHM#`bcmXHO! z`s^SJO%Gc{9?c3QR3t@K{oQSwE_~}N!ktP;qz>KdSyo+fBn6%KUsW<1$NqfB`^<(W zc~=BWu-Y|Ri{+t&Bah8gnvFYV*2Q%r@k(nlHhO?et2aNXN~p zg2bgREMSn2o*?R5Z+(rQ^Hr8Lk%HhLK#tgp;L zspyrJdlD;6fIcoI0n9%e6cAha?xOe_AFl$dJbTB7~G?$h7wo`0d)qa5CX_Zpxp7nCVSM`og4Lu9YOU!~HfI z>d*hNLO*Ie9@VKox6SL|$5Ns}X0sN_pjD39ls|IQsruGv4lui(2jvP@7zCz#-XyTL z!yJVlxdgO06*p*F-w+gb$@sWZ(yRjfQ}iwP!3rgq%3POQFH*@d+VRgMTg1}^WFJv{ z(aKeXMbl36PV!QR_dyHp^aT|Y*1HlF%OBP?Hu0mic`U952kt+H1RgHJ5deF;3b|KaO8ApNM zd2OxV^)5cVvBc^H%UU2bN7I@0c^g+1qM^nzyF3oYXDc|Bgxp$^?4sBCeKPTCgm~PX zKA(IVYqCOK=nj`FP}&M&hPJ^nzp|BuRlkihRUW%+FOg1QGm+gM+ML9Bky7N)VE=Wh z{`s6nQtZ3_9yZCtX;HcSR9EJ^WGeU0$Fbz9kb zdQnVZV^d++O;K5~if5Aib};F;scWsDN`hdW$FH&+>Q#hQCZiU#0UMOjTTHuyeukDK zF`YNo1xbY~)B0Jd3jFvQ>=Qolxy=E_zxlzIYz;7)Q2s!ovW**|jqDMxb*5^n&E;NX ze(4(^4Hyl+6pdx5zUkQFaW)-e%G)U02i5Uyy7H1Vzr}8p8EviDv#tau*$&yiHU=T=n2usrNO?kJH1Wllx}- z6doO;$)=1)Cq+6s;v5C#cEiKP?T#F*P^JwQrL9n%;qD8tcg21<-SGT9{0QSZK%;v%}IA|H|V)tU#l8;jZQpRERz_jU@N4pket)KU|ALs=4ZXTWC@GB0(KI)GS@$Qv z1Lc|n3rUj{buf&%v5uD;G_HqktoO~a=0+?Sv5v!pZ*dN#)+0z?u0S&W20+%G;BSO%HzF>rEYG=2$Y zASwCg3P`17zaXW!PBr?%_EtGUPkdiqKo&y5&0<|R*+ zqtvncy6#V}r!_FI^I!Qjd+2HA!pZ5mSt};gR|w<=A)|~{p2X;1ix87|yU|cp>OIrd zXtwl8f+n*Dw)&`{-|y7fXWWaMQ6g6?6N5tf3a)zSCBj*C7*>p$5m`-m{3WdB!g{5v zrk)e!ct9p1Mr~m*RMuZq&R176SABrA@1d19LdA*h?eL$9K-u?E-L0+Zl9exz(AZcQ zMQ(?MCDw$h2X_GlU3cpii<~U*(n7{U+J-+$ z?uP2eOxT#tM|{9b9Ym}rph3duC+Mt5${RN7>y!7qo~G!N&q~wL3FLLTS;(M+2---+ zmm7^|MsXso^)@=KXYtI>qFpuKl)=eW?N3#koh{B`yhf}ol(@~tWdEpVXiQrFgBAm= zK}{17TxedNN?$#yKYwoVv^HS$dQGYVO*07DLA`36Jv=Js=4#xci!>Wqof6#ZdfXR-Aq4oqn7bEZu24~ z-2};nT4u?L(FuZD9P{UOC4`S-4@*!ob9Uxd5peW9z$w*#oem_vec~!&e-j1#KM@E ziIc`yV=_s&X8K<&ySt{$n+5^@70}qN{fNioj7I(~eYI&KeOUf% zHhmy(BCjP~rtHz_m8ojFJQbNZo2R6e)r)nFYbwo(rID*BdiADmjav#5zPs^NwK*bD z&Wa;CsnQAvOVj@>WNUqYbYzlVuFOGpUSnO=?v!nOwba;|Z-ibH-2c#r3$!IZC&otL z%7{}#i?gdAa}!@2Cp?B+ZEdBB^yn)q(>P5Q=7>`_p}rBHn`k7Ca4-q8f8e7cI-N)# z@1&5>s7oFl-X@aD>PGgG9j=59Mc=D4p64Xw6(3%1NH5Y{?nBOx5=b21JG1(~xWHZ} zBBZvB14=^m*4tf<*sb+zqWgMlCVpf6UF+#2;$^Pj`o9!a#dG|FjP{_{>RgWLF`3Yn zC(Rf4XZf4eam9<~%8dRFcE;W1*p_1^m3(R1z|E;>0yh|3-nNwnc^;ZZeOEpBC8Q*d&D-*g!F4y59nodR2~7rwt20VQ z;k-K@)P~L=CWB~Wv7BqD;A#ZzfXiFiba_$nzMMDLhsZH&ImDF?Lzm83&g1cxX_ipx zvfUNpmDQ+lq0JnVK06GeO+<)Gpr?^9S3OC!V+bEf?HNAghsp^NOR=)LS{_-7@}Q z#`#k!Za*n@j=g^Kwtbh)V$txw=5IEyI-cX;=5~-8tyfD=A>{fGpOv=Pohg$b?(lU{ zYs{*DIXT)*l%=L5OiDs*B8C%FTQ;GOE?XpGM?Mm%p(*Yu+O3p@Jo`HHduf^2ei6s< zg5)m>`uJB`xBlDIez?@PtE&OUmhxmfT|bUZ-{X{RGSRAPP$|J`@cxGFxY)u|=>FI< zmq}_cQ3tbj63arXJiK{`NX~jsF+pkbS`jNp*w->k{=Ir|ZV$^j?;SP_BT_?|T6!5P zt*nGWoOkyc;^wnF*|XNgxpAi^LzVbn^1d#XiynHah4)2@y)1Us>T0H|ORZ%k72LtB z{e#`>m8D39^Z^dp~RMu4~PX4Sv<(hw}V~sPb6!++z zpZZABP0}6o=m5_5K4ZK ze6}d$Pu%3^3(1^$t&+9XZ4^0~*#bQcW7g;9M%rDgNW^H8$hX%&9Z_>RwjQe_67-?k{_>K;@zYNW1>=aw7hz43T>Lyea|5X2~nB}Pw zm?)k$a_N?JM!DCo5&L;$6C*TAPeopBu_tL(LEQG=cGDqZvngNU34#`)%yLpFz&US-yEl;7IJ#pznsC7 zaMB!fFUCJGdKUj3jsbc4g#7=PH`&`6C}P^5QSz+aXSU z_T-zk{zhwQM&o?N5r0;{hGMQ{62+jV1TJR)Ad^0+e)8$%4byT`HED#o7fCSV|I0aLl*JB5H8o>4e`knzSxB9ycc28NeQ{gjtW^> zEyTqC*PIu3o^+SFd09IoV$Y%Kw0%T!rU`HH(8Ad_EROdDyk_9_yex^+4fLJ2Jb$DF z_x}I{;W8D8vg6#`z>%35Drm6Ykz3E9-zXf9UK26Zi09Ozm4LvXU?oeH>z+Q})v(k# zvABZXO4~a2gruZK=Nm>EuH<7n2qwL%)%I;SHr-;w%@V>WyL+I$g}*7!vA@rFbDt4{C@|Mk3tLHz&v=G`|;mRg+ZK{6 z+$ld$zKz&LD9MRkPQ{VO5-xl1qY$XHrR40Zz4VA#aWRp*%H1%O2$)LG#Qw1O+xnJ~ zd3*DFdGW^!zooh)ugiI<;in%E^h<=Rdq%HZa5T^JfsV@drY8K5Be+i&CNcylh=Y?v*w$8B|68QJEn?HX60N3!?H)?Dx^B2(m4= z&JB%_+VlIi>}%wNsdBM;heAB&&o*`y*<js7aV{T|f?i)gC+6vNW@^7%*FUckSkDG@WC(=`Ws~c6r!+a^J{lZCAVs7dugfYjT&PbA23bBUwmtrXn>J=FRl;~ zYi3?{wmV+xHjVz_ljGI(kByLBkla2F9F42-UMAv&aJXp?N~k$7wrovjnm4BAiIAe> zd6t%!r{Qoz?nOUozvMpM>A%5}*a5FQy-HH#$S6A#Mx*J(kghQ}x1&o|=f0m5gId-U zLYG%?YdzavbWIgiutJEj{31^``V`{y_!A_eCtG_EYK8YYDQr;@#H zk;mNj=b8Re-mz|F%_NswWvwb2mV4jT+XM2JAI? znoeR1u5<@{ESjqft`lZX)&{PiZ4`eexo&5j$ZQ|2TKHbAZff~~Rj<5?kKq?FEX)fG zMKVp|?qkF|s((V*fA-EyB&c1*F;9_((`v2ayfCRRp8Y$IS{6NKZLNn6pI6RO5|DSh zM|pR8_g*1wVfKP3K{3BMf7}LTo_2C#hP{SC*bCT>k)@8VK-=6O^kLLrHaF>0q6}#X zS$;|FGA^`m zj@P3;3gIKwR|A7~T#Z3)oqA{fYgh{A_n1*H+1h<4k_B z!L7Bm?RjYY#_DdT&r_KQRA9eWdP)cczV!SNDPs&}9{~7t;B82Ju6SWOg&g3`i zM@nuqWA)M)Y2U0ohEC>mx_1(q>dW*tL!OF;rq8&GjFj{PCA;2Md_pglxF&oldoS*qe2)puuDwtCcb_9?K~>vxW)5JWOf0wHzvAwUCDAz z-^&xoaL?&Yr|b2t0-lU|z1#U8T#vUnvS=+&ynKOy7B2f&g@Iuq8wQ3&E%GZu0cY`o z`_{CFnWtN=?Ydps4kqMYoZ%(r_9r_T;v(eU&+><`wglO@0LQh#pPn9{L(W%F=21{k z>g_j|YnCAovmSk&lMnl@Y!)tWJzpRq7Ri;12B!NCXf|}va(HaRqfodwxiq@nLED5J zukGW**Q~5}ri!K`Kr4cGB=PrU@n4WO>Er1yGC>_jwapD_0W$2X8k?u@nQen|I9dcCru?)@hNq--G{4u%MgMY6_)#Q(B7J%_w~d8O4q$z6lM|^F zq}^oHk!;r+Tnv9;7waAl-|wrks4`hbnxbxcU7g&@mmiNOd2y~5*mb9KK4=zYR&zV0 z7lxe0+6MzNRL5L@xJhCZznX}l#mcU0;3e`crDnz3$tataPejKRwY|o_PYzg+)L8El zV$t?y&(67@YT(5@n#vllr?N1AmB#sT2Y`S5*H=kJGBMamv}!rl{1MHPz?rF^r4I=D zV5noE2eAdR2Jv$h~*8mtDJ)5gN zUutk?SUc=bQ0xbBbs+d?kmd2|?I>Ei)q!Fr)BbD*f4QJN=yEY->okx)-Z9zV^{JdZ z>;*D%VznMEvrDyjc*`eUYnU6mT=ldMnVt{lZ!p~%qG>dpj*=1RwVLr6zETBW9o>)A zSp${=loUDcLNl!8uyJAy->M~hDF6FC5H>6je zZQA{LxEj9AU#d_JAAP4(w1`JQKypj^{0?9IcL&OWQl4G} zFuwNNL&wdKPF9PBZl6N?^6NJE^V`GWAAa00-d+jchVfV|uZ?{97;Rb!JWquJ@i_}D zBU&Hb!qHxP62N#&I@EG`TdfJJ^I%`Egtb<=%WhYhRPc9Yl26UP73CmAwXU zxLoQK&qRZj%v>%nL3(;vUzxm$wD&ZF=|SzoS?hXIt!?cPr&iPTfrlf}*Ve^zh>$WI5xrNQ|^#^`1(x)pI_@T7k>8)7f($@%tWJ69?!bk^|=0~hIU_dtx-ujha+ zNF+G|%44hNj1y?doXt}Qi9ia)B(n*i@3jsmgc$LkA@vrE_J>QQ%uES(r$aonNfQ@G zXR9QizsW2cSysEZC+kwsn@u&8e`=Z=2tBWc@_gW;>MM*BU@Bn9;Ik8nMc$b_n+b8< zy}$Ke*sQ;>(DlDymZ7Sh>N?E%-G1bxV_}dL-dl72oXv5^1O+W2F>x$UGXf#7JPKlD zgyZmewp^WcO zPE^dz%;dSm9E&sjlFd!%`M^#Z?UJ)FKT~|Et2#g+S)TR%r&}K^E^;y;KE~J{@e**? z{(BD85Ag;%7UaLRjYb=m3w)`KPl9L0GABY)Vp88#XlvKW2nb!BT{qY%=J>?;sTsZ1nfwV$jY^%_ zhV2aFwO9lu-~<0rLuyi$6rhHPBCK*{Wo6+zGB?}7lNC@LLUaRy5mjB&r2TwbpJU9d6V6qSOU zA+bvsAKw&l=}UR1zmJcMW1SvUK(takT;}uBPw$9KA8!3;K73HCl;v0z@~u6K6!yb3 zkPo2iMvuvTf7Y#jb9$BUEhKR;eTE%`MZRRN3Il={^bbW}IAI)#xYEw&j57wc>Lk74 zU)PKoYSg&|BOr(9or*3nITSSk!uf(ywa#`t;K#qXmzy{%uP z^!-UJMYFk+Rzki8+@t$t24z&{kv*KKNBcEV;z1p853JU~DRMi$Dhm6&Fu zvs9Zvg@epm%11|Ec+6Z!?N)69*xqhvvFO|t(meNY}jB9k=xH{zXkB$=3!;H)SQ`|p`+#Ht2F48=~Pf<5kLSk8=Pdwhe|%f?rH!9+ zGIi<#8>K@Q(=V1Lo@1a-yq(+ScFecISshmo@gMww`H!XbB~4PnHO%3?ua2N@;pCXU zx!fVop;4;4Jcf$`#;6artww9+@IvyjzQ6(^v_Io2pD~=w4q<%EuSPOu20?FKuhyeD z{m&2RqP7Pj((|mBy{?Wo`xQ#nyZayZ-pn)$0iGm2949^f(}H@dQ~`~Fd~&1HexI-D z2T9AWLLdxX?@M&GR|Vll(Z0!O&D5K^l5+vVsE?8gZ>;eBEvZ;fEL9a`6g?=&6H*Vl zqu-59_lj*u^kM%{13XB|BLIg7$8^S96LgLBe##vzS5UM6l)`SanHfezLDFKdEr2-G z19f81!I>*olf>@Saz$~yJHI;(Y(wtVXPVba7DTSIGpbeMRi=P_Bv)HvOXaZ47Mcuq zYFa{3rxWf4WgCDW3?Gs#Ssx(pn-(_HJ>2BH3Yy}o>$bkMWUz`Yx(#0Obm&b8|APoWbDK_! zA2bevY^HgRJ=B<|GJE+IJ+I}JJqG)^aC1&H>2tG@ zpRADK*3Lmq1sW~Ui)lLLTB9q!K<4|O%X;KErRjW2fidmam?v1pSC$9EX)rEBUC+%4 z&UdbR+k2n6v>ge;8J=bBe?i=Q$h6lbX8?LH`ARy^X4E<`N2@Pj;`nEe4-SJd!gRF;#+Q5t7H{B?Nm z$j|@w7A7l%h);%7`%-UNy`vPAyPMaeo*tei@*H?jpqXV=cAX@#(WY|i|N3FhIunvs zs=jPSH`KH{@#e;uEV~#{UraGq*#I08sK^zCV8tM8m1k}y*ILCO@He#lhBQ2=v5yb8 z3``Gup10Lr(fPhz(8gi-+5_s5P@(Dv=i4WFD6>X;!GAx^ZMS-UC!uzquTdUkRdkdn zJy_z@PELi3d|6l?YrILP`7XJag>;O0sALmVEtTn)&m12#4Qg@4Gk`*W6M$3I87fRB zern&(X)d^PKmQIQ8W^RB>C(-^EZTaU0HR2m|K3m)5Lp-~BDAMfhiL&_!y3R7GL2d$ zDdG#4ZXU}wt&f%1rH(8D+9$J#d}c2I~ogJt0`zQ3sa=vYJ$ji0lBK-8sy z?m09>Qm740ZMHdqBEUAP8Q!VyiF;%rUx4z3t1Pk92yrKL1ivp2v*El_MK@t7HaE-9 zURg3o-|4S>Z$E8^C122Hn5uE6z=-tmS_aV2ydD{#GWFx~slDEcOfRBEkS8s5R*8KlnpIoM|nYBYgj6J(}Wq>rl(@Xcd92as!jGl8k!kHNG-@L9zAJ3l1hw1Y1aifR4v^6Kv;%%d#@d*^gh?JIp!CYGA zlkpW4X>A1~Nb$+E)Vl8yLKul(0a_nH$X7dxW>m{8+~Pee-b6kw4i2eOmoeNPi*~_J z!B>&bd{;@G&sWX>`(tHj4bF$a!wYG!r0dQagrznTyp4}CM0r#o=Ib>kYB5`_J0C@T zG0fgn`syc}+l$A8P1OhUHB~X0U%uU^6M!>f0As%YO*{F=`!oAtm%02mTR=j*1=|xhs5J*gQNl^#fI1Z*OD&Z!1V3Bmy z+Hlk+7LA6~zBzteg^}z*>H{jHObbCLuVUs`Tp?G^HjeDm>Wi~+iK)e0Nbq7^H5yu& zHs8w1o;0v}s}XuTB_Q7JjiGC@yZ;p)E=|tx;hgVB!E#;`$U`nRG#K~V-#u*P$WsCP zg9wiS&I!)ry?{@7i{tdA<#4MT$>OJL$1P?BzgP(1-@=C&Dpe_&OZgH%{hjAyT=_`a zlTM3^*x?2R7HWOHE)r>-y4yletE}`5E9Qj^oEUyj`)F3MD((xIwHEvA>H}^*`9uF; z#8N$F@`0S61O2m|VTh+=JOCmnu^3$EinYSoszm(aU%u>AWwVsdR#a%y$dzv;EfDjM zo%b-GckKEDTqFZ=u0o;g4XecWIO)_!*9w5|plZzys9zEn1_@j4kJYq_zrN`N%9B)< zRjAjSO@ISao3xs3u!8C>(mC1-ni?~>MWOvKk&VF&QfpeI3vDY7Ct&sm3Dt;t zLC#AjKYfz#K{Y?rKwPUpH5%dV%E>uRgf%kG6D2I0$JYot?lKiBdMM?u8OYU1HMf!y1H*jr#@SGAzZNgeY0o#b}+Tq=X5jxO4;RfP(dDaVYU|F@XOZ2cu}o|&5VCSdr_>Xp|yZ!U+<`#0?vmave6t1iFp z#!ZFQk>@p`>@Th^uWq(=47~0lb8E-J40Oy62pSXPqJoXrNqCV#5;4#}CthiBwYTyG zNweABYh?qH~n_kr7E+fOhjzMe6&_qmjc|@A|-q;U|uPd^$9x136i@zH_`FhUM=87Uir>o8A4I1cPC3i2Mz3 z*N-Vb;4T1-9LU|xv^ITN!_H}US9!K1GYV=DLzCHy$4w|g2rof!8fKB|n~)YibxjuQ zQ?v~@mh1rytbu@FoQxI!`R(rVxL26D5z#qQj+fCtv&KMfP!IY;g^EBobal$e_q{A7 z?_}0;4FIu2K_B$zw6pLzuWcTNnI(Re_^rUefYzPwyDRCn>iI2e952q-MX|F9ms{6& zTX8p5E}z53P$~wP%5~sWs*jju*w|uB_%)mK}eVu{I1^`=rHv(kNStMBG5a0U8Zb zt-e45qBd0stO;hbrL^kqXQ-<4d)uk2YsdgsNWhVE0m#!W?>d`1$#{m1Tt;ciHISNU zu)6XKD<&GfP#lAkr_u@%Yy<`0J6HWl2nda-*6*$%HV? zzjbUqmNxj1GQ+`yP$HVAxcR6=^q00bane-b&umeV~5_^9{=8u;dkeBI_(1s z8%LikTcgbb9Tt+(QdL7RLdpds>*U}bkG=K@I0QgKLV`u7#;d=#)N$sqLf8m-DE+xW z`)@hHr})S012Eu#;Q!2U2$+|0rqDM1{Cg%DsyO-;dL_+&xqxI7^EYRcZr6J;60FqZ zr0GashV3QfrDvOr5gIqSXB7d>YXdm*m7t*AYA8|zCnzznBQ7HC{VM3|-KeoKP|m)h zY`9-GZTp~HuD$({DpLxF1#tPM{T=(>H}-(9WeORrFdd&7Zm}4@HW$ZHWy#n2C4HZ! zYoK0ZQ*ItC_~U;O_m*K*Ze80jx{ZyLf;0jup@4L!(v5V3bV+xcAV`T)A|Tz}Eg&G$ z-5}lF{f-5??>L_4_>S-W^ZH|NS+dr;u6fNl>O99ehKs{_9ZMmY09O(D0DD`>#LBR{ z?`#YTqE|>E9qbkW2)G zhVLQz9Jz6m;EyQVHGY2L$}eG$magK?Y9;KCEOzo2jxTjR+gK?NYzSOFJ9yvNa+TWL zJf9_1n@f(6!(r2H;)``hxw`9(nn*`t7mqDoyJJZo-M1b~ojS{(-a`E;RjX8^@aIh5 z$-x?OO-rTJ=toJ4;nF?Pr&d3}fB^v{DS)uTy}u@tYZv#C+*h5-&k$s&RoGx*T|3{_ zfRpKyg3Y-M(9H$S00G@Fsv>rF=VXIUzqb0$9mXa;(yty-1P5EQ1YGtto#X&3n+dem*=TX*A9RLT~!5%w|XpM8P(&bLuU zo91*psil}n;;o)o>W!VTs93`WtPy zEt2&x%;Ghl2WH2PFxuf+UnvwI-MuBJSLKS!Pd#mcStb*i?}sc?znO^Bf;XJBg=FDE zzz7@ywO;DN)t(q+1WV@pVRxxs5P$+B%>FkjXxG?W;E_qSxcz-{`cr(Dm~bL?FO78l7#+C*SGe z;Nz#tMW7OPOl_B@kK`UQ@5&`SS;b)_one=s~?JitR^Z)zdD&lcE@+r_gwAG0b;7N)Z&$VhTx#X39boxX+@@R?>~+; z)kku0sf1D*);|RCeB+irzY8l~`;_}ytJ?W&c2uNH!crXzUo~ItEtIe6pZsQM%yY6a z2n*e_vaTRgE%X@fCER1p`l6l(MQX7(YFNavhOY!GzvWoxLjX#zqtNqJ8SxsLch%7I`l_4l275vpdFUU85YqfqtV6$N9v$A|N zNMY`N;t_c?EUx2_lTYD0YnrBr|O^B z;$hU98Jf)v`8ZjaZJp)TTDX-l z|7V&1)2;e!Wz>L-j!kHYAIaeON;mG}RY|E&00CO4WG4YYqCLE~Jmv{jqCX%L{6$}h z6y4%w&XcZ+Zn{SqnKT%{bei`hygKmk!R<|FriSO^_CZR?8XQp~XbbX?NI^dhjr!Ie zP$I+EFVbGc5Bvv>XlwnYdsaUSd#S<*2a+Ygy7yZE8`^Gkj*eLJnIJA|5CMWv^xN4$ z<^=8*Q#twjcYNQ;#n0h?$Mb89KD;hl!`3Nub(4nDtkwE>dZAfMbNJx!YJu^lt%G_b zayPE$)-L`?_7d<)LE50@%52k?a&P)4YQ>5ixzy!>qCp@pv}=_NB?$=-T*vyQBBHOK z5IH;tVW}1rSyl@=i&weBc|G4kTGnwo3T1KHZ>g)d&fWiFWLThF>U>YtOw>`JkrZ*R z``k-K!Xm|Vg{+PCLLiLQUlF#e31SSN3WuBchp|B8nI{@-pi0;7n=Y?Ai7I4mFD3cr zJYai$&K0`Ml|Mq%s2XP_dH*lwT^L@*>$Z8^H|pI@iCUOnh|g|ACA0!wbV2@sR>HSZ ziT()s4()edMIKq2Co8VS_Z!=x<{k?OF7St96_;KQ&q2k@P`B}tsD66`N*J{6~ zg4~RV+4|2(P=&D+Yt*uX%~@kM^iUl zXG!l>XN7g8tbhw3z+d+o$f1lo>N=lLS~{3n9#|q`z^KYqKjPGxrXw)F3zJxt^L3RZ26v5CSr&8-wn_8I)gS@A&mGF4TEc_eS zZwvMxN9!dwp~P%8`&dAA=$U2fcv#jbP@8tv3Q-6OmfP$vy+xUAj_EHHjlB#t=|vtZ zA|WcQ7uW2~>{dJST&p{KqB%@TKzM4#>@=yUG$PIV^$^Z%8fz8i4K~(AaWQ znl6Y>do|EP_Agwh;eGu;n-xW8nFVY{C=WYqE+^J5T;7~+!zaKLPsb{3*REQ4W@+S< zBL0?f)Wdvb_SE38;*6o@G5tE^!0B&Z)zpq;Sf8kUYIj)Xp!uRfUH0rn&tveJ7kTnU z2{A%(Y=mRnb6?Q^-QPRV`}!q{sAQ&g`BOOid31`Uqwp@BYvzKG_&D01dP_#pdTCX_ zjJlalnASW}68^iN>sjSjf3U%?6oFU83zm?>%#(L+DDOSzXla?}57h0{)7e|wB@ zSxS4{aL$^Mt$t*+UDSro<;y@SLxfVuS6|De60zG5VBvLM{VU-bq;;_H;OWm$;bK zX97}OY&6ArdFxv!U$veA1s9Z)YVpZ|1^P&6qLnKkCSFQCaQJMk;m`Y8~g2mC5FoutFOf@$( zANXdO#T6GA_mvRQN^gGv0X!!l`7=iNxrzeUujL~3E(xZQV24M=fhaF&H1XY~vX!8q zdVOeL=`b}0% z8z8Ziru=BUT>S_o%mT^?T`8Iim_l5ImJCLnpqfF;pcBrg*D^1*B^^_~38y2i%u)o2l=n_hY9ieck=&c$#d7Heg8vIrhg}$)ROP> zU8gR_44Wz_`AH=~HAdv-?C5n0JjiRoeI-DC#PQ{hh}37tnZqdX7Y<83Nrr{Klx-kc z=XYCE;qQES)HhCjXq%uG50<_sj?b8ads4-~)YQPBrVu!~oK8L4(VCvA+QrwpWt8F= zMxeE;4@ zVOqNnIb_yQjyddpW?liyVL*|TP7Tt}3=x#h|23M%^r^d!5fl1QF=IT)2hkqJf3NKP z%-MOfd3e?#&}x=v8e`lXBIu|7tG={UZw8|n1+Sh7hUu%;%D&`oZh8ZnErmlsAL=r0 zVWT4{JDO=l>J$1?3rIJ5`qaw1ND7ctkDZ3m6K@$W5kdNzuX{?#h9QswM^!rctJ|D* z`P1G}cXo0CGKAUL2YHzol=)ZTWZv)P8y#8$i1-@a{ebOGt;X(I<5t?PS>Z81fbX&; zDSDB}T(2ReRb70i_aCXk&q=XRgD&n!QKD6-?e;`StOEA+4N_2WzPEsey6sh2;T}`z zB;W{c++x(3-NI=c$Uo(7D@7%7uWD6ZAi~sj8DQd&6Jc{XkTkSCpXHb=kxWHUAUzJI z+hR}3wJU6veuKqfjUld|%3f5I+Q`)nH?rO1Z@uyi2nfp3D?=3yvOrBVq*GJOb||bl zDYM3S`{s>Sh1=^gwWPKC;YbB0o$J%5)q$&nJq0>Zy@W-b!B?3YBO+2uL;_>Vf|So> zQKm<`Ud*M{Gr<_IN~^vSu)g>3x^T|7@IN)O^L*(XsT1?7Lqx-Pg1EkM!^RA&5{YsP zziF!WY^$A69IZI{r9?^>cTW+)(A9uj(`HHT8>bojm`8b4MyWO4a8N{RSPV;w;NWjOVZ=C z5gjepFG*K=a}5lm&39j|3GzCB&M)XM_~%f7LZq)35ivAfK&HW}rt+oAZY}ns0=WjW zE4R>lh!+5PVI9nf;r6V*^4A4-H*RUxpX0aUgz$9b~Cvv8i%+K|ZYF$JpbDjrT|a9*8vj{TlHMQ(#yQZaA9a{s@aa>{F^<9Qxaf`TY1k(eU$RK@iZ5_KfY#GqavJE;yH+ z*_+c2nns+4mgm|F221JvdwEKbQhrty(f6VAz}F(4JhrL;h_Kt`3N*^WV~fCSBjI*w zfQ6}n%>6Co?~q1hPm&Gd$tzB`u230G;@*mNuql`u0tTm?NsmrX`7UuH9R>F-Q+r4- zOKxuOVN8$ZL>`50bmK5i#5yi5Pk$=}LQCQy{S9Pf&7wogOVT%`Yl24GlwyPA>$;mG zU5im`f`tG3LH?;EBHDc5LGH7vVa`ifj#SBLO9mk_c@u?q8MavJ3i=r>^osR>-2rFy zXQ6qX>h-HvncQEx?SIpP0)nK|1r#}ghJYfqN9&Qn$MQQj3pAHu5?dfEh8<^bs zdgcV3dw`lmA-Ct_W>dE9zmAOfG1kUP&mOKm2J9Gu61`d=CL=->K<`A+epjFWb+RUX z1rV9~sPOywL%ECCo!>4BdK~WQ8F>hUoQsV6-FYCXalv*7e zvEVOcf}kR#^lm`5WtMDwGq+nQ0+Fl6@X*j^WLPnjD|gN!24eW@V*Qy)eadxzsmeDq-l4%7G{dcp=*wFQbmYdGtwbjfmrZgAW%yQhzwy=lf2` z&i&ks`pMpHXp_>A{c!qSSv>jH3uwC5;4QTU{X}b$lf{Ef_2j-#oLTVvSheGk*Tzqa ztrmOb%W+x>jNrNLAv50=FzZ2Ja(TjqU@be^b|`mG6oI=}5(pqtG?#f##Fp~aU~Ehb zFPT+s@vsM7r2@@Ri9wF_PDHmGS@+nsC^lEop!bX}k$Fs?aP2=I@IR4%36WefC}Eg`BE{P%6`n&y_~Vtz0w%!W$RLqRE9jobYw>-E{G z<9hMxol18%?Mi3M<1587F$-%vfH&PxqCjfKl|0?-wH+Fnu{TwGD93(QPtB&sbC;Rs zxTCXs&L4fb{V^owgggan-xoYPk~ux5*SrIw(0ogW+;-LzMsggw`2cXkL-ja)8H!_w z5}I}PYqssm3pwS|u6kg!>*(o?R?07x*k;|wlS4GsSzi_K`Wx+r=rn%;O`5?z<+YHj z!OwsGMqtnPkUp<&OaA1~bJV2FA7?infH3W!!5!zQ*p?8&CR`F0;*>aV{lih6D3Le{ zJ-zgjy3gGDZ23cTuTW-Rib4|e-vSsA7db9ehyjGeVLclRpxx!CNeZ2YXiKNO&+`Ja zwN;!1A@{g{zmRym0wFSR_snePGTUDJwijQCyJ0Q8hv3&BYApfqep=e^vCpgw3?Onv$ZTP=vdcWBr31_JKCPv?A3h4k@7g5 z+;~Jo<9A4NMPLy!Tk@=DKW;{V7TE7&a#2JCWm$eZXC;8w$q9U+2GI7An$jHUZ>rn% zg`Ks*I6xGLzA3H!6LVD>0Mu7YvKT}>wg*Gzfr@34v%yuRNQQd zY{S^?Yzh!cI?tX3Y24l0!+>yU8EpS5p9>Cc&fUvQOrxk9j1d{OM>wF2DSn$;-`s%1 z?RXh*0PPAFCQ|xvWWUl=wElnRfy@Fz*F8sDy|->+(N6@`pxda2*fNfTgV5~jv9@Do zp&WL}ofFXPJl&O0Ab~?C3!>@s-%J+rp<)#xxd*@uc69C=fe+Wb-#CvlS(_Ko=L!i; zj;2CeRt^spG`^CyCj}!KUlpv4{>vd=spBorjLK{UQyIyUrk){s3JGv`Ef6P&I9(1A z#EFSirP7g*B}eFTy#pl?s4w!@)dKfjFJ{zYJHc7fNj!|^aCcOMpzSlGLq~7qo^*7n zAjy8soh8m4V%l%2146i{iqs*ME>pBDeP@=tUxdtzE7SMndl~8uS)Y%7yr{o>Ns>}& zE*?0QG!iBBE)J<_zFzIxvO8NE(#Y#&JbC1(xAcxI(&k@%NjFg=UJpQ3mGL&h_hp&s z-t)U*ZJ!JcqO{Oz^&_9}Hf2)-`(*el`70tsxR1Vg?gZl#NKGFFu6kOW1V(PnbmTH^ z*Yni0(~)vR7>7X8c9xVdPy*RK7HdEI51SxoO8g}(IYGR$h0TO1ibi*C<=NX<0oW=tK5sALIgH_f(X6Qz0T3L41X4EJy z1$yO%o))96Z|(v_7R6PtJmjbVYPNs~6TXyuB4Y75l9k!qj?1z}ea=q>n9(Y%OW==-J9s#2{sa7;_HTN`mW zc=eD%oE*#4>whc+LK7wO$-5~xXALRIPa`R}>LoN?|JKI<}HZ$mrvew=GdKk>=*X;Q{3)CS>!W*7xq; zSETv~iQgAq$B@Xd=}~VPp&K*#IrQaw>kqM_i`RJ3fr}WGiN>bQZoa|&zvEL4`2Ih3 z7Kc`$&I{d<;omOM69Ca{QNV?f_2>Q5z~1yi(dWFXkJw{Bwgo>JK@ykjcW3*s)pgZW zqF~kd-hDHrbgSj*s}!OaxbyGwGJ+3qzT%~rGI@{}2+=ogr0ont6H2l5%t&xOimk-G z9)c&W78ZUkFLkC23oXW;)uyC$A~=HF+^>*(!yj%vYH4N7{A>h0l&e(OzBxJT^mn6v z>Rj}&LsKlN3Zj(|irwdk;kogJa^VF+wPYwW8Tw}FZ#MLZzyB$?WnB~o7$EoR4(&)z zqsL>JE{3vEG_Pa5U@EjriGc7N=n#iLMd&`9`sJDy9>NLVSyIr>w>~1i+j@Zc9`LQ> z1wCJ!Hc!}exb&0)@>JL;VU~c=%VmgM#X}${DoO8e zTyjWW7C|^cDgC^Z$I((ZK41s1WrS~au51BeR|I2aYomK^5Jj2c65&9xM92+;q!z8d zB7-^r=HPVFW6^`^y?WoHp&aMAVMmhW0FpA@6-aWgaC)P|gu%- ztpt51?gRQH+SNJh?Iz}p+siQ_X} zz3R0bk$?TMB&tCVXop_^heU&Do{5mLvyz6jXhYz9wccKAK5iC|`$n0AKNXacnAw=7 zXfb=^#3~`(jeci~YSVxTmgOe)F_vDSZ^|#i&F7!@C+at*&l!f2o=9$UyN^Jx2?#&d`r)xS8clz(6Q9V#8X zV?g&Q^^nTe%(t0%^-|c%@#MrVQc;Vo_ z%t=a1`Tq!m(v+xNcN#QJGtS1BIo|hmG^&7LnV#2<`9K*Z7V*(?kwXzHxFL(a6pP$@ z2e?2_MJYDdu$iAX(P`U#^;4N2l77IH-f#~fa|9!{0WQ6UZF-P-qb`*yfkt3(NPjrb zLKaZT((+G)^Px(~;0vExd@YL6ekLe*1Y|YYkJ40R0!d|y5kPQ(!y}5L;cUG>Tifg=VU|D| zT}-980K+=%#c3v2eyfEu;j8<$})ou%6iR@Tz z+dit>1Z*thSnu#BUy}KW@onlEU_QJ4~>x2Z$vSR9&jW( zY|a>6C#WdME_>0mUeN2AF_Z^i?;Rlv~v>Q%i3WPtF_RA=ZA=HG~*tF`r6bR zZ{yC^o1m*3d^FUPSO|2YNNR4PaznYn_s9wOkBQ6nz?5{B=wrMhU`Xl9@t?pe%nDJv zr4Ws+nGB#2g1+*d5L9S6gnI4 zU*D^8TiN|urJ1fkW4qdA4>Djn6C-}VKtOK)*RC^Q9bhAI9)9v{NQgbVVKcb`_0*0` zwM<^;U7M*GGZrwt!2O3_U2x(-swvLGkuhuu6F3&T>~#dytPdPZa~hex|E8 zL;*KeZbTy^2#kQ8et7NDexSDlDQq|%0QsaWhz0)##fQxV0t@GeJ@D3`iUv0c3OQxK zhlQKO(2-IEg(}h;x?j>j_~|iCkP-aY_3}qj+8?zi5T`=*{-d0Zc=7u`I_m$getJA> z6sIu8T^(lTxwYwhw$aU3jvmc!`!IH{KU;mOcWDoS0l~!uJvdi9EO(7~>lwy;{!{mV=?31ptCb+Hp_j*oTPE6wzik3Kk3^zJL9XU5zc&ZGFZ>oE9)#rM5 z*UqK#%w-MlDt0 z>C`k#Kaqrd-_+4`9MR8%_&CfL=YnoULF$(naRS({{h-=CEB7)3(YM3mCsQ%pu0ktN zX!^|RAquJ&&;i3{G1gmb)X0Sq4J16*rV$@z>2jAT2Ou{__CGpYoDhPZEGu%v8ngei zL@#cNiYunvNsA2GqBD`iN0C1>`$%X}w~udZlLYTctro*ekB@^Rc|MrK)dizF zgOK72N@OxAle}ugx4<-dWYDoAxwtyAaD6@O0Bd3R-p6U8sBFmGUYgqQhT8(spyvEb zY=n%O#e=Mm>4PQW!}u|m#_{%a$+g9}$FIX@A^-lZI5E~$1?*tKwR7`_oYdyDr-{WS z2V#myx);CF6u9`Ui-ZT|AujB8>)cI4zw+D$b8=+fEATrT{1;z#Pw3gGW-3{%cRXeK zJ3l{!^|=`{PxH{nu`8Lxv;1PCKe<@91D)UyD-j|}dQn0}j=q#$#Dkv{P-RpaJC@dE z)>LR=GDeeNzA!{rey?X08KEox0~2n6!r+1IT6hB~Z*G19ne_d(J5!V<_wJobyCYpJ zn3!lDrU^F&;p%*I>qP7mwaie&F_%;`*HU=4U8LxhSoP>o}O?=N1?|eI6J~#VL z!HGzk2 zJ@oA(8JX&+RL`x(4XIbhCts&JNIh97GT`6ou4CW^>>}ddVioWnn~L< zDX0d|D*X2sCy=F6`scIvUHW+_glY03(C9%(ob_jTvii=S&>m94h&%B(LpNXh68Vw$ zU3mkqi-rFx*uHnlJI?>wiuMtF5c(z!e3=XgDbKX*GbFLNM2DK}NRVN=D3+06PLtds z*3~to2x+p{mP4F|AB^|z7k(fp6ply7KvM#B5Ca9VFYXErx&~S}A-d!Z5v*mY5+&Q( zqE?Ru1s@=mNMH5h*@^k^YUTW?1xN?`cZN#D8G3aI8}zM7`3T1K3ik%sqojIB(q#s$ z_wvb{@%axn;1%~a9QK#MrX+o4En~;Rc~vA1%RaE&a^Q)NIgCtDL6= zh5#s?zG(S<(3-^a%5Fzk1)rQ|j~FlPy$&e+K*Om@a7`y(`;+<)|4U}^ScMZScny{x z3_#lSd6ki<*q8bSz5Gtc_`C!i8%i7&83jL`p@Eh82^DvyLwyo2gguTR{{3iTfNUYQ zdsJ;xSM!7BP%>)XT1{*jnYtt<(^JvsG>g^Lt#3V`z$+#yLGr!4CoI+|nJY;LyPmji zf=pc{``pgFyy2J5;Wr;MP6gEr_Z<++bd&nR7U(Mkhro=gjF?|d`@MQ!rtVL)ZFKT| zz(;E*FwnqZS|I2?T}N1#A5W2^zUo!%;pshRr5YD6uol*j{}|V|PUe1eROm9v@j9U_6 zn2cH+CaDJFdZIKI8b5+0mT7jM@B1WmdqTp(jH_Qr#75lJra-d=OfPBoZ@c09k>==0 zaPOj9%oO_}$54vuS3lE68mf>*Vm8k0aC~X>Ltbve6Q+XkwY$BbTN^GqIl`ocY4*I2 zosfW1?SwX!mCAKd`k*svS#RJq%UH4j!W##LoE!UfGF4zj1nZ;e;snHNu_&z!&)*8L}HfB7k*^jKQ`G{!0{Pge8dH9!kPe}$@Lu$S(?(~Oy zh!qKaaj_!$6odQe4c1l4#5_TVC*O$$BG{!@!tsU8;$>z&MHo_=|3J7u!&y_tafNYT?oZD z(lC5L(@5~upNaGR1lWq3;DH>;ka-%}n;XP4H&$C2r^pB$V|I_Q5aY~9ZGSK5XYxTO zEip{JG~e(-JsdON2RQcg8+$sc=F4oic0OB&!s&k}RuLg_!+ zS&`Jqn|TEhkmS+)b};vw2;4uC#o~TwpHW_FA|>1>bM0e8tR z>aqQTY*kLPD`b9Gue3&KB~Xhtnv9xZNb3APB~dQ!^PyCp>>egJw`#xi`lJ&>^m8{- zo1Vvy-V^5=jL!)K@a5kdhfn)f?}$%?%=FV$_B|QRSn`R}hfaY*_x_Zn>PC*?>xipk zynAtV>yi3XH~|r|4*o2*^?T%)@p}nU>-AXbunkFX1n&n59P-jj;IlI_ZXUgWOQLg$ zVU?o_5%#=(Zq8)?aYg>l&I3+)S`}}n1nVQPs9X}1FwHowK^pK_a5B0Ii_+XQJ}bR$ zT#M}deR3D?o1klIP-pLJI4)J2nozC#@^pmWqGc|B!1CkW8MupI^&h#W^m)ZH7S!CkxEZF4n@ON;YB=^VmrF|Pps)$Vr+CA7xFCyd zW++o{aA+O{UYcK8n8$jIT|V3WEi#H_<>n^wevbkrcT;;+h2*0g|T_mNBVs* z32JY{pmVRVXUzFNV5Iwfaa2vhJoj_S(nA`z;%FRt*&E0W3xluNB(ttI(-zzq#?c#dAXaSh|SFDE*Zi> zeERJmyu?@=cvjU5B(wZ?!;MCd*}oEFEDkC3DdX-bfLc;W7Rgt1a(z2Ckyz1Jg) zzt?zFFkbJ8dKF`Q$kzRjiTKW2#4tRIgHD)Mi@vVLS8u7VCfCqD63sXk4Dz@{skH_fz=q z)mi!$wKWYb%!TywQQ`KQ<42|CnV86g3WPxG>(*qop@Ws-DVLf3UE$VZp`&B)mP!112PqDuLb#W1V;(4;EiUfu9WuYx4ja4!Z87vGe^$Jhn zBdo2H5AYZ%x(N*AaE>;Y8AyCxP|s7GyT3Q4TyU>P%+$bqnUk!os%T02Len&RHiV<3 z9&!A~0D#ByYCOoa`wR?&J>wMd#5m^`pRCStoMD}xjoTI z4Rd%a68oV^Y=K(*GQMrKBB9aKzI_ul#P)h2c(Gj5-eO9Q+_{}wg$zS#ID1Hl!1kF} zjbi*&hOH#lK6dk#_h{csVUHmRpMO)A#K^t%y&~p5COsnO7CcjgP&6MVb!$U;cACHS z`(?0BK?=WZ&y(N~4^O3v@wbTc7{p!|l+N0wJ4Hhz!wN&}AUnDB`=*2ScM=!^=>wui z*>}6)^GI*y3TvPh=e8gCdzI;=|4pkt@XC1hL^Yu|)qJOF!2QX>9EAEyS(z{@!BDO%1%0c*$GvI|<^tBAh0A z$B4(%^gEy_5mP#RO$o0&4km?p^hoQ!j}|h8stIEZB8Nj3wb&Ibf(IbV`TGF`G0*t4 zNxzgtK?)+bGaV1MC|(lU_;WKLNL8Vx8f-|O%xsYXBfoEgdT zYY5mf(6jBhKY$;~`an-ScnfLP>oU?0e$cH$Lp-ucZ?Z^J_-gTFD9^yqj`XCRh4&jt z8ViTp^-kSDKxdIKQhcD!xG>HylVrKjaX!%L7yiYXAvEv;^REa%Dt!|JV5}3*C5;Nv zyQX{|ySh7G=1?!oozBnusg>+tggeKd8>+5mkUWg!Iciqs>0!M)!s>aJP~|ZilhzrT zs*!9zDHE@wKJ{T(9T&alD0K+L3RDOrmQx;xl0yRw%$2%&)f*}apD*d7QH7X5FDfyg zJ3bLnE$J42v=k#OlqeIXPl9Aq9LdNC;JNtvTB3PkI9yFXJ=dodD98Df+v%YBYd7lp z>?2jPiVENaLCfF%?2J6fPln#Rx;2+uKwoWpW?d1OrOBEOdY!pAN}2t%4_>0JTGt)* zrYa`tEJgQd!8`+!QQmeSW{~5b1Z+q4nT_>=Tn~)t_Pe&bFqFSU*KaYB=&)vS2U>tX z4N9nN_GXS23j1qoRaX(3l_tJLd*4>bVrCaazfkp;nET1|J`nTrc-AC1&OiR6>jg}~ z$=%Z_w2uDRvA~x~U*Ne1+IjLE3;kMkeqqqF&QrBVSE|uhbrkdoTg8O1OuCf{)F=JR z(WDk4gPle!QwLM@&a|RXn?7Lx;humcp;#e}OZz93e>O7`akEEYDAal7&T*9_{p^6Q zm2HZ&nSn*N?F)CDL+j7m%BF?0L#^c?%7~7JB6D1RZFfbkSgE&2^IBd4ho{{U2%d*! zo<=50-#fVmlBB5SdW5ToK%>_>gf?Z&p>P?jBnk=;MWu%3s}HXZ@`eY=mRwEvW`L6g zFx+^FvytGN&l?!dtUCCiMwXfb$qEmIrN6jtwvbQFbW@<3cUROzpeTF$N7c(E04xNJ z2ia3c3Z)^5X5+G;wZTF&Qh4B55S^rGSZZV1e@t!$FV-XF6@he{!&;9Ql|+xn&g=>B z_q8Zvv&%gU*?QlDQfPDVFHe+YHI?w4WxmivwMn5ILZiL#&!4T#hZF#`HdqQc0XDo| z1x%$19SqL6b+9+L;Z=k}*pm*T!JL_$lJfqbZ4sKUU_2bB1M}v~cL+l|u#81qE(Vw7 zMY%(hi#DGpoFL=}au3dWmmG&2Nd#A-JhQ)+(+GWu+N}1lH`*zCk{i0WmQI*86)GPY z-5^NkLK)hA|LcPY96GfefS;g6hk@pKcZH6X=(~`_s}c`~WVARvyP$|TsbpBZGU7RV ztZ3SktzPNi{}aoIW;XQtb?^1hlIm>fe1o zWs{m`7TtDwJfUxn*Jbna6#nM(%|!WV^P!v~>j@Pov8F4sgmC5GzqSKH8;DjHd?fvw zL*a-X!aNqwm`W@E#prmiIjwuqHn%z5`&a0F5raz|hV$zg9a~pnD zpc*XPdoXqBpd*V2Y!8x{;1h-5=NU5*>`g!M+f$U>4o5-|O=TL%Wp_u1jEXZ=s-{8Y z*;FC(*|9!AyF_gP%pP(6&J2Hn1Xgdo zCY0d=$CK^&XORAwZwU19gz~S6nmKy{B!R?q@HTAQ{eE$4E0*CH+BK}BOhHeTs zcINGkS&-*^5M^CnbFf)5+Z_3xp#BE#(;qXSHNnh#^Vh;w|F)2SoY1D(z5D+1<|F#z z$e*-UVA$LUMq+uXO&KVz(Da=aNwF@0y#xU*?pUnXDSz|Aumg#H3p&+OA9Y5X+L6~> z{b;Mh_Ry>AbM{KolT^R(r@=z~!W84F=A^|lva2VzB~Z42eQ2{5x|${_1qI-iN=9=k z8jT{}zTytLXi8=2Aq}5GRt_$Xl3AsPMD zpgxuxrNBoX1^oUm5oyBc(5H5ZK2B_r5gt)gL3C^@hSf*w3tPCOo8OPnUVF=E;i1PR zSS*4pMJqwCl=w;uG^GLK94h7>fM-fvKK$g7CR-{#>Hv%*jxY1?`xry@3ozUchE|&U z2LOb46wUR7>f;^W(1h2`9)CNBvbjyGu7DB=kW>T?YTNm!Y*|v&>*z>3nui;mVnUQ| zh&T4UD>qu4Tz*Ww0MgzC#GNQ|KdIHz!b>B54mL$Q7?SY0M!Z52CG#ZzB-h%aK<HCU~__r#a8p)HI@HJG8vNLcKO;S4!s#_`FX z#t%aiA}@huapNMJN|*05i&bq@^;rea`0xD{vXu$91F(h^r2h_n9$M=<5&kqoe$2G9 zBs$h=ByXV|gQWUvk3?tFqgbhS4tWurA7OJRjWR{7eS~alL&xn}tFfH|M|Ova9GRk@ zE|beOrTExpk5=@@+p70}X)~ni9vk)Gu$@q=Sdy&ZbTsBj&}KXPMv5Vm{e?CFBpnGj ztSzjp-kpA_G6kivcROxF8o-3M11o3Tn!va*KD>7fF!2LxDDbQf$qg+pxt*YH;T-Vgt>q|pOYrmYiBER5507YLW2KtzQ?_-6 zpMk+HZT%Tse*-22sFhk!V+Wwg#54C73Q+T%A%S3gZzP7i;iK3`5^7`6dI~}*SH0h) z^_m)q9FbF>B}gh~Ng}Vh682^*rn}S=7OzompfXPByPVGNdY*ChfcWehUl|O&u&l}3 z8Lx5<#P16mdkrj}xHEI+FPv;Jl_9j%!{clM3p@5fJt+E%Way|RjVmR!zCaVE#O$=4 zJLSjy4b9Xm!h!#yvB!y+@$B&wLBdbWX4U1bpMMS1 zr6yka+Me`PipXeNL8&0F`Ps<3@l@X4*v6Zy7C_*hlOh@Ysu-AXa(sm?(&X)DCIhm} z9B5ZUnQeDJ-mZr}?!>R-U+?t1`1;IWc&}hJYN+GTID9?{I$Tf;{T`~vU+vBg1{%yT zsD5ZyBXJQf;bxDc;qj6`=v;`7WX_P{DkzYXFF&)?G>%HVMPS+;eb(o>mpPUbLf-vu z!#1=fG9}=UOu>khlxQbB}_{4M1)W!lImEtdF0h=SIq}1x^u|rAM69|HuH0D46h@HxxyL!(bKdzs=oF| zWd{dWJ2YYD+U|N^mb(b~hCX|mNOy7MgAfI?yaMLH9zh5bj(}Udu}i4XFa6|}U(rio zxkj8mQy(%k9^BXiaj?93=iU;gH<*Lx2USTTPcD=;Aevh{Q4arx;HpJ@jM*JN?5W4w z5B09g-zwEvBF^O6A>nkG{WV@*XxaT{p?zd|C|9xllAgNVa(|ALjQF>|j^<|tS6QCX zB(C>c^4gu6e;1Bd91REGk5-RYW&AX(!S--xxx}tFW=Gu)rnInUcEK!^OzmWsduwjB z^Oxiz3M{s=;MhJtSX!$tu;5p;Od5Z^GM2hil8Un6-)lL@v+DK}3F%&aRQ;Aj@k{U> zw`1VYGR+l@l-ZN5ZoYU-ayU7}W=YI#>%b(Dr-FOq*G~DBch~4ljPn5k92o^s#+Y(D z9{$YqdaJ(*SkgYN^>6j_DLqe!lgt#ZhRE-^3PYHyvFO=q!CMiNTVbSHw(L4iDuTOzWPM#wlaxT;dGJr;D2!@3;hhc;HZUu-S_x4~m=)W=miu+7}G zH>Ia)_?g0#5Ar~j&1Cq! zLmL-a*~<^?Y3SWg9qW{Mj6c5cHO@~CPqht2=YoCuX0T9-B&)GhPHL_NMTgcBqD`$_d~|M+C7qy(orVqicW(ZkT8Z$ zVOx@baWfGV5JN^h>RKdO98|Pjxgwdw zQglG4$aMA?yVc_$Q!4D`flWE6HAax{wn>3NPRmLM@#t3n{)ZE^7(ABw)tab>_zC#D z7OX21%`+xe18AEWB^?8c_04RlDlMalnnS=2Ugg_#cX+0@MiD@4buwG&(pvWP)9Qe& z{tQWX=}v$!&ertLC-#XBn%7y@I=sCc%g=@mH@d?L0y>=9J3r#4SRV}C2_^EG&VD18 zc6tY$+i9BFpsB32EIj-HFjMg!_k}WfcQ-4q>H1q&IiGcojL^treHj`3p5LCDA7(% z4K`b$$;r|4V}m1^w_yF9fdi!BKtmoN_F|LfEEo^*l)F<@x>`$?%fA3a2^v41#3$J* zhnDaCdN`x0rtYxHp&J{MP@z;P4go(3Iy(LxcTQ8e4r5d}D63U&Cjr+U8+&%;Ov79c z0@0?G&-Y`MzZR=Ob0{?Y+VtlnS(CZwF%MOot*7p063Vk#OKP7ci4?DK;;jYH0)#fX zc4j_)T94X=PF?~*$kVZej0D2BnaZ}cetdp09c9k1AsixTlLysI#F$^iNT-D%a{`4& zs&Rj#6~Zw%t#rq5&n>V$?b=~mvukAe!fl)CxhCx$7&T*NZRp;3 zV}(2OAB*)9%QvKLY82wo~)!+jV|H?{@?9*9@@sewBcKVAlDgtDD_?g8srzW0%T z3etweQUlE2)2?!1MB=#-0@)_atIZ8{bw0w={+oPko{{?ye4WLdC$Wh{yp_ zYAH-?pp9Vjsr50fDv*#UZYYdx*f9gc3<|vyjQv2NJ=|UHD9|xiyVTku`Op)aTJ9Hc zN+BNA9GjQSc0gmrROOFHk3)6MB3m09>H7Gy$b`}#IqF+X4tgs+ zpt{lNwHNBS$8chO(kM-?7dHl!F}$H3SB)x%a=Rv7Nw%ENn;!8}TV8yM+26PI+JQf2 zf-+)7<$3R4`?mr&ptQ~%RD6M?Cq#{j9bnQw4=tGuBKvdwGVcs4dgpf+BzR74p%svn z%>D8KM{Bn*uoxf7#WM%(F67p_H-63oz0J3fUYoeTZAE>yw>Rq!(ejEhA?Jrw(~{m_ z?Y&$6xHd+ZGc6Ntea1?SV2Ge3_4qK3toe?z>+8r-_aI0+GBUrKbp%XhU^kC@<0wn2n&6F(Q0mg3J$!mRQnx+%++iOJhb!F}5|w)KaYplQX3Vp7oKiD`xZ*jvLgWZIrt zL#j|;G*pYgR}1LWUu~>Kt+sbjN0~I5IB}-J6wR1NLdq9#Ghk*^YQFM3n`vK4K;#jw zf`1L(O2D-%*9?mD{R_Wb9>_7wy%N`qx58Xx(iwE4(SPY|8!{Yysm|~YLng&2=;1A2 zi|K_DmI>khaUvm1Rgy8*pbUiu3M*T3$Au2L*r3L;AbWnTY8JKk_gQ9rh-p+oFN)w- zY*nsuzc!|eH1u4|(y4y@zW4mG;%kt{X8a*e<^o!t@wfZRRR0>PdA@Di>h3Lr&qo=S zo_~akF%`eeSL)fiFe&JN#}!>UTh60*asl-4pr!=+0)l@Pq*kmd@gKZb>Utw*n)Rj4 zB8Hdnax|m#Q}K54%RiXi-2VdkbKOVGY~3@sAVXPN&ey^VoplFht*=H(Z91;$49e9v zNU8q_ZW$U1mebj|cQuM=#Y7|2W-=eQ?ESlhr128#Bi>?i?NZ$c9RH=Z@alUHBWTB@ zX5M_Fk<}j#IDr9>;+%J^CDBQ^1f~ke-(w$^I_yB(A|7AD(+A-e*B(%ljHg^88Cv&$ z_wLtF_biQio{jvLusyZ%gYE*^s6%_Rg82@%Qsw8<9iK}L7h2l2c><%^&-MqKMP#&| z?~fCK+O~CPnnZD7q+LLH=+7A&f^Q5XA>sy5pb!4`lgc$}C`$8-y})3o(%=H?H$GES zzP)z3w&>y6wZLXvGD2)O@9nj*xTf|*@!J8eRGc$3Q;#$YKX+wN&}cZ4O||ieqyz}0 zmcmpw&@4hUX}!FkjF?@Jh(dLxd*wU+d+5-Q+Gi@YQG^V{&nVq^a2qi_=&2CR2kjui zhLPti(+)Ffq>xr!?tO+nE+N0D+ZM#rf*w+O+qYzI`KOH` z`_J4AfmzTu&0Z{eNAr3r7Fb=PaQcyN5>UG{-(*{6CJ9pq1RfsgX^jb|KK=gr;HAye zlG9_yv?IGUHqNkgiT4dVRG+8^^CgBsx4tX*yC)GE64NMoscw&jaoC{Mykf-x^@mU5 zu7!O{p$!toMDH%?n`e(#(W$51%B*OJ=T*o7J^8t;k2S$T?#AWYKm>#04G{s3!~bFK zt;4EH-}h0R89+rr6i`~aJC%}dkY;a6x*McJ>F$(}me_PiOLv#Fbc1y7eO{c;d}ro+ zKG${rI=^$)T%&+{ueJ7Kt@nMO`?=$IP}r(`g*=(VR0I&yo%Z>JaL)jty|EsCr1W^c zrIlufi`zeBWxnpZ)bSVvgdQ}V4S9twcqzAyE1TYEJ+P$hDg>tf;0|Qy$sHr;l5wKg zGzS>3ED=xO(PphQOSn;Hl!jDiM~J5YQIpuj!;;0#k44HsqyQ7h;4lUt8xS!aaJ);& z@pDOv7TqF|>I~|%=QUH#f8+B?SWF%PJV|FHaz4bi`W;am-E>{KtOeu1;VH=zw4-kN zSF)D`jN$%kl`CLD`|`o|GyyD?d_ZX{Duix#7!Q5!f` z9fS_S%z-2P9?Pg$K}C%4Vj7{L;ucVVQ^_E*)=HX{a*I_VT;|QiM+GC@1~*nz!;=hQ zxEn(|X6n%b*2Oxr93j{JY#Svgr~wWp*S91ykBNZKmdTgm*D1qH-B07YQT$8Nn?vlC zgKMhGZ3eblNAdO+hLU;RCtKtAt|JIpUByDeZ$l#zQGTQV3oN~89<~T!VV;f<99~Dp z{9`P(dH&F@J+}zz8M;`8%Qj3r{RftuV+B(=!gMny;J$515iQiXok0LDD|T<3Ue7KU z$TB+v%y6!Bvct2(m16A9d0?sh@)?oUN5Z??%pExEkcZ{=r?7{XTNatK@w>o#M@Z^* z{cWcR1_4?3E=F@qho=zW#>D(}CF(UfT?hK{C71QUH6@p_E?QQ^7fKuEWqk%P8HLME zorKW8#tsR zwNUHCUNHVydjKdX6v?(04b5Xj>;!D>L^dLEm3BK_cwj2 zp&HBS$im;QFD?{6a>|TBn`9&@c_qp}#(Dbb2YZig6k~xfODlcRw%K!H(?mnyQ5Z{T zs%CcaEzQMl?fX|zu30B^<1Wc8R6Mzwo}5LO6?7igi&Ynp@<6b+rygx9&Tg;fo;zN@ z9AQe5q5CxcN3yt^EztepkJsQkL7IWnTAuui?POu6nGyd19%*c*QH5qyY16N^9-3n) zpx^WthCB1&WDUNB>|twpmAQ68?Su8obz2A+AeU%2N4U{m0qvq`zt7&;XL&)p@gUCEH!30-j3m z7?Dv3JL{() z1EDQ!@@+@&B-_LE?`LiR@QCHJKl+)T#ndU6-vyG{K0Uky@E&{0vxBJS$^u24ow*E^ zInpdQQ;NwH={~oM zT+x{dVpb>|q0?Wq`z?zEoKGy~#{IGaPPgWokSVr6tcImJO8{V-hyfoyiZLB^PQoVX zQWMR|zS;Bkq!3%;%-bF?Nrw?%UY9j`0So`RT7&@@-ed&K5kSspDt|QoVIZB~E9dc~ z(nc`>51}I@0#r+ilf|m}1S+ZU8cy6PWVv|YE2Oxv_*JhsKVS0g+n*n@jN@T&x&`C? zIs5iON(~UZ`Grx2;sD6R&+)j!dFq)nbeJ)NEZz$ z*WuffE$;`w+46w+?8z2*<7?n2<5yq=f)moY3k0O2fC^uUW)Em8LB7}KJj|nEXu8r+ z?IF5(Zz>(?-fyLVXcfQ3NM+M9(Q&DqGV?@59wj_cOl9VGp2;2qMW8NwFot8dWqO(1 zoSM4(d?@MXej|Qd9NG9bP|DjbI$p!|p^)(Xg^Q~_)D?3BTgt%;{mWJwU}hNC=NM-Q z?Guv=Odg?*x`}%$mBV^CTvh?X4($xib0xxzCj2dnU!~%{iLbGnR~2afwx2pH^NBgO zn&Cjeb+U2Mq>UF_auq)9b2M03Lrh9K{(vv<9h_Hs!>buS34V#mPMbW|Jy1O3*G=d0 zK_aeO+ptrwgUxxBgo>wIT+u3j00HzK@DK*Ro~zvt=25<-YdFah@oWP8zVcNWz^$4o zB8k23OU7gT2G}i%*xOsh&v8%1C&X?Y9cwf-%vU1Mj7=zg#h}613>QB~D%7VG^A3~1 z{0~X~wCHs*?)+Y#XV!v!;Rq;mX}8MBvsmIAYPCQ^BBeW+Fr+F@bb+^6lVSG7p5kbr zRs3Tsq;B^2aM&l;CXaJL2Oj<9pTIZKBRk!pT;#B|{)`}v)lHJLFDBDVXMU$dJJ0P_ zmBAmN*C#Qb_8m1f9jM6a>zh~8;>nDcG`KJhcp}FW!p?7xZ?yWA z_+!K`T%LtV*PY;+y2IWfp~FH!+s@@^*HBS4@+%1Nu5{h%PY0oLEj3h?(=v^kz%j#q z-3I;9qdI#;*`bE4Y_p)2;MzmOO=Kl^a;$SwHThe$viIg}Y!f5%UZb4uMO4E?Zu3T| z(M@FUY&MqRM^#V-+Yc1PM;3NLEJN$PTQXVnKw=oA!eaa0?(?ZAZPZ-d^{_KXw#W*I zF{KOfdUMl-P<)g?tDWetNX^sbIv{eSMoJT>It1{|pQ&T_L;^V5zQE1DuB(<0|3m-_ z2cV{R1`E@88RdIfA4fsYZ8_}^5C{`hPMRp#VMih00q@ny;WPwxFyhf1I8;65U#go# zXFqJYwjW}QL~a2?)MdszWxNBs-Y8(|quV2r&lSaXx-42~J6FImW=9B@fA6UV zwJ;V#Hcy{X*S-%(gX5sjlZ*J|6pg|;Wi>OXxZ3|}e#hSU@Q**PUI;iGueX5E9@Za^ zxtzS{D_NxaR`|0=Oh#dWlK2Udi!*ZM$DP+`l%yGtALR-(gdBDPmG#o{c(qDaTi2jX z{X@_qdWaTr@gsgbn#+Kuk`?c{aZLCbLkP~`V*L)2xLk@fWaL%GfRPkxWq7-mtm3rw zTeZlm5c|;(nY1eB!?_2b`))c{y^)fiHCt(7;_NW=VFg6F$+V#Fjb6rd%0ME^31`$J zjoIk-OBcTaz~x6SLpa4kT=$Ex&zkivObY=|d%$d`+@FW>q-53VjU8~887pPXd0|bw zh@~8s)bv~Y@aweyT;tw_JQnh4`a}ai6>b#JwLG6#nT>^ntZ4B&4l2f*!QqQ%dpcl} zECnf9RaFC=U#zD98^TsvyUCNw=%~R!D-{1n0GRo!KoAJ7O4gH${ecQ{r`7OIedUz{Y(uAp_GPbDOH&#HuO%1MXFKbdoV%yOR-uK5Z zH3BOx`E(`ePjb5d1ZP9WMWIy+YUUI3c9yU(;X3V~gaBK*Xm_r10@S0jH+QTl2~^u? z`Vg}njXy2K!O#D*1>W=1|Lbc&o%;Xncl1M3BY$B5|6iP?cj%#cy%AWpMpXUm+`Ddm zoct|%sAt3zxGDlyf1T?M0lF#6H5~NIMz`ZmM_E}}P$z%~jx|iI4P4%GByw;QAi)6v z(14Jd;CF*iW2Oa*ODAFu@St%7RIC6J6MIw?sNX79?LoSs<%%A{`{jz?>CPljCte=| zXUj{}yI3M%W*qj(i52kY=#lPNklq4-q6R^I*oB0RW;(J~5`?pq{4a(IPs@LeLnN%f+ zMGF|GnJz%MftLrcLu(UwS!&n8>S(SaSbRq_|LyNX2gKg;0B9~j0|wfg>exVAcx-10Hr1{Q%U3Y zC3vX zKv(kwaCW_A0B873_cA#IXJWDjI81e5-VtK|vpxAWz5M;%FU_q4XNu6Bpz4>%&D+5{ zpZ2d80Nlw1a9sZ9TkjYDC%(h^0H!V0B>H_j0JMDm{I0Y4Uw+V3RVif7Q>;`1^GB>8EF`Er|qDJ@+y zWr+)ppFSoaKt4M-0?AGQZ2E9GG(zI`+F~zfNdC{gw_^PEgoo+iNoV;e zQ%Ho|OYp^aCbUTYQOmBR2**E4dPZZZwVx4FN-IGwIb8hykBgOUDKp+hy)qyfJbIf$ z9-R~JmAxF(IWDY^caViXI!ti%md^AKX%3yRR@tnhVD_MM1*&okkOCLe*b^Uj zvbaNjsq{}FyzMfXy7^g#(bjlts8x{9obU z2aI)#)Q?F8v#temiN_0=DMPNmM4Q_63a1)m1S=yx--n%4be_b_u=6$%8_0Q$5G%+TovW7!n$366;)KFi7wp!RV!w_OTM2LVS_sBXv3O zYT%H1h{6PW0BFW{oA~Zl8}$-?J8Uk`eQ=SkuB}=Ly!+XlZr4QL`$i){4|ewt<0+)xGKl zvPvapxeKiE@n1fBzG^>HOiZP`LQP^G7@{32m(mF$XYit^ir2b7;Q4AiUbML=I3GXJ zB~}rolpVVuF6E)^S9~MO3&F|g`@wrR!SCX8Ka%DId`#8;!thwyB4??xdObRzoP70Z+24j#vw11TUHY z`E8?xS7D7Zi^J5+p>p%eFlw9*kppRaON`-0zNh$Rg!V!^Q!{BGt-l~W4|sQOp?C$9 z_TZ_MSHVF5i>d_CaQkjy&OXu}d-!YGqA3**y39wPgGp8_Z@jm{<0d)TuAx#};?0;L zp+!X}#jqpg+}qdfn*&azHeNWop6uThGQ*0jju-~IFu2)M#->}t@XI32IUdV2tFoU) zl~*DpuizWEVQ)X#XPh;h2{4v37b94R?j*p!w$9RYdOH=_2F*(vDLcFZUV3uOhmr;l za)^ml@X=Ks^(9{PBy~y^Fy{WqG3(DzgyQBfanR>r@~VWrQf8r-O;1$ap+_6&*dPX< zuj9n=Otbt6e?cb-__s{N7}ncAjubdJKR+KTyq_tCDf-#4An#P-50yt+Yb4{;`6nA$ zW)^SmMQTaK*@%&3teO*!(q!MSj1ucktS%arAKbvmj2ltKDA^}VzS5SG;aw}m*iL@*TKqNuuJoL zGC>hiZe6RA7HX_KJk;ghdx-FPNcS5C6(aD_tc~0bXPq~@I@zF$dfTu zqPuNK_CPWKp4Sj`Fu~08*Q_z}mK&$c0*?q|kfWOfLxDA|kJI&R=A|@T+PPhH=YV|W zkouiZ%2O|Vnq~@KIYC&0Vl+{gG>+Z^y%v^{DX+0p!=^CZOUa1EZaJJZrD!5^I^_4{ zD~&(oz`!F^Db*=p4EgeSrG|IFHZDN(@n4JeciydKfe`Ur@uUd|9&J>%_*$prQ)=+H zA9YQ;d^}N{&3f;RdR;fwq2U(kyl2);==nCj(zvpdNX9D7R0+w~evRafo;lqo zS3gHBanv2_{DfU^rJ}Ybrp?CIvSK7gjbj5mBUPgr>7vCxS@Avjb6?)vKLYj3CVdW~aNyoFBB1T5sq#fR$8+bU(M833i)>G?{m=w*Tt78L&7l>-++5DZZ>}{<2xr}X4Mfbj89bjSh8p_ zJ~2^RWYnQ?^YOa#r27xaLoDbaqYlraVn`arXM~o(@)PN$QWSLh(<%c)O}TMT_SyL(H#dRN z#WClMIhJ%*am=Jz4rxl+NS%v3-EGE5vTew+?LZ^ZjuU-H8q8uj%*x?LWzkX&5Or?A@It&4GgK{R@6ixT z<4aIZYhcGZO0{z(5x*iH#wyoS-sCFi}DZo}Ie#xbU}fuNkg z*WY+<+VsO}J{<}{IH?TNM(w0E;z{d>c285g6R}sZ&fkgP=c``3KX!8y$A7v)x)7Yw zfi;M1kh@NQNjiav$5b=ybgpN?(2dnQmxF~~n;pE0eVwVzmeC)b7ur9_QJdtK_26;v z#SLxi9^jt^^|q4hxVUhl2C<8lB;{(@DwJ#?7MPL)e6jOoSQT8dk-0?0kdTfa-*?g@XNb(0~4 zd(cd|eP4^El~F$uwR|yVek8xX89g*LBoJl%rC;d?BK(@#zQRtbQ2RKG;xv z|8N>yaI$d4xc}tqC%zvS2yLQyo_GGAJ|$TjSSLJ>>lf0E4vl4%Msvh^rjy-2s;IAu zyA?c0j;ml8`DvAp6>=K?)(6{*_DjxvhM4?`%*7xkMY={D(;x5rM0`=uY95Wxud`iQ zc`k-e*VG$$&hkW9qs`Bt3*vmWwRgN+CKf5=Rb^jdH~C0jRjKlIBXdIC@tZN=oHB=v z*S$6Dqesrh@s|nPtifW07~;;+aA?qpn)r?;Hp`MK4U35G$14#o8`BwQWazRQW9;Zn zti6{*nd?gM^jptij~VwxF>CVyAsVfQ)tsV&XtI5OVF6xQ0_u9hB79aO`H3nm!SN8Z zBbvIO63I~YH9BLHksf^=23E4Qd!Uoa1=FOp&p7Amu4&cT=M>XHFaHf zZoGB~X=uP+j6^0?8Lcztf@-SEW@oH)(~vJ z)Y0$*Szj_yIo^&LQg*YCQ1bH4`v!^XstHayCKcg`Y70wejYueTjF_g%Q4#s_I|Sk@ zR#0>lU)Foe#y9jRXQzdAEvg%P`x{dy#;e3)w#Z%!0<5GXKq=EAmX6ZWGt|m1K;G-` ziE%s7R8}~(?>1Ibn(4#Y=h~V67 zd{e7L>C$OY@QtY&r#y1lCZx!($g4m?Fd*0uSLFUf#p}@Rm&7aLwu_^cl^HnXRpm;^ zSKlET{tg)_?eIwb zkxkot-~uB3i36qY2AWjJjYV_kl1rbE4BW?`vTh7m!j|M>o;)QYxhK{+-R@JQgd+a? z&ae3(n^Q3$6esCeG~(=$NV9p=D<)a)E%l|e0I|w`?m8OVb$3lTr_dO7pJs=tTsKy{ zDl)Bvp_qrW9IcW%HnqH@f@^Hi_-hwpy)ZJ>5yQbeo5B9jV??ICLq)e@q}s^VpkW_m zt~6%l~#q$gbX)Gu30g5M9h|4jM}Fdxf1CkS~3h- zr=|_lo~!KERyX?8Plw$eQ0E~PGx>GPqoyCyo~RH6U^!~JMG@!#bG z!-K>URgpb)M;^~zyt`$P^}{|TlA#=v$~31bSs+uhrDRR9P4Qm})o=R|s_z5-zWe{~ zYi|Sagg)p|p8GLorZ?(=#nLMODT{pNgV@jD$f4w2ipv|z`09r+sZ^4XITA89JlVqR zN)$`;y!V4G&Yv?Y?w?PrQ+GH&$=Xy#hpdM42Fp?A^;E=#F$6Q#j%tjUlD2u;?2Ql$ zrXZ8qhDONLTM!T&JmY6-XNYbVg0B6+_WrY;J- zym==R8$L;czY?Iha$2*+{Z*<8#q+mAH$`zL7-x!YrdyI6fn55?*lWWz4F^_FQL4U3 z$wGA9l+T-waA z6rUU-*Lm9D>LWSZ-g>}6{-2~Q$7Tfpvtq;RELe|-8NtHYz%D{tSz!(6)a z+`hW+z19r$l$~E8ri$0JmJID) zZ5##_=hzqT*}e?^Pw5TL>_-iYBx1{dC;aPIh70SYr{>ou@H?ilq z3DM)pH@nfF=GcBQP4`@u$LVmUS_>8TuQwri_vkKt$7*QsFyq)dY9rRDc4hnfcoL@S z+;S|0P%bOwk~&RB#P$xC(?~Kf7~CAKr_|KkStdh? z&;MDYmX+dYeNYhNV8N^P?}I9$Y8H?hvKJk@b?8WiC3DxPU%IlbGZ_EHHa#Mv1EZs%NfW^>wR$9*#2i< z-pWYE1R?wFTOlV*gE7SXE)!NW6zfCnH&zEN&+gX9e{OFcw)_jR`uMLV2Qu0}5ks^; zACo`d_!5Wy*RA)T-TXiNn*Tl^lHR`$sJHF6g6ZShe%;WRddGphd*zFVf&j69!y5?1 z)sYJl5Gn9mfT>pXvgR{7)oN-tpIJ{%s@~m4q5dxcT=|ly9rO$&1Y|rq4}t0U=Fa3P z8qXQt{?-~9Z>`693nWGyAlpdHzkQ?!BbM_MU@!8rQ8LM#puUd#`Vx7Q*z@didv~=P zyylz9ePCgOv4cJ^ki2`=GH-qZVF-?MWj!^lN{3|JdKHM3UbM z$oD!un9dNIjy{PX;`h2d8;S!mPw;sj|Ksy`X7{(+0wz&jKRo7c)Up2O5rumdh4#!; z<}|flyr2@%8))AsiAQCYrsNk0i&N~A`B5>c+9nef!&IS5xdf}yr(B|98KJxCmpKV` zPolxYJml=5%M9Mj7X3vYldr2^iNwX+;=^WpPB}E7ArgoC}OisyV z>XN#_1|maTq>_AtcH<^rQ88=>AHXJQh>!J1vkWH_pc}jW0YRbh5WXZWIm*`pRelMr z3#peZQ7(l=?D5ru^xi?c6`icn5M$j;C$#hd^Q9O6KEu{BuQ$W+HY(f~;xtiXb_#-r*Gr!{`B6*trIm&9M`bu#wrepyn(Wq=&>W9~ zs}!|sbsVepEz+{i1@*t}WsMBl^6A}<;}urTvAicr{cMaaXHTP+%rHTgsHI)R?@Ve* zS%|?YK0h(hO5DiVa!FH!%s#RpDY01m^`%BF_GnX0WT2beEv);I9sbnZjJzF-6P<}6 z!qov0|c;zoW~S2pJE zGMn=ToQ&$s)G=2E);95~FvVpXvex{RxIp9QX(8R4=BC#WLxhB>nmT5yh&}5Z!}#d3 zW2}-w$V>3)F?PyAySYq9uts+6C#%5&njgP-%Jp`gJAWz#2wLwEgQ=R*mlx z7y-4$6<+3K*oD1*rrF}HbCwo4v0B#GT+1-&(T~?bHeAMTTkz6;!o9_i8m1$Nq2R=~ z2|>YX_^(S|OWWR-&(i>zIUY zBbqxuCSm?@*Tp-0IS9O)uA*4cxNZ*O5`yxwBG6A zPP)90h@d*-RxL&`BdlE3_Wyz?HOQ2oji~q#sUpn>e1v^$u#)l+sNTM8k5!JDsnKsy{p0Kq2;1T8@HcB;?}1n6wZFj~8o^YM9BQm{wFv zrS0p5s++9Av0F{d5%^I{ zwQCtfNB8Nn*hWo~B4&)Dr059Rm+WzNcF z++!Dku#V110+&^i>D47oRz)+41+&6)T63sn#AQRmjH{zf@o&NJdb_o7bZ@kOGhEH7 zcFihx$qRP$_<3&j$*{%6Ga6Mv%&J@>f5bcx6PlmbyIs{gsYJMgZl@f5@X_KSwa}N| z+V0Fyox(>`QUVIKG?oJ9dh^dBI3+fFe#0Sw7{p=*^tl9>7^cTgGR0Ll&AgcUEnWf) z1Oh?jLnXt8u^+m6>wB(Jt_cwjIH9`xh#ftYY)cE4w^-3Y061tBKVMo{u-ev#rs-J=$mmE zNpSiIeig&5{&JS+0{2b>sY`BUo|lY!0V5>A+$TbDe_;WC&#s26_?MbZs$&_GmMu-nmySO~Xv+50|VDuITG zO%DfMTyB?1J$WYLyXBIYa3tVZJi%B7GjTRCjYH>Q+GD^(uD81#1RAP8o5x5tdg_sc zeA#NV@sIshy1#re9?~hfcH02v7?7ihi^fr6w$66I8Dc92N+gWXikM3+|I&Jya+H%F_K{;?p zdGzQZ%R5ZCC?lL=h^lUT2ZoW1DC*>{0%ja?v5-`u=V_qINc zMn$=md`P<~iCbU6*|{AU$P@9oXg8X5443|lNN>m;(7<`|Vh*O?H#xB5=5`IdZ^Y8E z*0k%PhE-z6@T`rmG+u9ePwD|5uUNUMQkk~){js)kY`Ob%IvHtJeDT#aFc4ovumIn2 zHS4(?ual6loheO_4epO^55&&(_6L|(#Wpu*w|OkWZm7rs-)E;e%oh`Ikntu8858=N zudW8PD%Xz_N@Bz?9PI2go8I+y!rV@cl{>C$JP>tYjhfBgwrsIcEep(^D{ZDxmhNUV zx5os;I7E!R1fwN=C2_ryaN@Sy_2%0K6Oac4L=%j@1r56V|OOXLwB4m*3C( zUm*X%Y5Mj!;DJ$JbP}a6FdZ>n$PkcqpbE$PmMJg|E!va^vw?)kMB;GzNVezWCHoQ% z&qDR3glIn0(49GBESL$`mt!8je8oaE?Ea~;sX%nT0BpwCW+}gNUd8L4@lpY$Gca@0hden!(0Aa4@40bIw@(ETuM{vk0D0~ps}U7qT)r|tj*FV&HJr@v`e6m z-G=Yh5EBya_9on3!n5)K`*_Q9tves{vaVut=!S^LWnoB%OLWox$A$N14^^%0jwg5` z03{CV;VZ*z>D62DWQGcnkv>~H2Z_!4r}?YPai1K1PH&ilQnbm?;o7qi+T3w{{NUEo zR{QzyNZ!idO1VLpGa^i*^%I-7i-Umq+q1bWf`OPR7r$8Q?zZ-N6tigsi)k52vwM;@oOiIi z>~OpHyru1@Dy>77se?r*o%1xFnoDiN7jE~IfPj`o$k@ZYOm`asa0ll+&rtQdX+N{B>tNjB1e|-Cg$dCk3^68 zZX10pEQ?@8^n8sTo);R#e2wS30%G=k;Q1SC)7F~ZWTc+XXa-pWDfwj22I;t+26tP_ zvV0nCp1gRmd$H)t=9A@6XUt3>Zq0(zLnx6W5sYjFW1ItX)yP7nF(lHo^ zHd4TvQC$4GOI~}I&t`c$jOWqQpA%C83mc|<9R_rSxjo(e+UUPNuBD*?XDCK_dh^Bp zcQ=DgZ>vfh8J6x28lSivQ4s@WF#RTG$fY*UH7XURhhPI1w(S%;1CX{PwQEcdi(GvR zpQtKSd*yo6U@o>{&UG+Sln9U3>5b)f-?eCHwmWEvVch#f`F}PXeMm7+5Obs-Z6>04$-yQ zrwGR*yc)s3sRHt=aq{kK21w55TWI@LB_zryvvH!fEGW z@{0ojyMnhL8ctSQ^o@hDXiN38_8HxqJmJr(ca3^7n$0SpuqK;JqYeHnUunjD0rztI zdZSOOLwPFRZW!sEH0fZNVJ}eAzt)jXVdd`ED1U#pih1i=y;y;`2o#)WN}qdWa1v@Q zU^KS~W~5vL^^{ZiO^$i<2%vZJ)FP}O;#T<`vzE^{$YHRMETsnHxxW_%gY*HL)Q;h za6Mk32JK6^)s;Z!%T%4-U~FwTAf^b>T7das^Fy7Kc}#;3pMEo>b%h!lr+n{*BnBVq zF^u^aKAv+7q~eekbYHO~aU%Sa3rJFHzUS=Saf*k%Z7Pe}d6n3a@X^2gP+48kqwYxR$22Yz^ z0~u{?+FCS^X3GC4L~1$5RG;K=$S8BMMG-?=D;cf;=VWlH&EP>~l@MO>>TPQ+x`l|0 zQcJ6|nzK0?#2IVD-_+8QkcepTV#F8KZ-KY{(WzFX;bM4Jgxy*uSTCTQ>n|Kfp7j_R@(OY!Ke}GW6gejoU4OLu<;>LkSWCBDp6veP;7m*u?Qfb zUrSc3|AlGk<9LXlf`J2}o#PHnK6o2Fst;bB09l`ckJ~~m!WkZ2bvL^{^7{u6M+FL= zJ1+lPUp!b)^1CgWox}Bkgq)h!3&^#8=TRk_1ogs;NB;}!u~k>UC@o_HqJG@tTvYn2 z!BQXwFakS&NBwU!fvf8ii?vI}Z!U=G(uaPl^OP( zhjUw5gnN5n?KhimspM*s5h;($^F`}C5GXrWoM)ktLV}|FZ2B2WZ5&a4i83jc@~!--se0mZx&2w?f$RVlHbW(`&l>qFeo+w%tbDuISRw^-yv{F$%Zh|fjmhgI? zp_(D7&YRS?ASHypchg-xQA{jsGj4QJTpxQwgHuN38>ugCT7^tcC{AXa$LPc#JOZ+F zRxN2X$KuAZf}I%}_s6>h@{95{?q3ya*hGu1DZLfLJmdYV{!JR?SN^0ob@d$M-?!0& zWBJSDaf><0d}R>)kw{dmUQ=b7HS_4vGPMF{^=Ut2#Jd|T-Igj?a|*gsEmo2O!hoW$ zq#1I}>S@>A7Eg(Q!cuG@9I;-7U z**-2?7H$7Q+^CV4dT!+1&&P72U}`8teNnE+SQ*VMi+5m9>pWTIB2J^JW!(oD$bPBE zV!3S>D$ww1#N2YGos+#>43Lmg|3kUvtc=rDRv@=EI5K&+&(CI~BC7a`stYyf~B;i|Bp1*O1eM`jBM7wi#kU zI9G#4m06Z6z{y=L>~zjhp^;$Wm1NfD_xSv}+2%E{vji2Qkv2GkVN0ew)85w4@PmRf zZ6Z;1u4^Zpx|jlp5yYA58sqNGCXXvC=IgDKdWE609(Sjq*2!8LwkF)pjYGOPaA{9OG0ppk`jGG@n&2h z!4;%5pZ$1gUwgb?wv8h(a-mw`MY; z&rtChNlYnqk|D{uJ{`7gORBm=r6B8Ud9=J8=3rN7547Mg?z>8~rvYhJ+$59Yb-B!( zq3SZJ(v{S%m(hZZ0c16*i8Y{gW=O^$X%$m}b%z&kMBdynmKsKH0O43>taxf3G&3Qz zl8J6a8Y5L%%RojjzMR$}JpWOV&gFPMt|W$$G&cBSCT`XH2V)+;X1VV*WVc>=Wz_&Q zvSKyrA)@1!?mb(a&~`3yxbD_BQAA zaL@)NNP^g_BO+7LU85-nbSFBAGME(3ckc!O66F10hu;?2!bAa}N-f|y#gd&@g zyW*%Y5z*oN09!{S1Sd+6Zr>>SWem5P4h!EuCP-KPZE2Mv4bEAPEc+Cy`bz6+yAgJj z9)mpWb3L@*jFWnUlE}#{6I-#+#{0wkSYvFsRK%?MMbuQ-AS1O>vM0fs1N(fe19X`= zHdH}0)h?RZ3TIE1ERMPR`d1aP|JkhS97pQ{aq9T0{R44ipMDOHjCgL{NXp`mZ@;o3 z@Hvq+i;l6+V$;hd(WP`1rR{2(NIR@;!%6gbT9;ftdM?V?dIFzud>!>WB~uM9oTeVItojaSA}U7Yr__$aF{G~bq~QX0*p+F zU)eG)xIaS_{`mp!{#3-Lk>-JNz|=A{d!n9g#KLB>H@t0uee32-hKrDZjC8+k+GVX` zo8H<^)h;`;RDSmFR|&By`bQdTb|CpW_%c+4^bQ6C?DsBRRVR zsJ7PZT>5fPW2JJGX*IrDH2%~8&vm7Y`|ddM(o(Tk1h2~(>q};8qo#+o4Ng;>mq+W$ zg&b6JI+|rx=KTk9L~K8Q9#-(26J_Wumg=Ds62J1ay7){~Yj03dRn_EqyztR*Cr%3c z?w0v;W3H_PP804n?H za5|ZxZ+ueNNe=MXPS`aaj+~`>4YX(3Zz~BnAemInBwkiq&wn2sEjR4?pr3a$0!sH? z2pbh#ZA9AM$#LtuxpT$YZ8j?eI63{@(SIW)A#F^c1A>|-KgNNQK4U`LX$r{pG_@M+ z$QWAV8MNHnFJ1vC_MokGD5Y7kPUOxtfQq8!$WUrn?*w?xIW+tUNR0{_C@TXP;y0g< z!1eZ(>*$=Xr_*liWn*2n=|qudi+im{(-pkYaR~51^x@xDlS2G_@`iLqFbsya503_} zMc^P_wi~4Sf|~YrGw8SiuHN$3B?dRC1Qg0mi6G(?L~MFbs#Ap#F@vPn!A5Z?1OmIX z_lFyws6}3o;G6GgYt0M`EM=T;FrCCre&(q*ZLny;qLc1>g{DTx=fc0Rcze^~w4IVe zf92Z6_qqn}d;M5FMjC(+hY@c8UJl-%*dQUO0Sp=Z1y2)cFaMR=sYQf>p?Kk?x=vv*f5=yI}3^{Kz)iYWJ z)QG7cq*`Fu2Ra5-*p&2;n)%)M&5hbdDo{5th?SvLq4q4L z_gp^dfKJfzy#7973%WSKrCUCvy0Uv`eOqV#E-VXEB29@ybka#lo-6mzaz2tDDhK0!mRLYUY& z26`2x1`1I!?ow#@qRKCx>3nUSgyQzLKhG?!Q9@xmM5f!_lZ{r0^z#u`e)256Cz!Fd zb&!_UH%Q)4myApD)aapfF1x4?hb-dR|Hau`MpgN(@4_mI3L?@CDka_0A>AF)DJ(#` zTcrdR-Q6vciw>n57K={l?(Q@F?Y+-=&wq?}k9WL&VEDly>zU77bKduLUr`Xp@O&`& zhyEolWA^2>tGNE5p0Ix6Ua`8%k1>*Akt^=Gd#5hpvayPd(pEe(m&cla@`MtD)6}O3 zsNdV;<{349?5kR|c)r{|t&Bn1Q)yOKuIhIvGc2BmwtQ}6wKC0&(*-D$;ZUQS0|uVK zQ_;@WF(}?0Y*3gKx1%I9cP$Gc8sM+D&=vLE4ws8ou{tXM)BDbFSge|>=;P&Htv9F| zN0zSe&p7lk=Umh3Khak4V%ud9ox+5;xrRDZAGHJgk5e8_eWYt`IYb2LIl0|AW``--uKpG5_3WIV4&P>oeMx94T1;sCZOHiqxRIB>}k}|KiLsKON~d z=nNk}M_TUQCtsGpyip^;=;eY_;vB^uX0Kiu!J7G0j6cjL6+G*FPMM2#)z5Y0HNP03R){&c4Yr3+=% z!R&TQ5y7F^D5mI7x64I2Ff?lYrYV^wED5cX686sR4TF3F` zF6Q;JUvQWn{j!9H<4v6VW^B>rfQOURAstWBI@`ShluW7&eJBeRbw$(h&0Q3;V5bx< z6OS$ylB5#x@-pY*2Mh0}^e73bMlsH-OJ7$(dMi9Y~4#*u8a(IT_*u=@xH%q*Lu0%%4ChzEz zu+Qs+pH5&p+GRGp-16AFWWTtv0MMF}%yYObXf< zW4O7Zx)OO32lP-=T=lo7>dJ0AhM~CDx-kB-}qGD2vs668o@`W16P@ z6v2abriy2dOx(Zdj@rn>Lay0^qiIFBt6%34(epD~4pUx)2sqsbSXD|&nPwfp^RiP%0M(3kPTA;hIep5lm|gM1Ar z86AR{YH12bI<#*=9BJ>MYn>wLom`pCa?s2k0bLAGnDoQ{$x4+K~|$a zZFW8)lIbUrp#B)nTy#i%*0H#D|T-wnQqGI{Y^R51Z5#O*DW>+=6;uEhRSGVYw zZx?2lVS_S0M7RE6y}otVoR2=7$1LIbcJ-08&*6ko#n3_+X~Qer)$_yo8}wzwXu*Xz zE^ZSh$>Bz$XtB~YoT{C_)0a8e_(lu-;*kv6c1QRDRjJc_H+r}l9FICvS?!5E*M5Tf zaa+31h~LE_$~b-fc}8#!8hYfrwl*^TwF()E7tlm;zYyN8*AD01B(D-CFVDuS&%n8T zGZ>fH=IV{h`i3=8wgmS+_zx?$O7hY;J7J=5-dZ(x$}=EL$z%}|R|3iuIczMlPbTJ3 z(6=ZIub{THSipKVpX@6EMs2n zPF_|k-bszNm^3T^9OQUUOQc6S5AMAxNlXojahdHYE?G+ls?N$kr68( z{Q+-URcV1I@s;4}`b7O>h7vScHhSix+wAUjjpgc3mb_aQ2_IB{dslVhou2&^-LPtH zRn?#0WE`^)x%K5>d$#cAX^391+NB%(En$=1n}LW7q1CSZDn9D+>iAxUk4NyLl&!w& zvZwhlc*t};JJL;-0KYN&dPQ~YuwVBV_Us0Uxn~seCQ2X0G$=&m_eRxn&=uaM>zP=L z)v)_E)3!9`KHfx&l6)b}#{Q+6B};EEj3byW>q+@W-?cbjOWw^vguC?{xv_Q5t%wKD zbIlDQVkieMJskCJ#auc}=7_L~v#xHmh;V2!QA*29dL(2zv45C9*TeGiqV*~pSLW85 zA$s*g({VrZ<|qvL!F{Quw|?^}X}6gz$!*FHJ|fs1*83*bx!TPjsP z|11j?&ERl1jZocj`asc2^&{Vc5d4<8$uIcj>s)h4IqRib5`?_9D)LY_TL=f|a*5|Q z`XjG2eN+v5ArP&ddwN=#BV@oDx=Dwr zOj$1vUbbux*V3Z*w#ORHA$XR@NTrL9Ol+C#Fl zsumAVX9%6JOA4BsbG45uD-3%gKbmC1yq#ms#8A-TyHfZEdDN`fELBffLaFNCb~p$` zOz#`lE=W+tO6{gZpXU{;J=Yb{-@N^Hw@DkNiyTK!n@jbTPLyteKf|X7W&Dv<&K?rV zp{Gd6&A7+L`{1Y=&8R;c^2GnH3KZB`mYsumLxW9Gp=4=3M8<^8=UQ`? za0{xTWa3O9Nk{{W)nQjUQq@|p>O3%F(_XuXbDyj>^6hmCQJ{CW`qA!hN7cwAp+WNz zJ>i>~=bbq;;$oPKFLQ5vzwIeAG%h$6NrE4Pzj+85=(<`eQY;MqOTlB*EmrX+c9>%S z5p#eFfS6*(MVTb5>h0N-u9Yq2&Vpam3Hm;iJqsSi#A=eDDxQ}{z{pjwbz`QS06+Gh zKju$x!w_Xc=0}X}*7!ED{$y+i$niZ#YDRcDEFJYL)K*7~!8Weq{h|b3F$uKfB|+9Q zRux&-CPf-2k%@1~bK~4(WFwWni0{#{h}9fuT5T3{to-clS`7q=Rux<2dSou%qqp#3w3PH zx6=snBwLj%S@}A?E1koJrTJa_N_N|xTlvIEN{i03ja4V?6+gMw z&C*u;qQTyb2PKjcy&Ev;6D%zzv0BdMYaGZ&HpAXyVQJQ?+jI6}Yd`zHK3MdkHCYE}!)y~~G(vOJN7p24d9e_H z`kSfT2UwGcMS{eHGe0*`sa1v8vk7uB9qKHngWXKcG0e?@clljP&a&)=TAPEz^p3Xg zM@~zW+hf&zLOpE~o{|uwI-FxTq)CGyAb{Z{y}2JfrX1!10EwrAQ(3xkkmqF6&KTW9yE z!?lu%k2o>D>h}JD@o#nSUJlZBc7!by|G0b6dn$jrYrGF8G%kUrFUO8-9x=td@AI|y zy4{Z|Y-p<>MsdJ0?l{oPxvj{U{jKGHV5N=G4I==Y%}e<^-xKPP9hw5P#H8?q!JY!sS20wn>{~t$%iJ$wq(U1 z;W}+2*6T>q81|NVf{yZDM#>M8tD2}F>Nzv#R$Q%%8?&ALY(g(1Kx*K)*N1vXP@Vh7 z%icpxztG2976ol}r8j4L2;~0GKcDa3ex`6OVu*jb>Y0gY=koMltMfXvm0Nx7kC92I zW#piy$ztLv1}-KVn|y@$q130xVSgLx0?I*u~e7(vgrp<#Fw9r6DH@N+OWRj`dX229wWi4f|7T z32u1H=ZZ4=if1x3flz^B2EhWWR+wGk2MoT53Uz+VdQwj?fRb|uD7 zK!FC6QntRgtKw!kqtZ=d>Q%!8IK1TUuIzkRtFdypdyF9wQczv&@DXYLG4*uS@uEo& z(<;d(&i>_(3AD*?XXOZKIC!$3g+jN^B%EeBmJISH36lntvT|tSm2fEyJ590kB2~oG zwrZ)9ug&SfegSh!rJ6r)LyW{xGoh;E^9xR>RfO`Ew~Qm*KPnDhu)drV&iHv>es`o> zbD(Ec$^I_f!?DI-C|mnLMccLLhbOF-n)F|bEOb+5`_#k(Z64?0m^?@R{bA< z?te;d{#$GMUo}4e1|7bd!{F#Jtwo`8HLibH8~JQ+KHRca%W#J)RkL9WfFT2r-NzG9dt!++!@W2a@S8zE`?0JkdPc<+^&O5%!sD znG5Y=Ydd|oM&R1>(~^TrALJWUHX8LW7wH+n!yKn(?>v0`11qmWZjQJ#J^EnA*qqLh zsgNhkXwh;h9T0NKXuDXd5m7_dpCb-QS^sKO|MeOlwEzEJlK=S*QUd$~C77d`<-|?T z?Em3~nbVO!VEa|ml#@P+AIvyL@jZ*JZ}fq(4y{d#B->nGP^)N?`L~>pn-(_nUItoL zCnEn(I0t+_qlx`L$_ReTwQ2^!5!todrwUY5NlYa=TqTm3 zHvp$KH0q7>*Hc2`<(&b}Tr4a&3^6T$Q7xeFrACXxh6*Gi7nU6<85w`5hxA!kV4t;W z)M2!MQwKPdEG%lJTA7_Tu>zh)z~bG^$lk^2vhL%X(jk3zLNqJ{G)RPjf&MT5s$H}M zW_i8#NgBuF=p!rWR;fd#cVt_E%k6& zV47^}SmtxQN51^E(+24F%wM&-LdFZ65dj=bI?cab!8CIA&7g2aPHa#mKEja2RIMZA zZnV*EHzH!Vw3+x({cDb{frPwJvXM%MZL5Zt%<}CYSwQH?CmEdhzE5Aj{)S?)e0!ue zXo_a(?;q%Mxz)vmVa(Xi*;rA<+$P7@&GX5&ouJEH91!vWlUTRTH7T_OEXOy^Wy0~2 zPSH3(C?2rHTj@=-8XS$x4m>f+vUBb&^Pc*gL?279lmI5$_VNA65E~m#BgJrmx!mOg z6Zb5}{d-W~{ zIVF=0py9!k9<`m0*&gGkr~6~&fM`@*qA%);M3t0z+i(lBnD740AHFvw1Wyg}-NJ{d zSXyppQxCDROig~Y`6#fS|HzH4A>@wqVZy^^B;Uc^BzZ#qFLomt@-Q)n&h^1ar|o)w zFs<)|wsFQFM%I&Z*-+Q|RCXs&`tW28#fb#77TT4Nkk9s9Zx&j%Chk%R0&NcDR>itF zgE?$;%&z=0tE=C~;l}ktE|1ryE37}v^Kny!lW^7An3)pm!f&~aduxA(n1q zn4lABp_b+kFVygU;7frq^gA?%(!0|apAH$s#@xAV2Y>M5y{+_y@6eEgO`8C3e}+=E z-RO9i19yZHEiY);=K*$Y2Yby;nY}x*}l5vtxpQ* z*2jFQO*-2Rfr&gBx1oH4waE5(k@QTf>|^VnjF$0(e4aP6NiEfYin}(L7#d_c8j;r4 zHIP)}`j>LuuI-lpjc~YFWtU3#3N3loth=4CMMLMT%Y<=p(F0a|FKzjQ4mbGY!n57R zTNa0%6-}A?6!Rx^8$b1K=D)+PZ>-(5<{RH&(bTT-R`nqKK7|7B1#rA(I!l*>PZz?S5i{fRQC(oe;l3`uO6Yx}YIHteQJyjC%=Se)2o_2OK{Z@3)T zC`v!W^^3JWZEYZjrSRBwW-S!Q^OuW(P+tR%4^79Xk<}!)n?bs~-1Hf6 zEj>G_lL1uu0q8IOnDdQ5 zSNMjE7arTAu0P4WAz>ca+mrQnfTl(@q;s>e8A6(>4L9gdfQW5(fcOi!as^K163tkG ze3zQ)G(7>inbVT7oMQxDDd*1=iD)y^g0m7r7mFEa=htTMhbM$Ij6`*aUo{&Kl!?!S zj+s6MH`H>KXz_*OZsJqV=C>L37ZH=}{vC?kw(a;A{qqnne~AMbF4lPr-$o2&wZ-oG z_ax167uvo`vV@1cWY}j?riI3QUl7*ld3x&NJornL*{%N4r5Uh;2Q2S0G}8~wV%it?_M~+`D%w6HS@5}t=_DXcoTCmWX%`j3Xj&MS zid5>24ap0ta+X!Hny!7r-dyLoTb=F$VzWLjrXR7`nK%x$yAO+Q@|c+Z58hnBOd1_15Qm)28izNMq^ZGMzpbZ+$CNci~;MvyL=EZC-k+|OHNRM}T0wa=7s|cuKrSqU9 zRp1Elt-(BA?b%$%driF0>sZnC%MM!;#Co;8=wP|XJIY!n?SOnGf6bh*HKR|ru3n;Q zEwnEWLJp0QGbw$6`zYABabav+(<-tHwHEUmnN5YUEWU~sE@XU}NV&=|la9_m5sF$1 zEIT@qgiot2wBqlohXnNLZk4yX_fCN_h0o1E!S%ySf@`j>_QUpfK|#TH$vGTBYNT3_(~m>JRrK!T>meAC%jU23kbMv_%EL7YCu)bb@xN2FJ-@q&{U$(y zQ2q?2T!ey-+mliAT&C~#vY)ZE{&R5sv`dM@rn^!xF)&#^i%k6Uv z#t;a8iR4J{G{`Uje&u5r=Ldzhb8XHHMAi!)T&%3Y z0h^7iS!Ys(y0xAvK30TDD~=X3AemW2mRN9gNQ8yS?2NvOrUp!}0dHUZr{dZ4(EPn`PM)Bo zsFt0?Hg`b9jcdPr$pATB{I~lkI-2~f@$a+_W^=b?A6HpTmZ-=u*`$M#AUu)J2CxP; z$K(jv4uBGqxD;7z#oldA_9r_eyGhfc2+(VD#Mvz*nJh}?;Yd%s33{$hAwF9>I9qL% zX2%yW?$D!Jf&AzF;i-kYg7Z8?1L$?`p-`-j=YILLr9(@|Py%W&xe z9~|g^vK`WuW?aZno}-Zr7cY;e&N9+bVd{8bCgC@2&d%o`M3Xbvu$jRi@uY!$@9xgYCs%$~Q&~yCk&_=%iRF|z zs&QX;WHSLB1hnvcO$if|iqZh7RkkXh8Zh=8_6rqLm;p5z;roo4>eQ!@mQYHv_&>>1 z!Qe!tQ?C{JeN)#LLodF#94B$l>YD_?%f}Xn)X%M!*D-<@KPj{b3Rps)Gqiq~5%K!KKPmNq*0hrO&TJ z1j1SQW`7mZb#0cpR?==2PQyNn7MFv3$!x1u4xd{Q!(6dtp0Kc&!05pYd6AS;(-FGN z1S4yLlp(9r((Eq{vaZaj4cdY?I+E{S~pW66C1TomG0Ls(~Gp1CqR|! zbz^dInqBwQ|5rwYK3!~Pd?K$|HO(?I8Nc1$(pYjy@|a6iO@1L6+p}{eKm5hm+(?2w z^in&h|MFPx=L}~+5D&jHpdKG`fs zekUlqo2Q!g6Jle<_EM(On_MeBcX2J$v1^b|cN@JC@}^ZuEgBZnWw>I^fS>Mm?U~A4 zaldTF-V>>S>vpMX9AN|lr}r9xHeL7gI%CTZXLF~`dG zODuC(k{%PAJV?X>g{te_5(wG3Ynhi@4k&NCR@NWd-V#(TtapaM^F4TZ6J;i{X_-XfwxOOl5pN)~9Q&)08&MLqG+MK!TFhU>dS2C!myxNwA zZ@#$umK}Tt(K|PF_J~mJdX^;+Xap3IK7YDtmaoZ*#=r_Qo;qLvci0;NQL0bGJN_Q4lM5Y8R6PB-$vhaBBOc|2;a_qg9cMKT$9?j z*-Q^nzQ!c9x7Xi^-FAV}^OS=IYe_VTYkR$$T?%6lRiZByz!B!LDjzd$a%GpN zSDilMQc{KOvC_`1mB6T%^Cy6zfri=Z2te?|)qeCJ50zIiSzub=>(p zf`x7VPglpz)z&d^I6|uh(`0qYidG2#0eXXJtVJLXlR*whWaU|GI$8@sFTFcy`M5+u zDz~%Qw^eMR&>^uB;*?^O%H?IZ)ZbC%%$K1NxVUV}w!G4v_Gx?t<8Lrx%jN#qv9b4+ za-lNngi|?j-z#M*M!U~lpGrr;G@uMdy5M;3sOVCGD zR8(D9_N4(Al)Ac4*xniT{v+UvZ7P1p(UP05a-O0S? zA$K~;gT$J3v$3)6XOX@hdbOTSK+UrzPg3ZURuA`Xqb^q4@~J7w(QrH{C|_4ZT$s*R zDJ|zSUf^Gg*Q@f&r#A@qEkUuSnSb1AKhbKkS}0Pg31cu1^jxe!!%l~kq>D)$^3^%; zd8I+|BVJ9vzb~mfc`(bn6c)2ZS_P)`oE-J#b7>@Z7d{Y#gD~X19xrmvf0ppi+WJI*snt zHe_cr_?v)lHkh2WA05lmxXo*&bMNY0qvehMw_iY}NW*xwwYt8m-sB4^>|}_FAEjo0 z#9W@6FJKrd^=0ABG>Qlk9w>5CSO)n8yWgLURjo1d<)}qrfJ0xWUe8ss;fdSvitN?% zSUWv>G>%Xjo-Z+!o$cI_+(+MXx8KFVJ>R?h+&PZ_A!i?zB^%h0pysszW7MoWY@^=q zzt=6!2XgWrewi4XS?u;i+|6{(&-iVaUI>kQSd;vKz6`~EFWjyb0+RLWe&7$MCozOP zRkKNAQPS7Vb>60_jHlPS)3_#WgAA}cP;r);cn`+f@Zq#;w!dN=6w1bUQ>2`zTB@Z) zW*99A8Ye5Qov8{PpKTd((10Td823CPqybbEUCkk`kR;P3FCSKCZ`$CskFiSy*IP(0 z=6s(1O276qKU>(Vbg(}_+bV9AP%T6k%OtlAscNITc_61zFtH(M6l`l>%mR^dp4s!& z&Kf$&p^@{~mxi}i9Wa=;Ui6>SWpeRHos^^biquZv=#sZeR_4lKpxM%ENsl1P zaH)9@Q1|TANhc6qG?RTB#f>E{pTg^W)R>7>53|TjIqrv4ec<%G`O$Em zK5RO77V6V^6Q`aX+4Cv6dRN!*1WlYqZzREd2`c8YCzz*Zqt)i*Y|m%AQe|5hs_8?& zP~mL2yQK#85vy}BExyz4$lJXdQJxZ6OsaMxkOhLN z*WkXI6v~S@1hu27Jn7v;ZynI5UvI6qe7;)&5$sA7^Cv?so#_+A`*iO2@Q}M(^-H{@ z7!*y@#;j7z_P7l@2WOIjq6Q#e zx^7HrE)t``#6>)^0HVN~zM@bUhCkUf6cNO(l6xwr6_{m3y~vKTB{VzdSlsy!>D9LU(szH3woYp^;^1fnQ4Q(XOn{5+3iu3D1w0Y*xK#QZiMHIVLj{pE9>dx%a_g| zf>G;w;_MtwtwnfdWd7aS7XC1Yeh+J%Y%p^3X#qDnAvcGnipCzoFw#`*P&}7`_YB_n zCGUZfl9IZ5+~_ei|A#%hXv*QPEdpk!)Qz0a-O&JmVSr(Q5O>AOcTOl_i;<8GiP#ig4=2lBPoZ4a-1e~`ZTAxdj=-~OIF7+*FnFOsY*VN%RICA@#DB3ILK&K z^R)5Dg=fGzq*T-UtLor@IlfMPy#_sO@futHcvY)E|K5isGLobG=rzBy>mv+)+$FrMsyf^(X3pUf93p zi*qFn*Ibk17YnXkS#_Jqau8g~p0{G3i-(HppPI^`#+Ss6!0gG0MBomk3Xz8ctvY-o zLft?@K7qlHjGXc)c)s~wb!+`?)l2J17~XP*VW4<+CP{YgF%_KfaT7;t)7`sHpPQA{ z4TA-n4T{C2_u^T1flKAKvd^7&dEK|K{M(Qn)0+%d2Ytd7ubZ5YN#jv1kp3dTNLv+- z>HN^(tVPyP2$Oz;90Dr8o8-K~lERY+d_)FrjKwPCk;-AP%)yA41q~NlL_+BPgTca{ zrY-9r%&unqBEDLLx?`D0;pQhr6zJPY2D#GYI&*zNrRR<Qy*vPiM5uR3z)s#XOeas3 z#Uk7*P+ofP3j2Js48O8Sw0>N^N&LN~YKZub2RgCB_(cl19Vte-Mv|?6F;g9Krh0#A z-6&s4pTxGo&m|~0q(j!5PY3yN6~OTZ2NitG#ypGMisd{;*V{E;iM0Z^kO>OQs6PHQ z6ilu5NtX?Z+`7$lpTf74bRQzuT7hE%DZ2#^!zya=!4+098ft03tXXYsryVWMD|}f) zUuQnByvdZ6*9AciKruhrq7isb#>cIf6~9Hdn+Y@56Vy*5h83%Ubg!ZEXgudz4?pLW zJ$_f|LnxsGB!SlV zd%Z8vOe{2CcV9T!egNZ=lH>_N&z&C!@d&Oj^<|!Q4vKhdo2$nCpZ1qCj_S@2o?8tu zCU98auBD1}_V;frjW(;bfzU0T7N_)yRQyf!yfSvTej#^VcCx&ZKR%$e--4G}b1c3f z)WhRCJ3P$gv0Xm%(wfSb8w3f5{rVMW=lXQm-SOKeb#$stZo5ZHCE0R=Zv|H;(gTEW zzH}1I3+OAP)rt09N!|OYE@8TfRdLH2lk26^=GJ@dFRECS{OyZ%o1|}fT2y$mmWmXA zQZ#9v1cN3KAAQsl`FvNnp|tgh2;`kmiPK3$TEiL1>FH_sabm;hiwV8QSbun#GhU04 zPXM4gmR8B}rs@fR<7Xa#I{%EH<@x4sG%&hCJ?fd;qEO0T&jiV`GZXKKFNvAS5k@Pe{(YyZiHEp!E zwwd=S*6!E`83}T8O`^KYWdEK41x>WJHOFhhLFJ z%SD63kcoOo;f! zIV~edsl*MJSJqT=scQh2V(~mgp(IUAJ=|avzm6pwmbb zmhH89@z9U_pw0a0bgF-^m~Z%N7hc;X7Y8AevFrU}D7$1+fAEvQmb~4s^_`XA&XiAo z0?uE=R*EvJgcsdpAj)kL*jf^W`TdJ>2JAaHW-mVEC%bKwPt1B8^9a*zxl0 zuP=JGfrSRU1<7;^5l#kxyWy=*HZE1nn5h4b@VMJLIKmJV0I3W1Y~6kBvcu2nINJ|8 z2E|yflM6Shb10My3}UzLAilYtQ&HHuewU=Xi$IppOJ2#l9LPEiJY!Pt85cyXqRw#q z`|g2&HCT(Pl#;J^V=Vc+t#cr2SMhf4=3Tvto^y3Il&tsr{A)u?$GxnvDlbuod>IX! zr-6PW#l|;^;;oum4*8+gm;2Hmt7a1E%-(n6d=jnXGDMy%U$;fUteSJb8GVxE$?9#w zN)U_QaRhK?wZe~U3mcXz(R6T7E`k5U+4+4{O-~l*m6j8!UfO0`L*2_nbu#w{d}&6@kw-7>>?%1)?Y4G8Roe-N_A=}OUO1% zUP-j<$9i;(p0g&R&BUIBotERd(0HG~i8qvm#Ej(m6j`{7!()+Fi!B%m9Zf%z#KLd8 z!qP3!s!tgNWY;SR9nY%GfHrF0SQ=8OqZawNX?Z2leZ0f(oU&G>8%r?0Hz2|cya zX3ww!cVQIXOTR4Rdj^x?h`dh-_o{xmT#4WQlw{{wydH~$R_BZ<{EX*5BJ^5f1F`&c zG6KzSwBG?{oQK=?S*AIgK`T_OieR`v?{pP4Zx|9xiM4=-8d1@nz!kzGfoseoYkrif z_PhygfhH5*fs!RpEQX5Vz|IF@o|>wGKbB8JtO#R@U~);*nF4_6)p>OjfQkm=si^@l z==5MwZoT3Ao6a49wW|0{Mo8u2A2=IOZy3z2*5B zc@GtS6aF3gcP3{k9xot5#VUV7Fz0cQ^Pw*dL|5aa<)?}y;aop`%qky@cWd6&NqI#Y zk=G7SZ#6*jaLJmJM5;E#8v0&c>G1-8eP^ZC{PaedGMEdffOJBtH}M7S^f0VYc_(Ky zwYA~6#fLSmA%zn8aVlMCc}UYFRwLD=cfQ=a+V6SX*4{2lq(=%-Ax@ou?7|YX{q};` zSWFh-tt1S7&<(l#q**XS_pj_S1NK*~XQi=y@Y+y<5};J{^G%q0gtdsX+0pYI+4)vN zw!FF`$7^ohxAU0CLn|FV?@RrzVlbU0ARtOL2Urh_YA1!K6L|1L95@+r zbU)%-l-fGp9xHNR5v>pmAfW#C%qJ7sK<AYuxpw|n~4`Z!4ZgX~i;ru28pPHIh z{h6WdG=?_^Cac!--eq>Hd|J`D>({3{5kH88g0L%=UzmS9nLg|nRF@|bTme=#QjLza zY0Vo@&+-R_kIw<8z`=wiukEV3-%cGTzh;q@65IGEMQ4?yB;tZIm5No>h`yO~u8cWJ zF$pA0)2kFse$zAtwruZxdZz7E?wNOlzYzz%_4nze{w$7S!?TM;)bbs}Y^1~|Z8@3r zI1#v&>DSsjfGA-L_{4vB0g>#2W9hGLgp$@c+hVclbVJ{-PS%8LL0Y`lg#SYbdX<># z?+?IXgW;OJd5&y0E0t#&a#`-qkcsIbfoAr`yuH_T=c!O%%pkYid@l6+6waQx0m{mT znO~7A`xN(UY5#CTRA%xlLQPletyF(6nN&f-7!5A2p1dGt>F91)q>RECczUPDzaX5M zfz|`;@2!ArpBByEGu20l723ectDC*I(;_*wv*o(I`KROQbW`d#b?-@oqT#^R4l=_Q z-9WdSN9a=-ZyIV5jz8%&mzQdJH%-cx4PYtAyc$edGf_vtc%F4}_NlR=;}6~CiM`vd zS)U7b)0$uBIB*$=;=0RpDZsCnvZY9#ZhF!f#6*w07oUVEG zLwn~pX<&-l+hAywxe(k`G4aL`McVZ;ftovo-n-Z+2jwd8KhVTbcO}3wBNOuFWFtq#!~zwJVskRk#2DLoJj65 z&cJ)uF1X4cwI0i99za%w;*8&XgC)(>uezwo~ToU491ouXANOrhO0E^Oua#!FZ7Dd06{;n)ah@L$-_@2-d z`ddY~*-k}hzZFa={qz?(7^ErW+U6aBLl!8Ze=byQuO!OsGz6On;mh$2lx*YOm7AnK z^za6-Zw6%X5g7J*9d20xu7y$aW+%N71^cFHK=YKus!jWrZ_f9B{20 z5$aTkqLtCrYF8xG#AnrTO2GGKo-SL(m`6p1ll}%FkeCkie^^sdaQd=STrAZ z<$}jWdpC!AW)tvBq5>FNOUz}RbM&AcV~7fv)1Yms6R}#>^+?k z*F*7rv;~Mb6Sqy->Zi2qnOAp=lcO_hebLKDFy2o6wMMC1Lf>nrOdOtDk{;10SMk55 zfT;&2bJdh@5S{3S%H{pQ4$iL|TLPE@nZLQKhT|!ca@3mre&hyeIov#+00W}SyQkhX zDy@j^%7+>>!P!B_oTH_6rT12BO~9$aX$RcJe(&8>Z8kYaoZiy ztlGuE$JYTeXCIrn>;>bAY_#=(ne*opXRP>5Sxz!$z`soK&*$;7aX>W8AJ8>jNfYgD z^}`kI01ytFpCMBHDIq&px?VU6IU496E<~5-{)M?#C03cww4iOPWj|R0XGX{6S1?5a z_z{(zREW*H{3rgWeP+OR;1=wiY6c=S0BOj3vKYw5x?;;Pg2Exsu;D!eFrfo7&wGJ0 z%Hj>b+HA#l0ime``vw7dXlztyB;4Nem`pV_ga5?|@9 z_EKK&E#dy(O)AJ;;0OesD$(LuoeLuj4pcQf97Ssbsr(-A!@0<6Gtk~n|H=tl9%u+h z);Zw^SqoRGd>)Bl`jFE6aWh`|>+d(E_sJnB1iSMtF-rx93|oN&aCOW9KQN_cd$|h? z37D@}68mD5!Us!g_GU}()e*=X=0p(zr({sQNbKR)@yH_jw>R4FOQ>ff=}pCnnex(H z#R2DF(P{j-Rld*Bjm}TUAmS;0M%~-$V+DQ;G_L}E@1vdAcspR{0o>LA8nJcPW+sT! z&Fc;+Wz7w4alcJNj=6$v@Q@C^E3Iu_u zdZBlJ5(KhAWkJ*us8p=A3akx)JYKFaRlq0LcqR`psJOka9&spmiPI0X%6WX@1-v+b z@s7N!vkY7VO6}9r;|W0~M;!(IM(>Liw-KgKW%i4scWqP%p{2< z{XDmws?V10?M+KlP!9u-J2&VPDq!WLJf>8)o^atybKvb|c71j$ai$L^;kFCww2^AO z^Z>jRkOMvH@TZok`HubW^Hh%YPh!o&TF8#sM>sIa|C%@OG%PKyCkGf+LsUzV$37Y= z#>{2`mjTU6$2BGN;+(Frx<=;49_3}#>%BM0>U05T`_p;be-P_GcLY77 zT0g<`>4tUKP2q4-^d`MHc89z$VixbS!peA;TaWhndFzuuY2Yxz5fE~M|4=sO)R0)NM63E7*eJ(sgTZ9;p3ft2Uxxt#Y!dB$atRTyPW_1l7P6uS^rv{J{G zppmp{jQwhsIs3_|j|^k{-=m z{i*d3&~VugB@qNl$#%|z2U8Lg9(U@wjsC*kAkp6EJw2m#5f{SCwLNo*DHmT5tV>TM zLmvp;@ckwn*Pl9>>14dciEtEGNN*WjiYx>uHvsQBbMz-bgaS5gLVW!lAk*QpZA2YE z2gaSU7>>YpfW6wGZ=+%-yk|6t%hbfm>Qp;c-(5^UlJ+fYa2^7{#-l#*#^Qi(mfsiv zP5Y3&xXro$ciL_R4GBEj#nsVG@k7{uJo$88I#;Wmr>e9_8|ukcQC@9tTZR=zGvcz# z%cC*)0|12UX&7G!{s=H<(9D9Ih;tGinCI`9gmZb8AIlXmSb3S^@IxyauT!+L(8G0t zS063{>|H=wn;%Fxj;GhFmB?407}_CV`+?4uRlG>lQTtRMSfa3ZWp}tI-A@2lX+R;? zJPMJgja6nM!6p4rj6cUyt#j42@FY>*c(t5T&jYAGB;wxNUMhRlkt!uU+)op1AWGTiV3l2u%^Sm5oM;HhQ?bHuCU^ zi`G+kz?teWC;ys+8J{m5Ux{`m3&D~x)N zp}S@NhN`=!UMApJZ18rn+8dssgm~A0Mz?+wBdD-k?G@#XlcSn|F_2OHQ-89+X&+dU z*yy&r?&D6rR97gm>ZVtgtNDpy!MB@>NPEZrr&Vz;E$A|%7sGQWuqyRC_ImDcGlB(`ktL^ zjynUF($=dT;Ijc>HVGdhm|YSjq3z}S=FF5%ZomSbcG)mVUOO$;DB<#&w})xLEP9|F zefL==i63euSek#koJ8oItbt1J+j!TennG%&<3x+Z$0Mar+D)!J{>ijBz12H>%=Gjr zxNhe*`eHI!0dwcy?#dorI;e0n=$!3py<)$}`J~vkbYv6jlNq|me;w|1c3Dq-^`5J# zq587Uby3f^lZ{+aj@QffID+LQkn;5Ld^-VIh53}x(Ap|sb?8@{I>hx`1EbG;WSNkC z0DS_V@d)%9_=7^=vdw!+n0bFN+_;$y#^e5Un)#;RD_=3OPz}zmcN_I9Un1*&m5kP5 zG4&bqDF&qe3amV)9N#BwU8FqD0o4YHb{Bb4m9#~I;G*k`<2a6hBQ_HOQb3=^yIAAC z`=a@C9=>@nw!)JtkuvZduODa#B)BEe;n$0{X31+ZWox@#%%%MsgOibZ56~7p-lpUL zYcO5$-_&t+6>V9JR+An(@>fY7EoCw97y5_CZO4kqXf2uiW|T=$5}&1@s}{qH#(+6H z_FY~B$$BmpFY@VT)&2p^Eiw6(HQ!z9*aEP}?0}k9K*6=`WV9e2+*^ePb6$R#x$3gR z?YTZrZUMWxC`1KB69RM=Hq11)%PW%IOtli7Fs)tCDwv~3m}2xpsQSLI{ft-GyYU#i z0sJe3$YM0j`}gAv$>R;~w=c&Wie{jJ2?Pu})xQS^10ETU`g}M6aN$(tVIpn2o7f|i zs$3sN+RsDw82PZR^aY<4RX{7SGD`W~&XS$+CqnaB!_j(SXMWY!_sTi%@&-(Q-Z4o? zyiDKD(KuXhl+uvjOA+*9N3&jI)Z%w`U0ArA@z3+#AGi*8b67Qa@VfQaYtt^uoK8oV zka=4$IR+h#`ZbL2^s*z`vst|VnJ|ezv)v~+HJdWEg3kfC^}wyu6@`)Yy!Jm>K!DA= za81XP?N(iD=l=~}4y!L^cMY-CnVCp+as~qq5le_X6(V&|+=g*L8N#Cqa_3uWc1cF_ z9r~ktvG6K4c0OAnhbHNPlMyfyP>=ahUI8i}NF*4Hq6gP{{fdVEeRsFytrI5jL0MVb ziaHrJ!kq(Y0N|FI1{6%M7(tg!_wkjK?qe@_`9^LJAj7+CN9;s0pGTB1)qlIR4^LvR zfh^vbq1$NIxn2PtEAStm_c@{0G}BOm!&T2NT6{pR0H##|f(;ZmmMMG6_ny)hlN%(Z`i-=&15&-xoxRkhbI_WeUef*BW0gQ^aT=7XF}gG zo{{opYPM;OYK#alu)XZq1Y|Vpsd66>p5ryji1OQZs2yaPWH;7bD=mwlYzvu%%;cK}poGnP>o z*3+GQoyeN~u!+a?)Yb;!bWqH>p9dwP=eyh*2)smeDN+)%7|Pdp8;7;KC?JuuWqp;u zH&l724o`sU=>q#AWT&E3Zl72JhItK9NCM0oF=)OV$aQyqiA_#9ss+X$CxEp^WBimw ze#u1icdb0)=ilgrQg-jZ(+&n)+bQx?4z~*p{gyNSfT^jemm70-pccZ`Tpz+05tA0F zBCKyX;S#vpKgxcvNxbm&gD?pmqJ*Th#ddY=2Mu{4|H#T4nnFr!tmmgqCr?YY_->D2 zGcxMdveFi_B5sXh5 z)|zw7F~;<;tPt6nnf(lL98(0R{qB%HkW@&xH?RpO@{)WNZQTx!0hZc5%HY+PxHi=h zw~n(^#Zs21IQ2SCKz5rsAQt$V03s@M?j2Um7_dAlHC?_EwGhhqI3hz5?68?NBNR^v#od+TA@9H?l^5nA#IaP=5#o{{3~FlhS=N=7$U=d?t9DnL6Yz-M+kKpLqP>2AfroD zs?&B@CA(J8`Oq1jHZ9MyrJv9-D~&4HdE?tM!`mQZH%!XtAHdg5DoJK-m!nv6_cqmi z$zV$X89JGR;%iKM{-Qjr58Z$)BjFIkQ7Y}+J$>Q&`KFuC?WNcZY~M2T{_6 z;$BT~(*;}G56k>|LXQSod<<;i2u)<&h4jU^bzLg*YVDdPKgVd^YNs&&SFcxIOlpT9 z#;PDd8^z6Gc``qFxC74h$kv;tA6%wrfZ16(pxu=Wdi1TM(s9zj=n=l>wQLnB>jz{z z+Xna9N2-Kr-F~j$`Jmg5(A}=Df{AsT(P|aNvGyEbXwa+q)JZh?7Wb`oUxS3{HNo44 z<~058GQa_1+3j!xVj|E+#Y0Sq=kW;j3!O2n=RMy;TJIego3JbDUe_=&=#4!is$9EW zZhbuB*wP7)`+Pv+DgcOPX2rVT?T9c9E>7gqi7~T{w*P z`~A@Xpao0qHe9=!Y4@TAICyER>eOL+L&Ek#7r7wB+ry}eBp0uZQa&D$(8M+Un!oy! zGdg|7$ZLPM-5cz^ztBsBS0$!de3{zy)Ez`Xmtd>YZ?5^UA}^t~R|g;z$v-bv`KB7T zQ9VTUYgHJJly3QWF{?YwEPWSCj$MBoM6D5ol3*VJ;5y0vBbW0>M!^zY8L)#h<_@R! z#z(fzv&re0Yo~TMqU{?hx~#AIvd%uBlkrHcoVEA*Jf=JQMuP*@0eOitzwJuHS6O;r zyA$7m(6U9$F~T3|a9_qkv}dDv@+UlU>iNexzIq`o!^%7;r-3vsu?4upKYF|N=dt&ntnm(g6p})=l!kqTTw>JLM1q+a&kT^q=@R` zkTqw#-Dh*HNk;Q7248U1Acu-4ON2JAVh?ouhtgOePWY{$YD>7CUL zsYdD#I2m>vrfZ1wLa&#yN_p%bN1p$Zv!vY!k|lvUBNC?F(Rtmj;kgzLr)pG!uH+_Fo=Uc))Gv3ennrjteTt97Wi+oc;xCC-=@*luTT zIIFz=@WJOaQ(1TZAz-I`AC1{`_RUUXu>yWh)n#oy{OmckBE=QGseL@gNWfU?{g?KW z=i#55JII`fglBD7ZzjHH|1K;}$LAr;HTqt?=*eL8%;!+0bUB0lbx$ymv<2f$?Y%jf z!A+V2HX5w-SNJ&7_53I6rhY}$qhu+@D4G|`*0gU6xHyfklEEp8ZVv&R` zWol_ohzYacX<93M-7xhz*06{&9w823~IJd{MXJ?lVziZQA_v4jHb@2Y!Cv}ab z!l=WsD*h|zoC||y#$#&q2qNDVMKMw)U_jT!MN<82D`EMIFRo(b5>5@dBz3U{R@;d! zua;u(A4K=I0}148p_jC{rHELrQpu<09$qQg`&vOXJUmna6bi{~n|H2Z(aUsyYyUjnSv+6es975jw&^NH3)ss6A9x)*S>bH(2cS0CnRF*jf3n`) zi%T6tQI$z@ItoT2rD+-eKhfHy9(2yZ8)i|0laa_cX z+aTbXhh?z-{Yo34l0sC$?mF^QxT)jT1L-jN*AJLd8>`YojM=W{!blT&hL4LFiJd8L z+%PS)c_00 z)VZKhDys=wq93a)uC63qW0Ev#=Q_PY8;5+5V{&AUK?$O@N9*u*naI+s^ux<9wL$iDU(uch+D(~J!!4bio& zRTOD}_zXk0YwuiOQP~M6alzER>ZM12eN?-4pu9c45O^bc$;vXq#aX=S%B$swVDRv=+!YD*9^lqCggIJswlofDRW{< zQm3uDOH$;jZs`fU7CpR{7=x1k@E2fncs+FN#XVoDC+4P|pZ+>;7yuQfi4^z4Y~}2( z=OZM`L(cn$9;t`rFYb|V+H=`l&whqM2NS&5C{lKlSZyqytA&AAkbapcC$|AoV5MUYxvV(%;p~%9lbc@XVt}zGU0k+HL zj*x48#>QZTbsGz1E45afhW|s0wqS;)h$pUeQ)n*THnMkrIjl6N+q6{I>q+Y4rWSP_ zuE7JAiNBKDXOfR{`^NMo4K~2R;zhn~ba!-QqR*G__jQ>5G5eamH2QlTmb`{_xJz~y zO?^@%m4#0>w-d9#SK?GBIL-Wc`YHDU?kk??2UcVyeynx}Pr|81qfSeYx_uk;2}(Hz z42CH!56V>U_gE9ud-I74xO#YQwpWnW5dD{?FtIPiQ)378>TE z3lE!{W0bgPA-8h56C4~%TH`_Tpk_1bp8eLc2G*#&u1k`c(1}cm(P4XA^;+c_ae`PL zn^`b+Z8J(IPXv&wtEwMlIVE}=cKdJSai%Mr4h{jpLiA#HK6rc9j`zXgiGmtL^Q28s zN()_3AEB$*%MPUC@oevfG246Yj%{Ra-*#bc6?mOxq9XadlV$wb$?l>X7|WcYg+{@1 zo@i1ZRx3Q+x!pQ@!%vLLsAuEY&P0GL57FxX=lfTWcawuLCeo9PnRtS*Y5d&Tvo`HB zLUs=vx4(wnhPGHyd=@^j*#R2Ndk{vOsj{(>rcE$J2)?$GcD|WN)8UG#u^FE^w*FlBkbYfn;dKE@5uf$VjyTMf{ zHq2MVs9Iq~*bc*$w5AZ-%wo;O_C)EhsTN~tpDy8+uDVS%D%VhKa~GX3^EQmoFA($X z>ZgBftw4 zw9J_bFeT%262+@jy-#$rH$&x%QDNak-t?!{PdDxq+wQHmlnX~U^5#F&0BNV=RBb3x z-L6AoB_T-7+87gQJ0F9Z+t2#vWacOcW$!$50wrT0X;%&!^pE8Z7DaYVZt(sZgrSG` zUgv65*YU{H{qWec=XIYgbH96|+cl~&x(uD4?1z3}tkwoY-rb&+8YoSc5oz?;(bvnj zAn&b>lzU@4L_{-6`qnaSe319ujGV2+i}yIXW|8BU4ZBaUp3c!h$u}4|=p@~2Jn82g zrBg9$ocwDAgtRg!WWZPzv6OeA2po62LLaC9FO-T4}c&lz1YZJY`3pC|o z?)F|!sdAh|CV$o!<8dkYmQHF};uT%Ei4+aeYXj}hY2rhvUB;I>c&04(o83SRh0kW- zm7D868b{){AL72A^lZaK1=!TAML(c_A-DM>)5GziMy-doz2gMn&dlq8HW@gE&tH+- zd3`i={^O;Y-UO56r-iq>Dy&HyxWgw+Ki*1=R2AjTH%u7GfbMFAYbrqahJMk&$&2~; zktUGzO)q7z5(No+A-B1WCd&1NJ;IRaQoH3IcaJhPuG%G0wQc|y)-i0RzR+h14n$Qo zx|!jG*t(5qhf24#I0D*aHj}=zzE{TQGgX+IDvP4B)Wy?L`Gze5&NZ!7!-Lk#*Sb!| zoIT|`@#}1-{A*M$@Vzja`jlVb-W!&pdoi33e@~ah8bCS_#c`wz`=Y}dBJLuK#L|f{ zTf8w9tkrh!v;(TIPWK~CoH(^B{h~!+`0>UI^S2Gr^aCtT8&e0Xir0ZetDf%|6z9<> zb~6-O_Jz{TVs&d8n63{{#c5zNAF_f@K&2@StQQd(wkf5*W^Pu6tkbXx8!CYTj z2HHeaCIW{bvPa!2B_5SF`B^WY#2Fj(zL4rMF*3Sz%^ZUt`&QvPDtl@Tn!WA7-UnXz zhNA4%C=h{5yqIoWJfl^$OCn%j2Ajz+e=#Aa^+@dAYlbieRq#InVux(#+A2Ccc_dYP6#TG>&^B z4cjX>3Ap{Gy$M2ZDw{3!$b+o|9&@^| z*ZRCdvQ9s=DZG`_#*zsAERp(K=ZlF~-njC8`@CxhVx2AQ*V*Bll-;)H4I<_Lj$}wo zi^_Dfm!6Y$bwgu)<1n1zr*c1+b}yQ#KiI^i=<&^G(2ot0QRI-`PDSP27v= zop-41&G&dI+(Rfcc`o^N98c#q+K8jCFAO%mJ9e9XRv7~3uDEUSgB=}Hz#T)8t$l9p zp0^1-r$5)1;q<-P_=&jF;#6f0Nz`b?8W?B`PBiVaIjwC9Ljy96nAlegeTt8<$pp!I zQoFU(H<8KsJ>ID2>o-p5uVuC`cX0e$HFlApEhS1xEC^R_2%L$?-&|(z0?U;c- z!1@=V1RoUY>V)owLD-dzB*2X=!Gz`8*ytuvD19it-QYeO9Aw&V1c;B!qNg)5?<*IP zdDm2UH`CQwl<=v|hcymYSM;W6)X-o){u&|7{)CK}$T$&h*ZoOp2D}!5?+SQ2=~{bR ztaVP{zCtUz3JI?lM+E_tJ)0*ZhdQb426I&G%);)*D45=e_g zBpE{4vz3yRmb}9=KGo*QYq0al5_7&=?-WU!gBlV?$1ooBp`81gJ{8nr4h1DUPewJ- zp5dPC)l3a$t;(_ZX1YYKaOp5~XT!Mw{2@xQkBsULOIjbDY+{xnh6fB(nOUpx^WGX@ zago-L6M>$n?4zZ{lZ1$3y+z;d>xxvAk;t>G+OH- zu2*HJM27q;863#R$3bEZR`q)#Y^owU=_ybBz~G!K^a0KXE5@86DMPpInWATVRd7nU zZ2#cY9eGM~2_DKY(*)9G^{^89USJ0|UQjEWTTY}EDK$au=qrKO6STD?<$Us>V?ovH z(oA2WrdB*l)zK5Hj$3rUsi`xLYG^0yrGHrF=pT3@f(I4@^i?)KPg8iA1zFkb)1(;+ zYyOi&8gjoL7hP}wlOofvdw&bjLLooAVY{VDiRfLI4drwmt>CZwY$l`mR~9FjG%H)WKhuKueW;{4l-gUm zqgSC-OaQ@v;og#mVTYFM&wb@y$?7jTud@RJ@Qa06t5Mh5Nk_9W5wGJ$K(}Y{sJN`S z^J{Z+v)q-OmpTRnk#O2|TXmCOwz6b#iRK-Z3vJb69R2zQ!c{F^zL~s9_NoR1!Ph1)%O=ZK*6zL z?1x@Lb^zq)d>Wtqjv|3#HEJ`;tRs$Qe+8v`)8mI6-H*C6_Nm!t$Hpd2i)ZKI@Z7x= zi+?4(JnfmbwvR%(9&-2#u$5n27?$8U{Mjp8!a51oAk)lhlmZ0!_{7OSR>mCGKBslD zBqRNiiJ?GnzeEiKsEw!iHE63qdZlHu>=^8A;hXL5^l!v?s-7MMT77}K#Mrs5s>5TF zp;4>aN3R;zEo@ezl+{IQ18id#_GzMw5{FAJdmL3Yr;x1PuSdfc>t>*(i{AJ@fwm+H zkS-V&t5T8?F~i$58~Zg6&xs%D+fwOaGV|FQ0y3kY|APKB3p3hZOWXp$jK%U>MKnP> zESbUgPxkr(Xi*Df)6eGgv$C_jF3yDU2G1uXrbeRSWCsrd6iHv|y;u|LxKzzA(n~o~ zQ!-^%JB@p!JnA>gJo%+%{DSewNdpDrF~-jo^Yv_(^zLSPIv+wwvn-68}6aTm-&5dRDW(@v7l|z**)iGy1)y8mnKpUS?wP!5AtqAA^Cx;<7Hriw&O)#6?dj)5b{YDRie$>npFn3PRiI$@55Q*L2$6=0%QBI@z2BV9XtY zR{=OO^yMC?LxJ$V+B`=!O1h6J$#}F@pTnK3^IN!Q*Z+t%eOxt<7y`5jd63WF>v{>H zS%s4`-%HIy7RdK^7C6-Xdo!|k@`t!BW986D`4v4zExxMGvWpKkKNI7dB_}RKe2fL22qW^JNiFB2*Ql}|Z0DR9N>2oPv=(Gvg5gJ894e%}4QWj2)*BaqC_9d@v~#$z_#WRl&RBCX zUDf?g--xe_vTwQ#5vUxJ=tc+nz48k&VR+lWJ=VTz)tEO1>nN-}-ptZ+UfUAN8%lGo z$YGc`^jS0h=N~M9w*c(UhNm;h?D`J89)LfQ?)WX(8p$jNN@}3vvE@*i?4Xl02cYl` zUS&`RPV4m#!`S_JMK{e8tq(!LwJu`FxK_uHGA_8)Ex1*~Tez_`U9#w85uXABuRQ>V zSu^k#A0XSqQJ(edg=_R^nOZPD4hd;z|4m`&3_>F3pDZuYpe4CMz&6_(?SSHD@9^Pj z|M{ciiE+X^W)?<)m#YR*C@Qow%atoZDQ(SrYhXvkx#t^jGN>=%u;^kZqcgn6|lrw+{S%28+yaFXL~IRjc0x11ke?`v8*6 z#q;Me$Guxdw41SC=B@e^7 zcnb^(*qlW5Lgcr$#A{HDOpM?(5icnFV62X@f-PgmIT_MnMSI}K%F`>48WuWs)FT2H zt#VHS+EeU6!1!n}o0aQ!n42i5RYy%Z_w^s29k3-iE8E!CjIOcR7$<-#>$9aUJA+pt z?Q{mFlcC7AvF3HwoNu*m^KF*bPTk=v>H_cmCrxo#a(K)B0R&HidwH#vySGnYuab|m ztZs!`I|!qXo-!c67k>GwUxL`M}D4aN_u^A^2GhR4%7UW*dl#_apI>rJ*6a@sv(N z`H#0(Hfkx)Z0&D-U;Q~Ld8n3`WhKAELoi(7irG(wX!mBs6+1p?800=x+uM%WXV||G zjuOS5RjwN91pTJTXhu+I@+5Gn#`SOs2<&!;CLynZSUCV6K#YMI6S6`j@b!ZN;*yJRKc=g^*Vm9_UW*7IfY3nH|QH~P$O zbfg#zO;F+(qUc4-aUh92deur~{gG%>!CZvRSx|!~jNL~T+)1G){J-5S(15Xlo?Cd2 zwze~^*93y!Bdq`VW0K-UffAECF20dGYv05~SEEPUAdT;MT#`}!vV65P$x#$M3E#M_UWanH8W&^3+?+yAz$UT>#VK%p&Ik7Y6JS+58 zYj5)vmAM&oJ|BtIQQ_3XWdWNrt*SXf@{QvJ#3c4+`)kl$7}QZs2L@kmM$1o0TkKv6 zKR?j*i&`6wwp4BfG{=E?39$=>h}?j{y1HBnT;E_P!8MyH0iv7~co%MUy+{WC1DBsM z)}i?X?Qv}5lVDuLa5T*4md;~?@m9;&7CvX%UeBENGO7=`UQSYS-7na!dbk;5%04A z_9A#qN?`_mfYlx4X*)xWNG(xrOF;o*S4yRGh2X%+Y25gpzG%>>C`)Ww1;ucbydDR7 z?%?BXE}NwT^D?JAmqdZ1aFvFr{&>W5{0RvJ-x#)*{S~Vo z!dtg~2i>#>s9lzT77{m};UL-nE<;Oq(|0`PlP2#0#vb$%RWHpWY zlb9!J_Spz;Cta_U0W+rIysZ^rG|WbVhg#vBfOzxFTzfg>0lESaNSb;Lt4+Ag%h<)) zw_liWv`yD50~`kbN@)Fl80I|vsTc|Ownd-elK3&-l;-+)^47(w)gWK?KQAH<9g_Qf zdPMLvK}@nh6F_RU@M9;N{}PAkqxtxY{^ItJg&#qKV?n z(9F0$mZy05PM1N-KYhaci;8Ucoy2_o_6qIBZ_o{wF8S#vpPLXc9c^W85_J~Cu7%E{ z@_Zd}oNHw4O|TP;T7F9!(Hw+^fpxV)*7Hks=-{dJfU1O#JNmX((*toQn$B6?gXp0n zNK2>m~Jbjx19#}(jL{KR79 zF!u>EiBEy7eVSwKm5?&v{1;{{OMhoWu)B#N+3VY=md3EDlh$0E6GZdM<*r-g z;n7a;)Is{%0~$v#={bj;?v<^<5s*Iv>~2mpPxr-P86}`cwL%^xg5sB*_+J6o_I1?Cil{AcHZ*9C3#~GW@eQzXDt2Jb0 zeRuyb@%ltf8v@9^A=MU&CWUky>&Mi`i zSY_iY_zR6=D5IM^KXZE;GHaBwX1N#Rq_Q}HVd3|}EQ2tFECDi$T-mr0mdCS8jAVq@ z4mXFJmSWY-hx0H1m^S$j!Cc}dgZ;wO!!A3_^Se<HH&w*)q@}^FeIoKDwJ9+*YiA z11**mLLNB2MGJ#Lhk|*J29y17M2k}Q+yV^T@;@fyakXpw(0{wFa6I%WNWVDxuT=6s zmR2m@%}R-1p5UB;7)#)xgk8G^DeY6+eXa^ea6?x-eWo!w66g(UVA6rKqcp-;)Ure9 zxGd<%$IHuxz}Xz_j@o1eWvyHuEEtVrLE$)z>~74d!@2HCBzyQU_E4o`$4% zp1nZ5t<^igM)v(byoWzY1<6((^>bq=g-+i2*Di=)tU`d=6;a%i=9$>WP*70F##Hru z=vW3vWT4}JR$rXDX#W0j0@2!Ocm1gQ?NPL;Bi0{LpD0_ID~OBuBaJQ3{c zw$snrPC;#`g@QS&1a%jyurp{3y`641MniVx{PvlRJ{i}$=w#2ABP0X58r9Ffg8qcS z63)ApGEdu6hC-37RTqOTv0J#6te1%qUdj=W{gFco=KAv1i%k4~^4dSk4o8<%L-A~`>lJmi?{^yU@J0@L2#b9@jM0p@|KomIC z9jufCC9sh+7DaCf2?^;MDBaSE13|WxeTQ7JsB(Y`yGdD)U6w?V@6CO+%bU z%+98Y3GR3zWJ)@2&T^W&?bR+$om2BGKKnW$WuYs5-&s$LzK3(KMC@5r9aGiVbBIOv z#8H;w<3#z2&(O!l&05C?TWH^=u>L)RiWB@l#DRy3gkA+ zukQ!i+kfD1kx}@H_)AE!=FpD{oEl`5sI*IXN491*x|#hT=>K3{z(E-B%eTGCW#e-+ zLlaQgcX9@j%s?E4w{0^i8d#?YZXW}5;0U=eI=I;-49W$t!QI#ySFf0noeBxc%_TFJ zQmC%84k&^n9;oW1g68DrU(j`+Er3Gb$_Fd<&*Jo<86k7j4BTvP1H~rN7^h3bV#;cu zfufh^p`vQ*YVB%cYYk$OB3yL83uT-xjwJxE-D`_=r-`1FLYIT@wcM>XWEQIz&&+1T zCB@U6cJe8>DfIss>18B`<&`r0Io-YiYLA0CX8qVdSb(>zd}gmEf8k7Do`{iA#C>`a zLA#owXa@qpbaKk7y6#rIRy`|Q@EdydMF)CSGY5$ay8I*-Fe!Vo)h*plV6CA`4RUa& zy`?%t-=M@8b$bL|b0^V%4aC2VGMb-C*a}c|p9)RXwxH)Sbc;~gP44-T=K=f5@DLlb z>grc8pT>u4C4z*Q)$J&XIVuUkn5!pY%nqD1&UvTrBk%Ci*o}jpVB6k$EQPQ?@v3hS z%@$y*iukNu_eCTp@qCkmT73yD&Ph5L67TKqR3EGu11R%2zFJOsiPLBwEX}gBvz+YB zD}j-K06gnI`lTSPh#lmzv+ua;e96^IdZ={E%9lbglu2_Rm@Y_JZf1NkI$-WAT?C86 zB4IVyWa@60^oO_sdZ}!(F_FqbAP8kHb1K+loO`~<0Tdy20lE*&kpUOo6-rDcR}LP; zRTAUlxAFW!dY)q$3MCsOH5G#nWR%+_4xF>o4e#UL&D>DEY^eC^zl@Ik{J%z*<7j15 zVVPEXX=$)jX3guxi+ey$K7amo1c%e7cn0p_61(^+XE@)hZV!NvxpjL@8t}w_`3t8q zB?8$3*kp!Z(u~1wZ*1%e6qTU`!*u-cnIrHOa7h$?kB5CDP}6MpdpxUd^T}0nxeW;I zhCx5>(_(u_GX&=*lz{<(JZ*2LRP@O3z_-DEaPD1$S|;?y1vYn>m1-I`S*0XKg9AER z_xpcE;yeU=@Hv4qQ0U|AjNWe%Kpz!G(3N z5L~sMGV(DW<&^5OCEh1+V^yz_xL{lNh1BEA`auc*H_Eh1^7NK95_TD1FZ9VoGR1x8BrN^QS*n(V)~yQ_G@pb?%B zlndERfBJwsj#P3NL{-pw1Xgm*8`kbhLH8?e?`m17mjzu~QE>tKYA z7y7qe~beQ zJ7ZnlSn2}j;zQjC1gXDrqI`>hE$Db7lqIki)c|=EOL%$Mo4sS2epLi4_;&#RsXP-1 z`HU*E!zWhutI3o>KXb4Py9j(>Xnw$LA*hXBj`i@+^n9A!cUW|AAjX2^N>FG)($qngBORhVD(OnV2i(~?vq{9~``H%@URCfDED{~t zJ`j_2=2sts{kdn_f3({$w`_x`eX=&oAOb!ivhIuve$Ktz&q>d9y07mOUm(VajK8v9m1a1b-qj+q-m6^UiwFZwUMbMAW(WYbh8^~s7v?hpg+t|1s zID7ltxvokr$k=D*L4|@3n;begH8(JZClzfp^R1?XSzug!nECN~D!`K6_H+~VM^$}< zo>mb1cJ2J7>lh zrc*N?gJl#PXZm0%TT$T}aRs`0qy`X;ht!M#c?Jk+93H5&FHmd}b>;zlZ@6^pmifiA zor<*M^V71LBM{@Tm6jtG4JR+TLB{cYNAVmc2U-3Si;7TAC8{xrKs<>aUVf^e{>K^bom0b91=!jjm(a6G?tE>1 z1_D}~xMIZFC<9?wTYSv|xz-!fzHu!hsdGToP6w2=H>r2u6>}6+i$Oo>n!G z-@PHnOcZ=claMRD73#_#ct_`V-z#RB)BS}$%b%YA$#u3jjgR;+N%8MJlJ3Uf;URuf z|I=jI*ng2aN-a4C*#m`zgrq|0=!vz;oSb1l|3xKT*^%O=NThIHC))$?*P!stn_C0? zU6ynQMf#oKpPv#)q+@6rEXscF+@*xak9bnrS?PgL^}A|Z%VVl-hXrrEsoGc#=5{SMOV{~K!*hov{RuRyoc?CF?l120U(XrB@2egJ#>~wo z0Jzc;ZMeSy=LTG+{SVa!rXZq?yW`IJ2YIMWoQC}Xyeq#|+GJ+)Zz}eIKclwjyNmIV z^F7Q}TZQw(FAy?Xg>+PLQJ3g=^%>cUJ?)+@LxnO=jwm!R4d9F?H`_rC%mnNM}~O0ay8#D7=Gkc6a%WR>3`x8VcWr}EU|1n zw-S@ZMTK|m5}0D0pb^DDb@a;cr%&1gtTs=d>WP}lr4GF3ov@j1F0&i1ovT)c6fX^b zn&~mSb>neS1r3`Ume1fXWFfxX24m1${DVp{Y7_GAZXv!O&0%k z&Lc1dQv3WZ&j3bZV;C0AjeoQzf9OT4DTzR(NFMMz=@QvV&;&Lm?=A*|3UJ|@jgNhB zTEbzGzQc${zxTx%1LFiM2b%;<|38dU!-iVpf~nj`#m3W+XNp}~%6rn?K}p-eoweo+ zA$|JIFpz(4#!j(3Hr+3EyBUgpuTPYtQOypX{a^qDUGyMM_Lj5+DqI^6sM@C=kPTM| zV_dyu-FC(YOYXG;2eZ_AuZmay{)_hi0(~e-DC9Pw6xX&S!MNjV+1fh@Zl_v8W3Ose z8;3{gbJ!d{WmU@rB4WCT*+%Da&n&n7vCd3 zd#h_0y{P)NUks+di{AJ0lM>kjZ6=%PHNK0cD-D-ocx-5fldV!ZRQW(1WkRKdxyCeV zHNGxlP)OV_@L2R=KloezYA^Mj?DDs~uYY9>0nc^aIrJy6pSEXa+YxfwEn`VOR*2-?{we+?S9O+3j0_!=wbh@508!SA=6 zYE@_;yW!J|=Pntb<9|L{n{F%P%w%(eGaLk{Al-h%V{g+=r=dPIt>d|Ap_%jgWHa4k zIFQ;FDAkFvur6MZxPMQH&!R@=uKz{**`Zo4)6ic0qr@!KnJ*_ zytWG$aJ+%4qpyF3_^JxH50rL`cJAs<8ph{J6aUH=*FOJ+ycH}i{LD*NURlzKp$RUC z7=8f4CDu(T1p8o@gt{9->HZ4p7ko=TC_`VJIL1EI(;{#^u#S>q@T1uyAf`jP0HXaD zzM}L&ZSi*oMn-Zvzn{H=iQRdPa--v592#!d2gv`IOFSh`005MB(OhQ-i%mFmNAi(V z937!AnlgJ061t^M2)c}}aCZ_0}xOU9N zdIl^V*1KHs0*fA4kTPCGT6OL>g!{GuA;U`C$I6aKFN6{HwQ83Q-Em>jX7Nsr;g_lg zCrACCosb4{&dlDE|4k1TN8@3pR$mPGT*-Dy<89C_T=06&{%H3mpZH(nSuIv%vVN?I zM+xgwH3>zO#7TMuK52G<MC9UJP_kz4ZdL zIMLIbfydxWzy5sE{;&OAFZ}ry7FOQ>)(!ptw}!G8utWh=D6zK4Jsg{6sS3{-(kL-= z)t39~1QynFrL$v1(D3z^Pr{oVaRRL8Xu_f;%`BJOyI+%JEV+wOKm>mRH~glI+yy?4 zf0`Ay>uikm1uOy2S6&GJ*rGkdRv=9_ReH$+Iv@H!t8i*T{IVgYVC7)kpP<>}i&$9N`1Nw)v`7P6s~Z~M)4}VMFSh3r?Yl!kao8l>bb~lXWaNR z+ilUm8~c8VmCY6A)L_`(s@Ba8jy{9@Zu~0G&qjxL;-t3{IDD7h>zC!K4d|!{Ji^e^CTckqRZR?!yma;Xio0U>TL@@xr zgoSc8KKF-A(uF znO}@JkhL_vrWF!W?~G0*!;}%;gDJgn@7JyGJb4rPmmx9j2Z=rN$w%1=tjI6I7HQ$J zUc*qaGGE8r!ztV~>@hj13RgV&`$ns75N*Ab>vH^PLT#=11b*Uje2Kim&*2&eY@Ne5 zYUz6Lr8ijh@FQ43ROHQ)NvkgEm>W)|yz~@GnZ0btC1f&t?~A@_&F?jxaIoCL!6{F|6mduiuk5 z)N5C>ytRDj;%8IVy~UlZZ_RC~YxYHf;@+VarO>LX8r;dB8{uwlRc%{lHAlsFvzf26 zUp<>F`MOTnBSd~IOodA!Qe#V&9+|>fFVV!_G-Z<_dU^`!WF4=?(`M zx=Bl9h8#l+ZfV^OEV9fDSR0KHgKMRK+aj|KSiM3lfr^}Se_h0S=PTuo`@7H?!w;-i z(TyGAnej~~8%-$k=Hpn;caAoX%M$q>j8bZElAC^7${KB)HJSL4HJXao7pQCb!=ySn zSASIKCkDf8-X@3(FBj9BdbbY_)AuI4yVk+ z_{q;>&Cor)hV?wGLb*9=ilvERKELl`;n|*rHb45#jCwplTh8*WYS`IiBALvcHjU~{ zn}xZ)_0u0Rs_a+h?Bv9G1r(|#Ue3pQjw1pa*q677!YZ4d3!=D961iX1bH8RhP^U3J zl%A+y@>g2!o1nLD;M^^ZN@e#whvhh08`0B0A8MkhE1zOVV2HYNQW!V2cdKIBMvWw2 z2p;BxYrN;N?n(t-QtijdpM7dwW<-2|DK4uZW(?09Cse;PS)#@tMj^?4FxRJRy~Xly zvi~Q$(pY%fDJ)yx^w`<3cU+m>a%aqi(^cBFH)2%&QpNhjOAZ^iQOiAYhH_ypP38yJ z0}}hq_~Klk_q4yfN0IK951n-}X=Z&dS)!Wb4;-v_HXlctsp$24Yu3#OTtw|F9y*+| z8CXvhgWKykesp`6F7!_+>u!v+I0{r^@Y<){n`T>A7u%Nww;E2_4-Vv&n3$J_8m=j% zWNh4dKER0eZtbOC*4Z+Ryx#L_IDzjRIy2O=wnxV(k*C`B=IE)OB9A5^g!`j=FrI3Q z{)VzxI_=>Pnhk0Rw&**IVV~E-pNh$nw{?FwKEKhIQ4nH!3~O1Vj@Z_@rq9v5Z`p+K zW<>{f|A5@li}LGNZolEIG06|2^Sg7&d!ogzdK9xW;Y>_xVCx)CJKwv}YqUOFXN50v zef*k#0rS>-qE`E@V{Vt!DXw9C;dDClTAWFK?K);vwlBlNA(SY!#N%c}=-iv$PCY7E z6{#pVu-{R8duPSxL<*i737e2L=Xn=7YxrSjVT4yyyg$(aNjC?GX{= QT*8tNd2l~RNc-jg0f1UFG5`Po literal 252879 zcmb@uWmr^g+Xjjv1`<-?xur|J?J3!?k9uS?j*9JkRs;r;3scAwCs84h{~X?1%SiI5_vLaB%J%-M{t-t# zb@jDlN}p`I%E<02QewuTX4}KFq2{8=b5{dIX#eyVZ|gu84;s5uN{?JI} zi~*gX4CDWE^x4no_qsMc|9d?S*)zfaeU#7X|Ns2!a`VrA{wMIrx4*rUrx)k7JWb_OZ!zK*A!Y3<6! zv2sujNR{Rr0HpxqjkiOT0>JDC8KdvabX8o=f z$Z`L>Bp=X7IvP)h*>~#dfqxs!294t6mRA(0X{jihY-d$ZJj>XuX0qk=<(gj;M7Xe~$>eQ6ekvY`e^){L*Awp#0PG@c(RZ16p zRV%4TsN$jdkoHB@1vs>z%pT3qxbaQbq2zQUy!bC78fzR8imXCx)H{Oj;g zBha+$1IT$R1Jz4C0GdiTFqa>keJtt2X;`Q0JRQfX8Kz~WceodCbm{A4XPp)>H1>A8 zp5T8ia{Zk)c|k_0E)y3Zz?Ef7ny>t7murj@HsqY0!@o)1B zi(cd7p5-{msf;aq=+EqXT7_!XLMmvtXn(bm% z!Z$6-%Y^>BU6&P)XqfY0uyT)c4l%>jDr(Q^`okz};l>E;UBUh$GuO2{eOGv<21KnR zo4L6pdgEv7x%6_FaHN}5048O%9>84<(qLPpvaNlG4 zpSRg#XAo3!K*g#ONp0=Scp6pv$iqkz5}s%l=xfgrn>w`SV-=N*x7qe)I)hC~jI3}hhb zCBWv1rS_|t;f1TmTSE@bCEM(}`pm$9%7dS5ZdwWAV?)_N6C~7lYaLosYIb%RNgm+UPG5w_dsfKnuCDMM&Ndy|p{i^AeB{#6 zT3c!UVCeam#o`*0y`6B?wd-u6IC`-P41*r z72X%O@FPY?N2LjNMwgLVvE~CGsG9sYuL>&4ZLzYtceQJ>@1$JCb8FiyR)ag~>YXoc zP1nu?uA&~%N$9yg7UBR>DK--6f>>uHNM?Qt^wBEZYFRy8RwlkrKqu@o@xqdl%1Af> zAN?_YV8~Q9(HfC!XqBNJOmU>kq-5;2w)WKFwju3ykSo(jk(R2n#R;vjYws*Z!m&@Z_9px1<<(Z3igkkwA?xvVHkI2ZfZB zjOznFdpmPE@@yGvYNg|Kw8KpHokr_L;rkJ{Tms8}aY5VsZ)Fm^@y;jt%FkvhX>V$Z zP)@&m`p?e(=Kf>3XSg0Mm8bYb8kZ72B{zN$>(Z8*3{kUDDC3q2Cbz9l5jEv8{}Z~Z z2vbo)2|}Vp@GmqpH1zlj;b$$vRj?;dH_{PK+o)+7H;9_XxnvbgljQy#Dg1Gjmz?yv z*?Lmr@`iEC-;Q+sIUUIS8QTp)5q+qTAE(8N{o5DvEeiIhfezk z;WA1o**v7u>bw&X(JDR;al-a(3MsZGc7i@S#y&sVz1F!1PCCwouu#uL30In4X}y?Y zJtTN;uhXEb&UROoDp%jBqh`PilQYi;(-I6=Z;MhL?$UNtEwQOMPWDuXHMmbdcYUp% z^l|A-7lifYrl(2+fgckpr&ov;pH7rnKY+IR5~Wy8R8nv&adJ?B-~082v|+c6ni) zhll4V3H0_Dv%Sitmz`Z)!6v3u|BS4pv>g_kDhs%1&j25@TiaBIxU_ z`o48lO|qQ$ymbU0^en5AHdWO_ok|r$rFnSDNb|Ta=deH9Xe7Yr*!N^76^!lOThOkd?VQgI|ODcpV7D zPR~QpGAAW@uOJ7Wa@On9tP;@Rg@pyO)t69`*6qnks}Gyd*{Giei&N%>H9!fh+WTkh zF;+S(S<19Fce#S1sLAR4F5$rDunjbBtkj5b^Xm$$2!ZP597tk{S9|HIFUFh}LA%_F z!PF;F0pYdafhKdWj^E)) z^QvHo0Qiy@8scpD73VX_&ympqNxUVJiOKAipnoWvaMuY(Wni!l_2Gc$oW*v=m-pFLB}iyBC6z&C<%oNONMw z92krZR7m!|#8~KbzBfjMpNmy@O1wGGxA9t~%3;#O@GfJ+)ns5!VZAOn$Wr%DYj*6I zS_m8cQxYnz2IqDo+;+CRYl(x6&a~p`KsD^#%0){=BQE8QDEr0WsV&AD$hE3tu>X@C ziG@Vw<#ThPKP;9UbfVtzry!h0iPM zo#yD&$z1ceC%O3?q5@G93G>)!#GJ3P?!`_|(c7CPe}}ZYIN7SSZ;lqxj_^S=$qP_r zG=P2Fa%TGpZq8q+SvhmOE>(tvD4Cbc)=LC*3R9v5*eL)PEFVOr-t!appJ&RLZNd1| z&(A+zvB))`f9SIByw9;t<&1XdDtX~Kf6z;3I8kjg^KrC(+pWgBdBWt^TF-gmREy`1 zSngcy+vyFm>v0|ZB=T3&Rl83Z6PCMTGo0d|uYW(b888!wnV8p0qEt~Q#oW6iBmw?q z_kDU6k>^&P56)D#gm=iy)VilHSf3lphu%Iqoe5mh-tgT%i7;0((>RJIPu5X`{86Mr zmFO43QtjUYg{d4lO?N!&AYzuKBz=KkP=?*O1ccXg*QdU|UidDi9u$37M@tlZHH1db zwzRBm4cb>8t+@`~lO50gz(fwj8Kic}v?`5kH`1gx{-B{|SF8l6@oJRpx9p0DXhq~R zuYHwwdtQ!I>LnJ*++V)a4g^_ERn<(+C@rhvjkvo<>k1Ee!y7Nay+(D;kd2ot+$)&4 z6%{<`-7kwu^y3rt-hsW7C+xXnyP{@<>9iqZJis1&cC)s&R-zNA)Xzg5*}ix7n}5ge zW;>DJ*Tb4!y)FF8bAID~ka6bCcI|ndp#x1zNXK%+ZPYmgMc2Q2S#ld}&eSOa*-?gI zN6h7t!=ap{HKa5rz3-qK&iKOduJ6_o9)83ox}Xh@v50l!zgR#!VD$t<4-hU`{Wr3> zi25qHMka?;dKIjj>pDl&kjVD19NH zoQXEWimL1=1O$GSj26=_%l@f=f1hBc>EhM2j#~c%QfXpPuVtgz%#zLV1oZlS7v$o2 z<3pw>;~Wr4$-dshyH{9PI4t>2Q!}1Cc8rK#z_zzMF?LSfOHP1FmiugPzrtG{b&j#v zHhHUs$2ryyNszY&A_iISaP`}_LcmXyZ|%(2fzk6v`ZTVHmq_J?$#9Okq9_L-Mf%Nz zl@;rQ3>r+75V@&O$X(0H7WFXnWAjCJQ&U($hTh0AG>PuE!HvBjlKxNWs#d2g_b&&a!a zG~FsKD9^n+-dVzq`xAi;C=krpv^idkjODd19IxC6Z`Rw3&u&}{3-Q@L*(q+Y{3K!v zG&*12{!xlxWR;6Qb=dD14|+Eiq6B3vEm0{UX!VXgF{*d59s5l;3X4U$_9sT8HfwfV zN!5qs0tlNJt#Wt{{Ynbolz%!g+`<$>B|W3*&NquPw;;$1w2MXW!+ zgGx6l_hYbwtqSQpt>iWUG(j2XY zk56QNW7yWO2GAm&etEjL)G@RozJI!a4wKD8+~ed@v%WW>!OrR0A*PKelm5f1@>Z7K z_UGTv{y|d$9^Pr^1(LD`!J~BX0PpjORAD~$NV=Jt{)~Olim94%EmPa~MDMCYh$C5f zh0S24k&f55H_rs!cS07j0q?dM%%KC8S}LvKWy3~N5%C@FbRZDIv^n7=B_0<$FC64{ zBh%NprM|UE-|f_MBCCu&uH2hpNAMT_k?nkj{uJ~aj19|PI#`x--yFj%Q=jrtd*u2Z z0a+SoWHNCdfDuRa$yP)8{LnU_PKgtkT2Z;|Di0+xT*F&73;^ZpgI8jI#j4cUX$7EX zvJ(SRf6qN4C4C_udfEAPH@bBj=wrGJ6Qd-n@Z1)^6A~U-DADQGc#LM^*5{GZ4Ey9E zDOltER(?ul#rJM(OZW+ortwV8daJsX&>SlxLc=LM8C@0DXuZQnjv(f8E#tPFZM&eHVt`}bO#Av>qy%;AO0 zp17+d%2$f~O?K-G94{OkXSJQ!#PS`rMf3|}BZml7Ff_EJk_>_ZOk7H+9O3cN8T;t^ zKqqH#%+~r1QpNvWWaRPB5#0%^PT~zTpYFZkRqpArfHg9Co9Roi(gLfUX8&6KVJvuf z7E@Ko8j(ZX5YP_mwWS<*6?B|>Z9AT8{-X5(iwYf&zt;QrIo9H)J0{=)7w0&HR~KWt zcRR5|inw)4z;+VU?+4M+L2_69U$4Ehr&wrWqwn$B)VisfQUN?qe)566YA2>{WrL&< zEY1V$Qj^;b7kU!~drrToQ-8+3+TBZq{^Z?uLKwQ*`=mSe+X=xiGr@Gd#T86y7<01r zjD8lm8i3MmG-G)q%ofKjXU<5+9Wp=C&Pw^Hsuj7lxkSzBF8j99bLgvV}e zU*ei=7ucfndxo^~EXehUvV)Jw@81botSb>+LKEktfSx`3!K==-uiV`f$ZVa8DmK*I z&+r);5NBg!V^%U=*im}I=hT+-xRnNqf`pbZrVw58)#|_KzF%k6O%UAtf^rVPRSB!Zteet(-Ahq>OWwsdm%euf`eg)V-fn zmqqX3syv64>Xtce>|on#vD>rl9V!7An4r5DpS(Up?~MbPZu7*V%*iSp5CV<9&}@Nl z4(qYqzr`8|NJs8bMChwT=fA)%H zsSmu02v&j=&y+g{>K%o~vIb^;x!U`Ku==>tNY2WA;(Iw~QO`&%8`GPF{CZcKU^^Sq z`tT+Q;k-$UYcSN@ny!xKZTFXCV)>pKLO^)LUy?>oPCH$<>J;Bzq*H_VgiUGafx^ltlQYjCuDuvUHm@i6MJg?~3tmHjX3ky%&a~WZ493cY zB}2!C4dj~qZ@pmdnW(j)!}|g37SIBLN9C4nGzwpt?u6h~v{i(07*{Z+|NI0%ORT@P z#+rm$W63&J`W@9qnxcM|Wrl823&%$(`)%&L2$5rtPN|R z^IWVtp_I6EvH1%<9E=^}XPfff_# zvfX#yE%1_rHE3>o67(2f({aW-p)V`@9LTcMN2_T;vH-ShSH!vOdHA%uekaoZ~Ce$ZU1?J>)zGAPFPy}IsfwHElz>vg)0i|dgpYJaX zf5fE-1-N5F#*?TPestuUsSi2CH+2|bqi8gV;#l;)*?^f+UXrLd^P6Zct&wuzKo_c}zG?qut}Lbiq3C|)qL-iEBLYp79EZTR|}=L9^)W4gGd z63o^rqZm!ThU??Gb6_lAVu4;Ah8SK%6i+rLn-6yUbcOV%lhM-$oc!gBf8CpmA}uJC z@Vm5IM2RH^pz0MH);vwmU7+hVy%A!;{~&Fb=Pm+*xnq5qKc=r}3q?+zBpnEMyFGc+ zSq_!@VkXgkcwhL*nCqMuGL1Hvct`4wv!CDJjK}Jbo^%5MX(@TiE8j)WtZ00+0M##F zO%wL@zRbVnu(NBN=ixHD?HkD=sMYgh9cR4{+a4QYg!JYfZyC1We$CHYWSBh^b!$3t zE6IWZa1z}z4}Be`+qXlESp`x6ss`x4)^@tlj?4;%YnZNZ^V`t$SViMHe@`c537O~6 zEoy-)1f+lCSVYTltibD)Y`a$>YB!3n$-w~Ppqy5x=A`gq(v9ks`{lrieUi&++tBmW z3McZQb=SN|)vZ?;V4-5I&_!C#RzsFkjo3F`a|s)CdhS~Sek$-bfN7#1iJ_Lgg&8DW z1v*+-El8_mdPSure#IN8-4sx=`}ZP8-VFh^Oxcm{xD+)5U>S>T2918MXSIn379+7U zL-!J2`FtGhQgXM|UXJes@bYdg`_59KC0B^H+lO_X`r1ZTP=R&>RnS#uuANFvz26wX z8gU6Sy1QQ~f$^bcPB?NP6DMwf^O5HCWw|R&I{|`XsIPy2{8gWn9`GB$8(DpY zs{_(c zU$rH{h;Fola&kpj(+H?{wBo`F$&=wo3E)YwAjTHvA|26ixqNujt<20%A*t>Tx(!iH zt%OOQ$9qQ>x-z3m8dzy}4TFODP5QBN(}RzpE=%Tz%s{M?0H34^S&w}+r`}~c<{vJ% zMl^{D-}LKkUC}4a!>c7f!b3O5q4qq!BKRG?vQkfgk-Z21Qcy+e^aCFCU z-1*~hH8wFo7e+f?W^G*u>#dD3ymR{s#`t>64u2(k481)1dp@R4IN8itnDCd?;M7kW zo4=9iO*XdHOmZaal$4{%YOUUrTHysGZasTVr*z`ZE*yLoLk1YxHUzr*IJ9J@5wbx} zlusZA_L47LAzYJrS021ZIoOej_}lzEpCaBy&A7=&#B2nV#EwB|BW&>G4A(tbHUA7< zo>B=q%opufz9BerUk`5iJ+EFXoUCyw&#^v%3|&!*GW0yHaAXi+mvfX+PI+;y`ilT> zxgsxu%eat_FkPZLmBjB{l8j!NMl8yol;3_b)O>J{VoCDTf3bjlSGrkI|Mjlovu}6e zagS{}F$WX1e8*VXP!Ghg&qBm>FsopRegoB4(|<}i$(_6Hh`V88rLKh7JF_)l&5M#i zvSBV^s?1*iVGJay)2E;aEEANSPib|qwOe3F(O-Z~_y%SLeY90C&5-&sP`W46$5kNy zOCFBT)Z zzNUt2IywCiT`y%<4KydBEglSyNa{IQ%??ZvaG%#TxYgXjk zdM(qK4fXgVuP;$rMs1^G|22m2{4vcbRYH}68td(BYP4VH?>CVY@_M}x ztG1-doy7i@{5L~3S=nHW9(3XvLI%Q5W%5|+n&26C=0@U8OC&8X73jw-$x1NltE%KC zzAl^vz&5kx9&dq&-f!GEQ|wBO|GhT6@zeB1x0d7J@Mwu@l_(_*#Fe9f=@RuvsTsqU zChiJFoUYA-*|pv5Ce5R|L(v+6E>@=#KuJSPFA|d+)>K>x7x6kG*w!T4(Ib8I=>4$~ zP&?b}@h1tG7Ue;R1;%~39cgKM*U~2~EN1FL1tw~28l79W9%?&Cy_X&|+P{p`9~|Tf z8780~3v4$bSK8NcFnzziQ9YRVh%kL&bFCr5# zm#wxi;fV_K_01YOw4>O^D9bx{?&96Od%8VszPMr-GR^~S0}WxPFJ_~uhWir$+)?e8 z%yG5`OHV3Kyp37ioEN5L{?>V3sqWWmq`_ux*x=jn-tnt=UqsDFvd=O(-P zk+x%7S_v@EsL&&gieNxC)RpjEpTUy$|>AF)v?BZm` zTomAh2%klzr{N1;!WP-hs@^s!6#c~FM{dGn-Fq!MnD-!aVS9`9v+Y*-nj7j|1dneN zlI3Nd0Ki6#PamxNZf4c@utMS1v4DoTX&H-WMb76ax3?_I^( z*luV{t(Iw_g?u zfDE(FEUg7pC9V|k;eki!d^P{JoOxe^-Qvf$vM=)`b|7l z1JW4b!G1wo8C|lgSD}XogJ#%=k=&3EWjXAxR@tO%3uNUD zqhlDs>-joGn%Qby+_Z$JG_SObJe8179cH|(J{f&(kSEX5*p+X%&GxlF1*r$*8w_UP z%%eDYs_ue?S4x1JqX2my)8MzQMXnk57Tmxh{+@eBqgV(+Rgsglf_9+Oino|DB>(bp zT}~H`I0_AVe!TbNzFfHO5M*;(L>ZQ9ZoLc9QDd-`gvX8O3XSf^!e^cX_7=-A|0am9 z*QTFqdnhSaX0>|CvXi8rm|i~4?W0h3HhV**w4Ma5`u5`4WCOEi#rozZ?~O&Du`fA< znx2S>teBW!x#1fs&qkZvV-6|d>Nwz)snPjBAE4e>SG%0sckOCr$?`dBfdZI>x+Ul6 z$jB>xe!s;Ph5%apuy5})T`Mkkc171q8rvDDZ9{8oy;`sA#r-jVV}mV@kIoC-Ol6sm zxzi=;G%I|PJ!xW%+@~U$HgDmqu7pdV21Fl7Kf+ z)$vS$qTfmStEj7qw$QF0ujy>jj@dk*XY%1_Y}!b|fOgo1l8^i50K#>WSUf#{=S%qr z+F>_@oU`B`RCLnGIT_I}QZiiI8 zZjzbX=W7j2kM1v39WZ3hAK0LO{OE{^qKCwX|M-zbOD6A$thB+NkcZ}`iZnI%bI)P# zrP~VZ3_5TYcdG)3BzW;^ramJ`R90E`rCQztQPBzy#oKz31^l1-NF1tLpC4;*;~79r z>MGM%9__`X_5Bm>cB3}`@W^*XTyKh-ouZcAJ_I;HwqP;-(R8~@6h zBwOqyDvF>q!*0DhO9s%be%;#d{687l1A(NjJHFC?!ZmY(1Y%y9`gbBvRM#iEZ)_Oc3 z=XJ?Zb0+_=H#!f}U$<*dzANr^fGG~PP&<^Z+xzjMtKpPRv~!wMt)p$aIW&vwr$)V1 zE0e=Z0Oat_3VIe$VlBRSFEX8{!#BEo(HUDWx=7&6g8$CZP&-niLM(+^JE;MsQuqq|bCPm27Ca@IZ!+mUs`sUB!vTQ~L_~ z;-N)k-m^GAZyVG#lDUbVs*w-fn(10zlNY#LkP2D(^DK91K` zQW7iG&0lIPf8y*yGj;dQ9o+;<7Olz-&7e?XOX;5`Cr4;*;>)cY)ENibF;z) zk^H}Rt9-9(hTB-l_39i%7?B3tq>i)RdiuH@%-MHf0lR&7G38v5`S>q+7@TK%oR6^4 zH585DT~&bXdpNEKATo0s#|sHpNLd_sX=sN(h+np#Q7Xa^P1#L2g!woXSBp*-8qs!wDescry0qM{j z;YpWPKH@dp*K-Pv6*CBC1LUAfrumpJS#KPYxi$Z0nl`Lxh?Ij*=K<8sV?)S5UcSPu zzyy$RWy;V-V``R1^7S%(;tn_(itw+_Fl6p5?mHLiD5jl|ho3j2Aq317BWrq(ex`ySS@57c9;k(k8hw1%8)dmp`%)z8 zi_M39Vl244qer-drw;h^SxF_TJ+Z&0ZJ8pdUfC=d;J0IOzvlMoiu-QlizP#hM9UTn z;j;%-leO!B5cGAaXI_O&brJ9!(<@@!#;c%LU#BF;_hEecTJY(l$pRjE%%J;m+w@NA zM5UeHYzrXwNVN%FQ8)rRm?p}jeBE2aPh(^VXa`c0Ycqn1B$G(CgeD^$5`$lFwD#eKS_|fS;);_sXyX;jz()ze@ zZEcOi{Y4635-8SigX)j?p5A7hoP=^Q)?f0Dh5L;ft3mzBm2swU(IwldJ0{M2-yG)yvbg6Z3KuKe+jEtbVQJ{ZDw!qh5@03pTk-C%sTNw5I zTpn$&+fWK1)c_esD_|~eUUg^@rl_FM=zgji>2JF9a}q$AsRZ&1ya?O&XS;XWURDs;iN^d&C4yuBVR? z@JoixuM~`-B0q+9VnCAe+K%JCYcGSWZr%C=Pk$!3V<29Zws`cCe#cS&lY^q zB}Ix+2E#0E2D z=Q5#^Rc-$*X)XC?OxUX!mYnV z%c)D6_O_)0{kZUi9>=i5jDG^k-@2t%w=lj((B10LSK0CPYxT&}nF4`JNo>5{5ApphR2+h3J0GR}UPcUz~RT=k305zivg29q5vYp+y zdL+4q?tm+7*u>nLj)LyjXqr)K*96ilby>Z;qSz8rz1$tYC1A%J7$E8F8yXyGY`M-# zWun?iLZsoU{KvU`rEp>*i705hvJ#Xg9*|fRz)4HXNzL93RGNvrV-sYT8=L7H<|zN3 zyzjMU&KH2R_WSqmc^4*j&9FKRa5>vF`y#Y|tmCuzG@v9Evc{xt`2Re4h0>y+usCmi z@tj`5S@xPd2~;#%`)T_Z=}JgzmiKUbr48!M)uZKV(p>J4kPr*qN|Qga+06Z^NUy-< zv4LA+xk@x?QBf+{Z!ZKKc6n2poEVamDT!6V%}Y;1QI0dcHwkpE-_qI$=MNx6xs(O8 z$z|LoB7RQ&`|1=a2&eF6Mx>x%^{ak(Ug6=T6e{}tnXCB=!7JN*7;Ge8tL$B!+O{5F z!Ej{-94>Kj$mggB2b7{O+WG<1FVeo4(|T_Df_VH#SZ7F(I#6zStxRd=5>)2*VN*; zFHJ@&D)(`5I$m2SP^sowEEkIqZ;QA^FZ19XP@m&{D>n`b*%fs;S3KiW2y+t$U&){a zmtlZx3xz@@@dZGE0_8;8$#;|C*9YQVBT`!O7bFG9(}NSt9*_KX20*dX$Wy|N>ZT%& zL*t7le3ZQ1t;w#88eJ+h55EJ-2t$$Ti11L~rAyIeGf_InvkaqT6i0?}*bUu3BFH8z zQXp`%s7r6_g(s|>Q$jT5Ee4FewLi-_yUpHBk4z5^Py7Q2|4&aArHS&j;Hf6acwCGd zoluTvIV(HbAr)Hq82`>!@7HZ=phfy83NltTySYP&hEjRFo7%mJh>yoWi@NkeKF&$7 z($~J6wOuhW;OKoWmkBP;p3FrnXT-emA$N`7RcHY$|7d-*%M1h7izd41cS;^aBAn8P3vja${08K zPnI0wXqewrTbGI)y$>&kLKm^!N~s7M5|Vgmo=VAVwPtppOn=VSZe;r+;npNH0ItNE z4lO8nE3`WkC}Ef*B45D6x}lPKe~vG}kL$P_6gHr}DWmK($|vr*55Q*=sDjZeI== z9U9rg?Ck?k28XMgTV)7ES+<>kTBanzn-g^b2pF9}b=Y<1J0#RqxZL!*y2(xia`4&p>uc(9+b{=>>G1F{r8z?mu_{aOD(S zlcnbEx6ZS(gHP>M^6KCGCdS8KpYYT$Q+tgMs3BTinTH}R@GXyz-GM=r2KUuJOoX=m z$vo8x%WVc3-q_14#mSRxA*Zjtp#W9lw#IL^x&n=TtejSsAU%Wdahog&1muWBL=X60 z4Y>Ml4%*HM`hd$mRJ#3%1tw)M7(5+vkf$?Pz0PAZw!$!{bQ_oqVMo~&8VvF*XPcCD zw69i3K5npyOV!ZOOsi{@tIB>5Pgcj3DgpHfmrJ7;r`p&wZ1V7(NHH%fbGD7|b1SWR z4H}SWv@A|;Sj|}vjTQ^7+!}}^dZN~p`(1~IisF-ri3Z$5(??O6J6(OXM)r^c%9wjtTM|5;NW$^auBDzQ)1X~jyixJ14z_&;*HL5V1R%O7!ff- ziiOj^vODNtK!{Y^`X6Hqee!{R;RxN+?2JMepgrIVQFH=bprU1Bol@ka`RWiV215G#CY>laT z_X?q&fMcn5@1@YNbj1k4&TM$rYPI2ARRevvF2p5*k+O$MdGTI&O8xZ!>-Fah{?xln z^5GBgrm(?E3VeJ_6M9L2c2dRzzKyOh1)_q5GBwy~)dt^BJypk8IZt;{IMp}&iKq*k zJZ0;E=Z=szly7tm32(ZhofKGY%d5D13&$Ukk>NyZPk1-#p<89^+ds8QH_AsKq6lBR zIdM>g%8`2xpinvUoFYWA1ye1PtFKR0%-nhaV$%8<6Vu66GczCqu#oKy?~Y}wwi~4= z>7D~J>#n#{?ph4*RB&XvrZRr$CaT0^b;cB+Z#6VESxia{GPj`YHEK=ARmrD)Oor!} zL(19Hw3(wWjsnY1tCl5z{7wPId=R&{cb}@c{DZn=bIVx1;p*GtWcN(LD)s*P-sBP6 z(Z;u3toopmhTSikZ z{s5@!7t8v{!@X!?W+pk7ZRjmeVmM$4hlx+X$H$IS85Gd0$W%ie3PS4FhA-71p2`Yk zTd5b3$)J39K(p1_3F6}m3n!ed#U==F??%4Mgw^T#0|}j@0jMinF;kk}@ZMONT`{Mu zfL$fhTuucc=l5o@n}szTq_tPVov$INEW(%jR}NWLulh3Le3Gp~7I? zsL+U?g!n`O7kv`VK3e}+3JxPCFK`iHTxf(na{W^`MhoNNdIbj46Ph=!ym*4^!@|$c zwR{A~Nil_ad6M2N0!$1HJY4H6I}35ifOwEib*jm0@Gow#vIm=fxsQ2XZS7e@!Ld4n z??V6d)q$5NronD>C6w9IP#m!eO!NR6_!tY0lgqj0a+YVWyj@=!BR^-c);B)7e|D6s zCu{GH44@dl_cOd#_4?;8)xpk^y%9*2&c6!w0w{Sb zE*t)oy?+j9y(SNWhHL3u!ovtaEhca`{WBjgpItF8myuHO*dAEHgZ8jBOU(hl=6J*u zP_c`{G@5#_!OGTWZQJ+s^pXHaGH&=4lt!u%1Pih&)~m5f064KM^>rW%mT}+{;A?4W zrV6+k07X2jD(8xd)%o875cSP?i_rf6M@zmV*x|wiK$HL(8%{m4u1LOfR|i-G@@jhfI`n@u{w;@2tSB5EPggs1WYh( z_3i|riN|8ZLEm#w zOV&eXj~#I*696UR2vrCpw718{_+6%aLX!f#C@>W2IOFp&G|v^7`^`=OXO`~$0FY@G z$^IeNZV3DF{xBLSb}U>lTg7kxYhE}XLZRMY0x%0+-=b%4raj}KmQvEv(*Z}ZtZ_*y z9?J-Ev8&1Y#b=(1q8HCSp8r}yKm@vljgdI@<`#1J9&uN?z&v>cHi?){tb6GH{fZF* zVuF1lbV3arI`t*C?I=JYvD|icey?P<{9<7_`9Bf(=0WBtG3W-^|2z^7j+D?g?0Mld z8t{V4OknQ)qDrOXHqLJ(?#BS*7d{_UB(03HT_5X9aFdC|T z2RID7%=pi>1|~h0 zz`3Z0OULE;8NlsSgkvj!19gf)SYejFTR7k&@(2IL0-h;%7T=X)jr>Tie9{R$H3Poq z{jf_H_+f*C(7x(UZ>KO4GGK^AC6W7Yxe8m}@Xk-Vt_2=WeBdi*uKzx5omxlk%ZCy6 zMVz|QhrguPQ-0RH*9BJA+aOnNNP|gDP!P)>n^<8^9$~F|vxE}=u92z{L$caCzposE z92>S0u zDP-mA$j@><@Z_#qH>J6^*_W%(vqJdQ_rOdzOjibz5nc-><)Q3 zTcS?`8MFx0+sV6s(+YV%4Wmsj)tdEp8Veain6!?RyQ`k%!zCi>T6R2eQR zH1Nk`|CE7R#n$^HWz~}@@NTTHF2_3Z!pMn<95I_0Ls>7&=y0&WmujjWvc7|+d;2bmZQOOu>^kS98^-&f<+O?VO$9v zHrgu-$jXoG=pz5~4vhc40|oxLV;MqyQpat%9RIck1vWR*kd*DMMr z*++_6M1a*_TIUo@og1;Uv-HNJtk=Oi4&d%(8Q4BebFG z$WAbh$t-TNU-;vR?_8ymvvs-k;bnN>MUxj`zkFr&i{jm_uNl27Y2jn zQc%(Y;DYS(?&)hEe=Y}I8*_6inF=+Z$8?f6-g+(gAFq39&cNPq=QC*W-O1{lpsvj& zVr}^l&%~bd%bT137~}GAtyEo+L+vsCi{^KX(Ji~Hrz~VO5_jvq@}2*zoQB2$LY)b4JGB*zuMf2~Ht-sUi()L32qw&xDBjt26KcoWB^1DCZZ|M*T$tf`KVvhS{ zUH(o>N=osi39zqGxBtBmutLJ?)#}zx?q_cT3cgbqz6CDKc`4E>^YY3$DdL^7@{~&X zm)M{pD*T%$0{S;7C9;`86=oLBWY%Fi1499~$#2~Rw~;-(OS5IB3+ELjSaH^v@^KBD zp!bb^DE@0}6G2;%0p}r$!1^M^e)h{a0e&Bq65Fs8@kT0XB2eN=5&Co_lJe1w`{R5d z{LeGSIWx!)e?U@JvnEr!BAy?f`Q6}z1+p6yA?!TEuslUA3U(!?e?n>$(Z8G23~YE? z>;6)V+>J~!=ULqUL)=?GM76bV+($hgk0@XP0wNYYfJiseNOyyDcgKK=h=2^z-7zpQ z^w6QwokPP=Lx;dnL%oahJm>vB?_col9~5O`&+NU|UhBHA>vQMw$erg;N7EL-$Ba zT=LXQaFz7T&sR5DjV0YX$EhF41fI3BW3oysoPb8TNqnbM0`!>N1hTI7rrq(h-xG;{2=s?O81`! zM+fxpy_PYqmzb-QwaRW2Pb-y;^hCaA?TWkls{gyui_H?kXo8($t_yCzd+=>2rRQ5| zcd!#{@GuzA;-xF8ooIj0sld}%GgZ;mk zUXq(7fIVDz0hhwZ^-i~lwpqVE#7|#b&Y^<9&jg-`3vc)P6(9w(MjsX~{>?g^}H+*$wu6%DT@ArV{_% zeiwI(F~hv(8&7kBwZlAn$)luS1wD8$o1Z73tR80c?bGkJp}atLDc?QcN{6wwH?2r| zE%Se$3@!#<0jh_$VsE`_ktia#7zi*6iY|TQi|*$$hrVdiFb<}JFzXg-4VD;*o&1T~ z;Ba+6_}?erW`q!bmwo{G1TFNce|fMTsG~c@W%*-x(8&FAfx*kut?5DM4WkZu7LPA2%??az};q#yE%!oiIQ89_9-WyH8;k(r~qrI=; z>E&i*0#79_VE|&yYkKvR&J;k*=JuS69 zH~G&s1oa(J3wSMUin5WZGL~P@m8U=#Y0Z|Fq{x4~eqz}JS2UcN7Tlio_r-6-Ua%YDjm_zY>u5y0&U=kZY6cRp#M#p8o3F3JwCm%YT$2C zd=%<3HB{%3D%ti0RCLk={k60F_;YPR4dFeesA4Tdd_7;`9ytl8-RKZV2(!_UzeztW zv@$8fMFUSO34+_it8IrE7D~e6h+SYH@B;M_v&P_H4E|*L&0p&;AGL2n}sAHp^7RLbU?^zsjI6)REP=hSnv)P_yo}a zJNS>dVn1MvcZME}9^@pYrTHs4=J|^}3j=Y{fw5sgm0;AUkOsNZ7{*x6UJJQJsjxJZ z*{NSFJJ73Pn^PTudym=;>zsDxv*5(k!tgo;Gg-bECjB~dZ02EE-}|cqM43Ld@Z}%5 zYJdIZt=Yd-RqP_gN^K0H?`0vo?SQw}gy zz*&@B<>=-qZu(_-#a%*z>+E7~Ttw4zef6oLqT;)-8n#p!V-pijPEM*PPaw?9^78Tw z;<<7Ojmh5L-hf4x|5sP;a$x3SImY?l`@yqt9ye26U^-)H#{deFjag5t+?w2X!Tem! zkeBSq&**^kO3#ADXKB^Gx9+#!;v?umY&P__SI4qBs1W&`w%)LW?)w5&ROMi5L4-^A7r$^EK$#uR5cq#_rI}I}+m6t2`STcC}){Y{I(oJbo^#x_$ zld1b@-v=`7SWn`+Ee>PdASZ2G`c*-|9VavbD$&_@IdAhf}qJDg+d*nR>G?H^|~1p3T1=LP^lBhc%e;dk&?pz&4YLy>=^Nqg>QiWoucG=&fMb@f*=`ITu$}fU(Q6+bk z``Iwb;-^qYeRW$aGhK|AWr0NmR{Yog&VIZTr zqnsPCd8Gi02WPq>S-~e#sY8dtspF>cah}*4deqMn zA6VPjtt~G{*q#_Po_w<}ljarb>TB~|&mL63c3D|kCiA*Tz8Vd_-Rmr&`c`&d-i9oi zS@qNM=UJQm%JX4!W}_POpPRve2*7AiNpu=$J+gU>@sFwwZ~LbAuNE*LKTa=fxN~}> zpx02V%h7> z&r_Zf`*k*kr0Uv}Y*3zXCIn_~E@RJ|xv$hVdlT4V`{UG+$|SoW%E4mL*m=RC&yUIt zIf`C9C8D6aj7j*Gm^g6MJnWuwE4!8>Z_ocYe$7gd?CNGZN7iA=)GdG(VxwE_FzfiR zef#!pa9!w;5nNpR__D3X^QY3^qheC8CU_r`&MjDFaZbonO8Jnp0W{hOBsA-5hp zd`QW$;e0l*45apM-q`d%*wCGfJTVzn?ojL~0*Xl%&kOnMaBDI{z8s8OjllG?#Hrk4cLD+GSuk4CrlD{Th*LR~q=r~(=w}5-wj_=UlbK_AUQtZ}&UZXX8 zHw!CknhH(m5S+(v{rZfZQt_9@Mi#<>P$mV*v{K*&nl{qe(otXCsXL<9A{tmhmB!MH z;3C@PQ>8W%%}q8tE~4Y>>OH9*KPt@6AFP*Y0F^;OFZ?IkjgbtRoT=y{+~%a;Os>A1 zjEdmLXF+tg&aO8J^C$!pEtB;p4ds|HQRJStR%pwP*^WQ=k1h*03h)V|81*Gh7F#gy3uzS;nhs8DtYtIKZ|s#Qm|KE zqan#oKiyWlS9CTzwg(;zcX1| zuT;9;)F5fO-Qxs2nz|_WDO8e2{!{0k-xspWicsLyWqqTX!>FcT=d{v0MQm9ux3=I@ zS?r9M#Et#go=RQM??x?p1C3By9jjA45_(zlPG==s&_M$`)Kid0z|0&v+u)L1S5Q>M zX52z**hIYdgqM|GucLD@Wr!kvxY2dCa92&)ceP_}H9kH;Rmwq&vq@p`QovaQx6FxS z51l&Ov_K^Z+Mo z`FJp(kRXv=TXc)vW1cbO&6-x7OL&H+!!$6{o4wXj3#=)XK2IMN+%1CYJ#E^FW&-5j_3x?l=$gK@;|t!-?~6F)kP zpq1MK-`uTE_mOfLp_^4J(Wk&0zPi4jJ_# z>DHQg5W+u?{qO#LY=z0lva>X`z5if=^4@O-?46_}l9Po~q31#;WHS2lg>VNvR(8)= zOt$;SkJn9c7JILiRb~5sO#`y&=uv^DrPiL%SXdbI;-X1FK+~IvUt?UHsFO48+t7JU z$j<6TpXx56*n0if&2l;&ttG_`Q$mSH_UmiwJi<=5Zxcp^XWNxIKDQb6rQHz_fC~8; zO&BNkS4+jY>w6P6`5usRo6r50sR+U%7Ki4~mTt~ieqdas7(w2D{Mgn)RY_l-ekc7y zqd1r@sNL&g74OWzSAbAdpoDm%FE+MeCbO#FqIOUu>o1h$nv?q1kRfjy3M(6v18hO@ z)YQ>*f>3^+fI0&Sla7{Z;V!PmrjoyuQLRJfnUdF{dm2qnTbASw4(HqG(XYs?Zsq&& zdZ4dRV{oYYFU4zqZTou2^2W8|Ts`8>=`&cS=c105O1eN zE9N5YEFq`Y@pPxg=3k0nhd7y+R!os;(u1fm#}yw7xZLAW!F*zd*oxL6mQWgf;^z~? zRt4!4${@TBri$H-X*5dZCFWVnbH%PTVoulW^qb+*2cCS zrg6qDSxt`!Tox(O#hkW|{VxK|2)|>h8+6NzU*4?V6FEU8ZFZ!<(ocN-`jY;Tl9X^- z8wO)M7#0|)4H2JjNF+qTS#=ux__;qm;Em2#2i_?@9%n{fATLNu>8k36S`LW;?}*X- ze1_3|Kfi0B(9v-8BLU`mR-ab-#B!qWqja`Hv1EkKavI9K8I~Ki0V;dL^)lyMbwp*h z6D4MfQIe=N33vMlH|7Tvq(T-y5tQ6Jbv2iN!$Ky>5K4_bBf-s9Hx}EbYmUG?Wz+C> ziCd_oYkeD`jb#VEPF@%X4^WktC$t~$7)V#>Wd!a@v##&$x}bi9!qNo7v3Jd0AQQQh>l{(WS|#?2Mfb(E0*6H!HLp{Fd>lkGJ--)`3A4Ip()0IqK z2E_JK0x(@PV=h5lUY}sxubbhTRg@fCLxnOkV6Qb1gP$myZ09Q!sB~(5JeS$IYW?;N zFh3Xb-?VI<9Dtj@L5+J7?Rc#pTMjcwIvU9wi1t$(lrU*9uc+%b8VXVi2E6y>O;?c zZ~Rph5+2IcXW`~}Iqh?#&|%o*7cd*cgv$O5a+1E-5I?;+GgH$+c)v0G{x8EOZ!4>{ z6rqOgwgc!iPQ9SgX4qa%&Gd2W@Ru*<)b$2MnAhKat=iA5h>L%=w4qeX8^FzdJkc!L zw4U{~4EO@IRG^oyb7N2PVd9-+#rXUdcls8S6F8+y+$Ct$zp!{wqIo&Emwws0*iFf` zlk04}JTeGsdUP%AaSZALe6*5MQg!O5LVEAe*+yHu>}??JFn|9(ATntnr-aPfUc|a# zQ{D}a=3GE`BdjQjzM+XsvAeuG@b?@ljo zO-^WoGG5q|NssnavFHYsn9azWs&<7C==t@%gvYQ1o}&Tf39IAizgSX*YqS@=E2t)Q z$nFx}DZo^O!a;?v2vB!0HJ4gWaykz3+D{wYKrjG!Yx6wQ-3ANg73Jlfx)R_aLI|#BJG!^O&&Bbk z&I@{3YSL{Pkj#GcD|y1MzM<)yBY|f(z5Uc8M|O-{xQJ91@iIYh<=3d)LxYBBsD%xB z+tx(idpK7)Ff`g>*c{TJ;Hj1VMa!Zc*2%&$rlms06z~^vqRK-fuBiOer^cSdi87so zL!?f4NP1@lu2^p}Lbz!wiMz})XHqQLS+F>t^%-RH{^0RB|Cr~guk~oQ!Fwggx3cB? zWJRocm425Ble&_ms4B1|S^ZjC7Gj8)HLSFpoIP)^bGH^lnW;4rQ46LNu;*#yoV9V@ z0bps69|Ga22M=jde28)!NYT^+K8}rENd|S5$gkm{@U2Rfo9!s373+{zoWc6~I>6>F zF24;PEs7tmR5wA4|9$&5)4I}EKU_~@)hC~hk(Oh8U;u98XW<71by~U&>$Ip*p(e2@ z^E=CO*;LMYdID&FCB8Hti-Lb9LD`cOxGEkee5UaYNKQI6S~0CAmPOrmzg9wC#|>Tw zS^H|f15@9#T74gNUC#^iEt=Coo9^`5WyeWP86WH!%33XVBpzn4Ms|VT@Q7+tOJp+} z*<3R*sBWxrIu%l7#Akyb102o!We8Z+!!C+2$==qaUy)D-D-2XNH^z;^6|bc)0SP z+$McxUgd2r9t=NKH5|`^x-QU5&ctK)R{#$N_EgwoHLsADZ)}Sj{bS%Qf6i0aABI&j z?N`ECa)xQh>_+LnOKZgQ45PFryo#@+?w2(GKXPiPj{zftEp8H8#myM@MZzx?*jjS<`w~Bh;7s?Ae`sse;v1s%FDeq>+63 zv8)>sioDL2%0ZX3s&YMHRV5W<1ZcQ|oq)Hix-f>gS;}@{*L0)8rfNdXgWcN|mB5}@ ztTRz;H&q(L*hyL}l0I4AIv{pyS@E%<&(AZM%SuH#^OE#D)sgif5sA9`XRrXx9;ud& zARweX&~LII$63j@!;pZ`4Pd>GaN}CKDlt?ZagkX7oO`%-LLEnsE_&W=R3rKc>cHHM z)y8(mFm5Xy<9km7Wk=~%ON}foA6LN$minuVfMM>A#w2NhPOYdI0bGaEAst|`%3wCN zb#BcMLT0_)c0?SI$Cj;oX4`^i5gwY1JD%KYs!EyGhU15$DM3GfW&rIaUCn;5?0}=h zrT;#HK&aY6&Y-G27b;KeNtVEX2(=0r#wIR8F82lj&+xTI4wP3m6{5c2V={aYPf!=@&mnS)f^Wh1O4%wq9?@1STEYT+#PqAELh;y8q&+aIa_VCwA4x14lB}V zkPKQ~b!5rjo^bgYM9eC6w^(|=+}4T}tmtmuAvMu{JH@=kp8r(V=@q)4RjVwPMm>!s zfjh8rU9`1ekhIQgs)t&%HX+;dll^U@6vRm`$TLg5~(eAV7FTqbnATZ1U7 zvBk%-lft?>@qvDtjTYQy+d(0A;V#iZn^I17u1 z(&tGyz)Em_3_Od#RoNUi&!?V#gOE=vopMat4ENh_I&0rTK0Q4lb;#kjXRbgrdH~}T zwW9K;IIF@H$&