diff --git a/CHANGELOG.md b/CHANGELOG.md
index c8f214fdf..97f093f1a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1103,6 +1103,14 @@ If you are coming from 26.08 or earlier, the database update still has to be run
- Write it with AI: under the add-a-line row on a quote or invoice that can still change. Say what it is for ("three laptops for the new starters, set up and given Microsoft 365") and AI picks the lines: products and services from your catalog wherever one fits, at the catalog's price and billed as the product is; labor in hours, to the quarter hour, at the client's rate; and anything the catalog doesn't have, with no price for you to set. AI never prices anything. It suggests a scope, a note for the client (what is assumed and what isn't included), and what to check before sending. Tick the lines to keep, change any of them - the total follows, once and each month, quarter or year - and Add puts them on, each taxed as a line of its kind is, with the scope and the note if you keep them. A quote raised from a ticket can draw on the ticket, and both can use what ITFlow knows of the client - its people, sites and devices - to size per-user and per-device lines. Without Support, the ticket and the client's documentation are left out, and only how many people the client has is used.
- Invoicing a ticket: Write it beside the line's description says what was done, from the replies and notes - the ones that logged time first - as short points under the usual ticket line, for the client to read. No times, prices or names, nothing from internal notes the client shouldn't see, and passwords never sent nor written back. Put back what was there undoes it.
- AI Usage shows each by name, and each needs the Use AI permission and Sales to change quotes and invoices.
+- **Debug** (rebuilt)
+ - Admin > Debug says first what needs putting right - problems, then warnings - each with what was found, what to do about it and a link to where to do it. When nothing does, it says everything looks good.
+ - Along the top: the version and where the code came from, the database's version (and an update waiting), PHP, when cron last ran, failed email this week, the last backup, and errors logged in the last day - each a link to its page.
+ - Every check, by section: ITFlow (its version, the database's version against the one the code expects, updates and whether they can run here), background jobs (cron running and switched on, with the crontab line when it isn't; jobs that failed in the last day; jobs switched off), mail (sending, email given up on, email waiting to be retried, the ticket mailbox last read), backups (the last one, one that failed, the backup folder), PHP (version, the extensions ITFlow needs, zip, upload size, memory and time limits), web and security (HTTPS, the certificate and when it runs out, the address in ITFlow's emails against the one in use, a domain name, HTTPS only), files and disk (the uploads folder, one-click updates able to write, free space), the server (system, web server, database server, time zone, and the database's clock against PHP's), and errors logged in the last day with the latest few.
+ - Database: its tables, rows and size, the biggest tables with a bar for their size, and how its tables and columns compare with a fresh install's - each difference named, with what to do when something is missing. Row counts are the database's own estimate; Count exactly counts every table.
+ - Uploads: files and size by folder, biggest first.
+ - Copy for support puts the whole report on the clipboard as plain text, for a forum post or a support email - versions, what needs looking at and the size of things, without host names, paths, addresses or keys. The report is on the page too, to read before posting.
+ - The page opens quickly on a big install, and still opens when the database is behind the code - which is when it is wanted most.
### Bug Fixes
@@ -1293,6 +1301,9 @@ If you are coming from 26.08 or earlier, the database update still has to be run
- Ticket link: a rating could be changed afterwards, or set to any word by editing the address, and the custom action for a rating never ran.
- Ticket link: the client's photo never showed on their own messages.
- Ticket link: the key in the link matched in any mix of capital and small letters. It has to match exactly now.
+- Admin > Debug counted every row of every table and every file in uploads on each visit, which could take minutes on a big install. Rows are estimated unless you ask for an exact count, and counting files stops after a few seconds.
+- Admin > Debug checked the certificate of whatever host the browser asked for rather than ITFlow's own, said HTTPS was off behind a proxy that handles it, and counted shell_exec being on as a pass.
+- Admin > Debug flagged every whole-number column as a different type on MySQL 8, which no longer shows their display widths.
### Developer Updates
@@ -1886,6 +1897,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run
- Guest ticket page: in `guest/guest_post.php`, `close_ticket`, `reopen_ticket` and `add_ticket_feedback` are POST only, with `ticket_id` and `url_key`; the GET forms are gone. Each acts once, on the state it expects, so a second press logs nothing. Reopen calls `resetTicketResolutionSla()` and `syncTicketSlaClock()` and `appNotify()`; close calls `syncTicketSlaClock()`. `add_ticket_feedback` takes Good or Bad only. `ticket_calendar` (GET) sends the booking's calendar file. `ticketScheduleRow($ticket_id)` and `ticketScheduleIcs($row, $schedule)` are what `ticketSetSchedule()` used to build inline, so both give the same UID and event. The CSS class `.itflow-portal-todo-wrap` lets a portal to-do row's title wrap instead of being cut off.
- Ticket emails: `emailHtml()` takes a `history` block (a heading and items of who, organisation, action, when and plain text, or a `gap` count) drawn after the button. `emailPlainText($html, $limit)` (`functions/email.php`) is HTML as plain text, keeping line breaks, dropping pictures and the reply line, and cut at a word. `ticketEmailHistory($row, $opts)` (`functions/ticket.php`) builds the history: public and client replies newest first, never internal, archived or the portal's "Ticket ... by X." lines, then the request. `skip` leaves out the reply the email shows and anything newer, `request` and `replies` choose the parts, and `limit` (4) caps the replies. `ticketEmailExcerpt()` takes off the parser's "Email from" line. `ticketEmailContent()` and `ticketAgentEmailContent()` add it per kind and set the preview line through `ticketEmailPreview()`. `ticketMailColleaguesNew()` shows the details unless `ticket_source` is Email. `cron/ticket_email_parser.php` also cuts a reply at a plain-text reply line, quoted or not.
- Custom links: new `functions/custom_link.php` holds the link helpers. `customLinkLocations()` lists the menus (1 agent menu, 2 top bar, 3 client portal, 4 admin menu, 5 reports menu). `customLinkUriProblem()` and `customLinkHref()` check addresses: escapeUrl()'s schemes plus mailto and tel, relative paths allowed, anything else drawn as `#`. `customLinksFor($location)` is a menu's current links, read once a page, with `href` and `icon` added; `customLinkNavItemHtml()` draws a side menu line. Also `customLinkFromInput()`, `customLinkProblem()`, `customLinkCreate()`, `customLinkUpdate()` (returns the from-to changes for the audit log), `customLinkCopy()`, `customLinkReorder()`, `customLinkMenuIds()` and `customLinkIconChoices()`. `admin/includes/side_nav.php`, `agent/includes/side_nav.php`, `agent/reports/includes/reports_side_nav.php`, `includes/top_nav.php` and `portalNav()` read `customLinksFor()`; the portal's nav items take a `title`. `admin/post/custom_link.php` adds `copy_custom_link`, `archive_custom_link`, `restore_custom_link`, `move_custom_link` and `reorder_custom_links` (POST, JSON, like `reorder_categories`). The add and edit windows share `admin/modals/custom_link/custom_link_form.php` with `js/custom_link_form.js`. `custom_link_description` is now used; `custom_link_order` is set by position. No database change.
+- Debug: new `functions/system_health.php`. `healthChecks()` returns the sections in order (`key`, `title`, `icon`, `items`), each item from `healthItem($label, $level, $value, $fix, $url, $action, $private)` - level `ok`, `info`, `warning` or `danger`, `fix` as HTML, `private` leaving the value out of the report. `healthSummary($sections)` counts them by level and lists the warnings and problems, problems first. `healthDatabaseTables($exact)` reads sizes and InnoDB's row estimates from `information_schema.TABLES` in one query, or counts each table with `$exact` (`debug.php?exact=1`); `healthStructureDifferences()` compares `information_schema.COLUMNS` with `db.sql`, ignoring integer display widths (`healthColumnType()`); `healthUploads($seconds)` counts uploads by top-level folder and stops at the time given, marked `partial`. `healthReport()` is the plain-text report. Every read goes through `healthQuery()` / `healthRow()`, which answer null instead of throwing, so a column the database doesn't have yet costs a line, not the page. The certificate is read from `$config_base_url`'s host (`healthBaseHost()`, `healthCertificate()`), never `HTTP_HOST`; HTTPS counts `X-Forwarded-Proto`; the required extensions come from `setupRequiredExtensions()` in `setup/setup_checks.php` so setup and Debug agree; mail uses `mailQueueHealth()` and `mailQueueSummary()`; updates use `shellCommandsAvailable()` and `commandPath('git')`. `admin/debug.php` no longer closes the database before the footer, uses `reportStats()` for the figures along the top, and the copy button is the shared `.clipboardjs` one, targeting the report's textarea. New CSS: `.itflow-debug-section-icon`, `.itflow-debug-item`, `.itflow-debug-fix`, `.itflow-debug-attention`, `.itflow-debug-bar`, `.itflow-debug-stats`. No database change.
### Library Updates
diff --git a/admin/debug.php b/admin/debug.php
index d6ac0694c..1e39cd534 100644
--- a/admin/debug.php
+++ b/admin/debug.php
@@ -1,755 +1,325 @@
Debug - how ITFlow and the server under it are doing
+ * (functions/system_health.php). What needs putting right comes first, each
+ * with what to do and where; then the figures at a glance, every check by
+ * section, the database (its size, biggest tables and how its structure
+ * compares with db.sql) and the uploads folder. Copy for support puts it all
+ * on the clipboard as plain text for a forum post or a support email,
+ * without host names, paths or keys.
+ */
+
require_once "includes/inc_all_admin.php";
require_once "../includes/database_version.php";
-require_once "../config.php";
+require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/cron_jobs.php'; // the jobs' names
-$checks = [];
+$sections = healthChecks();
+$summary = healthSummary($sections);
+$exact = !empty($_GET['exact']); // Count exactly - every table counted, rather than the database's estimate
+$database_stats = healthDatabaseTables($exact);
+$differences = healthStructureDifferences();
+$uploads = healthUploads();
+$report = healthReport($sections, $database_stats, $differences, $uploads);
+$levels = healthLevels();
-// Read straight out of .git - no shell needed, and it still answers on a hardened host
-$commitHash = gitCurrentCommit();
-
-$gitBranch = gitCurrentBranch();
-
-// Section: System Information
-$systemInfo = [];
-
-// Operating System and Version
-$os = php_uname();
-$systemInfo[] = [
- 'name' => 'Operating System',
- 'value' => $os,
-];
-
-// Web Server and Version
-$webServer = $_SERVER['SERVER_SOFTWARE'] ?? 'Unknown';
-$systemInfo[] = [
- 'name' => 'Web Server',
- 'value' => $webServer,
-];
-
-// Kernel and Version
-$kernelVersion = php_uname('r');
-$systemInfo[] = [
- 'name' => 'Kernel Version',
- 'value' => $kernelVersion,
-];
-
-// Database and Version
-$dbVersion = $mysqli->server_info;
-$systemInfo[] = [
- 'name' => 'Database Version',
- 'value' => $dbVersion,
-];
-
-// Section: PHP Extensions
-$phpExtensions = [];
-$extensions = [
- 'php-mysqli' => 'mysqli',
- 'php-intl' => 'intl',
- 'php-curl' => 'curl',
- 'php-mbstring' => 'mbstring',
- 'php-gd' => 'gd',
- 'php-zip' => 'zip',
- 'php-xml' => 'xml',
-];
-
-foreach ($extensions as $name => $ext) {
- $loaded = extension_loaded($ext);
- $phpExtensions[] = [
- 'name' => "$name installed",
- 'passed' => $loaded,
- 'value' => $loaded ? 'Installed' : 'Not Installed',
- ];
+// The figures along the top
+$cron = healthRow("SELECT config_cron_last_dispatch_at FROM settings WHERE company_id = 1");
+$cron_heard = $cron['config_cron_last_dispatch_at'] ?? null;
+$cron_stale = !$cron_heard || time() - strtotime($cron_heard) >= 300;
+$last_backup = healthRow("SELECT backup_completed_at, backup_size FROM backups WHERE backup_status = 'Complete' ORDER BY backup_completed_at DESC LIMIT 1");
+$errors_day = healthRow("SELECT COUNT(*) AS n FROM app_logs WHERE app_log_type = 'error' AND app_log_created_at > NOW() - INTERVAL 1 DAY");
+try {
+ $mail = mailQueueSummary();
+} catch (Throwable $e) {
+ $mail = null;
}
-
-// Section: PHP Configuration
-$phpConfig = [];
-
-// Check if shell_exec is enabled
-$disabled_functions = explode(',', ini_get('disable_functions'));
-$disabled_functions = array_map('trim', $disabled_functions);
-$shell_exec_enabled = !in_array('shell_exec', $disabled_functions);
-
-$phpConfig[] = [
- 'name' => 'shell_exec is enabled',
- 'passed' => $shell_exec_enabled,
- 'value' => $shell_exec_enabled ? 'Enabled' : 'Disabled',
+$db_behind = version_compare((string) LATEST_DATABASE_VERSION, (string) CURRENT_DATABASE_VERSION, '>');
+$branch = gitCurrentBranch();
+$commit = gitCurrentCommit();
+$stats = [
+ ['label' => 'ITFlow', 'value' => APP_VERSION, 'sub' => escapeHtml($branch !== '' ? $branch . ($commit !== '' ? ' @ ' . substr($commit, 0, 7) : '') : 'Not a git checkout')],
+ ['label' => 'Database', 'value' => CURRENT_DATABASE_VERSION, 'tone' => $db_behind ? 'danger' : '', 'url' => $db_behind ? 'update.php' : '',
+ 'sub' => $db_behind ? 'Update to ' . escapeHtml(LATEST_DATABASE_VERSION) . ' waiting' : 'Up to date'],
+ ['label' => 'PHP', 'value' => PHP_VERSION, 'sub' => escapeHtml(PHP_SAPI)],
+ ['label' => 'Cron', 'value' => healthShortAgo($cron_heard), 'title' => $cron_heard ? 'Last ran ' . healthAgo($cron_heard) : '', 'tone' => $cron_stale ? 'danger' : '', 'url' => 'cron.php',
+ 'sub' => $cron_stale ? 'Not running' : 'Running'],
+ ['label' => 'Failed email', 'value' => $mail ? number_format($mail['failed_week']) : '-', 'tone' => $mail && $mail['failed_week'] ? 'warning' : '',
+ 'url' => $mail && $mail['failed_week'] ? 'mail_queue.php?show=failed' : 'mail_queue.php', 'sub' => $mail ? 'This week · ' . number_format($mail['waiting']) . ' waiting' : 'Database update needed'],
+ ['label' => 'Last backup', 'value' => $last_backup ? healthShortAgo($last_backup['backup_completed_at']) : 'None yet', 'url' => 'backup.php',
+ 'tone' => !$last_backup || strtotime($last_backup['backup_completed_at']) < time() - 7 * 86400 ? 'warning' : '',
+ 'sub' => $last_backup ? escapeHtml(formatBytes(intval($last_backup['backup_size']), 1)) : 'Take one now'],
+ ['label' => 'Errors', 'value' => $errors_day ? number_format(intval($errors_day['n'])) : '-', 'tone' => !empty($errors_day['n']) ? 'warning' : '',
+ 'url' => !empty($errors_day['n']) ? 'app_logs.php?view=error' : 'app_logs.php', 'sub' => 'In the last day'],
];
-// Check upload_max_filesize and post_max_size >= 500M
-function toBytes($val) {
- $val = trim($val);
- $unit = strtolower(substr($val, -1));
- $num = (float)$val;
- switch ($unit) {
- case 'g':
- $num *= 1024;
- case 'm':
- $num *= 1024;
- case 'k':
- $num *= 1024;
+$problems = $summary['counts']['danger'];
+$warnings = $summary['counts']['warning'];
+$checked = array_sum($summary['counts']);
+$plural = function ($n, $one, $many) {
+ return number_format($n) . ' ' . ($n === 1 ? $one : $many);
+};
+
+// A link from a check - ITFlow's own pages here, the docs in a new tab
+$action_link = function (array $item, $class) {
+ if ($item['url'] === '') {
+ return '';
}
- return $num;
-}
+ $outside = preg_match('#^https?://#i', $item['url']);
+ return ''
+ . escapeHtml($item['action'] !== '' ? $item['action'] : 'Open') . '';
+};
-$required_bytes = 500 * 1024 * 1024; // 500M in bytes
-
-$upload_max_filesize = ini_get('upload_max_filesize');
-$post_max_size = ini_get('post_max_size');
-
-$upload_passed = toBytes($upload_max_filesize) >= $required_bytes;
-$post_passed = toBytes($post_max_size) >= $required_bytes;
-
-$phpConfig[] = [
- 'name' => 'upload_max_filesize >= 500M',
- 'passed' => $upload_passed,
- 'value' => $upload_max_filesize,
-];
-
-$phpConfig[] = [
- 'name' => 'post_max_size >= 500M',
- 'passed' => $post_passed,
- 'value' => $post_max_size,
-];
-
-// PHP Memory Limit >= 512M
-$memoryLimit = ini_get('memory_limit');
-$memoryLimitBytes = toBytes($memoryLimit);
-$memoryLimitPassed = $memoryLimitBytes >= (512 * 1024 * 1024);
-$phpConfig[] = [
- 'name' => 'PHP Memory Limit >= 512M',
- 'passed' => $memoryLimitPassed,
- 'value' => $memoryLimit,
-];
-
-// Max Execution Time >= 300 seconds
-$maxExecutionTime = ini_get('max_execution_time');
-$maxExecutionTimePassed = $maxExecutionTime >= 300;
-$phpConfig[] = [
- 'name' => 'Max Execution Time >= 300 seconds',
- 'passed' => $maxExecutionTimePassed,
- 'value' => $maxExecutionTime . ' seconds',
-];
-
-// Check PHP version >= 8.2.0
-$php_version = PHP_VERSION;
-$php_passed = version_compare($php_version, '8.2.0', '>=');
-
-$phpConfig[] = [
- 'name' => 'PHP version >= 8.2.0',
- 'passed' => $php_passed,
- 'value' => $php_version,
-];
-
-// Section: Shell Commands
-$shellCommands = [];
-
-// Located by walking PATH rather than by running `which`, so this reports the truth on a
-// host with shell_exec disabled instead of reporting the host's php.ini back at itself
-foreach (['git'] as $command) {
- $path = commandPath($command);
- $shellCommands[] = [
- 'name' => "Command '$command' available",
- 'passed' => $path !== '',
- 'value' => $path !== '' ? $path : 'Not Found',
- ];
-}
-
-// Section: SSL Checks
-$sslChecks = [];
-
-// Check if accessing via HTTPS
-$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || $_SERVER['SERVER_PORT'] == 443;
-$sslChecks[] = [
- 'name' => 'Accessing via HTTPS',
- 'passed' => $https,
- 'value' => $https ? 'Yes' : 'No',
-];
-
-// SSL Certificate Validity Check
-if ($https) {
- $streamContext = stream_context_create(["ssl" => ["capture_peer_cert" => true]]);
- $socket = @stream_socket_client("ssl://{$_SERVER['HTTP_HOST']}:443", $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $streamContext);
-
- if ($socket) {
- $params = stream_context_get_params($socket);
- $cert = $params['options']['ssl']['peer_certificate'];
- $certInfo = openssl_x509_parse($cert);
-
- $validFrom = $certInfo['validFrom_time_t'];
- $validTo = $certInfo['validTo_time_t'];
- $currentTime = time();
-
- $certValid = ($currentTime >= $validFrom && $currentTime <= $validTo);
-
- $sslChecks[] = [
- 'name' => 'SSL Certificate is valid',
- 'passed' => $certValid,
- 'value' => $certValid ? 'Valid' : 'Invalid or Expired',
- ];
- } else {
- $sslChecks[] = [
- 'name' => 'SSL Certificate is valid',
- 'passed' => false,
- 'value' => 'Unable to retrieve certificate',
- ];
- }
-} else {
- $sslChecks[] = [
- 'name' => 'SSL Certificate is valid',
- 'passed' => false,
- 'value' => 'Not using HTTPS',
- ];
-}
-
-// Section: Domain Checks
-$domainChecks = [];
-
-// Check if the site has a valid FQDN
-$fqdn = $_SERVER['HTTP_HOST'];
-$isValidFqdn = (bool) filter_var('http://' . $fqdn, FILTER_VALIDATE_URL) && preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $fqdn);
-
-$domainChecks[] = [
- 'name' => 'Site has a valid FQDN',
- 'passed' => $isValidFqdn,
- 'value' => $fqdn,
-];
-
-// Section: File Permissions
-$filePermissions = [];
-
-// Check if web user has write access to webroot directory
-$webroot = $_SERVER['DOCUMENT_ROOT'];
-$writable = is_writable($webroot);
-$filePermissions[] = [
- 'name' => 'Web user has write access to webroot directory',
- 'passed' => $writable,
- 'value' => $webroot,
-];
-
-// Section: Uploads Directory Stats
-$uploadsStats = [];
-
-// Define the uploads directory path
-$uploadsDir = __DIR__ . '/../uploads'; // Adjust the path if needed
-
-if (is_dir($uploadsDir)) {
- // Function to recursively count files and calculate total size
- function getDirStats($dir) {
- $files = 0;
- $size = 0;
-
- $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir));
- foreach ($iterator as $file) {
- if ($file->isFile()) {
- $files++;
- $size += $file->getSize();
- }
- }
- return ['files' => $files, 'size' => $size];
- }
-
- $stats = getDirStats($uploadsDir);
- $sizeInMB = round($stats['size'] / (1024 * 1024), 2);
-
- $uploadsStats[] = [
- 'name' => 'Number of files in uploads directory',
- 'value' => $stats['files'],
- ];
-
- $uploadsStats[] = [
- 'name' => 'Total size of uploads directory (MB)',
- 'value' => $sizeInMB . ' MB',
- ];
-} else {
- $uploadsStats[] = [
- 'name' => 'Uploads directory exists',
- 'value' => 'Directory not found',
- ];
-}
-
-// Section: Database Stats
-$databaseStats = [];
-
-// Get list of tables
-$tablesResult = $mysqli->query("SHOW TABLE STATUS");
-if ($tablesResult) {
- $totalTables = 0;
- $totalFields = 0;
- $totalRows = 0;
- $totalSize = 0;
- $tableDetails = [];
-
- while ($table = $tablesResult->fetch_assoc()) {
- $tableName = $table['Name'];
-
- // Accurate row count
- $countResult = $mysqli->query("SELECT COUNT(*) AS cnt FROM `$tableName`");
- $countRow = $countResult->fetch_assoc();
- $tableRows = $countRow['cnt'];
- $countResult->free();
-
- $dataLength = $table['Data_length'];
- $indexLength = $table['Index_length'];
- $tableSize = ($dataLength + $indexLength) / (1024 * 1024); // Size in MB
-
- // Get number of fields
- $fieldsResult = $mysqli->query("SHOW COLUMNS FROM `$tableName`");
- $numFields = $fieldsResult->num_rows;
- $fieldsResult->free();
-
- $totalTables++;
- $totalFields += $numFields;
- $totalRows += $tableRows;
- $totalSize += $tableSize;
-
- $tableDetails[] = [
- 'name' => $tableName,
- 'fields' => $numFields,
- 'rows' => $tableRows,
- 'size' => round($tableSize, 2),
- ];
- }
- $tablesResult->free();
-
- $databaseStats[] = [
- 'name' => 'Total number of tables',
- 'value' => $totalTables,
- ];
- $databaseStats[] = [
- 'name' => 'Total number of fields',
- 'value' => $totalFields,
- ];
- $databaseStats[] = [
- 'name' => 'Total number of rows',
- 'value' => $totalRows,
- ];
- $databaseStats[] = [
- 'name' => 'Total database size (MB)',
- 'value' => round($totalSize, 2) . ' MB',
- ];
-}
-
-// Section: Database Structure Comparison
-$dbComparison = [];
-
-// Path to the db.sql file
-$dbSqlFile = __DIR__ . '/../db.sql';
-
-if (file_exists($dbSqlFile)) {
- // Read the db.sql file
- $sqlContent = file_get_contents($dbSqlFile);
-
- // Remove comments and empty lines
- $lines = explode("\n", $sqlContent);
- $sqlStatements = [];
- $statement = '';
-
- foreach ($lines as $line) {
- // Remove single-line comments
- $line = preg_replace('/--.*$/', '', $line);
- $line = preg_replace('/\/\*.*?\*\//', '', $line);
-
- // Skip empty lines
- if (trim($line) == '') {
- continue;
- }
-
- // Append line to the current statement
- $statement .= $line . "\n";
-
- // Check if the statement ends with a semicolon
- if (preg_match('/;\s*$/', $line)) {
- $sqlStatements[] = $statement;
- $statement = '';
- }
- }
-
- // Parse the CREATE TABLE statements
- $sqlTables = [];
-
- foreach ($sqlStatements as $sql) {
- if (preg_match('/CREATE TABLE\s+`?([^` ]+)`?\s*\((.*)\)(.*?);/msi', $sql, $match)) {
- $tableName = $match[1];
- $columnsDefinition = $match[2];
-
- // Extract column names and data types
- $columns = [];
- $columnLines = explode("\n", $columnsDefinition);
- foreach ($columnLines as $line) {
- $line = trim($line);
- // Skip empty lines and lines that do not define columns
- if ($line == '' || strpos($line, 'PRIMARY KEY') !== false || strpos($line, 'UNIQUE KEY') !== false || strpos($line, 'KEY') === 0 || strpos($line, 'CONSTRAINT') === 0 || strpos($line, ')') === 0) {
- continue;
- }
-
- // Remove trailing comma if present
- $line = rtrim($line, ',');
-
- // Match column definition
- if (preg_match('/^`([^`]+)`\s+(.+)/', $line, $colMatch)) {
- $colName = $colMatch[1];
- $colDefinition = $colMatch[2];
-
- // Extract the data type from the column definition
- $tokens = preg_split('/\s+/', $colDefinition);
- $colType = $tokens[0];
-
- // Handle data types with parentheses (e.g., varchar(255), decimal(15,2))
- if (preg_match('/^([a-zA-Z]+)\(([^)]+)\)/', $colType, $typeMatch)) {
- $colType = $typeMatch[1] . '(' . $typeMatch[2] . ')';
- }
-
- $columns[$colName] = $colType;
- }
- }
- $sqlTables[$tableName] = $columns;
- }
- }
-
- // Get current database table structures
- $dbTables = [];
- $tablesResult = $mysqli->query("SHOW TABLES");
- while ($row = $tablesResult->fetch_row()) {
- $tableName = $row[0];
- $columnsResult = $mysqli->query("SHOW COLUMNS FROM `$tableName`");
- $columns = [];
- while ($col = $columnsResult->fetch_assoc()) {
- $columns[$col['Field']] = $col['Type'];
- }
- $columnsResult->free();
- $dbTables[$tableName] = $columns;
- }
- $tablesResult->free();
-
- // Compare the structures
- foreach ($sqlTables as $tableName => $sqlColumns) {
- if (!isset($dbTables[$tableName])) {
- $dbComparison[] = [
- 'name' => "Table `$tableName` missing in database",
- 'status' => 'Missing Table',
- ];
- continue;
- }
-
- // Compare columns
- $dbColumns = $dbTables[$tableName];
- foreach ($sqlColumns as $colName => $colType) {
- if (!isset($dbColumns[$colName])) {
- $dbComparison[] = [
- 'name' => "Column `$colName` missing in table `$tableName`",
- 'status' => 'Missing Column',
- ];
- } else {
- // Normalize data types for comparison
- $sqlColType = strtolower($colType);
- $dbColType = strtolower($dbColumns[$colName]);
-
- // Remove attributes and constraints
- $sqlColType = preg_replace('/\s+.*$/', '', $sqlColType);
- $dbColType = preg_replace('/\s+.*$/', '', $dbColType);
-
- // Remove additional attributes like unsigned, zerofill, etc.
- $sqlColType = preg_replace('/\s+unsigned|\s+zerofill|\s+binary/', '', $sqlColType);
- $dbColType = preg_replace('/\s+unsigned|\s+zerofill|\s+binary/', '', $dbColType);
-
- if ($sqlColType != $dbColType) {
- $dbComparison[] = [
- 'name' => "Data type mismatch for `$colName` in table `$tableName`",
- 'status' => "Expected: $colType, Found: {$dbColumns[$colName]}",
- ];
- }
- }
- }
-
- // Check for extra columns in the database that are not in the SQL file
- foreach ($dbColumns as $colName => $colType) {
- if (!isset($sqlColumns[$colName])) {
- $dbComparison[] = [
- 'name' => "Extra column `$colName` in table `$tableName` not present in db.sql",
- 'status' => 'Extra Column',
- ];
- }
- }
- }
-
- // Check for tables in the database not present in the db.sql file
- foreach ($dbTables as $tableName => $dbColumns) {
- if (!isset($sqlTables[$tableName])) {
- $dbComparison[] = [
- 'name' => "Extra table `$tableName` in database not present in db.sql",
- 'status' => 'Extra Table',
- ];
- }
- }
-} else {
- $dbComparison[] = [
- 'name' => 'db.sql file not found',
- 'status' => 'File Missing',
- ];
-}
-
-$mysqli->close();
?>
Every table and column matches db.sql - what a fresh install makes.
+
+
+
+ = $plural(count($differences), 'difference', 'differences') ?> from db.sql.
+
+ Something missing usually means a database update didn't finish - Maintenance > Update.
+
+ Extra tables and columns are usually left by older versions or a plugin, and do no harm.
+
+
+
+ Every table counted. Back to estimates
+
+ Row counts are the database's own estimate, so a big install opens this page quickly. Count exactly - slower on a big install.
+
+