diff --git a/.github/skills/coding-style.md b/.github/skills/coding-style.md new file mode 100644 index 00000000..422b5f76 --- /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 00000000..615169b5 --- /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 00000000..808c2a64 --- /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 00000000..ebc0733c --- /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 00000000..1a899446 --- /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 66874cb8..f8fa56aa 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 0745d674..4b8e4af7 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/atest/resources/cli_output/version.txt b/atest/resources/cli_output/version.txt index 7ce24ee9..3ddfdc6d 100644 --- a/atest/resources/cli_output/version.txt +++ b/atest/resources/cli_output/version.txt @@ -6,4 +6,4 @@ |_| \_\\___/|____/ \___/ |_| |____/_/ \_|____/|_| |_|____/ \___/_/ \_|_| \_|____/ ====================================================================================== -Robotdashboard 1.6.2 \ No newline at end of file +Robotdashboard 1.7.0 \ No newline at end of file diff --git a/atest/resources/dashboard_output/keyword/baseKeywordSection.png b/atest/resources/dashboard_output/keyword/baseKeywordSection.png index 9d26a831..a62ec114 100644 Binary files a/atest/resources/dashboard_output/keyword/baseKeywordSection.png and b/atest/resources/dashboard_output/keyword/baseKeywordSection.png differ diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 56b20309..12dfc3cc 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: [ { @@ -86,6 +97,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 e299b82c..2cef9321 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 c8b5fadd..422c46cb 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. @@ -94,45 +101,15 @@ robotdashboard -o output_my_alias2.xml robotdashboard -f ./nightly_runs ``` -## Advanced UseLogs Information - -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). +## Log Linking -### 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 e3c28629..d514288b 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 @@ -137,14 +142,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/custom-database-class.md b/docs/custom-database-class.md index 52c289d4..4dc5e86c 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 34e36848..ea8f1e89 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 8d01dff3..62c56876 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. @@ -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 e29803cc..1b0cd2d0 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 bc65cec3..d3435087 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 @@ -38,7 +62,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,8 +92,21 @@ 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 bde061e3..fc1acd86 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/installation-version-info.md b/docs/installation-version-info.md index fd57ddc9..30bf0909 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 new file mode 100644 index 00000000..ac6e9a7a --- /dev/null +++ b/docs/log-linking.md @@ -0,0 +1,106 @@ +--- +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). + +## 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: + +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/docs/settings.md b/docs/settings.md index fc08bad1..152cad2d 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 85c44bd6..948dc189 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 b3cf1a67..5b4c019e 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 3aa5464e..1f2eb64b 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/example/robot_dashboard.html b/example/robot_dashboard.html index 0285a899..cf450c22 100644 --- a/example/robot_dashboard.html +++ b/example/robot_dashboard.html @@ -1,7 +1,7 @@ -Robot Framework Dashboard - 2026-02-11 22:57:34 +Robot Framework Dashboard - 2026-02-28 21:04:57 @@ -17,7 +17,10 @@ @@ -1189,13 +1196,17 @@

Settings

aria-controls="overview-settings" aria-selected="false">Overview +
+aria-labelledíby="general-tab">

Configure graph display settings and dashboard behavior.

@@ -1359,6 +1370,47 @@

Settings

Enable or disable libraries used for the keyword graphs.

+
+

Customize the dashboard colors to your preference.

+
+
+Background Color +
+ + +
+
+
+Card Color +
+ + +
+
+
+Highlight Color +
+ + +
+
+
+Text Color +
+ + +
+
+
+
@@ -1567,7 +1619,108 @@
Keyword Filters
}, 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)); +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); +} +} // === graphmetadata.js === +// 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", @@ -1575,21 +1728,7 @@
Keyword Filters
defaultType: "percentages", viewOptions: ["Percentages", "Line", "Amount"], hasFullscreenButton: true, -html: `
-
Statistics
-
- - - - - - - -
-
-
- -
`, +html: _graphHtml("runStatistics", "Statistics", ["Percentages", "Line", "Amount"]), }, { key: "runDonut", @@ -1709,20 +1848,7 @@
Stats
defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, -html: `
-
Duration
-
- - - - - - -
-
-
- -
`, +html: _graphHtml("runDuration", "Duration", ["Bar", "Line"]), }, { key: "runHeatmap", @@ -1835,21 +1961,7 @@
Folders
defaultType: "percentages", viewOptions: ["Percentages", "Line", "Amount"], hasFullscreenButton: true, -html: `
-
Statistics
-
- - - - - - - -
-
-
- -
`, +html: _graphHtml("suiteStatistics", "Statistics", ["Percentages", "Line", "Amount"]), }, { key: "suiteDuration", @@ -1857,20 +1969,7 @@
Statistics
defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, -html: `
-
Duration
-
- - - - - - -
-
-
- -
`, +html: _graphHtml("suiteDuration", "Duration", ["Bar", "Line"]), }, { key: "suiteMostFailed", @@ -1878,22 +1977,7 @@
Duration
defaultType: "bar", viewOptions: ["Bar", "Timeline"], hasFullscreenButton: true, -html: `
-
Most Failed
-
- - - - - - -
-
-
-
- -
-
`, +html: _graphHtml("suiteMostFailed", "Most Failed", ["Bar", "Timeline"], { hasVertical: true }), }, { key: "suiteMostTimeConsuming", @@ -1928,7 +2012,7 @@
Most Time Consuming
key: "testStatistics", label: "Test Statistics", defaultType: "timeline", -viewOptions: ["Timeline"], +viewOptions: ["Timeline", "Line"], hasFullscreenButton: true, html: `
Statistics
@@ -1951,6 +2035,7 @@
Statistics
+ @@ -1969,20 +2054,7 @@
Statistics
defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, -html: `
-
Duration
-
- - - - - - -
-
-
- -
`, +html: _graphHtml("testDuration", "Duration", ["Bar", "Line"]), }, { key: "testDurationDeviation", @@ -1990,19 +2062,7 @@
Duration
defaultType: "bar", viewOptions: ["Bar"], hasFullscreenButton: true, -html: `
-
Duration Deviation
-
- - - - - -
-
-
- -
`, +html: _graphHtml("testDurationDeviation", "Duration Deviation", ["Bar"], { viewClassOverrides: { "Bar": "boxplot-graph" } }), }, { key: "testMessages", @@ -2010,22 +2070,7 @@
Duration Deviation
defaultType: "timeline", viewOptions: ["Bar", "Timeline"], hasFullscreenButton: true, -html: `
-
Messages
-
- - - - - - -
-
-
-
- -
-
`, +html: _graphHtml("testMessages", "Messages", ["Bar", "Timeline"], { hasVertical: true }), }, { key: "testMostFlaky", @@ -2091,22 +2136,7 @@
Recent Most Flaky
defaultType: "timeline", viewOptions: ["Bar", "Timeline"], hasFullscreenButton: true, -html: `
-
Most Failed
-
- - - - - - -
-
-
-
- -
-
`, +html: _graphHtml("testMostFailed", "Most Failed", ["Bar", "Timeline"], { hasVertical: true }), }, { key: "testRecentMostFailed", @@ -2114,22 +2144,7 @@
Most Failed
defaultType: "timeline", viewOptions: ["Bar", "Timeline"], hasFullscreenButton: true, -html: `
-
Recent Most Failed
-
- - - - - - -
-
-
-
- -
-
`, +html: _graphHtml("testRecentMostFailed", "Recent Most Failed", ["Bar", "Timeline"], { hasVertical: true }), }, { key: "testMostTimeConsuming", @@ -2166,21 +2181,7 @@
Most Time Consuming
defaultType: "percentages", viewOptions: ["Percentages", "Line", "Amount"], hasFullscreenButton: true, -html: `
-
Statistics
-
- - - - - - - -
-
-
- -
`, +html: _graphHtml("keywordStatistics", "Statistics", ["Percentages", "Line", "Amount"]), }, { key: "keywordTimesRun", @@ -2188,20 +2189,7 @@
Statistics
defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, -html: `
-
Times Run
-
- - - - - - -
-
-
- -
`, +html: _graphHtml("keywordTimesRun", "Times Run", ["Bar", "Line"]), }, { key: "keywordTotalDuration", @@ -2209,20 +2197,7 @@
Times Run
defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, -html: `
-
Total Duration
-
- - - - - - -
-
-
- -
`, +html: _graphHtml("keywordTotalDuration", "Total Duration", ["Bar", "Line"]), }, { key: "keywordAverageDuration", @@ -2230,20 +2205,7 @@
Total Duration
defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, -html: `
-
Average Duration
-
- - - - - - -
-
-
- -
`, +html: _graphHtml("keywordAverageDuration", "Average Duration", ["Bar", "Line"]), }, { key: "keywordMinDuration", @@ -2251,20 +2213,7 @@
Average Duration
defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, -html: `
-
Min Duration
-
- - - - - - -
-
-
- -
`, +html: _graphHtml("keywordMinDuration", "Min Duration", ["Bar", "Line"]), }, { key: "keywordMaxDuration", @@ -2272,20 +2221,7 @@
Min Duration
defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, -html: `
-
Max Duration
-
- - - - - - -
-
-
- -
>`, +html: _graphHtml("keywordMaxDuration", "Max Duration", ["Bar", "Line"]), }, { key: "keywordMostFailed", @@ -2293,22 +2229,7 @@
Max Duration
defaultType: "timeline", viewOptions: ["Bar", "Timeline"], hasFullscreenButton: true, -html: `
-
Most Failed
-
- - - - - - -
-
-
-
- -
`, +html: _graphHtml("keywordMostFailed", "Most Failed", ["Bar", "Timeline"], { hasVertical: true }), }, { key: "keywordMostTimeConsuming", @@ -2374,19 +2295,7 @@
Most Used
defaultType: "bar", viewOptions: ["Bar"], hasFullscreenButton: true, -html: `
-
Statistics
-
- - - - - -
-
-
- -
`, +html: _graphHtml("compareStatistics", "Statistics", ["Bar"], { titleId: false }), }, { key: "compareSuiteDuration", @@ -2394,19 +2303,7 @@
Statistics
defaultType: "radar", viewOptions: ["Radar"], hasFullscreenButton: true, -html: `
-
Suite Duration
-
- - - - - -
-
-
- -
`, +html: _graphHtml("compareSuiteDuration", "Suite Duration", ["Radar"], { titleId: false }), }, { key: "compareTests", @@ -2454,18 +2351,7 @@
Tests
viewOptions: ["Table"], hasFullscreenButton: false, information: null, -html: `
-
-
Run Table
-
- - - - -
-
-
-
`, +html: _tableHtml("runTable", "Run"), }, { key: "suiteTable", @@ -2474,18 +2360,7 @@
Run Table
viewOptions: ["Table"], hasFullscreenButton: false, information: null, -html: `
-
-
Suite Table
-
- - - - -
-
-
-
`, +html: _tableHtml("suiteTable", "Suite"), }, { key: "testTable", @@ -2494,18 +2369,7 @@
Suite Table
viewOptions: ["Table"], hasFullscreenButton: false, information: null, -html: `
-
-
Test Table
-
- - - - -
-
-
-
`, +html: _tableHtml("testTable", "Test"), }, { key: "keywordTable", @@ -2514,18 +2378,7 @@
Test Table
viewOptions: ["Table"], hasFullscreenButton: false, information: null, -html: `
-
-
Keyword Table
-
- - - - -
-
-
-
`, +html: _tableHtml("keywordTable", "Keyword"), }, ]; // === graphs.js === @@ -2605,6 +2458,24 @@
Keyword Table
rounding: 6, prefixes: true, }, +theme_colors: { +light: { +background: '#eee', +card: '#ffffff', +highlight: '#3451b2', +text: '#000000', +}, +dark: { +background: '#0f172a', +card: 'rgba(30, 41, 59, 0.9)', +highlight: '#a8b1ff', +text: '#eee', +}, +custom: { +light: {}, +dark: {}, +} +}, menu: { overview: false, dashboard: true, @@ -2677,7 +2548,7 @@
Keyword Table
const decompressedData = pako.inflate(compressedData, { to: 'string' }); return JSON.parse(decompressedData); } -var unified_dashboard_title = 'Robot Framework Dashboard - 2026-02-11 22:57:34' +var unified_dashboard_title = 'Robot Framework Dashboard - 2026-02-28 21:04:57' var message_config = '"placeholder_message_config"' var force_json_config = false var json_config = "placeholder_json_config" @@ -3015,6 +2886,9 @@
Keyword Table
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); } @@ -3101,6 +2975,16 @@
Keyword Table
result.hide = [...localHide]; 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; @@ -3180,12 +3064,14 @@
Keyword Table
} // === log.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] @@ -3205,7 +3091,7 @@
Keyword Table
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) } // function to open the log file @@ -3249,7 +3135,7 @@
Keyword Table
} } // 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 @@ -3267,7 +3153,9 @@
Keyword Table
} 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") { @@ -4056,6 +3944,9 @@
Keyword Table
} } function handle_overview_latest_version_selection(overviewVersionSelectorList, latestRunByProject) { +show_loading_overlay(); +requestAnimationFrame(() => { +requestAnimationFrame(() => { const selectedOptions = Array.from( overviewVersionSelectorList.querySelectorAll("input:checked") ).map(inputElement => inputElement.value); @@ -4069,6 +3960,9 @@
Keyword Table
create_overview_latest_graphs(filteredLatestRunByProject); } update_overview_latest_heading(); +hide_loading_overlay(); +}); +}); } // this function updates the version select list in the latest runs bar function update_overview_version_select_list() { @@ -4668,7 +4562,7 @@
Keyword Table
"themeLight": "Theme", "themeDark": "Theme", "database": "Database Summary", -"versionInformation": 'Robotdashboard 1.6.2', +"versionInformation": 'Robotdashboard 1.7.0', "bug": "Report a bug or request a feature", "github": "Github", "docs": "Docs", @@ -4706,41 +4600,21 @@
Keyword Table
"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. @@ -4748,209 +4622,107 @@
Keyword Table
- 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", +"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", -"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"; +}); // === information.js === // Map decoration names to holiday greetings const holidayGreetings = { @@ -5425,7 +5197,9 @@
Keyword Table
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(); document.dispatchEvent(new Event("graphs-finalized")); @@ -5602,6 +5376,169 @@

