From fb9534b0a28f295a9d2d26c1c2ce603a4cc8099a Mon Sep 17 00:00:00 2001 From: johnnyq Date: Sat, 24 Oct 2026 14:46:01 -0400 Subject: [PATCH] Rebuild Admin > Debug as a system health page: problems and warnings first with what to do and where, key figures along the top, checks by section (ITFlow and database version, updates, cron and jobs, mail queue and email to tickets, backups, PHP version, extensions and limits, HTTPS, certificate, base URL and HTTPS only, folders and disk space, server and clock drift, errors logged), database size, biggest tables and structure compared with db.sql ignoring integer display widths, uploads by folder, and a Copy for support plain-text report without host names, paths or keys; row counts use the database's estimate with a Count exactly option, the uploads scan stops after a few seconds, the certificate is checked against config_base_url instead of the request host, HTTPS counts X-Forwarded-Proto, every query degrades instead of failing when the database is behind the code, and the page no longer closes the database before the footer; new functions/system_health.php --- CHANGELOG.md | 12 + admin/debug.php | 1018 ++++++++++------------------------- css/itflow_custom.css | 50 ++ functions.php | 1 + functions/system_health.php | 650 ++++++++++++++++++++++ 5 files changed, 1007 insertions(+), 724 deletions(-) create mode 100644 functions/system_health.php 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(); ?>
-

Debug

+

Debug

+
+ + + Check again + +
+
-

Debugging

-
    -
  • If you are experiencing a problem with ITFlow, this page should help you identify any configuration issues.
  • -
  • Note: You might also need to gather error logs
  • -
-
+ + +
+ +
Everything looks good. , nothing to put right.
+
+ +
+
+ + + - +
+
    + +
  • + +
    + · - + +
    +
  • + +
+
+ -
- - - - - - - - - - - - - - - - - -
ITFlow release version
Current DB Version
Current Code Commit
Current Branch
+ +
+ + +
+ +
+
+
+ +

+ + to look at + + + +
+
    + +
  • + +
    +
    + + +
    + +
    + + +
    + +
    +
  • + +
+
+
+
- -

System Information

- - - - - - - - - -
+
- -

PHP Extensions and Configuration

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +
+
+
+ +

Database

+ · rows · +
+
- -
- - - - - - - - - - - - - -
PHP Extensions
- - - - - -
PHP Configuration
- - - - - -
Shell Commands
- - - - - -
SSL Checks
- - - - - -
Domain Checks
- - - - - -
File Permissions
- - - - - -
+

Structure

+ +

db.sql isn't here to compare with.

+ +

Every table and column matches db.sql - what a fresh install makes.

+ +

+ + 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. + +

+
class="mb-3"> + Show them +
+ + + + + + + + + + + + + + +
WhatWheredb.sqlHere
+
+
+ + +

Biggest tables

