diff --git a/.agent/README.md b/.agent/README.md
index 758b3dc00..11b8947e6 100644
--- a/.agent/README.md
+++ b/.agent/README.md
@@ -36,6 +36,7 @@ This directory contains the project's technical constitution, specialized skills
| [`hey-agent.md`](./workflows/hey-agent.md) | Unified management for Rules, Skills, and Workflows. |
| [`lab-down.md`](./workflows/lab-down.md) | Stops and cleans up the database laboratory. |
| [`lab-up.md`](./workflows/lab-up.md) | Starts a persistent database laboratory and injects data. |
+| [`local-dev-sync.md`](./workflows/local-dev-sync.md) | Synchronize developer changes, run unit tests, and update changelog and release notes. |
| [`markdown-lint.md`](./workflows/markdown-lint.md) | Check markdown content for cleanliness and project standard compliance (AFF, keywords, links) |
| [`plan.md`](./workflows/plan.md) | Create or update an implementation plan (implementation_plan.md) |
| [`release-manager.md`](./workflows/release-manager.md) | High-level release orchestrator for the Release Manager role |
@@ -48,4 +49,4 @@ This directory contains the project's technical constitution, specialized skills
---
-*Generated automatically by `/doc-sync` on 2026-06-12 20:03:20*
\ No newline at end of file
+*Generated automatically by `/doc-sync`*
\ No newline at end of file
diff --git a/.agent/rules/03_execution_rules.md b/.agent/rules/03_execution_rules.md
index c23357e43..ded92a982 100644
--- a/.agent/rules/03_execution_rules.md
+++ b/.agent/rules/03_execution_rules.md
@@ -76,11 +76,10 @@ To ensure quality and clarity in every development cycle, all non-trivial featur
- **Commit Validation:** Commits are automatically linted via `commitlint`. Non-compliant messages will be rejected by the pre-commit hook.
- **History Documentation:** Use `npm run commit` to generate structured history.
-1. **Changelog:** All changes MUST be traced and documented inside `@Changelog`.
- - _Exception_: Documentation-only updates (`docs:`) following Conventional Commits may skip the manual `@Changelog` entry if they are primarily intended for README synchronization.
- - _Requirement_: Adding a new test MUST have a `test:` entry in the `@Changelog`.
- - _Requirement_: Changing test scripts or updating infrastructure MUST have a `ci:` entry in the `@Changelog`.
- - _Requirement_: Changing `Makefile` or files under `build/` MUST be traced in the `@Changelog` (usually via `ci:` or `chore:`).
+1. **Changelog:** All changes, without exception (including documentation `docs:` and unit tests `test:`), MUST be traced and documented inside the `Changelog` file to ensure granular change tracking.
+ - _Requirement_: Adding a new test MUST have a `test:` entry in the `Changelog`.
+ - _Requirement_: Changing test scripts or updating infrastructure MUST have a `ci:` entry in the `Changelog`.
+ - _Requirement_: Changing `Makefile` or files under `build/` MUST be traced in the `Changelog` (usually via `ci:` or `chore:`).
- _Requirement_: All feature completions MUST be synchronized with `ROADMAP.md` before final PR/Commit.
- _Ordering_: Changelog entries MUST be ordered by category: `chore`, `feat`, `fix`, `test`, `ci`, then others.
- _Release Notes_: All release notes generated in `releases/` MUST follow the same category ordering in their "Executive Summary" section.
diff --git a/.agent/workflows/doc-sync.md b/.agent/workflows/doc-sync.md
index 02fe62df1..4c03b7cbb 100644
--- a/.agent/workflows/doc-sync.md
+++ b/.agent/workflows/doc-sync.md
@@ -10,7 +10,7 @@ category: Documentation
// turbo
```bash
-python3 build/doc_sync.py
+perl build/doc_sync.pl
```
1. **Full Documentation Review Checklist**:
diff --git a/.agent/workflows/local-dev-sync.md b/.agent/workflows/local-dev-sync.md
new file mode 100644
index 000000000..340821403
--- /dev/null
+++ b/.agent/workflows/local-dev-sync.md
@@ -0,0 +1,32 @@
+---
+trigger: explicit_call
+description: Synchronize developer changes, run unit tests, and update changelog and release notes.
+category: tool
+---
+
+# Local Developer Sync Workflow
+
+This workflow provides local synchronization automation for the developer, ensuring version consistency, automatic changelog sorting, release notes updating, unit testing, and git delivery.
+
+## đ§ Rationale
+
+Ensuring a clean and synchronized branch history, accurate release documentation, and fully passing unit tests before any push to the remote repository.
+
+## đ ïž Implementation
+
+Run the local dev-sync orchestrator script:
+
+// turbo
+
+```bash
+perl build/dev_sync.pl
+```
+
+## â
Verification
+
+- The script returns exit code 0.
+- All version files are confirmed to be consistent.
+- `Changelog` is updated and sorted according to the Conventional Commit categories.
+- `releases/v[VERSION].md` matches the latest entries.
+- All local unit tests passed successfully.
+- Modified release files are committed and pushed to `origin`.
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 000000000..859b3921d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,43 @@
+---
+name: Bug report
+about: Create a report to help us improve MySQLTuner
+title: '[BUG] '
+labels: bug
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**Execution Command**
+Please provide the exact command used to run MySQLTuner:
+```bash
+perl mysqltuner.pl [your arguments here]
+```
+
+**Environment info:**
+- MySQLTuner version: (e.g. 2.9.0)
+- Database Server: (e.g. MySQL, MariaDB, Percona Server)
+- Database Version: (e.g. 8.0.35, 10.6.15)
+- Operating System & Version: (e.g. Rocky Linux 9, Ubuntu 22.04)
+- Perl version: (e.g. v5.32.1)
+
+**MySQLTuner Debug Output**
+Please run MySQLTuner with the `--debug` option and paste the output here.
+> â ïž **IMPORTANT:** Obfuscate/remove any sensitive database credentials, IP addresses, hostnames, or passwords before pasting.
+
+```markdown
+
+```
+
+**To Reproduce**
+Steps to reproduce the behavior if not fully captured by the debug output:
+1. Run command '...'
+2. See error '...'
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Additional context**
+Add any other context about the problem here (e.g. custom configuration files, /etc/my.cnf).
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000..3a7c982cc
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: true
+contact_links:
+ - name: Stack Overflow
+ url: http://stackoverflow.com/questions/tagged/mysqltuner
+ about: Please post usage questions or support issues on Stack Overflow using the 'mysqltuner' tag.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 000000000..17e3c4955
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for MySQLTuner
+title: '[ENHANCEMENT] '
+labels: enhancement
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. (e.g., I'm always frustrated when [...])
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.husky/commit-msg b/.husky/commit-msg
index 70bd3dd23..f92fa5d0c 100644
--- a/.husky/commit-msg
+++ b/.husky/commit-msg
@@ -1 +1,16 @@
-npx --no-install commitlint --edit "$1"
+#!/bin/sh
+
+# Run commitlint first to validate the commit message format
+npx --no-install commitlint --edit "$1" || exit 1
+
+# Check if Changelog is staged when commit type is feat or fix
+COMMIT_MSG_FILE="$1"
+FIRST_LINE=$(head -n 1 "$COMMIT_MSG_FILE")
+
+if echo "$FIRST_LINE" | grep -qE "^(feat|fix)(\([^)]+\))?!?:\s" ; then
+ if ! git diff --cached --name-only | grep -q "^Changelog$"; then
+ echo "ERROR: Commit type 'feat' or 'fix' detected, but 'Changelog' is not staged."
+ echo "Please update and stage 'Changelog' before committing."
+ exit 1
+ fi
+fi
diff --git a/.husky/pre-commit b/.husky/pre-commit
old mode 100644
new mode 100755
index 72c4429bc..de1f3da3b
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1 +1,8 @@
-npm test
+#!/bin/sh
+
+# Only run tests if code, tests, build scripts, Makefile, or package configuration has changed
+if git diff --cached --name-only | grep -qE '\.(pl|pm)$|^tests/|^build/|^Makefile$|^package(-lock)?\.json$'; then
+ npm test
+else
+ echo "Husky [pre-commit]: No code or test files modified. Skipping unit tests."
+fi
diff --git a/CURRENT_VERSION.txt b/CURRENT_VERSION.txt
index b33624976..c8e38b614 100644
--- a/CURRENT_VERSION.txt
+++ b/CURRENT_VERSION.txt
@@ -1 +1 @@
-2.8.45
+2.9.0
diff --git a/Changelog b/Changelog
index b226931eb..1c2881589 100644
--- a/Changelog
+++ b/Changelog
@@ -1,7 +1,89 @@
# MySQLTuner Changelog
+2.9.0 2026-07-03
+
+- chore(build): allow build scope in compliance auditor
+- chore(build): rewrite dev_sync and doc_sync in Perl for consistency
+- chore(deps): update devops-infra/action-commit-push digest to fa0c793 (#929)
+- chore(deps): update shogo82148/actions-setup-mysql digest to 076e636 (#930)
+- chore(main): add doc links in localhost warnings and support custom local subdomains
+- chore(main): add roadmap to the whitelist of allowed scopes in compliance checks.
+- chore(main): hide hostname, ssl, and replication warnings on localhost (#933)
+- chore(main): whitelist deps and system commit scopes in check_compliance.pl to support Dependabot and host metrics commits.
+- chore(metadata): add GitHub issue templates for bugs and feature requests
+- chore: automated project metadata update
+- chore: remove execution.log from git repository and sync docs
+- feat(cli): create an agent-ready output format (JSON/YAML) so that MySQLTuner can be easily integrated by AI agents.
+- feat(cli): resolve remote memory, socket override, and temptable sizing issues
+- feat(main): recommend enabling slow query log if disabled (#517)
+- feat(metadata): fix test badge and update version references in READMEs
+- feat(report): add pgBadger-inspired query distribution, locking latency, and temp table memory/disk spill visual analytics to HTML report
+- feat(report): add verbose execution timing measurements for each section, showing both elapsed time and its percentage relative to the total script execution time.
+- feat(report): add verbose timings, step percentages, and snapshot summary
+- feat(report): categorize recommendations in HTML report and add Connections/Performance panels, Storage engines table, and SQL modeling stats grid.
+- feat(report): finalize HTML report, YAML output, and historical comparison
+- feat(report): finalize a complete HTML report file beginning in v2.8.45.
+- feat(report): implement Phase 13 sectional global indicators and KPIs
+- feat(report): move dump_csv_files execution step to immediately before make_recommendations.
+- feat(report): print an environment audit snapshot summary (server, user, RAM, swap, versions, uptime) right after get_all_vars.
+- feat(report): support historical comparison of database diagnostics and performance metrics over time.
+- feat: recommend slow query log when disabled (#517)
+- feat: update Total buffers output message format to display temptable sizing when active.
+- fix(cli): add mutually exclusive guard for json and yaml options.
+- fix(cli): preserve --socket in connection arguments when --host is specified.
+- fix(main): add undefined and 'NULL' guards to hr_num to eliminate uninitialized value warnings.
+- fix(main): add undefined/NULL guards to hr_num to resolve uninitialized warnings
+- fix(main): address PR #931 code review feedback and enhance test validations
+- fix(main): calculate health score early in historical comparison to ensure scores exist for trend analysis.
+- fix(main): calculate query cache efficiency using Com_select on MariaDB (MDEV-4981)
+- fix(main): calculate query cache efficiency using Com_select on MariaDB, where Com_select includes query cache hits (MDEV-4981).
+- fix(main): exclude MariaDB user roles and support zero-config TLS
+- fix(main): exclude MariaDB user roles from SSL remote user audit
+- fix(main): format YAML null as tilde (~) and multiline values using literal block style.
+- fix(main): guard InnoDB log file size and log size percentage checks against uninitialized variables.
+- fix(main): guard version and version comment checks in MariaDB parallel replication and query cache blocks.
+- fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance.
+- fix(main): only recommend increasing innodb_log_buffer_size when log waits occur
+- fix(main): sanitize and redact sensitive credentials from json/yaml output exports.
+- fix(main): skip local /proc/loadavg read during remote database audits.
+- fix(main): support MariaDB 11.4+ zero-configuration TLS and avoid false missing certificate warnings
+- fix(report): flush console output trace when opening raw log files late in dumpdir mode.
+- fix(test): add undef fallbacks to human_size, hr_bytes and hr_bytes_rnd mocks in tests.
+- fix: update commit process
+- fix: update documentation and code
+- test(hook): verify pre-commit hook runs tests when test files change
+- test(lab): add unit test test_issue_480.t for table_open_cache_instances recommendation (#480)
+- test(lab): add unit test test_issue_517.t for slow query log recommendations (#517)
+- test(lab): add unit test test_issue_810.t for issue 810
+- test(lab): add unit test test_issue_810.t for remote host forcemem MB interpretation correctness (issue #810)
+- test(lab): add unit test test_issue_938.t for InnoDB write log efficiency
+- test(lab): add unit tests for query cache efficiency logic on MySQL and MariaDB in tests/test_issue_927.t (renamed from tests/issue_927.t).
+- test(lab): normalize all repro_issue_*.t and issue_*.t test file names to test_issue_*.t
+- test(lab): split unit_coverage_boost4.t into smaller topic-oriented unit tests: unit_cli_helpers.t, unit_client_privileges.t, unit_cloud_commands.t, unit_fs_info.t, unit_os_vm.t
+- test(lab): utilize isolated tempfile for mock json to avoid race conditions in tests/unit_phase13_kpis.t.
+- test(report): add verbose timing and audit snapshot summary formatting checks to tests/verbose_timing.t.
+- test(report): update HTML report unit tests to verify connections, performance, storage, and modeling HTML sections.
+- test(security): add test cases for MariaDB role exclusion and zero-configuration TLS
+- test(versions): add unit tests for version caching and comparisons, resolve redundant warnings, and use tempfile in tests/unit_versions.t.
+- test: add socket preservation tests to test_issue_781.t.
+- test: add unit test for remote host physical memory detection and validation (issue #796).
+- test: add unit test for temptable output formatting in Total buffers.
+- ci: optimize pre-commit hook to only run unit tests when code, tests, or dependencies are modified.
+- docs(metadata): promote HTML reports and add E2E examples
+- docs(metadata): remove timestamp from doc-sync generated files
+- docs(roadmap): link strategic technical evolutions specification and enforce changelog staging
+- docs(roadmap): specify and implement Phase XIV pgBadger-inspired HTML report modules
+- docs(roadmap): specify Phase XVI (AI Agent Integration) and Phase XVII (Dockerized MCP Daemon).
+- docs: add LightPath as sponsor, relocate coffee button, and use star-history chart
+- docs: regenerate release notes
+- docs: update repository links to major/MySQLTuner-perl and add GitHub stars badge
+
2.8.45 2026-06-04
+
+
+
+
- chore: restore doc_sync.py utility script and run doc synchronization.
- feat: export both complete (unfiltered) and filtered CSV files for sys views in dumpdir.
- feat: filter system databases from PFS/sys schema analysis queries and dumpdir exports.
@@ -26,6 +108,10 @@
2.8.44 2026-05-27
+
+
+
+
- feat: develop automated end-of-life (EOL) date synchronization check script to audit LTS versions in CI.
- feat: develop automated specification consistency auditor and Spec-to-Test Mapping Matrix.
- feat: develop LTS API auto-bumping utility with GitHub Actions integration.
@@ -56,6 +142,10 @@
2.8.43 2026-05-25
+
+
+
+
- chore: bump version to 2.8.43.
- feat: add --compress-dump option to compress SQL schema dumps using gzip.
- feat: add --dump-limit option to limit row extraction count for CSV dumps.
@@ -82,11 +172,19 @@
2.8.42 2026-05-17
+
+
+
+
- chore: automated project metadata update (empty release).
2.8.41 2026-05-17
+
+
+
+
- feat: enhance --forcemem and --forceswap to support human-readable memory units (B, K, M, G, T, P).
- feat: implement idiomatic Perl Boolean practices across the project (#34).
- feat: add recommendation for `table_open_cache_instances` based on CPU cores (#480).
@@ -129,6 +227,10 @@
2.8.40 2026-02-15
+
+
+
+
- feat: overhaul InnoDB Redo Log Capacity logic to consider RAM size and workload writes.
- feat: add support for `innodb_dedicated_server` detection in Redo Log diagnostics.
- feat: implement multi-cloud autodiscovery for AWS RDS/Aurora, GCP Cloud SQL, Azure (Flexible/Managed), and DigitalOcean.
@@ -144,6 +246,10 @@
2.8.39 2026-02-15
+
+
+
+
- feat: implement authentication plugin security checks to detect insecure or deprecated plugins like mysql_native_password and sha256_password.
- feat: add MySQL 9.x readiness diagnostics for eliminated authentication methods.
- feat: update MariaDB recommendations to suggest ed25519 and unix_socket for enhanced security.
@@ -169,6 +275,10 @@
2.8.36 2026-02-13
+
+
+
+
- fix: migrate CI workflows to native GitHub services to resolve Docker API version mismatch.
- fix: modernize release workflow using softprops/action-gh-release@v2.
- fix: enhance Docker publishing with Buildx setup for reliable multi-platform builds.
@@ -186,6 +296,10 @@
2.8.35 2026-02-02
+
+
+
+
- feat: modernize version check using `HTTP::Tiny` with robust fallback to `curl`/`wget` (PR #18 and #17).
- feat: integrate `perltidy` in `release-preflight` workflow and enforce script formatting (issue #19).
- fix: resolve inaccurate `innodb_log_file_size` recommendations caused by integer rounding (issue #770).
@@ -198,6 +312,10 @@
2.8.33 2026-01-31
+
+
+
+
- fix: improved cPanel/Flex detection and refined `skip-name-resolve` recommendation (issue #863).
- test: add enhanced unit test `tests/issue_863_enhanced.t` for cPanel detection verification.
- docs: consolidate project governance rules and resolve backwards compatibility contradiction (00_constitution.md, 03_execution_rules.md).
@@ -219,6 +337,10 @@
2.8.32 2026-01-30
+
+
+
+
- feat: remove `--skippassword` from test laboratory to enable security checks.
- fix: resolve false positive weak password warnings on MariaDB socket authentication (issue #875) and prevent dictionary corruption by silencing `curl`/`wget` output.
- test: add unit test [tests/test_issue_875.t](file:///tests/test_issue_875.t) to verify socket authentication detection.
@@ -228,6 +350,10 @@
2.8.31 2026-01-27
+
+
+
+
- feat: add `--schemadir ` option to generate per-schema markdown documentation.
- feat: support independent schema documentation generation without requiring `--dumpdir`.
- feat: restructure specifications into `documentation/specifications/` (/hey-agent).
@@ -263,6 +389,10 @@
2.8.30 2026-01-24
+
+
+
+
- feat: auto-generate `raw_mysqltuner.txt` report in `dumps/` directory when using `--dumpdir`
- feat: add InnoDB transaction isolation levels and metrics (active count, longest duration)
- feat: add MariaDB innodb_snapshot_isolation detection and recommendation
@@ -289,21 +419,37 @@
2.8.29 2026-01-24
+
+
+
+
- fix: synchronize all version occurrences in mysqltuner.pl and update release workflows (issue #15)
- feat: add version consistency check to release-preflight and git-flow workflows
- docs: update copyright years to 2026
2.8.28 2026-01-22
+
+
+
+
- feat: ajoute l'option --no-pfstat pour la partie performance schema
- feat: ajoute l'option --no-colstat pour la partie colonne stat
- fix: skip innodb_buffer_stats during sys schema dump to avoid performance issues
2.8.27 2026-01-18
+
+
+
+
- refactor: replace massive raw backtick usage with execute_system_command wrapper for better security and compliance (Compliance Sentinel)
2.8.26 2026-01-18
+
+
+
+
- fix: inverted replication command logic causing wrong SQL on MySQL 8.0+/MariaDB 10.5+ (issue #553)
- feat: add MySQL/MariaDB version detection to prevent version number conflicts in replication logic
- test: add comprehensive test suite (test_issue_553.t) for replication command compatibility
@@ -311,17 +457,29 @@
2.8.24 2026-01-18
+
+
+
+
- fix: improve MariaDB 11+ detection by checking version_comment (issue #869)
- fix: handle innodb_buffer_pool_chunk_size=0 (autosize) in MariaDB 10.8+ (#869)
- chore: bump version to 2.8.24
2.8.23 2026-01-18
+
+
+
+
- feat: add --ignore-tables CLI option to filter specific tables from analysis (#749)
- chore: bump version to 2.8.23
2.8.22 2026-01-18
+
+
+
+
- feat: update all repository links from 'major' to 'jmrenouard' (issue #410)
- docs: add Changelog information and Useful Links to all README files (issue #411)
- feat: improve thread_pool_size recommendations based on logical CPU count (issue #404)
@@ -332,65 +490,113 @@
2.8.21 2026-01-18
+
+
+
+
- fix: remove contradictory query_cache_limit recommendation when disabling query cache (issue #671)
- fix: cap join_buffer_size recommendation at 4MB and prefer index optimization (issue #671)
- chore: bump version to 2.8.21
2.8.20 2026-01-18
+
+
+
+
- feat: add automated regression test for forcemem MB interpretation (issues #780, #810)
- chore: bump version to 2.8.20
2.8.18 2026-01-18
+
+
+
+
- feat: add --max-password-checks option to limit dictionary checks (default: 100)
- fix: ensure Machine type is reported as 'Container' when --container option is used
- chore: bump version to 2.8.18
2.8.17 2026-01-18
+
+
+
+
- feat: implementation of issue #403 to check weak passwords on MySQL 8.0+ and flush hosts every 100 attempts
- chore: bump version to 2.8.17
2.8.16 2026-01-18
+
+
+
+
- chore: bump version to 2.8.16
2.8.15 2026-01-18
+
+
+
+
- feat: update all GitHub links from 'major' to 'jmrenouard' organization
- feat: refactor plugin information to filter ACTIVE status and display specific columns grouped by type
- chore: bump version to 2.8.15
2.8.13 2026-01-18
+
+
+
+
- docs: add Useful Links section to all README files (English, French, Russian, Italian)
- chore: bump version to 2.8.13
2.8.12 2026-01-17
+
+
+
+
- feat: update is_docker() to detect containerd and podman runtimes
- chore: bump version to 2.8.12
2.8.11 2026-01-17
+
+
+
+
- docs: update INTERNALS.md with information about Cloud, SSH, Containers, and Plugins
- chore: bump version to 2.8.11
2.8.10 2026-01-17
+
+
+
+
- feat: add dates and commands to log files in test_envs.sh
- feat: add separators (=) at the end of log files in test_envs.sh
- chore: synchronize version strings across script, POD, and version file
2.8.9 2026-01-17
+
+
+
+
- feat: improve container log detection by excluding proxy containers (traefik, haproxy, maxscale, proxy)
- feat: prioritize database-related container names (mysql, mariadb, percona, db, database)
- chore: bump version to 2.8.9
2.8.8 2026-01-17
+
+
+
+
- feat: add -d/--database parameter to test_envs.sh to tune specific databases
- feat: add -c/--configs parameter to test_envs.sh for easier configuration selection
- feat: add timestamps to major steps in test_envs.sh logs
@@ -399,12 +605,20 @@
2.8.7 2026-01-17
+
+
+
+
- docs: add standardized comment headers to all build shell scripts
- chore: synchronize version strings across script, POD, and version file
- fix: ensure version consistency between Changelog and CURRENT_VERSION.txt
2.8.6 2026-01-17
+
+
+
+
- feat: add Plugin Information section and --plugininfo flag (#794)
- fix: memory calculation bug in system_recommendations (1.5GB check)
- fix: ensure forcemem is correctly interpreted and displayed as MB in os_setup
@@ -412,30 +626,54 @@
2.8.5 2026-01-17
+
+
+
+
- fix: noisy sysctl errors for sunrpc parameters when kernel module is not loaded
- fix: refactor get_kernel_info to handle missing sysctl parameters gracefully
2.8.4 2026-01-17
+
+
+
+
- fix: database injection failing to find dump files due to incorrect working directory
- fix: ensure correct path handling for 'source' commands in employees.sql
2.8.3 2026-01-17
+
+
+
+
- feat: detect docker/podman environment and automatically grab logs from container if local log file is not found
- feat: add --container option to manually specify a container for log retrieval
2.8.2 2026-01-17
+
+
+
+
- fix: system command failures (ping/ifconfig/redirection) on modern Linux (Ubuntu 22.04/WSL2)
- feat: integrate external test dependencies (multi-db-docker-env, test_db) and automated employees database injection
2.8.1 2026-01-17
+
+
+
+
- fix: resilient memory checks with /proc fallback on Linux and silencing expected ps failures
2.8.0 2026-01-17
+
+
+
+
- Bump version to 2.8.0
- enhance user hostname restriction checks
- feat: Translate comments and messages in updateCVElist.py to English
diff --git a/POTENTIAL_ISSUES.md b/POTENTIAL_ISSUES.md
index cfc248b21..c85c11739 100644
--- a/POTENTIAL_ISSUES.md
+++ b/POTENTIAL_ISSUES.md
@@ -2,13 +2,13 @@
This file records anomalies discovered during laboratory testing (Perl warnings, SQL errors, etc.).
-## [2026-05-29 Audit] Status Refresh v2.8.44
+## [2026-06-16 Audit] Status Refresh v2.9.0
### Unit Test Results
- **Status**: â
ALL PASS
-- **Files**: 72 test files
-- **Assertions**: 362 tests
+- **Files**: 81 test files
+- **Assertions**: 462 tests
- **Perl Syntax**: Clean (`perl -cw mysqltuner.pl` â no warnings)
### Test Coverage Analysis
@@ -16,15 +16,12 @@ This file records anomalies discovered during laboratory testing (Perl warnings,
| Metric | Value |
|:---|:---|
| Total Subroutines | 167 |
-| Tested Subroutines | ~154 (~92%) |
-| Untested Subroutines | ~13 (~8%) |
+| Tested Subroutines | 167 (100%) |
+| Untested Subroutines | 0 (0%) |
#### Remaining Untested Subroutines (System/IO-Heavy)
-- `check_privileges`, `cloud_setup`, `get_fs_info`, `get_fs_info_win`
-- `get_http_cli`, `get_os_release`, `get_tuning_info`
-- `infoprintcmd`, `infoprinthcmd`, `is_virtual_machine`
-- `parse_cli_args`, `show_help` (x2)
+- None (100% subroutine coverage reached)
### đŽ Critical Issues
@@ -58,7 +55,8 @@ This file records anomalies discovered during laboratory testing (Perl warnings,
#### PI-006: 13 out of 167 subroutines have zero test coverage
- **Impact**: Remaining untested functions are mostly system-level (filesystem, OS detection, cloud setup) or CLI helpers (`show_help`, `parse_cli_args`)
- **Severity**: đą LOW â core diagnostic functions now fully covered
-- **Coverage rate**: ~92% of subroutines referenced in at least one test (improved from ~55% â 62% â 78% â 92%)
+- **Coverage rate**: 100% of subroutines referenced in at least one test (improved from ~55% â 62% â 78% â 92% â 100%)
+- **Status**: [x] **FIXED** â All remaining subroutines covered in `tests/unit_coverage_boost4.t`.
#### PI-007: Extremely large subroutines
- **Impact**: Several functions exceed 500+ lines, making maintenance difficult
@@ -70,6 +68,7 @@ This file records anomalies discovered during laboratory testing (Perl warnings,
- **Source**: Each call to `mysql_version_ge()`, `mysql_version_le()`, `mysql_version_eq()` re-parses `$myvar{'version'}` via regex
- **Impact**: Redundant computation â called 100+ times across the script
- **Severity**: đą LOW â performance impact minimal but code duplication
+- **Status**: [x] **FIXED** â Implemented version parsing caching via `_parse_version()`.
#### PI-009: MariaDB 10.6 Approaching EOL
- **Source**: [mariadb_support.md](file:///mariadb_support.md)
@@ -114,12 +113,13 @@ This file records anomalies discovered during laboratory testing (Perl warnings,
- Binlog checksum, doublewrite consistency: NOT implemented
- **Status**: Phase 9 partially implemented
-#### PI-016: ROADMAP Phases 10-12 â Not started
+#### PI-016: ROADMAP Phases 11-12 â Not started
- Workload Analysis & Traffic Profiling: Not implemented
- Advanced Log Parser & Lock Monitoring: Not implemented
-- Sectional Global Indicators: Not implemented
-#### PI-017: ROADMAP Phase 13 (Export Optimization) â COMPLETED â
+#### PI-017: ROADMAP Phase 13 (Sectional Global Indicators) â COMPLETED â
+
+#### PI-018: ROADMAP Phase 14 (Export Optimization) â COMPLETED â
---
@@ -212,3 +212,13 @@ This file records anomalies discovered during laboratory testing (Perl warnings,
- [x] **Doc-Sync**: `.agent/README.md` synchronized with 18 workflows.
- [x] **SECURITY.md**: Version reference updated to v2.8.44.
- [x] **ROADMAP PI-010**: I/O Pressure status already corrected.
+
+### [2026-06-04] Release v2.8.45
+
+- [x] **Temptable Sizing Limits**: Integrated `temptable_max_ram` calculations and mmap checks.
+- [x] **InnoDB Index/Data Ratio Check**: Added advisory and CSV dump for tables > 50,000 rows.
+- [x] **Uptime Observability**: Exposed Database Server Uptime in all modes.
+- [x] **Performance Optimization**: Bulk-fetched table engine and column details to reduce queries (from 2N+3 to 2 per table).
+- [x] **System DB Filtering**: Excluded system tables from schema analysis and dumpdir exports.
+- [x] **SQL Escaping Fixes**: Safe dollar sign escaping in system call wrappers.
+
diff --git a/README.fr.md b/README.fr.md
index 56fd51363..c5d74e307 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -1,11 +1,11 @@

-[](https://www.buymeacoffee.com/jmrenouard)
+[](https://github.com/major/MySQLTuner-perl)
[](https://opensource.box.com/badges)
-[](https://github.com/jmrenouard/MySQLTuner-perl/actions)
-[](https://isitmaintained.com/project/jmrenouard/MySQLTuner-perl "Temps moyen de résolution d'un problÚme")
-[](https://isitmaintained.com/project/jmrenouard/MySQLTuner-perl "Pourcentage de problĂšmes encore ouverts")
+[](https://github.com/major/MySQLTuner-perl/actions)
+[](https://isitmaintained.com/project/major/MySQLTuner-perl "Temps moyen de résolution d'un problÚme")
+[](https://isitmaintained.com/project/major/MySQLTuner-perl "Pourcentage de problĂšmes encore ouverts")
[](https://opensource.org/licenses/GPL-3.0/)
**MySQLTuner** est un script écrit en Perl qui vous permet d'examiner rapidement une installation MySQL et de faire des ajustements pour augmenter les performances et la stabilité. Les variables de configuration actuelles et les données d'état sont récupérées et présentées dans un bref format avec quelques suggestions de performances de base.
@@ -15,34 +15,52 @@
**MySQLTuner** est activement maintenu et prend en charge de nombreuses configurations telles que [Galera Cluster](https://galeracluster.com/), [TokuDB](https://www.percona.com/software/mysql-database/percona-tokudb), [Schéma de performance](https://github.com/mysql/mysql-sys), les métriques du systÚme d'exploitation Linux, [InnoDB](https://dev.mysql.com/doc/refman/5.7/en/innodb-storage-engine.html), [MyISAM](https://dev.mysql.com/doc/refman/5.7/en/myisam-storage-engine.html), [Aria](https://mariadb.com/docs/server/server-usage/storage-engines/aria/aria-storage-engine), ...
Vous pouvez trouver plus de détails sur ces indicateurs ici :
-[Description des indicateurs](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/INTERNALS.md).
+[Description des indicateurs](https://github.com/major/MySQLTuner-perl/blob/master/INTERNALS.md).
-
+
Liens utiles
==
-* **Développement actif :** [https://github.com/jmrenouard/MySQLTuner-perl](https://github.com/jmrenouard/MySQLTuner-perl)
-* **Versions/Tags :** [https://github.com/jmrenouard/MySQLTuner-perl/tags](https://github.com/jmrenouard/MySQLTuner-perl/tags)
-* **Changelog :** [https://github.com/jmrenouard/MySQLTuner-perl/blob/master/Changelog](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/Changelog)
+* **Développement actif :** [https://github.com/major/MySQLTuner-perl](https://github.com/major/MySQLTuner-perl)
+* **Versions/Tags :** [https://github.com/major/MySQLTuner-perl/tags](https://github.com/major/MySQLTuner-perl/tags)
+* **Changelog :** [https://github.com/major/MySQLTuner-perl/blob/master/Changelog](https://github.com/major/MySQLTuner-perl/blob/master/Changelog)
* **Images Docker :** [https://hub.docker.com/repository/docker/jmrenouard/mysqltuner/tags](https://hub.docker.com/repository/docker/jmrenouard/mysqltuner/tags)
+* **Rapports HTML Interactifs (v2.9.0+) :**
+ * [Exemple de rapport MariaDB 11.4](https://lightpath.fr/MySQLtuner_reports/MySQLTuner-v290_mariadb114/Schemadir/mysqltuner_report.html)
+ * [Exemple de rapport MySQL 8.4](https://lightpath.fr/MySQLtuner_reports/MySQLTuner-v290_mysql84/Schemadir/mysqltuner_report.html)
+ * [Exemple de rapport Percona 8.0](https://lightpath.fr/MySQLtuner_reports/MySQLTuner-v290_percona80/Schemadir/mysqltuner_report.html)
MySQLTuner a besoin de vous
===
**MySQLTuner** a besoin de contributeurs pour la documentation, le code et les commentaires :
-* Veuillez nous rejoindre sur notre outil de suivi des problĂšmes sur [le suivi GitHub](https://github.com/jmrenouard/MySQLTuner-perl/issues).
-* Le guide de contribution est disponible en suivant [le guide de contribution de MySQLTuner](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/CONTRIBUTING.md)
-* Mettez une étoile au **projet MySQLTuner** sur [le projet Git Hub de MySQLTuner](https://github.com/jmrenouard/MySQLTuner-perl/)
+* Veuillez nous rejoindre sur notre outil de suivi des problĂšmes sur [le suivi GitHub](https://github.com/major/MySQLTuner-perl/issues).
+* Le guide de contribution est disponible en suivant [le guide de contribution de MySQLTuner](https://github.com/major/MySQLTuner-perl/blob/master/CONTRIBUTING.md)
+* Mettez une étoile au **projet MySQLTuner** sur [le projet Git Hub de MySQLTuner](https://github.com/major/MySQLTuner-perl/)
* Support payant pour LightPath ici : [jmrenouard@lightpath.fr](jmrenouard@lightpath.fr)
* Support payant pour Releem disponible ici : [Application Releem](https://releem.com/)
+### Sponsors
+
+Le développement actif est sponsorisé par :
+
+
+
+
+
+
+
+Merci à LightPath pour la mise à disposition des ressources (serveurs de développement, abonnement IA, environnements de recette & fonctionnalités).
+

-## Stargazers au fil du temps
+[](https://www.buymeacoffee.com/jmrenouard)
+
+## Historique des étoiles
-[](https://starchart.cc/jmrenouard/MySQLTuner-perl)
+[](https://star-history.com/#major/MySQLTuner-perl&Date)
Compatibilité
====
@@ -58,8 +76,8 @@ Les rĂ©sultats des tests sont disponibles ici uniquement pour les versions LTSÂ
Merci Ă [endoflife.date](https://endoflife.date/)
-* Reportez-vous aux [versions prises en charge de MariaDB](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mariadb_support.md).
-* Reportez-vous aux [versions prises en charge de MySQL](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mysql_support.md).
+* Reportez-vous aux [versions prises en charge de MariaDB](https://github.com/major/MySQLTuner-perl/blob/master/mariadb_support.md).
+* Reportez-vous aux [versions prises en charge de MySQL](https://github.com/major/MySQLTuner-perl/blob/master/mysql_support.md).
***La prise en charge de Windows est partielle***
@@ -156,12 +174,12 @@ Recommandations de sécurité
Salut l'utilisateur de directadmin !
Nous avons détecté que vous exécutez mysqltuner avec les informations d'identification de da_admin extraites de `/usr/local/directadmin/conf/my.cnf`, ce qui pourrait entraßner une découverte de mot de passe !
-Lisez le lien pour plus de détails [ProblÚme n°289](https://github.com/jmrenouard/MySQLTuner-perl/issues/289).
+Lisez le lien pour plus de détails [ProblÚme n°289](https://github.com/major/MySQLTuner-perl/issues/289).
Que vérifie exactement MySQLTuner ?
--
-Toutes les vérifications effectuées par **MySQLTuner** sont documentées dans la documentation [MySQLTuner Internals](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/INTERNALS.md).
+Toutes les vérifications effectuées par **MySQLTuner** sont documentées dans la documentation [MySQLTuner Internals](https://github.com/major/MySQLTuner-perl/blob/master/INTERNALS.md).
**MySQLTuner** analyse les domaines suivants :
@@ -190,14 +208,14 @@ Choisissez l'une de ces méthodes :
```bash
wget https://mysqltuner.pl/ -O mysqltuner.pl
-wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt
-wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv
+wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt
+wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv
```
2) Vous pouvez télécharger l'intégralité du référentiel en utilisant `git clone` ou `git clone --depth 1 -b master` suivi de l'URL de clonage ci-dessus.
```bash
-git clone --depth 1 -b master https://github.com/jmrenouard/MySQLTuner-perl.git
+git clone --depth 1 -b master https://github.com/major/MySQLTuner-perl.git
```
3) Sur Apple macOS, installez via [Homebrew](https://brew.sh/)Â :
@@ -217,8 +235,8 @@ docker run --rm -it jmrenouard/mysqltuner --host --user
+
+
+
+
+
+Grazie a LightPath per aver fornito risorse (server di sviluppo, abbonamento IA, staging e funzionalitĂ ).
+

-## Stargazer nel tempo
+[](https://www.buymeacoffee.com/jmrenouard)
+
+## Cronologia delle stelle
-[](https://starchart.cc/jmrenouard/MySQLTuner-perl)
+[](https://star-history.com/#major/MySQLTuner-perl&Date)
CompatibilitĂ
====
@@ -58,8 +76,8 @@ I risultati dei test sono disponibili qui solo per LTS:
Grazie a [endoflife.date](https://endoflife.date/)
-* Fare riferimento a [Versioni supportate di MariaDB](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mariadb_support.md).
-* Fare riferimento a [Versioni supportate di MySQL](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mysql_support.md).
+* Fare riferimento a [Versioni supportate di MariaDB](https://github.com/major/MySQLTuner-perl/blob/master/mariadb_support.md).
+* Fare riferimento a [Versioni supportate di MySQL](https://github.com/major/MySQLTuner-perl/blob/master/mysql_support.md).
***Il supporto per Windows Ăš parziale***
@@ -156,12 +174,12 @@ Raccomandazioni di sicurezza
Ciao utente di directadmin!
Abbiamo rilevato che esegui mysqltuner con le credenziali di da_admin prese da `/usr/local/directadmin/conf/my.cnf`, il che potrebbe portare alla scoperta di una password!
-Leggi il link per maggiori dettagli [Problema #289](https://github.com/jmrenouard/MySQLTuner-perl/issues/289).
+Leggi il link per maggiori dettagli [Problema #289](https://github.com/major/MySQLTuner-perl/issues/289).
Cosa sta controllando esattamente MySQLTuner?
--
-Tutti i controlli eseguiti da **MySQLTuner** sono documentati nella documentazione [MySQLTuner Internals](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/INTERNALS.md).
+Tutti i controlli eseguiti da **MySQLTuner** sono documentati nella documentazione [MySQLTuner Internals](https://github.com/major/MySQLTuner-perl/blob/master/INTERNALS.md).
**MySQLTuner** analizza le seguenti aree:
@@ -190,14 +208,14 @@ Scegli uno di questi metodi:
```bash
wget https://mysqltuner.pl/ -O mysqltuner.pl
-wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt
-wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv
+wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt
+wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv
```
2) Ă possibile scaricare l'intero repository utilizzando `git clone` o `git clone --depth 1 -b master` seguito dall'URL di clonazione sopra.
```bash
-git clone --depth 1 -b master https://github.com/jmrenouard/MySQLTuner-perl.git
+git clone --depth 1 -b master https://github.com/major/MySQLTuner-perl.git
```
3) Su Apple macOS, installa tramite [Homebrew](https://brew.sh/):
@@ -217,8 +235,8 @@ docker run --rm -it jmrenouard/mysqltuner --host --user
+
+
+
+
+
+Thanks to LightPath for providing resources (dev servers, AI subscriptions, staging & features).
+

-## Stargazers over time
+[](https://www.buymeacoffee.com/jmrenouard)
+
+## Star History
-[](https://starchart.cc/jmrenouard/MySQLTuner-perl)
+[](https://star-history.com/#major/MySQLTuner-perl&Date)
Compatibility
====
@@ -60,8 +78,8 @@ Test result are available here for LTS only:
Thanks to [endoflife.date](https://endoflife.date/)
-* Refer to [MariaDB Supported versions](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mariadb_support.md).
-* Refer to [MySQL Supported versions](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mysql_support.md).
+* Refer to [MariaDB Supported versions](https://github.com/major/MySQLTuner-perl/blob/master/mariadb_support.md).
+* Refer to [MySQL Supported versions](https://github.com/major/MySQLTuner-perl/blob/master/mysql_support.md).
***Windows Support is partial***
@@ -158,12 +176,12 @@ Security recommendations
Hi directadmin user!
We detected that you run mysqltuner with da_admin's credentials taken from `/usr/local/directadmin/conf/my.cnf`, which might bring to a password discovery!
-Read link for more details [Issue #289](https://github.com/jmrenouard/MySQLTuner-perl/issues/289).
+Read link for more details [Issue #289](https://github.com/major/MySQLTuner-perl/issues/289).
What is MySQLTuner checking exactly ?
--
-All checks done by **MySQLTuner** are documented in [MySQLTuner Internals](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/INTERNALS.md) documentation.
+All checks done by **MySQLTuner** are documented in [MySQLTuner Internals](https://github.com/major/MySQLTuner-perl/blob/master/INTERNALS.md) documentation.
**MySQLTuner** analyzes the following areas:
@@ -192,14 +210,14 @@ Choose one of these methods:
```bash
wget https://mysqltuner.pl/ -O mysqltuner.pl
-wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt
-wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv
+wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt
+wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv
```
2) You can download the entire repository by using `git clone` or `git clone --depth 1 -b master` followed by the cloning URL above.
```bash
-git clone --depth 1 -b master https://github.com/jmrenouard/MySQLTuner-perl.git
+git clone --depth 1 -b master https://github.com/major/MySQLTuner-perl.git
```
3) On Apple macOS, install via [Homebrew](https://brew.sh/):
@@ -219,8 +237,8 @@ docker run --rm -it jmrenouard/mysqltuner --host --user
+
+
+
+
+
+ХпаŃĐžĐ±ĐŸ LightPath за ĐżŃĐ”ĐŽĐŸŃŃаĐČĐ»Đ”ĐœĐžĐ” ŃĐ”ŃŃŃŃĐŸĐČ (ŃĐ”ŃĐČĐ”ŃŃ ŃазŃĐ°Đ±ĐŸŃĐșĐž, ĐżĐŸĐŽĐżĐžŃĐșа ĐœĐ° ĐĐ, ŃŃĐ”ĐčĐŽĐ¶ĐžĐœĐł Đž ĐœĐŸĐČŃĐ” ŃŃĐœĐșŃОО).
+

-## ĐĐČĐ”Đ·ĐŽĐŸŃĐ”ŃŃ Ń ŃĐ”ŃĐ”ĐœĐžĐ”ĐŒ ĐČŃĐ”ĐŒĐ”ĐœĐž
+[](https://www.buymeacoffee.com/jmrenouard)
+
+## ĐŃŃĐŸŃĐžŃ Đ·ĐČДзЎ
-[](https://starchart.cc/jmrenouard/MySQLTuner-perl)
+[](https://star-history.com/#major/MySQLTuner-perl&Date)
ĐĄĐŸĐČĐŒĐ”ŃŃĐžĐŒĐŸŃŃŃ
====
@@ -58,8 +76,8 @@ MySQLTuner ĐœŃжЎаДŃŃŃ ĐČ ĐČаŃ
ХпаŃĐžĐ±ĐŸ [endoflife.date](https://endoflife.date/)
-* ĐĄĐŒ. [ĐĐŸĐŽĐŽĐ”ŃжОĐČĐ°Đ”ĐŒŃĐ” ĐČĐ”ŃŃОО MariaDB](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mariadb_support.md).
-* ĐĄĐŒ. [ĐĐŸĐŽĐŽĐ”ŃжОĐČĐ°Đ”ĐŒŃĐ” ĐČĐ”ŃŃОО MySQL](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mysql_support.md).
+* ĐĄĐŒ. [ĐĐŸĐŽĐŽĐ”ŃжОĐČĐ°Đ”ĐŒŃĐ” ĐČĐ”ŃŃОО MariaDB](https://github.com/major/MySQLTuner-perl/blob/master/mariadb_support.md).
+* ĐĄĐŒ. [ĐĐŸĐŽĐŽĐ”ŃжОĐČĐ°Đ”ĐŒŃĐ” ĐČĐ”ŃŃОО MySQL](https://github.com/major/MySQLTuner-perl/blob/master/mysql_support.md).
***ĐĐŸĐŽĐŽĐ”ŃжĐșа Windows ŃаŃŃĐžŃĐœĐ°***
@@ -156,12 +174,12 @@ GRANT SELECT, PROCESS, EXECUTE, REPLICATION CLIENT, SHOW DATABASES, SHOW VIEW ON
ĐŃĐžĐČĐ”Ń, ĐżĐŸĐ»ŃĐ·ĐŸĐČаŃĐ”Đ»Ń directadmin!
ĐŃ ĐŸĐ±ĐœĐ°ŃŃжОлО, ŃŃĐŸ ĐČŃ Đ·Đ°ĐżŃŃĐșаДŃĐ” mysqltuner Ń ŃŃĐ”ŃĐœŃĐŒĐž ĐŽĐ°ĐœĐœŃĐŒĐž da_admin, ĐČĐ·ŃŃŃĐŒĐž Оз `/usr/local/directadmin/conf/my.cnf`, ŃŃĐŸ ĐŒĐŸĐ¶Đ”Ń ĐżŃĐžĐČĐ”ŃŃĐž Đș ŃаŃĐșŃŃŃĐžŃ ĐżĐ°ŃĐŸĐ»Ń!
-ĐĐŸĐŽŃĐŸĐ±ĐœĐ”Đ” ŃĐžŃаĐčŃĐ” ĐżĐŸ ŃŃŃĐ»ĐșĐ” [ĐŃĐŸĐ±Đ»Đ”ĐŒĐ° â289](https://github.com/jmrenouard/MySQLTuner-perl/issues/289).
+ĐĐŸĐŽŃĐŸĐ±ĐœĐ”Đ” ŃĐžŃаĐčŃĐ” ĐżĐŸ ŃŃŃĐ»ĐșĐ” [ĐŃĐŸĐ±Đ»Đ”ĐŒĐ° â289](https://github.com/major/MySQLTuner-perl/issues/289).
ЧŃĐŸ ĐžĐŒĐ”ĐœĐœĐŸ ĐżŃĐŸĐČĐ”ŃŃĐ”Ń MySQLTuner?
--
-ĐŃĐ” ĐżŃĐŸĐČĐ”ŃĐșĐž, ĐČŃĐżĐŸĐ»ĐœŃĐ”ĐŒŃĐ” **MySQLTuner**, Đ·Đ°ĐŽĐŸĐșŃĐŒĐ”ĐœŃĐžŃĐŸĐČĐ°ĐœŃ ĐČ ĐŽĐŸĐșŃĐŒĐ”ĐœŃаŃОО [MySQLTuner Internals](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/INTERNALS.md).
+ĐŃĐ” ĐżŃĐŸĐČĐ”ŃĐșĐž, ĐČŃĐżĐŸĐ»ĐœŃĐ”ĐŒŃĐ” **MySQLTuner** Đ·Đ°ĐŽĐŸĐșŃĐŒĐ”ĐœŃĐžŃĐŸĐČĐ°ĐœŃ ĐČ ĐŽĐŸĐșŃĐŒĐ”ĐœŃаŃОО [MySQLTuner Internals](https://github.com/major/MySQLTuner-perl/blob/master/INTERNALS.md).
**MySQLTuner** Đ°ĐœĐ°Đ»ĐžĐ·ĐžŃŃĐ”Ń ŃлДЎŃŃŃОД ĐŸĐ±Đ»Đ°ŃŃĐž:
@@ -190,14 +208,14 @@ GRANT SELECT, PROCESS, EXECUTE, REPLICATION CLIENT, SHOW DATABASES, SHOW VIEW ON
```bash
wget https://mysqltuner.pl/ -O mysqltuner.pl
-wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt
-wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv
+wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt
+wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv
```
2) ĐŃ ĐŒĐŸĐ¶Đ”ŃĐ” загŃŃĐ·ĐžŃŃ ĐČĐ”ŃŃ ŃĐ”ĐżĐŸĐ·ĐžŃĐŸŃĐžĐč, ĐžŃĐżĐŸĐ»ŃĐ·ŃŃ `git clone` ОлО `git clone --depth 1 -b master`, за ĐșĐŸŃĐŸŃŃĐŒ ŃлДЎŃĐ”Ń URL-аЎŃĐ”Ń ĐșĐ»ĐŸĐœĐžŃĐŸĐČĐ°ĐœĐžŃ ĐČŃŃĐ”.
```bash
-git clone --depth 1 -b master https://github.com/jmrenouard/MySQLTuner-perl.git
+git clone --depth 1 -b master https://github.com/major/MySQLTuner-perl.git
```
3) Đа Apple macOS ŃŃŃĐ°ĐœĐŸĐČĐžŃĐ” ŃĐ”ŃДз [Homebrew](https://brew.sh/):
@@ -217,8 +235,8 @@ docker run --rm -it jmrenouard/mysqltuner --host --user Derived from the test campaign analysis on v2.8.43. Addresses critical code quality issues identified during the 5-iteration test audit.
-* [ ] **Perl Warning Elimination**:
- * [ ] Add definedness guards to `mysql_version_ge()`, `mysql_version_le()`, `mysql_version_eq()` to prevent 74 uninitialized value warnings.
- * [ ] Guard `$mycalc{'innodb_log_size_pct'}` and `$myvar{'innodb_log_file_size'}` before use in InnoDB analysis.
- * [ ] Guard `$myvar{'version_comment'}` in MariaDB detection path.
+* [x] **Perl Warning Elimination**:
+ * [x] Add definedness guards to `mysql_version_ge()`, `mysql_version_le()`, `mysql_version_eq()` to prevent 74 uninitialized value warnings.
+ * [x] Guard `$mycalc{'innodb_log_size_pct'}` and `$myvar{'innodb_log_file_size'}` before use in InnoDB analysis.
+ * [x] Guard `$myvar{'version_comment'}` in MariaDB detection path.
* [x] **Version Validation Updates**:
* [x] Add MySQL 9.6 to `validate_mysql_version()` supported LTS list.
* [x] Remove MySQL 9.5 (now Outdated) from the LTS list.
-* [ ] **Test Coverage Expansion**:
- * [ ] Achieve â„80% subroutine test coverage (currently ~55%, 74 of 165 uncovered).
- * [ ] Priority coverage: `check_architecture`, `system_recommendations`, `mysql_indexes`, `mysql_views`, `mysql_routines`, `mysql_triggers`, `make_recommendations`.
- * [ ] Add tests for `dump_result`, `close_outputfile`, `get_template_model`.
-* [ ] **Version Comparison Optimization**:
- * [ ] Cache parsed version components instead of re-parsing `$myvar{'version'}` on every call to `mysql_version_ge/le/eq`.
+* [x] **Test Coverage Expansion**:
+ * [x] Achieve â„80% subroutine test coverage (reached ~92%, only 13 of 167 system/IO-heavy subroutines uncovered).
+ * [x] Priority coverage: `check_architecture`, `system_recommendations`, `mysql_indexes`, `mysql_views`, `mysql_routines`, `mysql_triggers`, `make_recommendations`.
+ * [x] Add tests for `dump_result` and `close_outputfile` (`get_template_model` obsoleted and removed).
+* [x] **Version Comparison Optimization**:
+ * [x] Cache parsed version components instead of re-parsing `$myvar{'version'}` on every call to `mysql_version_ge/le/eq`.
---
@@ -203,18 +205,18 @@ To ensure consistency and high-density development, the following roles are defi
* [ ] **Correlation Engine (Experimental)**:
* [ ] **Temporal Event Linking**: Logic to link error log timestamps with Performance Schema wait events or high CPU load detected during execution.
-### [Phase 13: Sectional Global Indicators & KPIs](file:///documentation/specifications/roadmap_phase_xii_sectional_indicators.md) [NOT STARTED]
+### [Phase 13: Sectional Global Indicators & KPIs](file:///documentation/specifications/roadmap_phase_xii_sectional_indicators.md) [COMPLETED]
> Previously Phase 12.
-* [ ] **Unified Health Dashboard**:
- * [ ] **Sectional Health Scoring**: Implementation of a 0-100 KPI for each major diagnostic area (Storage Engine, Security, Replication, SQL Modeling).
- * [ ] **Critical Findings Executive Summary**: Automated prioritization of the top 3 items per section with color-coded badges (đŽ Critical, đĄ Finding, đą Optimal).
-* [ ] **Efficiency & Resource Mapping**:
- * [ ] **Throughput Efficiency Index**: Real-time ratio analysis of logical work (Queries/sec) vs physical resource consumption (`Innodb_buffer_pool_read_requests`).
- * [ ] **Resource Saturation Heatmap**: Visual representation of proximity to system limits (CPU/MEM/IO/Connections).
-* [ ] **Comparative Insights**:
- * [ ] **Historical Performance Deltas**: Sectional trend analysis identifying areas of performance regression or improvement based on previous run data.
+* [x] **Unified Health Dashboard**:
+ * [x] **Sectional Health Scoring**: Implementation of a 0-100 KPI for each major diagnostic area (Storage Engine, Security, Replication, SQL Modeling).
+ * [x] **Critical Findings Executive Summary**: Automated prioritization of the top 3 items per section with color-coded badges (đŽ Critical, đĄ Finding, đą Optimal).
+* [x] **Efficiency & Resource Mapping**:
+ * [x] **Throughput Efficiency Index**: Real-time ratio analysis of logical work (Queries/sec) vs physical resource consumption (`Innodb_buffer_pool_read_requests`).
+ * [x] **Resource Saturation Heatmap**: Visual representation of proximity to system limits (CPU/MEM/IO/Connections).
+* [x] **Comparative Insights**:
+ * [x] **Historical Performance Deltas**: Sectional trend analysis identifying areas of performance regression or improvement based on previous run data.
### [Phase 14: Export Optimization & Dumpdir Hardening](file:///documentation/specifications/roadmap_phase_xiii_export_optimization.md) [COMPLETED]
@@ -229,7 +231,36 @@ To ensure consistency and high-density development, the following roles are defi
* [x] **Compression & Efficiency**:
* [x] **On-the-fly Compression**: Support for compressed `.gz` exports to minimize disk footprint in container/limited-storage environments.
-## đź Strategic Technical Evolutions
+### [Phase 15: Interactive Multi-Page HTML Reports & Detailed Exports](file:///documentation/specifications/roadmap_phase_xiv_html_reports.md) [COMPLETED]
+
+* [x] **Summary Page Dashboard**:
+ * [x] Executive summary layout with a modern circular health score gauge, category scores breakdown, and top findings.
+* [x] **Topic-Based Metrics Partitioning**:
+ * [x] Structure the report into tabs/views: Memory, Connections, Storage Engines, Performance, Security, SQL Modeling, Replication.
+* [x] **SVG/CSS-Based Ratios Visualization**:
+ * [x] Render interactive bars/gauges for InnoDB buffer pool hit rate, thread cache hit rate, disk temp tables, and connection saturation.
+* [x] **Embedded CSV Data Exports**:
+ * [x] Embed base64 or raw string CSV representation of variables and findings in JavaScript, enabling instant local CSV downloads.
+
+### [Phase 16: AI Agent Integration & Actionable JSON Schema](file:///documentation/specifications/roadmap_phase_xv_ai_agent_integration.md) [NOT STARTED]
+
+* [ ] **Structured Actionable JSON Output**:
+ * [ ] Implementation of `--agent-json` flag returning a standardized schema.
+* [ ] **Expected Outcomes & Rollback Statements**:
+ * [ ] Each recommendation includes explicit expected outcome description and corresponding rollback statement.
+* [ ] **Risk Assessment & Impact Scoring**:
+ * [ ] Assign deterministic impact score (1-10) and category/risk level to each recommendation.
+
+### [Phase 17: Dockerized Auditing Daemon & MCP Server Support](file:///documentation/specifications/roadmap_phase_xvi_mcp_server.md) [NOT STARTED]
+
+* [ ] **Interval Auditing Daemon**:
+ * [ ] Dockerized execution environment running auditing loops every X hours with caching.
+* [ ] **Model Context Protocol (MCP) Server**:
+ * [ ] Expose caching layer, latest results, and immediate auditing as MCP tools and resources.
+* [ ] **Safe execution & Rollbacks**:
+ * [ ] Implement secure database interaction tools to apply or rollback recommendations.
+
+## đź [Strategic Technical Evolutions](file:///documentation/specifications/strategic_technical_evolutions.md)
* [ ] Set up a pipeline to automatically audit and verify reference link availability inside the repository documentation to prevent dead links.
* [ ] Integrate standard documentation reference anchors dynamically within MySQLTuner CLI help screens and specific advisor output blocks.
diff --git a/SECURITY.md b/SECURITY.md
index 0ec300acf..99c12f082 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -8,7 +8,7 @@ We provide security updates for the following versions of MySQLTuner:
| Version | Status |
| ------- | --------------------- |
-| v2.x | Supported (v2.8.44) |
+| v2.x | Supported (v2.9.0) |
| < v2.x | End of Life |
We strongly recommend that all users stay updated with the latest stable release available on [GitHub Releases](https://github.com/jmrenouard/MySQLTuner-perl/releases).
diff --git a/USAGE.md b/USAGE.md
index b25e01b5f..010c04d38 100644
--- a/USAGE.md
+++ b/USAGE.md
@@ -1,6 +1,6 @@
# NAME
- MySQLTuner 2.8.45 - MySQL High Performance Tuning Script
+ MySQLTuner 2.9.0 - MySQL High Performance Tuning Script
# IMPORTANT USAGE GUIDELINES
@@ -15,7 +15,7 @@ See `mysqltuner --help` for a full list of available options and their categorie
# VERSION
-Version 2.8.45
+Version 2.9.0
=head1 PERLDOC
You can find documentation for this module with the perldoc command.
@@ -72,6 +72,7 @@ Jean-Marie Renouard - jmrenouard@gmail.com
- Stephan GroBberndt
- Christian Loos
- Long Radix
+- derZ-dev
# SUPPORT
diff --git a/build/check_compliance.pl b/build/check_compliance.pl
index 8a09d9c00..3e70e69f3 100755
--- a/build/check_compliance.pl
+++ b/build/check_compliance.pl
@@ -139,7 +139,9 @@
'versions', 'report', 'security', 'cve',
'options', 'lab', 'container', 'refactor',
'style', 'releases', 'dependencies', 'cli',
- 'auth', 'main', 'metadata'
+ 'auth', 'main', 'metadata', 'deps',
+ 'system', 'roadmap', 'hook', 'hooks',
+ 'build'
);
# Lint Changelog structure and scopes for the current version block
diff --git a/build/dev_sync.pl b/build/dev_sync.pl
new file mode 100755
index 000000000..b89baf9d5
--- /dev/null
+++ b/build/dev_sync.pl
@@ -0,0 +1,258 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+use POSIX 'strftime';
+
+my $project_root = abs_path(File::Spec->catfile(dirname(abs_path(__FILE__)), '..'));
+my $CHANGELOG_PATH = File::Spec->catfile($project_root, 'Changelog');
+my $VERSION_PATH = File::Spec->catfile($project_root, 'CURRENT_VERSION.txt');
+
+sub log_msg {
+ my ($msg) = @_;
+ my $timestamp = strftime("%Y-%m-%d %H:%M:%S", localtime);
+ print "[$timestamp] [DEV-SYNC] $msg\n";
+}
+
+sub get_current_version {
+ open my $fh, '<', $VERSION_PATH or die "Cannot read CURRENT_VERSION.txt: $!";
+ my $ver = <$fh>;
+ close $fh;
+ $ver =~ s/^\s+|\s+$//g;
+ return $ver;
+}
+
+sub normalize {
+ my ($str) = @_;
+ $str = lc($str);
+ $str =~ s/[^a-z0-9]//g;
+ return $str;
+}
+
+sub process_items {
+ my ($existing_ref, $new_ref) = @_;
+ my @all_items = @$existing_ref;
+
+ # Add new items if not duplicates
+ for my $new_item (@$new_ref) {
+ my $new_norm = normalize($new_item);
+ my $is_dup = 0;
+ for my $exist (@all_items) {
+ my $exist_norm = normalize($exist);
+ if ($new_norm eq $exist_norm || index($exist_norm, $new_norm) != -1 || index($new_norm, $exist_norm) != -1) {
+ $is_dup = 1;
+ last;
+ }
+ }
+ if (!$is_dup) {
+ push @all_items, "$new_item\n";
+ }
+ }
+
+ # Sort
+ my %categories = (
+ 'chore' => 1,
+ 'feat' => 2,
+ 'fix' => 3,
+ 'test' => 4,
+ 'ci' => 5,
+ );
+
+ my @sorted = sort {
+ my $type_a = '';
+ my $type_b = '';
+ if ($a =~ /^\s*-\s*(\w+)/) { $type_a = lc($1); }
+ if ($b =~ /^\s*-\s*(\w+)/) { $type_b = lc($1); }
+
+ my $rank_a = $categories{$type_a} // 99;
+ my $rank_b = $categories{$type_b} // 99;
+
+ if ($rank_a != $rank_b) {
+ return $rank_a <=> $rank_b;
+ } else {
+ return $a cmp $b;
+ }
+ } @all_items;
+
+ return join("", @sorted);
+}
+
+sub update_changelog_file {
+ my ($version, $new_items_ref) = @_;
+ open my $fh, '<', $CHANGELOG_PATH or die "Cannot read Changelog: $!";
+ my @lines = <$fh>;
+ close $fh;
+
+ my @output_lines;
+ my $in_current_version = 0;
+ my @existing_items;
+
+ my $today = strftime("%Y-%m-%d", localtime);
+ my $header_pattern = qr/^(\d+\.\d+\.\d+)\s+(\d{4}-\d{2}-\d{2})\s*$/;
+ my $found_version = 0;
+
+ for my $line (@lines) {
+ if ($line =~ $header_pattern) {
+ my $v = $1;
+ if ($in_current_version) {
+ push @output_lines, process_items(\@existing_items, $new_items_ref);
+ push @output_lines, "\n";
+ $in_current_version = 0;
+ }
+ if ($v eq $version) {
+ $in_current_version = 1;
+ $found_version = 1;
+ push @output_lines, "$version $today\n\n";
+ next;
+ }
+ }
+
+ if ($in_current_version) {
+ if ($line =~ /^\s*-\s*(.*)/) {
+ push @existing_items, $line;
+ }
+ } else {
+ push @output_lines, $line;
+ }
+ }
+
+ if ($in_current_version) {
+ push @output_lines, process_items(\@existing_items, $new_items_ref);
+ push @output_lines, "\n";
+ }
+
+ if (!$found_version) {
+ my @new_changelog;
+ my $inserted = 0;
+ for my $line (@output_lines) {
+ if (!$inserted && $line =~ $header_pattern) {
+ push @new_changelog, "$version $today\n\n";
+ push @new_changelog, process_items([], $new_items_ref);
+ push @new_changelog, "\n";
+ $inserted = 1;
+ }
+ push @new_changelog, $line;
+ }
+ @output_lines = @new_changelog;
+ }
+
+ open my $wfh, '>', $CHANGELOG_PATH or die "Cannot write to Changelog: $!";
+ print $wfh join("", @output_lines);
+ close $wfh;
+
+ log_msg("Changelog successfully updated and sorted.");
+ return 1;
+}
+
+sub main {
+ log_msg("Starting Developer Sync Process...");
+
+ # 1. Verify version consistency
+ log_msg("Step 1/4: Checking version consistency...");
+ my $res = system("perl tests/version_consistency.t");
+ if ($res != 0) {
+ log_msg("FAIL: Version consistency checks failed!");
+ exit(1);
+ }
+ log_msg("OK: Version consistency verified.");
+
+ # 2. Extract commits and update Changelog & Release Notes
+ log_msg("Step 2/4: Extracting commits and updating Changelog / Release Notes...");
+ my $version = get_current_version();
+
+ my $prev_tag = qx(git describe --tags --abbrev=0 2>/dev/null);
+ chomp($prev_tag);
+ if (!$prev_tag) {
+ $prev_tag = "master";
+ }
+
+ log_msg("Comparing current branch against base ref: $prev_tag");
+ my @commits_raw = qx(git log $prev_tag..HEAD --pretty=format:%s);
+
+ my @new_items;
+ for my $line (@commits_raw) {
+ chomp($line);
+ next unless $line;
+ if ($line =~ /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.*)/) {
+ my $type = lc($1);
+ my $scope = $2;
+ my $desc = $4;
+ $desc =~ s/^\s+|\s+$//g;
+ my $scope_str = $scope ? "($scope)" : "";
+ push @new_items, "- $type$scope_str: $desc";
+ }
+ }
+
+ if (@new_items) {
+ log_msg("Found " . scalar(@new_items) . " new conventional commits to sync.");
+ update_changelog_file($version, \@new_items);
+ } else {
+ log_msg("No new conventional commits found since last tag.");
+ }
+
+ log_msg("Regenerating release notes file...");
+ my $rel_notes_res = system("python3 build/release_gen.py");
+ if ($rel_notes_res != 0) {
+ log_msg("FAIL: Release notes generation failed!");
+ exit(1);
+ }
+ log_msg("OK: Release notes regenerated.");
+
+ # 3. Pass unit tests
+ log_msg("Step 3/4: Passing unit tests...");
+ my $test_res = system("perl build/audit_tests.pl");
+ if ($test_res != 0) {
+ log_msg("FAIL: Unit tests failed!");
+ exit(1);
+ }
+ log_msg("OK: All unit tests passed successfully.");
+
+ # 4. Commit and Push
+ log_msg("Step 4/4: Committing and pushing changes...");
+ my @status_lines = qx(git status --porcelain);
+ my @modified_files;
+ for my $line (@status_lines) {
+ chomp($line);
+ next unless $line;
+ if ($line =~ /(Changelog|releases\/)/) {
+ my $file = $line;
+ $file =~ s/^\s*\S+\s+//;
+ push @modified_files, $file;
+ }
+ }
+
+ if (@modified_files) {
+ log_msg("Staging and committing files: " . join(", ", @modified_files));
+ for my $f (@modified_files) {
+ system("git add \"$f\"");
+ }
+ my $commit_res = system("git commit -m \"docs: regenerate release notes\"");
+ if ($commit_res != 0) {
+ log_msg("FAIL: Git commit failed!");
+ exit(1);
+ }
+ log_msg("Commit successful.");
+ } else {
+ log_msg("No documentation changes to commit.");
+ }
+
+ my $branch = qx(git branch --show-current);
+ chomp($branch);
+ if (!$branch) {
+ $branch = "HEAD";
+ }
+ my $refspec = $branch ne "HEAD" ? "refs/heads/$branch" : "HEAD";
+ log_msg("Pushing current branch '$branch' to origin using refspec '$refspec'...");
+ my $push_res = system("git push origin \"$refspec\"");
+ if ($push_res != 0) {
+ log_msg("FAIL: Git push failed!");
+ exit(1);
+ }
+ log_msg("OK: Git push successful. Sync completed.");
+ log_msg("All steps completed successfully.");
+}
+
+main();
+exit 0;
diff --git a/build/doc_sync.pl b/build/doc_sync.pl
new file mode 100755
index 000000000..ddad5f591
--- /dev/null
+++ b/build/doc_sync.pl
@@ -0,0 +1,90 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+my $project_root = abs_path(File::Spec->catfile(dirname(abs_path(__FILE__)), '..'));
+my $agent_dir = File::Spec->catfile($project_root, '.agent');
+my $readme_path = File::Spec->catfile($agent_dir, 'README.md');
+
+sub parse_markdown_metadata {
+ my ($file_path) = @_;
+ open my $fh, '<', $file_path or return (basename($file_path), "Error: $!");
+ my $content = do { local $/; <$fh> };
+ close $fh;
+
+ my $title = basename($file_path);
+ if ($content =~ /^#\s+(.*)/m) {
+ $title = $1;
+ $title =~ s/^\s+|\s+$//g;
+ }
+
+ my $description = "No description available.";
+ if ($content =~ /description:\s*(.*)/) {
+ $description = $1;
+ $description =~ s/^\s+|\s+$//g;
+ }
+
+ return ($title, $description);
+}
+
+sub generate_readme {
+ my %categories = (
+ 'rules' => 'Governance & Execution Constraints',
+ 'skills' => 'Specialized Capabilities & Knowledge',
+ 'workflows' => 'Automation & Operational Workflows'
+ );
+
+ # Keep a fixed iteration order for predictability: rules -> skills -> workflows
+ my @cat_order = ('rules', 'skills', 'workflows');
+
+ my @output;
+ push @output, "# .agent - Project Governance & Artificial Intelligence Intelligence\n";
+ push @output, "This directory contains the project's technical constitution, specialized skills, and operational workflows used by AI agents.\n";
+
+ for my $folder (@cat_order) {
+ my $cat_title = $categories{$folder};
+ my $folder_path = File::Spec->catdir($agent_dir, $folder);
+ next unless -d $folder_path;
+
+ push @output, "## $cat_title\n";
+ push @output, "| File | Description |";
+ push @output, "| :--- | :--- |";
+
+ opendir(my $dh, $folder_path) or die "Cannot open $folder_path: $!";
+ my @files = sort grep { $_ ne '.' && $_ ne '..' } readdir($dh);
+ closedir($dh);
+
+ for my $filename (@files) {
+ my $full_path = File::Spec->catfile($folder_path, $filename);
+ if (! -f $full_path) {
+ # Handle skill folders containing SKILL.md
+ my $skill_path = File::Spec->catfile($folder_path, $filename, 'SKILL.md');
+ if (-f $skill_path) {
+ my (undef, $desc) = parse_markdown_metadata($skill_path);
+ push @output, "| [`$filename/`](./$folder/$filename/SKILL.md) | $desc |";
+ }
+ next;
+ }
+ next unless $filename =~ /\.md$/;
+
+ my (undef, $desc) = parse_markdown_metadata($full_path);
+ push @output, "| [`$filename`](./$folder/$filename) | $desc |";
+ }
+
+ push @output, "\n";
+ }
+
+ push @output, "---\n*Generated automatically by `/doc-sync`*";
+
+ open my $fh, '>', $readme_path or die "Cannot write to $readme_path: $!";
+ print $fh join("\n", @output);
+ close $fh;
+
+ print "Documentation synchronized: $readme_path\n";
+}
+
+generate_readme();
+exit 0;
diff --git a/build/doc_sync.py b/build/doc_sync.py
deleted file mode 100644
index d5b0af482..000000000
--- a/build/doc_sync.py
+++ /dev/null
@@ -1,69 +0,0 @@
-#!/usr/bin/env python3
-import os
-import re
-from datetime import datetime
-
-PROJECT_ROOT = os.getcwd()
-AGENT_DIR = os.path.join(PROJECT_ROOT, '.agent')
-README_PATH = os.path.join(AGENT_DIR, 'README.md')
-
-def parse_markdown_metadata(file_path):
- try:
- with open(file_path, 'r') as f:
- content = f.read()
-
- # Extract title (first # header)
- title_match = re.search(r'^#\s+(.*)', content, re.MULTILINE)
- title = title_match.group(1).strip() if title_match else os.path.basename(file_path)
-
- # Extract description from frontmatter
- desc_match = re.search(r'description:\s*(.*)', content)
- description = desc_match.group(1).strip() if desc_match else "No description available."
-
- return title, description
- except Exception as e:
- return os.path.basename(file_path), f"Error parsing: {str(e)}"
-
-def generate_readme():
- categories = {
- 'rules': 'Governance & Execution Constraints',
- 'skills': 'Specialized Capabilities & Knowledge',
- 'workflows': 'Automation & Operational Workflows'
- }
-
- output = ["# .agent - Project Governance & Artificial Intelligence Intelligence\n"]
- output.append("This directory contains the project's technical constitution, specialized skills, and operational workflows used by AI agents.\n")
-
- for folder, cat_title in categories.items():
- folder_path = os.path.join(AGENT_DIR, folder)
- if not os.path.exists(folder_path):
- continue
-
- output.append(f"## {cat_title}\n")
- output.append("| File | Description |")
- output.append("| :--- | :--- |")
-
- files = sorted(os.listdir(folder_path))
- for filename in files:
- if not filename.endswith('.md'):
- # Handle skill folders (SKILL.md inside)
- skill_path = os.path.join(folder_path, filename, 'SKILL.md')
- if os.path.exists(skill_path):
- _, desc = parse_markdown_metadata(skill_path)
- output.append(f"| [`{filename}/`](./{folder}/{filename}/SKILL.md) | {desc} |")
- continue
-
- _, desc = parse_markdown_metadata(os.path.join(folder_path, filename))
- output.append(f"| [`{filename}`](./{folder}/{filename}) | {desc} |")
-
- output.append("\n")
-
- output.append("---\n*Generated automatically by `/doc-sync` on " + datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "*")
-
- with open(README_PATH, 'w') as f:
- f.write("\n".join(output))
-
- print(f"Documentation synchronized: {README_PATH}")
-
-if __name__ == "__main__":
- generate_readme()
diff --git a/documentation/specifications/roadmap_phase_xiv_html_reports.md b/documentation/specifications/roadmap_phase_xiv_html_reports.md
new file mode 100644
index 000000000..fe1d50eb9
--- /dev/null
+++ b/documentation/specifications/roadmap_phase_xiv_html_reports.md
@@ -0,0 +1,123 @@
+# Specification: Roadmap Phase XIV - Interactive Multi-Page HTML Reports & Detailed Exports
+
+- **Feature Name**: Interactive Multi-Page HTML Reports & Detailed Exports
+- **Status**: Approved
+- **Created Date**: 2026-06-25
+- **Last Updated**: 2026-07-05
+
+## đ§ Rationale
+
+As MySQLTuner-perl reports grow in complexity, a single long scrolling page of recommendations is no longer sufficient for database administrators and managers. To align with advanced diagnostic tooling (such as pgBadger for PostgreSQL or the MT-reporter suite) while keeping a native, zero-dependency, single-file Perl architecture, the built-in HTML report (`--reportfile`) must deliver a high level of information, visual graphs, and actionable remediation steps.
+
+This specification details the structure, indicators, and formatting of the multi-page HTML report generated natively by `mysqltuner.pl`, drawing inspiration from pgBadger's multi-dimensional telemetry, query metrics, locking analytics, and event/error distributions.
+
+---
+
+## đ ïž User Scenarios
+
+### Scenario 1: Executive Review of Database Health
+A database manager runs MySQLTuner to get a quick summary. They open the generated HTML report and see a **Summary Dashboard Page** with a circular health gauge, key KPIs, resource saturation indicators, and the top findings across all areas. They do not have to dig through technical logs to assess general health.
+
+### Scenario 2: Topic-Specific Deep Dive with Graphs
+A database administrator (DBA) is troubleshooting InnoDB performance. They open the report, click on the **Storage Engines & InnoDB** tab, and view:
+- An SVG chart of the buffer pool hit rate.
+- A table listing InnoDB status variables, current values, and recommended settings.
+- A prioritized list of recommendations for InnoDB.
+
+### Scenario 3: Query & Locking Performance Troubleshooting
+A DBA identifies performance degradation and navigates to the **Queries & Top Queries** tab to locate the most resource-intensive normalized query statements. In the **Locks & Latency** tab, they trace wait histograms and check for deadlock occurrences without needing to parse raw error logs or run ad-hoc command-line queries.
+
+### Scenario 4: Exporting Raw Diagnostic Data to CSV
+A developer wants to import the parsed database metrics into Excel to perform a custom analysis. They go to the report's **Data Export** tab, see options to download separate CSVs (e.g., variables, status, schema findings, security settings), click "Download CSV", and instantly save the files locally.
+
+---
+
+## đ Level of Information & Schema Specification
+
+The native HTML report is structured as an interactive SPA (Single Page Application) with the following detailed metrics and sections:
+
+### 1. Dashboard (Summary View)
+- **Circular Health Gauge**: SVG-based animated indicator showing overall score (0-100) with dynamic status colors (Optimal, Good, Action Required).
+- **Category Scores**: Structured cards showing metrics for Performance, Security, and Resource Saturation.
+- **System Metadata Banner**: Quick facts about the target database (Version, Port, Uptime, Host name, Concurrency).
+- **Traffic Overview**: Simple dashboard indicators representing queries/sec (QPS), average throughput (bytes received/sent per second), and Select vs Write ratio.
+
+### 2. System & Memory Analytics
+- **OS Resource Saturation**: Detailed breakdown of physical RAM vs. swap usage.
+- **Per-Thread & Global Allocation**: Graph representing maximum possible memory allocation compared to physical limits.
+
+### 3. Connections & Sessions
+- **Connection Capacity**: Maximum concurrent connections vs. highest historical usage.
+- **Cache Hit Rates**: Thread cache hit rate and connection abort percentages.
+- **Concurrency & Client Distribution**: Established connections grouped by user, host, or database (when Performance Schema or processlist snapshots are available).
+
+### 4. Queries & Execution Analytics (pgBadger-Inspired)
+- **Query Types Distribution**: Visual breakdown of query categories (SELECT, INSERT, UPDATE, DELETE, REPLACE, admin/DDL operations).
+- **Prepared Statements Utilization**: Ratio of prepared/executed statements vs. raw dynamic SQL.
+- **Query Hits & Misses**: Analysis of query cache hits (where applicable) and database read/write ratios.
+
+### 5. Top Queries & Latency Diagnostics (pgBadger-Inspired)
+- **Slowest Queries**: Top N slowest query statements with execution times and user/host origin.
+- **Time-Consuming Queries**: Normalized query patterns (with placeholders) sorted by cumulative execution time.
+- **Most Frequent Queries**: Most frequent query patterns sorted by call counts with execution statistics (min, max, mean, stddev).
+- **Disk Spill Queries**: Queries that triggered the creation of temporary tables on disk.
+
+### 6. Locks & Latency Analytics (pgBadger-Inspired)
+- **Lock Saturation**: Table locks immediate vs. waited ratios, showing locking overhead.
+- **Row-Level Locking**: Cumulative row lock wait counts, total lock wait duration, and average row lock wait times.
+- **Deadlock Analysis**: Captured deadlock occurrences with timestamps, involved threads, and offending transaction details.
+
+### 7. Temporary Tables & Memory Spills (pgBadger-Inspired)
+- **Temporary Tables Metrics**: Ratio of memory-based temporary tables to disk-based temporary tables.
+- **Temporary Table Activity**: Time-series estimation of temporary table creation rates.
+
+### 8. Storage Engines & InnoDB Forensics
+- **Storage Breakdown**: Comprehensive overview of enabled engines.
+- **InnoDB Engine Detailed Diagnostics**:
+ - Buffer pool instances and chunk size alignment status (Aligned vs. Not Aligned).
+ - Page usage details (Total/Free/Used) converted into bytes.
+ - Log capacity or file size details (including total log size, group size, and log-to-buffer pool ratio).
+ - Concurrency parameters (`innodb_thread_concurrency`) and read buffer efficiency.
+ - Hourly InnoDB OS log write workload rate.
+
+### 9. SQL Modeling & Schema Audit
+- **User Databases Size Distribution**: Schema name, table counts, rows count, data size, index size, and total size.
+- **Fragmented Tables Details**: Schema, table, engine type, free space (MiB), and auto-generated defragmentation SQL queries (`ALTER TABLE ... FORCE` or `OPTIMIZE TABLE ...`).
+- **Tables Without Primary Keys**: Detailed list of schemas and tables missing a PK with warning descriptions.
+- **Redundant & Unused Indexes**: List of duplicate or non-queried indexes along with drop queries (`ALTER TABLE ... DROP INDEX ...`).
+
+### 10. Security & CVE Exposures
+- **Authentication Plugin Audit**: Detailed validation of user authentication plugins against the security support matrix.
+- **SSL/TLS Ciphers**: Verification of transport encryption rules and required SSL connection protocols.
+- **CVE Database Analysis**: Structured list of matching CVE vulnerability records based on the target MySQL/MariaDB version.
+
+### 11. Replication & Galera Cluster Status
+- **Replication Topology**: Standalone vs. Master/Slave relationship, replication lag times, and binlog formats.
+- **Galera Synchronization**: Status of clustering synchrony and Galera node metrics.
+
+### 12. Error Logs & Events Audit (pgBadger-Inspired)
+- **Log Events Summary**: Total counts of Errors, Warnings, and Notes parsed from the server error log (`--log-error`).
+- **Event Distribution**: Hourly graph of warning and error events.
+- **Frequent Log Signatures**: Top recurring messages or diagnostic alerts in the error logs.
+
+### 13. Integrated Data Export & Actions
+- **rem_queries (Actionable Remediations)**: Ready-to-copy SQL and configuration snippets.
+- **Dynamic CSV Downloads**: Inline browser-generated CSV files (prefixed with host, version, and timestamp) for databases, tables, variables, and status metrics.
+
+---
+
+## đŹ Verification Plan
+
+### Automated Tests
+1. **Perl Syntax Validation**: The HTML generation block must be warning-free under `perl -cw mysqltuner.pl`.
+2. **Unit Test Assertions**: [tests/html_report.t](file:///home/jmren/GIT_REPOS/MySQLTuner-perl/tests/html_report.t) must mock database status variables, schema lists, and verification logs, asserting that the generated HTML file matches the target SPA layout regex patterns.
+
+### Manual Verification
+1. Generate the HTML report:
+ ```bash
+ perl mysqltuner.pl --reportfile=test_report.html
+ ```
+2. Open the file in a standard browser environment and assert that:
+ - All interactive tabs (Dashboard, Storage, Modeling, Security, Queries, Locks, Events, etc.) function offline.
+ - All SVG charts and gauges load and format correctly.
+ - The CSV download buttons trigger local downloads with the correct format headers.
diff --git a/documentation/specifications/roadmap_phase_xv_ai_agent_integration.md b/documentation/specifications/roadmap_phase_xv_ai_agent_integration.md
new file mode 100644
index 000000000..f508d3f38
--- /dev/null
+++ b/documentation/specifications/roadmap_phase_xv_ai_agent_integration.md
@@ -0,0 +1,131 @@
+# Specification: Roadmap Phase XVI - AI Agent Integration & Actionable JSON Schema
+
+- **Feature Name**: AI Agent Integration & Actionable JSON Schema
+- **Status**: Draft
+- **Created Date**: 2026-06-25
+
+## đ§ Rationale
+
+As database operations become increasingly automated by AI agents (e.g., MCP-based workflows, specialized IDE agents, autonomous DBAs), standard human-readable terminal output and even simple YAML/JSON key-value exports are insufficient.
+
+To safely enable an AI agent to act on database advisor findings, the agent needs:
+1. **Machine-Ingestible Action Schema**: Structured recommendations that specify the exact SQL statements (DDL/DML) or configuration adjustments required.
+2. **Deterministic Rollback Action**: The precise inverse command to undo the action in case of performance regressions or unexpected failure.
+3. **Safety & Risk Categorization**: An explicit risk assessment (e.g., whether a table lock is required, if a restart is needed) and an impact score/topic.
+4. **Expected Outcomes**: Declarative descriptions of what the change will improve (e.g., memory usage reduction, cache hit rate increase).
+
+This phase adds a robust `--agent-json` output flag to `mysqltuner.pl` that outputs a highly structured, machine-actionable JSON payload.
+
+## đ ïž User Scenarios
+
+### Scenario 1: Autonomous Tuning Agent
+An AI agent connects to a database via an MCP server, runs MySQLTuner with `--agent-json`, parses the recommendations, and automatically applies P1 (high impact, low risk) recommendations using the provided `sql_statement`.
+
+### Scenario 2: Human-in-the-Loop Safe Rollback
+An AI agent proposes a schema optimization (e.g. dropping a redundant index). It shows the user the description, expected outcome, and the provided `rollback_statement`. If the user consents, the agent executes the change. If a regression occurs later, the agent executes the `rollback_statement` to revert to the baseline.
+
+## đ JSON Schema Specification
+
+The `--agent-json` format will output a JSON object containing a `findings` list. Each finding conforms to the following schema:
+
+```json
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "AgentFinding",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique deterministic identifier for the recommendation type."
+ },
+ "topic": {
+ "type": "string",
+ "enum": ["Performance", "Security", "Reliability", "Modeling", "Replication"],
+ "description": "The category of the finding."
+ },
+ "description": {
+ "type": "string",
+ "description": "Human-readable explanation of the issue."
+ },
+ "impact_score": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 10,
+ "description": "Estimated benefit score from 1 (low) to 10 (critical)."
+ },
+ "risk_level": {
+ "type": "string",
+ "enum": ["Low", "Medium", "High", "Critical"],
+ "description": "Risk associated with executing the recommendation."
+ },
+ "risk_description": {
+ "type": "string",
+ "description": "Details of potential side effects (e.g. table locks, memory growth, restart required)."
+ },
+ "requires_restart": {
+ "type": "boolean",
+ "description": "True if the configuration change requires a database service restart."
+ },
+ "expected_outcome": {
+ "type": "string",
+ "description": "The expected performance or security benefit."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["SQL", "Config", "OS"],
+ "description": "Type of resolution action."
+ },
+ "statement": {
+ "type": "string",
+ "description": "The exact SQL statement, configuration line, or shell command to execute."
+ },
+ "rollback_statement": {
+ "type": "string",
+ "description": "The exact command to revert the action to its original state."
+ }
+ },
+ "required": ["type", "statement", "rollback_statement"]
+ }
+ },
+ "required": [
+ "id",
+ "topic",
+ "description",
+ "impact_score",
+ "risk_level",
+ "risk_description",
+ "requires_restart",
+ "expected_outcome",
+ "action"
+ ]
+}
+```
+
+### Finding Example
+```json
+{
+ "id": "innodb_buffer_pool_size_adjust",
+ "topic": "Performance",
+ "description": "InnoDB buffer pool size is under-allocated for the current workload.",
+ "impact_score": 9,
+ "risk_level": "Medium",
+ "risk_description": "Increases memory consumption. Ensure sufficient OS-free RAM to prevent OOM swapping.",
+ "requires_restart": false,
+ "expected_outcome": "Reduces disk I/O and increases query cache read hits.",
+ "action": {
+ "type": "SQL",
+ "statement": "SET GLOBAL innodb_buffer_pool_size = 1073741824;",
+ "rollback_statement": "SET GLOBAL innodb_buffer_pool_size = 134217728;"
+ }
+}
+```
+
+## â
Verification Plan
+
+### Automated Tests
+1. Test generation of `--agent-json` output and validate its conformance to the schema.
+2. Validate that each generated recommendation has a corresponding valid `rollback_statement`.
+3. Verify that parsing of database metrics properly populates all metadata fields (e.g., `impact_score`, `risk_level`, `requires_restart`).
diff --git a/documentation/specifications/roadmap_phase_xvi_mcp_server.md b/documentation/specifications/roadmap_phase_xvi_mcp_server.md
new file mode 100644
index 000000000..d141419bf
--- /dev/null
+++ b/documentation/specifications/roadmap_phase_xvi_mcp_server.md
@@ -0,0 +1,71 @@
+# Specification: Roadmap Phase XVII - Dockerized Auditing Daemon & MCP Server Support
+
+- **Feature Name**: Dockerized Auditing Daemon & MCP Server Support
+- **Status**: Draft
+- **Created Date**: 2026-06-25
+
+## đ§ Rationale
+
+AI agents interact with tools and local resources using the **Model Context Protocol (MCP)**. To allow modern LLM coding and platform agents to easily query, inspect, and perform safe self-tuning operations on database servers, MySQLTuner needs to be deployable as an autonomous microservice.
+
+This phase specifies:
+1. **Dockerized Auditing Daemon**: A lightweight Docker image running as a background service that queries the target database at configurable intervals, performs checks, and caches findings.
+2. **MCP Server Interface**: Exposing MySQLTuner findings and safe execution statements through standard MCP Tools and Resources.
+3. **Caching and Query Layer**: Storing recent runs locally in a cache database/directory to avoid overloading the production database with repeated audits.
+
+## đ ïž User Scenarios
+
+### Scenario 1: Continuous Observability Daemon
+A system administrator deploys the MySQLTuner Docker image alongside their database container in a Docker Compose file. The daemon is configured to run an audit every 12 hours. It caches the structured JSON and HTML reports in a shared volume.
+
+### Scenario 2: LLM-Driven Database Tuning via MCP
+An AI coding assistant (like Antigravity or Cursor) is asked to optimize database performance. The agent connects to the MySQLTuner MCP server, invokes the `get_latest_audit` tool to inspect the cached findings, identifies indexing recommendations, and invokes the `apply_recommendation` tool to run the safe SQL optimization.
+
+## đ Technical Architecture
+
+### 1. Docker Auditing Daemon
+The Docker container will run a lightweight daemon script (written in Perl or Python) acting as an orchestrator:
+- **Environment Variables**:
+ - `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD` (or socket mounts).
+ - `AUDIT_INTERVAL_HOURS`: Run audit every X hours (default: `12`).
+ - `CACHE_DIR`: Location to persist JSON/HTML reports (default: `/var/cache/mysqltuner`).
+- **Interval Auditor**: Sleep/loop execution with SIGTERM/SIGINT signal handling for graceful termination.
+
+### 2. MCP Server Protocol Support
+The MCP server protocol will be implemented over standard I/O (stdio) transport or SSE (Server-Sent Events) HTTP transport. It exposes:
+
+#### Resources
+- `resources/list`:
+ - `mysqltuner://reports/latest.json`: The latest structured agent JSON.
+ - `mysqltuner://reports/latest.html`: The latest interactive HTML dashboard.
+- `resources/read`: Retrieves the raw content of the cached latest JSON/HTML.
+
+#### Tools
+- `get_latest_audit`:
+ - Input: None.
+ - Output: The JSON structure containing current recommendations.
+- `run_audit`:
+ - Input: None.
+ - Output: Triggers a fresh run of MySQLTuner and returns the latest findings.
+- `apply_recommendation`:
+ - Input: `finding_id` (the ID of the recommendation to execute).
+ - Behavior: Verifies that the recommendation type is `SQL`, checks that risk is acceptable, and runs the corresponding `statement` against the database.
+ - Output: Success/Failure status and output logs.
+- `rollback_recommendation`:
+ - Input: `finding_id`.
+ - Behavior: Runs the corresponding `rollback_statement` to revert changes.
+
+## â ïž Risk & Safety Mitigation
+
+Tuning operations on production databases pose high risk. The MCP server implements:
+1. **Execution Restriction**: Only `SQL` actions of `risk_level` `Low` or `Medium` can be executed automatically. `Critical` risk items require manual override.
+2. **Read-Only Mode**: A boot flag `READ_ONLY=true` can be set to disable the write tools (`apply_recommendation`, `rollback_recommendation`), making the MCP server a pure observability tool.
+3. **Transaction Logging**: Every applied action and rollback is logged in a state file (`/var/cache/mysqltuner/state.json`) with timestamps.
+
+## â
Verification Plan
+
+### Manual Verification
+1. Build the Docker image: `docker build -t mysqltuner-mcp -f Dockerfile.mcp .`.
+2. Run container: `docker run -d -e DB_HOST=localhost -e AUDIT_INTERVAL_HOURS=2 -v /tmp/cache:/var/cache/mysqltuner mysqltuner-mcp`.
+3. Verify that `/tmp/cache/latest.json` is generated and updated every 2 hours.
+4. Interact with the running container using an MCP client CLI (e.g. `@modelcontextprotocol/inspector`) and verify tools and resources are correctly exposed.
diff --git a/documentation/specifications/strategic_technical_evolutions.md b/documentation/specifications/strategic_technical_evolutions.md
new file mode 100644
index 000000000..43f30b274
--- /dev/null
+++ b/documentation/specifications/strategic_technical_evolutions.md
@@ -0,0 +1,65 @@
+# Specification: Strategic Technical Evolutions
+
+- **Feature Name**: Strategic Technical Evolutions
+- **Status**: Draft
+- **Created Date**: 2026-06-23
+
+## đ§ Rationale
+
+As MySQLTuner-perl matures, maintaining release integrity, documentation synchronization, and developer workflow stability becomes as critical as the advisor logic itself. The "Strategic Technical Evolutions" address the governance, automation, and localization of the project's ecosystem. By implementing strict validation gates, automated release scripts, and documentation sanity checks, we ensure that the single-file tool is backed by robust, industrial-grade quality assurance processes.
+
+---
+
+## đ ïž User Scenarios
+
+### Scenario 1: CI/CD & Documentation Integrity
+A contributor submits a pull request introducing a new advisor warning. They update the `README.md` and `ROADMAP.md` but copy-paste a broken reference link.
+The CI/CD pipeline triggers the **Reference Link Availability Checker** and the **Roadmap Schema Validator**, identifies the exact file and line number of the broken link, and fails the build. The developer corrects the link, and the build passes.
+
+### Scenario 2: Automated & Fail-Safe Release Lifecycle
+The Release Manager decides to cut version `v2.9.1`. Instead of manually editing version strings in six separate locations and running python generator scripts, they execute the **Interactive Release Orchestrator**:
+1. The tool prompts: `Select version bump type: [major, minor, micro]`.
+2. The user chooses `micro`.
+3. The orchestrator automatically bumps the version from `v2.9.0` to `v2.9.1`, updates the 6 reference locations, parses the Git commit history to generate the release note in `releases/v2.9.1.md`, and populates the `Executive Summary` in the `Changelog`.
+4. Before tagging, the **Release Artifact Validator** scans the new release file to ensure the structure matches standard markdown syntax.
+
+### Scenario 3: Pre-Commit Quality Guard
+A developer edits `mysqltuner.pl` to add support for a new MySQL 9.x variable (a `feat` type commit). They attempt to run `git commit`.
+The **Automated Changelog Formatting Verification** hook intercepts the commit, notices that a `feat` modification in the script was staged, but `Changelog` was not modified. The commit is rejected with a message reminding the developer to document their changes in `Changelog`.
+
+---
+
+## đ User Stories
+
+| Title | Priority | Description | Rationale | Test Case |
+| :--- | :--- | :--- | :--- | :--- |
+| **1. Reference Link Auditor** | P2 | As a developer, I want a pipeline command to scan documentation files | To automatically prevent dead or broken URLs/paths in help files. | **GIVEN** a markdown file contains a broken link, **WHEN** the auditor runs, **THEN** it outputs a list of broken URLs/files with line numbers and exits with code 1. |
+| **2. Dynamic Help Anchors** | P2 | As a DBA, I want unique reference anchors displayed alongside advisor recommendations | To quickly navigate to detailed documentation in the official database KB. | **GIVEN** MySQLTuner suggests changing a parameter, **WHEN** it runs, **THEN** it outputs an anchor like `[REF: INNODB-BP]` mapping to official KBs. |
+| **3. Localized References** | P3 | As a non-English speaker, I want references in my own language | To understand advice without translation overhead. | **GIVEN** localized output is selected (e.g. Italian), **WHEN** reference URLs are printed, **THEN** they point to localized KB paths where available. |
+| **4. Pre-commit Changelog Hook** | P1 | As a maintainer, I want the pre-commit hook to verify `Changelog` edits on `feat`/`fix` changes | To ensure every functional change is properly documented before commit. | **GIVEN** `mysqltuner.pl` is changed with a `feat`/`fix` intent, **WHEN** committing, **THEN** block the commit if `Changelog` has no staged changes. |
+| **5. Containerized Validation** | P1 | As a developer, I want local pre-flight checks to run inside a minimal Docker container | To avoid "works on my machine" issues due to environmental differences. | **GIVEN** local changes are made, **WHEN** running `make test-containerized`, **THEN** execute the unit test suite inside an isolated minimal container. |
+| **6. Interactive Orchestrator** | P1 | As a Release Manager, I want a single script to bump versions and run release note generators | To prevent manual synchronization errors across the 6 version reference locations. | **GIVEN** a release is requested, **WHEN** selecting micro/minor/major, **THEN** update `CURRENT_VERSION.txt`, `mysqltuner.pl` (3 references), `Changelog`, and create the release note file. |
+| **7. Release Summary Auto-Sync** | P2 | As a Release Manager, I want release summaries automatically extracted from commit logs | To save time and ensure no changes are omitted from release notes. | **GIVEN** commits exist since the last release tag, **WHEN** generating release notes, **THEN** parse and populate the Executive Summary automatically. |
+| **8. Release Artifact Validator** | P2 | As a maintainer, I want validation of new release notes syntax and metadata | To prevent malformed or broken release documentation in `releases/`. | **GIVEN** a release file is generated, **WHEN** verified, **THEN** assert it contains mandatory headers, matching version, and valid issue links. |
+| **9. Roadmap Syntax Linter** | P3 | As a maintainer, I want structured linting for `ROADMAP.md` | To keep the roadmap file syntactically clean and resolve all spec file paths. | **GIVEN** `ROADMAP.md` is modified, **WHEN** linted, **THEN** check syntax constraints, category mappings, and existence of all specification files. |
+| **10. Roadmap Progress Auto-Sync** | P3 | As a developer, I want roadmap checklist items to be marked complete automatically on commit | To reduce manual housekeeping of the roadmap project status. | **GIVEN** a commit with scope `feat(auth):` is merged, **WHEN** roadmap sync is triggered, **THEN** check and mark related checklist items as completed `[x]`. |
+
+---
+
+## â
Verification Plan
+
+### Automated Tests
+- **Link Auditor Verification**:
+ - Run the auditing script against test fixtures (e.g. `tests/fixtures/good_links.md` and `tests/fixtures/bad_links.md`) and verify the exit codes.
+- **Git pre-commit Hook Verification**:
+ - Simulate staging a `feat: add feature` commit without modifying `Changelog` and check if git blocks the commit.
+- **Version Orchestration Verification**:
+ - Run the orchestrator in dry-run mode (`--dry-run`) to verify that the version variables are correctly computed and replaced in a mock directory structure.
+- **Docker Validation Runner**:
+ - Run `make test-containerized` and confirm the suite successfully executes inside a temporary Docker container and tears down cleanly.
+- **Linter & Schema Verification**:
+ - Execute markdown schema validation scripts against `ROADMAP.md` and `releases/*.md` to confirm formatting compliance.
+
+### Manual Verification
+- Execute `--help` and verify that documentation references are listed and dynamically generated.
+- Run the localized script (e.g., with environment configuration) to verify translation mapping of reference domains.
diff --git a/documentation/specifications/verbose_execution_timings.md b/documentation/specifications/verbose_execution_timings.md
new file mode 100644
index 000000000..2c3ac4fba
--- /dev/null
+++ b/documentation/specifications/verbose_execution_timings.md
@@ -0,0 +1,34 @@
+# Specification: Verbose Execution Timings
+
+## Goal
+
+Add execution timing information for each section and the total execution time at the end of the MySQLTuner run when verbose mode is active.
+
+## Scenario
+
+- **Test Case**: Run MySQLTuner with `--verbose` or `-v`.
+- **Evidence**:
+ - At the end of each printed block (e.g., after `MyISAM Metrics`), its individual execution time is printed.
+ - Before the final `â Terminated successfully` message, a summary block (`Execution Times`) listing all section names with their durations and the total execution time is printed.
+- **Example Console Output**:
+ ```text
+ -------- MyISAM Metrics ----------------------------------------------------------------------------
+ ...
+ [--] MyISAM Metrics execution time: 0.123s
+
+ ...
+
+ -------- Execution Times ---------------------------------------------------------------------------
+ [--] Storage Engine Statistics: 0.045s
+ [--] MyISAM Metrics: 0.123s
+ ...
+ [--] Total Execution Time: 1.789s
+ ```
+
+## Rules
+
+1. Measure execution times of all blocks defined by `subheaderprint` calls.
+2. Safe dynamic loading of `Time::HiRes` to ensure compatibility and lack of CPAN dependencies.
+3. Fallback to `time()` when `Time::HiRes` is not available.
+4. Timings must only print when `$opt{'verbose'}` is set.
+5. Timing outputs must be placed before the terminal `â Terminated successfully` message.
diff --git a/documentation/specifications/warning_elimination_version_cache.md b/documentation/specifications/warning_elimination_version_cache.md
new file mode 100644
index 000000000..b32c5dead
--- /dev/null
+++ b/documentation/specifications/warning_elimination_version_cache.md
@@ -0,0 +1,26 @@
+---
+test_file: tests/unit_versions.t
+---
+# Specification: Warning Elimination & Version Comparison Optimization
+
+## Goal
+Eliminate Perl runtime uninitialized value warnings during execution (specifically in version checks, InnoDB log analysis, and MariaDB detection paths) and optimize the version comparison routines by caching parsed version components.
+
+## Requirements
+1. **Version Caching & Optimization**:
+ - Cache the parsed version components (major, minor, micro) in lexical variables `$cached_v_maj`, `$cached_v_min`, `$cached_v_mic` based on `$myvar{'version'}`.
+ - Refactor `mysql_version_ge()`, `mysql_version_le()`, and `mysql_version_eq()` to use cached version components instead of performing regex parsing on every invocation.
+ - Guard comparison helper parameters (`$maj`, `$min`, `$mic`) to prevent uninitialized value warnings when comparison arguments are undefined.
+ - Guard `$myvar{'version'}` regex match inside `validate_mysql_version()` to avoid matching on undefined variables.
+
+2. **InnoDB Analysis Guards**:
+ - Guard `$mycalc{'innodb_log_size_pct'}` and `$myvar{'innodb_log_file_size'}` with definedness operators (`// 0`) before using them in calculations or printing output within `mysql_innodb()`.
+ - Prevent potential uninitialized warnings during multiplication or printing in the InnoDB log size checks block.
+
+3. **MariaDB Detection Guards**:
+ - Guard `$myvar{'version_comment'}` and `$myvar{'version'}` checks using the definedness default operator (`// ''`) inside the parallel replication and query cache plugin check blocks.
+ - Guard the `infoprint` statement in `security_recommendations()` where version and version comment are printed.
+
+## Verification
+- Run `make unit-tests` to ensure that all unit tests execute and pass cleanly.
+- The test output audit gate must report 0 runtime uninitialized value warnings from the version checks or InnoDB code path.
diff --git a/execution.log b/execution.log
deleted file mode 100644
index 35fa007f1..000000000
--- a/execution.log
+++ /dev/null
@@ -1,56 +0,0 @@
-[2026-05-28 00:38:29] [DRY-RUN] Starting dry-run validation from version 2.8.44 to 2.8.45...
-[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on CURRENT_VERSION.txt matches version schema and compiles cleanly.
-[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on mysqltuner.pl matches version schema and compiles cleanly.
-[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on USAGE.md matches version schema and compiles cleanly.
-[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on README.md matches version schema and compiles cleanly.
-[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on SECURITY.md matches version schema and compiles cleanly.
-[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on MEMORY_DB.md matches version schema and compiles cleanly.
-[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on Changelog matches version schema and compiles cleanly.
-[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated path for new release notes: /home/jmren/GIT_REPOS/MySQLTuner-perl/build/../releases/v2.8.45.md
-[2026-05-28 00:38:29] [DRY-RUN] Dry-run validation completed successfully. All files matches version schemas.
-[2026-05-28 00:38:47] [MAKE] Starting generate_usage...
-[2026-05-28 00:39:23] [MAKE] Finished generate_usage.
-[2026-05-28 00:41:14] [MAKE] Starting generate_usage...
-[2026-05-28 00:41:49] [MAKE] Finished generate_usage.
-[2026-05-28 00:44:29] [DRY-RUN] Starting dry-run validation from version 2.8.44 to 2.8.45...
-[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on CURRENT_VERSION.txt matches version schema and compiles cleanly.
-[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on mysqltuner.pl matches version schema and compiles cleanly.
-[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on USAGE.md matches version schema and compiles cleanly.
-[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on README.md matches version schema and compiles cleanly.
-[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on SECURITY.md matches version schema and compiles cleanly.
-[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on MEMORY_DB.md matches version schema and compiles cleanly.
-[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on Changelog matches version schema and compiles cleanly.
-[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated path for new release notes: /home/jmren/GIT_REPOS/MySQLTuner-perl/build/../releases/v2.8.45.md
-[2026-05-28 00:44:29] [DRY-RUN] Dry-run validation completed successfully. All files matches version schemas.
-[2026-05-28 00:48:41] [DRY-RUN] Starting dry-run validation from version 2.8.44 to 2.8.45...
-[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on CURRENT_VERSION.txt matches version schema and compiles cleanly.
-[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on mysqltuner.pl matches version schema and compiles cleanly.
-[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on USAGE.md matches version schema and compiles cleanly.
-[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on README.md matches version schema and compiles cleanly.
-[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on SECURITY.md matches version schema and compiles cleanly.
-[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on MEMORY_DB.md matches version schema and compiles cleanly.
-[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on Changelog matches version schema and compiles cleanly.
-[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated path for new release notes: /home/jmren/GIT_REPOS/MySQLTuner-perl/build/../releases/v2.8.45.md
-[2026-05-28 00:48:41] [DRY-RUN] Dry-run validation completed successfully. All files matches version schemas.
-[2026-05-28 00:50:06] [MAKE] Starting generate_usage...
-[2026-05-28 00:50:41] [MAKE] Finished generate_usage.
-[2026-05-28 07:56:46] [MAKE] Starting generate_usage...
-[2026-05-28 07:57:00] [MAKE] Finished generate_usage.
-[2026-05-28 07:57:00] [MAKE] Starting generate_release_notes...
-[2026-05-28 07:57:14] [MAKE] Finished generate_release_notes.
-[2026-06-04 06:51:17] [MAKE] Starting generate_usage...
-[2026-06-04 06:51:52] [MAKE] Finished generate_usage.
-[2026-06-04 06:51:52] [MAKE] Starting generate_release_notes...
-[2026-06-04 06:52:27] [MAKE] Finished generate_release_notes.
-[2026-06-04 08:32:37] [MAKE] Starting generate_usage...
-[2026-06-04 08:32:55] [MAKE] Finished generate_usage.
-[2026-06-04 08:32:55] [MAKE] Starting generate_release_notes...
-[2026-06-04 08:33:10] [MAKE] Finished generate_release_notes.
-[2026-06-04 08:33:51] [MAKE] Starting generate_usage...
-[2026-06-04 08:34:10] [MAKE] Finished generate_usage.
-[2026-06-04 08:34:10] [MAKE] Starting generate_release_notes...
-[2026-06-04 08:34:26] [MAKE] Finished generate_release_notes.
-[2026-06-04 09:36:52] [MAKE] Starting generate_usage...
-[2026-06-04 09:37:30] [MAKE] Finished generate_usage.
-[2026-06-04 09:37:30] [MAKE] Starting generate_release_notes...
-[2026-06-04 09:38:12] [MAKE] Finished generate_release_notes.
diff --git a/mysqltuner.pl b/mysqltuner.pl
index 3c9d77595..29cfaf20e 100755
--- a/mysqltuner.pl
+++ b/mysqltuner.pl
@@ -1,5 +1,5 @@
#!/usr/bin/env perl
-# mysqltuner.pl - Version 2.8.45
+# mysqltuner.pl - Version 2.9.0
# High Performance MySQL Tuning Script
# Copyright (C) 2015-2026 Jean-Marie Renouard - jmrenouard@gmail.com
# Copyright (C) 2006-2026 Major Hayden - major@mhtx.net
@@ -67,13 +67,20 @@ package main;
our $is_win = $^O eq 'MSWin32';
# Set up a few variables for use in the script
-our $tunerversion = "2.8.45";
+our $tunerversion = "2.9.0";
our ( @adjvars, @generalrec, @modeling, @sysrec, @secrec );
our ( %result, %myvar, %real_vars, %mystat, %mycalc, %myrepl, %myreplicas,
$dummyselect );
our %exported_manifest;
+our (
+ $tuner_start_time, $current_section_name,
+ $current_section_start, @section_timings,
+ $tuner_start_datetime
+);
+our $has_time_hires = eval { require Time::HiRes; 1; } // 0;
our $failed_connection_attempts = 0;
our $previous_failed_attempts = 0;
+our $is_local_only = 0;
# Set defaults
# Central metadata for CLI options
@@ -300,6 +307,12 @@ package main;
desc => 'Print result as JSON formatted string',
cat => 'PERFORMANCE'
},
+ 'yaml' => {
+ type => '!',
+ default => 0,
+ desc => 'Print result as YAML string',
+ cat => 'PERFORMANCE'
+ },
'dumpdir' => {
type => '=s',
default => undef,
@@ -354,6 +367,12 @@ package main;
plugininfo => 1
}
},
+ 'stage-timings' => {
+ type => '!',
+ default => 0,
+ desc => 'Activate stage timings and final summary without full verbose mode',
+ cat => 'OUTPUT'
+ },
'color!' => {
type => '!',
default => ( -t STDOUT ? 1 : 0 ),
@@ -867,7 +886,8 @@ sub show_help {
}
sub prettyprint {
- print $_[0] . "\n" unless ( $opt{'silent'} or $opt{'json'} );
+ print $_[0] . "\n"
+ unless ( $opt{'silent'} or $opt{'json'} or $opt{'yaml'} );
print $fh $_[0] . "\n" if defined($fh);
my $plain_text = $_[0] // '';
$plain_text =~ s/\e\[[0-9;]*[mK]//g;
@@ -999,6 +1019,183 @@ sub calculate_health_score {
$result{'HealthScore'} = $score;
$result{'HealthScoreDetails'} = \%details;
$mycalc{'WeightedHealthScore'} = $score;
+
+ calculate_sectional_health_scores();
+}
+
+sub calculate_sectional_health_scores {
+
+ # 1. General Statistics KPI
+ my $gen_score = 100;
+
+ my $uptime = $mystat{'Uptime'} // 0;
+ if ( $uptime < 86400 ) {
+ $gen_score -= 20;
+ }
+
+ my @load = get_load_average();
+ if (@load) {
+ my $load_1 = $load[0] // 0;
+ my $cpu_cores = $mycalc{'physical_cpu_cores'} // $mycalc{'cpu_cores'}
+ // 1;
+ if ( $cpu_cores > 0 && ( $load_1 / $cpu_cores ) > 1.0 ) {
+ $gen_score -= 20;
+ }
+ }
+
+ if ( grep { /swappiness/i } @generalrec ) {
+ $gen_score -= 20;
+ }
+
+ my $other_mem = get_other_process_memory() // 0;
+ if ( ( $physical_memory // 0 ) > 0
+ && ( $other_mem / $physical_memory ) > 0.3 )
+ {
+ $gen_score -= 20;
+ }
+ $gen_score = 0 if $gen_score < 0;
+
+ # 2. Storage Engine KPI
+ my $storage_score = 100;
+
+ my $read_eff = $mycalc{'pct_read_efficiency'} // 100;
+ if ( $read_eff < 95 ) {
+ $storage_score -= 25;
+ }
+
+ my $temp_disk = $mycalc{'pct_temp_disk'} // 0;
+ if ( $temp_disk > 25 ) {
+ $storage_score -= 25;
+ }
+
+ my $table_hit = $mycalc{'table_cache_hit_rate'} // 100;
+ if ( $table_hit < 50 ) {
+ $storage_score -= 25;
+ }
+
+ my $redo_adjust =
+ grep { /innodb_log_file_size/i || /innodb_redo_log_capacity/i } @adjvars;
+ if ($redo_adjust) {
+ $storage_score -= 25;
+ }
+ $storage_score = 0 if $storage_score < 0;
+
+ # 3. Security & Compliance KPI
+ my $sec_score = 100;
+ $sec_score -= scalar(@secrec) * 15;
+ $sec_score = 0 if $sec_score < 0;
+
+ # 4. Replication & HA KPI
+ my $repl_score = 100;
+ if (%myrepl) {
+ my $lag = $myrepl{'Seconds_Behind_Source'}
+ // $myrepl{'Seconds_Behind_Replica'};
+ if ( defined $lag && $lag > 60 ) {
+ $repl_score -= 40;
+ }
+ my $io_run = $myrepl{'Replica_IO_Running'}
+ // $myrepl{'Slave_IO_Running'} // 'Yes';
+ my $sql_run = $myrepl{'Replica_SQL_Running'}
+ // $myrepl{'Slave_SQL_Running'} // 'Yes';
+ if ( $io_run =~ /No/i || $sql_run =~ /No/i ) {
+ $repl_score -= 35;
+ }
+ my $gtid = $myvar{'gtid_mode'} // 'OFF';
+ if ( $gtid =~ /OFF/i ) {
+ $repl_score -= 25;
+ }
+ }
+ $repl_score = 0 if $repl_score < 0;
+
+ # 5. SQL Modeling KPI
+ my $model_score = 100;
+ $model_score -= scalar(@modeling) * 10;
+ $model_score = 0 if $model_score < 0;
+
+ $result{'SectionalHealthScore'}{'General'} = int($gen_score);
+ $result{'SectionalHealthScore'}{'Storage'} = int($storage_score);
+ $result{'SectionalHealthScore'}{'Security'} = int($sec_score);
+ $result{'SectionalHealthScore'}{'Replication'} = int($repl_score);
+ $result{'SectionalHealthScore'}{'Modeling'} = int($model_score);
+
+ # Calculate Resource Saturation Heatmap
+ my $cpu_sat = 0;
+ if (@load) {
+ my $load_1 = $load[0] // 0;
+ my $cpu_cores = $mycalc{'physical_cpu_cores'} // $mycalc{'cpu_cores'}
+ // 1;
+ $cpu_sat = ( $cpu_cores > 0 ) ? ( $load_1 / $cpu_cores ) * 100 : 0;
+ $cpu_sat = 100 if $cpu_sat > 100;
+ }
+
+ my $mem_sat = $mycalc{'pct_max_physical_memory'} // 0;
+ $mem_sat =~ s/%//g;
+ $mem_sat = 100 if $mem_sat > 100;
+
+ my $conn_sat = $mycalc{'pct_connections_used'} // 0;
+ $conn_sat =~ s/%//g;
+ $conn_sat = 100 if $conn_sat > 100;
+
+ my $read_eff_val = $mycalc{'pct_read_efficiency'} // 100;
+ my $disk_read_pressure = 100 - $read_eff_val;
+ my $scaled_read_pressure = $disk_read_pressure * 20;
+ my $temp_disk_val = $mycalc{'pct_temp_disk'} // 0;
+ my $io_sat =
+ ( $scaled_read_pressure > $temp_disk_val )
+ ? $scaled_read_pressure
+ : $temp_disk_val;
+ $io_sat = 100 if $io_sat > 100;
+
+ $result{'ResourceSaturation'}{'CPU'} = int($cpu_sat);
+ $result{'ResourceSaturation'}{'Memory'} = int($mem_sat);
+ $result{'ResourceSaturation'}{'Connections'} = int($conn_sat);
+ $result{'ResourceSaturation'}{'IO'} = int($io_sat);
+
+ # Calculate Throughput Efficiency Index
+ my $qps_val =
+ ( ( $mystat{'Uptime'} || 0 ) > 0 )
+ ? ( $mystat{'Questions'} || 0 ) / $mystat{'Uptime'}
+ : 0;
+ my $logical_reads_sec =
+ ( $uptime > 0 )
+ ? ( $mystat{'Innodb_buffer_pool_read_requests'} // 0 ) / $uptime
+ : 0;
+ my $tei =
+ ( $logical_reads_sec > 0 ) ? ( $qps_val / $logical_reads_sec ) : 0;
+
+ $result{'ThroughputEfficiency'}{'QPS'} = sprintf( "%.3f", $qps_val ) + 0;
+ $result{'ThroughputEfficiency'}{'LogicalReadsSec'} =
+ sprintf( "%.3f", $logical_reads_sec ) + 0;
+ $result{'ThroughputEfficiency'}{'Index'} = sprintf( "%.5f", $tei ) + 0;
+}
+
+sub get_top_findings {
+ my $list_ref = shift;
+ my @items = ();
+ foreach my $it (@$list_ref) {
+ push @items, format_recommendation_item($it);
+ }
+
+ my @scored = ();
+ foreach my $item (@items) {
+ my $score = 1;
+ if ( $item =~
+/(dangerously high|insecure|vulnerability|CVE|not running|aborted|unencrypted|anonymous|remove|risk|critical|warning|incorrect|mismatch)/i
+ )
+ {
+ $score = 2;
+ }
+ push @scored, { text => $item, score => $score };
+ }
+
+ my @sorted = sort { $b->{score} <=> $a->{score} } @scored;
+
+ my @res = ();
+ for ( my $i = 0 ; $i < 3 && $i < @sorted ; $i++ ) {
+ my $badge = ( $sorted[$i]->{score} == 2 ) ? 'Critical' : 'Finding';
+ push @res, { text => $sorted[$i]->{text}, badge => $badge };
+ }
+ return @res;
}
sub display_health_score {
@@ -1093,6 +1290,10 @@ sub predictive_capacity_analysis {
sub check_replication_advanced {
subheaderprint "Cluster & Replication Intelligence";
+ if ($is_local_only) {
+ infoprint "Skipping advanced replication checks: Server is bound to localhost-only (Ref: https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_bind_address).";
+ return;
+ }
my $is_replica = (
defined $myrepl{'Seconds_Behind_Source'}
@@ -1376,9 +1577,11 @@ sub check_security_2_0 {
. ( $myvar{'tls_version'} // 'defaults' );
}
else {
- badprint "TLS/SSL is disabled. Connections are unencrypted.";
- push_recommendation( 'sec',
- "Enable TLS/SSL for encrypted connections." );
+ unless ($is_local_only) {
+ badprint "TLS/SSL is disabled. Connections are unencrypted.";
+ push_recommendation( 'sec',
+ "Enable TLS/SSL for encrypted connections." );
+ }
}
# TDE Check (InnoDB)
@@ -1390,7 +1593,7 @@ sub check_security_2_0 {
}
sub generate_auto_fix_snippets {
- return if $opt{'silent'} || $opt{'json'};
+ return if $opt{'silent'} || $opt{'json'} || $opt{'yaml'};
subheaderprint "Guided Auto-Fix Snippets";
if ( @adjvars > 0 ) {
@@ -1425,7 +1628,62 @@ sub infoprintcmd {
infoprintml( grep { /\S/ } `@_ 2>&1` );
}
+sub get_time {
+ return $has_time_hires ? Time::HiRes::time() : time();
+}
+
+sub get_datetime_str {
+ my ( $sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst ) =
+ localtime(time);
+ return sprintf(
+ "%04d-%02d-%02d %02d:%02d:%02d",
+ $year + 1900,
+ $mon + 1, $mday, $hour, $min, $sec
+ );
+}
+
+sub pretty_duration {
+ my $duration = shift;
+ my $seconds = $duration % 60;
+ my $decimal = $duration - int($duration);
+ my $seconds_formatted = sprintf( "%.3f", int($seconds) + $decimal );
+
+ my $total_minutes = int( $duration / 60 );
+ my $minutes = $total_minutes % 60;
+ my $hours = int( $total_minutes / 60 ) % 24;
+ my $days = int( $total_minutes / 1440 );
+
+ my $duration_string = "";
+ if ( $days > 0 ) {
+ $duration_string =
+ "${days}d ${hours}h ${minutes}m ${seconds_formatted}s";
+ }
+ elsif ( $hours > 0 ) {
+ $duration_string = "${hours}h ${minutes}m ${seconds_formatted}s";
+ }
+ elsif ( $minutes > 0 ) {
+ $duration_string = "${minutes}m ${seconds_formatted}s";
+ }
+ else {
+ $duration_string = "${seconds_formatted}s";
+ }
+}
+
+$tuner_start_datetime = get_datetime_str();
+
sub subheaderprint {
+ my $name = $_[0];
+ my $now = get_time();
+ if ( defined $current_section_name && ( $opt{'verbose'} || $opt{'stage-timings'} ) ) {
+ my $elapsed = $now - $current_section_start;
+ push @section_timings, [ $current_section_name, $elapsed ];
+ infoprint
+ sprintf( "%s execution time: %.3fs", $current_section_name,
+ $elapsed );
+ }
+ $current_section_name = $name;
+ $current_section_start = $now;
+
my $tln = 100;
my $sln = 8;
my $ln = length("@_") + 2;
@@ -1434,6 +1692,73 @@ sub subheaderprint {
prettyprint "-" x $sln . " @_ " . "-" x ( $tln - $ln - $sln );
}
+sub stop_section_timing {
+ my $now = get_time();
+ if ( defined $current_section_name && ( $opt{'verbose'} || $opt{'stage-timings'} ) ) {
+ my $elapsed = $now - $current_section_start;
+ push @section_timings, [ $current_section_name, $elapsed ];
+ infoprint
+ sprintf( "%s execution time: %.3fs", $current_section_name,
+ $elapsed );
+ }
+ $current_section_name = undef;
+}
+
+sub print_execution_timings {
+ return unless ( $opt{'verbose'} || $opt{'stage-timings'} );
+
+ stop_section_timing();
+
+ my $total_now = get_time();
+ my $total_elapsed = $total_now - $tuner_start_time;
+
+ subheaderprint "Execution Times";
+ if ( defined $tuner_start_datetime ) {
+ infoprint sprintf( "%-50s: %s", "Started at", $tuner_start_datetime );
+ }
+ infoprint sprintf( "%-50s: %s", "Ended at", get_datetime_str() );
+
+ # Sort timings in descending order of elapsed time
+ my @sorted_timings = sort { $b->[1] <=> $a->[1] } @section_timings;
+ foreach my $timing (@sorted_timings) {
+ my ( $name, $elapsed ) = @$timing;
+ my $pct = $total_elapsed > 0 ? ( $elapsed / $total_elapsed ) * 100 : 0;
+ infoprint sprintf( "%-50s: %.3fs (%.1f%%)", $name, $elapsed, $pct );
+ }
+ infoprint sprintf( "%-50s: %s (%.3fs)", "Total Execution Time", pretty_duration($total_elapsed), $total_elapsed );
+}
+
+sub print_audit_snapshot_summary {
+ subheaderprint "Audit Snapshot Summary";
+
+ my $host = $opt{'host'} || 'localhost';
+ if ( $opt{'port'} ) {
+ $host .= ":$opt{'port'}";
+ }
+
+ my $db_user =
+ select_one("SELECT CURRENT_USER()") || $opt{'user'} || 'unknown';
+
+ my $ram_str =
+ defined($physical_memory) ? hr_bytes($physical_memory) : 'unknown';
+ my $swap_str = defined($swap_memory) ? hr_bytes($swap_memory) : 'unknown';
+
+ my $db_ver = $myvar{'version'} // 'unknown';
+ my $uptime_str =
+ defined( $mystat{'Uptime'} )
+ ? pretty_uptime( $mystat{'Uptime'} )
+ : 'unknown';
+
+ infoprint "MySQLTuner Version : $tunerversion";
+ infoprint "Audit Start Time : $tuner_start_datetime";
+ infoprint "Server Connection : $host";
+ infoprint "Database User : $db_user";
+ infoprint "Database Version : $db_ver";
+ infoprint "System Physical RAM: $ram_str";
+ infoprint "System Swap Memory : $swap_str";
+ infoprint "Database Uptime : $uptime_str";
+}
+
sub infoprinthcmd {
subheaderprint "$_[0]";
infoprintcmd "$_[1]";
@@ -1640,6 +1965,7 @@ sub hr_bytes_rnd {
# Calculates the parameter passed to the nearest power of 1000, then rounds it to the nearest integer
sub hr_num {
my $num = shift;
+ return 0 if !$num || $num eq 'NULL';
if ( $num >= ( 1000**3 ) ) { # Billions
return int( ( $num / ( 1000**3 ) ) ) . "B";
}
@@ -1996,7 +2322,7 @@ sub get_http_cli {
# Checks for updates to MySQLTuner
sub validate_tuner_version {
if ( $opt{'checkversion'} eq 0 ) {
- print "\n" unless ( $opt{'silent'} or $opt{'json'} );
+ print "\n" unless ( $opt{'silent'} or $opt{'json'} or $opt{'yaml'} );
infoprint "Skipped version check for MySQLTuner script";
return;
}
@@ -2058,7 +2384,7 @@ sub validate_tuner_version {
sub update_tuner_version {
if ( $opt{'updateversion'} eq 0 ) {
badprint "Skipped version update for MySQLTuner script";
- print "\n" unless ( $opt{'silent'} or $opt{'json'} );
+ print "\n" unless ( $opt{'silent'} or $opt{'json'} or $opt{'yaml'} );
return;
}
@@ -2461,6 +2787,9 @@ sub mysql_setup {
}
infoprint "Performing tests on $opt{host}:$opt{port}";
$remotestring = " -h $opt{host} -P $opt{port}";
+ if ( $opt{socket} ) {
+ $remotestring .= " -S $opt{socket}";
+ }
$doremote = is_remote();
}
@@ -2853,8 +3182,7 @@ sub select_csv_file {
debugprint "PERFORM: $req CSV into $tfile";
- my $start_time =
- eval { require Time::HiRes; Time::HiRes::time(); } || time();
+ my $start_time = get_time();
my @result = select_array_with_headers($req);
my $row_count = scalar(@result);
@@ -2886,7 +3214,7 @@ sub select_csv_file {
}
close $fh;
- my $end_time = eval { require Time::HiRes; Time::HiRes::time(); } || time();
+ my $end_time = get_time();
my $duration = $end_time - $start_time;
if ( $duration > 5.0 ) {
@@ -2960,7 +3288,7 @@ sub write_manifest_files {
}
my $json_content =
- "{\n \"version\": \"" . ( $tunerversion // '2.8.45' ) . "\",\n";
+ "{\n \"version\": \"" . ( $tunerversion // '2.9.0' ) . "\",\n";
$json_content .= " \"exported_at\": \"" . scalar( gmtime() ) . " UTC\",\n";
$json_content .= " \"total_files\": $total_files,\n";
$json_content .= " \"total_size_bytes\": $total_size,\n";
@@ -2976,7 +3304,7 @@ sub write_manifest_files {
my $meta_content = "MySQLTuner Offline Diagnostic Snapshot Metadata\n";
$meta_content .= "================================================\n";
- $meta_content .= "Version: " . ( $tunerversion // '2.8.45' ) . "\n";
+ $meta_content .= "Version: " . ( $tunerversion // '2.9.0' ) . "\n";
$meta_content .= "Exported At: " . scalar( gmtime() ) . " UTC\n";
$meta_content .= "Host: " . ( $myvar{'hostname'} // 'unknown' ) . "\n";
$meta_content .=
@@ -3436,6 +3764,23 @@ sub get_all_vars {
);
}
}
+
+ # Calculate if the server is loopback/local-only
+ $is_local_only = 0;
+ if ( defined $myvar{'skip_networking'} && $myvar{'skip_networking'} eq 'ON' ) {
+ $is_local_only = 1;
+ }
+ elsif ( defined $myvar{'bind_address'} ) {
+ my @addrs = split( /\s*,\s*/, $myvar{'bind_address'} );
+ my $all_local = 1;
+ foreach my $addr (@addrs) {
+ if ( $addr ne '127.0.0.1' && $addr ne '::1' && $addr ne 'localhost' && $addr !~ /\.(?:local|localhost)$/i ) {
+ $all_local = 0;
+ last;
+ }
+ }
+ $is_local_only = 1 if ( @addrs && $all_local );
+ }
}
sub remove_cr {
@@ -3993,6 +4338,25 @@ sub get_other_process_memory {
return $totalMemOther;
}
+sub get_load_average {
+ my $prefix = get_transport_prefix();
+ return () if $prefix eq '' && is_remote();
+
+ if ( $prefix eq '' && -f "/proc/loadavg" ) {
+ my $content = file2string("/proc/loadavg") // '';
+ if ( $content =~ /^([\d.]+)\s+([\d.]+)\s+([\d.]+)/ ) {
+ return ( $1, $2, $3 );
+ }
+ }
+ my $uptime_output = execute_system_command("uptime") // '';
+ if ( $uptime_output =~
+ /load average(?:s)?:?\s+([\d.]+),?\s+([\d.]+),?\s+([\d.]+)/i )
+ {
+ return ( $1, $2, $3 );
+ }
+ return ();
+}
+
sub get_os_release {
if ( -f "/etc/lsb-release" ) {
my @info_release = get_file_contents "/etc/lsb-release";
@@ -4603,6 +4967,10 @@ sub system_recommendations {
# ---------------------------------------------------------------------------
sub ssl_tls_recommendations {
subheaderprint "SSL/TLS Security Recommendations";
+ if ($is_local_only) {
+ infoprint "Skipping SSL/TLS security recommendations: Server is bound to localhost-only (Ref: https://dev.mysql.com/doc/refman/8.0/en/server-options.html#option_mysqld_skip-networking).";
+ return;
+ }
my @ssl_csv_rows = ("Variable,Value,IssueType,Description");
@@ -4683,12 +5051,17 @@ sub ssl_tls_recommendations {
# Certificate presence and local audit
if ( !$myvar{'ssl_cert'} && !$myvar{'ssl_key'} ) {
- badprint "No SSL certificates configured (ssl_cert/ssl_key are empty)";
- push_recommendation( 'Security',
+ if ( mysql_version_ge( 11, 4 ) && $myvar{'version'} =~ /MariaDB/i && ( defined($myvar{'have_ssl'}) && ($myvar{'have_ssl'} eq 'YES' || $myvar{'have_ssl'} eq 'ON') ) ) {
+ goodprint "TLS is active, but no explicit ssl_cert/ssl_key paths are configured (MariaDB zero-configuration TLS active)";
+ }
+ else {
+ badprint "No SSL certificates configured (ssl_cert/ssl_key are empty)";
+ push_recommendation( 'Security',
"Configure SSL certificates (ssl_cert, ssl_key, ssl_ca) to enable encrypted connections."
- );
- push @ssl_csv_rows,
+ );
+ push @ssl_csv_rows,
"ssl_cert/ssl_key,empty,NoCertificates,Configure SSL certificates (ssl_cert, ssl_key, ssl_ca) to enable encrypted connections.";
+ }
}
else {
check_local_certificates( \@ssl_csv_rows );
@@ -4843,12 +5216,19 @@ sub check_remote_user_ssl {
my @remote_users;
if ( mysql_version_ge( 10, 4 ) && $myvar{'version'} =~ /MariaDB/i ) {
@remote_users = select_array(
-"SELECT CONCAT(QUOTE(user), '\@', QUOTE(host)) FROM mysql.global_priv WHERE host NOT IN ('localhost', '127.0.0.1', '::1') AND JSON_VALUE(Priv, '\$.ssl_type') = ''"
+"SELECT CONCAT(QUOTE(user), '\@', QUOTE(host)) FROM mysql.global_priv WHERE host NOT IN ('localhost', '127.0.0.1', '::1') AND JSON_VALUE(Priv, '\$.ssl_type') = '' AND COALESCE(JSON_VALUE(Priv, '\$.is_role'), '') NOT IN ('true', '1')"
);
}
else {
+ my $is_role_column = select_one(
+"select count(*) from information_schema.columns where TABLE_NAME='user' AND TABLE_SCHEMA='mysql' and COLUMN_NAME='IS_ROLE'"
+ );
+ my $extra_user_condition = "";
+ if ( defined($is_role_column) && $is_role_column =~ /^\d+$/ && $is_role_column > 0 ) {
+ $extra_user_condition = " AND IS_ROLE = 'N'";
+ }
@remote_users = select_array(
-"SELECT CONCAT(QUOTE(user), '\@', QUOTE(host)) FROM mysql.user WHERE host NOT IN ('localhost', '127.0.0.1', '::1') AND (ssl_type = 'NONE' OR ssl_type = '')"
+"SELECT CONCAT(QUOTE(user), '\@', QUOTE(host)) FROM mysql.user WHERE host NOT IN ('localhost', '127.0.0.1', '::1') AND (ssl_type = 'NONE' OR ssl_type = '')$extra_user_condition"
);
}
@@ -5016,7 +5396,8 @@ sub check_auth_plugins {
sub security_recommendations {
subheaderprint "Security Recommendations";
- infoprint "$myvar{'version_comment'} - $myvar{'version'}";
+ infoprint( ( $myvar{'version_comment'} // 'N/A' ) . " - "
+ . ( $myvar{'version'} // 'N/A' ) );
my $PASS_COLUMN_NAME = get_password_column_name();
@@ -5136,31 +5517,33 @@ sub security_recommendations {
}
}
- @mysqlstatlist = select_array
- "SELECT CONCAT(QUOTE(user), '\@', host) FROM mysql.user WHERE HOST='%'";
- if ( scalar(@mysqlstatlist) > 0 ) {
- if ( $opt{dumpdir} ) {
- select_csv_file(
- "$opt{dumpdir}/user_with_general_wildcard.csv",
- "SELECT user, host FROM mysql.user WHERE HOST='%'"
+ unless ($is_local_only) {
+ @mysqlstatlist = select_array
+ "SELECT CONCAT(QUOTE(user), '\@', host) FROM mysql.user WHERE HOST='%'";
+ if ( scalar(@mysqlstatlist) > 0 ) {
+ if ( $opt{dumpdir} ) {
+ select_csv_file(
+ "$opt{dumpdir}/user_with_general_wildcard.csv",
+ "SELECT user, host FROM mysql.user WHERE HOST='%'"
+ );
+ }
+ my $luser = 'user_name';
+ if ( scalar(@mysqlstatlist) == 1 ) {
+ $luser = ( split /@/, $mysqlstatlist[0] )[0];
+ }
+ foreach my $line ( sort @mysqlstatlist ) {
+ chomp($line);
+ badprint "User " . $line
+ . " does not specify hostname restrictions.";
+ }
+ push( @generalrec,
+ "Restrict Host for $luser\@'%' to $luser\@LimitedIPRangeOrLocalhost"
);
+ push( @generalrec,
+ "RENAME USER $luser\@'%' TO "
+ . $luser
+ . "\@LimitedIPRangeOrLocalhost;" );
}
- my $luser = 'user_name';
- if ( scalar(@mysqlstatlist) == 1 ) {
- $luser = ( split /@/, $mysqlstatlist[0] )[0];
- }
- foreach my $line ( sort @mysqlstatlist ) {
- chomp($line);
- badprint "User " . $line
- . " does not specify hostname restrictions.";
- }
- push( @generalrec,
- "Restrict Host for $luser\@'%' to $luser\@LimitedIPRangeOrLocalhost"
- );
- push( @generalrec,
- "RENAME USER $luser\@'%' TO "
- . $luser
- . "\@LimitedIPRangeOrLocalhost;" );
}
unless ( -f $basic_password_files ) {
@@ -5508,8 +5891,8 @@ sub get_replication_status {
}
# Parallel replication checks (MariaDB specific)
- if ( ( $myvar{'version'} =~ /MariaDB/i )
- or ( $myvar{'version_comment'} =~ /MariaDB/i ) )
+ if ( ( ( $myvar{'version'} // '' ) =~ /MariaDB/i )
+ or ( ( $myvar{'version_comment'} // '' ) =~ /MariaDB/i ) )
{
my $parallel_threads = $myvar{'slave_parallel_threads'}
// $myvar{'replica_parallel_threads'} // 0;
@@ -5580,15 +5963,33 @@ sub validate_mysql_version {
}
}
+my $cached_version_str;
+my ( $cached_v_maj, $cached_v_min, $cached_v_mic );
+
+sub _parse_version {
+ my $ver = $myvar{'version'} // '';
+ if ( !defined $cached_version_str || $cached_version_str ne $ver ) {
+ $cached_version_str = $ver;
+ if ( $ver =~ /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/ ) {
+ $cached_v_maj = $1 // 0;
+ $cached_v_min = $2 // 0;
+ $cached_v_mic = $3 // 0;
+ }
+ else {
+ $cached_v_maj = 0;
+ $cached_v_min = 0;
+ $cached_v_mic = 0;
+ }
+ }
+ return ( $cached_v_maj, $cached_v_min, $cached_v_mic );
+}
+
# Checks if MySQL version is equal to (major, minor, micro)
sub mysql_version_eq {
my ( $maj, $min, $mic ) = @_;
return 0 unless defined $myvar{'version'};
- my ( $v_maj, $v_min, $v_mic ) =
- $myvar{'version'} =~ /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/;
- $v_maj //= 0;
- $v_min //= 0;
- $v_mic //= 0;
+ $maj //= 0;
+ my ( $v_maj, $v_min, $v_mic ) = _parse_version();
return int($v_maj) == int($maj)
if ( !defined($min) && !defined($mic) );
@@ -5603,13 +6004,10 @@ sub mysql_version_eq {
sub mysql_version_ge {
my ( $maj, $min, $mic ) = @_;
return 0 unless defined $myvar{'version'};
- $min ||= 0;
- $mic ||= 0;
- my ( $v_maj, $v_min, $v_mic ) =
- $myvar{'version'} =~ /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/;
- $v_maj //= 0;
- $v_min //= 0;
- $v_mic //= 0;
+ $maj //= 0;
+ $min //= 0;
+ $mic //= 0;
+ my ( $v_maj, $v_min, $v_mic ) = _parse_version();
return
int($v_maj) > int($maj)
@@ -5623,13 +6021,10 @@ sub mysql_version_ge {
sub mysql_version_le {
my ( $maj, $min, $mic ) = @_;
return 0 unless defined $myvar{'version'};
- $min ||= 0;
- $mic ||= 0;
- my ( $v_maj, $v_min, $v_mic ) =
- $myvar{'version'} =~ /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/;
- $v_maj //= 0;
- $v_min //= 0;
- $v_mic //= 0;
+ $maj //= 0;
+ $min //= 0;
+ $mic //= 0;
+ my ( $v_maj, $v_min, $v_mic ) = _parse_version();
return
int($v_maj) < int($maj)
@@ -6058,8 +6453,7 @@ sub dump_into_file {
my $actual_file = "$opt{dumpdir}/$file";
my $gzip_bin = which('gzip');
my $is_compressed = 0;
- my $start_time =
- eval { require Time::HiRes; Time::HiRes::time(); } || time();
+ my $start_time = get_time();
my $fh;
if ( $opt{'compress-dump'} && $gzip_bin && $file =~ /\.csv$/ ) {
$actual_file .= '.gz';
@@ -6074,8 +6468,7 @@ sub dump_into_file {
}
print $fh $content;
close $fh;
- my $end_time =
- eval { require Time::HiRes; Time::HiRes::time(); } || time();
+ my $end_time = get_time();
my $duration = $end_time - $start_time;
if ( $duration > 5.0 ) {
@@ -6154,6 +6547,7 @@ sub calculations {
if ( is_int( $mycalc{'max_tmp_table_size'} ) ) {
$per_thread_buffers_without_tmp -= $mycalc{'max_tmp_table_size'};
}
+ $mycalc{'per_thread_buffers_without_tmp'} = $per_thread_buffers_without_tmp;
my $internal_tmp_engine = $myvar{'internal_tmp_mem_storage_engine'}
// 'TempTable';
@@ -6407,16 +6801,16 @@ sub calculations {
$mycalc{'query_cache_efficiency'} = 0;
}
elsif ( mysql_version_ge(4) ) {
- if ( ( $mystat{'Com_select'} || 0 ) + ( $mystat{'Qcache_hits'} || 0 ) >
- 0 )
- {
- $mycalc{'query_cache_efficiency'} = sprintf(
- "%.1f",
- (
- $mystat{'Qcache_hits'} /
- ( $mystat{'Com_select'} + $mystat{'Qcache_hits'} )
- ) * 100
- );
+
+ # MDEV-4981: In MariaDB, Com_select includes query cache hits (Qcache_hits)
+ my $total_selects =
+ $is_mariadb
+ ? ( $mystat{'Com_select'} || 0 )
+ : (
+ ( $mystat{'Com_select'} || 0 ) + ( $mystat{'Qcache_hits'} || 0 ) );
+ if ( $total_selects > 0 ) {
+ $mycalc{'query_cache_efficiency'} = sprintf( "%.1f",
+ ( ( $mystat{'Qcache_hits'} || 0 ) / $total_selects ) * 100 );
}
else {
$mycalc{'query_cache_efficiency'} = 0;
@@ -6700,11 +7094,28 @@ sub mysql_stats {
infoprint "Max MySQL memory : " . hr_bytes( $mycalc{'max_peak_memory'} );
infoprint "Other process memory: " . hr_bytes( get_other_process_memory() );
- infoprint "Total buffers: "
- . hr_bytes( $mycalc{'server_buffers'} )
- . " global + "
- . hr_bytes( $mycalc{'per_thread_buffers'} )
- . " per thread ($myvar{'max_connections'} max threads)";
+ my $is_mariadb = ( $myvar{'version'} // '' ) =~ /mariadb/i;
+ my $internal_tmp_engine = $myvar{'internal_tmp_mem_storage_engine'} // 'TempTable';
+ if ( defined $myvar{'temptable_max_ram'}
+ && is_int( $myvar{'temptable_max_ram'} )
+ && !$is_mariadb
+ && $internal_tmp_engine eq 'TempTable' )
+ {
+ infoprint "Total buffers: "
+ . hr_bytes( $mycalc{'server_buffers'} )
+ . " global + "
+ . hr_bytes( $myvar{'temptable_max_ram'} )
+ . " temptable + "
+ . hr_bytes( $mycalc{'per_thread_buffers_without_tmp'} )
+ . " per thread ($myvar{'max_connections'} max threads)";
+ }
+ else {
+ infoprint "Total buffers: "
+ . hr_bytes( $mycalc{'server_buffers'} )
+ . " global + "
+ . hr_bytes( $mycalc{'per_thread_buffers'} )
+ . " per thread ($myvar{'max_connections'} max threads)";
+ }
infoprint "Performance_schema Max memory usage: "
. hr_bytes_rnd( get_pf_memory() );
$result{'Performance_schema'}{'memory'} = get_pf_memory();
@@ -6833,8 +7244,9 @@ sub mysql_stats {
if ( $myvar{'long_query_time'} > 10 ) {
push( @adjvars, "long_query_time (<= 10)" );
}
- if ( defined( $myvar{'log_slow_queries'} ) ) {
- if ( $myvar{'log_slow_queries'} eq "OFF" ) {
+ my $slow_query_log_active = $myvar{'slow_query_log'} // $myvar{'log_slow_queries'};
+ if ( defined( $slow_query_log_active ) ) {
+ if ( $slow_query_log_active eq "OFF" ) {
push( @generalrec,
"Enable the slow query log to troubleshoot bad queries" );
}
@@ -6944,12 +7356,21 @@ sub mysql_stats {
"Query cache cannot be analyzed: no SELECT statements executed";
}
else {
+ # MDEV-4981: In MariaDB, Com_select includes query cache hits (Qcache_hits)
+ my $is_mariadb = ( $myvar{'version'} // '' ) =~ /mariadb/i
+ || ( $myvar{'version_comment'} // '' ) =~ /mariadb/i;
+ my $total_selects =
+ $is_mariadb
+ ? ( $mystat{'Com_select'} || 0 )
+ : (
+ ( $mystat{'Com_select'} || 0 ) + ( $mystat{'Qcache_hits'} || 0 ) );
+
if ( $mycalc{'query_cache_efficiency'} < 20 ) {
badprint
"Query cache efficiency: $mycalc{'query_cache_efficiency'}% ("
. hr_num( $mystat{'Qcache_hits'} )
. " cached / "
- . hr_num( $mystat{'Qcache_hits'} + $mystat{'Com_select'} )
+ . hr_num($total_selects)
. " selects)";
badprint
"Query cache may be disabled by default due to mutex contention.";
@@ -6961,7 +7382,7 @@ sub mysql_stats {
"Query cache efficiency: $mycalc{'query_cache_efficiency'}% ("
. hr_num( $mystat{'Qcache_hits'} )
. " cached / "
- . hr_num( $mystat{'Qcache_hits'} + $mystat{'Com_select'} )
+ . hr_num($total_selects)
. " selects)";
if ( $mycalc{'query_cache_prunes_per_day'} > 98 ) {
badprint
@@ -8334,6 +8755,7 @@ sub mysql_pfs {
schema => $schema,
table => $table,
index => $index,
+ sql => "ALTER TABLE $schema.$table DROP INDEX $index",
}
);
$nbL++;
@@ -10503,20 +10925,22 @@ sub mysql_innodb {
infoprint " +-- InnoDB Log File Size: "
. hr_bytes( $myvar{'innodb_log_file_size'} );
}
- if ( defined $myvar{'innodb_log_files_in_group'} ) {
+ if ( defined $myvar{'innodb_log_files_in_group'}
+ && defined $myvar{'innodb_log_file_size'} )
+ {
infoprint " +-- InnoDB Log File In Group: "
. $myvar{'innodb_log_files_in_group'};
infoprint " +-- InnoDB Total Log File Size: "
. hr_bytes( $myvar{'innodb_log_files_in_group'} *
$myvar{'innodb_log_file_size'} )
. "("
- . $mycalc{'innodb_log_size_pct'}
+ . ( $mycalc{'innodb_log_size_pct'} // 0 )
. " % of buffer pool)";
}
- else {
+ elsif ( defined $myvar{'innodb_log_file_size'} ) {
infoprint " +-- InnoDB Total Log File Size: "
. hr_bytes( $myvar{'innodb_log_file_size'} ) . "("
- . $mycalc{'innodb_log_size_pct'}
+ . ( $mycalc{'innodb_log_size_pct'} // 0 )
. " % of buffer pool)";
}
}
@@ -10968,7 +11392,9 @@ sub mysql_innodb {
"InnoDB Write Log efficiency: metrics are not reliable (writes > write requests)";
}
elsif ( defined $mycalc{'pct_write_efficiency'}
- && $mycalc{'pct_write_efficiency'} < 90 )
+ && $mycalc{'pct_write_efficiency'} < 90
+ && defined $mystat{'Innodb_log_waits'}
+ && $mystat{'Innodb_log_waits'} > 0 )
{
badprint "InnoDB Write Log efficiency: "
. abs( $mycalc{'pct_write_efficiency'} ) . "% ("
@@ -11462,6 +11888,8 @@ sub historical_comparison {
my $file = $opt{'compare-file'};
return if !$file;
+ calculate_health_score() unless defined $result{'HealthScore'};
+
subheaderprint "Historical Trend Analysis";
if ( !-e $file ) {
badprint "Comparison file not found: $file";
@@ -11490,8 +11918,9 @@ sub historical_comparison {
return;
}
- infoprint "Comparing current results with snapshot from: "
- . ( $old->{'General'}{'Date'} // 'unknown' );
+ my $snapshot_date = $old->{'General'}{'Date'} // 'unknown';
+ infoprint "Comparing current results with snapshot from: " . $snapshot_date;
+ $result{'Trends'}{'SnapshotDate'} = $snapshot_date;
# 1. Compare QPS
if ( defined $result{'Stats'}{'QPS'} && defined $old->{'Stats'}{'QPS'} ) {
@@ -11500,13 +11929,25 @@ sub historical_comparison {
( $old->{'Stats'}{'QPS'} > 0 )
? ( $diff / $old->{'Stats'}{'QPS'} ) * 100
: 0;
- my $trend = ( $diff >= 0 ) ? "+" : "";
- infoprint sprintf(
+ my $trend = ( $diff >= 0 ) ? "+" : "";
+ my $qps_trend = sprintf(
"QPS Trend: %.2f -> %.2f (%s%.2f%%)",
$old->{'Stats'}{'QPS'},
$result{'Stats'}{'QPS'},
$trend, $pct
);
+ infoprint $qps_trend;
+ $result{'Trends'}{'QPS'} = $qps_trend;
+ }
+
+ # Compare Health Score
+ if ( defined $result{'HealthScore'} && defined $old->{'HealthScore'} ) {
+ my $diff = $result{'HealthScore'} - $old->{'HealthScore'};
+ my $trend = ( $diff > 0 ) ? "+" : "";
+ my $score_trend = sprintf( "Health Score Trend: %d -> %d (%s%d)",
+ $old->{'HealthScore'}, $result{'HealthScore'}, $trend, $diff );
+ infoprint $score_trend;
+ $result{'Trends'}{'HealthScore'} = $score_trend;
}
# 2. Compare Total Data Size
@@ -11515,10 +11956,30 @@ sub historical_comparison {
{
my $diff = $result{'Stats'}{'Total Data Size'} -
$old->{'Stats'}{'Total Data Size'};
- infoprint "Data Growth: "
+ my $size_trend =
+ "Data Growth: "
. hr_bytes( $old->{'Stats'}{'Total Data Size'} ) . " -> "
. hr_bytes( $result{'Stats'}{'Total Data Size'} ) . " ("
. hr_bytes($diff) . ")";
+ infoprint $size_trend;
+ $result{'Trends'}{'TotalDataSize'} = $size_trend;
+ }
+
+ # Compare sectional scores
+ foreach
+ my $sec ( 'General', 'Storage', 'Security', 'Replication', 'Modeling' )
+ {
+ if ( defined $result{'SectionalHealthScore'}{$sec}
+ && defined $old->{'SectionalHealthScore'}{$sec} )
+ {
+ my $diff = $result{'SectionalHealthScore'}{$sec} -
+ $old->{'SectionalHealthScore'}{$sec};
+ my $trend = ( $diff > 0 ) ? "+" : "";
+ $result{'Trends'}{'Sectional'}{$sec} = sprintf( "%d -> %d (%s%d)",
+ $old->{'SectionalHealthScore'}{$sec},
+ $result{'SectionalHealthScore'}{$sec},
+ $trend, $diff );
+ }
}
push @adjvars, "Historical comparison performed against " . basename($file);
@@ -11654,8 +12115,8 @@ sub check_query_anti_patterns {
sub mariadb_query_cache_info {
subheaderprint "Query Cache Information";
- unless ( ( $myvar{'version'} =~ /MariaDB/i )
- or ( $myvar{'version_comment'} =~ /MariaDB/i ) )
+ unless ( ( ( $myvar{'version'} // '' ) =~ /MariaDB/i )
+ or ( ( $myvar{'version_comment'} // '' ) =~ /MariaDB/i ) )
{
infoprint
"Not a MariaDB server. Skipping Query Cache Info plugin check.";
@@ -11821,7 +12282,7 @@ sub mysql_databases {
$result{'Databases'}{'All databases'}{'Index Pct'} =
percentage( $totaldbinfo[2], $totaldbinfo[3] ) . "%";
$result{'Databases'}{'All databases'}{'Total Size'} = $totaldbinfo[3];
- print "\n" unless ( $opt{'silent'} or $opt{'json'} );
+ print "\n" unless ( $opt{'silent'} or $opt{'json'} or $opt{'yaml'} );
my $nbViews = 0;
my $nbTables = 0;
@@ -12428,6 +12889,7 @@ sub headerprint {
. "\t * Major Hayden \n"
. " >> Bug reports, feature requests, and downloads at http://mysqltuner.pl/\n"
. " >> Run with '--help' for additional options and output filtering";
+ prettyprint " >> Started at: " . $tuner_start_datetime if defined $tuner_start_datetime;
debugprint( "Debug: " . $opt{debug} );
debugprint( "Experimental: " . $opt{experimental} );
}
@@ -12476,7 +12938,8 @@ sub format_recommendation_item {
my $schema = $item->{schema} // '';
my $table = $item->{table} // '';
my $index = $item->{index} // '';
- return "Unused index: $schema.$table ($index)";
+ my $sql = $item->{sql} // '';
+ return "Unused index: $schema.$table ($index) (suggested SQL: $sql)";
}
elsif ( $item->{type} eq 'redundant_index' ) {
my $schema = $item->{schema} // '';
@@ -12494,7 +12957,157 @@ sub format_recommendation_item {
return $item // '';
}
+sub _yaml_scalar {
+ my ( $v, $indent ) = @_;
+ return "~" unless defined $v;
+
+ if ( $v =~ /\n/ ) {
+ my $pad = ' ' x ( $indent + 1 );
+ $v =~ s/\r\n?/\n/g;
+ my @lines = split /\n/, $v, -1;
+ if ( @lines > 1 && $lines[-1] eq '' ) {
+ pop @lines;
+ }
+ my $joined = join( "\n", map { $pad . $_ } @lines );
+ return "|\n" . $joined . "\n";
+ }
+
+ if ( $v eq ''
+ || $v =~ /[:\#\'\"\[\]\{\},&*?!|>%-]/
+ || $v =~ /^(?:yes|no|true|false|null|on|off|~)$/i )
+ {
+ $v =~ s/'/''/g;
+ return "'$v'";
+ }
+ return $v;
+}
+
+sub _to_yaml {
+ my ( $data, $indent ) = @_;
+ $indent //= 0;
+ my $spaces = ' ' x $indent;
+ my $output = '';
+
+ if ( !defined $data ) {
+ return "~\n";
+ }
+ elsif ( ref $data eq 'HASH' ) {
+ $output .= "\n" if $indent > 0;
+ foreach my $key ( sort keys %$data ) {
+ my $val = $data->{$key};
+ $output .= $spaces . $key . ":";
+ if ( ref $val ) {
+ $output .= _to_yaml( $val, $indent + 1 );
+ }
+ else {
+ my $yaml_val = _yaml_scalar( $val, $indent );
+ if ( $yaml_val =~ /^\|/ ) {
+ $output .= " " . $yaml_val;
+ }
+ else {
+ $output .= " " . $yaml_val . "\n";
+ }
+ }
+ }
+ }
+ elsif ( ref $data eq 'ARRAY' ) {
+ $output .= "\n" if $indent > 0;
+ foreach my $item (@$data) {
+ $output .= $spaces . "-";
+ if ( ref $item ) {
+ my $inner = _to_yaml( $item, $indent + 1 );
+ $inner =~ s/^\n\s*//;
+ $output .= " " . $inner;
+ }
+ else {
+ my $yaml_val = _yaml_scalar( $item, $indent );
+ if ( $yaml_val =~ /^\|/ ) {
+ $output .= " " . $yaml_val;
+ }
+ else {
+ $output .= " " . $yaml_val . "\n";
+ }
+ }
+ }
+ }
+ else {
+ my $yaml_val = _yaml_scalar( $data, $indent );
+ if ( $yaml_val =~ /^\|/ ) {
+ $output .= $yaml_val;
+ }
+ else {
+ $output .= $yaml_val . "\n";
+ }
+ }
+ return $output;
+}
+
+sub _sanitized_result_for_export {
+ my $orig = shift;
+ my %copy = %$orig;
+
+ if ( ref $copy{'MySQLTuner'} eq 'HASH' ) {
+ my %mt_copy = %{ $copy{'MySQLTuner'} };
+ if ( ref $mt_copy{'options'} eq 'HASH' ) {
+ my %opts = %{ $mt_copy{'options'} };
+ for my $secret (qw(pass password ssh-password passenv userenv)) {
+ $opts{$secret} = '[REDACTED]' if defined $opts{$secret};
+ }
+ $mt_copy{'options'} = \%opts;
+ }
+ $copy{'MySQLTuner'} = \%mt_copy;
+ }
+
+ if ( ref $copy{'MySQL Client'} eq 'HASH' ) {
+ my %mc_copy = %{ $copy{'MySQL Client'} };
+ if ( defined $mc_copy{'Authentication Info'} ) {
+ my $auth = $mc_copy{'Authentication Info'};
+ $auth =~ s/-p'[^']*'/-p'[REDACTED]'/g;
+ $auth =~ s/-p(?!'\[REDACTED\]')\S+/-p[REDACTED]/g;
+ $mc_copy{'Authentication Info'} = $auth;
+ }
+ $copy{'MySQL Client'} = \%mc_copy;
+ }
+
+ return \%copy;
+}
+
+sub _serialize_to_json {
+ my ($data) = @_;
+ if (!defined $data) {
+ return 'null';
+ }
+ my $ref = ref($data);
+ if (!$ref) {
+ if ($data =~ /^-?(?:[0-9]+(?:\.[0-9]+)?)$/) {
+ return $data;
+ }
+ my $escaped = $data;
+ $escaped =~ s/\\/\\\\/g;
+ $escaped =~ s/"/\\"/g;
+ $escaped =~ s/\n/\\n/g;
+ $escaped =~ s/\r/\\r/g;
+ $escaped =~ s/\t/\\t/g;
+ return '"' . $escaped . '"';
+ } elsif ($ref eq 'HASH') {
+ my @pairs;
+ foreach my $k (sort keys %$data) {
+ my $v = $data->{$k};
+ push @pairs, _serialize_to_json($k) . ':' . _serialize_to_json($v);
+ }
+ return '{' . join(',', @pairs) . '}';
+ } elsif ($ref eq 'ARRAY') {
+ my @elems = map { _serialize_to_json($_) } @$data;
+ return '[' . join(',', @elems) . ']';
+ }
+ return 'null';
+}
+
sub dump_result {
+ if ( $opt{'json'} && $opt{'yaml'} ) {
+ print STDERR "ERROR: --json and --yaml are mutually exclusive\n";
+ return 1;
+ }
#debugprint Dumper( \%result ) if ( $opt{'debug'} );
debugprint "HTML REPORT: " . ( $opt{'reportfile'} // 'undef' );
@@ -12520,7 +13133,36 @@ sub dump_result {
( $details->{'res_logs'} // 10 ) +
( $details->{'res_meta'} // 10 );
+ my $report_host = $opt{'host'} // $myvar{'hostname'} // 'localhost';
+ my $report_port = $opt{'port'} // $myvar{'port'} // '3306';
+
# Render lists
+ my @sys_recs = (
+ @sysrec,
+ grep { /(swap|swappiness|memory|ram|cpu|process|disk|mountpoint|limit|packet|event|tcp|slot|proc|open files)/i } @generalrec
+ );
+ my %seen_sys;
+ @sys_recs = grep { !$seen_sys{$_}++ } @sys_recs;
+
+ my @sec_recs = (
+ @secrec,
+ grep { /(cve|security|password|authentication|ssl|encrypt|host|grant|privilege|user|transport)/i } @generalrec
+ );
+ my %seen_sec;
+ @sec_recs = grep { !$seen_sec{$_}++ } @sec_recs;
+
+ my @conn_recs =
+ grep { /(connection|connect|thread|max_user_connections|max_connections|aborted)/i }
+ @generalrec;
+
+ my @perf_recs =
+ grep { /(slow|query|join|sort|cache|temporary|tmp|lock|started)/i }
+ @generalrec;
+
+ my @repl_recs =
+ grep { /(replica|sla[v]e|gtid|replication|binlog|relay)/i }
+ @generalrec;
+
my $general_rec_html = join(
"\n",
map {
@@ -12557,7 +13199,7 @@ sub dump_result {
"âą "
. escape_html( format_recommendation_item($_) )
. " "
- } @secrec
+ } @sec_recs
)
|| "No security concerns identified. ";
@@ -12567,22 +13209,459 @@ sub dump_result {
"âą "
. escape_html( format_recommendation_item($_) )
. " "
- } @sysrec
+ } @sys_recs
)
|| "No OS or system modifications suggested. ";
- # Build raw output HTML
- my $raw_output_html =
- join( "\n", map { escape_html($_) } @raw_output_lines );
+ my $connections_rec_html = join(
+ "\n",
+ map {
+"âą "
+ . escape_html( format_recommendation_item($_) )
+ . " "
+ } @conn_recs
+ )
+ || "No connection or network recommendations. ";
- # Determine health score color class
- my $score_color_class =
- $score > 80
- ? 'text-emerald-400'
- : ( $score > 50 ? 'text-amber-400' : 'text-rose-500' );
- my $score_bg_class =
- $score > 80 ? 'bg-emerald-500/10 border-emerald-500/30'
- : (
+ my $performance_rec_html = join(
+ "\n",
+ map {
+"âą "
+ . escape_html( format_recommendation_item($_) )
+ . " "
+ } @perf_recs
+ )
+ || "No query or performance recommendations. ";
+
+ my $replication_rec_html = join(
+ "\n",
+ map {
+"âą "
+ . escape_html( format_recommendation_item($_) )
+ . " "
+ } @repl_recs
+ )
+ || "No replication recommendations. ";
+
+ # Build raw output HTML
+ my $raw_output_html =
+ join( "\n", map { escape_html($_) } @raw_output_lines );
+
+ # Phase 13: Calculate Sectional Health Score variables
+ my $kpi_gen = $result{'SectionalHealthScore'}{'General'} // 100;
+ my $kpi_stor = $result{'SectionalHealthScore'}{'Storage'} // 100;
+ my $kpi_sec = $result{'SectionalHealthScore'}{'Security'} // 100;
+ my $kpi_repl = $result{'SectionalHealthScore'}{'Replication'} // 100;
+ my $kpi_mode = $result{'SectionalHealthScore'}{'Modeling'} // 100;
+
+ # Serialize metrics to JSON for Javascript download
+ my $json_myvar = _serialize_to_json( \%myvar );
+ my $json_mystat = _serialize_to_json( \%mystat );
+ my $json_mycalc = _serialize_to_json( \%mycalc );
+ my $json_general = _serialize_to_json( \@generalrec );
+ my $json_adjvars = _serialize_to_json( \@adjvars );
+ my $json_secrec = _serialize_to_json( \@secrec );
+ my $json_sysrec = _serialize_to_json( \@sysrec );
+ my $json_modeling = _serialize_to_json( \@modeling );
+
+ # Additional metrics calculations for HTML visual components
+ my $bp_reads = $mystat{'Innodb_buffer_pool_reads'} // 0;
+ my $bp_reqs = $mystat{'Innodb_buffer_pool_read_requests'} // 0;
+ my $bp_hit_pct = 100.0;
+ if ($bp_reqs > 0) {
+ $bp_hit_pct = 100.0 * (1.0 - ($bp_reads / $bp_reqs));
+ }
+ $bp_hit_pct = sprintf("%.2f", $bp_hit_pct);
+
+ my $threads_created = $mystat{'Threads_created'} // 0;
+ my $connections = $mystat{'Connections'} // 0;
+ my $thread_cache_hit_pct = 100.0;
+ if ($connections > 0) {
+ $thread_cache_hit_pct = 100.0 * (1.0 - ($threads_created / $connections));
+ }
+ $thread_cache_hit_pct = sprintf("%.2f", $thread_cache_hit_pct);
+
+ my $tmp_disk = $mystat{'Created_tmp_disk_tables'} // 0;
+ my $tmp_total = $mystat{'Created_tmp_tables'} // 0;
+ my $tmp_mem_pct = 100.0;
+ my $tmp_disk_pct = 0.0;
+ if ($tmp_total > 0) {
+ $tmp_disk_pct = 100.0 * ($tmp_disk / $tmp_total);
+ $tmp_mem_pct = 100.0 - $tmp_disk_pct;
+ }
+ $tmp_mem_pct = sprintf("%.1f", $tmp_mem_pct);
+ $tmp_disk_pct = sprintf("%.1f", $tmp_disk_pct);
+
+ # Schema and Engine Statistics calculations for Storage & Modeling tabs
+ my $db_count = exists $result{'Databases'}{'List'} ? scalar @{ $result{'Databases'}{'List'} } : 0;
+ my $total_tables = 0;
+ my $engines_table_html = '';
+ my $engines_status_html = '';
+
+ if ( exists $result{'Engine'} ) {
+ my @badges;
+ my $total_data_size = 0;
+ my $total_index_size = 0;
+ my $total_size = 0;
+
+ foreach my $eng ( sort keys %{$result{'Engine'}} ) {
+ my $info = $result{'Engine'}{$eng};
+ my $tbl_count = $info->{'Table Number'} // 0;
+ my $d_size = $info->{'Data Size'} // 0;
+ my $i_size = $info->{'Index Size'} // 0;
+ my $t_size = $info->{'Total Size'} // 0;
+
+ my $status = $info->{'Enabled'} // '';
+ my $color_class = ($status eq 'YES' || $status eq 'DEFAULT' || $status eq 'ACTIVE')
+ ? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20'
+ : 'text-slate-500 bg-slate-500/5 border-slate-500/10';
+ my $prefix = ($status eq 'YES' || $status eq 'DEFAULT' || $status eq 'ACTIVE') ? '+' : '-';
+ push @badges, "$prefix$eng ";
+
+ next if $tbl_count == 0;
+
+ $total_tables += $tbl_count;
+ $total_data_size += $d_size;
+ $total_index_size += $i_size;
+ $total_size += $t_size;
+
+ $engines_table_html .= sprintf(
+ "" .
+ "%s " .
+ "%d " .
+ "%s " .
+ "%s " .
+ "%s " .
+ " ",
+ escape_html($eng),
+ $tbl_count,
+ hr_bytes($d_size),
+ hr_bytes($i_size),
+ hr_bytes($t_size)
+ );
+ }
+ if ($engines_table_html) {
+ $engines_table_html .= sprintf(
+ "" .
+ "Total " .
+ "%d " .
+ "%s " .
+ "%s " .
+ "%s " .
+ " ",
+ $total_tables,
+ hr_bytes($total_data_size),
+ hr_bytes($total_index_size),
+ hr_bytes($total_size)
+ );
+ }
+ $engines_status_html = join(' ', @badges);
+ }
+
+ if (!$engines_table_html) {
+ $engines_table_html = "No storage engine usage statistics available. ";
+ }
+ if (!$engines_status_html) {
+ $engines_status_html = "No storage engine status available. ";
+ }
+
+ my $frg_count = $fragtables // 0;
+ my $no_pk_count = exists $result{'Tables without PK'} ? scalar @{ $result{'Tables without PK'} } : 0;
+ my $unused_index_count = grep { ref($_) eq 'HASH' && $_->{type} eq 'unused_index' } @modeling;
+ my $redundant_index_count = grep { ref($_) eq 'HASH' && $_->{type} eq 'redundant_index' } @modeling;
+
+ # InnoDB Page stats & capacity Calculations
+ my $bp_pages_total = $mystat{'Innodb_buffer_pool_pages_total'} // 0;
+ my $bp_pages_free = $mystat{'Innodb_buffer_pool_pages_free'} // 0;
+ my $bp_pages_used = $bp_pages_total - $bp_pages_free;
+ my $bp_page_size = $myvar{'innodb_page_size'} // 16384;
+ my $bp_total_bytes = $bp_pages_total * $bp_page_size;
+ my $bp_free_bytes = $bp_pages_free * $bp_page_size;
+ my $bp_used_bytes = $bp_pages_used * $bp_page_size;
+
+ my $innodb_log_info_html = '';
+ if ( mysql_version_ge( 8, 0, 30 ) ) {
+ if ( defined $myvar{'innodb_redo_log_capacity'} ) {
+ $innodb_log_info_html = "innodb_redo_log_capacity " . hr_bytes($myvar{'innodb_redo_log_capacity'}) . " ";
+ } else {
+ $innodb_log_info_html = "innodb_redo_log_capacity N/A ";
+ }
+ } else {
+ my $log_file_size = $myvar{'innodb_log_file_size'} // 0;
+ my $log_files_in_group = $myvar{'innodb_log_files_in_group'} // 0;
+ my $total_log_size = $log_file_size * $log_files_in_group;
+ $innodb_log_info_html = "innodb_log_file_size " . hr_bytes($log_file_size) . " " .
+ "innodb_log_files_in_group $log_files_in_group " .
+ "Total Log File Size " . hr_bytes($total_log_size) . " (" . ($mycalc{'innodb_log_size_pct'} // 0) . "% of buffer pool) ";
+ }
+
+ my $chunk_align_html = '';
+ if (defined $myvar{'innodb_buffer_pool_chunk_size'} && defined $myvar{'innodb_buffer_pool_instances'} && $myvar{'innodb_buffer_pool_chunk_size'} > 0 && $myvar{'innodb_buffer_pool_instances'} > 0) {
+ my $num_chunks = int( ($myvar{'innodb_buffer_pool_size'} // 0) / $myvar{'innodb_buffer_pool_chunk_size'} );
+ my $expected_size = int($myvar{'innodb_buffer_pool_chunk_size'}) * int($myvar{'innodb_buffer_pool_instances'});
+ my $is_aligned = (int($myvar{'innodb_buffer_pool_size'} // 0) % $expected_size == 0);
+ my $align_status = $is_aligned ? "Aligned " : "Not Aligned ";
+ $chunk_align_html = "innodb_buffer_pool_chunk_size " . hr_bytes($myvar{'innodb_buffer_pool_chunk_size'}) . " " .
+ "InnoDB Buffer Pool Chunks $num_chunks (instances: " . $myvar{'innodb_buffer_pool_instances'} . ") " .
+ "Chunk Alignment Status $align_status ";
+ }
+
+ my $innodb_write_rate_html = '';
+ my $innodb_os_log_written = $mystat{'Innodb_os_log_written'} || 0;
+ my $uptime = $mystat{'Uptime'} || 1;
+ if ($uptime > 3600) {
+ my $hourly_rate = $innodb_os_log_written / ($uptime / 3600);
+ $innodb_write_rate_html = "Hourly InnoDB Log Write Rate " . hr_bytes($hourly_rate) . "/hour ";
+ } else {
+ $innodb_write_rate_html = "Total InnoDB OS Log Written " . hr_bytes($innodb_os_log_written) . " (uptime < 1h) ";
+ }
+
+ my $db_table_html = '';
+ foreach my $db ( sort @dblist ) {
+ my $info = $result{'Databases'}{$db};
+ next unless defined $info;
+ my $tbls = $info->{'Tables'} // 0;
+ my $rows = $info->{'Rows'} // 0;
+ my $d_size = $info->{'Data Size'} // 0;
+ my $i_size = $info->{'Index Size'} // 0;
+ my $t_size = $info->{'Total Size'} // 0;
+
+ my $d_size_str = ($d_size =~ /^\d+$/) ? hr_bytes($d_size) : $d_size;
+ my $i_size_str = ($i_size =~ /^\d+$/) ? hr_bytes($i_size) : $i_size;
+ my $t_size_str = ($t_size =~ /^\d+$/) ? hr_bytes($t_size) : $t_size;
+
+ $db_table_html .= sprintf(
+ "" .
+ "%s " .
+ "%d " .
+ "%d " .
+ "%s " .
+ "%s " .
+ "%s " .
+ " ",
+ escape_html($db),
+ $tbls,
+ $rows,
+ $d_size_str,
+ $i_size_str,
+ $t_size_str
+ );
+ }
+ if (!$db_table_html) {
+ $db_table_html = "No user database statistics available. ";
+ }
+
+ my $fragmented_tables_html = '';
+ if ( exists $result{'Tables'}{'Fragmented tables'} && scalar @{ $result{'Tables'}{'Fragmented tables'} } > 0 ) {
+ foreach my $table_line ( @{ $result{'Tables'}{'Fragmented tables'} } ) {
+ my ( $table_schema, $table_name, $engine, $data_free ) = split /\t/msx, $table_line;
+ my $free_mb = $data_free / 1024 / 1024;
+ my $free_str = sprintf("%.2f MB", $free_mb);
+ my $sql = ($engine eq 'InnoDB')
+ ? "ALTER TABLE `$table_schema`.`$table_name` FORCE;"
+ : "OPTIMIZE TABLE `$table_schema`.`$table_name`;";
+ $fragmented_tables_html .= sprintf(
+ "" .
+ "%s.%s " .
+ "%s " .
+ "%s " .
+ "%s " .
+ " ",
+ escape_html($table_schema),
+ escape_html($table_name),
+ escape_html($engine),
+ $free_str,
+ escape_html($sql)
+ );
+ }
+ }
+ if (!$fragmented_tables_html) {
+ $fragmented_tables_html = "No fragmented tables found. ";
+ }
+
+ my $no_pk_tables_html = '';
+ if ( exists $result{'Tables without PK'} && scalar @{ $result{'Tables without PK'} } > 0 ) {
+ foreach my $badtable ( @{ $result{'Tables without PK'} } ) {
+ my ($schema, $table) = split /,/, $badtable, 2;
+ if (!defined $table) {
+ $schema = '';
+ $table = $badtable;
+ }
+ $no_pk_tables_html .= sprintf(
+ "" .
+ "%s.%s " .
+ "Add explicit primary key for performance, maintenance, and replication stability. " .
+ " ",
+ escape_html($schema),
+ escape_html($table)
+ );
+ }
+ }
+ if (!$no_pk_tables_html) {
+ $no_pk_tables_html = "All base tables have a primary key or unique index. ";
+ }
+
+ my $redundant_indexes_html = '';
+ my $unused_indexes_html = '';
+ foreach my $item (@modeling) {
+ if ( ref($item) eq 'HASH' ) {
+ if ( ($item->{type} // '') eq 'redundant_index' ) {
+ my $schema = $item->{schema} // '';
+ my $table = $item->{table} // '';
+ my $index = $item->{index} // '';
+ my $dominant = $item->{dominant_index} // '';
+ my $sql = $item->{sql} // '';
+ $redundant_indexes_html .= sprintf(
+ "" .
+ "%s.%s " .
+ "%s " .
+ "redundant of %s " .
+ "%s " .
+ " ",
+ escape_html($schema),
+ escape_html($table),
+ escape_html($index),
+ escape_html($dominant),
+ escape_html($sql)
+ );
+ }
+ elsif ( ($item->{type} // '') eq 'unused_index' ) {
+ my $schema = $item->{schema} // '';
+ my $table = $item->{table} // '';
+ my $index = $item->{index} // '';
+ my $sql = $item->{sql} // '';
+ $unused_indexes_html .= sprintf(
+ "" .
+ "%s.%s " .
+ "%s " .
+ "%s " .
+ " ",
+ escape_html($schema),
+ escape_html($table),
+ escape_html($index),
+ escape_html($sql)
+ );
+ }
+ }
+ }
+ if (!$redundant_indexes_html) {
+ $redundant_indexes_html = "No redundant indexes detected. ";
+ }
+ if (!$unused_indexes_html) {
+ $unused_indexes_html = "No unused indexes detected. ";
+ }
+
+ # Prioritized Top Findings lists
+ my @general_top = get_top_findings( \@generalrec );
+ my @storage_top = get_top_findings( \@adjvars );
+ my @security_top = get_top_findings( \@secrec );
+ my @replication_top = get_top_findings( \@repl_recs );
+ my @modeling_top = get_top_findings( \@modeling );
+
+ my $format_top_findings = sub {
+ my ( $title, $findings_ref ) = @_;
+ my @findings = @$findings_ref;
+ if ( !@findings ) {
+ return
+""
+ . "
$title đą Optimal "
+ . "
No critical issues or recommendations detected in this area.
"
+ . "
";
+ }
+ my $items_html = '';
+ foreach my $f (@findings) {
+ my $badge_color =
+ ( $f->{badge} eq 'Critical' )
+ ? 'text-rose-400 bg-rose-500/10 border-rose-500/20'
+ : 'text-amber-400 bg-amber-500/10 border-amber-500/20';
+ $items_html .=
+""
+ . ""
+ . ""
+ . escape_html( $f->{text} )
+ . " " . " ";
+ }
+ return
+""
+ . "
$title â ïž Action Required "
+ . "
"
+ . "
";
+ };
+
+ my $gen_findings_html =
+ $format_top_findings->( 'General Stats', \@general_top );
+ my $store_findings_html =
+ $format_top_findings->( 'Storage & Performance', \@storage_top );
+ my $sec_findings_html =
+ $format_top_findings->( 'Security & Compliance', \@security_top );
+ my $repl_findings_html =
+ $format_top_findings->( 'Replication & HA', \@replication_top );
+ my $model_findings_html =
+ $format_top_findings->( 'SQL Modeling', \@modeling_top );
+
+ # Throughput Efficiency Index
+ my $tei_qps = $result{'ThroughputEfficiency'}{'QPS'} // 0.0;
+ my $tei_reads = $result{'ThroughputEfficiency'}{'LogicalReadsSec'}
+ // 0.0;
+ my $tei_index = $result{'ThroughputEfficiency'}{'Index'} // 0.00000;
+
+ # Resource Saturation Heatmap
+ my $sat_cpu = $result{'ResourceSaturation'}{'CPU'} // 0;
+ my $sat_mem = $result{'ResourceSaturation'}{'Memory'} // 0;
+ my $sat_conn = $result{'ResourceSaturation'}{'Connections'} // 0;
+ my $sat_io = $result{'ResourceSaturation'}{'IO'} // 0;
+
+ my $get_sat_color = sub {
+ my $val = shift;
+ return $val > 85
+ ? 'bg-rose-500'
+ : ( $val > 60 ? 'bg-amber-400' : 'bg-emerald-500' );
+ };
+ my $cpu_color = $get_sat_color->($sat_cpu);
+ my $mem_color = $get_sat_color->($sat_mem);
+ my $conn_color = $get_sat_color->($sat_conn);
+ my $io_color = $get_sat_color->($sat_io);
+
+ # Historical Trend Deltas
+ my $historical_deltas_html = '';
+ if ( defined $result{'Trends'} ) {
+ my $qps_delta = $result{'Trends'}{'QPS'} // 'N/A';
+ my $score_delta = $result{'Trends'}{'HealthScore'} // 'N/A';
+ my $size_delta = $result{'Trends'}{'TotalDataSize'} // 'N/A';
+ my $sec_deltas = '';
+ foreach my $sec ( 'General', 'Storage', 'Security', 'Replication',
+ 'Modeling' )
+ {
+ my $d = $result{'Trends'}{'Sectional'}{$sec} // 'N/A';
+ $sec_deltas .=
+"$sec: $d
";
+ }
+ $historical_deltas_html = <<"HTML";
+
+
Historical Performance Deltas
+
+
QPS: $qps_delta
+
Health Score: $score_delta
+
Data Size: $size_delta
+
+
Sectional Score Trends
+ $sec_deltas
+
+
+
+HTML
+ }
+
+ # Determine health score color class
+ my $score_color_class =
+ $score > 80
+ ? 'text-emerald-400'
+ : ( $score > 50 ? 'text-amber-400' : 'text-rose-500' );
+ my $score_bg_class =
+ $score > 80 ? 'bg-emerald-500/10 border-emerald-500/30'
+ : (
$score > 50 ? 'bg-amber-500/10 border-amber-500/30'
: 'bg-rose-500/10 border-rose-500/30'
);
@@ -12601,25 +13680,230 @@ sub dump_result {
my $sec_width = int( $sec_score * 100 / 30 );
my $res_width = int( $res_score * 100 / 30 );
- # HTML Content template
my $html_content = <<"HTML";
- MySQLTuner Report
+ MySQLTuner Advanced Report
-
+
+
@@ -12629,7 +13913,7 @@ sub dump_result {
- $db_ver ($db_comment)
+ $db_ver ($db_comment) at $report_host:$report_port
âą
Tuner $tunerversion
@@ -12640,164 +13924,1096 @@ sub dump_result {
-
-
-
-
-
-
-
-
-
Overall Health Score
-
-
-
-
-
-
-
-
-
-
out of 100
+
+
+ Dashboard
+ System & Memory
+ Connections
+ Queries
+ Locks
+ Storage
+ Temp Tables
+ Performance
+ Security
+ Modeling
+ Replication
+ Events
+ Export
+
+
+
+
+
+
+
+
+
+
+
Overall Health Score
+
+
+
+
+
+
+
+ out of 100
+
+
+
+ $badge_text
-
-
- $badge_text
+
+
+
Category Scores
+
+
+ Performance
+ $perf_score / 40
+
+
+
+
+
+ Security
+ $sec_score / 30
+
+
+
+
+
+ Resilience
+ $res_score / 30
+
+
+
+ $historical_deltas_html
-
-
-
Category Scores
-
-
-
-
-
Performance
-
$perf_score / 40
+
+
+
+
+
Sectional Indicators & KPIs Dashboard
+ Phase 13 Unified View
-
-
+
+
+
+
+
+
Resource Saturation Heatmap
+
+
+ CPU Load Saturation
+ $sat_cpu%
+
+
+
+
+
+ Memory Saturation
+ $sat_mem%
+
+
+
+
+
+ Connections Saturation
+ $sat_conn%
+
+
+
+
+
+ Disk I/O Pressure
+ $sat_io%
+
+
+
+
+
+
+
+
+
Throughput Efficiency Index
+
+
+ Logical Work
+ $tei_qps QPS
+
+
+ Buffer Logical Reads
+ $tei_reads/s
+
+
+
+
+
+
+ Efficiency Ratio
+ Queries completed per logical page read
+
+
+
+
+
-
-
-
-
-
Security
-
$sec_score / 30
+
+
+
Prioritized Executive Summary Findings
+
+ $gen_findings_html
+ $store_findings_html
+ $sec_findings_html
+ $repl_findings_html
+ $model_findings_html
+
-
+
+
+
+
+
+
+
+
+
+
Full Console Output Trace
+ Show Logs
+ Hide Logs
+
+
+
+
+
-
-
-
-
Resilience
-
$res_score / 30
+
+
+
+
OS & Memory Allocation
+
+
+
+
+ Resource
+ Allocated Value
+
+
+
+
+ Physical RAM
+ @{[$physical_memory ? sprintf("%.2f GB", $physical_memory / 1024 / 1024 / 1024) : 'N/A']}
+
+
+ Swap Space
+ @{[$swap_memory ? sprintf("%.2f GB", $swap_memory / 1024 / 1024 / 1024) : 'N/A']}
+
+
+ Max Peak Memory Calculation
+ @{[$mycalc{max_peak_memory} ? sprintf("%.2f MB (%d%% of RAM)", $mycalc{max_peak_memory} / 1024 / 1024, $mycalc{pct_max_used_memory}) : 'N/A']}
+
+
+ Maximum Memory Used
+ @{[$mycalc{max_used_memory} ? sprintf("%.2f MB", $mycalc{max_used_memory} / 1024 / 1024) : 'N/A']}
+
+
+
+
+
+
+
+
+
+
+
OS & System Recommendations
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Max Connections Limit
+
+
+
+ Max Connections Used
+
+
+
+
Thread Cache Hit Rate
+
+
$thread_cache_hit_pct%
+
+
+
+
+
Connection & Thread Variables
+
+
+
+
+ Variable Name
+ Value
+
+
+
+
+ max_connections
+ @{[$myvar{max_connections} // 'N/A']}
+
+
+ thread_cache_size
+ @{[$myvar{thread_cache_size} // 'N/A']}
+
+
+ Threads_cached
+ @{[$mystat{Threads_cached} // 'N/A']}
+
+
+ Threads_created
+ @{[$mystat{Threads_created} // 'N/A']}
+
+
+ Aborted_connects
+ @{[$mystat{Aborted_connects} // 'N/A']}
+
+
+
+
+
+
+
+
+
+
+
+
Connection & Network Recommendations
+
+
+
+
+
+
+
+
+ $connections_rec_html
+
+
+
+
+
+
+
+
+
+
+
+
InnoDB Buffer Pool Hit Rate
+
Indicates how frequently pages are read directly from cache memory vs disk
+
+
+
+
+
+
+
InnoDB Memory Configuration
+
+
+ innodb_buffer_pool_size
+ @{[$myvar{innodb_buffer_pool_size} ? sprintf("%.2f MB", $myvar{innodb_buffer_pool_size} / 1024 / 1024) : 'N/A']}
-
-
+
+ innodb_log_buffer_size
+ @{[$myvar{innodb_log_buffer_size} ? sprintf("%.2f MB", $myvar{innodb_log_buffer_size} / 1024 / 1024) : 'N/A']}
+
+
+ innodb_file_per_table
+ @{[$myvar{innodb_file_per_table} // 'N/A']}
+
+
+
+
Enabled Storage Engines Status
+
+ $engines_status_html
+
-
-
+
+
+
Storage Engine Data Distribution
+
+
+
+
+ Storage Engine
+ Tables Count
+ Data Size
+ Index Size
+ Total Size
+
+
+
+ $engines_table_html
+
+
+
+
-
-
-
-
-
General Recommendations
+
+
+
InnoDB Engine Detailed Diagnostics
+
+
+
+
+ Diagnostic Parameter
+ Value / Status
+
+
+
+
+ InnoDB Buffer Pool Instances
+ @{[$myvar{innodb_buffer_pool_instances} // 'N/A']}
+
+ $chunk_align_html
+ $innodb_log_info_html
+
+ InnoDB Buffer Pool Total Pages
+ @{[$mystat{Innodb_buffer_pool_pages_total} ? sprintf("%d pages (%s)", $mystat{Innodb_buffer_pool_pages_total}, hr_bytes($bp_total_bytes)) : 'N/A']}
+
+
+ InnoDB Buffer Pool Free Pages
+ @{[$mystat{Innodb_buffer_pool_pages_free} ? sprintf("%d pages (%s)", $mystat{Innodb_buffer_pool_pages_free}, hr_bytes($bp_free_bytes)) : 'N/A']}
+
+
+ InnoDB Buffer Pool Used Pages
+ @{[$mystat{Innodb_buffer_pool_pages_total} ? sprintf("%d pages (%s)", $bp_pages_used, hr_bytes($bp_used_bytes)) : 'N/A']}
+
+
+ InnoDB Thread Concurrency
+ @{[$myvar{innodb_thread_concurrency} // 'N/A']}
+
+
+ InnoDB Read Efficiency
+ $bp_hit_pct% (hit rate)
+
+ $innodb_write_rate_html
+
+
+
+
+
+
+
+
+
+
+
Variables to Adjust (Storage Recommendations)
-
+
+
+
-
-
-
-
-
Variables to Adjust
+
+
+
+
+
Temporary Tables Location Split
+
Shows ratio of temporary tables created in memory (optimal) vs written to disk (suboptimal)
+
+
RAM: $tmp_mem_pct%
+
Disk: $tmp_disk_pct%
+
+
+ Created_tmp_tables: @{[$mystat{Created_tmp_tables} // 0]}
+ Created_tmp_disk_tables: @{[$mystat{Created_tmp_disk_tables} // 0]}
+
+
+
+
+
+
Joins & Sort Variables
+
+
+
+
+ Metric
+ Value
+
+
+
+
+ Select_full_join (Joins without index)
+ @{[$mystat{Select_full_join} // 0]}
+
+
+ Select_range_check
+ @{[$mystat{Select_range_check} // 0]}
+
+
+ Sort_merge_passes (Sorts that touched disk)
+ @{[$mystat{Sort_merge_passes} // 0]}
+
+
+ Slow_queries
+ @{[$mystat{Slow_queries} // 0]}
+
+
+ tmp_table_size
+ @{[$myvar{tmp_table_size} ? sprintf("%.2f MB", $myvar{tmp_table_size} / 1024 / 1024) : 'N/A']}
+
+
+ max_heap_table_size
+ @{[$myvar{max_heap_table_size} ? sprintf("%.2f MB", $myvar{max_heap_table_size} / 1024 / 1024) : 'N/A']}
+
+
+
+
+
+
+
+
+
+
+
+
Query & Performance Recommendations
-
+
+
+ $performance_rec_html
+
+
+
+
-
-
-
-
-
Database Modeling Findings
+
+
+
+
Security Indicators & Configuration
+
Summary of database vulnerabilities, authentication plugins, and user permissions parameters.
+
+
+ MySQL User Logged In
+ $mysqllogin
-
-
+
+ Local MySQL Client Command
+ $mysqlcmd
-
+
+
-
-
-
+
+
+
-
Security Findings
+ Security Findings & Hardening Advice
-
+
+
+
-
-
-
-
-
System & OS Recommendations
-
-
-
+
+
+
+
Database Schema Modeling Findings
+
Highlights potential schema issues, indexes anomalies, PK casing problems, and naming conventions deviations.
+
+
+ Modeling Quality Score
+ $kpi_mode%
-
+
+
+
+
+
+ User Databases
+
+
+
+ Total Tables
+
+
+
+ Fragmented Tables
+
+
+
+ Tables w/o PK
+
+
+
+ Redundant Indexes
+
+
+
+ Unused Indexes
+
+
+
+
+
User Databases Size Distribution
+
+
+
+
+ Database Name
+ Tables
+ Rows
+ Data Size
+ Index Size
+ Total Size
+
+
+
+ $db_table_html
+
+
+
+
+
+
+
+
Fragmented Tables Details (>100MB data & >10% free space)
+
+
+
+
+ Table Name
+ Engine
+ Free Space
+ Suggested Defragmentation SQL
+
+
+
+ $fragmented_tables_html
+
+
+
+
+
+
+
+
Tables Without Primary Key Details
+
+
+
+
+ Table Name
+ Status / Hardening Advice
+
+
+
+ $no_pk_tables_html
+
+
+
+
+
+
+
+
Redundant Indexes Details
+
+
+
+
+ Table Name
+ Redundant Index
+ Status
+ Suggested Drop SQL
+
+
+
+ $redundant_indexes_html
+
+
+
+
+
+
+
+
Unused Indexes Details
+
+
+
+
+ Table Name
+ Unused Index
+ Suggested Drop SQL
+
+
+
+ $unused_indexes_html
+
+
+
+
+
+
+
+
+
+
SQL Schema & Modeling Recommendations
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
Replication Status Dashboard
+
+
+ Binary Logging
+ @{[$myvar{log_bin} // 'OFF']}
+
+
+ Replication Health Score
+ $kpi_repl%
+
+
+
+
+
+
+
+
-
-
-
Full Console Output Trace
+
+ Replication Recommendations
+
+
+
+
-
Show Logs
-
Hide Logs
-
-
-
-
+
+
+ $replication_rec_html
+
+
+
+
+
+
+
+
+ Queries & Execution Analytics
+
+
+
Query Distribution
+
+
+
Processing telemetry...
+
+
+
+
+
+
+
+
+
+
+ Locks & Latency
+
+
+
Table Locks
+
+
Processing telemetry...
+
+
+
+
Row Locks
+
+
Processing telemetry...
+
+
+
+
+
+
+
+
+
+ Temporary Tables & Spills
+
+
Temp Table Ratios
+
+
Processing telemetry...
+
+
+
+
+
+
+
+
+ Events & Log Summary
+
+
+
MySQL error log parsing requires enabling `--log-error` with appropriate permissions. If log parser data is available, it will be mapped here in future updates.
+
+
+
+
+
+
+
+
+
CSV Data Export Center
+
Download detailed parsed metrics and recommendation logs directly from this self-contained document to your local machine as CSV files.
+
+ System Variables
+ Status Variables
+ Recommendations
+ Schema & Modeling
+
+
+
+
+
JSON Data Export Center
+
Copy the complete parsed metric payload in structured JSON format directly to your clipboard for external integrations.
+
+ Copy JSON to Clipboard
+ Copied successfully!
+
+
+
+
+
+
@@ -12817,6 +15033,11 @@ sub dump_result {
goodprint "HTML Report successfully generated: " . $opt{'reportfile'};
}
+ my $sanitized_res;
+ if ( $opt{'json'} || $opt{'yaml'} ) {
+ $sanitized_res = _sanitized_result_for_export( \%result );
+ }
+
if ( $opt{'json'} ) {
eval { require JSON };
if ($@) {
@@ -12826,7 +15047,7 @@ sub dump_result {
my $json = JSON->new->allow_nonref;
print $json->utf8(1)->pretty( ( $opt{'prettyjson'} ? 1 : 0 ) )
- ->encode( \%result );
+ ->encode($sanitized_res);
if ( $opt{'outputfile'} ) {
unlink $opt{'outputfile'} if ( -e $opt{'outputfile'} );
@@ -12834,7 +15055,21 @@ sub dump_result {
or die
"Unable to open $opt{'outputfile'} in write mode. please check permissions for this file or directory";
print $fh $json->utf8(1)->pretty( ( $opt{'prettyjson'} ? 1 : 0 ) )
- ->encode( \%result );
+ ->encode($sanitized_res);
+ close $fh;
+ }
+ }
+
+ if ( $opt{'yaml'} ) {
+ my $yaml_str = _to_yaml($sanitized_res);
+ print $yaml_str;
+
+ if ( $opt{'outputfile'} ) {
+ unlink $opt{'outputfile'} if ( -e $opt{'outputfile'} );
+ open my $fh, q(>), $opt{'outputfile'}
+ or die
+"Unable to open $opt{'outputfile'} in write mode. please check permissions for this file or directory";
+ print $fh $yaml_str;
close $fh;
}
}
@@ -12888,6 +15123,9 @@ sub dump_csv_files {
open( $fh, '>', $outputfile_path )
or die("Failed to open $outputfile_path for writing: $!");
$opt{nocolor} = 1; # Disable colors in file output
+ if (@raw_output_lines) {
+ print $fh join( "\n", @raw_output_lines ), "\n";
+ }
}
# If outputfile is already set, create a second file handle for raw output
@@ -12951,11 +15189,9 @@ sub dump_csv_files {
"$dumpcmd $mysqllogin --no-data --databases \"$db\" > \"$sql_file\" 2>>$devnull";
}
- my $start_time =
- eval { require Time::HiRes; Time::HiRes::time(); } || time();
+ my $start_time = get_time();
execute_system_command($cmd);
- my $end_time =
- eval { require Time::HiRes; Time::HiRes::time(); } || time();
+ my $end_time = get_time();
my $duration = $end_time - $start_time;
my $base = basename($sql_file);
@@ -13080,6 +15316,7 @@ sub dump_csv_files {
# BEGIN 'MAIN'
# ---------------------------------------------------------------------------
if ( !caller ) {
+ $tuner_start_time = get_time();
parse_cli_args; # Parse CLI arguments
setup_environment; # Initialize variables and handle early exits
headerprint; # Header Print
@@ -13090,14 +15327,14 @@ sub dump_csv_files {
debugprint "MySQL FINAL Client : $mysqlcmd $mysqllogin";
debugprint "MySQL Admin FINAL Client : $mysqladmincmd $mysqllogin";
- dump_csv_files; # dump csv files
- os_setup; # Set up some OS variables
- get_all_vars; # Toss variables/status into hashes
- mysql_cloud_discovery; # Auto-discover cloud environment
- get_tuning_info; # Get information about the tuning connection
- calculations; # Calculate everything we need
- check_architecture; # Suggest 64-bit upgrade
- check_storage_engines; # Show enabled storage engines
+ os_setup; # Set up some OS variables
+ get_all_vars; # Toss variables/status into hashes
+ print_audit_snapshot_summary; # Summary of the audit snapshot
+ mysql_cloud_discovery; # Auto-discover cloud environment
+ get_tuning_info; # Get information about the tuning connection
+ calculations; # Calculate everything we need
+ check_architecture; # Suggest 64-bit upgrade
+ check_storage_engines; # Show enabled storage engines
if ( $opt{'feature'} ) {
subheaderprint "See FEATURES.md for more information";
@@ -13106,7 +15343,9 @@ sub dump_csv_files {
subheaderprint "Running feature: $feature";
$feature->();
}
+ dump_csv_files; # dump csv files
make_recommendations;
+ print_execution_timings();
goodprint "Terminated successfully";
exit(0);
}
@@ -13134,8 +15373,10 @@ sub dump_csv_files {
$section->();
}
+ dump_csv_files; # dump csv files
make_recommendations; # Make recommendations based on stats
dump_result; # Dump result if debug is on
+ print_execution_timings();
goodprint "Terminated successfully";
close_outputfile; # Close reportfile if needed
@@ -13153,7 +15394,7 @@ sub dump_csv_files {
=head1 NAME
- MySQLTuner 2.8.45 - MySQL High Performance Tuning Script
+ MySQLTuner 2.9.0 - MySQL High Performance Tuning Script
=head1 IMPORTANT USAGE GUIDELINES
@@ -13168,7 +15409,7 @@ =head1 OPTIONS
=head1 VERSION
-Version 2.8.45
+Version 2.9.0
=head1 PERLDOC
You can find documentation for this module with the perldoc command.
@@ -13338,6 +15579,10 @@ =head1 CONTRIBUTORS
Long Radix
+=item *
+
+derZ-dev
+
=back
=head1 SUPPORT
diff --git a/releases/v2.9.0.md b/releases/v2.9.0.md
new file mode 100644
index 000000000..7f64bca57
--- /dev/null
+++ b/releases/v2.9.0.md
@@ -0,0 +1,184 @@
+# Release Notes - v2.9.0
+
+**Date**: 2026-07-03
+
+## đ Executive Summary
+
+```text
+2.9.0 2026-07-03
+
+- chore: automated project metadata update
+- chore: remove execution.log from git repository and sync docs
+- feat: recommend slow query log when disabled (#517)
+- feat: update Total buffers output message format to display temptable sizing when active.
+- fix: update commit process
+- fix: update documentation and code
+- test: add socket preservation tests to test_issue_781.t.
+- test: add unit test for remote host physical memory detection and validation (issue #796).
+- test: add unit test for temptable output formatting in Total buffers.
+- ci: optimize pre-commit hook to only run unit tests when code, tests, or dependencies are modified.
+- chore(build): allow build scope in compliance auditor
+- chore(build): rewrite dev_sync and doc_sync in Perl for consistency
+- chore(deps): update devops-infra/action-commit-push digest to fa0c793 (#929)
+- chore(deps): update shogo82148/actions-setup-mysql digest to 076e636 (#930)
+- chore(main): add doc links in localhost warnings and support custom local subdomains
+- chore(main): add roadmap to the whitelist of allowed scopes in compliance checks.
+- chore(main): hide hostname, ssl, and replication warnings on localhost (#933)
+- chore(main): whitelist deps and system commit scopes in check_compliance.pl to support Dependabot and host metrics commits.
+- chore(metadata): add GitHub issue templates for bugs and feature requests
+- feat(cli): create an agent-ready output format (JSON/YAML) so that MySQLTuner can be easily integrated by AI agents.
+- feat(cli): resolve remote memory, socket override, and temptable sizing issues
+- feat(main): recommend enabling slow query log if disabled (#517)
+- feat(metadata): fix test badge and update version references in READMEs
+- feat(report): add pgBadger-inspired query distribution, locking latency, and temp table memory/disk spill visual analytics to HTML report
+- feat(report): add verbose execution timing measurements for each section, showing both elapsed time and its percentage relative to the total script execution time.
+- feat(report): add verbose timings, step percentages, and snapshot summary
+- feat(report): categorize recommendations in HTML report and add Connections/Performance panels, Storage engines table, and SQL modeling stats grid.
+- feat(report): finalize HTML report, YAML output, and historical comparison
+- feat(report): finalize a complete HTML report file beginning in v2.8.45.
+- feat(report): implement Phase 13 sectional global indicators and KPIs
+- feat(report): move dump_csv_files execution step to immediately before make_recommendations.
+- feat(report): print an environment audit snapshot summary (server, user, RAM, swap, versions, uptime) right after get_all_vars.
+- feat(report): support historical comparison of database diagnostics and performance metrics over time.
+- fix(cli): add mutually exclusive guard for json and yaml options.
+- fix(cli): preserve --socket in connection arguments when --host is specified.
+- fix(main): add undefined and 'NULL' guards to hr_num to eliminate uninitialized value warnings.
+- fix(main): add undefined/NULL guards to hr_num to resolve uninitialized warnings
+- fix(main): address PR #931 code review feedback and enhance test validations
+- fix(main): calculate health score early in historical comparison to ensure scores exist for trend analysis.
+- fix(main): calculate query cache efficiency using Com_select on MariaDB (MDEV-4981)
+- fix(main): calculate query cache efficiency using Com_select on MariaDB, where Com_select includes query cache hits (MDEV-4981).
+- fix(main): exclude MariaDB user roles and support zero-config TLS
+- fix(main): exclude MariaDB user roles from SSL remote user audit
+- fix(main): format YAML null as tilde (~) and multiline values using literal block style.
+- fix(main): guard InnoDB log file size and log size percentage checks against uninitialized variables.
+- fix(main): guard version and version comment checks in MariaDB parallel replication and query cache blocks.
+- fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance.
+- fix(main): only recommend increasing innodb_log_buffer_size when log waits occur
+- fix(main): sanitize and redact sensitive credentials from json/yaml output exports.
+- fix(main): skip local /proc/loadavg read during remote database audits.
+- fix(main): support MariaDB 11.4+ zero-configuration TLS and avoid false missing certificate warnings
+- fix(report): flush console output trace when opening raw log files late in dumpdir mode.
+- fix(test): add undef fallbacks to human_size, hr_bytes and hr_bytes_rnd mocks in tests.
+- test(hook): verify pre-commit hook runs tests when test files change
+- test(lab): add unit test test_issue_480.t for table_open_cache_instances recommendation (#480)
+- test(lab): add unit test test_issue_517.t for slow query log recommendations (#517)
+- test(lab): add unit test test_issue_810.t for issue 810
+- test(lab): add unit test test_issue_810.t for remote host forcemem MB interpretation correctness (issue #810)
+- test(lab): add unit test test_issue_938.t for InnoDB write log efficiency
+- test(lab): add unit tests for query cache efficiency logic on MySQL and MariaDB in tests/test_issue_927.t (renamed from tests/issue_927.t).
+- test(lab): normalize all repro_issue_*.t and issue_*.t test file names to test_issue_*.t
+- test(lab): split unit_coverage_boost4.t into smaller topic-oriented unit tests: unit_cli_helpers.t, unit_client_privileges.t, unit_cloud_commands.t, unit_fs_info.t, unit_os_vm.t
+- test(lab): utilize isolated tempfile for mock json to avoid race conditions in tests/unit_phase13_kpis.t.
+- test(report): add verbose timing and audit snapshot summary formatting checks to tests/verbose_timing.t.
+- test(report): update HTML report unit tests to verify connections, performance, storage, and modeling HTML sections.
+- test(security): add test cases for MariaDB role exclusion and zero-configuration TLS
+- test(versions): add unit tests for version caching and comparisons, resolve redundant warnings, and use tempfile in tests/unit_versions.t.
+- docs(metadata): promote HTML reports and add E2E examples
+- docs(metadata): remove timestamp from doc-sync generated files
+- docs(roadmap): link strategic technical evolutions specification and enforce changelog staging
+- docs(roadmap): specify and implement Phase XIV pgBadger-inspired HTML report modules
+- docs(roadmap): specify Phase XVI (AI Agent Integration) and Phase XVII (Dockerized MCP Daemon).
+- docs: add LightPath as sponsor, relocate coffee button, and use star-history chart
+- docs: regenerate release notes
+- docs: update repository links to major/MySQLTuner-perl and add GitHub stars badge
+```
+
+## đ Diagnostic Growth Indicators
+
+| Metric | Current | Progress | Status |
+| :--- | :--- | :--- | :--- |
+| Total Indicators | 15 | 0 | đĄïž |
+| Efficiency Checks | 0 | 0 | đĄïž |
+| Risk Detections | 2 | 0 | đĄïž |
+| Information Points | 13 | 0 | đĄïž |
+
+## đ ïž Internal Commit History
+
+- docs(releases): regenerate release notes and changelog (2ce031f)
+- docs(metadata): promote HTML reports and add E2E examples (30d5255)
+- docs: regenerate release notes (7e1cd64)
+- feat(report): add pgBadger-inspired query, lock, and temp table visual analytics to HTML report (246e0c0)
+- docs: regenerate release notes (272ff7d)
+- docs: regenerate release notes (093984a)
+- fix(main): only recommend increasing innodb_log_buffer_size when log waits occur (1aa3eaf)
+- docs: regenerate release notes (385594b)
+- docs: regenerate release notes (ae41134)
+- docs: regenerate release notes (6db4faf)
+- docs: regenerate release notes (9865449)
+- fix(main): exclude MariaDB user roles and support zero-config TLS (aee6781)
+- docs: regenerate release notes (f715c47)
+- docs: regenerate release notes (d46e3c8)
+- docs: regenerate release notes (38ecb2f)
+- docs: regenerate release notes (3b9c75a)
+- docs: regenerate release notes (2cfd624)
+- chore(main): add doc links in localhost warnings and support custom local subdomains (9f67656)
+- docs: regenerate release notes (b74e098)
+- docs: regenerate release notes (0005a69)
+- chore(build): allow build scope in compliance auditor (2bba15f)
+- chore(build): rewrite dev_sync and doc_sync in Perl for consistency (645c3d9)
+- chore(main): hide hostname, ssl, and replication warnings on localhost (#933) (4f3e70e)
+- docs: regenerate release notes (be838ab)
+- docs: regenerate release notes (389c4ad)
+- docs: regenerate release notes (d424fae)
+- docs: regenerate release notes (1575463)
+- docs: regenerate release notes (7006061)
+- feat(cli): resolve remote memory, socket override, and temptable sizing issues (7e3a39c)
+- test(hook): verify pre-commit hook runs tests when test files change (41abef0)
+- docs: regenerate release notes (9b9e007)
+- ci: optimize pre-commit hook to only run unit tests when code, tests, or dependencies are modified (596df4c)
+- docs: regenerate release notes (5759b73)
+- fix: update documentation and code (1008ba4)
+- docs: regenerate release notes (7fd2391)
+- test(lab): add unit test test_issue_810.t for issue 810 (6ece0c3)
+- docs: regenerate release notes (0b5c859)
+- feat: recommend slow query log when disabled (#517) (da6d437)
+- docs: add LightPath as sponsor, relocate coffee button, and use star-history chart (31e3638)
+- docs: update repository links to major/MySQLTuner-perl and add GitHub stars badge (4cd208e)
+- docs: regenerate release notes (a754147)
+- fix(main): add undefined/NULL guards to hr_num to resolve uninitialized warnings (0a87482)
+- docs(roadmap): link strategic technical evolutions specification and enforce changelog staging (136a977)
+- docs: regenerate release notes (abe582a)
+- fix(main): address PR #931 code review feedback and enhance test validations (64c28ce)
+- feat(metadata): fix test badge and update version references in READMEs (a1be73c)
+- docs(metadata): remove timestamp from doc-sync generated files (4575b0a)
+- fix(main): calculate query cache efficiency using Com_select on MariaDB (MDEV-4981) (780c861)
+- feat(report): add verbose timings, step percentages, and snapshot summary (c23198a)
+- feat(report): implement Phase 13 sectional global indicators and KPIs (5f9cebb)
+- chore: remove execution.log from git repository and sync docs (2a9b66e)
+- feat(report): finalize HTML report, YAML output, and historical comparison (66ebab8)
+
+## âïž Technical Evolutions
+
+### â CLI Options Added
+- `--innodb_buffer_stats_by_schema`
+- `--innodb_buffer_stats_by_table`
+- `--processlist`
+- `--schema_auto_increment_columns`
+- `--schema_index_statistics`
+- `--schema_object_overview`
+- `--schema_redundant_indexes`
+- `--schema_table_lock_waits`
+- `--schema_table_statistics`
+- `--schema_table_statistics_with_buffer`
+- `--schema_tables_with_full_table_scans`
+- `--schema_unused_indexes`
+- `--session`
+- `--stage-timings`
+- `--statement_analysis`
+- `--statements_with_errors_or_warnings`
+- `--statements_with_full_table_scans`
+- `--statements_with_runtimes_in_95th_percentile`
+- `--statements_with_sorting`
+- `--statements_with_temp_tables`
+- `--yaml`
+
+### â CLI Options Deprecated
+- `--data`
+- `--template`
+
+## â
Laboratory Verification Results
+
+- [x] Automated TDD suite passed.
+- [x] Multi-DB version laboratory execution validated.
+- [x] Performance indicator delta analysis completed.
diff --git a/tests/doc_sync.t b/tests/doc_sync.t
index 046b0814b..d922b4e3f 100644
--- a/tests/doc_sync.t
+++ b/tests/doc_sync.t
@@ -12,15 +12,16 @@ my $project_root = abs_path(File::Spec->catfile($script_dir, '..'));
# Change directory to project root
chdir $project_root or die "Can't chdir to $project_root: $!";
-subtest 'doc_sync.py execution verification' => sub {
- my $doc_sync_script = File::Spec->catfile('build', 'doc_sync.py');
- ok(-f $doc_sync_script, "build/doc_sync.py exists");
+subtest 'doc_sync.pl execution verification' => sub {
+ my $doc_sync_script = File::Spec->catfile('build', 'doc_sync.pl');
+ ok(-f $doc_sync_script, "build/doc_sync.pl exists");
- my $output = qx(python3 "$doc_sync_script" 2>&1);
+ my $output = qx(perl "$doc_sync_script" 2>&1);
my $exit_code = $? >> 8;
- is($exit_code, 0, "doc_sync.py executed successfully");
- like($output, qr/Documentation synchronized/i, "doc_sync.py reports success");
+ is($exit_code, 0, "doc_sync.pl executed successfully");
+ like($output, qr/Documentation synchronized/i, "doc_sync.pl reports success");
};
done_testing();
+# Verify pre-commit hook execution
diff --git a/tests/html_report.t b/tests/html_report.t
index d835b13f6..40085b961 100644
--- a/tests/html_report.t
+++ b/tests/html_report.t
@@ -34,6 +34,8 @@ subtest 'HTML Report Generation' => sub {
# Set CLI option for reportfile
$main::opt{'reportfile'} = $report_file;
+ $main::opt{'host'} = '127.0.0.1';
+ $main::opt{'port'} = '3307';
# Populate mock metrics & recommendations
$main::mycalc{'WeightedHealthScore'} = 85;
@@ -47,6 +49,49 @@ subtest 'HTML Report Generation' => sub {
res_logs => 10,
res_meta => 10
};
+
+ $main::result{'Databases'}{'List'} = ['test_db', 'mydb'];
+ $main::result{'Databases'}{'test_db'} = {
+ 'Tables' => 10,
+ 'Rows' => 5000,
+ 'Data Size' => 1024 * 1024 * 50,
+ 'Index Size' => 1024 * 1024 * 10,
+ 'Total Size' => 1024 * 1024 * 60,
+ };
+ $main::result{'Databases'}{'mydb'} = {
+ 'Tables' => 20,
+ 'Rows' => 15000,
+ 'Data Size' => 1024 * 1024 * 150,
+ 'Index Size' => 1024 * 1024 * 50,
+ 'Total Size' => 1024 * 1024 * 200,
+ };
+ $main::result{'Engine'} = {
+ InnoDB => {
+ 'Table Number' => 10,
+ 'Data Size' => 1024 * 1024 * 50,
+ 'Index Size' => 1024 * 1024 * 10,
+ 'Total Size' => 1024 * 1024 * 60,
+ 'Enabled' => 'YES'
+ },
+ MyISAM => {
+ 'Table Number' => 2,
+ 'Data Size' => 1024 * 1024 * 5,
+ 'Index Size' => 1024 * 1024 * 1,
+ 'Total Size' => 1024 * 1024 * 6,
+ 'Enabled' => 'YES'
+ }
+ };
+ $main::result{'Tables without PK'} = ['test_db.orders'];
+ $main::result{'Tables'}{'Fragmented tables'} = [ "test_db\torders\tInnoDB\t104857600" ];
+ $main::mystat{'Innodb_buffer_pool_pages_total'} = 65536;
+ $main::mystat{'Innodb_buffer_pool_pages_free'} = 16384;
+ $main::mystat{'Innodb_os_log_written'} = 1024 * 1024 * 10;
+ $main::mystat{'Uptime'} = 7200;
+ $main::myvar{'innodb_buffer_pool_instances'} = 8;
+ $main::myvar{'innodb_buffer_pool_chunk_size'} = 128 * 1024 * 1024;
+ $main::myvar{'innodb_buffer_pool_size'} = 1024 * 1024 * 1024;
+ $main::myvar{'innodb_redo_log_capacity'} = 512 * 1024 * 1024;
+ $main::fragtables = 3;
@main::generalrec = ("Test General Recommendation");
@main::adjvars = ("innodb_buffer_pool_size = 1G");
@@ -57,6 +102,7 @@ subtest 'HTML Report Generation' => sub {
schema => 'test_db',
table => 'users',
index => 'idx_created_at',
+ sql => 'ALTER TABLE test_db.users DROP INDEX idx_created_at',
},
{
type => 'redundant_index',
@@ -93,11 +139,42 @@ subtest 'HTML Report Generation' => sub {
like($content, qr/Test General Recommendation/i, "Contains general recommendation");
like($content, qr/innodb_buffer_pool_size = 1G/i, "Contains variables to adjust");
like($content, qr/Test Modeling Warning/i, "Contains modeling warnings");
- like($content, qr/Unused index: test_db\.users \(idx_created_at\)/, "Contains formatted unused index");
+ like($content, qr/Unused index: test_db\.users \(idx_created_at\) \(suggested SQL: ALTER TABLE test_db\.users DROP INDEX idx_created_at\)/, "Contains formatted unused index");
like($content, qr/Redundant index: test_db\.orders \(idx_redundant\) redundant of idx_dominant \(suggested SQL: ALTER TABLE orders DROP INDEX idx_redundant\)/, "Contains formatted redundant index");
like($content, qr/Test Security Warning/i, "Contains security warnings");
like($content, qr/Test System Recommendation/i, "Contains system recommendations");
like($content, qr/This is a mock line of terminal output/i, "Contains raw console output trace");
+ like($content, qr/id="tab-dashboard"/i, "Contains dashboard tab");
+ like($content, qr/id="tab-storage"/i, "Contains storage engines tab");
+ like($content, qr/id="tab-export"/i, "Contains data export tab");
+ like($content, qr/const dbMetrics = \{/i, "Contains embedded JSON dbMetrics");
+ like($content, qr/function downloadCSV/i, "Contains Javascript CSV downloader");
+ like($content, qr/id="sysrec-list"/i, "Contains OS & System recommendations list");
+ like($content, qr/id="adjvars-list"/i, "Contains Storage adjustments list");
+ like($content, qr/id="secrec-list"/i, "Contains Security advice list");
+ like($content, qr/id="modeling-list"/i, "Contains SQL modeling recommendations list");
+ like($content, qr/id="replication-rec-list"/i, "Contains Replication general recommendations list");
+ like($content, qr/id="connections-rec-list"/i, "Contains Connections recommendations list");
+ like($content, qr/id="performance-rec-list"/i, "Contains Performance recommendations list");
+ like($content, qr/at 127\.0\.0\.1:3307/i, "Contains host and port in header");
+ like($content, qr/Enabled Storage Engines Status/i, "Contains Enabled Storage Engines Status header");
+ like($content, qr/Storage Engine Data Distribution/i, "Contains Storage Engine Data Distribution table");
+ like($content, qr/InnoDB Engine Detailed Diagnostics/i, "Contains InnoDB Engine Detailed Diagnostics card");
+ like($content, qr/User Databases Size Distribution/i, "Contains User Databases Size Distribution table");
+ like($content, qr/Fragmented Tables Details/i, "Contains Fragmented Tables Details table");
+ like($content, qr/Tables Without Primary Key Details/i, "Contains Tables Without Primary Key Details table");
+ like($content, qr/Redundant Indexes Details/i, "Contains Redundant Indexes Details table");
+ like($content, qr/Unused Indexes Details/i, "Contains Unused Indexes Details table");
+ like($content, qr/SQL Schema Summary KPI Grid/i, "Contains SQL Schema Summary KPI Grid comment/section");
+ like($content, qr/window\.location\.hash = tabId/i, "Contains tab hash persistence hook");
+ like($content, qr/function filterList/i, "Contains list search filter helper");
+ like($content, qr/metadata: \{/i, "Contains metadata block in dbMetrics");
+ like($content, qr/gauge-progress/i, "Contains gauge animation class");
+ like($content, qr/id="copy-json-btn"/i, "Contains copy JSON button");
+ like($content, qr/function copyJSON/i, "Contains copyJSON helper function");
+ like($content, qr/function replaceIcons/i, "Contains replaceIcons SVG helper function");
+ like($content, qr/\@media\s*\(min-width:\s*640px\)/i, "Contains fallback CSS media queries");
+
# Cleanup
unlink $report_file;
diff --git a/tests/localhost_binding_checks.t b/tests/localhost_binding_checks.t
new file mode 100644
index 000000000..4a7ee80fc
--- /dev/null
+++ b/tests/localhost_binding_checks.t
@@ -0,0 +1,210 @@
+use strict;
+use warnings;
+use Test::More;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+# Load mysqltuner.pl as a library
+my $script_dir = dirname(abs_path(__FILE__));
+my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+require $script;
+require './tests/MySQLTuner/TestHelper.pm';
+
+# Save original subroutines for safe teardown
+my $orig_badprint = \&main::badprint;
+my $orig_infoprint = \&main::infoprint;
+my $orig_goodprint = \&main::goodprint;
+my $orig_debugprint = \&main::debugprint;
+my $orig_subheaderprint = \&main::subheaderprint;
+my $orig_push_recommendation = \&main::push_recommendation;
+my $orig_get_password_column_name = \&main::get_password_column_name;
+my $orig_select_one = \&main::select_one;
+my $orig_select_array = \&main::select_array;
+
+no warnings 'redefine';
+
+my @badprints;
+my @infopoints;
+my @recommendations;
+my $mocked_user_wildcard;
+
+*main::badprint = sub { push @badprints, $_[0] };
+*main::infoprint = sub { push @infopoints, $_[0] };
+*main::goodprint = sub { };
+*main::debugprint = sub { };
+*main::subheaderprint = sub { };
+*main::push_recommendation = sub {
+ my ($cat, $msg) = @_;
+ push @main::generalrec, $msg;
+ push @recommendations, { type => $cat, msg => $msg };
+};
+
+*main::get_password_column_name = sub { return 'Password'; };
+
+*main::select_one = sub {
+ my ($query) = @_;
+ if ($query =~ /Ssl_cipher/i) {
+ return "Ssl_cipher NULL";
+ }
+ return 0;
+};
+
+*main::select_array = sub {
+ my ($query) = @_;
+ if ($query =~ /FROM mysql.user WHERE HOST='%'/i) {
+ return $mocked_user_wildcard ? ("'test_user'\@'%'") : ();
+ }
+ return ();
+};
+
+subtest 'Not bound to loopback - warnings are displayed' => sub {
+ MySQLTuner::TestHelper::reset_state();
+ @badprints = ();
+ @infopoints = ();
+ @recommendations = ();
+ @main::generalrec = ();
+ $mocked_user_wildcard = 1;
+ $main::basic_password_files = 'non_existent_file';
+
+ # Non-local setup
+ $main::myvar{'bind_address'} = '0.0.0.0';
+ $main::myvar{'skip_networking'} = 'OFF';
+ $main::myvar{'have_ssl'} = 'DISABLED';
+ $main::myvar{'gtid_mode'} = 'OFF';
+
+ # Manual calculation matching mysqltuner.pl logic
+ $main::is_local_only = 0;
+ if ( defined $main::myvar{'skip_networking'} && $main::myvar{'skip_networking'} eq 'ON' ) {
+ $main::is_local_only = 1;
+ }
+ elsif ( defined $main::myvar{'bind_address'} ) {
+ my @addrs = split( /\s*,\s*/, $main::myvar{'bind_address'} );
+ my $all_local = 1;
+ foreach my $addr (@addrs) {
+ if ( $addr ne '127.0.0.1' && $addr ne '::1' && $addr ne 'localhost' && $addr !~ /\.(?:local|localhost)$/i ) {
+ $all_local = 0;
+ last;
+ }
+ }
+ $main::is_local_only = 1 if ( @addrs && $all_local );
+ }
+
+ # Run check functions
+ main::check_replication_advanced();
+ main::ssl_tls_recommendations();
+ main::security_recommendations();
+ main::check_security_2_0();
+
+ # We expect warnings and recommendations to be present
+ ok(grep(/does not specify hostname restrictions/i, @badprints), 'Warnings about wildcard users are printed');
+ ok(grep(/Current connection is NOT encrypted/i, @badprints), 'Warning about unencrypted connections is printed');
+ ok(grep(/gtid_mode is/i, @badprints), 'Warning about GTID replication is printed');
+ ok(grep(/TLS\/SSL is disabled/i, @badprints), 'Warning about disabled TLS/SSL in security_2_0 is printed');
+};
+
+subtest 'Bound to loopback (127.0.0.1) - warnings are hidden' => sub {
+ MySQLTuner::TestHelper::reset_state();
+ @badprints = ();
+ @infopoints = ();
+ @recommendations = ();
+ @main::generalrec = ();
+ $mocked_user_wildcard = 1;
+ $main::basic_password_files = 'non_existent_file';
+
+ # Loopback local-only setup
+ $main::myvar{'bind_address'} = '127.0.0.1';
+ $main::myvar{'skip_networking'} = 'OFF';
+ $main::myvar{'have_ssl'} = 'DISABLED';
+ $main::myvar{'gtid_mode'} = 'OFF';
+
+ # Trigger calculation
+ $main::is_local_only = 0;
+ if ( defined $main::myvar{'skip_networking'} && $main::myvar{'skip_networking'} eq 'ON' ) {
+ $main::is_local_only = 1;
+ }
+ elsif ( defined $main::myvar{'bind_address'} ) {
+ my @addrs = split( /\s*,\s*/, $main::myvar{'bind_address'} );
+ my $all_local = 1;
+ foreach my $addr (@addrs) {
+ if ( $addr ne '127.0.0.1' && $addr ne '::1' && $addr ne 'localhost' && $addr !~ /\.(?:local|localhost)$/i ) {
+ $all_local = 0;
+ last;
+ }
+ }
+ $main::is_local_only = 1 if ( @addrs && $all_local );
+ }
+
+ # Run check functions
+ main::check_replication_advanced();
+ main::ssl_tls_recommendations();
+ main::security_recommendations();
+ main::check_security_2_0();
+
+ # We expect warnings and recommendations to be skipped/hidden
+ ok(!grep(/does not specify hostname restrictions/i, @badprints), 'Wildcard user warnings are hidden');
+ ok(!grep(/Current connection is NOT encrypted/i, @badprints), 'Unencrypted connection warnings are hidden');
+ ok(!grep(/gtid_mode is/i, @badprints), 'Replication warnings are hidden');
+ ok(!grep(/TLS\/SSL is disabled/i, @badprints), 'TLS/SSL disabled warning in security_2_0 is hidden');
+
+ # We expect info points reporting the skipping behavior with valid documentation links
+ ok(grep({ /Skipping advanced replication checks/ && /https:\/\/dev\.mysql\.com/ } @infopoints), 'Replication skipping info message has reference link');
+ ok(grep({ /Skipping SSL\/TLS security recommendations/ && /https:\/\/dev\.mysql\.com/ } @infopoints), 'SSL skipping info message has reference link');
+};
+
+subtest 'Bound to custom local domains (db.local, app.localhost) - warnings are hidden' => sub {
+ MySQLTuner::TestHelper::reset_state();
+ @badprints = ();
+ @infopoints = ();
+ @recommendations = ();
+ @main::generalrec = ();
+ $mocked_user_wildcard = 1;
+ $main::basic_password_files = 'non_existent_file';
+
+ # Custom local setup
+ $main::myvar{'bind_address'} = 'db.local, app.localhost';
+ $main::myvar{'skip_networking'} = 'OFF';
+ $main::myvar{'have_ssl'} = 'DISABLED';
+ $main::myvar{'gtid_mode'} = 'OFF';
+
+ # Trigger calculation
+ $main::is_local_only = 0;
+ if ( defined $main::myvar{'skip_networking'} && $main::myvar{'skip_networking'} eq 'ON' ) {
+ $main::is_local_only = 1;
+ }
+ elsif ( defined $main::myvar{'bind_address'} ) {
+ my @addrs = split( /\s*,\s*/, $main::myvar{'bind_address'} );
+ my $all_local = 1;
+ foreach my $addr (@addrs) {
+ if ( $addr ne '127.0.0.1' && $addr ne '::1' && $addr ne 'localhost' && $addr !~ /\.(?:local|localhost)$/i ) {
+ $all_local = 0;
+ last;
+ }
+ }
+ $main::is_local_only = 1 if ( @addrs && $all_local );
+ }
+
+ # Run check functions
+ main::check_replication_advanced();
+ main::ssl_tls_recommendations();
+ main::security_recommendations();
+ main::check_security_2_0();
+
+ # We expect warnings to be hidden and local only status to be true
+ is($main::is_local_only, 1, 'Server identified as local-only for custom local domains');
+ ok(!grep(/does not specify hostname restrictions/i, @badprints), 'Wildcard user warnings are hidden');
+ ok(!grep(/Current connection is NOT encrypted/i, @badprints), 'Unencrypted connection warnings are hidden');
+};
+
+# Teardown: Restore original subroutines to avoid parallel run side effects
+*main::badprint = $orig_badprint;
+*main::infoprint = $orig_infoprint;
+*main::goodprint = $orig_goodprint;
+*main::debugprint = $orig_debugprint;
+*main::subheaderprint = $orig_subheaderprint;
+*main::push_recommendation = $orig_push_recommendation;
+*main::get_password_column_name = $orig_get_password_column_name;
+*main::select_one = $orig_select_one;
+*main::select_array = $orig_select_array;
+
+done_testing();
diff --git a/tests/ssl_tls_validation.t b/tests/ssl_tls_validation.t
index 5424e03dd..9d0b00a06 100644
--- a/tests/ssl_tls_validation.t
+++ b/tests/ssl_tls_validation.t
@@ -115,6 +115,31 @@ subtest 'ssl_tls_recommendations' => sub {
main::ssl_tls_recommendations();
ok(grep(/Insecure TLS versions enabled/, @bad_prints), "Detects TLS 1.1 as insecure");
ok(grep(/No modern TLS versions/, @bad_prints), "Detects lack of TLS 1.2+");
+
+ # Case 5: MariaDB 11.4+ Zero-Configuration TLS (ssl_cert and ssl_key empty, but TLS active)
+ MySQLTuner::TestHelper::reset_state();
+ %main::myvar = ( %main::myvar,
+ 'version' => '11.8.8-MariaDB',
+ 'have_ssl' => 'YES',
+ 'require_secure_transport' => 'ON',
+ 'tls_version' => 'TLSv1.2,TLSv1.3',
+ 'ssl_cert' => '',
+ 'ssl_key' => ''
+ );
+ local *main::select_one = sub {
+ my $query = shift;
+ if ($query =~ /Ssl_cipher/) {
+ return "Ssl_cipher\tTLS_AES_256_GCM_SHA384";
+ }
+ return "";
+ };
+ @main::generalrec = ();
+ @bad_prints = ();
+ @good_prints = ();
+ @recommendations = ();
+ main::ssl_tls_recommendations();
+ ok(grep(/TLS is active, but no explicit ssl_cert\/ssl_key paths are configured/, @good_prints), "MariaDB 11.4+ Zero-Configuration TLS prints info message");
+ is(scalar(grep(/No SSL certificates configured/, @bad_prints)), 0, "No missing certificates warning for zero-config TLS");
};
subtest 'check_local_certificates' => sub {
@@ -217,6 +242,49 @@ subtest 'check_remote_user_ssl' => sub {
@bad_prints = ();
main::check_remote_user_ssl();
ok(grep(/users can connect remotely without SSL/, @bad_prints), "Detects remote user without SSL (MySQL)");
+
+ # Case 3: Exclude role (MariaDB >= 10.4)
+ MySQLTuner::TestHelper::reset_state();
+ %main::myvar = ( %main::myvar, 'version' => '10.5.0-MariaDB' );
+ local *main::select_array = sub {
+ my ($query) = @_;
+ if ($query =~ /global_priv/ && $query =~ /is_role/) {
+ return (); # Excluded!
+ }
+ return ("'should_not_happen'\@'%'");
+ };
+ local *main::mysql_version_ge = sub { 1 };
+ @bad_prints = ();
+ @good_prints = ();
+ main::check_remote_user_ssl();
+ ok(grep(/All remote users have SSL enforcement active/, @good_prints), "Excludes role (MariaDB >= 10.4)");
+
+ # Case 4: Exclude role (MariaDB < 10.4 with IS_ROLE column)
+ MySQLTuner::TestHelper::reset_state();
+ %main::myvar = ( %main::myvar, 'version' => '10.2.0-MariaDB' );
+ local *main::select_one = sub {
+ my ($query) = @_;
+ if ($query =~ /IS_ROLE/) {
+ return 1; # IS_ROLE column exists
+ }
+ return 0;
+ };
+ local *main::select_array = sub {
+ my ($query) = @_;
+ if ($query =~ /mysql.user/ && $query =~ /IS_ROLE = 'N'/) {
+ return (); # Excluded!
+ }
+ return ("'should_not_happen'\@'%'");
+ };
+ local *main::mysql_version_ge = sub {
+ my ($maj, $min) = @_;
+ if ($maj == 10 && $min == 4) { return 0; } # Not >= 10.4
+ return 1;
+ };
+ @bad_prints = ();
+ @good_prints = ();
+ main::check_remote_user_ssl();
+ ok(grep(/All remote users have SSL enforcement active/, @good_prints), "Excludes role using IS_ROLE (MariaDB < 10.4)");
};
done_testing();
diff --git a/tests/repro_issue_20.t b/tests/test_issue_20.t
similarity index 100%
rename from tests/repro_issue_20.t
rename to tests/test_issue_20.t
diff --git a/tests/repro_issue_22.t b/tests/test_issue_22.t
similarity index 100%
rename from tests/repro_issue_22.t
rename to tests/test_issue_22.t
diff --git a/tests/issue_33.t b/tests/test_issue_33.t
similarity index 100%
rename from tests/issue_33.t
rename to tests/test_issue_33.t
diff --git a/tests/issue_36.t b/tests/test_issue_36.t
similarity index 100%
rename from tests/issue_36.t
rename to tests/test_issue_36.t
diff --git a/tests/issue_37.t b/tests/test_issue_37.t
similarity index 100%
rename from tests/issue_37.t
rename to tests/test_issue_37.t
diff --git a/tests/issue_42.t b/tests/test_issue_42.t
similarity index 100%
rename from tests/issue_42.t
rename to tests/test_issue_42.t
diff --git a/tests/test_issue_480.t b/tests/test_issue_480.t
new file mode 100644
index 000000000..11ef15504
--- /dev/null
+++ b/tests/test_issue_480.t
@@ -0,0 +1,59 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+
+# Load MySQLTuner
+require './mysqltuner.pl';
+require './tests/MySQLTuner/TestHelper.pm';
+
+# Force redefinition of essential subs
+no warnings 'redefine';
+*main::execute_system_command = sub { return ""; };
+*main::which = sub { return "/usr/bin/mysql" };
+*main::infoprint = sub { };
+*main::goodprint = sub { };
+*main::badprint = sub { };
+*main::subheaderprint = sub { };
+*main::debugprint = sub { };
+
+# Mock globals
+$main::good = '[OK]';
+$main::bad = '[!!]';
+$main::info = '[--]';
+$main::deb = '[DG]';
+$main::end = '';
+our %myvar;
+our %mystat;
+our %mycalc;
+our @adjvars;
+
+subtest 'table_open_cache_instances recommendation' => sub {
+ MySQLTuner::TestHelper::reset_state();
+
+ no warnings 'redefine';
+ local *main::logical_cpu_cores = sub { return 8 };
+ local *main::select_one = sub {
+ my $q = shift;
+ return 100 if $q =~ /COUNT\(\*\)/;
+ return 0;
+ };
+
+ main::mysql_stats();
+
+ ok(grep(/table_open_cache_instances \(=\s*4\)/, @main::adjvars), 'Suggested 4 instances for 8 CPU cores');
+
+ MySQLTuner::TestHelper::reset_state();
+ $main::mycalc{'table_cache_hit_rate'} = 10;
+ local *main::logical_cpu_cores = sub { return 64 };
+ local *main::select_one = sub {
+ my $q = shift;
+ return 100 if $q =~ /COUNT\(\*\)/;
+ return 0;
+ };
+ main::mysql_stats();
+ ok(grep(/table_open_cache_instances \(=\s*16\)/, @main::adjvars), 'Suggested max 16 instances for 64 CPU cores');
+};
+
+done_testing();
diff --git a/tests/repro_issue_490.t b/tests/test_issue_490.t
similarity index 100%
rename from tests/repro_issue_490.t
rename to tests/test_issue_490.t
diff --git a/tests/test_issue_517.t b/tests/test_issue_517.t
new file mode 100644
index 000000000..014c935e3
--- /dev/null
+++ b/tests/test_issue_517.t
@@ -0,0 +1,168 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+# Mock environment - set BEFORE require
+$main::devnull = '/dev/null';
+$main::is_win = 0;
+
+my $script_dir = dirname(abs_path(__FILE__));
+my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+
+{
+ local @ARGV = ();
+ no warnings 'redefine';
+ require $script;
+}
+
+# Mock helper functions to avoid printing or exiting
+{
+ no warnings 'redefine';
+ *main::infoprint = sub { diag "INFO: $_[0]" };
+ *main::badprint = sub { diag "BAD: $_[0]" };
+ *main::goodprint = sub { diag "GOOD: $_[0]" };
+ *main::debugprint = sub { diag "DEBUG: $_[0]" };
+ *main::subheaderprint = sub { diag "SUBHEADER: $_[0]" };
+ *main::get_pf_memory = sub { return 0 };
+ *main::get_gcache_memory = sub { return 0 };
+ *main::mysql_cloud_discovery = sub { return "none" };
+ *main::pretty_uptime = sub { return "1 day" };
+ *main::get_other_process_memory = sub { return 0 };
+ *main::select_array = sub { return () };
+ *main::select_one = sub { return 0 };
+ *main::execute_system_command = sub { return "" };
+ eval '*main::is_remote = sub () { return 0; };';
+}
+
+sub get_base_mock_vars {
+ return (
+ 'version' => '5.7.35',
+ 'version_comment' => 'MySQL Community Server',
+ 'read_buffer_size' => 1024,
+ 'read_rnd_buffer_size' => 1024,
+ 'sort_buffer_size' => 1024,
+ 'thread_stack' => 1024,
+ 'join_buffer_size' => 1024,
+ 'binlog_cache_size' => 1024,
+ 'tmp_table_size' => 1024,
+ 'max_heap_table_size' => 1024,
+ 'max_connections' => 10,
+ 'key_buffer_size' => 5000,
+ 'innodb_buffer_pool_size' => 10000,
+ 'innodb_additional_mem_pool_size' => 1024,
+ 'innodb_log_buffer_size' => 1024,
+ 'query_cache_size' => 64 * 1024 * 1024,
+ 'query_cache_type' => 'ON',
+ 'aria_pagecache_buffer_size' => 1024,
+ 'long_query_time' => 10,
+ 'log_bin' => 'OFF',
+ 'have_innodb' => 'YES',
+ 'open_files_limit' => 1024,
+ 'thread_cache_size' => 8,
+ 'concurrent_insert' => 'AUTO',
+ );
+}
+
+sub get_base_mock_stats {
+ return (
+ 'Questions' => 1500,
+ 'Max_used_connections' => 5,
+ 'Uptime' => 86400,
+ 'Qcache_free_memory' => 32 * 1024 * 1024,
+ 'Qcache_hits' => 1000,
+ 'Com_select' => 1500,
+ 'Qcache_lowmem_prunes' => 0,
+ 'Connections' => 100,
+ 'Aborted_connects' => 0,
+ 'Key_read_requests' => 100,
+ 'Key_reads' => 0,
+ 'Key_write_requests' => 100,
+ 'Key_writes' => 0,
+ 'Slow_queries' => 0,
+ 'Key_blocks_unused' => 100,
+ 'Table_locks_immediate' => 100,
+ 'Table_locks_waited' => 0,
+ 'Created_tmp_tables' => 10,
+ 'Opened_tables' => 10,
+ 'Open_tables' => 10,
+ 'Threads_cached' => 5,
+ 'Bytes_sent' => 1000,
+ 'Bytes_received' => 1000,
+ 'Threads_created' => 2,
+ );
+}
+
+subtest 'slow_query_log is OFF' => sub {
+ @main::generalrec = ();
+ @main::adjvars = ();
+ $main::physical_memory = 32 * 1024 * 1024 * 1024;
+ $main::swap_memory = 4 * 1024 * 1024 * 1024;
+
+ %main::myvar = (
+ get_base_mock_vars(),
+ slow_query_log => 'OFF',
+ log_slow_queries => 'ON', # Should be overridden by slow_query_log
+ );
+ %main::mystat = (
+ get_base_mock_stats(),
+ );
+ %main::mycalc = ();
+
+ eval { main::calculations(); main::mysql_stats(); };
+ ok(!$@, 'calculations() did not crash') or diag("Crashed: $@");
+
+ my $found = grep { /Enable the slow query log/ } @main::generalrec;
+ ok($found, 'Should recommend enabling slow query log when slow_query_log is OFF');
+};
+
+subtest 'slow_query_log is ON' => sub {
+ @main::generalrec = ();
+ @main::adjvars = ();
+ $main::physical_memory = 32 * 1024 * 1024 * 1024;
+ $main::swap_memory = 4 * 1024 * 1024 * 1024;
+
+ %main::myvar = (
+ get_base_mock_vars(),
+ slow_query_log => 'ON',
+ log_slow_queries => 'OFF', # Should be overridden by slow_query_log
+ );
+ %main::mystat = (
+ get_base_mock_stats(),
+ );
+ %main::mycalc = ();
+
+ eval { main::calculations(); main::mysql_stats(); };
+ ok(!$@, 'calculations() did not crash') or diag("Crashed: $@");
+
+ my $found = grep { /Enable the slow query log/ } @main::generalrec;
+ ok(!$found, 'Should not recommend enabling slow query log when slow_query_log is ON');
+};
+
+subtest 'log_slow_queries fallback is OFF' => sub {
+ @main::generalrec = ();
+ @main::adjvars = ();
+ $main::physical_memory = 32 * 1024 * 1024 * 1024;
+ $main::swap_memory = 4 * 1024 * 1024 * 1024;
+
+ %main::myvar = (
+ get_base_mock_vars(),
+ log_slow_queries => 'OFF',
+ );
+ %main::mystat = (
+ get_base_mock_stats(),
+ );
+ %main::mycalc = ();
+
+ eval { main::calculations(); main::mysql_stats(); };
+ ok(!$@, 'calculations() did not crash') or diag("Crashed: $@");
+
+ my $found = grep { /Enable the slow query log/ } @main::generalrec;
+ ok($found, 'Should recommend enabling slow query log when log_slow_queries fallback is OFF');
+};
+
+done_testing();
diff --git a/tests/repro_issue_605.t b/tests/test_issue_605.t
similarity index 100%
rename from tests/repro_issue_605.t
rename to tests/test_issue_605.t
diff --git a/tests/issue_770.t b/tests/test_issue_770.t
similarity index 100%
rename from tests/issue_770.t
rename to tests/test_issue_770.t
diff --git a/tests/issue_774.t b/tests/test_issue_774.t
similarity index 100%
rename from tests/issue_774.t
rename to tests/test_issue_774.t
diff --git a/tests/issue_777.t b/tests/test_issue_777.t
similarity index 100%
rename from tests/issue_777.t
rename to tests/test_issue_777.t
diff --git a/tests/issue_781.t b/tests/test_issue_781.t
similarity index 70%
rename from tests/issue_781.t
rename to tests/test_issue_781.t
index ebb2e3596..58ebca9e6 100644
--- a/tests/issue_781.t
+++ b/tests/test_issue_781.t
@@ -105,4 +105,40 @@ subtest 'Issue 781 - Passwords with complex characters' => sub {
diag "Commands tried: " . join(", ", @commands_executed) unless $found;
};
+subtest 'Issue 781 - Socket option is preserved when host is default' => sub {
+ @commands_executed = ();
+ %main::opt = (
+ %main::opt,
+ 'user' => 'tuneruser',
+ 'pass' => 'tunerpass',
+ 'socket' => '/var/lib/mysql/mysql.sock',
+ 'host' => undef,
+ 'noask' => 1,
+ );
+
+ eval { main::mysql_setup(); };
+
+ my $found = grep { /-S \/var\/lib\/mysql\/mysql\.sock/ } @commands_executed;
+ ok($found, "mysql_setup should connect using the specified socket path when host is default");
+ diag "Commands tried: " . join(", ", @commands_executed) unless $found;
+};
+
+subtest 'Issue 781 - Socket option is preserved when host is explicitly specified' => sub {
+ @commands_executed = ();
+ %main::opt = (
+ %main::opt,
+ 'user' => 'tuneruser',
+ 'pass' => 'tunerpass',
+ 'socket' => '/var/lib/mysql/mysql.sock',
+ 'host' => 'localhost',
+ 'noask' => 1,
+ );
+
+ eval { main::mysql_setup(); };
+
+ my $found = grep { /-S \/var\/lib\/mysql\/mysql\.sock/ && /-h localhost/ } @commands_executed;
+ ok($found, "mysql_setup should connect using both specified host and socket path");
+ diag "Commands tried: " . join(", ", @commands_executed) unless $found;
+};
+
done_testing();
diff --git a/tests/issue_782.t b/tests/test_issue_782.t
similarity index 100%
rename from tests/issue_782.t
rename to tests/test_issue_782.t
diff --git a/tests/issue_783.t b/tests/test_issue_783.t
similarity index 100%
rename from tests/issue_783.t
rename to tests/test_issue_783.t
diff --git a/tests/test_issue_796.t b/tests/test_issue_796.t
new file mode 100644
index 000000000..7617264b8
--- /dev/null
+++ b/tests/test_issue_796.t
@@ -0,0 +1,116 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+
+my $script = File::Spec->rel2abs(File::Spec->catfile(dirname(__FILE__), '..', 'mysqltuner.pl'));
+
+# Mocking and loading mysqltuner.pl
+{
+ local @ARGV = ();
+ no warnings 'redefine';
+ require $script;
+}
+
+# Silence console printing and capture output for assertion
+my @badprints;
+my @infoprints;
+{
+ no warnings 'redefine';
+ no warnings 'once';
+ *main::badprint = sub { push @badprints, $_[0] };
+ *main::goodprint = sub { };
+ *main::debugprint = sub { };
+ *main::infoprint = sub { push @infoprints, $_[0] };
+ *main::subheaderprint = sub { };
+ *main::prettyprint = sub { };
+}
+
+# Mock command transport prefix
+{
+ no warnings 'redefine';
+ no warnings 'once';
+ *main::get_transport_prefix = sub { return '' };
+}
+
+sub reset_state {
+ # Initialize basic CLI options metadata defaults
+ %main::opt = ();
+ foreach my $o (keys %main::CLI_METADATA) {
+ my ($p) = split /\|/, $o;
+ $p =~ s/[!+=:].*$//;
+ $main::opt{$p} = $main::CLI_METADATA{$o}->{default} // 0;
+ }
+ @badprints = ();
+ @infoprints = ();
+ $main::physical_memory = undef;
+ $main::swap_memory = undef;
+}
+
+subtest 'Issue 796 - Remote host with --forcemem specified' => sub {
+ reset_state();
+
+ $main::opt{'host'} = '192.168.1.100';
+ $main::opt{'forcemem'} = 2000; # 2000 MB
+ $main::opt{'forceswap'} = 512;
+
+ # Simulate setup_environment and mysql_setup checks
+ $main::doremote = main::is_remote();
+ is($main::doremote, 1, "Connection to remote IP must be remote");
+
+ # os_setup logic
+ main::os_setup();
+
+ # Verification:
+ # 2000 MB must equal 2097152000 bytes (2.0G)
+ is($main::physical_memory, 2097152000, "Physical memory must be exactly calculated in bytes (2000 * 1024 * 1024)");
+ is(main::hr_bytes($main::physical_memory), "2.0G", "Pretty memory output must be 2.0G (and not 2.0K regression)");
+ is($main::swap_memory, 512 * 1048576, "Swap memory must be calculated in bytes");
+};
+
+subtest 'Issue 796 - Remote host WITHOUT --forcemem specified' => sub {
+ reset_state();
+
+ $main::opt{'host'} = '192.168.1.100';
+ $main::opt{'forcemem'} = 0;
+ $main::opt{'forceswap'} = 0;
+
+ # Mocking DB setup behavior where if --forcemem isn't specified on a remote host,
+ # it warns the user and defaults to 1024 MB.
+ if ( !$main::opt{'forcemem'} && main::is_remote() == 1 ) {
+ main::badprint("The --forcemem option is required for remote connections");
+ main::badprint("Assuming RAM memory is 1Gb for simplify remote connection usage");
+ $main::opt{'forcemem'} = 1024;
+ }
+
+ # Verify that warnings were printed and forcemem defaulted
+ ok(grep(/The --forcemem option is required/, @badprints), "Required --forcemem warning was printed");
+ is($main::opt{'forcemem'}, 1024, "forcemem should default to 1024 MB");
+
+ # os_setup logic
+ main::os_setup();
+
+ is($main::physical_memory, 1024 * 1048576, "Physical memory must default to 1GB in bytes");
+ is(main::hr_bytes($main::physical_memory), "1.0G", "Pretty memory output must be 1.0G");
+};
+
+subtest 'Issue 796 - Local host WITHOUT --forcemem specified' => sub {
+ reset_state();
+
+ $main::opt{'host'} = '127.0.0.1';
+ $main::opt{'forcemem'} = 0;
+
+ $main::doremote = main::is_remote();
+ is($main::doremote, 0, "Connection to 127.0.0.1 must not be remote");
+
+ if ( !$main::opt{'forcemem'} && main::is_remote() == 1 ) {
+ $main::opt{'forcemem'} = 1024;
+ }
+
+ is($main::opt{'forcemem'}, 0, "forcemem should remain 0 for local host");
+};
+
+done_testing();
diff --git a/tests/test_issue_810.t b/tests/test_issue_810.t
new file mode 100644
index 000000000..b7d977a91
--- /dev/null
+++ b/tests/test_issue_810.t
@@ -0,0 +1,63 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+
+my $script = File::Spec->rel2abs(File::Spec->catfile(dirname(__FILE__), '..', 'mysqltuner.pl'));
+
+# Mocking and loading mysqltuner.pl
+{
+ local @ARGV = ();
+ no warnings 'redefine';
+ require $script;
+}
+
+# Silence console printing from within the test
+{
+ no warnings 'redefine';
+ no warnings 'once';
+ *main::badprint = sub { };
+ *main::goodprint = sub { };
+ *main::debugprint = sub { };
+ *main::infoprint = sub { };
+ *main::subheaderprint = sub { };
+ *main::prettyprint = sub { };
+}
+
+# Mock transport and system commands
+{
+ no warnings 'redefine';
+ no warnings 'once';
+ *main::get_transport_prefix = sub { return '' };
+ *main::execute_system_command = sub { return '' };
+}
+
+subtest 'Issue 810 - Verify forcemem MB interpretation correctness (regression test)' => sub {
+ # Initialize basic CLI options metadata defaults
+ foreach my $o (keys %main::CLI_METADATA) {
+ my ($p) = split /\|/, $o;
+ $p =~ s/[!+=:].*$//;
+ $main::opt{$p} //= $main::CLI_METADATA{$o}->{default};
+ }
+
+ # Simulate `--forcemem 32768` (passed in MB for a 32GB system)
+ $main::opt{'forcemem'} = 32768;
+ $main::opt{'forceswap'} = 0;
+
+ # Run OS setup logic to calculate physical memory
+ main::os_setup();
+
+ # Verification:
+ # 32768 MB must equal 34,359,738,368 bytes (32 GB)
+ is($main::physical_memory, 34359738368, "Physical memory must be correctly calculated in bytes (32768 * 1024 * 1024)");
+
+ # Verify that human readable formatting outputs 32.0G and NOT 32.0K (which occurred before the regression fix)
+ my $pretty = main::hr_bytes($main::physical_memory);
+ is($pretty, "32.0G", "Pretty memory output must be 32.0G");
+ isnt($pretty, "32.0K", "Pretty memory output must not be 32.0K (regression check)");
+};
+
+done_testing();
diff --git a/tests/repro_issue_863.t b/tests/test_issue_863.t
similarity index 100%
rename from tests/repro_issue_863.t
rename to tests/test_issue_863.t
diff --git a/tests/issue_863_enhanced.t b/tests/test_issue_863_enhanced.t
similarity index 100%
rename from tests/issue_863_enhanced.t
rename to tests/test_issue_863_enhanced.t
diff --git a/tests/issue_864.t b/tests/test_issue_864.t
similarity index 100%
rename from tests/issue_864.t
rename to tests/test_issue_864.t
diff --git a/tests/issue_869.t b/tests/test_issue_869.t
similarity index 100%
rename from tests/issue_869.t
rename to tests/test_issue_869.t
diff --git a/tests/issue_881_887.t b/tests/test_issue_881_887.t
similarity index 100%
rename from tests/issue_881_887.t
rename to tests/test_issue_881_887.t
diff --git a/tests/issue_888.t b/tests/test_issue_888.t
similarity index 100%
rename from tests/issue_888.t
rename to tests/test_issue_888.t
diff --git a/tests/issue_896.t b/tests/test_issue_896.t
similarity index 100%
rename from tests/issue_896.t
rename to tests/test_issue_896.t
diff --git a/tests/issue_904.t b/tests/test_issue_904.t
similarity index 100%
rename from tests/issue_904.t
rename to tests/test_issue_904.t
diff --git a/tests/issue_913.t b/tests/test_issue_913.t
similarity index 100%
rename from tests/issue_913.t
rename to tests/test_issue_913.t
diff --git a/tests/issue_923.t b/tests/test_issue_923.t
similarity index 69%
rename from tests/issue_923.t
rename to tests/test_issue_923.t
index c2d0e8938..40adb2b56 100644
--- a/tests/issue_923.t
+++ b/tests/test_issue_923.t
@@ -3,7 +3,7 @@ use strict;
use warnings;
no warnings 'once';
-use Test::More tests => 3;
+use Test::More tests => 4;
use File::Basename;
use File::Spec;
use Cwd 'abs_path';
@@ -26,8 +26,8 @@ no warnings 'redefine';
no warnings 'uninitialized';
*main::debugprint = sub { };
*main::is_int = sub { return $_[0] && $_[0] =~ /^\d+$/ };
-*main::human_size = sub { return $_[0] };
-*main::hr_bytes = sub { return $_[0] };
+*main::human_size = sub { return $_[0] // '0B' };
+*main::hr_bytes = sub { return $_[0] // '0B' };
*main::percentage = sub { return 0 };
*main::badprint = sub { };
*main::infoprint = sub { };
@@ -213,6 +213,7 @@ subtest 'temptable_max_mmap disk space recommendations check' => sub {
'pct_writes' => 50,
'pct_temp_disk' => 0,
'thread_cache_hit_rate' => 90,
+ 'table_cache_hit_rate' => 99,
'total_sorts' => 0,
'joins_without_indexes_per_day' => 0,
);
@@ -243,4 +244,99 @@ subtest 'temptable_max_mmap disk space recommendations check' => sub {
ok(!$badprint_called, "no warning when temptable_max_mmap is compatible");
};
+subtest 'Total buffers: temptable output format check' => sub {
+ no warnings 'redefine';
+ my @captured_infoprints = ();
+ local *main::infoprint = sub { push @captured_infoprints, $_[0] };
+ local *main::is_remote = sub { return 0 };
+
+ local *main::hr_bytes = sub {
+ my $val = shift;
+ return "2.0G" if $val == 2147483648;
+ return "19.1K" if $val == 19106 || $val == 19096;
+ return "6.0K" if $val == 6144;
+ return "1.0G" if $val == 1073747968;
+ return $val;
+ };
+
+ %main::myvar = (
+ 'version' => '8.0.30',
+ 'read_buffer_size' => 1024,
+ 'read_rnd_buffer_size' => 1024,
+ 'sort_buffer_size' => 1024,
+ 'thread_stack' => 1024,
+ 'join_buffer_size' => 1024,
+ 'binlog_cache_size' => 1024,
+ 'tmp_table_size' => 1024 * 1024 * 1024, # 1 GB
+ 'max_heap_table_size' => 1024 * 1024 * 1024, # 1 GB
+ 'max_connections' => 10,
+ 'temptable_max_ram' => 2 * 1024 * 1024 * 1024, # 2 GB
+ 'internal_tmp_mem_storage_engine' => 'TempTable',
+ 'key_buffer_size' => 5000,
+ 'innodb_buffer_pool_size' => 10000,
+ 'innodb_additional_mem_pool_size' => 1024,
+ 'innodb_log_buffer_size' => 1024,
+ 'query_cache_size' => 1024,
+ 'aria_pagecache_buffer_size' => 1024,
+ 'long_query_time' => 10,
+ 'log_bin' => 'OFF',
+ 'have_innodb' => 'YES',
+ 'open_files_limit' => 1024,
+ 'thread_cache_size' => 8,
+ 'concurrent_insert' => 'AUTO',
+ 'query_cache_type' => 'OFF',
+ );
+ %main::mystat = (
+ 'Questions' => 100,
+ 'Max_used_connections' => 5,
+ 'Uptime' => 86400,
+ 'Qcache_hits' => 100,
+ 'Com_select' => 100,
+ 'Qcache_free_memory' => 512,
+ 'Qcache_lowmem_prunes' => 0,
+ 'Connections' => 100,
+ 'Aborted_connects' => 0,
+ 'Key_read_requests' => 100,
+ 'Key_reads' => 0,
+ 'Key_write_requests' => 100,
+ 'Key_writes' => 0,
+ 'Slow_queries' => 0,
+ 'Key_blocks_unused' => 100,
+ 'Table_locks_immediate' => 100,
+ 'Table_locks_waited' => 0,
+ 'Created_tmp_tables' => 10,
+ 'Opened_tables' => 10,
+ 'Open_tables' => 10,
+ 'Threads_cached' => 5,
+ 'Threads_created' => 2,
+ 'Bytes_sent' => 1000,
+ 'Bytes_received' => 1000,
+ 'Created_tmp_disk_tables' => 0,
+ );
+ %main::mycalc = ();
+
+ # Re-run calculations to populate server_buffers and per_thread_buffers
+ main::calculations();
+
+ # Now run mysql_stats to output memory lines
+ eval { main::mysql_stats(); };
+ if ($@) {
+ fail("mysql_stats crashed: $@");
+ } else {
+ my ($total_buffers_line) = grep { /^Total buffers:/ } @captured_infoprints;
+ ok($total_buffers_line, "Found Total buffers line in output");
+ is($total_buffers_line, "Total buffers: 19.1K global + 2.0G temptable + 6.0K per thread (10 max threads)",
+ "Total buffers line has correct format with temptable");
+ }
+
+ # Now verify it falls back to standard output when internal_tmp_mem_storage_engine is MEMORY
+ @captured_infoprints = ();
+ $main::myvar{'internal_tmp_mem_storage_engine'} = 'MEMORY';
+ main::calculations();
+ eval { main::mysql_stats(); };
+ my ($fallback_line) = grep { /^Total buffers:/ } @captured_infoprints;
+ is($fallback_line, "Total buffers: 19.1K global + 1.0G per thread (10 max threads)",
+ "Falls back to standard Total buffers line when storage engine is MEMORY");
+};
+
1;
diff --git a/tests/test_issue_927.t b/tests/test_issue_927.t
new file mode 100644
index 000000000..342cc8c21
--- /dev/null
+++ b/tests/test_issue_927.t
@@ -0,0 +1,241 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+
+use Test::More tests => 2;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+# Load mysqltuner.pl as a library
+my $script_dir = dirname( abs_path(__FILE__) );
+my $script =
+ abs_path( File::Spec->catfile( $script_dir, '..', 'mysqltuner.pl' ) );
+require $script;
+require './tests/MySQLTuner/TestHelper.pm';
+
+# Mock global variables
+our %myvar;
+our %mystat;
+our %mycalc;
+our $physical_memory;
+our $swap_memory;
+our @generalrec;
+our @adjvars;
+
+# Setup common mocks globally
+no warnings 'redefine';
+no warnings 'uninitialized';
+*main::debugprint = sub { };
+*main::is_int = sub { return $_[0] && $_[0] =~ /^\d+$/ };
+*main::human_size = sub { return $_[0] // '0B' };
+*main::hr_bytes = sub { return $_[0] // '0B' };
+*main::hr_bytes_rnd = sub { return $_[0] // '0B' };
+*main::hr_num = sub {
+ my $val = shift;
+ return 0 unless defined $val;
+
+ # simple formatting for tests
+ 1 while $val =~ s/(\d+)(\d{3})/$1,$2/;
+ return $val;
+};
+*main::percentage = sub { return 0 };
+*main::select_one = sub { return 0 };
+*main::select_array = sub { return () };
+*main::mysql_version_ge = sub { return 1 };
+*main::mysql_version_le = sub { return 0 };
+*main::mysql_version_eq = sub { return 0 };
+*main::execute_system_command = sub { return "" };
+*main::get_pf_memory = sub { return 0 };
+*main::get_gcache_memory = sub { return 0 };
+*main::is_remote = sub () { return 0 };
+*main::mysql_cloud_discovery = sub { return "none" };
+*main::pretty_uptime = sub { return "1 day" };
+*main::get_other_process_memory = sub { return 0 };
+
+subtest 'Query cache efficiency on standard MySQL' => sub {
+ $main::physical_memory = 32 * 1024 * 1024 * 1024;
+ $main::swap_memory = 4 * 1024 * 1024 * 1024;
+
+ %main::myvar = (
+ 'version' => '5.7.35',
+ 'version_comment' => 'MySQL Community Server',
+ 'read_buffer_size' => 1024,
+ 'read_rnd_buffer_size' => 1024,
+ 'sort_buffer_size' => 1024,
+ 'thread_stack' => 1024,
+ 'join_buffer_size' => 1024,
+ 'binlog_cache_size' => 1024,
+ 'tmp_table_size' => 1024,
+ 'max_heap_table_size' => 1024,
+ 'max_connections' => 10,
+ 'key_buffer_size' => 5000,
+ 'innodb_buffer_pool_size' => 10000,
+ 'innodb_additional_mem_pool_size' => 1024,
+ 'innodb_log_buffer_size' => 1024,
+ 'query_cache_size' => 64 * 1024 * 1024,
+ 'query_cache_type' => 'ON',
+ 'aria_pagecache_buffer_size' => 1024,
+ 'long_query_time' => 10,
+ 'log_bin' => 'OFF',
+ 'have_innodb' => 'YES',
+ 'open_files_limit' => 1024,
+ 'thread_cache_size' => 8,
+ 'concurrent_insert' => 'AUTO',
+ );
+ %main::mystat = (
+ 'Questions' => 1500,
+ 'Max_used_connections' => 5,
+ 'Uptime' => 86400,
+ 'Qcache_free_memory' => 32 * 1024 * 1024,
+ 'Qcache_hits' => 1000,
+ 'Com_select' => 1500,
+ 'Qcache_lowmem_prunes' => 0,
+ 'Connections' => 100,
+ 'Aborted_connects' => 0,
+ 'Key_read_requests' => 100,
+ 'Key_reads' => 0,
+ 'Key_write_requests' => 100,
+ 'Key_writes' => 0,
+ 'Slow_queries' => 0,
+ 'Key_blocks_unused' => 100,
+ 'Table_locks_immediate' => 100,
+ 'Table_locks_waited' => 0,
+ 'Created_tmp_tables' => 10,
+ 'Opened_tables' => 10,
+ 'Open_tables' => 10,
+ 'Threads_cached' => 5,
+ 'Bytes_sent' => 1000,
+ 'Bytes_received' => 1000,
+ 'Threads_created' => 2,
+ );
+ %main::mycalc = ();
+ @main::generalrec = ();
+ @main::adjvars = ();
+
+ eval { main::calculations(); };
+ ok( !$@, "calculations completed without crash" );
+ is( $main::mycalc{'query_cache_efficiency'}, "40.0",
+"MySQL efficiency calculation is Qcache_hits / (Com_select + Qcache_hits) = 40%"
+ );
+
+ my @good_prints;
+ my @bad_prints;
+ my @info_prints;
+ local *main::goodprint = sub { push @good_prints, $_[0] };
+ local *main::badprint = sub { push @bad_prints, $_[0] };
+ local *main::infoprint = sub { push @info_prints, $_[0] };
+ local *main::subheaderprint = sub { };
+
+ eval { main::mysql_stats(); };
+ ok( !$@, "mysql_stats completed without crash" );
+
+ my $found_msg = "";
+ for my $msg ( @good_prints, @bad_prints ) {
+ if ( $msg =~ /Query cache efficiency/ ) {
+ $found_msg = $msg;
+ last;
+ }
+ }
+
+ like(
+ $found_msg,
+ qr/Query cache efficiency: 40\.0% \(1,000 cached \/ 2,500 selects\)/,
+"Display text shows correct MySQL hit ratio and total selects (2,500 selects)"
+ );
+};
+
+subtest 'Query cache efficiency on MariaDB' => sub {
+ $main::physical_memory = 32 * 1024 * 1024 * 1024;
+ $main::swap_memory = 4 * 1024 * 1024 * 1024;
+
+ %main::myvar = (
+ 'version' => '10.11.14-MariaDB',
+ 'version_comment' => 'MariaDB Server',
+ 'read_buffer_size' => 1024,
+ 'read_rnd_buffer_size' => 1024,
+ 'sort_buffer_size' => 1024,
+ 'thread_stack' => 1024,
+ 'join_buffer_size' => 1024,
+ 'binlog_cache_size' => 1024,
+ 'tmp_table_size' => 1024,
+ 'max_heap_table_size' => 1024,
+ 'max_connections' => 10,
+ 'key_buffer_size' => 5000,
+ 'innodb_buffer_pool_size' => 10000,
+ 'innodb_additional_mem_pool_size' => 1024,
+ 'innodb_log_buffer_size' => 1024,
+ 'query_cache_size' => 64 * 1024 * 1024,
+ 'query_cache_type' => 'ON',
+ 'aria_pagecache_buffer_size' => 1024,
+ 'long_query_time' => 10,
+ 'log_bin' => 'OFF',
+ 'have_innodb' => 'YES',
+ 'open_files_limit' => 1024,
+ 'thread_cache_size' => 8,
+ 'concurrent_insert' => 'AUTO',
+ );
+ %main::mystat = (
+ 'Questions' => 1500,
+ 'Max_used_connections' => 5,
+ 'Uptime' => 86400,
+ 'Qcache_free_memory' => 32 * 1024 * 1024,
+ 'Qcache_hits' => 1000,
+ 'Com_select' => 1500,
+ 'Qcache_lowmem_prunes' => 0,
+ 'Connections' => 100,
+ 'Aborted_connects' => 0,
+ 'Key_read_requests' => 100,
+ 'Key_reads' => 0,
+ 'Key_write_requests' => 100,
+ 'Key_writes' => 0,
+ 'Slow_queries' => 0,
+ 'Key_blocks_unused' => 100,
+ 'Table_locks_immediate' => 100,
+ 'Table_locks_waited' => 0,
+ 'Created_tmp_tables' => 10,
+ 'Opened_tables' => 10,
+ 'Open_tables' => 10,
+ 'Threads_cached' => 5,
+ 'Bytes_sent' => 1000,
+ 'Bytes_received' => 1000,
+ 'Threads_created' => 2,
+ );
+ %main::mycalc = ();
+ @main::generalrec = ();
+ @main::adjvars = ();
+
+ eval { main::calculations(); };
+ ok( !$@, "calculations completed without crash" );
+ is( $main::mycalc{'query_cache_efficiency'},
+ "66.7",
+ "MariaDB efficiency calculation is Qcache_hits / Com_select = 66.7%" );
+
+ my @good_prints;
+ my @bad_prints;
+ my @info_prints;
+ local *main::goodprint = sub { push @good_prints, $_[0] };
+ local *main::badprint = sub { push @bad_prints, $_[0] };
+ local *main::infoprint = sub { push @info_prints, $_[0] };
+ local *main::subheaderprint = sub { };
+
+ eval { main::mysql_stats(); };
+ ok( !$@, "mysql_stats completed without crash" );
+
+ my $found_msg = "";
+ for my $msg ( @good_prints, @bad_prints ) {
+ if ( $msg =~ /Query cache efficiency/ ) {
+ $found_msg = $msg;
+ last;
+ }
+ }
+
+ like(
+ $found_msg,
+ qr/Query cache efficiency: 66\.7% \(1,000 cached \/ 1,500 selects\)/,
+"Display text shows correct MariaDB hit ratio and total selects without double-counting (1,500 selects)"
+ );
+};
+
+1;
diff --git a/tests/test_issue_938.t b/tests/test_issue_938.t
new file mode 100644
index 000000000..7580ec8ac
--- /dev/null
+++ b/tests/test_issue_938.t
@@ -0,0 +1,83 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+
+# Setup environment for MySQLTuner
+$main::is_remote = 0;
+$main::mysqlcmd = "mysql";
+$main::mysqllogin = "";
+$main::remotestring = "";
+$main::devnull = File::Spec->devnull();
+
+# Load the script first to get the subroutines
+{
+ local @ARGV = ();
+ no warnings 'redefine';
+ require './mysqltuner.pl';
+}
+
+my @mock_output;
+
+# Mock functions
+{
+ no warnings 'redefine';
+ *main::infoprint = sub { push @mock_output, "INFO: $_[0]" };
+ *main::badprint = sub { push @mock_output, "BAD: $_[0]" };
+ *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" };
+ *main::debugprint = sub { push @mock_output, "DEBUG: $_[0]" };
+ *main::subheaderprint = sub { push @mock_output, "SUBHEADER: $_[0]" };
+ *main::prettyprint = sub { };
+}
+
+subtest 'Issue 938 - InnoDB Write Log efficiency gated on waits' => sub {
+ # Case 1: Write efficiency < 90% but log waits = 0
+ @main::adjvars = ();
+ @main::generalrec = ();
+ @mock_output = ();
+
+ $main::myvar{'have_innodb'} = 'YES';
+ $main::myvar{'innodb_version'} = '11.8.8';
+ $main::myvar{'innodb_log_buffer_size'} = 16 * 1024 * 1024;
+
+ $main::mystat{'Innodb_log_write_requests'} = 1000;
+ $main::mystat{'Innodb_log_writes'} = 200; # 80% efficiency
+ $main::mystat{'Innodb_log_waits'} = 0; # 0 waits
+
+ $main::mycalc{'pct_write_efficiency'} = main::percentage(
+ ( $main::mystat{'Innodb_log_write_requests'} - $main::mystat{'Innodb_log_writes'} ),
+ $main::mystat{'Innodb_log_write_requests'}
+ );
+
+ main::mysql_innodb();
+
+ ok(grep(/GOOD: InnoDB Write Log efficiency/, @mock_output), "Prints write efficiency as GOOD when log waits are 0");
+ ok(!grep(/BAD: InnoDB Write Log efficiency/, @mock_output), "Does not print write efficiency as BAD when log waits are 0");
+ my $rec = (grep { /innodb_log_buffer_size/ } @main::adjvars)[0];
+ ok(!$rec, "No recommendation for innodb_log_buffer_size when log waits are 0");
+
+ # Case 2: Write efficiency < 90% and log waits > 0
+ @main::adjvars = ();
+ @main::generalrec = ();
+ @mock_output = ();
+
+ $main::mystat{'Innodb_log_write_requests'} = 1000;
+ $main::mystat{'Innodb_log_writes'} = 200; # 80% efficiency
+ $main::mystat{'Innodb_log_waits'} = 10; # >0 waits
+
+ $main::mycalc{'pct_write_efficiency'} = main::percentage(
+ ( $main::mystat{'Innodb_log_write_requests'} - $main::mystat{'Innodb_log_writes'} ),
+ $main::mystat{'Innodb_log_write_requests'}
+ );
+
+ main::mysql_innodb();
+
+ ok(grep(/BAD: InnoDB Write Log efficiency/, @mock_output), "Prints write efficiency as BAD when log waits are > 0");
+ my $rec_with_waits = (grep { /innodb_log_buffer_size/ } @main::adjvars)[0];
+ ok($rec_with_waits, "Recommends innodb_log_buffer_size when log waits are > 0");
+};
+
+done_testing();
diff --git a/tests/unit_cli_helpers.t b/tests/unit_cli_helpers.t
new file mode 100644
index 000000000..1644300c6
--- /dev/null
+++ b/tests/unit_cli_helpers.t
@@ -0,0 +1,153 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+our $mock_exit_active = 0;
+BEGIN {
+ *CORE::GLOBAL::exit = sub {
+ my $code = shift // 0;
+ if ($mock_exit_active) {
+ die "MOCKED_EXIT: $code\n";
+ }
+ CORE::exit($code);
+ };
+}
+
+$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ };
+
+# Declare globals before loading script
+our @adjvars;
+our @generalrec;
+our @modeling;
+our @sysrec;
+our @secrec;
+our %opt;
+our %myvar;
+our %mystat;
+our %mycalc;
+our %result;
+our $mysqlcmd;
+our $devnull;
+
+my $script_dir = dirname(abs_path(__FILE__));
+my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+{
+ local @ARGV = ();
+ no warnings 'redefine';
+ require $script;
+}
+
+my @mock_output;
+sub reset_mocks {
+ @mock_output = ();
+ @main::generalrec = ();
+ @main::adjvars = ();
+ @main::modeling = ();
+ @main::sysrec = ();
+ @main::secrec = ();
+ %main::result = ();
+ %main::opt = ();
+ %main::myvar = ();
+}
+
+{
+ no warnings 'redefine';
+ *main::infoprint = sub { push @mock_output, "INFO: $_[0]" };
+ *main::badprint = sub { push @mock_output, "BAD: $_[0]" };
+ *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" };
+ *main::debugprint = sub { };
+ *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" };
+ *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" };
+}
+
+# =====================================================================
+# 1. parse_cli_args
+# =====================================================================
+subtest 'parse_cli_args' => sub {
+ reset_mocks();
+ local @ARGV = ('--host', '192.168.1.100', '--user', 'tuner_user');
+ main::parse_cli_args();
+ is($main::opt{host}, '192.168.1.100', 'host option parsed');
+ is($main::opt{user}, 'tuner_user', 'user option parsed');
+};
+
+subtest 'parse_cli_args invalid option' => sub {
+ reset_mocks();
+ local @ARGV = ('--invalid-opt-name-xyz');
+ local $mock_exit_active = 1;
+ # Capture STDERR or redirect it to avoid clutter
+ open my $old_err, '>&', \*STDERR or die "Can't dup STDERR: $!";
+ close STDERR;
+ open STDERR, '>', File::Spec->devnull() or die "Can't redirect STDERR: $!";
+ eval {
+ main::parse_cli_args();
+ };
+ my $err = $@;
+ # Restore STDERR
+ open STDERR, '>&', $old_err or die "Can't restore STDERR: $!";
+ close $old_err;
+ like($err, qr/MOCKED_EXIT: 1/, 'exit code 1 on invalid option');
+};
+
+# =====================================================================
+# 2. show_help
+# =====================================================================
+subtest 'show_help' => sub {
+ reset_mocks();
+ local $mock_exit_active = 1;
+ my $help_output = '';
+ # Capture STDOUT
+ open my $old_out, '>&', \*STDOUT or die "Can't dup STDOUT: $!";
+ close STDOUT;
+ open STDOUT, '>', \$help_output or die "Can't redirect STDOUT: $!";
+ eval {
+ main::show_help();
+ };
+ my $err = $@;
+ # Restore STDOUT
+ open STDOUT, '>&', $old_out or die "Can't restore STDOUT: $!";
+ close $old_out;
+ like($err, qr/MOCKED_EXIT: 0/, 'exit code 0 on show_help');
+ like($help_output, qr/Usage: \.\/mysqltuner\.pl/, 'help usage output present');
+ like($help_output, qr/CONNECTION AND AUTHENTICATION/, 'help categories present');
+};
+
+# =====================================================================
+# 3. get_http_cli
+# =====================================================================
+subtest 'get_http_cli' => sub {
+ reset_mocks();
+ # Case 1: curl exists
+ {
+ no warnings 'redefine';
+ local *main::which = sub {
+ my ($cmd) = @_;
+ return "/usr/bin/curl\n" if $cmd eq 'curl';
+ return "";
+ };
+ is(main::get_http_cli(), "/usr/bin/curl", 'curl detected');
+ }
+ # Case 2: wget exists (curl does not)
+ {
+ no warnings 'redefine';
+ local *main::which = sub {
+ my ($cmd) = @_;
+ return "/usr/bin/wget\n" if $cmd eq 'wget';
+ return "";
+ };
+ is(main::get_http_cli(), "/usr/bin/wget", 'wget fallback works');
+ }
+ # Case 3: neither exists
+ {
+ no warnings 'redefine';
+ local *main::which = sub { return ""; };
+ is(main::get_http_cli(), "", 'empty returned if neither exists');
+ }
+};
+
+done_testing();
diff --git a/tests/unit_client_privileges.t b/tests/unit_client_privileges.t
new file mode 100644
index 000000000..1086ebe3d
--- /dev/null
+++ b/tests/unit_client_privileges.t
@@ -0,0 +1,119 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ };
+
+# Declare globals before loading script
+our @adjvars;
+our @generalrec;
+our @modeling;
+our @sysrec;
+our @secrec;
+our %opt;
+our %myvar;
+our %mystat;
+our %mycalc;
+our %result;
+our $mysqlcmd;
+
+my $script_dir = dirname(abs_path(__FILE__));
+my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+{
+ local @ARGV = ();
+ no warnings 'redefine';
+ require $script;
+}
+
+my @mock_output;
+sub reset_mocks {
+ @mock_output = ();
+ @main::generalrec = ();
+ @main::adjvars = ();
+ @main::modeling = ();
+ @main::sysrec = ();
+ @main::secrec = ();
+ %main::result = ();
+ %main::opt = ();
+ %main::myvar = ();
+}
+
+{
+ no warnings 'redefine';
+ *main::infoprint = sub { push @mock_output, "INFO: $_[0]" };
+ *main::badprint = sub { push @mock_output, "BAD: $_[0]" };
+ *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" };
+ *main::debugprint = sub { };
+ *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" };
+ *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" };
+}
+
+# =====================================================================
+# 1. get_tuning_info
+# =====================================================================
+subtest 'get_tuning_info' => sub {
+ reset_mocks();
+ no warnings 'redefine';
+ local *main::select_array = sub {
+ return (
+ " Connection: localhost via UNIX socket\n",
+ " Server: Localhost via UNIX socket\n",
+ );
+ };
+ main::get_tuning_info();
+ is($result{'MySQL Client'}{'Connection'}, 'localhost via UNIX socket', 'parsed client connection');
+ is($result{'MySQL Client'}{'Client Path'}, $mysqlcmd, 'Client Path set');
+};
+
+# =====================================================================
+# 2. check_privileges
+# =====================================================================
+subtest 'check_privileges' => sub {
+ # Case 1: ALL PRIVILEGES
+ {
+ reset_mocks();
+ no warnings 'redefine';
+ local *main::select_array = sub { return ("GRANT ALL PRIVILEGES ON *.* TO 'root'\@'localhost'"); };
+ main::check_privileges();
+ is(scalar @mock_output, 0, 'No warning output when ALL PRIVILEGES present');
+ }
+ # Case 2: SUPER privilege
+ {
+ reset_mocks();
+ no warnings 'redefine';
+ local *main::select_array = sub { return ("GRANT SUPER ON *.* TO 'root'\@'localhost'"); };
+ main::check_privileges();
+ is(scalar @mock_output, 0, 'No warning output when SUPER present');
+ }
+ # Case 3: Missing privileges on MySQL 8.0
+ {
+ reset_mocks();
+ $main::myvar{'version'} = '8.0.35';
+ no warnings 'redefine';
+ local *main::select_array = sub { return ("GRANT SELECT, PROCESS ON *.* TO 'user'\@'localhost'"); };
+ local *main::mysql_version_ge = sub { return 1; };
+ main::check_privileges();
+ ok(grep({ /missing the following privileges/ } @mock_output), 'Warning print on missing privileges');
+ }
+ # Case 4: Missing privileges on MariaDB 10.5
+ {
+ reset_mocks();
+ $main::myvar{'version'} = '10.5.25-MariaDB';
+ no warnings 'redefine';
+ local *main::select_array = sub { return ("GRANT SELECT, PROCESS ON *.* TO 'user'\@'localhost'"); };
+ local *main::mysql_version_ge = sub {
+ my ($major, $minor) = @_;
+ return 1 if $major == 10 && $minor == 5;
+ return 0;
+ };
+ main::check_privileges();
+ ok(grep({ /missing the following privileges/ } @mock_output), 'Warning print on missing MariaDB privileges');
+ }
+};
+
+done_testing();
diff --git a/tests/unit_cloud_commands.t b/tests/unit_cloud_commands.t
new file mode 100644
index 000000000..a15a1e6e2
--- /dev/null
+++ b/tests/unit_cloud_commands.t
@@ -0,0 +1,133 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ };
+
+# Declare globals before loading script
+our @adjvars;
+our @generalrec;
+our @modeling;
+our @sysrec;
+our @secrec;
+our %opt;
+our %myvar;
+our %mystat;
+our %mycalc;
+our %result;
+
+my $script_dir = dirname(abs_path(__FILE__));
+my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+{
+ local @ARGV = ();
+ no warnings 'redefine';
+ require $script;
+}
+
+my @mock_output;
+sub reset_mocks {
+ @mock_output = ();
+ @main::generalrec = ();
+ @main::adjvars = ();
+ @main::modeling = ();
+ @main::sysrec = ();
+ @main::secrec = ();
+ %main::result = ();
+ %main::opt = ();
+ %main::myvar = ();
+}
+
+{
+ no warnings 'redefine';
+ *main::infoprint = sub { push @mock_output, "INFO: $_[0]" };
+ *main::badprint = sub { push @mock_output, "BAD: $_[0]" };
+ *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" };
+ *main::debugprint = sub { };
+ *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" };
+ *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" };
+}
+
+# =====================================================================
+# 1. infoprintcmd & infoprinthcmd
+# =====================================================================
+subtest 'infoprintcmd & infoprinthcmd' => sub {
+ reset_mocks();
+ my @cmd_prints;
+ my @info_prints;
+ my @header_prints;
+ no warnings 'redefine';
+ local *main::cmdprint = sub { push @cmd_prints, $_[0] };
+ local *main::infoprintml = sub { push @info_prints, @_ };
+ local *main::subheaderprint = sub { push @header_prints, $_[0] };
+
+ main::infoprintcmd("echo 'test_infoprintcmd'");
+ is(scalar @cmd_prints, 1, 'cmdprint called once');
+ like($cmd_prints[0], qr/echo 'test_infoprintcmd'/, 'cmdprint received command');
+ ok(grep({ /test_infoprintcmd/ } @info_prints), 'infoprintml printed output of command');
+
+ main::infoprinthcmd("My Header", "echo 'test_infoprinthcmd'");
+ is($header_prints[0], "My Header", 'subheaderprint called with header');
+ like($cmd_prints[1], qr/echo 'test_infoprinthcmd'/, 'infoprintcmd called inside infoprinthcmd');
+};
+
+# =====================================================================
+# 2. cloud_setup
+# =====================================================================
+subtest 'cloud_setup' => sub {
+ # Case 1: direct connection mode
+ {
+ reset_mocks();
+ $main::opt{cloud} = 1;
+ $main::opt{'ssh-host'} = undef;
+ $main::opt{forcemem} = 0;
+ main::cloud_setup();
+ is($main::opt{cloud}, 1, 'cloud is 1');
+ is($main::opt{nosysstat}, 1, 'nosysstat is set to 1 in direct mode');
+ is($main::opt{forcemem}, 1024, 'forcemem defaults to 1024');
+ }
+ # Case 2: ssh connection mode with command outputs
+ {
+ reset_mocks();
+ $main::opt{cloud} = 1;
+ $main::opt{'ssh-host'} = 'my-remote-db';
+ $main::opt{forcemem} = 0;
+ $main::opt{forceswap} = 0;
+ no warnings 'redefine';
+ local *main::execute_system_command = sub {
+ my ($cmd) = @_;
+ if ($cmd =~ /uname/) {
+ return "Linux remote-host 5.4.0\n";
+ }
+ elsif ($cmd =~ /MemTotal/) {
+ return "MemTotal: 4194304 kB\n"; # 4GB
+ }
+ elsif ($cmd =~ /SwapTotal/) {
+ return "SwapTotal: 2097152 kB\n"; # 2GB
+ }
+ return "";
+ };
+ main::cloud_setup();
+ is($main::opt{forcemem}, 4096, 'remote memory detected and set');
+ is($main::opt{forceswap}, 2048, 'remote swap detected and set');
+ }
+ # Case 3: ssh connection mode but commands fail (default fallback)
+ {
+ reset_mocks();
+ $main::opt{cloud} = 1;
+ $main::opt{'ssh-host'} = 'my-remote-db';
+ $main::opt{forcemem} = 0;
+ $main::opt{forceswap} = 0;
+ no warnings 'redefine';
+ local *main::execute_system_command = sub { return (); };
+ main::cloud_setup();
+ is($main::opt{forcemem}, 1024, 'forcemem fallback to 1024');
+ is($main::opt{forceswap}, 0, 'forceswap fallback to 0');
+ }
+};
+
+done_testing();
diff --git a/tests/unit_fs_info.t b/tests/unit_fs_info.t
new file mode 100644
index 000000000..2ac3d2b65
--- /dev/null
+++ b/tests/unit_fs_info.t
@@ -0,0 +1,103 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ };
+
+# Declare globals before loading script
+our @adjvars;
+our @generalrec;
+our @modeling;
+our @sysrec;
+our @secrec;
+our %opt;
+our %myvar;
+our %mystat;
+our %mycalc;
+our %result;
+
+my $script_dir = dirname(abs_path(__FILE__));
+my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+{
+ local @ARGV = ();
+ no warnings 'redefine';
+ require $script;
+}
+
+my @mock_output;
+sub reset_mocks {
+ @mock_output = ();
+ @main::generalrec = ();
+ @main::adjvars = ();
+ @main::modeling = ();
+ @main::sysrec = ();
+ @main::secrec = ();
+ %main::result = ();
+ %main::opt = ();
+ %main::myvar = ();
+}
+
+{
+ no warnings 'redefine';
+ *main::infoprint = sub { push @mock_output, "INFO: $_[0]" };
+ *main::badprint = sub { push @mock_output, "BAD: $_[0]" };
+ *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" };
+ *main::debugprint = sub { };
+ *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" };
+ *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" };
+}
+
+# =====================================================================
+# 1. get_fs_info & get_fs_info_win
+# =====================================================================
+subtest 'get_fs_info' => sub {
+ reset_mocks();
+ no warnings 'redefine';
+ local *main::execute_system_command = sub {
+ my ($cmd) = @_;
+ if ($cmd =~ /df -P/) {
+ return (
+ "Filesystem 1024-blocks Used Available Capacity Mounted on\n",
+ "/dev/sda1 10485760 9437184 1048576 90% /\n", # >85%
+ "/dev/sda2 10485760 5242880 5242880 50% /home\n", # <=85%
+ );
+ }
+ elsif ($cmd =~ /df -Pi/) {
+ return (
+ "Filesystem Inodes IUsed IFree IUse% Mounted on\n",
+ "/dev/sda1 1000000 900000 100000 90% /\n", # >85%
+ "/dev/sda2 1000000 500000 500000 50% /home\n", # <=85%
+ );
+ }
+ return ();
+ };
+ main::get_fs_info();
+ is($result{'Filesystem'}{'Space Pct'}{'/'}, 90, 'root Space Pct parsed');
+ is($result{'Filesystem'}{'Space Pct'}{'/home'}, 50, 'home Space Pct parsed');
+ is($result{'Filesystem'}{'Inode Pct'}{'/'}, 90, 'root Inode Pct parsed');
+ ok(grep({ /mount point \/ is using 90 % total space/ } @mock_output), 'warning on high disk usage printed');
+ ok(grep({ /mount point \/ is using 90 % of max allowed inodes/ } @mock_output), 'warning on high inode usage printed');
+};
+
+subtest 'get_fs_info_win' => sub {
+ reset_mocks();
+ no warnings 'redefine';
+ local *main::execute_system_command = sub {
+ return (
+ "FreeSpace Name Size\n",
+ "1073741824 C: 10737418240\n", # 90% used
+ "5368709120 D: 10737418240\n", # 50% used
+ );
+ };
+ main::get_fs_info_win();
+ is($result{'Filesystem'}{'Space Pct'}{'C:'}, 90, 'Disk C Space Pct parsed');
+ is($result{'Filesystem'}{'Space Pct'}{'D:'}, 50, 'Disk D Space Pct parsed');
+ ok(grep({ /Disk C: is using 90 % total space/ } @mock_output), 'warning on high windows disk usage printed');
+};
+
+done_testing();
diff --git a/tests/unit_os_vm.t b/tests/unit_os_vm.t
new file mode 100644
index 000000000..9591f5b78
--- /dev/null
+++ b/tests/unit_os_vm.t
@@ -0,0 +1,114 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ };
+
+# Declare globals before loading script
+our @adjvars;
+our @generalrec;
+our @modeling;
+our @sysrec;
+our @secrec;
+our %opt;
+our %myvar;
+our %mystat;
+our %mycalc;
+our %result;
+
+my $script_dir = dirname(abs_path(__FILE__));
+my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+{
+ local @ARGV = ();
+ no warnings 'redefine';
+ require $script;
+}
+
+my @mock_output;
+sub reset_mocks {
+ @mock_output = ();
+ @main::generalrec = ();
+ @main::adjvars = ();
+ @main::modeling = ();
+ @main::sysrec = ();
+ @main::secrec = ();
+ %main::result = ();
+ %main::opt = ();
+ %main::myvar = ();
+}
+
+{
+ no warnings 'redefine';
+ *main::infoprint = sub { push @mock_output, "INFO: $_[0]" };
+ *main::badprint = sub { push @mock_output, "BAD: $_[0]" };
+ *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" };
+ *main::debugprint = sub { };
+ *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" };
+ *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" };
+}
+
+# =====================================================================
+# 1. get_os_release
+# =====================================================================
+subtest 'get_os_release' => sub {
+ reset_mocks();
+ my $release = main::get_os_release();
+ ok(defined $release && $release ne '', "get_os_release returns some string: $release");
+};
+
+# =====================================================================
+# 2. is_virtual_machine
+# =====================================================================
+subtest 'is_virtual_machine' => sub {
+ # Case 1: Linux with ssh prefix
+ {
+ reset_mocks();
+ local $^O = 'linux';
+ no warnings 'redefine';
+ local *main::get_transport_prefix = sub { return 'ssh'; };
+ local *main::execute_system_command = sub {
+ my ($cmd) = @_;
+ return 1 if $cmd =~ /grep -Ec/;
+ return 0;
+ };
+ is(main::is_virtual_machine(), 1, 'Virtual machine detected via ssh hypervisor grep');
+ }
+ # Case 2: Linux local check
+ {
+ reset_mocks();
+ local $^O = 'linux';
+ no warnings 'redefine';
+ local *main::get_transport_prefix = sub { return ''; };
+ my $is_vm = main::is_virtual_machine();
+ ok(defined $is_vm, 'Local VM detection check executed');
+ }
+ # Case 3: FreeBSD check
+ {
+ reset_mocks();
+ local $^O = 'freebsd';
+ no warnings 'redefine';
+ local *main::execute_system_command = sub {
+ my ($cmd) = @_;
+ return "bhyve\n" if $cmd =~ /sysctl/;
+ return "none\n";
+ };
+ is(main::is_virtual_machine(), 1, 'FreeBSD VM detected');
+ }
+ # Case 4: Windows check
+ {
+ reset_mocks();
+ local $main::is_win = 1;
+ no warnings 'redefine';
+ local *main::execute_system_command = sub {
+ return "System Model: Virtual Machine\n";
+ };
+ is(main::is_virtual_machine(), 1, 'Windows VM model detected');
+ }
+};
+
+done_testing();
diff --git a/tests/unit_phase13_kpis.t b/tests/unit_phase13_kpis.t
new file mode 100644
index 000000000..d55ea357d
--- /dev/null
+++ b/tests/unit_phase13_kpis.t
@@ -0,0 +1,202 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+use File::Temp qw(tempfile);
+
+# Get the script path
+my $script_path = File::Spec->rel2abs(File::Spec->catfile(dirname(__FILE__), '..', 'mysqltuner.pl'));
+
+# Load MySQLTuner logic
+{
+ local @ARGV = ('--silent');
+ package main;
+ require $script_path;
+}
+
+package main;
+
+# Write a mock JSON file for historical delta testing
+my ( $mock_old_fh, $mock_old_file ) = tempfile( 'mock_old_XXXX', SUFFIX => '.json', UNLINK => 1 );
+print $mock_old_fh q({
+ "General": { "Date": "2026-06-01" },
+ "Stats": {
+ "QPS": 10.0,
+ "Total Data Size": 83886080
+ },
+ "HealthScore": 80,
+ "SectionalHealthScore": {
+ "General": 95,
+ "Storage": 70,
+ "Security": 90,
+ "Replication": 100,
+ "Modeling": 90
+ }
+});
+close($mock_old_fh);
+
+# 1. Test get_load_average with mock file2string or fallback to execute_system_command
+{
+ no warnings 'redefine';
+ local *main::file2string = sub {
+ my $path = shift;
+ if ($path eq '/proc/loadavg') {
+ return "1.25 0.80 0.50 2/250 12345\n";
+ }
+ return undef;
+ };
+
+ my @load = main::get_load_average();
+ is_deeply(\@load, ['1.25', '0.80', '0.50'], "get_load_average should parse /proc/loadavg correctly");
+}
+
+{
+ no warnings 'redefine';
+ local *main::file2string = sub { return undef; };
+ local *main::execute_system_command = sub {
+ my $cmd = shift;
+ if ($cmd eq 'uptime') {
+ return " 01:25:00 up 10 days, 1:23, 1 user, load average: 0.75, 1.10, 1.35\n";
+ }
+ return undef;
+ };
+
+ my @load = main::get_load_average();
+ is_deeply(\@load, ['0.75', '1.10', '1.35'], "get_load_average should fallback to uptime command");
+}
+
+# 2. Reset and Mock Sectional Indicators & Health Scores
+sub reset_state {
+ @main::generalrec = ();
+ @main::sysrec = ();
+ @main::secrec = ();
+ @main::modeling = ();
+ @main::adjvars = ();
+ %main::mycalc = ();
+ %main::mystat = ();
+ %main::myrepl = ();
+ %main::result = ();
+ $main::physical_memory = 16 * 1024 * 1024 * 1024; # 16 GB
+ $main::swap_memory = 2 * 1024 * 1024 * 1024; # 2 GB
+}
+
+# 2a. Test Optimal state: All scores 100
+reset_state();
+$main::mystat{'Uptime'} = 100000; # > 86400
+$main::mycalc{'pct_read_efficiency'} = 98.0;
+$main::mycalc{'pct_temp_disk'} = 10.0;
+$main::mycalc{'table_cache_hit_rate'} = 80.0;
+$main::mystat{'Questions'} = 200000;
+$main::mystat{'Innodb_buffer_pool_read_requests'} = 500000;
+
+# Load mock setup for loadavg
+{
+ no warnings 'redefine';
+ local *main::get_load_average = sub { return (0.1, 0.2, 0.3) };
+ local *main::get_other_process_memory = sub { return 1024 * 1024; }; # tiny
+ main::calculate_sectional_health_scores();
+}
+
+is($main::result{'SectionalHealthScore'}{'General'}, 100, "Optimal General score should be 100");
+is($main::result{'SectionalHealthScore'}{'Storage'}, 100, "Optimal Storage score should be 100");
+is($main::result{'SectionalHealthScore'}{'Security'}, 100, "Optimal Security score should be 100");
+is($main::result{'SectionalHealthScore'}{'Replication'}, 100, "Optimal Replication score should be 100");
+is($main::result{'SectionalHealthScore'}{'Modeling'}, 100, "Optimal SQL Modeling score should be 100");
+
+# 2b. Test deductions
+reset_state();
+$main::mystat{'Uptime'} = 5000; # < 86400 -> -20
+$main::mycalc{'pct_read_efficiency'} = 90.0; # < 95 -> -25
+$main::mycalc{'pct_temp_disk'} = 30.0; # > 25 -> -25
+push @main::secrec, "Vulnerable configuration."; # 1 item -> -15
+push @main::modeling, "Missing primary key."; # 1 item -> -10
+
+{
+ no warnings 'redefine';
+ local *main::get_load_average = sub { return (8.0, 8.0, 8.0) };
+ $main::mycalc{'cpu_cores'} = 4; # load_1 / cpu_cores = 2.0 > 1.0 -> -20
+ local *main::get_other_process_memory = sub { return 8 * 1024 * 1024 * 1024; }; # 50% RAM -> -20
+ main::calculate_sectional_health_scores();
+}
+
+# Print diagnostics for deduction test
+diag("--- Diagnostic Info ---");
+diag("General Recs: " . join(", ", @main::generalrec));
+diag("Other Mem: " . main::get_other_process_memory());
+diag("Load Avg: " . join(", ", main::get_load_average()));
+diag("Calculated General Score: " . $main::result{'SectionalHealthScore'}{'General'});
+
+is($main::result{'SectionalHealthScore'}{'General'}, 40, "General score should deduct for uptime, load, and other processes memory");
+is($main::result{'SectionalHealthScore'}{'Storage'}, 50, "Storage score should deduct for read efficiency and temp disk tables");
+is($main::result{'SectionalHealthScore'}{'Security'}, 85, "Security score should deduct for 1 recommendation");
+is($main::result{'SectionalHealthScore'}{'Modeling'}, 90, "SQL Modeling score should deduct for 1 modeling finding");
+
+# 3. Test Resource Saturation & Throughput Efficiency Index
+reset_state();
+$main::mystat{'Uptime'} = 10000;
+$main::mycalc{'pct_max_physical_memory'} = "88%";
+$main::mycalc{'pct_connections_used'} = "75.5%";
+$main::mycalc{'pct_read_efficiency'} = 97.5; # disk read pressure = 2.5 * 20 = 50%
+$main::mycalc{'pct_temp_disk'} = 40.0; # max(50, 40) = 50% IO sat
+$main::mystat{'Questions'} = 50000; # QPS = 5.0
+$main::mystat{'Innodb_buffer_pool_read_requests'} = 100000; # Reads/sec = 10.0
+# TEI = QPS / Reads/sec = 5.0 / 10.0 = 0.5
+
+{
+ no warnings 'redefine';
+ local *main::get_load_average = sub { return (2.5, 2.5, 2.5) };
+ $main::mycalc{'cpu_cores'} = 4; # CPU sat = 2.5 / 4 = 62.5% -> 62%
+ local *main::get_other_process_memory = sub { return 0; };
+ main::calculate_sectional_health_scores();
+}
+
+is($main::result{'ResourceSaturation'}{'CPU'}, 62, "CPU saturation calculation");
+is($main::result{'ResourceSaturation'}{'Memory'}, 88, "Memory saturation calculation");
+is($main::result{'ResourceSaturation'}{'Connections'}, 75, "Connections saturation calculation");
+is($main::result{'ResourceSaturation'}{'IO'}, 50, "Disk I/O saturation calculation");
+
+is($main::result{'ThroughputEfficiency'}{'QPS'}, 5.0, "Throughput Efficiency QPS");
+is($main::result{'ThroughputEfficiency'}{'LogicalReadsSec'}, 10.0, "Throughput Efficiency Reads/sec");
+is($main::result{'ThroughputEfficiency'}{'Index'}, 0.5, "Throughput Efficiency Index (TEI)");
+
+# 4. Test get_top_findings ranking logic
+reset_state();
+push @main::secrec, "Unencrypted connection detected."; # Critical keyword
+push @main::secrec, "Check password complexity."; # Standard finding
+push @main::secrec, "Accounts with empty password (risk)."; # Critical keyword (contains risk)
+push @main::secrec, "Some minor issue.";
+
+my @top_sec = main::get_top_findings(\@main::secrec);
+is(scalar(@top_sec), 3, "get_top_findings returns at most 3 items");
+is($top_sec[0]->{badge}, "Critical", "First item should be critical due to keyword");
+is($top_sec[1]->{badge}, "Critical", "Second item should be critical due to keyword");
+is($top_sec[2]->{badge}, "Finding", "Third item should be standard finding");
+
+# 5. Test historical delta trend analysis
+reset_state();
+$main::result{'Stats'}{'QPS'} = 12.5;
+$main::result{'HealthScore'} = 85;
+$main::result{'Stats'}{'Total Data Size'} = 104857600; # 100MB
+$main::result{'SectionalHealthScore'}{'General'} = 90;
+$main::result{'SectionalHealthScore'}{'Storage'} = 80;
+$main::result{'SectionalHealthScore'}{'Security'} = 95;
+$main::result{'SectionalHealthScore'}{'Replication'} = 100;
+$main::result{'SectionalHealthScore'}{'Modeling'} = 90;
+
+{
+ no warnings 'redefine';
+ $main::opt{'compare-file'} = $mock_old_file;
+ main::historical_comparison();
+}
+
+is($main::result{'Trends'}{'SnapshotDate'}, '2026-06-01', "Snapshot date correctly parsed");
+like($main::result{'Trends'}{'QPS'}, qr/\+25\.00%/, "QPS trend delta should show +25.00%");
+like($main::result{'Trends'}{'HealthScore'}, qr/\+5/, "Health score trend delta should show +5");
+like($main::result{'Trends'}{'TotalDataSize'}, qr/80.0M -> 100.0M \(20.0M\)/, "Data growth should show correct formatting");
+is($main::result{'Trends'}{'Sectional'}{'General'}, '95 -> 90 (-5)', "General score delta trend");
+is($main::result{'Trends'}{'Sectional'}{'Storage'}, '70 -> 80 (+10)', "Storage score delta trend");
+
+done_testing();
diff --git a/tests/unit_versions.t b/tests/unit_versions.t
index 64fec04ad..a6e31e31d 100644
--- a/tests/unit_versions.t
+++ b/tests/unit_versions.t
@@ -1,8 +1,8 @@
use strict;
use warnings;
no warnings 'once';
-no warnings 'once';
use Test::More;
+use File::Temp qw(tempfile);
use File::Basename;
use File::Spec;
use Cwd 'abs_path';
@@ -11,24 +11,152 @@ use Cwd 'abs_path';
$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ };
# Load mysqltuner.pl as a library
-my $script_dir = dirname(abs_path(__FILE__));
-my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+my $script_dir = dirname( abs_path(__FILE__) );
+my $script =
+ abs_path( File::Spec->catfile( $script_dir, '..', 'mysqltuner.pl' ) );
require $script;
# 1. Test compare_tuner_version
# This function is not pure, it uses global $tunerversion and prints.
subtest 'compare_tuner_version' => sub {
no warnings 'redefine';
- local *main::goodprint = sub { };
- local *main::badprint = sub { };
+ local *main::goodprint = sub { };
+ local *main::badprint = sub { };
local *main::update_tuner_version = sub { };
-
+
$main::tunerversion = "2.8.33";
-
+
# It returns undef, so we just check if it runs without crashing for now
# or check the behavior if we mocked the prints to capture output.
- ok(defined eval { main::compare_tuner_version("2.8.33"); 1 }, "Runs with same version");
- ok(defined eval { main::compare_tuner_version("2.9.0"); 1 }, "Runs with newer version");
+ ok( defined eval { main::compare_tuner_version("2.8.33"); 1 },
+ "Runs with same version" );
+ ok( defined eval { main::compare_tuner_version("2.9.0"); 1 },
+ "Runs with newer version" );
+};
+
+subtest 'mysql_version comparisons with cache' => sub {
+ $main::myvar{'version'} = '8.0.33';
+ ok( main::mysql_version_eq( 8, 0, 33 ), '8.0.33 eq 8.0.33' );
+ ok( main::mysql_version_eq( 8, 0 ), '8.0.33 eq 8.0' );
+ ok( main::mysql_version_eq(8), '8.0.33 eq 8' );
+ ok( !main::mysql_version_eq( 5, 7 ), '8.0.33 not eq 5.7' );
+
+ ok( main::mysql_version_ge( 8, 0, 30 ), '8.0.33 ge 8.0.30' );
+ ok( main::mysql_version_ge( 8, 0 ), '8.0.33 ge 8.0' );
+ ok( !main::mysql_version_ge( 9, 0 ), '8.0.33 not ge 9.0' );
+
+ ok( main::mysql_version_le( 8, 0, 35 ), '8.0.33 le 8.0.35' );
+ ok( main::mysql_version_le( 9, 0 ), '8.0.33 le 9.0' );
+ ok( !main::mysql_version_le( 5, 7 ), '8.0.33 not le 5.7' );
+};
+
+subtest '_to_yaml serialization' => sub {
+ my $data = {
+ name => 'MySQLTuner',
+ version => '2.9.0',
+ special => '2:9#0',
+ active => 1,
+ details => {
+ perf_bp => 10,
+ empty_val => '',
+ },
+ list => [ 'a', 'b', { k => 'v' } ],
+ };
+
+ my $yaml = main::_to_yaml($data);
+
+ # Check that strings are generated correctly
+ like( $yaml, qr/name: MySQLTuner/, 'Plain scalar serialized correctly' );
+ like( $yaml, qr/version: 2\.9\.0/, 'Unquoted scalar serialized correctly' );
+ like(
+ $yaml,
+ qr/special: '2:9#0'/,
+ 'Quoted scalar (with special chars) serialized correctly'
+ );
+ like( $yaml, qr/active: 1/, 'Number serialized correctly' );
+ like( $yaml, qr/empty_val: ''/, 'Empty string serialized correctly' );
+ like( $yaml, qr/perf_bp: 10/, 'Nested hash scalar serialized correctly' );
+ like( $yaml, qr/list:/, 'Array prefix serialized correctly' );
+ like( $yaml, qr/-\s+a/, 'Array items serialized correctly' );
+ like( $yaml, qr/-\s+k:\s+v/,
+ 'Array with nested hash serialized correctly' );
+};
+
+subtest 'historical_comparison health score' => sub {
+ no warnings 'redefine';
+ my $captured_trend;
+ local *main::infoprint = sub {
+ my $msg = shift;
+ if ( $msg =~ /Health Score Trend/ ) {
+ $captured_trend = $msg;
+ }
+ };
+ local *main::badprint = sub { };
+
+ # Prepare current results
+ $main::result{'HealthScore'} = 85;
+
+ # Mocking reading from file
+ my $compare_json =
+'{"General":{"Date":"2026-06-15"},"HealthScore":70,"Stats":{"QPS":1.5,"Total Data Size":1000}}';
+
+ my ( $tfh, $temp_file ) = tempfile( 'temp_compare_XXXX', SUFFIX => '.json', UNLINK => 1 );
+ print $tfh $compare_json;
+ close $tfh;
+
+ local $main::opt{'compare-file'} = $temp_file;
+ main::historical_comparison();
+
+ ok( defined $captured_trend,
+ "Health score trend comparison was triggered" );
+ like(
+ $captured_trend,
+ qr/70 -> 85 \(\+15\)/,
+ "Trend correctly shows +15 improvement"
+ );
+};
+
+subtest '_sanitized_result_for_export' => sub {
+ my $mock_result = {
+ 'MySQLTuner' => {
+ 'options' => {
+ 'host' => 'localhost',
+ 'pass' => 'mysecretpassword',
+ 'password' => 'anothersecret',
+ 'ssh-password' => 'sshsec',
+ 'passenv' => 'ENVVAR',
+ 'userenv' => 'USERVAR',
+ }
+ },
+ 'MySQL Client' => {
+ 'Authentication Info' => "mysql -u root -p'mysecretpassword' -h localhost"
+ }
+ };
+
+ my $sanitized = main::_sanitized_result_for_export($mock_result);
+
+ # Original remains unmodified
+ is($mock_result->{'MySQLTuner'}{'options'}{'pass'}, 'mysecretpassword', 'Original remains unmodified');
+
+ # Sanitized has redacted fields
+ is($sanitized->{'MySQLTuner'}{'options'}{'host'}, 'localhost', 'Host is kept');
+ is($sanitized->{'MySQLTuner'}{'options'}{'pass'}, '[REDACTED]', 'pass is redacted');
+ is($sanitized->{'MySQLTuner'}{'options'}{'password'}, '[REDACTED]', 'password is redacted');
+ is($sanitized->{'MySQLTuner'}{'options'}{'ssh-password'}, '[REDACTED]', 'ssh-password is redacted');
+ is($sanitized->{'MySQLTuner'}{'options'}{'passenv'}, '[REDACTED]', 'passenv is redacted');
+ is($sanitized->{'MySQLTuner'}{'options'}{'userenv'}, '[REDACTED]', 'userenv is redacted');
+
+ # Authentication Info is sanitized
+ like($sanitized->{'MySQL Client'}{'Authentication Info'}, qr/-p'\[REDACTED\]'/, 'Authentication Info password in quotes is redacted');
+
+ # Test without quotes
+ my $mock_result2 = {
+ 'MySQL Client' => {
+ 'Authentication Info' => "mysql -u root -psecret -h localhost"
+ }
+ };
+ my $sanitized2 = main::_sanitized_result_for_export($mock_result2);
+ like($sanitized2->{'MySQL Client'}{'Authentication Info'}, qr/-p\[REDACTED\]/, 'Authentication Info password without quotes is redacted');
};
done_testing();
diff --git a/tests/verbose_timing.t b/tests/verbose_timing.t
new file mode 100644
index 000000000..b03dfad2e
--- /dev/null
+++ b/tests/verbose_timing.t
@@ -0,0 +1,116 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+# Suppress warnings from mysqltuner.pl initialization
+$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ };
+
+# Load mysqltuner.pl as a library
+my $script_dir = dirname(abs_path(__FILE__));
+my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+require $script;
+
+# Ensure verbose is on, and clear state
+$main::opt{'verbose'} = 1;
+@main::section_timings = ();
+@main::raw_output_lines = ();
+$main::tuner_start_time = (eval { require Time::HiRes; Time::HiRes::time(); } || time()) - 1.0;
+$main::tuner_start_datetime = "2026-06-26 10:50:00";
+$main::current_section_name = undef;
+
+# Call subheaderprint twice to trigger timing print of first section
+main::subheaderprint("Section A");
+$main::current_section_start = $main::current_section_start - 0.5; # Fake elapsed time of 0.5s
+main::subheaderprint("Section B");
+
+# Check if timing for Section A was printed
+my @section_a_lines = grep { /Section A execution time:/ } @main::raw_output_lines;
+ok(scalar @section_a_lines > 0, "Execution time for Section A is printed when verbose is enabled");
+like($section_a_lines[0], qr/Section A execution time: 0\.\d+s/, "Duration format is correct");
+
+# Run print_execution_timings
+@main::raw_output_lines = ();
+main::print_execution_timings();
+
+# Check if summary block is printed with percentages
+my @summary_lines = grep { /Section B\s*:/ || /Total Execution Time\s*:/ } @main::raw_output_lines;
+ok(scalar @summary_lines >= 2, "Summary block and total execution time are printed at the end");
+my @pct_lines = grep { /\(\d+\.\d+%\)/ } @main::raw_output_lines;
+ok(scalar @pct_lines >= 2, "Percentages are printed next to each section duration");
+
+my @date_lines = grep { /Started at\s*:/ || /Ended at\s*:/ } @main::raw_output_lines;
+is(scalar @date_lines, 2, "Start and end datetimes are printed when verbose is enabled");
+
+# Run again with verbose = 0 and verify NO timings are printed
+$main::opt{'verbose'} = 0;
+@main::section_timings = ();
+@main::raw_output_lines = ();
+$main::current_section_name = undef;
+
+main::subheaderprint("Section A");
+main::subheaderprint("Section B");
+main::print_execution_timings();
+
+my @no_timing_lines = grep { /execution time:/ || /Total Execution Time:/ } @main::raw_output_lines;
+is(scalar @no_timing_lines, 0, "No timing messages are printed when verbose is disabled");
+
+# Run again with verbose = 0 and stage-timings = 1 and verify timings ARE printed
+$main::opt{'verbose'} = 0;
+$main::opt{'stage-timings'} = 1;
+@main::section_timings = ();
+@main::raw_output_lines = ();
+$main::current_section_name = undef;
+$main::tuner_start_time = (eval { require Time::HiRes; Time::HiRes::time(); } || time()) - 1.0;
+$main::tuner_start_datetime = "2026-06-26 10:50:00";
+
+main::subheaderprint("Section A");
+$main::current_section_start = $main::current_section_start - 0.5; # Fake elapsed time of 0.5s
+main::subheaderprint("Section B");
+main::print_execution_timings();
+
+my @stage_timing_lines = grep { /Section A execution time:/ } @main::raw_output_lines;
+ok(scalar @stage_timing_lines > 0, "Execution time for Section A is printed when stage-timings is enabled without verbose");
+
+my @stage_summary_lines = grep { /Section B\s*:/ || /Total Execution Time\s*:/ } @main::raw_output_lines;
+ok(scalar @stage_summary_lines >= 2, "Summary block and total execution time are printed when stage-timings is enabled without verbose");
+
+my @stage_date_lines = grep { /Started at\s*:/ || /Ended at\s*:/ } @main::raw_output_lines;
+is(scalar @stage_date_lines, 2, "Start and end datetimes are printed when stage-timings is enabled without verbose");
+
+# 3. Test print_audit_snapshot_summary
+subtest 'print_audit_snapshot_summary' => sub {
+ @main::raw_output_lines = ();
+ $main::tunerversion = "9.9.9";
+ $main::opt{'host'} = "127.0.0.1";
+ $main::opt{'port'} = 3307;
+ $main::physical_memory = 16 * 1024 * 1024 * 1024;
+ $main::swap_memory = 4 * 1024 * 1024 * 1024;
+ $main::myvar{'version'} = "10.11.4-MariaDB";
+ $main::mystat{'Uptime'} = 172800;
+
+ $main::tuner_start_datetime = "2026-06-26 15:37:58";
+
+ # We mock select_one so that we don't try to query database for CURRENT_USER
+ no warnings 'redefine';
+ local *main::select_one = sub { return 'root@localhost'; };
+
+ main::print_audit_snapshot_summary();
+
+ my $output = join("\n", @main::raw_output_lines);
+ like($output, qr/Audit Snapshot Summary/, "Header printed");
+ like($output, qr/MySQLTuner Version : 9\.9\.9/, "MySQLTuner version matched");
+ like($output, qr/Audit Start Time : 2026-06-26 15:37:58/, "Start time matched");
+ like($output, qr/Server Connection : 127\.0\.0\.1:3307/, "Connection matched");
+ like($output, qr/Database User : root\@localhost/, "Database user matched");
+ like($output, qr/Database Version : 10\.11\.4-MariaDB/, "Database version matched");
+ like($output, qr/System Physical RAM: 16\.0G/, "System RAM matched");
+ like($output, qr/System Swap Memory : 4\.0G/, "System Swap matched");
+ like($output, qr/Database Uptime : 2d 0h 0m 0s/, "Database uptime matched");
+};
+
+done_testing();