`; } +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 => @@ -5663,13 +5600,13 @@

const percentageSelector = document.getElementById("overviewLatestDurationPercentage"); if (percentageSelector) { percentageSelector.addEventListener('change', () => { +show_loading_overlay(); +requestAnimationFrame(() => { +requestAnimationFrame(() => { create_overview_latest_graphs(); +hide_loading_overlay(); +}); }); -} -const sortSelector = document.getElementById("overviewLatestSectionOrder"); -if (sortSelector) { -sortSelector.addEventListener('change', () => { -create_overview_latest_graphs(); }); } const versionFilterSearch = document.getElementById("overviewLatestVersionFilterSearch"); @@ -6081,142 +6018,22 @@
Total Runs: ${totalRunsAmount} | Passed Runs: ${passRate}%
config.options.plugins.legend.display = false; 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 }) { -const cardsContainerElement = document.getElementById(cardsContainerId); -const scrollOffsetBefore = cardsContainerElement.getBoundingClientRect().top; -const versionDropDownFilter = document.getElementById(versionDropDownFilterId); -const dropDownCheckBoxes = versionDropDownFilter.querySelectorAll(".version-checkbox"); -const selectedVersions = Array.from(dropDownCheckBoxes) -.filter(checkBox => checkBox.checked) -.map(checkBox => checkBox.value); -const runCardNodeList = cardsContainerElement.querySelectorAll("div.overview-card"); -const runCardsArray = Array.from(runCardNodeList); -let dropDownFilteredRunCards = runCardsArray; -if (!selectedVersions.includes("All")) { -dropDownFilteredRunCards = runCardsArray.filter(runCard => -selectedVersions.includes(runCard.dataset.projectVersion) -); +// apply version select checkbox and version textinput filter +function update_project_version_filter_run_card_visibility({ cardsContainerId, versionDropDownFilterId, versionStringFilterId }) { +const cardsContainerElement = document.getElementById(cardsContainerId); +const scrollOffsetBefore = cardsContainerElement.getBoundingClientRect().top; +const versionDropDownFilter = document.getElementById(versionDropDownFilterId); +const dropDownCheckBoxes = versionDropDownFilter.querySelectorAll(".version-checkbox"); +const selectedVersions = Array.from(dropDownCheckBoxes) +.filter(checkBox => checkBox.checked) +.map(checkBox => checkBox.value); +const runCardNodeList = cardsContainerElement.querySelectorAll("div.overview-card"); +const runCardsArray = Array.from(runCardNodeList); +let dropDownFilteredRunCards = runCardsArray; +if (!selectedVersions.includes("All")) { +dropDownFilteredRunCards = runCardsArray.filter(runCard => +selectedVersions.includes(runCard.dataset.projectVersion) +); } const versionStringFilter = document.getElementById(versionStringFilterId); const lowerCaseVersionStringFilterValue = versionStringFilter.value.toLowerCase(); @@ -6233,59 +6050,11 @@
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_latest_heading() { -const overviewCardsContainer = document.getElementById("overviewLatestRunCardsContainer"); -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 -${headerContent} -`; +_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() { const latestSection = document.getElementById("overviewLatestRunsSection"); @@ -6578,22 +6347,23 @@
} return [statisticsData, names]; } -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( +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") -)]; -const [runStarts, datasets] = [[], []]; -var labels = []; -function getTestLabel(test) { +)], +}; +} +function _get_test_label(test) { if (settings.menu.dashboard) { return settings.switch.suitePathsTestSection ? test.full_name : test.name; } else if (settings.menu.compare) { @@ -6601,25 +6371,34 @@
} return test.name; } -for (const test of filteredTests) { +function _should_skip_test(test, filters) { if (settings.menu.dashboard) { const testBaseName = test.name; -if (suiteSelectTests !== "All") { -const expectedFull = `${suiteSelectTests}.${testBaseName}`; +if (filters.suiteSelectTests !== "All") { +const expectedFull = `${filters.suiteSelectTests}.${testBaseName}`; const isMatch = settings.switch.suitePathsTestSection ? test.full_name === expectedFull -: test.full_name.includes(`.${suiteSelectTests}.${testBaseName}`) || test.full_name === expectedFull; -if (!isMatch) continue; +: test.full_name.includes(`.${filters.suiteSelectTests}.${testBaseName}`) || test.full_name === expectedFull; +if (!isMatch) return true; } -if (testSelect !== "All" && testBaseName !== testSelect) continue; -if (testTagsSelect !== "All") { +if (filters.testSelect !== "All" && testBaseName !== filters.testSelect) return true; +if (filters.testTagsSelect !== "All") { const tagList = test.tags.replace(/\[|\]/g, "").split(","); -if (!tagList.includes(testTagsSelect)) continue; +if (!tagList.includes(filters.testTagsSelect)) return true; } } else if (settings.menu.compare) { -if (!(selectedRuns.includes(test.run_start) || selectedRuns.includes(test.run_alias))) continue; +if (!(filters.selectedRuns.includes(test.run_start) || filters.selectedRuns.includes(test.run_alias))) return true; } -const testLabel = getTestLabel(test); +return false; +} +function get_test_statistics_data(filteredTests) { +const filters = _get_test_filters(); +const [runStarts, datasets] = [[], []]; +const testMetaMap = {}; +var labels = []; +for (const test of filteredTests) { +if (_should_skip_test(test, filters)) continue; +const testLabel = _get_test_label(test); if (!labels.includes(testLabel)) { labels.push(testLabel); } @@ -6628,6 +6407,7 @@
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 : @@ -6639,22 +6419,27 @@
...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; @@ -6666,17 +6451,16 @@
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) ); })); } @@ -6687,7 +6471,84 @@
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 function get_compare_statistics_graph_data(filteredData) { @@ -7242,14 +7103,95 @@
data.averagePassRate = `${Math.round(passRates.reduce((a, b) => a + b, 0) / passRates.length)}%`; return data; } -// === run.js === -// function to create run statistics graph in the run section -function create_run_statistics_graph() { -if (runStatisticsGraph) { -runStatisticsGraph.destroy(); +// === tooltip_helpers.js === +// 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}`; +} +// === chart_factory.js === +// Generic chart create function - replaces boilerplate create_X_graph() pattern +function create_chart(chartId, buildConfigFn, addLogClickHandler = true) { +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) { +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(); } +// === run.js === +// build functions +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); @@ -7258,17 +7200,17 @@
} 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 } -runStatisticsGraph = new Chart("runStatisticsGraph", 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(); +return config; } +function _build_run_donut_config() { const data = get_donut_graph_data("run", filteredRuns); const graphData = data[0] const callbackData = data[1] @@ -7286,42 +7228,18 @@
targetCanvas.style.cursor = 'default'; } }; -runDonutGraph = new Chart("runDonutGraph", config); -} -// function to create run donut graph in the run section -function create_run_donut_total_graph() { -if (runDonutTotalGraph) { -runDonutTotalGraph.destroy(); +return config; } +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); -} -// 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 -} -// function to create run duration graph in the run section -function create_run_duration_graph() { -if (runDurationGraph) { -runDurationGraph.destroy(); +return config; } +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; @@ -7329,17 +7247,15 @@
} 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 } -runDurationGraph = new Chart("runDurationGraph", 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(); +return config; } +function _build_run_heatmap_config() { const data = get_heatmap_graph_data(filteredTests); const graphData = data[0] const callbackData = data[1] @@ -7362,8 +7278,38 @@
stepSize: 1, callback: val => callbackData[val] || '' } -runHeatmapGraph = new Chart("runHeatmapGraph", config); +return config; +} +// 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 functions +function update_run_statistics_graph() { update_chart("runStatisticsGraph", _build_run_statistics_config); } +function update_run_donut_graph() { update_chart("runDonutGraph", _build_run_donut_config, false); } +function update_run_donut_total_graph() { update_chart("runDonutTotalGraph", _build_run_donut_total_config, false); } +function update_run_stats_graph() { +create_run_stats_graph(); } +function update_run_duration_graph() { update_chart("runDurationGraph", _build_run_duration_config); } +function update_run_heatmap_graph() { update_chart("runHeatmapGraph", _build_run_heatmap_config, false); } // === failed.js === // function to prepare the data in the correct format for most failed graphs function get_most_failed_data(dataType, graphType, filteredData, recent) { @@ -7445,6 +7391,7 @@
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 => @@ -7454,6 +7401,14 @@
); 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 }], @@ -7470,38 +7425,189 @@
labels, datasets, }; -return [graphData, runStartsArray]; +return [graphData, runStartsArray, pointMeta]; } } -// === time_consuming.js === -// function to prepare the most time consuming or most used data for suites/tests/keywords -function get_most_time_consuming_or_most_used_data(dataType, graphType, filteredData, onlyLastRun, mostUsed = false) { -const useLibraryNames = settings?.switch?.useLibraryNames === true; -function getTestKey(value, dataType) { -if (dataType === "keyword" && useLibraryNames && value.owner) { -return `${value.owner}.${value.name}`; +// === flaky.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, limit) { +var data = {}; +for (const value of filteredData) { +if (ignore && value.skipped == 1) { +continue; } -if (dataType === "suite" && settings.switch.suitePathsSuiteSection) { -return value.full_name; -} else if (dataType === "test" && settings.switch.suitePathsTestSection) { -return value.full_name; -} else { -return value.name; +const key = settings.switch.suitePathsTestSection ? value.full_name : value.name; +if (data[key]) { +data[key]["run_starts"].push(value.run_start); +let current_status; +if (value.passed == 1) { +current_status = "passed"; +} else if (value.failed == 1) { +current_status = "failed"; +data[key]["failed_run_starts"].push(value.run_start); +} else if (!ignore) { +if (value.skipped == 1) { +current_status = "skipped"; +data[key]["failed_run_starts"].push(value.run_start); } } -const limit = inFullscreen && inFullscreenGraph.includes("MostTimeConsuming") ? 50 : 10; -if (onlyLastRun) { -const latestRunStart = filteredData[filteredData.length - 1].run_start; -filteredData = filteredData.filter( -(item) => item.run_start === latestRunStart -); -filteredData.sort((a, b) => { -if (mostUsed) { -return Number(b.times_run) - Number(a.times_run); +if (current_status !== data[key]["previous_status"]) { +data[key]["flips"] += 1; +data[key]["previous_status"] = current_status; +} } else { -const durA = -dataType === "keyword" -? Number(a.total_time_s) +let previous_status; +data[key] = { +"run_starts": [value.run_start], +"flips": 0, +"failed_run_starts": [] +}; +if (value.passed == 1) { +previous_status = "passed"; +} else if (value.failed == 1) { +previous_status = "failed"; +data[key]["failed_run_starts"].push(value.run_start); +} else if (!ignore) { +if (value.skipped == 1) { +previous_status = "skipped"; +data[key]["failed_run_starts"].push(value.run_start); +} +} +data[key]["previous_status"] = previous_status; +} +} +var sortedData = []; +for (var test in data) { +if (data[test].flips > 0) { +sortedData.push([test, data[test]]); +} +} +sortedData.sort(function (a, b) { +return b[1].flips - a[1].flips; +}); +if (recent) { // do extra filtering to get most recent flaky tests at the top +sortedData.sort(function (a, b) { +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() +}) +} +if (graphType == "bar") { +var [datasets, labels, count] = [[], [], 0]; +for (const key in sortedData) { +if (count == limit) { +break; +} +labels.push(sortedData[key][0]); +datasets.push(sortedData[key][1].flips); +count += 1; +} +const graphData = { +labels, +datasets: [{ +data: datasets, +...failedConfig, +}], +}; +return [graphData, data]; +} else if (graphType == "timeline") { +var [labels, runStarts, count, run_aliases] = [[], [], 0, []]; +for (const key in sortedData) { +if (count == limit) { +break; +} +labels.push(sortedData[key][0]); +for (const runStart of sortedData[key][1].run_starts) { +if (!runStarts.includes(runStart)) { +runStarts.push(runStart); +} +} +count += 1; +} +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) { +var foundValues = []; +for (value of filteredData) { +const compareKey = settings.switch.suitePathsTestSection ? value.full_name : value.name; +if (compareKey == label && value.run_start == runStart) { +// if (value.name == label && value.run_start == runStart) { +foundValues.push(value); +if (!run_aliases.includes(value.run_alias)) { run_aliases.push(value.run_alias) } +} +} +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, +data: [{ x: [runAxis, runAxis + 1], y: label }], +...passedConfig, +}); +} +else if (value.failed == 1) { +datasets.push({ +label: label, +data: [{ x: [runAxis, runAxis + 1], y: label }], +...failedConfig, +}); +} +else if (value.skipped == 1) { +datasets.push({ +label: label, +data: [{ x: [runAxis, runAxis + 1], y: label }], +...skippedConfig, +}); +} +} +} +runAxis += 1; +} +if (settings.show.aliases) { runStarts = run_aliases } +datasets = convert_timeline_data(datasets) +var graphData = { +labels: labels, +datasets: datasets, +}; +return [graphData, runStarts, pointMeta]; +} +} +// === time_consuming.js === +// function to prepare the most time consuming or most used data for suites/tests/keywords +function get_most_time_consuming_or_most_used_data(dataType, graphType, filteredData, onlyLastRun, mostUsed = false) { +const useLibraryNames = settings?.switch?.useLibraryNames === true; +function getTestKey(value, dataType) { +if (dataType === "keyword" && useLibraryNames && value.owner) { +return `${value.owner}.${value.name}`; +} +if (dataType === "suite" && settings.switch.suitePathsSuiteSection) { +return value.full_name; +} else if (dataType === "test" && settings.switch.suitePathsTestSection) { +return value.full_name; +} else { +return value.name; +} +} +const limit = inFullscreen && inFullscreenGraph.includes("MostTimeConsuming") ? 50 : 10; +if (onlyLastRun) { +const latestRunStart = filteredData[filteredData.length - 1].run_start; +filteredData = filteredData.filter( +(item) => item.run_start === latestRunStart +); +filteredData.sort((a, b) => { +if (mostUsed) { +return Number(b.times_run) - Number(a.times_run); +} else { +const durA = +dataType === "keyword" +? Number(a.total_time_s) : Number(a.elapsed_s); const durB = dataType === "keyword" @@ -7528,7 +7634,10 @@
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); @@ -7538,7 +7647,10 @@
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, }); } } @@ -7548,7 +7660,10 @@
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, }); } } @@ -7562,7 +7677,10 @@
} 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, }; } for (const entry of perRunEntries) { @@ -7656,93 +7774,258 @@
]; } } -// === suite.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(); -} -const data = get_donut_folder_graph_data("suite", filteredSuites, folder); -const graphData = data[0] -const callbackData = data[1] -const labels = graphData.labels -var config = get_graph_config("donut", graphData, "All Folders"); -config.options.plugins.tooltip.callbacks = { +// === config_helpers.js === +// Shared timeline scale/tooltip config used by most failed, flaky, and time consuming graphs +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) { -const label = labels[context.dataIndex] -const passed = callbackData[label].passed -const failed = callbackData[label].failed -const skipped = callbackData[label].skipped -return [`Total: ${context.raw}`, `Passed: ${passed}`, `Failed: ${failed}`, `Skipped: ${skipped}`]; -}, -title: function (tooltipItems) { -const fullTitle = tooltipItems[0].label; -const maxLineLength = 30; -const lines = fullTitle.match(new RegExp('.{1,' + maxLineLength + '}', 'g')) || [fullTitle]; -return lines; -} +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}`); } -config.options.onClick = (event) => { -if (event.chart.tooltip.title) { -setTimeout(() => { -create_suite_folder_donut_graph(event.chart.tooltip.title.join('')); -}, 0); +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; +}, +}, }; -config.options.onHover = function (event, chartElement) { -const targetCanvas = event.native.target; -if (chartElement.length > 0) { -targetCanvas.style.cursor = 'pointer'; -} else { -targetCanvas.style.cursor = 'default'; +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); } }; -suiteFolderDonutGraph = new Chart("suiteFolderDonutGraph", config); +if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false; } } -// function to create suite last failed donut -function create_suite_folder_fail_donut_graph() { -if (suiteFolderFailDonutGraph) { -suiteFolderFailDonutGraph.destroy(); +// 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 pointMeta = data[2] || null; +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, pointMeta, dataType); } -const data = get_donut_folder_fail_graph_data("suite", filteredSuites); -const graphData = data[0] -const callbackData = data[1] -const labels = graphData.labels -if (graphData.labels.length == 0) { -graphData.labels = ["No Failed Folders In Last Run"] -graphData.datasets = [{ -data: [1], -backgroundColor: ["grey"], -}] +update_height(`${graphKey}Vertical`, config.data.labels.length, graphType); +return config; } -var config = get_graph_config("donut", graphData, "Last Run"); -config.options.plugins.tooltip.callbacks = { -label: function (context) { -if (context.label == "No Failed Folders In Last Run") { return null } -const label = labels[context.dataIndex] -const passed = callbackData[label].passed -const failed = callbackData[label].failed -const skipped = callbackData[label].skipped -return [`Passed: ${passed}`, `Failed: ${failed}`, `Skipped: ${skipped}`]; -}, -title: function (tooltipItem) { -return tooltipItem.label; +// 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 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 pointMeta = data[2] || null; +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, pointMeta, dataType); } +update_height(`${graphKey}Vertical`, config.data.labels.length, graphType); +return config; } -config.options.plugins.datalabels = { -...dataLabelConfig, -formatter: function (value, context) { -if (value === 0) return null; -const total = graphData.datasets[0].data.reduce((a, b) => a + b, 0); -const percentage = Math.round((value / total) * 100); +// 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)`; +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; +} +}, +}; +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; +} +// === suite.js === +// build functions +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] +const labels = graphData.labels +var config = get_graph_config("donut", graphData, "All Folders"); +config.options.plugins.tooltip.callbacks = { +label: function (context) { +const label = labels[context.dataIndex] +const passed = callbackData[label].passed +const failed = callbackData[label].failed +const skipped = callbackData[label].skipped +return [`Total: ${context.raw}`, `Passed: ${passed}`, `Failed: ${failed}`, `Skipped: ${skipped}`]; +}, +title: function (tooltipItems) { +const fullTitle = tooltipItems[0].label; +const maxLineLength = 30; +const lines = fullTitle.match(new RegExp('.{1,' + maxLineLength + '}', 'g')) || [fullTitle]; +return lines; +} +} +config.options.onClick = (event) => { +if (event.chart.tooltip.title) { +setTimeout(() => { +update_graphs_with_loading( +["suiteFolderDonutGraph", "suiteFolderFailDonutGraph", "suiteStatisticsGraph", "suiteDurationGraph"], +() => { update_suite_folder_donut_graph(event.chart.tooltip.title.join('')); } +); +}, 0); +} +}; +config.options.onHover = function (event, chartElement) { +const targetCanvas = event.native.target; +if (chartElement.length > 0) { +targetCanvas.style.cursor = 'pointer'; +} else { +targetCanvas.style.cursor = 'default'; +} +}; +return config; +} +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] +const labels = graphData.labels +if (graphData.labels.length == 0) { +graphData.labels = ["No Failed Folders In Last Run"] +graphData.datasets = [{ +data: [1], +backgroundColor: ["grey"], +}] +} +var config = get_graph_config("donut", graphData, "Last Run"); +config.options.plugins.tooltip.callbacks = { +label: function (context) { +if (context.label == "No Failed Folders In Last Run") { return null } +const label = labels[context.dataIndex] +const passed = callbackData[label].passed +const failed = callbackData[label].failed +const skipped = callbackData[label].skipped +return [`Passed: ${passed}`, `Failed: ${failed}`, `Skipped: ${skipped}`]; +}, +title: function (tooltipItem) { +return tooltipItem.label; +} +} +config.options.plugins.datalabels = { +...dataLabelConfig, +formatter: function (value, context) { +if (value === 0) return null; +const total = graphData.datasets[0].data.reduce((a, b) => a + b, 0); +const percentage = Math.round((value / total) * 100); if (percentage <= 5) return null; const label = graphData.labels[context.dataIndex].split(".").pop(); return `${label}: ${value} (${percentage}%)`; @@ -7751,7 +8034,10 @@
config.options.onClick = (event) => { if (event.chart.tooltip.title) { setTimeout(() => { -create_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); } }; @@ -7763,16 +8049,16 @@
targetCanvas.style.cursor = 'default'; } }; -suiteFolderFailDonutGraph = new Chart("suiteFolderFailDonutGraph", config); -} -// function to create suite statistics graph in the suite section -function create_suite_statistics_graph() { -if (suiteStatisticsGraph) { -suiteStatisticsGraph.destroy(); +return config; } +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); @@ -7780,6 +8066,11 @@
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 ''; } } } @@ -7791,6 +8082,11 @@
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 ''; } } } @@ -7802,22 +8098,25 @@
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 ''; } } } } if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } -suiteStatisticsGraph = new Chart("suiteStatisticsGraph", 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(); +return config; } +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; @@ -7825,147 +8124,59 @@
} else if (settings.graphTypes.suiteDurationGraphType == "line") { config = get_graph_config("line", graphData, "", "Date", "Duration"); } -if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } -suiteDurationGraph = new Chart("suiteDurationGraph", 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(); -} -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) -} +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; } -update_height("suiteMostFailedVertical", config.data.labels.length, settings.graphTypes.suiteMostFailedGraphType); -suiteMostFailedGraph = new Chart("suiteMostFailedGraph", 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(); -} -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 } +function _build_suite_most_failed_config() { +return build_most_failed_config("suiteMostFailed", "suite", "Suite", filteredSuites, false); } -update_height("suiteMostTimeConsumingVertical", config.data.labels.length, settings.graphTypes.suiteMostTimeConsumingGraphType); -suiteMostTimeConsumingGraph = new Chart("suiteMostTimeConsumingGraph", config); -suiteMostTimeConsumingGraph.canvas.addEventListener("click", (event) => { -open_log_from_label(suiteMostTimeConsumingGraph, event) -}); +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); } +// update functions +function update_suite_folder_donut_graph(folder) { +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(); +} +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); } // === messages.js === // function to prepare the data in the correct format for messages graphs function get_messages_data(dataType, graphType, filteredData) { @@ -8042,6 +8253,7 @@
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) @@ -8052,6 +8264,11 @@
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 }], @@ -8068,7 +8285,7 @@
labels, datasets, }; -return [graphData, runStartsArray]; +return [graphData, runStartsArray, pointMeta]; } } // === duration_deviation.js === @@ -8116,167 +8333,38 @@
}], }; } -// === flaky.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) { -var data = {}; -for (const value of filteredData) { -const key = settings.switch.suitePathsTestSection ? value.full_name : value.name; -if (data[key]) { -data[key]["run_starts"].push(value.run_start); -let current_status; -if (value.passed == 1) { -current_status = "passed"; -} else if (value.failed == 1) { -current_status = "failed"; -data[key]["failed_run_starts"].push(value.run_start); -} else if (!ignore) { -if (value.skipped == 1) { -current_status = "skipped"; -data[key]["failed_run_starts"].push(value.run_start); -} -} -if (current_status !== data[key]["previous_status"]) { -data[key]["flips"] += 1; -data[key]["previous_status"] = current_status; -} -} else { -let previous_status; -data[key] = { -"run_starts": [value.run_start], -"flips": 0, -"failed_run_starts": [] -}; -if (value.passed == 1) { -previous_status = "passed"; -} else if (value.failed == 1) { -previous_status = "failed"; -data[key]["failed_run_starts"].push(value.run_start); -} else if (!ignore) { -if (value.skipped == 1) { -previous_status = "skipped"; -data[key]["failed_run_starts"].push(value.run_start); -} -} -data[key]["previous_status"] = previous_status; -} -} -var sortedData = []; -for (var test in data) { -if (data[test].flips > 0) { -sortedData.push([test, data[test]]); -} -} -sortedData.sort(function (a, b) { -return b[1].flips - a[1].flips; -}); -if (recent) { // do extra filtering to get most recent flaky tests at the top -sortedData.sort(function (a, b) { -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) { -if (count == limit) { -break; -} -labels.push(sortedData[key][0]); -datasets.push(sortedData[key][1].flips); -count += 1; -} -const graphData = { -labels, -datasets: [{ -data: datasets, -...failedConfig, -}], -}; -return [graphData, data]; -} else if (graphType == "timeline") { -var [labels, runStarts, count, run_aliases] = [[], [], 0, []]; -for (const key in sortedData) { -if (count == limit) { -break; -} -labels.push(sortedData[key][0]); -for (const runStart of sortedData[key][1].run_starts) { -if (!runStarts.includes(runStart)) { -runStarts.push(runStart); -} -} -count += 1; -} -var datasets = []; -var runAxis = 0; -runStarts = runStarts.sort((a, b) => new Date(a).getTime() - new Date(b).getTime()) -for (const runStart of runStarts) { -for (const label of labels) { -var foundValues = []; -for (value of filteredData) { -const compareKey = settings.switch.suitePathsTestSection ? value.full_name : value.name; -if (compareKey == label && value.run_start == runStart) { -// if (value.name == label && value.run_start == runStart) { -foundValues.push(value); -if (!run_aliases.includes(value.run_alias)) { run_aliases.push(value.run_alias) } -} -} -if (foundValues.length > 0) { -var value = foundValues[0]; -if (value.passed == 1) { -datasets.push({ -label: label, -data: [{ x: [runAxis, runAxis + 1], y: label }], -...passedConfig, -}); -} -else if (value.failed == 1) { -datasets.push({ -label: label, -data: [{ x: [runAxis, runAxis + 1], y: label }], -...failedConfig, -}); -} -else if (value.skipped == 1) { -datasets.push({ -label: label, -data: [{ x: [runAxis, runAxis + 1], y: label }], -...skippedConfig, -}); -} -} -} -runAxis += 1; -} -if (settings.show.aliases) { runStarts = run_aliases } -datasets = convert_timeline_data(datasets) -var graphData = { -labels: labels, -datasets: datasets, -}; -return [graphData, runStarts]; -} -} // === test.js === -// function to create test statistics graph in the test section -function create_test_statistics_graph() { -if (testStatisticsGraph) { -testStatisticsGraph.destroy(); +// build functions +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(); +} +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; }, }, }; @@ -8301,17 +8389,115 @@
}; if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } update_height("testStatisticsVertical", config.data.labels.length, "timeline"); -testStatisticsGraph = new Chart("testStatisticsGraph", config); -testStatisticsGraph.canvas.addEventListener("click", (event) => { -open_log_from_label(testStatisticsGraph, event) -}); +return config; +} +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; +}, +}, +}, +}, +}, +}; +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); } -// function to create test duration graph in the test section -function create_test_duration_graph() { -if (testDurationGraph) { -testDurationGraph.destroy(); } +}; +update_height("testStatisticsVertical", testLabels.length, "timeline"); +return config; +} +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; @@ -8319,20 +8505,24 @@
} else if (settings.graphTypes.testDurationGraphType == "line") { config = get_graph_config("line", graphData, "", "Date", "Duration"); } -if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } -testDurationGraph = new Chart("testDurationGraph", config); -testDurationGraph.canvas.addEventListener("click", (event) => { -open_log_from_label(testDurationGraph, event) -}); +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}`); } -// function to create test messages graph in the test section -function create_test_messages_graph() { -if (testMessagesGraph) { -testMessagesGraph.destroy(); +return lines; +}; +if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } +return config; } +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") { @@ -8364,645 +8554,22 @@
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) -} -}; -config.options.scales.y.ticks = { -callback: function (value, index, ticks) { -return this.getLabelForValue(value).slice(0, 40); -}, -}; -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); -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(); -} -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); -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(); -} -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); -testMostFlakyGraph = new Chart("testMostFlakyGraph", 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(); -} -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); -testRecentMostFlakyGraph = new Chart("testRecentMostFlakyGraph", 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(); -} -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); -testMostFailedGraph = new Chart("testMostFailedGraph", 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(); -} -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); -testRecentMostFailedGraph = new Chart("testRecentMostFailedGraph", 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(); -} -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); -testMostTimeConsumingGraph = new Chart("testMostTimeConsumingGraph", config); -testMostTimeConsumingGraph.canvas.addEventListener("click", (event) => { -open_log_from_label(testMostTimeConsumingGraph, event) -}); -} -// === keyword.js === -// function to keyword statistics graph in the keyword section -function create_keyword_statistics_graph() { -if (keywordStatisticsGraph) { -keywordStatisticsGraph.destroy(); -} -const data = get_statistics_graph_data("keyword", settings.graphTypes.keywordStatisticsGraphType, filteredKeywords); -const graphData = data[0] -var config; -if (settings.graphTypes.keywordStatisticsGraphType == "line") { -config = get_graph_config("line", graphData, "", "Date", "Amount", false); -} else if (settings.graphTypes.keywordStatisticsGraphType == "amount") { -config = get_graph_config("bar", graphData, "", "Run", "Amount"); -} else if (settings.graphTypes.keywordStatisticsGraphType == "percentages") { -config = get_graph_config("bar", graphData, "", "Run", "Percentage"); -} -if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } -keywordStatisticsGraph = new Chart("keywordStatisticsGraph", 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(); -} -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 } -keywordTimesRunGraph = new Chart("keywordTimesRunGraph", 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(); -} -const graphData = get_duration_graph_data("keyword", settings.graphTypes.keywordTotalDurationGraphType, "total_time_s", 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.show.dateLabels) { config.options.scales.x.ticks.display = false } -keywordTotalDurationGraph = new Chart("keywordTotalDurationGraph", 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(); -} -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 } -keywordAverageDurationGraph = new Chart("keywordAverageDurationGraph", 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(); -} -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 } -keywordMinDurationGraph = new Chart("keywordMinDurationGraph", 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(); -} -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 } -keywordMaxDurationGraph = new Chart("keywordMaxDurationGraph", 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(); -} -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); -keywordMostFailedGraph = new Chart("keywordMostFailedGraph", 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(); -} -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); -keywordMostTimeConsumingGraph = new Chart("keywordMostTimeConsumingGraph", 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(); -} -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`; +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; +}, }, }; config.options.scales.x = { @@ -9011,10 +8578,7 @@
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; +return callbackData[this.getLabelForValue(value)]; }, }, title: { @@ -9024,50 +8588,157 @@
}; config.options.onClick = (event, chartElement) => { if (chartElement.length) { -open_log_file(event, chartElement, callbackData.runs) +open_log_file(event, chartElement, callbackData) } }; +config.options.scales.y.ticks = { +callback: function (value, index, ticks) { +return this.getLabelForValue(value).slice(0, 40); +}, +}; 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); -keywordMostUsedGraph.canvas.addEventListener("click", (event) => { -open_log_from_label(keywordMostUsedGraph, event) -}); +update_height("testMessagesVertical", config.data.labels.length, settings.graphTypes.testMessagesGraphType); +return config; } -// === compare.js === -// function to create the compare statistics in the compare section -function create_compare_statistics_graph() { -if (compareStatisticsGraph) { -compareStatisticsGraph.destroy(); +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 +return config; +} +function _build_test_most_flaky_config() { +return build_most_flaky_config("testMostFlaky", "test", filteredTests, ignoreSkips, false); +} +function _build_test_recent_most_flaky_config() { +return build_most_flaky_config("testRecentMostFlaky", "test", filteredTests, ignoreSkipsRecent, true); +} +function _build_test_most_failed_config() { +return build_most_failed_config("testMostFailed", "test", "Test", filteredTests, false); +} +function _build_test_recent_most_failed_config() { +return build_most_failed_config("testRecentMostFailed", "test", "Test", filteredTests, true); +} +function _build_test_most_time_consuming_config() { +return build_most_time_consuming_config("testMostTimeConsuming", "test", "Test", filteredTests, "onlyLastRunTest"); +} +// 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 functions +function update_test_statistics_graph() { update_chart("testStatisticsGraph", _build_test_statistics_config); } +function update_test_duration_graph() { update_chart("testDurationGraph", _build_test_duration_config); } +function update_test_messages_graph() { update_chart("testMessagesGraph", _build_test_messages_config); } +function update_test_duration_deviation_graph() { update_chart("testDurationDeviationGraph", _build_test_duration_deviation_config); } +function update_test_most_flaky_graph() { update_chart("testMostFlakyGraph", _build_test_most_flaky_config); } +function update_test_recent_most_flaky_graph() { update_chart("testRecentMostFlakyGraph", _build_test_recent_most_flaky_config); } +function update_test_most_failed_graph() { update_chart("testMostFailedGraph", _build_test_most_failed_config); } +function update_test_recent_most_failed_graph() { update_chart("testRecentMostFailedGraph", _build_test_recent_most_failed_config); } +function update_test_most_time_consuming_graph() { update_chart("testMostTimeConsumingGraph", _build_test_most_time_consuming_config); } +// === keyword.js === +// build functions +function _build_keyword_statistics_config() { +const data = get_statistics_graph_data("keyword", settings.graphTypes.keywordStatisticsGraphType, filteredKeywords); +const graphData = data[0] +var config; +if (settings.graphTypes.keywordStatisticsGraphType == "line") { +config = get_graph_config("line", graphData, "", "Date", "Amount", false); +} else if (settings.graphTypes.keywordStatisticsGraphType == "amount") { +config = get_graph_config("bar", graphData, "", "Run", "Amount"); +} else if (settings.graphTypes.keywordStatisticsGraphType == "percentages") { +config = get_graph_config("bar", graphData, "", "Run", "Percentage"); +} +if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } +return config; +} +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[`${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 _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"); } +function _build_keyword_most_failed_config() { +return build_most_failed_config("keywordMostFailed", "keyword", "Keyword", filteredKeywords, false); +} +function _build_keyword_most_time_consuming_config() { +return build_most_time_consuming_config("keywordMostTimeConsuming", "keyword", "Keyword", filteredKeywords, "onlyLastRunKeyword"); +} +function _build_keyword_most_used_config() { +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); } +// === compare.js === +// build functions +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); -} -// function to create the compare statistics in the compare section -function create_compare_suite_duration_graph() { -if (compareSuiteDurationGraph) { -compareSuiteDurationGraph.destroy(); +return config; } +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); -} -// function to create the compare statistics in the compare section -function create_compare_tests_graph() { -if (compareTestsGraph) { -compareTestsGraph.destroy(); +return get_graph_config("radar", graphData, ""); } +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; }, }, }; @@ -9092,191 +8763,92 @@
}; if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } update_height("compareTestsVertical", config.data.labels.length, "timeline"); -compareTestsGraph = new Chart("compareTestsGraph", config); -compareTestsGraph.canvas.addEventListener("click", (event) => { -open_log_from_label(compareTestsGraph, event) -}); +return config; } +// 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 functions +function update_compare_statistics_graph() { update_chart("compareStatisticsGraph", _build_compare_statistics_config, false); } +function update_compare_suite_duration_graph() { update_chart("compareSuiteDurationGraph", _build_compare_suite_duration_config, false); } +function update_compare_tests_graph() { update_chart("compareTestsGraph", _build_compare_tests_config); } // === tables.js === -// function to create run table in the run section -function create_run_table() { -if (runTable) { -runTable.destroy(); -} -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, +// 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, ]); } -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: data, -}); -} -// function to create suite table in the suite section -function create_suite_table() { -if (suiteTable) { -suiteTable.destroy(); -} -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, +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, ]); } -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: data, -}); -} -// function to create test table in the test section -function create_test_table() { -if (testTable) { -testTable.destroy(); -} -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 +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, ]); } -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: data, -}); -} -// function to create keyword table in the tables tab -function create_keyword_table() { -if (keywordTable) { -keywordTable.destroy(); -} -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, +function _get_keyword_table_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, ]); } -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: 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" }, +]; +// 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); } +function update_keyword_table() { update_data_table("keywordTable", keywordColumns, _get_keyword_table_data); } // === all.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(); @@ -9323,6 +8895,59 @@
create_keyword_table(); } } +// 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(); +} // === theme.js === // function to update the theme when the button is clicked function toggle_theme() { @@ -9332,7 +8957,7 @@
set_local_storage_item("theme", "dark"); } setup_theme() -setup_dashboard_graphs() +update_dashboard_graphs() } // theme function based on browser/machine color scheme function setup_theme() { @@ -9349,120 +8974,62 @@
btn.classList.add(to2); }); } -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"); +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"); -// 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; -}); -} } -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)) { @@ -9476,30 +9043,76 @@
} } // 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); +} +} +// Apply custom theme colors if set +apply_theme_colors(); +} +// function to apply custom theme colors +function apply_theme_colors() { +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] || {}; +// Apply colors (custom overrides default) +const finalColors = { +background: customColors.background || defaultColors.background, +card: customColors.card || defaultColors.card, +highlight: customColors.highlight || defaultColors.highlight, +text: customColors.text || defaultColors.text, +}; +// Set CSS custom properties - background color +root.style.setProperty('--color-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) +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); } // === eventlisteners.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 () { @@ -9617,72 +9230,105 @@
} }; } -const toggle_unified = create_toggle_handler({ -key: "show.unified", -elementId: "toggleUnified" -}); -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()); +// 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()); +}); 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); +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 = to_hex_color(storedColor); +} else { +// Use default from settings for current theme mode +const defaults = settings.theme_colors[themeMode]; +element.value = to_hex_color(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.custom) { +settings.theme_colors.custom = { light: {}, dark: {} }; +} +if (!settings.theme_colors.custom[themeMode]) { +settings.theme_colors.custom[themeMode] = {}; +} +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); +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 = to_hex_color(defaults[colorKey]); +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(); +} +return { load_color, update_color, reset_color }; +} +const backgroundColorHandler = create_theme_color_handler('background', 'themeBackgroundColor'); +const cardColorHandler = create_theme_color_handler('card', 'themeCardColor'); +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(); +highlightColorHandler.load_color(); +textColorHandler.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('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('resetHighlightColor').addEventListener('click', () => highlightColorHandler.reset_color()); +document.getElementById('resetTextColor').addEventListener('click', () => textColorHandler.reset_color()); function show_settings_in_textarea() { const textArea = document.getElementById("settingsTextArea"); textArea.value = JSON.stringify(settings, null, 2); @@ -9776,6 +9422,9 @@
document.getElementById("switchRunTags").addEventListener("click", function () { settings.switch.runTags = !settings.switch.runTags update_switch_local_storage("switch.runTags", settings.switch.runTags); +show_loading_overlay(); +requestAnimationFrame(() => { +requestAnimationFrame(() => { // create latest and total bars and set visibility create_overview_latest_graphs(); update_overview_latest_heading(); @@ -9785,10 +9434,16 @@
// 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); +show_loading_overlay(); +requestAnimationFrame(() => { +requestAnimationFrame(() => { // create latest and total bars and set visibility create_overview_latest_graphs(); update_overview_latest_heading(); @@ -9798,6 +9453,9 @@
// 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 @@ -9825,86 +9483,135 @@
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); +update_graphs_with_loading( +["suiteStatisticsGraph", "suiteDurationGraph", "suiteMostFailedGraph", "suiteMostTimeConsumingGraph"], +() => { 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_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", "suiteFolderFailDonutGraph", "suiteStatisticsGraph", "suiteDurationGraph"], () => { +update_suite_folder_donut_graph(""); +}); }); document.getElementById("suiteSelectTests").addEventListener("change", () => { +update_graphs_with_loading( +["testStatisticsGraph", "testDurationGraph", "testDurationDeviationGraph"], +() => { setup_testtags_in_select(); setup_tests_in_select(); -create_test_statistics_graph(); -create_test_duration_graph(); -create_test_duration_deviation_graph(); +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); +update_graphs_with_loading( +["testStatisticsGraph", "testDurationGraph", "testDurationDeviationGraph", "testMessagesGraph", +"testMostFlakyGraph", "testRecentMostFlakyGraph", "testMostFailedGraph", +"testRecentMostFailedGraph", "testMostTimeConsumingGraph"], +() => { 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_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", () => { +update_graphs_with_loading( +["testStatisticsGraph", "testDurationGraph", "testDurationDeviationGraph"], +() => { setup_tests_in_select(); -create_test_statistics_graph(); -create_test_duration_graph(); -create_test_duration_deviation_graph(); +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); +update_graphs_with_loading( +["keywordStatisticsGraph", "keywordTimesRunGraph", "keywordTotalDurationGraph", +"keywordAverageDurationGraph", "keywordMinDurationGraph", "keywordMaxDurationGraph", +"keywordMostFailedGraph", "keywordMostTimeConsumingGraph", "keywordMostUsedGraph"], +() => { 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_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(); +} +); }); } }); @@ -9912,9 +9619,14 @@
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(); +} +); }); } // function to setup eventlisteners for changing the graph view buttons @@ -9923,25 +9635,21 @@
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; content.classList.toggle("fullscreen", entering); 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(); -} +document.documentElement.classList.toggle("html-scroll", !entering); +setTimeout(() => { +const graphBody = content.querySelector('.graph-body'); let section = null; if (fullscreenButton.includes("suite")) { section = "suite"; @@ -9962,6 +9670,22 @@
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); }; document.getElementById(fullscreenId).addEventListener("click", () => { inFullscreenGraph = fullscreenId; @@ -9976,6 +9700,13 @@
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) { @@ -9985,52 +9716,50 @@
} const folder = remove_last_folder(previousFolder) if (previousFolder == "" && folder == "") { return } -create_suite_folder_donut_graph(folder) +update_graphs_with_loading(["suiteFolderDonutGraph", "suiteFolderFailDonutGraph", "suiteStatisticsGraph", "suiteDurationGraph"], () => { +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", "suiteFolderFailDonutGraph", "suiteStatisticsGraph", "suiteDurationGraph"], () => { +update_suite_folder_donut_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("heatMapTestType").addEventListener("change", () => { -create_run_heatmap_graph(); }); document.getElementById("heatMapHour").addEventListener("change", () => { heatMapHourAll = document.getElementById("heatMapHour").value == "All" ? true : false; -create_run_heatmap_graph(); -}); -document.getElementById("testOnlyChanges").addEventListener("change", () => { -create_test_statistics_graph(); -}); -document.getElementById("testNoChanges").addEventListener("change", () => { -create_test_statistics_graph(); -}); -document.getElementById("compareOnlyChanges").addEventListener("change", () => { -create_compare_tests_graph(); -}); -document.getElementById("compareNoChanges").addEventListener("change", () => { -create_compare_tests_graph(); -}); -// most time consuming only latest run switch event listeners -document.getElementById("onlyLastRunSuite").addEventListener("change", () => { -create_suite_most_time_consuming_graph(); -}); -document.getElementById("onlyLastRunTest").addEventListener("change", () => { -create_test_most_time_consuming_graph(); -}); -document.getElementById("onlyLastRunKeyword").addEventListener("change", () => { -create_keyword_most_time_consuming_graph(); +update_graphs_with_loading(["runHeatmapGraph"], () => { +update_run_heatmap_graph(); }); -document.getElementById("onlyLastRunKeywordMostUsed").addEventListener("change", () => { -create_keyword_most_used_graph(); }); // graph layout changes document.querySelectorAll(".shown-graph").forEach(btn => { @@ -10077,11 +9806,16 @@
}); } function handle_graph_change_type_button_click(graphChangeButton, graphType, camelButtonName) { +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') { create_run_donut_total_graph(); } -if (graphChangeButton == 'suite_folder_donut') { create_suite_folder_fail_donut_graph(); } +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); @@ -10107,47 +9841,21 @@
const activeGraphType = storedGraphType || defaultGraphType; 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); -} -}); -// Handle modal hide event - return filters to original positions +["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 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); +}); }); } // function to setup collapse buttons and icons @@ -10225,7 +9933,13 @@
const selectId = select.id; if (selectId === "overviewLatestSectionOrder") { select.addEventListener('change', (e) => { +show_loading_overlay(); +requestAnimationFrame(() => { +requestAnimationFrame(() => { create_overview_latest_graphs(); +hide_loading_overlay(); +}); +}); }); } else { const projectId = parseProjectId(selectId); diff --git a/robotframework_dashboard/css/base.css b/robotframework_dashboard/css/base.css new file mode 100644 index 00000000..1526e53c --- /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 00000000..c24109bb --- /dev/null +++ b/robotframework_dashboard/css/colors.css @@ -0,0 +1,58 @@ +:root { + --color-bg: #eee; + --color-card: #ffffff; + --color-menu-text: var(--color-text); + --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: var(--color-bg); + --color-section-card-bg: #ffffff; + --color-section-card-text: var(--color-text); + --color-section-card-border: transparent; + --color-fullscreen-bg: #ffffff; +} + +.dark-mode { + color-scheme: dark; + --color-bg: #0f172a; + --color-card: rgba(30, 41, 59, 0.9); + --color-menu-text: var(--color-text); + --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: 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); + --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 00000000..703f4fd1 --- /dev/null +++ b/robotframework_dashboard/css/components.css @@ -0,0 +1,460 @@ +.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); + color: var(--color-text); + background: var(--color-bg); +} + +.overview-card > .card { + background: var(--color-card) !important; +} + +.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) !important; +} + +.fullscreen .section-filters { + flex: 0 0 auto; +} + +.fullscreen .graph-body { + flex: 1 1 0; + min-height: 0; + overflow: hidden; +} + +.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); + background-color: var(--color-section-card-bg) +} + +.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; +} + +/* 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: var(--color-fullscreen-bg); + border-radius: 8px; + z-index: 10; +} + +/* 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; + 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; +} + +.list-group-item { + background-color: var(--color-card); +} diff --git a/robotframework_dashboard/css/dark.css b/robotframework_dashboard/css/dark.css new file mode 100644 index 00000000..da7d8cc8 --- /dev/null +++ b/robotframework_dashboard/css/dark.css @@ -0,0 +1,28 @@ +.modal-dialog { + background: var(--color-modal-bg); + color: var(--color-text); +} + +.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 8cdfba34..00000000 --- a/robotframework_dashboard/css/styling.css +++ /dev/null @@ -1,759 +0,0 @@ -#overview, -#dashboard, -#unified, -#compare, -#tables { - margin-bottom: 35vh; -} -/* LIGHT MODE STYLING */ -body { - background-color: #eee; -} - -.border-bottom { - border-color: rgba(0, 0, 0, 0.175) !important; -} - -.fullscreen, -.sticky-top, -body { - background-color: #eee; -} - -.grid-stack { - max-height: 10000px; -} - -.grid-stack-item-content { - background-color: white; -} - -.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 .grid-stack-item-content, -.dark-mode .overview-card .card { - background: rgba(30, 41, 59, 0.9); -} - -.dark-mode .fullscreen { - background: rgba(30, 41, 59, 1); -} - -.dark-mode .modal-content { - background: #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: #0f172a; - color: #eee; -} - -.dark-mode .list-group-item:not(.disabled), -.dark-mode .form-label, -.dark-mode .form-control, -.dark-mode .form-select { - 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: #eee; -} - -.dark-mode .collapse-icon { - color: #eee; -} - -.dark-mode .stat-label { - font-size: 0.85rem; - color: #9ca3af; -} - -.dark-mode .white-text { - color: #eee; -} - -/* Dark mode */ -.dark-mode .nav-item { - 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; -} - -.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/js/common.js b/robotframework_dashboard/js/common.js index 37337426..9e30e5ac 100644 --- a/robotframework_dashboard/js/common.js +++ b/robotframework_dashboard/js/common.js @@ -153,6 +153,61 @@ 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)); + 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 { camelcase_to_underscore, get_next_folder_level, @@ -165,5 +220,10 @@ export { combine_paths, add_alert, close_alert, - debounce + debounce, + show_graph_loading, + hide_graph_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 751c216f..90c867d1 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 { toggle_theme } from "./theme.js"; +import { toggle_theme, apply_theme_colors } from "./theme.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, 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 () { @@ -203,83 +213,121 @@ function setup_settings_modal() { }; } - const toggle_unified = create_toggle_handler({ - key: "show.unified", - elementId: "toggleUnified" - }); - - const toggle_labels = create_toggle_handler({ - key: "show.dateLabels", - elementId: "toggleLabels" - }); - - const toggle_legends = create_toggle_handler({ - key: "show.legends", - elementId: "toggleLegends" + // 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()); }); + document.getElementById("themeLight").addEventListener("click", () => toggle_theme()); + document.getElementById("themeDark").addEventListener("click", () => toggle_theme()); - const toggle_aliases = create_toggle_handler({ - key: "show.aliases", - elementId: "toggleAliases" - }); + // 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; + } - const toggle_milliseconds = create_toggle_handler({ - key: "show.milliseconds", - elementId: "toggleMilliseconds" - }); + function create_theme_color_handler(colorKey, elementId) { + function load_color() { + const element = document.getElementById(elementId); + 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 = to_hex_color(storedColor); + } else { + // Use default from settings for current theme mode + const defaults = settings.theme_colors[themeMode]; + element.value = to_hex_color(defaults[colorKey]); + } + } - const toggle_axis_titles = create_toggle_handler({ - key: "show.axisTitles", - elementId: "toggleAxisTitles" - }); + 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.custom) { + settings.theme_colors.custom = { light: {}, dark: {} }; + } + if (!settings.theme_colors.custom[themeMode]) { + settings.theme_colors.custom[themeMode] = {}; + } + + settings.theme_colors.custom[themeMode][colorKey] = newColor; + set_local_storage_item(`theme_colors.custom.${themeMode}.${colorKey}`, newColor); + apply_theme_colors(); + } - const toggle_animations = create_toggle_handler({ - key: "show.animation", - elementId: "toggleAnimations" - }); + function reset_color() { + const element = document.getElementById(elementId); + 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 = to_hex_color(defaults[colorKey]); + + 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(); + } - const toggle_animation_duration = create_toggle_handler({ - key: "show.duration", - elementId: "toggleAnimationDuration", - isNumber: true - }); + return { load_color, update_color, reset_color }; + } - const toggle_bar_rounding = create_toggle_handler({ - key: "show.rounding", - elementId: "toggleBarRounding", - isNumber: true - }); + const backgroundColorHandler = create_theme_color_handler('background', 'themeBackgroundColor'); + const cardColorHandler = create_theme_color_handler('card', 'themeCardColor'); + const highlightColorHandler = create_theme_color_handler('highlight', 'themeHighlightColor'); + const textColorHandler = create_theme_color_handler('text', 'themeTextColor'); - const toggle_prefixes = create_toggle_handler({ - key: "show.prefixes", - elementId: "togglePrefixes" + // Load colors on modal open + $("#settingsModal").on("shown.bs.modal", function () { + backgroundColorHandler.load_color(); + cardColorHandler.load_color(); + highlightColorHandler.load_color(); + textColorHandler.load_color(); }); - // 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 for color inputs + document.getElementById('themeBackgroundColor').addEventListener('change', () => backgroundColorHandler.update_color()); + document.getElementById('themeCardColor').addEventListener('change', () => cardColorHandler.update_color()); + document.getElementById('themeHighlightColor').addEventListener('change', () => highlightColorHandler.update_color()); + document.getElementById('themeTextColor').addEventListener('change', () => textColorHandler.update_color()); - // 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()); + // Add event listeners for reset buttons + document.getElementById('resetBackgroundColor').addEventListener('click', () => backgroundColorHandler.reset_color()); + document.getElementById('resetCardColor').addEventListener('click', () => cardColorHandler.reset_color()); + document.getElementById('resetHighlightColor').addEventListener('click', () => highlightColorHandler.reset_color()); + document.getElementById('resetTextColor').addEventListener('click', () => textColorHandler.reset_color()); function show_settings_in_textarea() { const textArea = document.getElementById("settingsTextArea"); @@ -382,28 +430,40 @@ 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(() => { + 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(() => { + 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 +491,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", "suiteFolderFailDonutGraph", "suiteStatisticsGraph", "suiteDurationGraph"], () => { + 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 +627,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,50 +644,63 @@ 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; 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(() => { + const graphBody = content.querySelector('.graph-body'); + 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 (typeof window[graphFunctionName] === "function") { - window[graphFunctionName](); - } + // 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 (fullscreenButton === "runDonut") { - create_run_donut_total_graph(); - } else if (fullscreenButton === "suiteFolderDonut") { - create_suite_folder_fail_donut_graph(); - } + 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); + }, 0); }; document.getElementById(fullscreenId).addEventListener("click", () => { @@ -590,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) { @@ -599,52 +733,50 @@ 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", "suiteFolderFailDonutGraph", "suiteStatisticsGraph", "suiteDurationGraph"], () => { + 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", "suiteFolderFailDonutGraph", "suiteStatisticsGraph", "suiteDurationGraph"], () => { + update_suite_folder_donut_graph(""); + }); }); - document.getElementById("heatMapTestType").addEventListener("change", () => { - create_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", () => { heatMapHourAll = document.getElementById("heatMapHour").value == "All" ? true : false; - create_run_heatmap_graph(); - }); - document.getElementById("testOnlyChanges").addEventListener("change", () => { - create_test_statistics_graph(); - }); - document.getElementById("testNoChanges").addEventListener("change", () => { - create_test_statistics_graph(); - }); - document.getElementById("compareOnlyChanges").addEventListener("change", () => { - create_compare_tests_graph(); - }); - document.getElementById("compareNoChanges").addEventListener("change", () => { - create_compare_tests_graph(); - }); - // most time consuming only latest run switch event listeners - document.getElementById("onlyLastRunSuite").addEventListener("change", () => { - create_suite_most_time_consuming_graph(); - }); - document.getElementById("onlyLastRunTest").addEventListener("change", () => { - create_test_most_time_consuming_graph(); - }); - document.getElementById("onlyLastRunKeyword").addEventListener("change", () => { - create_keyword_most_time_consuming_graph(); - }); - document.getElementById("onlyLastRunKeywordMostUsed").addEventListener("change", () => { - create_keyword_most_used_graph(); + update_graphs_with_loading(["runHeatmapGraph"], () => { + update_run_heatmap_graph(); + }); }); // graph layout changes document.querySelectorAll(".shown-graph").forEach(btn => { @@ -691,11 +823,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); @@ -722,52 +859,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); + }); }); } @@ -853,7 +960,13 @@ function setup_overview_order_filters() { const selectId = select.id; if (selectId === "overviewLatestSectionOrder") { select.addEventListener('change', (e) => { - create_overview_latest_graphs(); + show_loading_overlay(); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + create_overview_latest_graphs(); + hide_loading_overlay(); + }); + }); }); } else { const projectId = parseProjectId(selectId); diff --git a/robotframework_dashboard/js/filter.js b/robotframework_dashboard/js/filter.js index 222d0e4d..bd6eb7c9 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/all.js b/robotframework_dashboard/js/graph_creation/all.js index 6551a1a2..f915037a 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/chart_factory.js b/robotframework_dashboard/js/graph_creation/chart_factory.js new file mode 100644 index 00000000..ed29f9ca --- /dev/null +++ b/robotframework_dashboard/js/graph_creation/chart_factory.js @@ -0,0 +1,23 @@ +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) { + 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) { + 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 92b894ab..8820b868 100644 --- a/robotframework_dashboard/js/graph_creation/compare.js +++ b/robotframework_dashboard/js/graph_creation/compare.js @@ -2,44 +2,48 @@ 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 { 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"; -// function to create the compare statistics in the compare section -function create_compare_statistics_graph() { - if (compareStatisticsGraph) { - compareStatisticsGraph.destroy(); - } +// build functions +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 _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 _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; }, }, }; @@ -64,14 +68,24 @@ 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); - compareTestsGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(compareTestsGraph, event) - }); + return config; } +// 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 functions +function update_compare_statistics_graph() { update_chart("compareStatisticsGraph", _build_compare_statistics_config, false); } +function update_compare_suite_duration_graph() { update_chart("compareSuiteDurationGraph", _build_compare_suite_duration_config, false); } +function update_compare_tests_graph() { update_chart("compareTestsGraph", _build_compare_tests_config); } + 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/config_helpers.js b/robotframework_dashboard/js/graph_creation/config_helpers.js new file mode 100644 index 00000000..f1a78596 --- /dev/null +++ b/robotframework_dashboard/js/graph_creation/config_helpers.js @@ -0,0 +1,191 @@ +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, pointMeta = null, dataType = null, callbackLookup = null) { + const lookupFn = callbackLookup || ((val) => callbackData[val]); + config.options.plugins.tooltip = { + callbacks: { + label: function (context) { + 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; + }, + }, + }; + 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 pointMeta = data[2] || null; + 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, pointMeta, dataType); + } + 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 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 pointMeta = data[2] || null; + 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, pointMeta, dataType); + } + 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)`; + 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; + } + }, + }; + 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 18024108..3df62be2 100644 --- a/robotframework_dashboard/js/graph_creation/keyword.js +++ b/robotframework_dashboard/js/graph_creation/keyword.js @@ -2,18 +2,12 @@ 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"; -// function to keyword statistics graph in the keyword section -function create_keyword_statistics_graph() { - if (keywordStatisticsGraph) { - keywordStatisticsGraph.destroy(); - } +// build functions +function _build_keyword_statistics_config() { const data = get_statistics_graph_data("keyword", settings.graphTypes.keywordStatisticsGraphType, filteredKeywords); const graphData = data[0] var config; @@ -25,327 +19,59 @@ 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); - keywordStatisticsGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordStatisticsGraph, event) - }); + return config; } -// function to keyword times run graph in the keyword section -function create_keyword_times_run_graph() { - if (keywordTimesRunGraph) { - keywordTimesRunGraph.destroy(); - } - const graphData = get_duration_graph_data("keyword", settings.graphTypes.keywordTimesRunGraphType, "times_run", filteredKeywords); +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.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.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 } - keywordTimesRunGraph = new Chart("keywordTimesRunGraph", config); - keywordTimesRunGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordTimesRunGraph, event) - }); + return config; } -// function to keyword total time graph in the keyword section -function create_keyword_total_duration_graph() { - if (keywordTotalDurationGraph) { - keywordTotalDurationGraph.destroy(); - } - const graphData = get_duration_graph_data("keyword", settings.graphTypes.keywordTotalDurationGraphType, "total_time_s", 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.show.dateLabels) { config.options.scales.x.ticks.display = false } - keywordTotalDurationGraph = new Chart("keywordTotalDurationGraph", config); - keywordTotalDurationGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordTotalDurationGraph, 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"); } -// function to keyword average time graph in the keyword section -function create_keyword_average_duration_graph() { - if (keywordAverageDurationGraph) { - keywordAverageDurationGraph.destroy(); - } - 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 } - keywordAverageDurationGraph = new Chart("keywordAverageDurationGraph", config); - keywordAverageDurationGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordAverageDurationGraph, event) - }); +function _build_keyword_most_failed_config() { + return build_most_failed_config("keywordMostFailed", "keyword", "Keyword", filteredKeywords, false); } - -// function to keyword min time graph in the keyword section -function create_keyword_min_duration_graph() { - if (keywordMinDurationGraph) { - keywordMinDurationGraph.destroy(); - } - 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 } - keywordMinDurationGraph = new Chart("keywordMinDurationGraph", config); - keywordMinDurationGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordMinDurationGraph, event) - }); +function _build_keyword_most_time_consuming_config() { + return build_most_time_consuming_config("keywordMostTimeConsuming", "keyword", "Keyword", filteredKeywords, "onlyLastRunKeyword"); } - -// function to keyword max time graph in the keyword section -function create_keyword_max_duration_graph() { - if (keywordMaxDurationGraph) { - keywordMaxDurationGraph.destroy(); - } - 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 } - keywordMaxDurationGraph = new Chart("keywordMaxDurationGraph", config); - keywordMaxDurationGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordMaxDurationGraph, event) - }); +function _build_keyword_most_used_config() { + return build_most_time_consuming_config("keywordMostUsed", "keyword", "Keyword", filteredKeywords, "onlyLastRunKeywordMostUsed", "Most Used", true, (info, name) => `${name}: ran ${info.timesRun} times`); } -// function to create test most failed graph in the keyword section -function create_keyword_most_failed_graph() { - if (keywordMostFailedGraph) { - keywordMostFailedGraph.destroy(); - } - 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); - keywordMostFailedGraph = new Chart("keywordMostFailedGraph", 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(); - } - 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); - keywordMostTimeConsumingGraph = new Chart("keywordMostTimeConsumingGraph", config); - keywordMostTimeConsumingGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordMostTimeConsumingGraph, event) - }); -} +// 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); } -// function to create the most used keyword graph in the keyword section -function create_keyword_most_used_graph() { - if (keywordMostUsedGraph) { - keywordMostUsedGraph.destroy(); - } - 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); - keywordMostUsedGraph = new Chart("keywordMostUsedGraph", config); - keywordMostUsedGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(keywordMostUsedGraph, event) - }); -} +// 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, @@ -356,5 +82,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/overview.js b/robotframework_dashboard/js/graph_creation/overview.js index 830cf556..6fb435bd 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 { @@ -116,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 => @@ -180,14 +351,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(); + }); + }); }); } @@ -620,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 }) { @@ -773,65 +823,12 @@ 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_latest_heading() { - const overviewCardsContainer = document.getElementById("overviewLatestRunCardsContainer"); - 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 - ${headerContent} - `; + _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 a6f99a85..b4a2f113 100644 --- a/robotframework_dashboard/js/graph_creation/run.js +++ b/robotframework_dashboard/js/graph_creation/run.js @@ -4,8 +4,9 @@ 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, open_log_from_label } from '../log.js'; +import { open_log_file } from '../log.js'; import { settings } from '../variables/settings.js'; import { inFullscreen, @@ -16,14 +17,13 @@ import { filteredSuites, filteredTests } from '../variables/globals.js'; +import { create_chart, update_chart } from './chart_factory.js'; -// function to create run statistics graph in the run section -function create_run_statistics_graph() { - if (runStatisticsGraph) { - runStatisticsGraph.destroy(); - } +// build functions +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); @@ -32,18 +32,18 @@ function create_run_statistics_graph() { } 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 } - runStatisticsGraph = new Chart("runStatisticsGraph", config); - runStatisticsGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(runStatisticsGraph, event) - }); + return config; } -// function to create run donut graph in the run section -function create_run_donut_graph() { - if (runDonutGraph) { - runDonutGraph.destroy(); - } +function _build_run_donut_config() { const data = get_donut_graph_data("run", filteredRuns); const graphData = data[0] const callbackData = data[1] @@ -61,45 +61,20 @@ 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 _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); -} - -// 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 + return config; } -// function to create run duration graph in the run section -function create_run_duration_graph() { - if (runDurationGraph) { - runDurationGraph.destroy(); - } +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; @@ -107,18 +82,16 @@ function create_run_duration_graph() { } 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 } - runDurationGraph = new Chart("runDurationGraph", config); - runDurationGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(runDurationGraph, event) - }); + return config; } -// function to create the run heatmap -function create_run_heatmap_graph() { - if (runHeatmapGraph) { - runHeatmapGraph.destroy(); - } +function _build_run_heatmap_config() { const data = get_heatmap_graph_data(filteredTests); const graphData = data[0] const callbackData = data[1] @@ -141,8 +114,40 @@ function create_run_heatmap_graph() { stepSize: 1, callback: val => callbackData[val] || '' } - runHeatmapGraph = new Chart("runHeatmapGraph", config); + return config; +} + +// 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 functions +function update_run_statistics_graph() { update_chart("runStatisticsGraph", _build_run_statistics_config); } +function update_run_donut_graph() { update_chart("runDonutGraph", _build_run_donut_config, false); } +function update_run_donut_total_graph() { update_chart("runDonutTotalGraph", _build_run_donut_total_config, false); } +function update_run_stats_graph() { + create_run_stats_graph(); } +function update_run_duration_graph() { update_chart("runDurationGraph", _build_run_duration_config); } +function update_run_heatmap_graph() { update_chart("runHeatmapGraph", _build_run_heatmap_config, false); } export { create_run_statistics_graph, @@ -150,5 +155,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 f35b820e..e070b073 100644 --- a/robotframework_dashboard/js/graph_creation/suite.js +++ b/robotframework_dashboard/js/graph_creation/suite.js @@ -1,30 +1,20 @@ 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 { 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'; +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'; -// 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 functions +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 +38,10 @@ 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_graphs_with_loading( + ["suiteFolderDonutGraph", "suiteFolderFailDonutGraph", "suiteStatisticsGraph", "suiteDurationGraph"], + () => { update_suite_folder_donut_graph(event.chart.tooltip.title.join('')); } + ); }, 0); } }; @@ -60,14 +53,10 @@ 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 _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 +96,10 @@ 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_graphs_with_loading( + ["suiteFolderDonutGraph", "suiteFolderFailDonutGraph", "suiteStatisticsGraph", "suiteDurationGraph"], + () => { update_suite_folder_donut_graph(event.chart.tooltip.title.join('')); } + ); }, 0); } }; @@ -119,17 +111,17 @@ 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 _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); @@ -137,6 +129,11 @@ function create_suite_statistics_graph() { 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 ''; } } } @@ -148,6 +145,11 @@ function create_suite_statistics_graph() { 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 ''; } } } @@ -159,23 +161,26 @@ function create_suite_statistics_graph() { 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 ''; } } } } if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false } - suiteStatisticsGraph = new Chart("suiteStatisticsGraph", config); - suiteStatisticsGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(suiteStatisticsGraph, event) - }); + return config; } -// function to create suite duration graph in the suite section -function create_suite_duration_graph() { - if (suiteDurationGraph) { - suiteDurationGraph.destroy(); - } +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; @@ -183,149 +188,62 @@ function create_suite_duration_graph() { } 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 } - suiteDurationGraph = new Chart("suiteDurationGraph", config); - suiteDurationGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(suiteDurationGraph, event) - }); + return config; } -// function to create suite most failed graph in the suite section -function create_suite_most_failed_graph() { - if (suiteMostFailedGraph) { - suiteMostFailedGraph.destroy(); - } - 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); - suiteMostFailedGraph = new Chart("suiteMostFailedGraph", config); - suiteMostFailedGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(suiteMostFailedGraph, event) - }); +function _build_suite_most_failed_config() { + return build_most_failed_config("suiteMostFailed", "suite", "Suite", filteredSuites, false); +} +function _build_suite_most_time_consuming_config() { + return build_most_time_consuming_config("suiteMostTimeConsuming", "suite", "Suite", filteredSuites, "onlyLastRunSuite"); } -// function to create the most time consuming suite graph in the suite section -function create_suite_most_time_consuming_graph() { - if (suiteMostTimeConsumingGraph) { - suiteMostTimeConsumingGraph.destroy(); +// 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(); } - 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 } + 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); } + +// update functions +function update_suite_folder_donut_graph(folder) { + 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(); } - update_height("suiteMostTimeConsumingVertical", config.data.labels.length, settings.graphTypes.suiteMostTimeConsumingGraphType); - suiteMostTimeConsumingGraph = new Chart("suiteMostTimeConsumingGraph", config); - suiteMostTimeConsumingGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(suiteMostTimeConsumingGraph, event) - }); + 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(); } +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 { @@ -334,5 +252,11 @@ export { 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 b5dab2c5..e6e31162 100644 --- a/robotframework_dashboard/js/graph_creation/tables.js +++ b/robotframework_dashboard/js/graph_creation/tables.js @@ -1,188 +1,91 @@ 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(); - } - 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, - ]); - } - 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: 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, + ]); } -// function to create suite table in the suite section -function create_suite_table() { - if (suiteTable) { - suiteTable.destroy(); - } - 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, - ]); - } - 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: 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() { - if (testTable) { - testTable.destroy(); - } - 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 - ]); - } - 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: 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, + ]); +} + +function _get_keyword_table_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() { - if (keywordTable) { - keywordTable.destroy(); - } - 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, - ]); - } - 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: 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" }, +]; + +// 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); } +function update_keyword_table() { update_data_table("keywordTable", keywordColumns, _get_keyword_table_data); } 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 06f571d3..0879c4a1 100644 --- a/robotframework_dashboard/js/graph_creation/test.js +++ b/robotframework_dashboard/js/graph_creation/test.js @@ -1,30 +1,50 @@ -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_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 { 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, open_log_from_label } from "../log.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"; +import { build_most_failed_config, build_most_flaky_config, build_most_time_consuming_config } from "./config_helpers.js"; -// function to create test statistics graph in the test section -function create_test_statistics_graph() { - if (testStatisticsGraph) { - testStatisticsGraph.destroy(); +// build functions +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(); +} + +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; }, }, }; @@ -49,18 +69,117 @@ 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); - testStatisticsGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testStatisticsGraph, event) - }); + return config; } -// function to create test duration graph in the test section -function create_test_duration_graph() { - if (testDurationGraph) { - testDurationGraph.destroy(); +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; + }, + }, + }, + }, + }, + }; + 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; +} + +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; @@ -68,21 +187,25 @@ function create_test_duration_graph() { } 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 } - testDurationGraph = new Chart("testDurationGraph", config); - testDurationGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testDurationGraph, event) - }); + return config; } -// function to create test messages graph in the test section -function create_test_messages_graph() { - if (testMessagesGraph) { - testMessagesGraph.destroy(); - } +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") { @@ -114,7 +237,21 @@ function create_test_messages_graph() { 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; }, }, }; @@ -145,323 +282,54 @@ 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); - testMessagesGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testMessagesGraph, event) - }); + return config; } -// function to create test duration deviation graph in test section -function create_test_duration_deviation_graph() { - if (testDurationDeviationGraph) { - testDurationDeviationGraph.destroy(); - } +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); - testDurationDeviationGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testDurationDeviationGraph, event) - }); + return config; } -// function to create test most flaky graph in test section -function create_test_most_flaky_graph() { - if (testMostFlakyGraph) { - testMostFlakyGraph.destroy(); - } - 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); - testMostFlakyGraph = new Chart("testMostFlakyGraph", config); - testMostFlakyGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testMostFlakyGraph, event) - }); +function _build_test_most_flaky_config() { + return build_most_flaky_config("testMostFlaky", "test", filteredTests, ignoreSkips, false); } - -// function to create test recent most flaky graph in test section -function create_test_recent_most_flaky_graph() { - if (testRecentMostFlakyGraph) { - testRecentMostFlakyGraph.destroy(); - } - 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); - testRecentMostFlakyGraph = new Chart("testRecentMostFlakyGraph", config); - testRecentMostFlakyGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testRecentMostFlakyGraph, event) - }); +function _build_test_recent_most_flaky_config() { + return build_most_flaky_config("testRecentMostFlaky", "test", filteredTests, ignoreSkipsRecent, true); } - -// function to create test most failed graph in the test section -function create_test_most_failed_graph() { - if (testMostFailedGraph) { - testMostFailedGraph.destroy(); - } - 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); - testMostFailedGraph = new Chart("testMostFailedGraph", config); - testMostFailedGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testMostFailedGraph, event) - }); +function _build_test_most_failed_config() { + return build_most_failed_config("testMostFailed", "test", "Test", filteredTests, false); } - -// function to create test recent most failed graph in the test section -function create_test_recent_most_failed_graph() { - if (testRecentMostFailedGraph) { - testRecentMostFailedGraph.destroy(); - } - 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); - testRecentMostFailedGraph = new Chart("testRecentMostFailedGraph", config); - testRecentMostFailedGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testRecentMostFailedGraph, event) - }); +function _build_test_recent_most_failed_config() { + return build_most_failed_config("testRecentMostFailed", "test", "Test", filteredTests, true); } - -// function to create the most time consuming test graph in the test section -function create_test_most_time_consuming_graph() { - if (testMostTimeConsumingGraph) { - testMostTimeConsumingGraph.destroy(); - } - 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); - testMostTimeConsumingGraph = new Chart("testMostTimeConsumingGraph", config); - testMostTimeConsumingGraph.canvas.addEventListener("click", (event) => { - open_log_from_label(testMostTimeConsumingGraph, event) - }); +function _build_test_most_time_consuming_config() { + return build_most_time_consuming_config("testMostTimeConsuming", "test", "Test", filteredTests, "onlyLastRunTest"); } +// 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 functions +function update_test_statistics_graph() { update_chart("testStatisticsGraph", _build_test_statistics_config); } +function update_test_duration_graph() { update_chart("testDurationGraph", _build_test_duration_config); } +function update_test_messages_graph() { update_chart("testMessagesGraph", _build_test_messages_config); } +function update_test_duration_deviation_graph() { update_chart("testDurationDeviationGraph", _build_test_duration_deviation_config); } +function update_test_most_flaky_graph() { update_chart("testMostFlakyGraph", _build_test_most_flaky_config); } +function update_test_recent_most_flaky_graph() { update_chart("testRecentMostFlakyGraph", _build_test_recent_most_flaky_config); } +function update_test_most_failed_graph() { update_chart("testMostFailedGraph", _build_test_most_failed_config); } +function update_test_recent_most_failed_graph() { update_chart("testRecentMostFailedGraph", _build_test_recent_most_failed_config); } +function update_test_most_time_consuming_graph() { update_chart("testMostTimeConsumingGraph", _build_test_most_time_consuming_config); } + export { create_test_statistics_graph, create_test_duration_graph, @@ -472,4 +340,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/graph_data/failed.js b/robotframework_dashboard/js/graph_data/failed.js index fc90c549..1b169290 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 33d5d42c..07baa906 100644 --- a/robotframework_dashboard/js/graph_data/flaky.js +++ b/robotframework_dashboard/js/graph_data/flaky.js @@ -1,12 +1,14 @@ 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 +63,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) { @@ -101,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) { @@ -115,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, @@ -146,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 771a0448..117a7b96 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 dedfa6e6..f3f9bd89 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 546b1e2c..6be8244f 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 00000000..5b6cc93b --- /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/localstorage.js b/robotframework_dashboard/js/localstorage.js index ecd73881..efc0b6ca 100644 --- a/robotframework_dashboard/js/localstorage.js +++ b/robotframework_dashboard/js/localstorage.js @@ -78,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); } @@ -169,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/log.js b/robotframework_dashboard/js/log.js index 53683baf..99341db1 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") { diff --git a/robotframework_dashboard/js/menu.js b/robotframework_dashboard/js/menu.js index 895d516a..2622ace9 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(); diff --git a/robotframework_dashboard/js/theme.js b/robotframework_dashboard/js/theme.js index da0cae07..8c2a947b 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 @@ -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,27 +130,75 @@ 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); } } + + // Apply custom theme colors if set + apply_theme_colors(); +} + +// function to apply custom theme colors +function apply_theme_colors() { + 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] || {}; + + // Apply colors (custom overrides default) + const finalColors = { + background: customColors.background || defaultColors.background, + card: customColors.card || defaultColors.card, + highlight: customColors.highlight || defaultColors.highlight, + text: customColors.text || defaultColors.text, + }; + + // Set CSS custom properties - background color + root.style.setProperty('--color-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) + 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 { toggle_theme, - setup_theme + setup_theme, + apply_theme_colors }; \ No newline at end of file diff --git a/robotframework_dashboard/js/variables/chartconfig.js b/robotframework_dashboard/js/variables/chartconfig.js index 56586340..d883f042 100644 --- a/robotframework_dashboard/js/variables/chartconfig.js +++ b/robotframework_dashboard/js/variables/chartconfig.js @@ -84,5 +84,5 @@ export { skippedConfig, blueConfig, lineConfig, - dataLabelConfig + dataLabelConfig, }; \ No newline at end of file diff --git a/robotframework_dashboard/js/variables/graphmetadata.js b/robotframework_dashboard/js/variables/graphmetadata.js index 827154b2..97019c38 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", @@ -358,7 +343,7 @@ const graphMetadata = [ key: "testStatistics", label: "Test Statistics", defaultType: "timeline", - viewOptions: ["Timeline"], + viewOptions: ["Timeline", "Line"], hasFullscreenButton: true, html: `
Statistics
@@ -381,6 +366,7 @@ const graphMetadata = [
+ @@ -399,20 +385,7 @@ const graphMetadata = [ defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, - html: `
-
Duration
-
- - - - - - -
-
-
- -
`, + html: _graphHtml("testDuration", "Duration", ["Bar", "Line"]), }, { key: "testDurationDeviation", @@ -420,19 +393,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 +401,7 @@ const graphMetadata = [ defaultType: "timeline", viewOptions: ["Bar", "Timeline"], hasFullscreenButton: true, - html: `
-
Messages
-
- - - - - - -
-
-
-
- -
-
`, + html: _graphHtml("testMessages", "Messages", ["Bar", "Timeline"], { hasVertical: true }), }, { key: "testMostFlaky", @@ -521,22 +467,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 +475,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 +512,7 @@ const graphMetadata = [ defaultType: "percentages", viewOptions: ["Percentages", "Line", "Amount"], hasFullscreenButton: true, - html: `
-
Statistics
-
- - - - - - - -
-
-
- -
`, + html: _graphHtml("keywordStatistics", "Statistics", ["Percentages", "Line", "Amount"]), }, { key: "keywordTimesRun", @@ -618,20 +520,7 @@ const graphMetadata = [ defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, - html: `
-
Times Run
-
- - - - - - -
-
-
- -
`, + html: _graphHtml("keywordTimesRun", "Times Run", ["Bar", "Line"]), }, { key: "keywordTotalDuration", @@ -639,20 +528,7 @@ const graphMetadata = [ defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, - html: `
-
Total Duration
-
- - - - - - -
-
-
- -
`, + html: _graphHtml("keywordTotalDuration", "Total Duration", ["Bar", "Line"]), }, { key: "keywordAverageDuration", @@ -660,20 +536,7 @@ const graphMetadata = [ defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, - html: `
-
Average Duration
-
- - - - - - -
-
-
- -
`, + html: _graphHtml("keywordAverageDuration", "Average Duration", ["Bar", "Line"]), }, { key: "keywordMinDuration", @@ -681,20 +544,7 @@ const graphMetadata = [ defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, - html: `
-
Min Duration
-
- - - - - - -
-
-
- -
`, + html: _graphHtml("keywordMinDuration", "Min Duration", ["Bar", "Line"]), }, { key: "keywordMaxDuration", @@ -702,20 +552,7 @@ const graphMetadata = [ defaultType: "line", viewOptions: ["Bar", "Line"], hasFullscreenButton: true, - html: `
-
Max Duration
-
- - - - - - -
-
-
- -
>`, + html: _graphHtml("keywordMaxDuration", "Max Duration", ["Bar", "Line"]), }, { key: "keywordMostFailed", @@ -723,22 +560,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 +626,7 @@ const graphMetadata = [ defaultType: "bar", viewOptions: ["Bar"], hasFullscreenButton: true, - html: `
-
Statistics
-
- - - - - -
-
-
- -
`, + html: _graphHtml("compareStatistics", "Statistics", ["Bar"], { titleId: false }), }, { key: "compareSuiteDuration", @@ -824,19 +634,7 @@ const graphMetadata = [ defaultType: "radar", viewOptions: ["Radar"], hasFullscreenButton: true, - html: `
-
Suite Duration
-
- - - - - -
-
-
- -
`, + html: _graphHtml("compareSuiteDuration", "Suite Duration", ["Radar"], { titleId: false }), }, { key: "compareTests", @@ -884,18 +682,7 @@ const graphMetadata = [ viewOptions: ["Table"], hasFullscreenButton: false, information: null, - html: `
-
-
Run Table
-
- - - - -
-
-
-
`, + html: _tableHtml("runTable", "Run"), }, { key: "suiteTable", @@ -904,18 +691,7 @@ const graphMetadata = [ viewOptions: ["Table"], hasFullscreenButton: false, information: null, - html: `
-
-
Suite Table
-
- - - - -
-
-
-
`, + html: _tableHtml("suiteTable", "Suite"), }, { key: "testTable", @@ -924,18 +700,7 @@ const graphMetadata = [ viewOptions: ["Table"], hasFullscreenButton: false, information: null, - html: `
-
-
Test Table
-
- - - - -
-
-
-
`, + html: _tableHtml("testTable", "Test"), }, { key: "keywordTable", @@ -944,18 +709,7 @@ const graphMetadata = [ viewOptions: ["Table"], hasFullscreenButton: false, information: null, - html: `
-
-
Keyword Table
-
- - - - -
-
-
-
`, + html: _tableHtml("keywordTable", "Keyword"), }, ]; diff --git a/robotframework_dashboard/js/variables/information.js b/robotframework_dashboard/js/variables/information.js index e335bda5..a9d70bb9 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,109 @@ 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", + "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", - "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 diff --git a/robotframework_dashboard/js/variables/settings.js b/robotframework_dashboard/js/variables/settings.js index b9d25709..610d9c33 100644 --- a/robotframework_dashboard/js/variables/settings.js +++ b/robotframework_dashboard/js/variables/settings.js @@ -37,6 +37,24 @@ var settings = { rounding: 6, prefixes: true, }, + theme_colors: { + light: { + background: '#eee', + card: '#ffffff', + highlight: '#3451b2', + text: '#000000', + }, + dark: { + background: '#0f172a', + card: 'rgba(30, 41, 59, 0.9)', + highlight: '#a8b1ff', + text: '#eee', + }, + custom: { + light: {}, + dark: {}, + } + }, menu: { overview: false, dashboard: true, diff --git a/robotframework_dashboard/templates/dashboard.html b/robotframework_dashboard/templates/dashboard.html index ed918433..823dde7b 100644 --- a/robotframework_dashboard/templates/dashboard.html +++ b/robotframework_dashboard/templates/dashboard.html @@ -539,6 +539,10 @@

Settings

data-bs-target="#overview-settings" type="button" role="tab" aria-controls="overview-settings" aria-selected="false">Overview +