+
+ + + + + + $table_stats) { + $i++; ?> + 10 ? 'class="d-none" data-debug-more-table' : '' ?>> + + + + + + + +
TableSize
+ +
+
+ 10) { ?> + + +

+ + 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. + +

+
+ +
+ + +
+
+
+ +

Uploads

+ · +
+
+ +

Nothing uploaded yet.

+ +
+ + + + + + $folder_stats) { ?> + + + + + + + +
FolderFilesSize
+
+ + +

There are a lot of files - counting stopped after a few seconds, so these are at least what is there.

+ +
+
+
+ +
+ The report Copy for support copies +

Versions, what needs looking at, and the size of things - without host names, paths, addresses or keys. Read it before you post it.

+ +
- -

Database Structure Comparison

-
- - - - - - - - - - - - - - - -
No discrepancies found between the database and db.sql file.
-
- - -

Uploads Directory Stats

-
- - - - - - - - - -
-
- - -

Database Stats

-
- - - - - - - - - -
-
- - -

Table Stats

-
- - - - - - - - - - - - - - - - - -
Table NameFields / RowsSize (MB)
-
- +

+ For a problem this page doesn't show, the web server's and PHP's own error logs say more - + how to find them. +

-
- + // Show every table, not just the ten biggest + document.querySelectorAll('[data-debug-show-tables]').forEach(function (button) { + button.addEventListener('click', function () { + document.querySelectorAll('[data-debug-more-table]').forEach(function (row) { + row.classList.remove('d-none'); + }); + button.remove(); + }); + }); + + Debug (admin/debug.php) - a grey tile for each section's icon, and + each check as its level's icon beside what was found and what to do */ +.itflow-debug-section-icon { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 1.85rem; + height: 1.85rem; + border-radius: .5rem; + font-size: .85rem; + color: var(--bs-secondary-color); + background-color: var(--bs-secondary-bg); +} +.itflow-debug-item { + display: flex; + gap: .6rem; + align-items: flex-start; +} +.itflow-debug-item-icon { + margin-top: .2rem; +} +.itflow-debug-fix { + color: var(--bs-secondary-color); + overflow-wrap: anywhere; +} +.itflow-debug-fix code { + white-space: normal; + overflow-wrap: anywhere; +} +.itflow-debug-attention li + li { + border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); +} +.itflow-debug-bar { + height: .4rem; +} +/* Its figures wrap as flex rather than grid, so a short last row stretches + across rather than leaving empty cells */ +.itflow-debug-stats .itflow-report-stats { + display: flex; + flex-direction: row; + flex-wrap: wrap; +} +.itflow-debug-stats .itflow-report-stat { + flex: 1 1 8.5rem; +} +.itflow-debug-stats .itflow-report-stat-value { + font-size: 1.25rem; +} diff --git a/functions.php b/functions.php index 9552f8f3e..85226de91 100644 --- a/functions.php +++ b/functions.php @@ -60,6 +60,7 @@ require_once __DIR__ . '/functions/client_overview.php'; require_once __DIR__ . '/functions/tags.php'; require_once __DIR__ . '/functions/categories.php'; require_once __DIR__ . '/functions/custom_link.php'; +require_once __DIR__ . '/functions/system_health.php'; require_once __DIR__ . '/functions/ticket_fields.php'; require_once __DIR__ . '/functions/ticket.php'; require_once __DIR__ . '/functions/ticket_template.php'; diff --git a/functions/system_health.php b/functions/system_health.php new file mode 100644 index 000000000..5ae6ae4b7 --- /dev/null +++ b/functions/system_health.php @@ -0,0 +1,650 @@ + Debug + * (admin/debug.php). Checks in sections - ITFlow itself, background jobs, + * mail, backups, PHP, web and security, files and disk, the server, and + * errors logged - each a label, a level (ok, info, warning, danger), what + * was found, and what to do when it isn't right, with where to do it. Then + * the database (its size, its biggest tables and how its structure compares + * with db.sql) and the uploads folder, folder by folder. healthReport() is + * all of it as plain text to paste into a forum post or a support email - + * no host names, paths, addresses or keys. + * + * Every read is careful: the page has to open on a database the code has + * run ahead of (a missing column) and on a host with parts of PHP turned + * off, as that is when it is wanted most. + */ + +/** One check. $fix is HTML - escape anything in it. 'private' leaves its value out of healthReport(). */ +function healthItem($label, $level, $value, $fix = '', $url = '', $action = '', $private = false) { + return ['label' => (string) $label, 'level' => $level, 'value' => (string) $value, 'fix' => (string) $fix, 'url' => (string) $url, 'action' => (string) $action, 'private' => (bool) $private]; +} + +/** A query that answers null rather than stopping the page - a column the database doesn't have yet, say. */ +function healthQuery($sql) { + global $mysqli; + try { + $result = mysqli_query($mysqli, $sql); + return $result ?: null; + } catch (Throwable $e) { + return null; + } +} + +/** The first row of a query, or [] (healthQuery()). */ +function healthRow($sql) { + $result = healthQuery($sql); + return $result ? (mysqli_fetch_assoc($result) ?: []) : []; +} + +/** "3 minutes ago", "less than a minute ago", or "never". */ +function healthAgo($datetime) { + if (empty($datetime)) { + return 'never'; + } + return abs(time() - strtotime($datetime)) < 60 ? 'less than a minute ago' : timeAgo($datetime); +} + +/** "Just now", "4 min ago", "20 hr ago", "3 days ago" or "Never" - short enough for a figure along the top. */ +function healthShortAgo($datetime) { + if (empty($datetime)) { + return 'Never'; + } + $seconds = max(0, time() - strtotime($datetime)); + if ($seconds < 60) { + return 'Just now'; + } + if ($seconds < 3600) { + return floor($seconds / 60) . ' min ago'; + } + if ($seconds < 86400) { + return floor($seconds / 3600) . ' hr ago'; + } + $days = floor($seconds / 86400); + return $days . ($days == 1 ? ' day' : ' days') . ' ago'; +} + +/** The levels, worst first - icon, colour and word. */ +function healthLevels() { + return [ + 'danger' => ['icon' => 'fa-times-circle', 'tone' => 'danger', 'word' => 'Problem'], + 'warning' => ['icon' => 'fa-exclamation-triangle', 'tone' => 'warning', 'word' => 'Warning'], + 'info' => ['icon' => 'fa-info-circle', 'tone' => 'secondary', 'word' => 'Note'], + 'ok' => ['icon' => 'fa-check-circle', 'tone' => 'success', 'word' => 'OK'], + ]; +} + +/* + * The setup wizard's requirement list (setup/setup_checks.php), when it is + * here - the PHP extensions ITFlow can't run without, name => what for. The + * same list setup checks, so the two never disagree. + */ +function healthRequiredExtensions() { + $file = dirname(__DIR__) . '/setup/setup_checks.php'; + if (!function_exists('setupRequiredExtensions') && is_file($file)) { + defined('FROM_SETUP') || define('FROM_SETUP', true); // the file's guard against being opened on its own + require_once $file; + } + return function_exists('setupRequiredExtensions') ? setupRequiredExtensions() : ['mysqli' => 'the database', 'intl' => 'money and dates', 'curl' => 'mail', 'mbstring' => 'text', 'gd' => 'images', 'xml' => 'mail and documents', 'openssl' => 'encryption', 'fileinfo' => 'uploads']; +} + +/** The host ITFlow's links use ($config_base_url), without a port or path - lower case. */ +function healthBaseHost() { + global $config_base_url; + $host = preg_replace('#^[a-z]+://#i', '', trim((string) $config_base_url)); + $host = preg_replace('#[/?\#].*$#', '', $host); + return strtolower(preg_replace('/:\d+$/', '', $host)); +} + +/* + * The certificate a host serves - ['valid' => bool, 'until' => timestamp, + * 'issuer' => name], or null when it can't be reached from here (behind a + * proxy, a firewall). Four seconds at most. Asked of the configured host, + * never one a request named. + */ +function healthCertificate($host) { + if ($host === '' || !function_exists('openssl_x509_parse')) { + return null; + } + $context = stream_context_create(['ssl' => ['capture_peer_cert' => true, 'verify_peer' => false, 'verify_peer_name' => false, 'SNI_enabled' => true, 'peer_name' => $host]]); + $socket = @stream_socket_client("ssl://$host:443", $errno, $errstr, 4, STREAM_CLIENT_CONNECT, $context); + if (!$socket) { + return null; + } + $params = stream_context_get_params($socket); + fclose($socket); + $cert = $params['options']['ssl']['peer_certificate'] ?? null; + $info = $cert ? openssl_x509_parse($cert) : false; + if (!$info) { + return null; + } + $from = intval($info['validFrom_time_t']); + $until = intval($info['validTo_time_t']); + return ['valid' => time() >= $from && time() <= $until, 'until' => $until, 'issuer' => (string) ($info['issuer']['O'] ?? $info['issuer']['CN'] ?? '')]; +} + +/** Whether this request came over HTTPS - directly, or through a proxy that says so. */ +function healthRequestIsHttps() { + return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || intval($_SERVER['SERVER_PORT'] ?? 0) === 443 + || strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https'; +} + +/* + * Every section of checks, in the order the page shows them - each + * ['key', 'title', 'icon', 'items' => [healthItem(), ...]]. + */ +function healthChecks() { + return [ + ['key' => 'itflow', 'title' => 'ITFlow', 'icon' => 'fa-cube', 'items' => healthItflowChecks()], + ['key' => 'jobs', 'title' => 'Background jobs', 'icon' => 'fa-clock', 'items' => healthJobChecks()], + ['key' => 'mail', 'title' => 'Mail', 'icon' => 'fa-envelope', 'items' => healthMailChecks()], + ['key' => 'backups', 'title' => 'Backups', 'icon' => 'fa-cloud-upload-alt', 'items' => healthBackupChecks()], + ['key' => 'php', 'title' => 'PHP', 'icon' => 'fa-code', 'items' => healthPhpChecks()], + ['key' => 'web', 'title' => 'Web and security', 'icon' => 'fa-shield-alt', 'items' => healthWebChecks()], + ['key' => 'files', 'title' => 'Files and disk', 'icon' => 'fa-hdd', 'items' => healthFileChecks()], + ['key' => 'server', 'title' => 'Server', 'icon' => 'fa-server', 'items' => healthServerChecks()], + ['key' => 'errors', 'title' => 'Errors logged', 'icon' => 'fa-bug', 'items' => healthErrorChecks()], + ]; +} + +/** ITFlow itself - its version, where its code came from, the database's version against the code's, and updates. */ +function healthItflowChecks() { + $items = []; + $branch = gitCurrentBranch(); + $commit = gitCurrentCommit(); + $items[] = healthItem('Version', 'ok', APP_VERSION . ($branch !== '' ? " - $branch" . ($commit !== '' ? ' @ ' . substr($commit, 0, 7) : '') : ' - not a git checkout')); + + $installed = (string) CURRENT_DATABASE_VERSION; + $expected = (string) LATEST_DATABASE_VERSION; + if (version_compare($expected, $installed, '>')) { + $items[] = healthItem('Database version', 'danger', "$installed - this code expects $expected", + 'The files were updated but the database wasn\'t. Parts of ITFlow will fail until it is - apply the database update.', 'update.php', 'Maintenance > Update'); + } elseif (version_compare($installed, $expected, '>')) { + $items[] = healthItem('Database version', 'warning', "$installed - newer than this code ($expected)", + 'The database has been updated past these files - the code was rolled back, or the update didn\'t finish. Update the files to match.', 'update.php', 'Maintenance > Update'); + } else { + $items[] = healthItem('Database version', 'ok', "$installed - up to date"); + } + + // Updates - Maintenance > Update fetches them with git, run through exec and shell_exec + if ($commit === '') { + $items[] = healthItem('Updates', 'info', 'Not a git checkout - updated by hand', '', 'https://docs.itflow.org/updating', 'How to update'); + } elseif (!shellCommandsAvailable()) { + $items[] = healthItem('Updates', 'warning', 'PHP can\'t run git here', 'exec or shell_exec is in disable_functions in php.ini, so ITFlow can\'t check for or apply updates. Take them out of it, or update by hand.', 'https://docs.itflow.org/updating', 'How to update'); + } elseif (commandPath('git') === '') { + $items[] = healthItem('Updates', 'warning', 'git isn\'t installed', 'ITFlow checks for and applies updates with git - apt install git on Debian and Ubuntu.'); + } else { + $update = healthRow("SELECT config_update_latest_commit, config_update_pending_commits, config_update_checked_at FROM settings WHERE company_id = 1"); + $latest = (string) ($update['config_update_latest_commit'] ?? ''); + $pending = json_decode((string) ($update['config_update_pending_commits'] ?? ''), true); + if (!$update || empty($update['config_update_checked_at'])) { + $items[] = healthItem('Updates', 'info', 'Not checked yet', 'The Update Check job looks each night.', 'update.php', 'Maintenance > Update'); + } elseif ($latest !== '' && $latest !== $commit) { + $count = is_array($pending) ? count($pending) : 0; + $items[] = healthItem('Updates', 'info', ($count ? "$count " . ($count === 1 ? 'change' : 'changes') . ' waiting' : 'An update is waiting') . ' - checked ' . healthAgo($update['config_update_checked_at']), '', 'update.php', 'See what\'s new'); + } else { + $items[] = healthItem('Updates', 'ok', 'Up to date - checked ' . healthAgo($update['config_update_checked_at'])); + } + } + return $items; +} + +/** Cron - the heartbeat, the master switch, jobs that failed lately and jobs switched off. */ +function healthJobChecks() { + $items = []; + $settings = healthRow("SELECT config_enable_cron, config_cron_last_dispatch_at FROM settings WHERE company_id = 1"); + if (!$settings) { + return [healthItem('Cron', 'warning', 'Can\'t tell yet', 'The database needs updating before cron can be checked.', 'update.php', 'Maintenance > Update')]; + } + $heard = $settings['config_cron_last_dispatch_at'] ?? null; + $command = '* * * * * php ' . dirname(__DIR__) . '/cron/cron.php >/dev/null'; + if (!$heard || time() - strtotime($heard) >= 300) { + $items[] = healthItem('Cron', 'danger', 'Last ran ' . healthAgo($heard), + 'Nothing runs without it - no mail goes out, no email becomes a ticket, nothing is invoiced or backed up. Add this line to the crontab of the user that owns the ITFlow files: ' . escapeHtml($command) . '', 'cron.php', 'Maintenance > Cron'); + } else { + $items[] = healthItem('Cron', 'ok', 'Last ran ' . healthAgo($heard)); + } + if (empty($settings['config_enable_cron'])) { + $items[] = healthItem('Cron switch', 'danger', 'Off', 'Every job stops itself while it is off. Turn it on, unless this is a copy that mustn\'t email clients or charge cards.', 'cron.php', 'Maintenance > Cron'); + } + + $labels = []; + if (function_exists('cronJobRegistry')) { + foreach (cronJobRegistry() as $job) { + $labels[$job['name']] = $job['label']; + } + } + $failing = []; + $off = []; + $result = healthQuery("SELECT cron_job_name, cron_job_enabled, cron_job_last_error, cron_job_last_error_at FROM cron_jobs ORDER BY cron_job_name"); + while ($result && $row = mysqli_fetch_assoc($result)) { + $label = $labels[$row['cron_job_name']] ?? $row['cron_job_name']; + if (!intval($row['cron_job_enabled'])) { + $off[] = $label; + } elseif (!empty($row['cron_job_last_error']) && !empty($row['cron_job_last_error_at']) && strtotime($row['cron_job_last_error_at']) > time() - 86400) { + $failing[] = $label . ': ' . mb_strimwidth(trim((string) $row['cron_job_last_error']), 0, 160, '...'); + } + } + if ($failing) { + $items[] = healthItem('Jobs', 'warning', count($failing) . ' failed in the last day', escapeHtml(implode(' · ', $failing)), 'cron.php', 'Maintenance > Cron'); + } elseif ($result) { + $items[] = healthItem('Jobs', 'ok', 'None failed in the last day'); + } + if ($off) { + $items[] = healthItem('Switched off', 'info', implode(', ', $off), '', 'cron.php', 'Maintenance > Cron'); + } + return $items; +} + +/** Mail - sending set up and moving (mailQueueHealth()), mail given up on, and email becoming tickets. */ +function healthMailChecks() { + global $config_ticket_email_parse, $config_module_enable_ticketing; + $items = []; + try { + $queue = mailQueueHealth(); + if (in_array($queue['key'], ['cron_off', 'cron_stopped'], true)) { + // Said once, under Background jobs - here, only what it means for mail + $waiting = intval($queue['waiting']); + $items[] = healthItem('Sending', $waiting ? 'warning' : 'info', 'Waiting on cron' . ($waiting ? " - $waiting " . ($waiting === 1 ? 'email' : 'emails') . ' held up' : ''), + 'Email goes out when cron runs - see Background jobs.', 'mail_queue.php', 'Mail Queue'); + } elseif ($queue['level'] !== '') { + $items[] = healthItem('Sending', $queue['level'], $queue['title'], escapeHtml($queue['text']), $queue['url'], $queue['action']); + } else { + $summary = mailQueueSummary(); + $items[] = healthItem('Sending', 'ok', $summary['last_sent'] ? 'Last sent ' . healthAgo($summary['last_sent']) : 'Nothing sent yet'); + } + $summary = mailQueueSummary(); + if ($summary['failed_week']) { + $items[] = healthItem('Failed', 'warning', $summary['failed_week'] . ' in the last week', 'Tried ' . MAIL_QUEUE_TRIES . ' times and given up on - the Mail Queue says why, and can try again.', 'mail_queue.php?show=failed', 'Mail Queue'); + } else { + $items[] = healthItem('Failed', 'ok', 'None in the last week'); + } + if ($summary['retrying']) { + $items[] = healthItem('Retrying', 'info', $summary['retrying'] . ' to be tried again', '', 'mail_queue.php?show=retrying', 'Mail Queue'); + } + } catch (Throwable $e) { + $items[] = healthItem('Sending', 'warning', 'Can\'t tell yet', 'The database needs updating first.', 'update.php', 'Maintenance > Update'); + } + if (!$config_module_enable_ticketing) { + return $items; + } + if (intval($config_ticket_email_parse) === 1) { + $job = healthRow("SELECT cron_job_enabled, cron_job_last_run_at, cron_job_last_error, cron_job_last_error_at FROM cron_jobs WHERE cron_job_name = 'ticket_email_parser'"); + if ($job && !intval($job['cron_job_enabled'])) { + $items[] = healthItem('Email to tickets', 'warning', 'Its job is switched off', 'Email to the ticket mailbox isn\'t being read.', 'cron.php', 'Maintenance > Cron'); + } elseif ($job && !empty($job['cron_job_last_error']) && !empty($job['cron_job_last_error_at']) && strtotime($job['cron_job_last_error_at']) > time() - 3600) { + $items[] = healthItem('Email to tickets', 'warning', 'Failed ' . healthAgo($job['cron_job_last_error_at']), escapeHtml(mb_strimwidth((string) $job['cron_job_last_error'], 0, 200, '...')), 'settings_mail.php?tab=receiving', 'Admin > Mail'); + } elseif ($job) { + $items[] = healthItem('Email to tickets', 'ok', 'Mailbox last read ' . healthAgo($job['cron_job_last_run_at'])); + } + } else { + $items[] = healthItem('Email to tickets', 'info', 'Off', '', 'settings_mail.php?tab=receiving', 'Admin > Mail'); + } + return $items; +} + +/** Backups - the last one that worked, any that failed lately, and somewhere to put them. */ +function healthBackupChecks() { + global $config_backup_path; + $items = []; + $last = healthRow("SELECT backup_type, backup_size, backup_completed_at FROM backups WHERE backup_status = 'Complete' ORDER BY backup_completed_at DESC LIMIT 1"); + $result = healthQuery("SELECT 1 FROM backups LIMIT 1"); + if (!$result) { + return [healthItem('Backups', 'warning', 'Can\'t tell yet', 'The database needs updating first.', 'update.php', 'Maintenance > Update')]; + } + if (!$last) { + $items[] = healthItem('Last backup', 'warning', 'None yet', 'Take one now, and let the Backup job keep taking them.', 'backup.php', 'Maintenance > Backup'); + } else { + $age = time() - strtotime($last['backup_completed_at']); + $value = healthAgo($last['backup_completed_at']) . ' - ' . (function_exists('backupTypeLabel') ? backupTypeLabel((string) $last['backup_type']) : $last['backup_type']) . ', ' . formatBytes(intval($last['backup_size']), 1); + $items[] = $age > 7 * 86400 + ? healthItem('Last backup', 'warning', $value, 'It has been more than a week. Check the Backup job is on, and take one now.', 'backup.php', 'Maintenance > Backup') + : healthItem('Last backup', 'ok', $value); + } + // One that failed this week - unless one has worked since + $failed = healthRow("SELECT backup_error, backup_created_at FROM backups WHERE backup_status = 'Failed' AND backup_created_at > NOW() - INTERVAL 7 DAY ORDER BY backup_created_at DESC LIMIT 1"); + if ($failed && (!$last || strtotime($failed['backup_created_at']) > strtotime($last['backup_completed_at']))) { + $items[] = healthItem('Failed', 'warning', healthAgo($failed['backup_created_at']), escapeHtml(mb_strimwidth((string) $failed['backup_error'], 0, 200, '...')), 'backup.php', 'Maintenance > Backup'); + } + $dir = !empty($config_backup_path) ? rtrim($config_backup_path, '/\\') : dirname(__DIR__) . '/uploads/backups'; + $writable = is_dir($dir) ? is_writable($dir) : is_writable(dirname($dir)); + $items[] = $writable + ? healthItem('Backup folder', 'ok', 'Writable', '', '', '', true) + : healthItem('Backup folder', 'danger', 'Can\'t be written to', 'Backups are written to ' . escapeHtml($dir) . ' - make it writable by the web server and the user cron runs as.', 'backup.php', 'Maintenance > Backup', true); + return $items; +} + +/** PHP - its version, the extensions ITFlow needs, and the limits a backup or a big upload runs into. */ +function healthPhpChecks() { + $items = []; + $extensions = healthRequiredExtensions(); // loads setup's requirements, the minimum version with them + $minimum = defined('SETUP_PHP_MINIMUM') ? SETUP_PHP_MINIMUM : '8.2.0'; + $items[] = version_compare(PHP_VERSION, $minimum, '>=') + ? healthItem('Version', 'ok', PHP_VERSION) + : healthItem('Version', 'danger', PHP_VERSION, "ITFlow needs PHP $minimum or later."); + + $missing = []; + foreach ($extensions as $extension => $what) { + if (!extension_loaded($extension)) { + $missing[$extension] = $what; + } + } + if ($missing) { + foreach ($missing as $extension => $what) { + $items[] = healthItem("$extension extension", 'danger', 'Not installed', 'ITFlow uses it for ' . escapeHtml($what) . ' - apt install php-' . escapeHtml($extension) . ' on Debian and Ubuntu, then restart the web server.'); + } + } else { + $items[] = healthItem('Extensions', 'ok', 'All ' . count($extensions) . ' ITFlow needs'); + } + $items[] = class_exists('ZipArchive') + ? healthItem('zip extension', 'ok', 'Installed') + : healthItem('zip extension', 'warning', 'Not installed', 'Backups and client exports are zip files - apt install php-zip.'); + + $bytes = function ($value) { + return function_exists('backupParseIniBytes') ? backupParseIniBytes($value) : intval($value); + }; + $upload = ini_get('upload_max_filesize'); + $post = ini_get('post_max_size'); + $room = 500 * 1024 * 1024; + $items[] = $bytes($upload) >= $room && $bytes($post) >= $room + ? healthItem('Upload size', 'ok', "upload_max_filesize $upload, post_max_size $post") + : healthItem('Upload size', 'warning', "upload_max_filesize $upload, post_max_size $post", 'Restoring a backup and uploading large files need room - set both to 500M in php.ini.'); + $memory = (string) ini_get('memory_limit'); + $items[] = $memory === '-1' || $bytes($memory) >= 512 * 1024 * 1024 + ? healthItem('Memory limit', 'ok', $memory === '-1' ? 'No limit' : $memory) + : healthItem('Memory limit', 'warning', $memory, 'Big exports, reports and backups can run out - set memory_limit = 512M in php.ini.'); + $time = intval(ini_get('max_execution_time')); + $items[] = $time === 0 || $time >= 300 + ? healthItem('Time limit', 'ok', $time === 0 ? 'No limit' : "$time seconds") + : healthItem('Time limit', 'warning', "$time seconds", 'A restore or a big export in the browser can stop part way - set max_execution_time = 300 in php.ini.'); + + return $items; +} + +/** How the site is reached - HTTPS, its certificate, the address in its links and sign-in over HTTPS only. */ +function healthWebChecks() { + global $config_https_only; + $items = []; + $https = healthRequestIsHttps(); + $items[] = $https + ? healthItem('HTTPS', 'ok', 'Yes') + : healthItem('HTTPS', 'warning', 'No', 'Sign-ins, passwords and client data cross the network - put a certificate on the site (Let\'s Encrypt is free) and use https://.'); + + $host = healthBaseHost(); + if ($https) { + $cert = healthCertificate($host); + if ($cert === null) { + $items[] = healthItem('Certificate', 'info', 'Can\'t be checked from here', 'This server couldn\'t reach its own address on port 443 - normal behind a proxy or a firewall.'); + } elseif (!$cert['valid']) { + $items[] = healthItem('Certificate', 'danger', 'Expired or not yet valid', 'Renew the site\'s certificate.'); + } else { + $days = intval(floor(($cert['until'] - time()) / 86400)); + $value = 'Valid until ' . date('M j, Y', $cert['until']) . ($cert['issuer'] !== '' ? ' - ' . $cert['issuer'] : ''); + $items[] = $days < 14 + ? healthItem('Certificate', 'warning', $value, "It runs out in $days " . ($days === 1 ? 'day' : 'days') . ' - check it renews.') + : healthItem('Certificate', 'ok', $value); + } + } + + $request_host = strtolower(preg_replace('/:\d+$/', '', trim((string) ($_SERVER['HTTP_HOST'] ?? '')))); + if ($host === '') { + $items[] = healthItem('Address in links', 'danger', 'Not set', 'Emails link to ITFlow with $config_base_url in config.php - set it to the address you reach ITFlow on.', '', '', true); + } elseif ($request_host !== '' && $request_host !== $host) { + $items[] = healthItem('Address in links', 'warning', "Emails link to $host, but you are on $request_host", + 'Links in ITFlow\'s emails and the client portal go to $config_base_url in config.php. If that isn\'t the address clients use, change it there.', '', '', true); + } else { + $items[] = healthItem('Address in links', 'ok', $host, '', '', '', true); + } + $fqdn = (bool) preg_match('/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $host); + if ($host !== '' && !$fqdn) { + $items[] = healthItem('Domain name', 'warning', 'An IP address or a local name', 'Clients follow links in ITFlow\'s emails - give it a domain name, like itflow.example.com.', '', '', true); + } + if (isset($config_https_only) && !$config_https_only) { + $items[] = healthItem('HTTPS only', 'warning', 'Off - $config_https_only is FALSE', 'Sign-in cookies can travel over plain HTTP. Fine for a test install - on a real one, set it to TRUE in config.php once the site has a certificate.'); + } + return $items; +} + +/** The folders ITFlow writes to, and room on the disk. */ +function healthFileChecks() { + $items = []; + $root = dirname(__DIR__); + $uploads = "$root/uploads"; + $items[] = is_dir($uploads) && is_writable($uploads) + ? healthItem('Uploads folder', 'ok', 'Writable', '', '', '', true) + : healthItem('Uploads folder', 'danger', is_dir($uploads) ? 'Can\'t be written to' : 'Missing', 'Logos, avatars, attachments and files go in ' . escapeHtml($uploads) . ' - make it writable by the web server.', '', '', true); + $tmp = "$uploads/tmp"; + if (is_dir($tmp) && !is_writable($tmp)) { + $items[] = healthItem('Uploads tmp folder', 'danger', 'Can\'t be written to', 'Email to tickets and imports work in ' . escapeHtml($tmp) . ' - make it writable by the web server and the user cron runs as.', '', '', true); + } + $items[] = is_writable($root) + ? healthItem('ITFlow folder', 'ok', 'Writable - one-click updates can replace the files', '', '', '', true) + : healthItem('ITFlow folder', 'info', 'Read only to the web server', 'One-click updates need to write here - update by hand, or make the files belong to the web server\'s user.', '', '', true); + + $free = @disk_free_space($uploads); + $total = @disk_total_space($uploads); + if ($free !== false && $total) { + $value = formatBytes($free, 1) . ' free of ' . formatBytes($total, 1); + if ($free < 1024 * 1024 * 1024) { + $items[] = healthItem('Disk', 'danger', $value, 'Uploads, backups and the database will start failing - free some space.'); + } elseif ($free < 5 * 1024 * 1024 * 1024) { + $items[] = healthItem('Disk', 'warning', $value, 'Running low - backups alone can take a lot.'); + } else { + $items[] = healthItem('Disk', 'ok', $value); + } + } + return $items; +} + +/** What it runs on, and whether the clocks agree - PHP's, the database's and ITFlow's time zone. */ +function healthServerChecks() { + global $mysqli, $config_timezone; + $items = []; + $items[] = healthItem('Operating system', 'info', php_uname('s') . ' ' . php_uname('r')); + $items[] = healthItem('Web server', 'info', ($_SERVER['SERVER_SOFTWARE'] ?? 'Unknown') . ' - PHP ' . PHP_SAPI); + $items[] = healthItem('Database server', 'info', (string) mysqli_get_server_info($mysqli)); + $zone = date_default_timezone_get(); + $items[] = healthItem('Time zone', 'info', $zone . (!empty($config_timezone) && $config_timezone !== $zone ? " (ITFlow is set to $config_timezone)" : '')); + $db = healthRow("SELECT NOW() AS now"); + if (!empty($db['now'])) { + $drift = strtotime($db['now']) - time(); + $items[] = abs($drift) > 120 + ? healthItem('Clocks', 'warning', 'The database is ' . round(abs($drift) / 60) . ' minutes ' . ($drift > 0 ? 'ahead of' : 'behind') . ' PHP', + 'Times on tickets, SLAs and logs will be off. Check both servers keep time (NTP) and use the same time zone.') + : healthItem('Clocks', 'ok', 'PHP and the database agree'); + } + return $items; +} + +/** Errors in the app logs in the last day - how many, and the latest few. */ +function healthErrorChecks() { + $count = healthRow("SELECT COUNT(*) AS n FROM app_logs WHERE app_log_type = 'error' AND app_log_created_at > NOW() - INTERVAL 1 DAY"); + if (!$count) { + return []; + } + $n = intval($count['n']); + if (!$n) { + return [healthItem('Last 24 hours', 'ok', 'No errors', '', 'app_logs.php', 'App Logs')]; + } + $latest = []; + $result = healthQuery("SELECT app_log_category, app_log_details, app_log_created_at FROM app_logs WHERE app_log_type = 'error' AND app_log_created_at > NOW() - INTERVAL 1 DAY ORDER BY app_log_id DESC LIMIT 3"); + while ($result && $row = mysqli_fetch_assoc($result)) { + $latest[] = escapeHtml(healthAgo($row['app_log_created_at']) . ' - ' . $row['app_log_category'] . ': ' . mb_strimwidth((string) $row['app_log_details'], 0, 140, '...')); + } + return [healthItem('Last 24 hours', 'warning', "$n " . ($n === 1 ? 'error' : 'errors'), implode('
', $latest), 'app_logs.php?view=error', 'App Logs')]; +} + +/** How many checks are at each level - and those that aren't ok or info, worst first, each with its section's title. */ +function healthSummary(array $sections) { + $counts = ['danger' => 0, 'warning' => 0, 'info' => 0, 'ok' => 0]; + $attention = []; + foreach ($sections as $section) { + foreach ($section['items'] as $item) { + $counts[$item['level']]++; + if ($item['level'] === 'danger' || $item['level'] === 'warning') { + $attention[] = $item + ['section' => $section['title']]; + } + } + } + usort($attention, function ($a, $b) { + return ($a['level'] === 'danger' ? 0 : 1) <=> ($b['level'] === 'danger' ? 0 : 1); + }); + return ['counts' => $counts, 'attention' => $attention]; +} + +/* + * The database - its tables from information_schema in one go: ['tables' => + * [name => rows, bytes], 'size', 'rows', 'exact']. Rows are InnoDB's + * estimate, unless $exact - then each table is counted, which takes a while + * on a big install. + */ +function healthDatabaseTables($exact = false) { + global $database; + $tables = []; + $size = $rows = 0; + $schema = escapeSql($database); + $result = healthQuery("SELECT TABLE_NAME, TABLE_ROWS, DATA_LENGTH + INDEX_LENGTH AS bytes FROM information_schema.TABLES WHERE TABLE_SCHEMA = '$schema' AND TABLE_TYPE = 'BASE TABLE' ORDER BY bytes DESC, TABLE_NAME"); + while ($result && $row = mysqli_fetch_assoc($result)) { + $count = intval($row['TABLE_ROWS']); + if ($exact) { + $counted = healthRow("SELECT COUNT(*) AS n FROM `" . str_replace('`', '``', $row['TABLE_NAME']) . "`"); + $count = $counted ? intval($counted['n']) : $count; + } + $tables[$row['TABLE_NAME']] = ['rows' => $count, 'bytes' => intval($row['bytes'])]; + $size += intval($row['bytes']); + $rows += $count; + } + return ['tables' => $tables, 'size' => $size, 'rows' => $rows, 'exact' => (bool) $exact]; +} + +/** A column type as compared - lower case, without integer display widths, which MySQL 8 no longer shows ("int(11)" is "int"). */ +function healthColumnType($type) { + $type = strtolower(trim(preg_replace('/\s+/', ' ', (string) $type))); + return preg_replace('/^(tinyint|smallint|mediumint|int|bigint)\(\d+\)/', '$1', $type); +} + +/* + * How the database's tables and columns compare with db.sql - what a fresh + * install makes. Each difference ['what' => missing table, missing column, + * extra table, extra column or type, 'table', 'column', 'expected', 'found']. + * null when db.sql isn't there. + */ +function healthStructureDifferences() { + global $database; + $file = dirname(__DIR__) . '/db.sql'; + if (!is_file($file)) { + return null; + } + $expected = []; + if (preg_match_all('/CREATE TABLE\s+`([^`]+)`\s*\((.*?)\n\)[^;]*;/s', (string) file_get_contents($file), $creates, PREG_SET_ORDER)) { + foreach ($creates as $create) { + $columns = []; + foreach (explode("\n", $create[2]) as $line) { + if (preg_match('/^\s*`([^`]+)`\s+([a-z]+(?:\([^)]*\))?(?:\s+unsigned)?)/i', $line, $column)) { + $columns[$column[1]] = $column[2]; + } + } + $expected[$create[1]] = $columns; + } + } + $found = []; + $schema = escapeSql($database); + $result = healthQuery("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '$schema' ORDER BY TABLE_NAME, ORDINAL_POSITION"); + while ($result && $row = mysqli_fetch_assoc($result)) { + $found[$row['TABLE_NAME']][$row['COLUMN_NAME']] = $row['COLUMN_TYPE']; + } + $differences = []; + foreach ($expected as $table => $columns) { + if (!isset($found[$table])) { + $differences[] = ['what' => 'Missing table', 'table' => $table, 'column' => '', 'expected' => '', 'found' => '']; + continue; + } + foreach ($columns as $column => $type) { + if (!isset($found[$table][$column])) { + $differences[] = ['what' => 'Missing column', 'table' => $table, 'column' => $column, 'expected' => $type, 'found' => '']; + } elseif (healthColumnType($type) !== healthColumnType($found[$table][$column])) { + $differences[] = ['what' => 'Different type', 'table' => $table, 'column' => $column, 'expected' => $type, 'found' => $found[$table][$column]]; + } + } + foreach ($found[$table] as $column => $type) { + if (!isset($columns[$column])) { + $differences[] = ['what' => 'Extra column', 'table' => $table, 'column' => $column, 'expected' => '', 'found' => $type]; + } + } + } + foreach (array_keys($found) as $table) { + if (!isset($expected[$table])) { + $differences[] = ['what' => 'Extra table', 'table' => $table, 'column' => '', 'expected' => '', 'found' => '']; + } + } + return $differences; +} + +/* + * The uploads folder, folder by folder - [name => ['files', 'bytes']], the + * biggest first, with 'partial' => true when it took longer than $seconds + * and stopped counting (so the totals are at least that). + */ +function healthUploads($seconds = 4) { + $root = dirname(__DIR__) . '/uploads'; + $folders = []; + $partial = false; + if (!is_dir($root)) { + return ['folders' => [], 'files' => 0, 'bytes' => 0, 'partial' => false]; + } + $stop = microtime(true) + $seconds; + try { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY, RecursiveIteratorIterator::CATCH_GET_CHILD); + foreach ($iterator as $file) { + if (!$file->isFile()) { + continue; + } + $relative = substr($file->getPathname(), strlen($root) + 1); + $top = strpos($relative, DIRECTORY_SEPARATOR) !== false ? strtok($relative, DIRECTORY_SEPARATOR) : '(top level)'; + $folders[$top]['files'] = ($folders[$top]['files'] ?? 0) + 1; + $folders[$top]['bytes'] = ($folders[$top]['bytes'] ?? 0) + $file->getSize(); + if (microtime(true) > $stop) { + $partial = true; + break; + } + } + } catch (Throwable $e) { + $partial = true; + } + uasort($folders, function ($a, $b) { + return $b['bytes'] <=> $a['bytes']; + }); + return ['folders' => $folders, 'files' => array_sum(array_column($folders, 'files')), 'bytes' => array_sum(array_column($folders, 'bytes')), 'partial' => $partial]; +} + +/* + * Everything as plain text, to paste into a forum post or a support email - + * versions, what needs attention, and the size of things. No host names, + * paths, addresses or keys: a check marked private gives its label alone. + */ +function healthReport(array $sections, array $database, $differences, array $uploads) { + $summary = healthSummary($sections); + $lines = ['ITFlow ' . APP_VERSION . ' - debug report, ' . date('Y-m-d H:i T'), '']; + foreach ($sections as $section) { + $lines[] = '[' . $section['title'] . ']'; + foreach ($section['items'] as $item) { + $mark = ['danger' => 'X', 'warning' => '!', 'info' => '-', 'ok' => 'ok'][$item['level']]; + $lines[] = str_pad($mark, 3) . $item['label'] . ($item['private'] ? '' : ': ' . $item['value']); + } + $lines[] = ''; + } + $lines[] = '[Database]'; + $lines[] = count($database['tables']) . ' tables, ' . (empty($database['exact']) ? 'about ' : '') . number_format($database['rows']) . ' rows, ' . formatBytes($database['size'], 1); + $lines[] = $differences === null ? 'db.sql not found' : ($differences ? count($differences) . ' differences from db.sql:' : 'Matches db.sql'); + foreach (array_slice((array) $differences, 0, 30) as $difference) { + $lines[] = ' ' . $difference['what'] . ': ' . $difference['table'] . ($difference['column'] !== '' ? '.' . $difference['column'] : '') + . ($difference['expected'] !== '' || $difference['found'] !== '' ? ' (' . trim($difference['expected'] . ($difference['found'] !== '' ? ' -> ' . $difference['found'] : '')) . ')' : ''); + } + $lines[] = ''; + $lines[] = '[Uploads]'; + $lines[] = number_format($uploads['files']) . ' files, ' . formatBytes($uploads['bytes'], 1) . ($uploads['partial'] ? ' (at least - stopped counting)' : ''); + $lines[] = ''; + $lines[] = $summary['counts']['danger'] . ' problems, ' . $summary['counts']['warning'] . ' warnings'; + return implode("\n", $lines); +}