From 93113fbd3fa7f17ef83fa7d36ad4df1132280232 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Wed, 9 Sep 2026 17:39:21 +0200 Subject: [PATCH 1/5] feat(database): add a database page with the DB Doctor checks The Monitoring app reports what the database is, never whether it is configured well, and the answer to that already exists in Nextcloud's DB Doctor app. This ports its check engine into a third page, between Status and Background jobs. What came across: - the rule engine (Rule, RuleResult, RuleSet, the expression evaluator and the advisor) and the probe that snapshots the server - the MySQL/MariaDB rule set, ported in DB Doctor from phpMyAdmin's advisor and carrying its GPL-2.0-or-later attribution, plus the PostgreSQL and Nextcloud schema rules - the dialog for pointing the checks at a separate connection, so they can run as a user with rights Nextcloud itself does not need. It stores under serverinfo's own app config, with the password in the credentials manager - the live connections, throughput and latency boxes What deliberately did not: - the score, grade, mascot, history and trend charts. This is a page about what is wrong, not a gamified dashboard - everything that writes. Failing checks offer the config snippet to paste into my.cnf or postgresql.conf, but nothing applies a value: a bundled monitoring app has no business holding write access to the database The checks run once when the page is first opened rather than on a button, and the page keeps its results when you switch tabs and come back. Two departures from how DB Doctor does it. Its dashboard polls /insights/metrics every three seconds and /check/ping once a second; here both are one endpoint that returns the metrics and times its own round trip, because the request cycle costs orders of magnitude more than the queries inside it. And both live endpoints close the PHP session before doing their work: holding it for the length of a five-second check made the Status page's two-second poll queue up behind it, far enough to exhaust the web server's workers. The charts reuse the chart.js setup the CPU and memory graphs already use, rather than porting DB Doctor's own sparkline and latency chart, and they poll only while the database page is the one on screen and the tab is visible. Signed-off-by: Frank Karlitschek Assisted-by: Claude Code:claude-opus-5 Co-Authored-By: Claude Opus 5 Signed-off-by: Daniel Kesselberg --- appinfo/routes.php | 5 + lib/Controller/DatabaseController.php | 260 ++++ lib/Database/Advisor.php | 289 +++++ lib/Database/ApplyDescriptor.php | 51 + lib/Database/DatabaseProbe.php | 484 ++++++++ lib/Database/ExpressionEvaluator.php | 427 +++++++ lib/Database/ExpressionException.php | 13 + lib/Database/LiveMetrics.php | 134 +++ lib/Database/NextcloudSchema.php | 215 ++++ lib/Database/Rule.php | 68 ++ lib/Database/RuleResult.php | 65 + lib/Database/RuleSet.php | 30 + lib/Database/RuleSet/Mysql.php | 1058 +++++++++++++++++ lib/Database/RuleSet/NextcloudSchemaRules.php | 76 ++ lib/Database/RuleSet/Postgres.php | 173 +++ lib/Database/Snapshot.php | 82 ++ src/components/DatabaseLiveCharts.vue | 256 ++++ src/components/DatabaseRuleCard.vue | 253 ++++ src/components/DatabaseSettingsDialog.vue | 165 +++ src/components/DatabaseSnippetDialog.vue | 116 ++ src/components/SettingsNavigation.vue | 7 + src/types.ts | 44 +- src/views/DatabaseView.vue | 256 ++++ src/views/SettingsAdmin.vue | 2 + tests/psalm-baseline.xml | 91 ++ tests/stub.phpstub | 33 + 26 files changed, 4652 insertions(+), 1 deletion(-) create mode 100644 lib/Controller/DatabaseController.php create mode 100644 lib/Database/Advisor.php create mode 100644 lib/Database/ApplyDescriptor.php create mode 100644 lib/Database/DatabaseProbe.php create mode 100644 lib/Database/ExpressionEvaluator.php create mode 100644 lib/Database/ExpressionException.php create mode 100644 lib/Database/LiveMetrics.php create mode 100644 lib/Database/NextcloudSchema.php create mode 100644 lib/Database/Rule.php create mode 100644 lib/Database/RuleResult.php create mode 100644 lib/Database/RuleSet.php create mode 100644 lib/Database/RuleSet/Mysql.php create mode 100644 lib/Database/RuleSet/NextcloudSchemaRules.php create mode 100644 lib/Database/RuleSet/Postgres.php create mode 100644 lib/Database/Snapshot.php create mode 100644 src/components/DatabaseLiveCharts.vue create mode 100644 src/components/DatabaseRuleCard.vue create mode 100644 src/components/DatabaseSettingsDialog.vue create mode 100644 src/components/DatabaseSnippetDialog.vue create mode 100644 src/views/DatabaseView.vue diff --git a/appinfo/routes.php b/appinfo/routes.php index 36416dee..942da39a 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -22,6 +22,11 @@ ['name' => 'page#update', 'url' => '/update', 'verb' => 'GET'], ['name' => 'page#periodic', 'url' => '/periodic', 'verb' => 'GET'], ['name' => 'page#phpinfo', 'url' => '/phpinfo', 'verb' => 'GET'], + ['name' => 'database#check', 'url' => '/database/check', 'verb' => 'GET'], + ['name' => 'database#live', 'url' => '/database/live', 'verb' => 'GET'], + ['name' => 'database#settings', 'url' => '/database/settings', 'verb' => 'GET'], + ['name' => 'database#updateSettings', 'url' => '/database/settings', 'verb' => 'PUT'], + ['name' => 'database#testConnection', 'url' => '/database/test-connection', 'verb' => 'POST'], ], 'ocs' => [ ['name' => 'api#info', 'url' => '/api/v1/info', 'verb' => 'GET'], diff --git a/lib/Controller/DatabaseController.php b/lib/Controller/DatabaseController.php new file mode 100644 index 00000000..84c8adbb --- /dev/null +++ b/lib/Controller/DatabaseController.php @@ -0,0 +1,260 @@ +session->close(); + + try { + $snapshot = $this->probe->snapshot(); + } catch (\Throwable $e) { + $this->logger->error('serverinfo: database snapshot failed: {msg}', [ + 'msg' => $e->getMessage(), 'app' => Application::APP_ID, 'exception' => $e, + ]); + + return new JSONResponse([ + 'supported' => false, + 'reason' => 'error', + 'message' => $e->getMessage(), + ]); + } + + if (!$snapshot->isSupported()) { + return new JSONResponse([ + 'supported' => false, + 'reason' => 'flavour', + 'flavour' => $snapshot->flavour, + ]); + } + + $results = $this->advisor->run($this->ruleSetFor($snapshot), $snapshot); + + return new JSONResponse([ + 'supported' => true, + 'flavour' => $snapshot->flavour, + 'version' => $snapshot->version, + 'ranAt' => time(), + 'results' => array_map(static fn ($result) => $result->jsonSerialize(), $results), + ]); + } + + /** + * Connections, throughput and round-trip latency in one response. + * + * Deliberately one endpoint rather than two: each poll costs a full + * Nextcloud request cycle, which dwarfs the queries themselves, so asking + * for the metrics and timing the round trip together halves the traffic. + */ + public function live(): JSONResponse { + // Held for the length of the request otherwise, which would make the + // page's own polling queue up behind it. Nothing below writes to it. + $this->session->close(); + + $started = microtime(true); + try { + $this->probe->ping(); + $latencyMs = round((microtime(true) - $started) * 1000.0, 2); + } catch (\Throwable) { + $latencyMs = null; + } + + try { + $metrics = $this->liveMetrics->collect(); + } catch (\Throwable $e) { + $this->logger->debug('serverinfo: live database metrics failed: {msg}', [ + 'msg' => $e->getMessage(), 'app' => Application::APP_ID, + ]); + + return new JSONResponse(['available' => false, 'latencyMs' => $latencyMs]); + } + + return new JSONResponse([ + 'available' => true, + 'latencyMs' => $latencyMs, + 'connections' => $metrics['connections'], + 'throughput' => $metrics['throughput'], + 'threadsRunning' => $metrics['threadsRunning'], + ]); + } + + /** + * The optional connection used instead of Nextcloud's own, so that the + * checks can be run as a user with rights Nextcloud's does not need. + */ + public function settings(): JSONResponse { + return new JSONResponse(['override' => $this->override()]); + } + + #[PasswordConfirmationRequired] + public function updateSettings( + ?string $host = null, + ?int $port = null, + ?string $user = null, + ?string $database = null, + ?string $driver = null, + #[SensitiveParameter] + ?string $password = null, + ?bool $clearPassword = false, + ): JSONResponse { + if ($host !== null) { + $host = trim($host); + if ($host !== '' && !$this->probe->isHostAllowed($host)) { + return new JSONResponse([ + 'message' => 'This host is not on serverinfo.allowed_override_hosts in config.php.', + ], Http::STATUS_FORBIDDEN); + } + $this->config->setAppValue(Application::APP_ID, 'override.host', $host); + } + if ($port !== null) { + $this->config->setAppValue(Application::APP_ID, 'override.port', (string)max(0, $port)); + } + if ($user !== null) { + $this->config->setAppValue(Application::APP_ID, 'override.user', trim($user)); + } + if ($database !== null) { + $this->config->setAppValue(Application::APP_ID, 'override.database', trim($database)); + } + if ($driver !== null && in_array($driver, self::DRIVERS, true)) { + $this->config->setAppValue(Application::APP_ID, 'override.driver', $driver); + } + + if ($password !== null && $password !== '') { + $this->credentials->store('', Application::APP_ID . ':override.password', $password); + } elseif ($clearPassword === true) { + $this->credentials->delete('', Application::APP_ID . ':override.password'); + } + + return new JSONResponse(['override' => $this->override()]); + } + + /** + * Dials the supplied parameters without storing them, so an admin can find + * out whether they work before committing to them. + */ + #[PasswordConfirmationRequired] + public function testConnection( + string $host, + int $port, + string $user, + #[SensitiveParameter] + string $password, + string $database, + string $driver = 'pdo_mysql', + ): JSONResponse { + if (!in_array($driver, self::DRIVERS, true)) { + return new JSONResponse(['ok' => false, 'message' => 'Unsupported driver.'], Http::STATUS_BAD_REQUEST); + } + if (!$this->probe->isHostAllowed($host)) { + return new JSONResponse([ + 'ok' => false, + 'message' => 'This host is not on serverinfo.allowed_override_hosts in config.php.', + ], Http::STATUS_FORBIDDEN); + } + + try { + $connection = DriverManager::getConnection([ + 'driver' => $driver, + 'host' => $host, + 'port' => $port > 0 ? $port : null, + 'user' => $user, + 'password' => $password, + 'dbname' => $database, + ]); + $connection->connect(); + $version = $connection->fetchOne($driver === 'pdo_pgsql' ? 'SHOW server_version' : 'SELECT VERSION()'); + $connection->close(); + + return new JSONResponse(['ok' => true, 'version' => (string)$version]); + } catch (\Throwable $e) { + return new JSONResponse(['ok' => false, 'message' => $e->getMessage()]); + } + } + + /** + * @return array the stored override, never the password + */ + private function override(): array { + $host = $this->config->getAppValue(Application::APP_ID, 'override.host', ''); + + return [ + 'host' => $host, + 'port' => (int)$this->config->getAppValue(Application::APP_ID, 'override.port', '0'), + 'user' => $this->config->getAppValue(Application::APP_ID, 'override.user', ''), + 'database' => $this->config->getAppValue(Application::APP_ID, 'override.database', ''), + 'driver' => $this->config->getAppValue(Application::APP_ID, 'override.driver', 'pdo_mysql'), + 'passwordSet' => $host !== '' + && $this->credentials->retrieve('', Application::APP_ID . ':override.password') !== null, + ]; + } + + private function ruleSetFor(Snapshot $snapshot): RuleSet { + return match ($snapshot->flavour) { + Snapshot::FLAVOUR_PGSQL => $this->postgresRules, + default => $this->mysqlRules, + }; + } +} diff --git a/lib/Database/Advisor.php b/lib/Database/Advisor.php new file mode 100644 index 00000000..3daa10a1 --- /dev/null +++ b/lib/Database/Advisor.php @@ -0,0 +1,289 @@ + + */ + private const COUNTER_SENSITIVE_RULE_IDS = [ + 'Aborted_connects', + 'Thread_cache_hit_rate', + 'Slow_query_ratio', + 'Select_full_join', + 'Sort_merge_passes', + 'Created_tmp_disk_tables', + 'Innodb_buffer_pool_hit_rate', + 'Query_cache_efficiency', + 'Key_buffer_hit_rate', + 'Table_locks_waited', + 'Binlog_cache_disk_use', + 'Slow_query_rate', + 'Sort_rows', + 'Sort_merge_passes_rate', + 'Join_without_index_rate', + 'Handler_read_first_rate', + 'Handler_read_rnd_rate', + 'Handler_read_rnd_next_rate', + 'Open_files_rate', + 'Table_lock_wait_rate', + 'Aborted_connects_rate', + 'Aborted_clients_percentage', + 'Aborted_clients_rate', + 'Slow_launch_threads', + ]; + + public function __construct( + private ExpressionEvaluator $evaluator, + private LoggerInterface $logger, + ) { + } + + /** + * @return list + */ + public function run(RuleSet $ruleSet, Snapshot $snapshot): array { + $context = $snapshot->context(); + $results = []; + + foreach ($ruleSet->rules() as $rule) { + $results[] = $this->evaluateRule($rule, $snapshot, $context); + } + + return $results; + } + + /** + * @param array $context + */ + private function evaluateRule(Rule $rule, Snapshot $snapshot, array $context): RuleResult { + // Skip if any required snapshot key is missing. This is common + // across MySQL versions (e.g. query_cache_* removed in 8.0) and + // across flavours (a MariaDB-specific status doesn't exist on + // MySQL). Skipping is preferable to evaluating with a default + // 0 — the result would be misleading. + foreach ($rule->requires as $req) { + if (!array_key_exists($req, $context)) { + return new RuleResult( + $rule, + RuleResult::STATUS_SKIPPED, + skipReason: "Required value '$req' not present in this database's reports.", + ); + } + } + + // Uptime gate for counter-derived rules (see the constant docs). + if (in_array($rule->id, self::COUNTER_SENSITIVE_RULE_IDS, true)) { + $uptimeHours = $context['Uptime_hours'] ?? null; + if (is_numeric($uptimeHours) && (float)$uptimeHours < self::MIN_UPTIME_HOURS) { + return new RuleResult( + $rule, + RuleResult::STATUS_SKIPPED, + skipReason: sprintf( + 'Server has been up %.1f h; counter-based statistics need at least %d h to be reliable.', + (float)$uptimeHours, + (int)self::MIN_UPTIME_HOURS, + ), + ); + } + } + + try { + $value = $this->evaluator->evaluate($rule->formula, $context); + } catch (ExpressionException $e) { + $this->logger->warning( + 'serverinfo: failed to evaluate formula for rule {id}: {msg}', + ['id' => $rule->id, 'msg' => $e->getMessage(), 'app' => 'serverinfo'], + ); + return new RuleResult( + $rule, + RuleResult::STATUS_SKIPPED, + skipReason: 'Formula could not be evaluated: ' . $e->getMessage(), + ); + } + + $valueFloat = is_bool($value) ? ($value ? 1.0 : 0.0) : $value; + + // `test` is evaluated against the same context augmented with + // `value`. phpMyAdmin's rules consistently reference `value` + // as the result of `formula` so we follow that convention. + $testCtx = $context; + $testCtx['value'] = $valueFloat; + + try { + $failed = $this->evaluator->evaluate($rule->test, $testCtx); + $failed = is_bool($failed) ? $failed : ($valueFloat !== 0.0); + } catch (ExpressionException $e) { + $this->logger->warning( + 'serverinfo: failed to evaluate test for rule {id}: {msg}', + ['id' => $rule->id, 'msg' => $e->getMessage(), 'app' => 'serverinfo'], + ); + return new RuleResult( + $rule, + RuleResult::STATUS_SKIPPED, + skipReason: 'Test could not be evaluated: ' . $e->getMessage(), + ); + } + + if (!$failed) { + return new RuleResult($rule, RuleResult::STATUS_OK, value: $valueFloat); + } + + // Failure path — interpolate the justification. + $justification = $this->interpolate( + $rule->justification, + $rule->justificationFormula, + $testCtx, + ); + + // Optional apply descriptor. + $apply = null; + if ($rule->apply !== null) { + try { + $recommended = $rule->apply->compute($snapshot); + $apply = [ + 'variable' => $rule->apply->variable, + 'configKey' => $rule->apply->configKey, + 'configFile' => $rule->apply->configFile, + 'runtimeWritable' => $rule->apply->runtimeWritable, + 'recommendedValue' => $recommended, + 'note' => $rule->apply->note, + ]; + } catch (\Throwable $e) { + // A broken applier shouldn't sink the whole rule. + $this->logger->warning( + 'serverinfo: apply descriptor failed for rule {id}: {msg}', + ['id' => $rule->id, 'msg' => $e->getMessage(), 'app' => 'serverinfo'], + ); + } + } + + // Optional name list backing the numeric value (e.g. which + // tables triggered a count-based rule). Only attached on + // failure — passing rules don't need the extra payload. + $details = null; + if ($rule->detailsKey !== null) { + $list = $snapshot->details[$rule->detailsKey] ?? null; + if (is_array($list) && $list !== []) { + $details = array_map('strval', $list); + } + } + + return new RuleResult( + $rule, + RuleResult::STATUS_FAIL, + justification: $justification, + value: $valueFloat, + apply: $apply, + details: $details, + ); + } + + /** + * sprintf-style interpolation driven by a comma-separated list of + * expressions in `$formula`. Each expression's value fills one + * `%s` (or numeric specifier) in `$template`. + * + * @param array $ctx + */ + private function interpolate(string $template, string $formula, array $ctx): string { + if ($formula === '') { + return $template; + } + + // Split on top-level commas only. The expressions are simple + // enough that we don't need real parsing here, but a function + // call could embed a comma — handle by tracking paren depth. + $parts = []; + $depth = 0; + $buf = ''; + foreach (str_split($formula) as $ch) { + if ($ch === '(') { + $depth++; + $buf .= $ch; + } elseif ($ch === ')') { + $depth--; + $buf .= $ch; + } elseif ($ch === ',' && $depth === 0) { + $parts[] = trim($buf); + $buf = ''; + } else { + $buf .= $ch; + } + } + if ($buf !== '') { + $parts[] = trim($buf); + } + + $values = []; + foreach ($parts as $part) { + try { + $v = $this->evaluator->evaluate($part, $ctx); + $values[] = is_bool($v) ? ($v ? 'true' : 'false') : $this->formatNumber($v); + } catch (ExpressionException) { + $values[] = '?'; + } + } + + // vsprintf without strict checking — phpMyAdmin's templates use + // %s and we coerce values to strings beforehand, so we don't + // expect type mismatches. Suppress warnings if the template + // has fewer placeholders than we have values. + $rendered = @vsprintf($template, $values); + return $rendered === false ? $template : $rendered; + } + + private function formatNumber(float $f): string { + if (is_infinite($f)) { + return '∞'; + } + if (is_nan($f)) { + return 'NaN'; + } + // Drop trailing zeros after a decimal point (1.500 → 1.5, + // 1.000 → 1) but keep precision when it matters. + if (floor($f) === $f && abs($f) < 1e15) { + return (string)(int)$f; + } + return rtrim(rtrim(sprintf('%.4f', $f), '0'), '.'); + } +} diff --git a/lib/Database/ApplyDescriptor.php b/lib/Database/ApplyDescriptor.php new file mode 100644 index 00000000..9bc25273 --- /dev/null +++ b/lib/Database/ApplyDescriptor.php @@ -0,0 +1,51 @@ +recommendedValue)($snapshot); + } +} diff --git a/lib/Database/DatabaseProbe.php b/lib/Database/DatabaseProbe.php new file mode 100644 index 00000000..ff87bb13 --- /dev/null +++ b/lib/Database/DatabaseProbe.php @@ -0,0 +1,484 @@ +openConnection(); + $flavour = $this->flavourOf($conn); + $this->closeIfOverride($conn); + return $flavour; + } catch (\Throwable $e) { + $this->logger->warning( + 'serverinfo: flavour detection failed: {msg}', + ['msg' => $e->getMessage(), 'app' => 'serverinfo'], + ); + return Snapshot::FLAVOUR_UNSUPPORTED; + } + } + + public function detectVersion(string $flavour): string { + if ($flavour === Snapshot::FLAVOUR_UNSUPPORTED) { + return ''; + } + try { + $conn = $this->openConnection(); + $version = match ($flavour) { + Snapshot::FLAVOUR_PGSQL => (string)$this->scalar($conn, 'SHOW server_version'), + default => (string)$this->scalar($conn, 'SELECT VERSION()'), + }; + $this->closeIfOverride($conn); + return $version; + } catch (\Throwable $e) { + $this->logger->warning( + 'serverinfo: version detection failed for {flavour}: {msg}', + ['flavour' => $flavour, 'msg' => $e->getMessage(), 'app' => 'serverinfo'], + ); + return ''; + } + } + + /** + * Run a cheap, read-only query so the caller can time the round-trip. + * + * Used by the live latency chart on the dashboard. We deliberately + * use the same query the user requested ("SHOW TABLES") on + * MySQL/MariaDB and the closest catalog-read equivalent on Postgres + * — it exercises parsing, planner lookup, and result transit so the + * timing is meaningful rather than a pure round-trip ping. + * + * Throws on failure; the caller decides how to surface that. + */ + public function ping(): void { + $conn = $this->openConnection(); + try { + $flavour = $this->flavourOf($conn); + $sql = $flavour === Snapshot::FLAVOUR_PGSQL + ? "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema')" + : 'SHOW TABLES'; + // Materialise the rowset so the timing reflects the full + // query, not just sending the SQL. + $this->all($conn, $sql); + } finally { + $this->closeIfOverride($conn); + } + } + + /** + * Total on-disk size of the probed database in bytes, or null when + * it cannot be determined. Same approach as the serverinfo app + * (phpBB's get_database_size): sum of table data + index sizes on + * MySQL/MariaDB, pg_database_size() on PostgreSQL — but routed + * through our own connection so a configured override is honoured. + */ + public function databaseSizeBytes(): ?int { + try { + $conn = $this->openConnection(); + try { + $flavour = $this->flavourOf($conn); + $size = match ($flavour) { + Snapshot::FLAVOUR_PGSQL => $this->scalar( + $conn, + 'SELECT pg_database_size(current_database())', + ), + Snapshot::FLAVOUR_MYSQL, Snapshot::FLAVOUR_MARIADB => $this->scalar( + $conn, + 'SELECT SUM(data_length + index_length) FROM information_schema.tables WHERE table_schema = DATABASE()', + ), + default => null, + }; + return is_numeric($size) ? (int)$size : null; + } finally { + $this->closeIfOverride($conn); + } + } catch (\Throwable $e) { + $this->logger->warning( + 'serverinfo: database size probe failed: {msg}', + ['msg' => $e->getMessage(), 'app' => 'serverinfo'], + ); + return null; + } + } + + public function snapshot(): Snapshot { + $conn = $this->openConnection(); + try { + $flavour = $this->flavourOf($conn); + if ($flavour === Snapshot::FLAVOUR_UNSUPPORTED) { + return new Snapshot($flavour, '', [], [], []); + } + + $base = $flavour === Snapshot::FLAVOUR_PGSQL + ? $this->snapshotPostgres($conn) + : $this->snapshotMysql($conn, $flavour); + + // Merge Nextcloud-specific schema facts (missing indices / + // columns / primary keys, and MySQL charset/engine/isolation) + // into the derived context so the Nextcloud rule group can + // reference them. These are read from Nextcloud's own + // connection, independent of any advisor override. + $ncFacts = $this->nextcloudSchema->facts($flavour); + if ($ncFacts === []) { + return $base; + } + return new Snapshot( + $base->flavour, + $base->version, + $base->status, + $base->variables, + $base->derived + $ncFacts, + $base->details, + ); + } finally { + $this->closeIfOverride($conn); + } + } + + // ── MySQL / MariaDB ───────────────────────────────────────────── + + private function snapshotMysql(Connection|IDBConnection $conn, string $flavour): Snapshot { + $version = (string)$this->scalar($conn, 'SELECT VERSION()'); + + $status = $this->keyValuePairs($conn, 'SHOW GLOBAL STATUS'); + $variables = $this->keyValuePairs($conn, 'SHOW GLOBAL VARIABLES'); + + // Derived metrics that several rules reference. + $derived = [ + 'value' => 0.0, // populated per-rule by Advisor + 'Uptime_hours' => isset($status['Uptime']) ? (float)$status['Uptime'] / 3600.0 : 0.0, + 'Uptime_days' => isset($status['Uptime']) ? (float)$status['Uptime'] / 86400.0 : 0.0, + ]; + + // `Questions` was renamed to `Questions` (unchanged) but + // `Com_*` totals are useful for rules that need to compute + // "ratio per query" without touching the slow cousin + // `Queries`. Pre-compute total questions so rules don't + // need to. + if (isset($status['Questions'], $status['Slow_queries'])) { + $q = (float)$status['Questions']; + $slow = (float)$status['Slow_queries']; + $derived['slow_query_ratio'] = $q > 0 ? $slow / $q : 0.0; + } + + return new Snapshot($flavour, $version, $status, $variables, $derived); + } + + // ── PostgreSQL ────────────────────────────────────────────────── + + private function snapshotPostgres(Connection|IDBConnection $conn): Snapshot { + $version = (string)$this->scalar($conn, 'SHOW server_version'); + + // "Status"-equivalent: aggregate stats from pg_stat_database. + // We sum across non-template databases so a single number + // represents the whole server (matching the MySQL global + // status convention). + $status = []; + $row = $this->row($conn, " + SELECT + COALESCE(SUM(blks_read), 0) AS blks_read, + COALESCE(SUM(blks_hit), 0) AS blks_hit, + COALESCE(SUM(xact_commit), 0) AS xact_commit, + COALESCE(SUM(xact_rollback), 0) AS xact_rollback, + COALESCE(SUM(temp_files), 0) AS temp_files, + COALESCE(SUM(temp_bytes), 0) AS temp_bytes, + COALESCE(SUM(deadlocks), 0) AS deadlocks + FROM pg_stat_database + WHERE datname NOT LIKE 'template%' + "); + foreach ($row ?? [] as $k => $v) { + $status[$k] = $v; + } + + // Active connection count. + $status['active_connections'] = (int)$this->scalar( + $conn, + 'SELECT count(*) FROM pg_stat_activity WHERE state IS NOT NULL', + ); + + // Long-running queries (>5 min). + $status['long_running_queries'] = (int)$this->scalar( + $conn, + "SELECT count(*) FROM pg_stat_activity + WHERE state = 'active' AND now() - query_start > interval '5 minutes'", + ); + + // Worst replication lag in seconds (0 if no replicas). + $status['max_replay_lag_seconds'] = (float)($this->scalar( + $conn, + 'SELECT COALESCE(MAX(EXTRACT(EPOCH FROM replay_lag)), 0) FROM pg_stat_replication', + ) ?? 0); + + // Worst dead-tuple count across user tables. + $status['max_dead_tuples'] = (int)$this->scalar( + $conn, + 'SELECT COALESCE(MAX(n_dead_tup), 0) FROM pg_stat_user_tables', + ); + + // "Have any user tables ever benefitted from an index?" We + // flag tables with > 1M sequential scans and zero index scans, + // and keep the worst offenders' names so the rule card can show + // which tables to look at instead of just a count. + $seqScanTables = $this->all( + $conn, + 'SELECT relname FROM pg_stat_user_tables WHERE seq_scan > 1000000 AND idx_scan = 0 ORDER BY seq_scan DESC', + ); + $status['tables_without_index_use'] = count($seqScanTables); + $details = []; + if ($seqScanTables !== []) { + $names = array_map(static fn (array $row): string => (string)$row['relname'], $seqScanTables); + $details['tables_without_index_use'] = count($names) > self::MAX_DETAIL_ITEMS + ? array_slice($names, 0, self::MAX_DETAIL_ITEMS) + : $names; + } + + // "Variables"-equivalent: pg_settings. + $variables = []; + foreach ($this->all($conn, 'SELECT name, setting FROM pg_settings') as $row) { + $variables[$row['name']] = $row['setting']; + } + + $blksHit = (float)($status['blks_hit'] ?? 0); + $blksRead = (float)($status['blks_read'] ?? 0); + $total = $blksHit + $blksRead; + $derived = [ + 'value' => 0.0, + 'cache_hit_ratio' => $total > 0 ? $blksHit / $total : 1.0, + ]; + + return new Snapshot(Snapshot::FLAVOUR_PGSQL, $version, $status, $variables, $derived, $details); + } + + // ── Connection plumbing ───────────────────────────────────────── + + private function openConnection(): Connection|IDBConnection { + $override = $this->overrideParams(); + if ($override === null) { + return $this->defaultConnection; + } + return DriverManager::getConnection($override); + } + + private function closeIfOverride(Connection|IDBConnection $conn): void { + // Doctrine\DBAL\Connection isn't an IDBConnection. When we + // opened a separate one for an override, dispose it. + if ($conn instanceof Connection) { + $conn->close(); + } + } + + /** + * Whether connecting to $host is permitted by the operator. + * + * Admins configure overrides through the web UI, which makes the + * connection feature an internal-network probe primitive for a + * web-only admin. Operators can pin the reachable hosts with a + * config.php entry: + * + * 'serverinfo.allowed_override_hosts' => ['db1.internal', '10.0.0.5'], + * + * When the entry is absent or empty every host is allowed (the + * historical behaviour); when present, only exact matches pass. + */ + public function isHostAllowed(string $host): bool { + $allowed = $this->config->getSystemValue('serverinfo.allowed_override_hosts', []); + if (!is_array($allowed) || $allowed === []) { + return true; + } + return in_array($host, array_map('strval', $allowed), true); + } + + /** + * @return array|null Doctrine DBAL connection params or null when no override is configured. + */ + private function overrideParams(): ?array { + $host = $this->config->getAppValue(Application::APP_ID, 'override.host', ''); + if ($host === '') { + return null; + } + if (!$this->isHostAllowed($host)) { + // A host that was stored before the operator tightened the + // allow-list (or slipped past it) must not be dialled. + $this->logger->warning( + 'serverinfo: override host {host} is not on serverinfo.allowed_override_hosts — falling back to the default connection', + ['host' => $host, 'app' => 'serverinfo'], + ); + return null; + } + $port = (int)$this->config->getAppValue(Application::APP_ID, 'override.port', '0'); + $user = $this->config->getAppValue(Application::APP_ID, 'override.user', ''); + $dbname = $this->config->getAppValue(Application::APP_ID, 'override.database', ''); + $driver = $this->config->getAppValue(Application::APP_ID, 'override.driver', 'pdo_mysql'); + $password = (string)$this->credentials->retrieve('', Application::APP_ID . ':override.password'); + + $params = [ + 'driver' => $driver, + 'host' => $host, + 'user' => $user, + 'password' => $password, + 'dbname' => $dbname, + ]; + if ($port > 0) { + $params['port'] = $port; + } + return $params; + } + + /** + * Run $callback with an open (override-aware) connection and its + * detected flavour, disposing an override connection afterwards. + * Lets sibling services (e.g. InsightsService) reuse the exact same + * connection precedence and query helpers without re-implementing + * the plumbing. + * + * @template T + * @param callable(Connection|IDBConnection, string): T $callback + * @return T + */ + public function withConnection(callable $callback): mixed { + $conn = $this->openConnection(); + try { + $flavour = $this->flavourOf($conn); + return $callback($conn, $flavour); + } finally { + $this->closeIfOverride($conn); + } + } + + private function flavourOf(Connection|IDBConnection $conn): string { + try { + // PostgreSQL has SHOW server_version; SHOW VERSION() is MySQL. + // We probe the cheap MySQL form first; if it errors, it's + // likely Postgres or unsupported. + $version = (string)$this->scalar($conn, 'SELECT VERSION()'); + if ($version === '') { + return Snapshot::FLAVOUR_UNSUPPORTED; + } + if (str_contains(strtolower($version), 'mariadb')) { + return Snapshot::FLAVOUR_MARIADB; + } + if (str_contains(strtolower($version), 'postgresql')) { + return Snapshot::FLAVOUR_PGSQL; + } + return Snapshot::FLAVOUR_MYSQL; + } catch (\Throwable) { + // Postgres returns a quoted version string from + // SELECT version() — different syntax wouldn't normally + // throw, but if our generic call fails for any reason + // we fall through to a SHOW server_version probe. + try { + $pgver = (string)$this->scalar($conn, 'SHOW server_version'); + return $pgver !== '' ? Snapshot::FLAVOUR_PGSQL : Snapshot::FLAVOUR_UNSUPPORTED; + } catch (\Throwable) { + return Snapshot::FLAVOUR_UNSUPPORTED; + } + } + } + + // ── Query helpers (work for both Doctrine\DBAL\Connection and IDBConnection) ── + // Public so sibling services can query through withConnection(). + + public function scalar(Connection|IDBConnection $conn, string $sql): mixed { + $row = $this->row($conn, $sql); + if ($row === null) { + return null; + } + // First column, regardless of name. + return reset($row); + } + + /** + * @return array|null + */ + public function row(Connection|IDBConnection $conn, string $sql): ?array { + if ($conn instanceof Connection) { + $row = $conn->fetchAssociative($sql); + return $row === false ? null : $row; + } + $result = $conn->prepare($sql)->execute(); + $row = $result->fetch(); + $result->closeCursor(); + return $row === false ? null : $row; + } + + /** + * @return list> + */ + public function all(Connection|IDBConnection $conn, string $sql): array { + if ($conn instanceof Connection) { + return $conn->fetchAllAssociative($sql); + } + $result = $conn->prepare($sql)->execute(); + $rows = []; + while (($r = $result->fetch()) !== false) { + $rows[] = $r; + } + $result->closeCursor(); + return $rows; + } + + /** + * @return array + */ + private function keyValuePairs(Connection|IDBConnection $conn, string $sql): array { + $out = []; + foreach ($this->all($conn, $sql) as $row) { + $values = array_values($row); + if (count($values) < 2) { + continue; + } + $key = (string)$values[0]; + $val = $values[1]; + $out[$key] = is_scalar($val) || $val === null ? $val : (string)$val; + } + return $out; + } +} diff --git a/lib/Database/ExpressionEvaluator.php b/lib/Database/ExpressionEvaluator.php new file mode 100644 index 00000000..3fb4b64d --- /dev/null +++ b/lib/Database/ExpressionEvaluator.php @@ -0,0 +1,427 @@ +' | '>=') additive )* + * additive ::= multiplicative ( ('+' | '-') multiplicative )* + * multiplicative ::= power ( ('*' | '/' | '%') power )* + * power ::= unary ( '**' unary )* + * unary ::= ('-' | '+' | '!') unary | primary + * primary ::= number + * | identifier + * | identifier '(' arglist? ')' + * | '(' expr ')' + * arglist ::= expr ( ',' expr )* + * + * **There is no `eval()` anywhere in this class.** The token list and + * AST are produced by a recursive-descent parser we control end-to-end. + * + * Identifiers must resolve in the context map. Function calls must + * resolve to one of the whitelisted functions in {@see self::$functions}. + * Anything else — string literals, function names not on the list, + * unbalanced parentheses, dangling operators — throws an + * {@see ExpressionException}. Division by zero returns `INF` to match + * phpMyAdmin's semantics (which exploits IEEE-754 behaviour for ratios). + */ +final class ExpressionEvaluator { + /** + * Whitelisted functions. Matches what phpMyAdmin's advisor uses + * (which is essentially `pow` and a few aliases). Adding to this + * list is the only way new functions can be called from rules. + * + * @var array + */ + private array $functions = []; + + public function __construct() { + $this->functions = [ + 'pow' => static fn (float $base, float $exp): float => $base ** $exp, + 'power' => static fn (float $base, float $exp): float => $base ** $exp, + 'min' => static fn (float ...$args): float => count($args) === 0 ? 0.0 : min($args), + 'max' => static fn (float ...$args): float => count($args) === 0 ? 0.0 : max($args), + 'abs' => static fn (float $x): float => abs($x), + 'ceil' => static fn (float $x): float => ceil($x), + 'floor' => static fn (float $x): float => floor($x), + 'round' => static fn (float $x): float => round($x), + ]; + } + + /** + * Evaluate `$expression` with identifiers resolved from `$context`. + * + * @param array $context + * @throws ExpressionException on any parse / evaluation error. + */ + public function evaluate(string $expression, array $context): float|bool { + $tokens = $this->tokenize($expression); + $pos = 0; + $result = $this->parseExpr($tokens, $pos, $context); + if ($pos !== count($tokens)) { + throw new ExpressionException( + 'Trailing tokens after expression at offset ' . $pos . ': ' . ($tokens[$pos][1] ?? ''), + ); + } + return $result; + } + + // ── Tokenizer ───────────────────────────────────────────────── + + /** + * @return list Pairs of (type, lexeme). + */ + private function tokenize(string $src): array { + $tokens = []; + $len = strlen($src); + $i = 0; + + while ($i < $len) { + $c = $src[$i]; + + // Whitespace + if (ctype_space($c)) { + $i++; + continue; + } + + // Numbers (integers and decimals; no scientific notation — + // phpMyAdmin formulas don't use any). + if (ctype_digit($c) || ($c === '.' && $i + 1 < $len && ctype_digit($src[$i + 1]))) { + $start = $i; + while ($i < $len && (ctype_digit($src[$i]) || $src[$i] === '.')) { + $i++; + } + $tokens[] = ['NUMBER', substr($src, $start, $i - $start)]; + continue; + } + + // Identifiers (letters, digits after first, underscore) + if (ctype_alpha($c) || $c === '_') { + $start = $i; + while ($i < $len && (ctype_alnum($src[$i]) || $src[$i] === '_')) { + $i++; + } + $ident = substr($src, $start, $i - $start); + $upper = strtoupper($ident); + if ($upper === 'AND') { + $tokens[] = ['AND', $ident]; + } elseif ($upper === 'OR') { + $tokens[] = ['OR', $ident]; + } elseif ($upper === 'TRUE') { + $tokens[] = ['NUMBER', '1']; + } elseif ($upper === 'FALSE') { + $tokens[] = ['NUMBER', '0']; + } else { + $tokens[] = ['IDENT', $ident]; + } + continue; + } + + // Two-character operators + $two = substr($src, $i, 2); + if (in_array($two, ['==', '!=', '<=', '>=', '&&', '||', '**'], true)) { + $tokens[] = [$two, $two]; + $i += 2; + continue; + } + + // Single-character operators + if (strpbrk($c, '+-*/%<>=()!?,:') !== false) { + $tokens[] = [$c, $c]; + $i++; + continue; + } + + throw new ExpressionException("Unexpected character '$c' at offset $i in: $src"); + } + + return $tokens; + } + + // ── Parser / evaluator ──────────────────────────────────────── + // + // We fold parse + evaluate into a single pass. The expressions are + // tiny (almost always under 60 tokens) so a separate AST step + // would be over-engineering. + + /** + * @param list $tokens + * @param array $ctx + */ + private function parseExpr(array $tokens, int &$pos, array $ctx): float|bool { + return $this->parseTernary($tokens, $pos, $ctx); + } + + /** + * @param list $tokens + * @param array $ctx + */ + private function parseTernary(array $tokens, int &$pos, array $ctx): float|bool { + $cond = $this->parseOr($tokens, $pos, $ctx); + if ($this->peek($tokens, $pos) === '?') { + $pos++; + $then = $this->parseTernary($tokens, $pos, $ctx); + $this->expect($tokens, $pos, ':'); + $else = $this->parseTernary($tokens, $pos, $ctx); + return $this->toBool($cond) ? $then : $else; + } + return $cond; + } + + /** + * @param list $tokens + * @param array $ctx + */ + private function parseOr(array $tokens, int &$pos, array $ctx): float|bool { + $left = $this->parseAnd($tokens, $pos, $ctx); + while (in_array($this->peek($tokens, $pos), ['||', 'OR'], true)) { + $pos++; + $right = $this->parseAnd($tokens, $pos, $ctx); + $left = $this->toBool($left) || $this->toBool($right); + } + return $left; + } + + /** + * @param list $tokens + * @param array $ctx + */ + private function parseAnd(array $tokens, int &$pos, array $ctx): float|bool { + $left = $this->parseEquality($tokens, $pos, $ctx); + while (in_array($this->peek($tokens, $pos), ['&&', 'AND'], true)) { + $pos++; + $right = $this->parseEquality($tokens, $pos, $ctx); + $left = $this->toBool($left) && $this->toBool($right); + } + return $left; + } + + /** + * @param list $tokens + * @param array $ctx + */ + private function parseEquality(array $tokens, int &$pos, array $ctx): float|bool { + $left = $this->parseRelational($tokens, $pos, $ctx); + while (in_array($op = $this->peek($tokens, $pos), ['==', '!='], true)) { + $pos++; + $right = $this->parseRelational($tokens, $pos, $ctx); + $lf = $this->toFloat($left); + $rf = $this->toFloat($right); + $left = match ($op) { + '==' => $lf === $rf, + '!=' => $lf !== $rf, + }; + } + return $left; + } + + /** + * @param list $tokens + * @param array $ctx + */ + private function parseRelational(array $tokens, int &$pos, array $ctx): float|bool { + $left = $this->parseAdditive($tokens, $pos, $ctx); + while (in_array($op = $this->peek($tokens, $pos), ['<', '<=', '>', '>='], true)) { + $pos++; + $right = $this->parseAdditive($tokens, $pos, $ctx); + $lf = $this->toFloat($left); + $rf = $this->toFloat($right); + $left = match ($op) { + '<' => $lf < $rf, + '<=' => $lf <= $rf, + '>' => $lf > $rf, + '>=' => $lf >= $rf, + }; + } + return $left; + } + + /** + * @param list $tokens + * @param array $ctx + */ + private function parseAdditive(array $tokens, int &$pos, array $ctx): float|bool { + $left = $this->parseMultiplicative($tokens, $pos, $ctx); + while (in_array($op = $this->peek($tokens, $pos), ['+', '-'], true)) { + $pos++; + $right = $this->parseMultiplicative($tokens, $pos, $ctx); + $lf = $this->toFloat($left); + $rf = $this->toFloat($right); + $left = $op === '+' ? $lf + $rf : $lf - $rf; + } + return $left; + } + + /** + * @param list $tokens + * @param array $ctx + */ + private function parseMultiplicative(array $tokens, int &$pos, array $ctx): float|bool { + $left = $this->parsePower($tokens, $pos, $ctx); + while (in_array($op = $this->peek($tokens, $pos), ['*', '/', '%'], true)) { + $pos++; + $right = $this->parsePower($tokens, $pos, $ctx); + $lf = $this->toFloat($left); + $rf = $this->toFloat($right); + $left = match ($op) { + '*' => $lf * $rf, + // Match phpMyAdmin: divide-by-zero yields INF instead + // of throwing, so ratio formulas (e.g. slow/total) + // produce a sensible "very high" sentinel when the + // denominator hasn't accumulated yet. + '/' => $rf === 0.0 ? INF : $lf / $rf, + '%' => $rf === 0.0 ? 0.0 : fmod($lf, $rf), + }; + } + return $left; + } + + /** + * @param list $tokens + * @param array $ctx + */ + private function parsePower(array $tokens, int &$pos, array $ctx): float|bool { + $left = $this->parseUnary($tokens, $pos, $ctx); + if ($this->peek($tokens, $pos) === '**') { + $pos++; + // Right-associative + $right = $this->parsePower($tokens, $pos, $ctx); + return $this->toFloat($left) ** $this->toFloat($right); + } + return $left; + } + + /** + * @param list $tokens + * @param array $ctx + */ + private function parseUnary(array $tokens, int &$pos, array $ctx): float|bool { + $op = $this->peek($tokens, $pos); + if ($op === '-' || $op === '+' || $op === '!') { + $pos++; + $operand = $this->parseUnary($tokens, $pos, $ctx); + return match ($op) { + '-' => -$this->toFloat($operand), + '+' => $this->toFloat($operand), + '!' => !$this->toBool($operand), + }; + } + return $this->parsePrimary($tokens, $pos, $ctx); + } + + /** + * @param list $tokens + * @param array $ctx + */ + private function parsePrimary(array $tokens, int &$pos, array $ctx): float|bool { + $type = $this->peek($tokens, $pos); + if ($type === null) { + throw new ExpressionException('Unexpected end of expression'); + } + + if ($type === '(') { + $pos++; + $inner = $this->parseExpr($tokens, $pos, $ctx); + $this->expect($tokens, $pos, ')'); + return $inner; + } + + if ($type === 'NUMBER') { + $lex = $tokens[$pos][1]; + $pos++; + return (float)$lex; + } + + if ($type === 'IDENT') { + $name = $tokens[$pos][1]; + $pos++; + + // Function call? + if ($this->peek($tokens, $pos) === '(') { + $pos++; + $args = []; + if ($this->peek($tokens, $pos) !== ')') { + $args[] = $this->toFloat($this->parseExpr($tokens, $pos, $ctx)); + while ($this->peek($tokens, $pos) === ',') { + $pos++; + $args[] = $this->toFloat($this->parseExpr($tokens, $pos, $ctx)); + } + } + $this->expect($tokens, $pos, ')'); + if (!isset($this->functions[$name])) { + throw new ExpressionException("Unknown function '$name'"); + } + return ($this->functions[$name])(...$args); + } + + // Identifier resolution. Names are case-sensitive to + // match what `SHOW STATUS` and `SHOW VARIABLES` return. + if (!array_key_exists($name, $ctx)) { + throw new ExpressionException("Unknown identifier '$name'"); + } + $val = $ctx[$name]; + if ($val === null) { + throw new ExpressionException("Identifier '$name' has null value"); + } + if (is_bool($val)) { + return $val ? 1.0 : 0.0; + } + if (is_numeric($val)) { + return (float)$val; + } + // Remaining strings are accepted when they are one of MySQL's + // boolean spellings. MySQL/MariaDB return slow_query_log, + // log_bin, etc. as 'ON'/'OFF' rather than 1/0; phpMyAdmin's + // advisor rules treat them as numeric. + $bool = match (strtoupper($val)) { + 'ON', 'YES', 'TRUE' => 1.0, + 'OFF', 'NO', 'FALSE', '' => 0.0, + default => null, + }; + if ($bool !== null) { + return $bool; + } + throw new ExpressionException("Identifier '$name' is not numeric"); + } + + throw new ExpressionException("Unexpected token '$type' (lexeme: " . ($tokens[$pos][1] ?? '?') . ')'); + } + + private function peek(array $tokens, int $pos): ?string { + return $tokens[$pos][0] ?? null; + } + + private function expect(array $tokens, int &$pos, string $type): void { + if ($this->peek($tokens, $pos) !== $type) { + throw new ExpressionException( + "Expected '$type' but got '" . ($this->peek($tokens, $pos) ?? '') . "'", + ); + } + $pos++; + } + + private function toFloat(float|bool $v): float { + return is_bool($v) ? ($v ? 1.0 : 0.0) : $v; + } + + private function toBool(float|bool $v): bool { + return is_bool($v) ? $v : $v !== 0.0; + } +} diff --git a/lib/Database/ExpressionException.php b/lib/Database/ExpressionException.php new file mode 100644 index 00000000..d8407de3 --- /dev/null +++ b/lib/Database/ExpressionException.php @@ -0,0 +1,13 @@ +probe->withConnection(function (Connection|IDBConnection $conn, string $flavour): array { + if ($flavour === Snapshot::FLAVOUR_PGSQL) { + return $this->liveMetricsPostgres($conn); + } + return $this->liveMetricsMysql($conn); + }); + } + + private function liveMetricsMysql(Connection|IDBConnection $conn): array { + $status = $this->statusMap($conn, + 'SHOW GLOBAL STATUS WHERE Variable_name IN ' + . "('Threads_connected','Threads_running','Innodb_buffer_pool_read_requests','Innodb_buffer_pool_reads','Questions')"); + $maxConn = $this->variableInt($conn, 'max_connections'); + + $readReq = (float)($status['Innodb_buffer_pool_read_requests'] ?? 0); + $reads = (float)($status['Innodb_buffer_pool_reads'] ?? 0); + $hit = $readReq > 0 ? 1.0 - ($reads / $readReq) : null; + + return [ + 'connections' => [ + 'used' => (int)($status['Threads_connected'] ?? 0), + 'max' => $maxConn, + ], + 'cacheHitRatio' => $hit, + 'throughput' => [ + 'counter' => (float)($status['Questions'] ?? 0), + 'label' => 'Queries/s', + ], + 'threadsRunning' => (int)($status['Threads_running'] ?? 0), + ]; + } + + private function liveMetricsPostgres(Connection|IDBConnection $conn): array { + $used = (int)$this->probe->scalar($conn, 'SELECT count(*) FROM pg_stat_activity'); + $max = (int)$this->probe->scalar($conn, "SELECT setting FROM pg_settings WHERE name = 'max_connections'"); + $active = (int)$this->probe->scalar($conn, "SELECT count(*) FROM pg_stat_activity WHERE state = 'active'"); + + $row = $this->probe->row($conn, " + SELECT COALESCE(SUM(blks_hit), 0) AS hit, + COALESCE(SUM(blks_read), 0) AS rd, + COALESCE(SUM(xact_commit + xact_rollback), 0) AS xacts + FROM pg_stat_database WHERE datname NOT LIKE 'template%'"); + $hitB = (float)($row['hit'] ?? 0); + $readB = (float)($row['rd'] ?? 0); + $total = $hitB + $readB; + + return [ + 'connections' => ['used' => $used, 'max' => $max], + 'cacheHitRatio' => $total > 0 ? $hitB / $total : null, + 'throughput' => [ + 'counter' => (float)($row['xacts'] ?? 0), + 'label' => 'Transactions/s', + ], + 'threadsRunning' => $active, + ]; + } + + // ── helpers ───────────────────────────────────────────────────── + + /** + * MySQL 8 returns unaliased information_schema column headers in + * UPPERCASE (they are data-dictionary views), so normalise keys to + * lowercase before any field access. + * + * @return list> + */ + private function all(Connection|IDBConnection $conn, string $sql): array { + return array_map( + static fn (array $row): array => array_change_key_case($row, CASE_LOWER), + $this->probe->all($conn, $sql), + ); + } + + /** + * Turn a SHOW ... STATUS/VARIABLES result into a Variable_name→Value map. + * + * @return array + */ + private function statusMap(Connection|IDBConnection $conn, string $sql): array { + $map = []; + foreach ($this->all($conn, $sql) as $row) { + $vals = array_values($row); + if (isset($vals[0], $vals[1])) { + $map[(string)$vals[0]] = (string)$vals[1]; + } + } + return $map; + } + + private function variableInt(Connection|IDBConnection $conn, string $name): int { + $map = $this->statusMap($conn, "SHOW VARIABLES WHERE Variable_name = '" . $name . "'"); + return (int)($map[$name] ?? 0); + } +} diff --git a/lib/Database/NextcloudSchema.php b/lib/Database/NextcloudSchema.php new file mode 100644 index 00000000..49195b92 --- /dev/null +++ b/lib/Database/NextcloudSchema.php @@ -0,0 +1,215 @@ + Numeric derived facts (nc_* keys) for the given flavour. + * A key is omitted when it couldn't be determined, so the + * dependent rule skips instead of evaluating on a bad value. + */ + public function facts(string $flavour): array { + $out = $this->missingSchemaCounts(); + + if ($flavour === Snapshot::FLAVOUR_MYSQL || $flavour === Snapshot::FLAVOUR_MARIADB) { + $out += $this->mysqlSchemaFacts(); + } + + return $out; + } + + /** + * Counts of schema objects Nextcloud expects but the live database + * lacks — the same comparison `occ db:add-missing-*` performs. + * + * @return array + */ + private function missingSchemaCounts(): array { + try { + $schema = new SchemaWrapper($this->connection); + return [ + 'nc_missing_indices' => $this->countMissingIndices($schema), + 'nc_missing_columns' => $this->countMissingColumns($schema), + 'nc_missing_primary_keys' => $this->countMissingPrimaryKeys($schema), + ]; + } catch (\Throwable $e) { + $this->logger->warning( + 'serverinfo: Nextcloud schema probe failed: {msg}', + ['msg' => $e->getMessage(), 'app' => 'serverinfo'], + ); + return []; + } + } + + private function countMissingIndices(SchemaWrapper $schema): int { + $event = new AddMissingIndicesEvent(); + $this->dispatcher->dispatchTyped($event); + + $count = 0; + foreach ($event->getMissingIndices() as $missing) { + if ($schema->hasTable($missing['tableName']) + && !$schema->getTable($missing['tableName'])->hasIndex($missing['indexName'])) { + $count++; + } + } + foreach ($event->getIndicesToReplace() as $replace) { + if ($schema->hasTable($replace['tableName']) + && !$schema->getTable($replace['tableName'])->hasIndex($replace['newIndexName'])) { + $count++; + } + } + return $count; + } + + private function countMissingColumns(SchemaWrapper $schema): int { + $event = new AddMissingColumnsEvent(); + $this->dispatcher->dispatchTyped($event); + + $count = 0; + foreach ($event->getMissingColumns() as $missing) { + if ($schema->hasTable($missing['tableName']) + && !$schema->getTable($missing['tableName'])->hasColumn($missing['columnName'])) { + $count++; + } + } + return $count; + } + + private function countMissingPrimaryKeys(SchemaWrapper $schema): int { + $event = new AddMissingPrimaryKeyEvent(); + $this->dispatcher->dispatchTyped($event); + + $count = 0; + foreach ($event->getMissingPrimaryKeys() as $missing) { + if ($schema->hasTable($missing['tableName']) + && $schema->getTable($missing['tableName'])->getPrimaryKey() === null) { + $count++; + } + } + return $count; + } + + /** + * MySQL/MariaDB-only facts about the Nextcloud tables: charset, + * storage engine, and transaction isolation level. Scoped to the + * configured table prefix so unrelated tables sharing the schema + * don't skew the counts. + * + * @return array + */ + private function mysqlSchemaFacts(): array { + try { + $prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_'); + // Escape LIKE wildcards in the prefix ('oc_' → 'oc\_') so the + // underscore matches literally rather than as a single-char + // wildcard. + $like = str_replace(['\\', '_', '%'], ['\\\\', '\\_', '\\%'], $prefix) . '%'; + + $nonUtf8mb4 = (int)$this->scalar( + "SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' + AND table_name LIKE ? + AND table_collation IS NOT NULL AND table_collation NOT LIKE 'utf8mb4%'", + [$like], + ); + + $nonInnodb = (int)$this->scalar( + "SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' + AND table_name LIKE ? + AND engine IS NOT NULL AND engine <> 'InnoDB'", + [$like], + ); + + return [ + 'nc_non_utf8mb4_tables' => $nonUtf8mb4, + 'nc_non_innodb_tables' => $nonInnodb, + 'nc_read_committed' => $this->isReadCommitted() ? 1 : 0, + ]; + } catch (\Throwable $e) { + $this->logger->warning( + 'serverinfo: Nextcloud charset/engine probe failed: {msg}', + ['msg' => $e->getMessage(), 'app' => 'serverinfo'], + ); + return []; + } + } + + /** + * Reads the server-wide (global) transaction isolation level — the + * value the my.cnf recommendation targets. We deliberately read the + * GLOBAL rather than the SESSION value: Nextcloud forces READ-COMMITTED + * per session on its own connection, so `SHOW VARIABLES` (session) would + * always report READ-COMMITTED and hide the server's real default. + * + * The variable is `transaction_isolation` on MySQL 5.7.20+ / MariaDB + * 11.1+ and `tx_isolation` on older servers; we accept either. + */ + private function isReadCommitted(): bool { + // SHOW GLOBAL VARIABLES returns (Variable_name, Value) rows, which + // fetchAllKeyValue() folds into a name → value map. + $rows = $this->connection->fetchAllKeyValue( + "SHOW GLOBAL VARIABLES WHERE Variable_name IN ('transaction_isolation', 'tx_isolation')", + ); + $value = (string)($rows['transaction_isolation'] ?? $rows['tx_isolation'] ?? ''); + + // Servers report "READ-COMMITTED"; normalise spacing defensively. + return strtoupper(str_replace(' ', '-', trim($value))) === 'READ-COMMITTED'; + } + + /** + * @param list $params + */ + private function scalar(string $sql, array $params): mixed { + return $this->connection->fetchOne($sql, $params); + } +} diff --git a/lib/Database/Rule.php b/lib/Database/Rule.php new file mode 100644 index 00000000..02378ef4 --- /dev/null +++ b/lib/Database/Rule.php @@ -0,0 +1,68 @@ + $requires Snapshot keys the rule reads (status / variables names). + * @param string|null $docUrl Optional "learn more" link shown on the rule card. + * @param string|null $detailsKey Optional key into the snapshot's details map; + * when the rule fails, the named list (e.g. affected table names) is + * attached to the result for the UI to show alongside the justification. + */ + public function __construct( + public readonly string $id, + public readonly string $name, + public readonly string $category, + public readonly string $severity, + public readonly string $formula, + public readonly string $test, + public readonly string $issue, + public readonly string $recommendation, + public readonly string $justification, + public readonly string $justificationFormula, + public readonly ?ApplyDescriptor $apply = null, + public readonly array $requires = [], + public readonly ?string $docUrl = null, + public readonly ?string $detailsKey = null, + ) { + } +} diff --git a/lib/Database/RuleResult.php b/lib/Database/RuleResult.php new file mode 100644 index 00000000..ca98c290 --- /dev/null +++ b/lib/Database/RuleResult.php @@ -0,0 +1,65 @@ +|null $details Names behind the numeric value (e.g. the + * affected tables), shown by the UI alongside the justification. + */ + public function __construct( + public readonly Rule $rule, + public readonly string $status, + public readonly ?string $justification = null, + public readonly ?float $value = null, + public readonly ?string $skipReason = null, + public readonly ?array $apply = null, + public readonly ?array $details = null, + ) { + } + + #[\Override] + public function jsonSerialize(): array { + return [ + 'id' => $this->rule->id, + 'name' => $this->rule->name, + 'category' => $this->rule->category, + 'severity' => $this->rule->severity, + 'status' => $this->status, + 'issue' => $this->rule->issue, + 'recommendation' => $this->rule->recommendation, + 'justification' => $this->justification, + // json_encode() returns false on INF / NaN. The expression + // evaluator documents division-by-zero → INF (phpMyAdmin + // semantics), so a single such rule would otherwise produce + // an empty response body for the entire run. Coerce here so + // both the live response and the persisted history JSON are + // always encodable. + 'value' => ($this->value !== null && is_finite($this->value)) ? $this->value : null, + 'skipReason' => $this->skipReason, + 'apply' => $this->apply, + 'details' => $this->details, + 'docUrl' => $this->rule->docUrl, + ]; + } +} diff --git a/lib/Database/RuleSet.php b/lib/Database/RuleSet.php new file mode 100644 index 00000000..1ebf837a --- /dev/null +++ b/lib/Database/RuleSet.php @@ -0,0 +1,30 @@ + + */ + public function rules(): array; + + /** + * Flavour identifier the rule set targets ('mysql' | 'mariadb' | 'pgsql'). + * Matched against {@see \OCA\ServerInfo\Database\Snapshot::$flavour}. + */ + public function flavour(): string; +} diff --git a/lib/Database/RuleSet/Mysql.php b/lib/Database/RuleSet/Mysql.php new file mode 100644 index 00000000..4219ed37 --- /dev/null +++ b/lib/Database/RuleSet/Mysql.php @@ -0,0 +1,1058 @@ +|null */ + private ?array $cache = null; + + public function rules(): array { + if ($this->cache === null) { + $this->cache = $this->build(); + } + return $this->cache; + } + + public function flavour(): string { + return Snapshot::FLAVOUR_MYSQL; + } + + /** + * @return list + */ + private function build(): array { + $rules = []; + + // ── Logging ───────────────────────────────────────────────── + + $rules[] = new Rule( + id: 'Slow_query_log_off', + name: 'Slow query log', + category: 'Logging', + severity: Rule::SEVERITY_NOTICE, + formula: 'slow_query_log', + test: 'value == 0', + issue: 'The slow query log is disabled, so problematic queries are not being recorded.', + recommendation: 'Enable slow_query_log to capture queries that take longer than long_query_time to execute.', + justification: 'slow_query_log is currently disabled.', + justificationFormula: '', + apply: new ApplyDescriptor( + variable: 'slow_query_log', + recommendedValue: static fn (Snapshot $s) => 'ON', + runtimeWritable: true, + configKey: 'slow_query_log', + configFile: 'my.cnf', + ), + requires: ['slow_query_log'], + ); + + $rules[] = new Rule( + id: 'Long_query_time', + name: 'Long query time', + category: 'Logging', + severity: Rule::SEVERITY_NOTICE, + formula: 'long_query_time', + test: 'value > 5', + issue: 'long_query_time is set high, so only the slowest queries are logged.', + recommendation: 'Set long_query_time to 2 (or less) to catch problematic queries earlier.', + justification: 'long_query_time is %s seconds; a value of 2 or less is recommended.', + justificationFormula: 'value', + apply: new ApplyDescriptor( + variable: 'long_query_time', + recommendedValue: static fn (Snapshot $s) => '2', + runtimeWritable: true, + configKey: 'long_query_time', + configFile: 'my.cnf', + ), + requires: ['long_query_time'], + ); + + // ── Connections ───────────────────────────────────────────── + + $rules[] = new Rule( + id: 'Max_used_connections_close_to_max', + name: 'Connection saturation', + category: 'Connections', + severity: Rule::SEVERITY_ALERT, + formula: 'Max_used_connections / max_connections * 100', + test: 'value > 80', + issue: 'The server has approached its max_connections limit.', + recommendation: 'Increase max_connections to give the server more headroom under load spikes.', + justification: 'Peak connection usage was %s%% of the configured max_connections.', + justificationFormula: 'value', + apply: new ApplyDescriptor( + variable: 'max_connections', + recommendedValue: static fn (Snapshot $s) => (string)max( + 200, + (int)((float)($s->variables['max_connections'] ?? 100) * 1.5), + ), + runtimeWritable: true, + configKey: 'max_connections', + configFile: 'my.cnf', + ), + requires: ['Max_used_connections', 'max_connections'], + ); + + $rules[] = new Rule( + id: 'Aborted_connects', + name: 'Aborted connections', + category: 'Connections', + severity: Rule::SEVERITY_WARNING, + formula: 'Aborted_connects / Connections * 100', + test: 'value > 1', + issue: 'A noticeable share of connection attempts are aborting before completing.', + recommendation: 'Investigate clients connecting with bad credentials, network drops, or insufficient max_allowed_packet.', + justification: '%s%% of connection attempts failed (Aborted_connects=%s, Connections=%s).', + justificationFormula: 'value, Aborted_connects, Connections', + requires: ['Aborted_connects', 'Connections'], + ); + + $rules[] = new Rule( + id: 'Thread_cache_hit_rate', + name: 'Thread cache hit rate', + category: 'Connections', + severity: Rule::SEVERITY_WARNING, + formula: '100 - (Threads_created / Connections * 100)', + test: 'value < 90', + issue: 'Many incoming connections are creating new threads instead of reusing cached ones.', + recommendation: 'Increase thread_cache_size so most connections are served from the thread cache.', + justification: 'Thread cache hit rate is %s%%; aim for 90%% or higher.', + justificationFormula: 'value', + apply: new ApplyDescriptor( + variable: 'thread_cache_size', + recommendedValue: static fn (Snapshot $s) => (string)max( + 16, + (int)((float)($s->variables['max_connections'] ?? 100) / 4), + ), + runtimeWritable: true, + configKey: 'thread_cache_size', + configFile: 'my.cnf', + ), + requires: ['Threads_created', 'Connections'], + ); + + // ── Performance: queries ──────────────────────────────────── + + $rules[] = new Rule( + id: 'Slow_query_ratio', + name: 'Slow query ratio', + category: 'Performance', + severity: Rule::SEVERITY_WARNING, + formula: 'Slow_queries / Questions * 100', + test: 'value > 0.05', + issue: 'A measurable fraction of queries are exceeding long_query_time.', + recommendation: 'Inspect the slow query log; add indexes or rewrite the worst offenders.', + justification: '%s%% of queries are flagged as slow (Slow_queries=%s, Questions=%s).', + justificationFormula: 'value, Slow_queries, Questions', + requires: ['Slow_queries', 'Questions'], + ); + + $rules[] = new Rule( + id: 'Select_full_join', + name: 'Joins without indexes', + category: 'Performance', + severity: Rule::SEVERITY_WARNING, + formula: 'Select_full_join / Questions * 100', + test: 'value > 0.05', + issue: 'Some joins are scanning entire tables because no usable index exists on the join column.', + recommendation: 'Identify the offending queries (set log_queries_not_using_indexes) and add the missing indexes.', + justification: '%s%% of queries did a full join without an index.', + justificationFormula: 'value', + requires: ['Select_full_join', 'Questions'], + ); + + $rules[] = new Rule( + id: 'Sort_merge_passes', + name: 'Sort buffer passes', + category: 'Performance', + severity: Rule::SEVERITY_NOTICE, + formula: 'Sort_merge_passes / Sort_scan', + test: 'value > 0.2', + issue: 'Sorts are spilling to disk because sort_buffer_size is too small.', + recommendation: 'Increase sort_buffer_size for sessions that run heavy sorts (per-thread; do not over-allocate).', + justification: 'sort_buffer_size is too small for some sorts (Sort_merge_passes=%s).', + justificationFormula: 'Sort_merge_passes', + apply: new ApplyDescriptor( + variable: 'sort_buffer_size', + recommendedValue: static fn (Snapshot $s) => '4M', + runtimeWritable: true, + configKey: 'sort_buffer_size', + configFile: 'my.cnf', + note: 'Per-thread buffer — keep modest to avoid memory blow-up at high connection counts.', + ), + requires: ['Sort_merge_passes', 'Sort_scan'], + ); + + // ── Memory: temp tables ───────────────────────────────────── + + $rules[] = new Rule( + id: 'Created_tmp_disk_tables', + name: 'On-disk temp tables', + category: 'Memory', + severity: Rule::SEVERITY_WARNING, + formula: 'Created_tmp_disk_tables / Created_tmp_tables * 100', + test: 'value > 25', + issue: 'A large share of temporary tables are being created on disk instead of in memory.', + recommendation: 'Increase tmp_table_size and max_heap_table_size to keep more temp tables in memory.', + justification: '%s%% of temp tables ended up on disk.', + justificationFormula: 'value', + apply: new ApplyDescriptor( + variable: 'tmp_table_size', + recommendedValue: static fn (Snapshot $s) => '64M', + runtimeWritable: true, + configKey: 'tmp_table_size', + configFile: 'my.cnf', + ), + requires: ['Created_tmp_disk_tables', 'Created_tmp_tables'], + ); + + // ── Table cache ───────────────────────────────────────────── + + $rules[] = new Rule( + id: 'Table_open_cache', + name: 'Table open cache hit rate', + category: 'Performance', + severity: Rule::SEVERITY_NOTICE, + formula: 'Table_open_cache_misses / (Table_open_cache_hits + Table_open_cache_misses) * 100', + test: 'value > 1', + issue: 'The table open cache is too small; tables are being opened from disk frequently.', + recommendation: 'Increase table_open_cache (and table_definition_cache) until the miss rate drops below 1%.', + justification: '%s%% of table opens missed the cache.', + justificationFormula: 'value', + apply: new ApplyDescriptor( + variable: 'table_open_cache', + recommendedValue: static fn (Snapshot $s) => (string)max( + 4000, + (int)((float)($s->variables['table_open_cache'] ?? 2000) * 2), + ), + runtimeWritable: true, + configKey: 'table_open_cache', + configFile: 'my.cnf', + ), + requires: ['Table_open_cache_misses', 'Table_open_cache_hits'], + ); + + // ── InnoDB buffer pool ────────────────────────────────────── + + $rules[] = new Rule( + id: 'Innodb_buffer_pool_hit_rate', + name: 'InnoDB buffer pool hit rate', + category: 'InnoDB', + severity: Rule::SEVERITY_WARNING, + formula: '100 - (Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests * 100)', + test: 'value < 99', + issue: 'The InnoDB buffer pool is too small — pages are being fetched from disk too often.', + recommendation: 'Increase innodb_buffer_pool_size; aim for ~60% of total RAM on a dedicated DB host.', + justification: 'Hit rate is %s%%; aim for 99%% or higher.', + justificationFormula: 'value', + apply: new ApplyDescriptor( + variable: 'innodb_buffer_pool_size', + recommendedValue: static fn (Snapshot $s) => (string)max( + 128 * 1024 * 1024, + (int)((float)($s->variables['innodb_buffer_pool_size'] ?? 134217728) * 1.5), + ), + runtimeWritable: true, + configKey: 'innodb_buffer_pool_size', + configFile: 'my.cnf', + note: 'Dynamic on MySQL 5.7+ — but persisting to my.cnf is recommended so the value survives restarts.', + ), + requires: ['Innodb_buffer_pool_reads', 'Innodb_buffer_pool_read_requests'], + ); + + $rules[] = new Rule( + id: 'Innodb_buffer_pool_pages_dirty', + name: 'InnoDB dirty page ratio', + category: 'InnoDB', + severity: Rule::SEVERITY_NOTICE, + formula: 'Innodb_buffer_pool_pages_dirty / Innodb_buffer_pool_pages_total * 100', + test: 'value > 75', + issue: 'A large share of buffer pool pages are dirty — flushing is falling behind writes.', + recommendation: 'Increase innodb_io_capacity (or io_capacity_max) to let the flusher keep pace.', + justification: '%s%% of buffer pool pages are dirty.', + justificationFormula: 'value', + requires: ['Innodb_buffer_pool_pages_dirty', 'Innodb_buffer_pool_pages_total'], + ); + + // ── InnoDB log ────────────────────────────────────────────── + + $rules[] = new Rule( + id: 'Innodb_log_file_size', + name: 'InnoDB log file size', + category: 'InnoDB', + severity: Rule::SEVERITY_WARNING, + formula: 'innodb_log_file_size', + test: 'value < 134217728', + issue: 'innodb_log_file_size is small; a small log forces frequent checkpoints and limits write throughput.', + recommendation: 'Increase innodb_log_file_size to at least 128 MB on busy servers.', + justification: 'innodb_log_file_size is %s bytes; a value of at least 128 MB is recommended.', + justificationFormula: 'value', + apply: new ApplyDescriptor( + variable: 'innodb_log_file_size', + recommendedValue: static fn (Snapshot $s) => '256M', + runtimeWritable: false, // Older MySQL requires restart; MySQL 8.0+ accepts SET GLOBAL but only resizes after a flush. + configKey: 'innodb_log_file_size', + configFile: 'my.cnf', + note: 'Server restart recommended after changing this setting.', + ), + requires: ['innodb_log_file_size'], + ); + + $rules[] = new Rule( + id: 'Innodb_flush_log_at_trx_commit', + name: 'InnoDB log flush mode', + category: 'InnoDB', + severity: Rule::SEVERITY_NOTICE, + formula: 'innodb_flush_log_at_trx_commit', + test: 'value != 1', + issue: 'innodb_flush_log_at_trx_commit is not set to the durable value (1).', + recommendation: 'Use 1 unless you accept losing up to one second of writes on crash. Values 0 or 2 trade durability for throughput.', + justification: 'innodb_flush_log_at_trx_commit is currently %s.', + justificationFormula: 'value', + apply: new ApplyDescriptor( + variable: 'innodb_flush_log_at_trx_commit', + recommendedValue: static fn (Snapshot $s) => '1', + runtimeWritable: true, + configKey: 'innodb_flush_log_at_trx_commit', + configFile: 'my.cnf', + ), + requires: ['innodb_flush_log_at_trx_commit'], + ); + + // ── Network ───────────────────────────────────────────────── + + $rules[] = new Rule( + id: 'Max_allowed_packet', + name: 'max_allowed_packet', + category: 'Connections', + severity: Rule::SEVERITY_NOTICE, + formula: 'max_allowed_packet', + test: 'value < 16777216', + issue: 'max_allowed_packet is small; large file uploads or BLOBs may be rejected.', + recommendation: 'Set max_allowed_packet to at least 16 MB. Nextcloud benefits from 64 MB.', + justification: 'max_allowed_packet is %s bytes.', + justificationFormula: 'value', + apply: new ApplyDescriptor( + variable: 'max_allowed_packet', + recommendedValue: static fn (Snapshot $s) => '64M', + runtimeWritable: true, + configKey: 'max_allowed_packet', + configFile: 'my.cnf', + ), + requires: ['max_allowed_packet'], + ); + + // ── Replication ───────────────────────────────────────────── + + $rules[] = new Rule( + id: 'Slave_lag', + name: 'Replica lag', + category: 'Replication', + severity: Rule::SEVERITY_ALERT, + formula: 'Slave_running', + test: 'value == 0', + issue: 'A configured replica is not running.', + recommendation: 'Check the replica with SHOW REPLICA STATUS; resolve any IO/SQL errors and restart replication.', + justification: 'Slave_running is reporting OFF.', + justificationFormula: '', + requires: ['Slave_running'], + ); + + // ── Security ──────────────────────────────────────────────── + + $rules[] = new Rule( + id: 'Skip_name_resolve', + name: 'Skip name resolve', + category: 'Security', + severity: Rule::SEVERITY_NOTICE, + formula: 'skip_name_resolve', + test: 'value == 0', + issue: 'Reverse DNS is performed on every connection — slow DNS makes connection setup slow.', + recommendation: 'Set skip_name_resolve=1 and grant by IP. Removes a network round-trip per connection.', + justification: 'skip_name_resolve is currently OFF.', + justificationFormula: '', + requires: ['skip_name_resolve'], + ); + + // ── Memory: query cache (MySQL 5.x / MariaDB only — auto-skipped on 8.0+) ── + + $rules[] = new Rule( + id: 'Query_cache_efficiency', + name: 'Query cache efficiency', + category: 'Performance', + severity: Rule::SEVERITY_NOTICE, + formula: 'Qcache_hits / (Qcache_hits + Com_select + 1) * 100', + test: 'value < 20', + issue: 'The query cache is enabled but has poor hit rates; it may be hurting more than helping.', + recommendation: 'Disable the query cache (query_cache_type=OFF) or accept the inefficiency. The query cache was removed in MySQL 8.0.', + justification: 'Query cache hit rate is %s%%.', + justificationFormula: 'value', + requires: ['Qcache_hits', 'Com_select'], + ); + + // ── MyISAM ────────────────────────────────────────────────── + + $rules[] = new Rule( + id: 'Key_buffer_hit_rate', + name: 'MyISAM key buffer hit rate', + category: 'Performance', + severity: Rule::SEVERITY_NOTICE, + formula: '100 - (Key_reads / Key_read_requests * 100)', + test: 'value < 99', + issue: 'MyISAM index reads are not finding their pages in the key buffer.', + recommendation: 'Increase key_buffer_size, or migrate the affected tables to InnoDB.', + justification: 'Key buffer hit rate is %s%%.', + justificationFormula: 'value', + apply: new ApplyDescriptor( + variable: 'key_buffer_size', + recommendedValue: static fn (Snapshot $s) => '64M', + runtimeWritable: true, + configKey: 'key_buffer_size', + configFile: 'my.cnf', + ), + requires: ['Key_reads', 'Key_read_requests'], + ); + + // ── Locks ─────────────────────────────────────────────────── + + $rules[] = new Rule( + id: 'Table_locks_waited', + name: 'Table lock contention', + category: 'Performance', + severity: Rule::SEVERITY_WARNING, + formula: 'Table_locks_waited / Table_locks_immediate * 100', + test: 'value > 1', + issue: 'A noticeable share of table-level lock requests had to wait.', + recommendation: 'Migrate locking-heavy MyISAM tables to InnoDB; review long-running transactions.', + justification: '%s%% of table locks had to wait.', + justificationFormula: 'value', + requires: ['Table_locks_waited', 'Table_locks_immediate'], + ); + + // ── Open files ────────────────────────────────────────────── + + $rules[] = new Rule( + id: 'Open_files_limit', + name: 'Open files limit', + category: 'Connections', + severity: Rule::SEVERITY_NOTICE, + formula: 'Open_files / open_files_limit * 100', + test: 'value > 80', + issue: 'The server has approached its OS open-files limit.', + recommendation: 'Raise open_files_limit and / or systemd LimitNOFILE.', + justification: '%s%% of open_files_limit is in use.', + justificationFormula: 'value', + requires: ['Open_files', 'open_files_limit'], + ); + + // ── Binlog cache ──────────────────────────────────────────── + + $rules[] = new Rule( + id: 'Binlog_cache_disk_use', + name: 'Binlog cache spilled to disk', + category: 'Performance', + severity: Rule::SEVERITY_NOTICE, + formula: 'Binlog_cache_disk_use / Binlog_cache_use * 100', + test: 'value > 10', + issue: 'Some transactions are exceeding binlog_cache_size and spilling to a temporary file.', + recommendation: 'Increase binlog_cache_size if your transactions are large.', + justification: '%s%% of binlog cache uses spilled to disk.', + justificationFormula: 'value', + apply: new ApplyDescriptor( + variable: 'binlog_cache_size', + recommendedValue: static fn (Snapshot $s) => '4M', + runtimeWritable: true, + configKey: 'binlog_cache_size', + configFile: 'my.cnf', + ), + requires: ['Binlog_cache_disk_use', 'Binlog_cache_use'], + ); + + $rules[] = new Rule( + id: 'Sync_binlog', + name: 'Binary log durability', + category: 'Performance', + severity: Rule::SEVERITY_NOTICE, + formula: 'sync_binlog', + test: 'value != 1', + issue: 'sync_binlog is not 1 — replicas can lose transactions on a primary crash.', + recommendation: 'Set sync_binlog=1 unless you knowingly accept replica drift on crash.', + justification: 'sync_binlog is currently %s.', + justificationFormula: 'value', + apply: new ApplyDescriptor( + variable: 'sync_binlog', + recommendedValue: static fn (Snapshot $s) => '1', + runtimeWritable: true, + configKey: 'sync_binlog', + configFile: 'my.cnf', + ), + requires: ['sync_binlog'], + ); + + // ── Additional rules ported from phpMyAdmin's advisor ─────── + // + // These mirror upstream rules that didn't have a local + // equivalent in the original 24-rule port. Upstream's English + // `id` is preserved in the trailing comment so future sync + // passes can match by id verbatim. Rules that depend on + // `fired()` (cross-rule predicate), `substr/preg_match` on + // version strings, or `system_memory` (not exposed via + // `SHOW VARIABLES` on most installs) are intentionally not + // ported — porting them faithfully would require either + // rule-graph evaluation or string ops that the safe expression + // evaluator deliberately doesn't support. + + // Server / data-quality + $rules[] = new Rule( + // upstream id: "Questions below 1,000" + id: 'Questions_below_1000', + name: 'Too few queries to analyse', + category: 'Server', + severity: Rule::SEVERITY_NOTICE, + formula: 'Questions', + test: 'value < 1000', + issue: 'Fewer than 1,000 queries have run against this server, so statistical advice may be inaccurate.', + recommendation: 'Let the server run for longer before relying on the recommendations below.', + justification: 'Current Questions: %s.', + justificationFormula: 'value', + requires: ['Questions'], + ); + + // Performance / queries + $rules[] = new Rule( + // upstream id: "Slow query rate" + id: 'Slow_query_rate', + name: 'Slow query rate', + category: 'Performance', + severity: Rule::SEVERITY_WARNING, + formula: '(Slow_queries / Questions * 100) / Uptime', + test: 'value * 60 * 60 > 1', + issue: 'A high rate of slow queries relative to server uptime.', + recommendation: 'Increase long_query_time, or optimize the queries listed in the slow query log.', + justification: 'Slow query rate is %s%% per hour; aim for less than 1%% per hour.', + justificationFormula: 'round(value * 3600, 2)', + requires: ['Slow_queries', 'Questions', 'Uptime'], + ); + + // Performance / sorts + $rules[] = new Rule( + // upstream id: "Sort rows" + id: 'Sort_rows', + name: 'High sorted-row rate', + category: 'Performance', + severity: Rule::SEVERITY_NOTICE, + formula: 'Sort_rows / Uptime', + test: 'value * 60 >= 1', + issue: 'Many rows are being sorted server-side.', + recommendation: 'Make sure ORDER BY columns are indexed so sorts use indexes instead of merge passes.', + justification: 'Sorted rows average: %s per hour.', + justificationFormula: 'round(value * 3600, 1)', + requires: ['Sort_rows', 'Uptime'], + ); + + $rules[] = new Rule( + // upstream id: "Rate of sorts that cause temporary tables" + id: 'Sort_merge_passes_rate', + name: 'Sort merge-pass rate', + category: 'Performance', + severity: Rule::SEVERITY_NOTICE, + formula: 'Sort_merge_passes / Uptime', + test: 'value * 60 * 60 > 1', + issue: 'Sorts are spilling to temporary tables faster than once per hour.', + recommendation: 'Increase sort_buffer_size and/or read_rnd_buffer_size, depending on your memory budget.', + justification: 'Merge-pass rate: %s per hour; aim for less than 1.', + justificationFormula: 'round(value * 3600, 2)', + requires: ['Sort_merge_passes', 'Uptime'], + ); + + // Performance / joins + scans (rate-based companions to existing absolute checks) + $rules[] = new Rule( + // upstream id: "Rate of joins without indexes" + id: 'Join_without_index_rate', + name: 'Join-without-index rate', + category: 'Performance', + severity: Rule::SEVERITY_WARNING, + formula: '(Select_range_check + Select_scan + Select_full_join) / Uptime', + test: 'value * 60 * 60 > 1', + issue: 'Many SELECTs are doing full table scans or joins without indexes.', + recommendation: 'Add indexes for the columns used in JOIN conditions and WHERE filters.', + justification: 'Index-less join+scan rate: %s per hour; aim for less than 1.', + justificationFormula: 'round(value * 3600, 2)', + requires: ['Select_range_check', 'Select_scan', 'Select_full_join', 'Uptime'], + ); + + $rules[] = new Rule( + // upstream id: "Rate of reading first index entry" + id: 'Handler_read_first_rate', + name: 'Full-index-scan rate', + category: 'Performance', + severity: Rule::SEVERITY_NOTICE, + formula: 'Handler_read_first / Uptime', + test: 'value * 60 * 60 > 1', + issue: 'Many queries are reading the first index entry, suggesting frequent full-index scans.', + recommendation: 'Investigate whether the indexes match query predicates; rewrite queries that scan the whole index.', + justification: 'Full-index-scan rate: %s per hour; aim for less than 1.', + justificationFormula: 'round(value * 3600, 2)', + requires: ['Handler_read_first', 'Uptime'], + ); + + $rules[] = new Rule( + // upstream id: "Rate of reading fixed position" + id: 'Handler_read_rnd_rate', + name: 'Fixed-position read rate', + category: 'Performance', + severity: Rule::SEVERITY_NOTICE, + formula: 'Handler_read_rnd / Uptime', + test: 'value * 60 * 60 > 1', + issue: 'Many queries are reading rows by fixed position — usually a sign of full table scans + sorts.', + recommendation: 'Add indexes that match the queries\' ORDER BY / JOIN columns.', + justification: 'Fixed-position read rate: %s per hour; aim for less than 1.', + justificationFormula: 'round(value * 3600, 2)', + requires: ['Handler_read_rnd', 'Uptime'], + ); + + $rules[] = new Rule( + // upstream id: "Rate of reading next table row" + id: 'Handler_read_rnd_next_rate', + name: 'Sequential-row read rate', + category: 'Performance', + severity: Rule::SEVERITY_NOTICE, + formula: 'Handler_read_rnd_next / Uptime', + test: 'value * 60 * 60 > 1', + issue: 'Many queries are scanning tables sequentially.', + recommendation: 'Add indexes that match common WHERE / JOIN predicates.', + justification: 'Sequential-row read rate: %s per hour; aim for less than 1.', + justificationFormula: 'round(value * 3600, 2)', + requires: ['Handler_read_rnd_next', 'Uptime'], + ); + + // Memory / temp tables + $rules[] = new Rule( + // upstream id: "Different tmp_table_size and max_heap_table_size" + id: 'Tmp_max_heap_size_mismatch', + name: 'tmp_table_size and max_heap_table_size differ', + category: 'Memory', + severity: Rule::SEVERITY_NOTICE, + formula: 'tmp_table_size - max_heap_table_size', + test: 'value != 0', + issue: 'tmp_table_size and max_heap_table_size are not equal.', + recommendation: 'Set both to the same value; the server uses the lower of the two as the in-memory limit.', + justification: 'tmp_table_size = %s, max_heap_table_size = %s.', + justificationFormula: 'tmp_table_size, max_heap_table_size', + requires: ['tmp_table_size', 'max_heap_table_size'], + ); + + // Memory / MyISAM key buffer + $rules[] = new Rule( + // upstream id: "MyISAM key buffer size" + id: 'Key_buffer_size_zero', + name: 'MyISAM key buffer disabled', + category: 'Memory', + severity: Rule::SEVERITY_NOTICE, + formula: 'key_buffer_size', + test: 'value == 0', + issue: 'key_buffer_size is 0; MyISAM indexes will not be cached.', + recommendation: 'Set key_buffer_size based on your MyISAM index footprint; 64M is a reasonable starting point.', + justification: 'key_buffer_size is 0.', + justificationFormula: '', + apply: new ApplyDescriptor( + variable: 'key_buffer_size', + recommendedValue: static fn (Snapshot $s) => (string)(64 * 1024 * 1024), + runtimeWritable: true, + configKey: 'key_buffer_size', + configFile: 'my.cnf', + ), + requires: ['key_buffer_size'], + ); + + $rules[] = new Rule( + // upstream id: "Max % MyISAM key buffer ever used" + id: 'Key_buffer_peak_usage_low', + name: 'MyISAM key buffer peak usage low', + category: 'Memory', + severity: Rule::SEVERITY_NOTICE, + formula: 'Key_blocks_used * key_cache_block_size / key_buffer_size * 100', + test: 'value < 95 && key_buffer_size > 0', + issue: 'The MyISAM key buffer has rarely been more than 95%% full.', + recommendation: 'Consider lowering key_buffer_size, or check whether expected indexes still exist.', + justification: 'Peak key buffer usage: %s%%; aim for >= 95%%.', + justificationFormula: 'round(value, 1)', + requires: ['Key_blocks_used', 'key_cache_block_size', 'key_buffer_size'], + ); + + // Performance / open files + $rules[] = new Rule( + // upstream id: "Rate of open files" + id: 'Open_files_rate', + name: 'File-open rate', + category: 'Performance', + severity: Rule::SEVERITY_NOTICE, + formula: 'Open_files / Uptime', + test: 'value * 60 * 60 > 5', + issue: 'Files are being opened at a high rate.', + recommendation: 'Increase open_files_limit and verify that the OS limit is not throttling the server.', + justification: 'File-open rate: %s per hour; aim for less than 5.', + justificationFormula: 'round(value * 3600, 1)', + requires: ['Open_files', 'Uptime'], + ); + + // Performance / locks + $rules[] = new Rule( + // upstream id: "Table lock wait rate" + id: 'Table_lock_wait_rate', + name: 'Table-lock wait rate', + category: 'Performance', + severity: Rule::SEVERITY_NOTICE, + formula: 'Table_locks_waited / Uptime', + test: 'value * 60 * 60 > 1', + issue: 'Table locks are being waited on at a high rate.', + recommendation: 'Move busy MyISAM tables to InnoDB, or optimize the queries that hold the locks.', + justification: 'Lock-wait rate: %s per hour; aim for less than 1.', + justificationFormula: 'round(value * 3600, 2)', + requires: ['Table_locks_waited', 'Uptime'], + ); + + // Performance / threads + $rules[] = new Rule( + // upstream id: "Thread cache" + id: 'Thread_cache_size_zero', + name: 'Thread cache disabled', + category: 'Performance', + severity: Rule::SEVERITY_WARNING, + formula: 'thread_cache_size', + test: 'value < 1', + issue: 'thread_cache_size is 0; every new connection creates a new thread.', + recommendation: 'Set thread_cache_size to a non-zero value (max_connections / 4 is a reasonable start).', + justification: 'thread_cache_size is 0.', + justificationFormula: '', + apply: new ApplyDescriptor( + variable: 'thread_cache_size', + recommendedValue: static fn (Snapshot $s) => (string)max( + 16, + (int)((float)($s->variables['max_connections'] ?? 100) / 4), + ), + runtimeWritable: true, + configKey: 'thread_cache_size', + configFile: 'my.cnf', + ), + requires: ['thread_cache_size'], + ); + + $rules[] = new Rule( + // upstream id: "Slow launch time" + id: 'Slow_launch_time_high', + name: 'slow_launch_time threshold high', + category: 'Performance', + severity: Rule::SEVERITY_NOTICE, + formula: 'slow_launch_time', + test: 'value > 2', + issue: 'slow_launch_time is set higher than 2 seconds.', + recommendation: 'Set slow_launch_time to 1 or 2 so the Slow_launch_threads counter is meaningful.', + justification: 'slow_launch_time is %s seconds.', + justificationFormula: 'value', + requires: ['slow_launch_time'], + ); + + $rules[] = new Rule( + // upstream id: "Threads that are slow to launch" + id: 'Slow_launch_threads', + name: 'Threads slow to launch', + category: 'Performance', + severity: Rule::SEVERITY_WARNING, + formula: 'Slow_launch_threads', + test: 'value > 0 && slow_launch_time > 0', + issue: 'Some threads have taken longer than slow_launch_time to start.', + recommendation: 'Investigate system load — slow thread launches usually indicate broader contention.', + justification: '%s thread(s) took longer than slow_launch_time to start.', + justificationFormula: 'value', + requires: ['Slow_launch_threads', 'slow_launch_time'], + ); + + // Connections (rate companions to existing absolute checks) + $rules[] = new Rule( + // upstream id: "Rate of aborted connections" + id: 'Aborted_connects_rate', + name: 'Aborted-connection rate', + category: 'Connections', + severity: Rule::SEVERITY_WARNING, + formula: 'Aborted_connects / Uptime', + test: 'value * 60 * 60 > 1', + issue: 'Connection attempts are aborting at a high rate.', + recommendation: 'Check for clients with bad credentials, network drops, or insufficient max_allowed_packet.', + justification: 'Aborted-connection rate: %s per hour; aim for less than 1.', + justificationFormula: 'round(value * 3600, 2)', + requires: ['Aborted_connects', 'Uptime'], + ); + + $rules[] = new Rule( + // upstream id: "Percentage of aborted clients" + id: 'Aborted_clients_percentage', + name: 'Aborted-client percentage', + category: 'Connections', + severity: Rule::SEVERITY_WARNING, + formula: 'Aborted_clients / Connections * 100', + test: 'value > 2', + issue: 'A noticeable share of connected clients are dropping without closing the connection.', + recommendation: 'Check for network issues or client code that does not close DB handles cleanly.', + justification: '%s%% of all clients aborted (Aborted_clients=%s, Connections=%s).', + justificationFormula: 'round(value, 1), Aborted_clients, Connections', + requires: ['Aborted_clients', 'Connections'], + ); + + $rules[] = new Rule( + // upstream id: "Rate of aborted clients" + id: 'Aborted_clients_rate', + name: 'Aborted-client rate', + category: 'Connections', + severity: Rule::SEVERITY_WARNING, + formula: 'Aborted_clients / Uptime', + test: 'value * 60 * 60 > 1', + issue: 'Clients are aborting at a high rate.', + recommendation: 'Check for network issues or client code that does not close DB handles cleanly.', + justification: 'Aborted-client rate: %s per hour; aim for less than 1.', + justificationFormula: 'round(value * 3600, 2)', + requires: ['Aborted_clients', 'Uptime'], + ); + + // InnoDB + $rules[] = new Rule( + // upstream id: "InnoDB log size" + id: 'Innodb_log_size_small', + name: 'InnoDB log size small relative to buffer pool', + category: 'InnoDB', + severity: Rule::SEVERITY_NOTICE, + // `innodb_log_files_in_group` is 1 on MariaDB 10.5+ and not all + // installs expose it as a status/variable. We rely on the + // requires list to skip the rule on installs where the value + // isn't reported. + formula: '(innodb_log_file_size * innodb_log_files_in_group) / innodb_buffer_pool_size * 100', + test: 'value < 20 && innodb_log_file_size / (1024 * 1024) < 256', + issue: 'InnoDB log size is small relative to the buffer pool.', + recommendation: 'Aim for innodb_log_file_size around 25%% of innodb_buffer_pool_size, capped at 256 MiB.', + justification: 'InnoDB log is %s%% of the buffer pool; aim for >= 20%%.', + justificationFormula: 'round(value, 1)', + requires: ['innodb_log_file_size', 'innodb_log_files_in_group', 'innodb_buffer_pool_size'], + ); + + $rules[] = new Rule( + // upstream id: "Max InnoDB log size" + id: 'Innodb_log_size_large', + name: 'InnoDB log size very large', + category: 'InnoDB', + severity: Rule::SEVERITY_NOTICE, + formula: 'innodb_log_file_size / (1024 * 1024)', + test: 'value > 256', + issue: 'innodb_log_file_size is set above 256 MiB.', + recommendation: 'A very large log slows recovery after a crash; 25%% of the buffer pool is usually enough.', + justification: 'innodb_log_file_size is %s MiB.', + justificationFormula: 'round(value, 1)', + requires: ['innodb_log_file_size'], + ); + + // MyISAM + $rules[] = new Rule( + // upstream id: "MyISAM concurrent inserts" + id: 'Concurrent_insert_off', + name: 'MyISAM concurrent inserts disabled', + category: 'MyISAM', + severity: Rule::SEVERITY_NOTICE, + formula: 'concurrent_insert', + test: 'value == 0', + issue: 'concurrent_insert is set to 0, blocking concurrent INSERT/SELECT on MyISAM tables.', + recommendation: 'Set concurrent_insert to 1 (or AUTO/2) to reduce reader/writer contention on MyISAM.', + justification: 'concurrent_insert is 0.', + justificationFormula: '', + requires: ['concurrent_insert'], + ); + + // Query cache (legacy — these rules will skip on MySQL 8.0+ via + // `requires`, since the QC variables don't exist there. The + // upstream `fired()` precondition that hides them when the QC + // is fully disabled is intentionally not modelled — at worst, + // users see two QC-related findings instead of one.) + $rules[] = new Rule( + // upstream id: "Query cache disabled" + id: 'Query_cache_disabled', + name: 'Query cache disabled', + category: 'QueryCache', + severity: Rule::SEVERITY_NOTICE, + formula: 'query_cache_size', + test: 'value == 0', + issue: 'The query cache is disabled (query_cache_size == 0).', + recommendation: 'On read-heavy MySQL <= 5.7, sizing query_cache_size and setting query_cache_type=ON can help. (Removed in MySQL 8.0+.)', + justification: 'query_cache_size is 0.', + justificationFormula: '', + requires: ['query_cache_size'], + ); + + $rules[] = new Rule( + // upstream id: "Query Cache usage" + id: 'Query_cache_usage_low', + name: 'Query cache underutilized', + category: 'QueryCache', + severity: Rule::SEVERITY_NOTICE, + formula: '100 - Qcache_free_memory / query_cache_size * 100', + test: 'value < 80 && query_cache_size > 0', + issue: 'Less than 80%% of the query cache is in use.', + recommendation: 'query_cache_limit may be too low; raising it can let larger results enter the cache.', + justification: 'Query cache is %s%% utilized.', + justificationFormula: 'round(value, 1)', + requires: ['Qcache_free_memory', 'query_cache_size'], + ); + + $rules[] = new Rule( + // upstream id: "Query cache fragmentation" + id: 'Query_cache_fragmentation', + name: 'Query cache fragmented', + category: 'QueryCache', + severity: Rule::SEVERITY_NOTICE, + formula: 'Qcache_free_blocks / (Qcache_total_blocks / 2) * 100', + test: 'value > 20 && Qcache_total_blocks > 0', + issue: 'The query cache is significantly fragmented.', + recommendation: 'Tune query_cache_min_res_unit, or temporarily flush the cache to defragment.', + justification: 'Cache fragmentation: %s%%; aim for less than 20%%.', + justificationFormula: 'round(value, 1)', + requires: ['Qcache_free_blocks', 'Qcache_total_blocks'], + ); + + $rules[] = new Rule( + // upstream id: "Query cache low memory prunes" + id: 'Query_cache_lowmem_prunes', + name: 'Query cache evicting under memory pressure', + category: 'QueryCache', + severity: Rule::SEVERITY_NOTICE, + formula: 'Qcache_lowmem_prunes / Qcache_inserts * 100', + test: 'value > 0.1 && Qcache_inserts > 0', + issue: 'Cached queries are being evicted because the query cache is full.', + recommendation: 'Increase query_cache_size — but in small increments; an oversized cache adds maintenance overhead.', + justification: 'Lowmem prune ratio: %s%% of inserts.', + justificationFormula: 'round(value, 2)', + requires: ['Qcache_lowmem_prunes', 'Qcache_inserts'], + ); + + $rules[] = new Rule( + // upstream id: "Query cache max size" + id: 'Query_cache_size_too_large', + name: 'Query cache size very large', + category: 'QueryCache', + severity: Rule::SEVERITY_NOTICE, + formula: 'query_cache_size', + test: 'value > 1024 * 1024 * 128', + issue: 'query_cache_size is above 128 MiB; cache maintenance overhead grows with size.', + recommendation: 'Reducing query_cache_size to 64-128 MiB is usually enough on MySQL <= 5.7.', + justification: 'query_cache_size is %s bytes.', + justificationFormula: 'value', + requires: ['query_cache_size'], + ); + + // ── Uptime sanity ─────────────────────────────────────────── + + $rules[] = new Rule( + id: 'Short_uptime', + name: 'Short server uptime', + category: 'Performance', + severity: Rule::SEVERITY_NOTICE, + formula: 'Uptime_hours', + test: 'value < 24', + issue: 'The server has been up for less than a day; statistics-driven rules will be less reliable.', + recommendation: 'Re-run DB Doctor after the server has been running for at least a day.', + justification: 'Uptime is %s hours; rules using counters are most accurate after 24+ hours.', + justificationFormula: 'Uptime_hours', + requires: ['Uptime_hours'], + ); + + // ── Nextcloud-specific database requirements ───────────────── + + $rules[] = new Rule( + id: 'nc.transaction_isolation', + name: 'Transaction isolation level', + category: 'Nextcloud', + severity: Rule::SEVERITY_NOTICE, + formula: 'nc_read_committed', + test: 'value == 0', + issue: 'The global transaction isolation level is not READ-COMMITTED. Nextcloud recommends READ-COMMITTED on MySQL/MariaDB to reduce lock contention and deadlocks under concurrent load.', + recommendation: 'Set "transaction_isolation = READ-COMMITTED" in the [mysqld] section of my.cnf and restart the server.', + justification: 'The server-wide transaction isolation level is not READ-COMMITTED.', + justificationFormula: '', + apply: new ApplyDescriptor( + variable: 'transaction_isolation', + recommendedValue: static fn (Snapshot $s) => 'READ-COMMITTED', + runtimeWritable: false, + configKey: 'transaction_isolation', + configFile: 'my.cnf', + note: 'Nextcloud already forces READ-COMMITTED per session; setting it globally is belt-and-suspenders and needs a server restart.', + ), + requires: ['nc_read_committed'], + docUrl: 'https://docs.nextcloud.com/server/latest/admin_manual/configuration_database/linux_database_configuration.html', + ); + + $rules[] = new Rule( + id: 'nc.utf8mb4_charset', + name: '4-byte charset (utf8mb4)', + category: 'Nextcloud', + severity: Rule::SEVERITY_WARNING, + formula: 'nc_non_utf8mb4_tables', + test: 'value > 0', + issue: 'Some Nextcloud tables are not using the 4-byte utf8mb4 charset, so they cannot store emoji and some characters, and may break file names.', + recommendation: 'Enable "mysql.utf8mb4" => true in config.php, then run "occ maintenance:repair --include-expensive" to convert the tables (requires innodb_file_per_table and Barracuda/DYNAMIC row format).', + justification: '%s Nextcloud table(s) are not using utf8mb4.', + justificationFormula: 'nc_non_utf8mb4_tables', + requires: ['nc_non_utf8mb4_tables'], + docUrl: 'https://docs.nextcloud.com/server/latest/admin_manual/configuration_database/mysql_4byte_support.html', + ); + + $rules[] = new Rule( + id: 'nc.innodb_engine', + name: 'InnoDB storage engine', + category: 'Nextcloud', + severity: Rule::SEVERITY_ALERT, + formula: 'nc_non_innodb_tables', + test: 'value > 0', + issue: 'Some Nextcloud tables are not using the InnoDB storage engine. Nextcloud requires InnoDB; MyISAM and others lack transactions and row-level locking and can corrupt under load.', + recommendation: 'Convert each affected table with "ALTER TABLE ENGINE=InnoDB;" during a maintenance window.', + justification: '%s Nextcloud table(s) are not using InnoDB.', + justificationFormula: 'nc_non_innodb_tables', + requires: ['nc_non_innodb_tables'], + docUrl: 'https://docs.nextcloud.com/server/latest/admin_manual/configuration_database/linux_database_configuration.html', + ); + + // Shared Nextcloud schema-integrity checks (missing indices / + // columns / primary keys). + foreach ($this->nextcloudSchemaRules() as $rule) { + $rules[] = $rule; + } + + return $rules; + } +} diff --git a/lib/Database/RuleSet/NextcloudSchemaRules.php b/lib/Database/RuleSet/NextcloudSchemaRules.php new file mode 100644 index 00000000..a80805c2 --- /dev/null +++ b/lib/Database/RuleSet/NextcloudSchemaRules.php @@ -0,0 +1,76 @@ + + */ + private function nextcloudSchemaRules(): array { + return [ + new Rule( + id: 'nc.missing_primary_keys', + name: 'Missing primary keys', + category: 'Nextcloud', + severity: Rule::SEVERITY_ALERT, + formula: 'nc_missing_primary_keys', + test: 'value > 0', + issue: 'One or more Nextcloud tables are missing a primary key. Tables without a primary key hurt replication and cluster performance.', + recommendation: 'Run "occ db:add-missing-primary-keys" while the instance is in maintenance mode.', + justification: '%s Nextcloud table(s) are missing an expected primary key.', + justificationFormula: 'nc_missing_primary_keys', + requires: ['nc_missing_primary_keys'], + docUrl: 'https://docs.nextcloud.com/server/latest/admin_manual/occ_command.html#commands-label', + ), + new Rule( + id: 'nc.missing_indices', + name: 'Missing indices', + category: 'Nextcloud', + severity: Rule::SEVERITY_WARNING, + formula: 'nc_missing_indices', + test: 'value > 0', + issue: 'Nextcloud expects indices that are not present on the database. Missing indices force full-table scans and slow the instance down.', + recommendation: 'Run "occ db:add-missing-indices" to create them (safe to run while online, though large tables take time).', + justification: '%s expected index(es) are missing.', + justificationFormula: 'nc_missing_indices', + requires: ['nc_missing_indices'], + docUrl: 'https://docs.nextcloud.com/server/latest/admin_manual/occ_command.html#commands-label', + ), + new Rule( + id: 'nc.missing_columns', + name: 'Missing columns', + category: 'Nextcloud', + severity: Rule::SEVERITY_WARNING, + formula: 'nc_missing_columns', + test: 'value > 0', + issue: 'Nextcloud expects columns that are not present on the database, usually left over from an interrupted upgrade.', + recommendation: 'Run "occ db:add-missing-columns" while the instance is in maintenance mode.', + justification: '%s expected column(s) are missing.', + justificationFormula: 'nc_missing_columns', + requires: ['nc_missing_columns'], + docUrl: 'https://docs.nextcloud.com/server/latest/admin_manual/occ_command.html#commands-label', + ), + ]; + } +} diff --git a/lib/Database/RuleSet/Postgres.php b/lib/Database/RuleSet/Postgres.php new file mode 100644 index 00000000..4d68ed42 --- /dev/null +++ b/lib/Database/RuleSet/Postgres.php @@ -0,0 +1,173 @@ +|null */ + private ?array $cache = null; + + public function rules(): array { + if ($this->cache === null) { + $this->cache = $this->build(); + } + return $this->cache; + } + + public function flavour(): string { + return Snapshot::FLAVOUR_PGSQL; + } + + /** + * @return list + */ + private function build(): array { + $rules = []; + + $rules[] = new Rule( + id: 'pg.cache_hit_ratio', + name: 'Buffer cache hit ratio', + category: 'Performance', + severity: Rule::SEVERITY_WARNING, + formula: 'cache_hit_ratio * 100', + test: 'value < 99', + issue: 'A significant share of page reads are missing the shared buffer cache.', + recommendation: 'Increase shared_buffers (a common starting point is 25 % of system RAM).', + justification: 'Cache hit ratio is %s%%; aim for 99%% or higher.', + justificationFormula: 'value', + apply: new ApplyDescriptor( + variable: 'shared_buffers', + recommendedValue: static fn (Snapshot $s) => '256MB', + runtimeWritable: false, + configKey: 'shared_buffers', + configFile: 'postgresql.conf', + note: 'Server restart required for shared_buffers to take effect.', + ), + requires: ['cache_hit_ratio'], + ); + + $rules[] = new Rule( + id: 'pg.dead_tuples', + name: 'Dead tuples in user tables', + category: 'Performance', + severity: Rule::SEVERITY_WARNING, + formula: 'max_dead_tuples', + test: 'value > 100000', + issue: 'At least one user table has accumulated many dead tuples — autovacuum may not be keeping up.', + recommendation: 'Tune autovacuum_naptime / autovacuum_vacuum_scale_factor, or run VACUUM ANALYZE on the worst tables.', + justification: 'Largest table has %s dead tuples.', + justificationFormula: 'value', + apply: new ApplyDescriptor( + variable: 'autovacuum_naptime', + recommendedValue: static fn (Snapshot $s) => '30', + runtimeWritable: true, + configKey: 'autovacuum_naptime', + configFile: 'postgresql.conf', + note: 'Postgres reload required (handled automatically). Value in seconds.', + ), + requires: ['max_dead_tuples'], + ); + + $rules[] = new Rule( + id: 'pg.tables_without_index_use', + name: 'Tables scanning sequentially', + category: 'Performance', + severity: Rule::SEVERITY_NOTICE, + formula: 'tables_without_index_use', + test: 'value > 0', + issue: 'Some tables have many sequential scans and no index scans, suggesting a missing index.', + recommendation: 'Add appropriate indexes to the affected tables (pg_stat_user_tables shows the full scan statistics).', + justification: '%s table(s) have > 1M sequential scans without any index scans.', + justificationFormula: 'value', + requires: ['tables_without_index_use'], + // The probe records the offending table names next to the + // count; showing them saves the admin the pg_stat_user_tables + // round-trip the recommendation would otherwise require. + detailsKey: 'tables_without_index_use', + ); + + $rules[] = new Rule( + id: 'pg.replication_lag', + name: 'Replication lag', + category: 'Replication', + severity: Rule::SEVERITY_ALERT, + formula: 'max_replay_lag_seconds', + test: 'value > 30', + issue: 'A standby is more than 30 seconds behind the primary.', + recommendation: 'Check pg_stat_replication; investigate WAL apply bottlenecks on the standby.', + justification: 'Worst replica is %s seconds behind.', + justificationFormula: 'value', + requires: ['max_replay_lag_seconds'], + ); + + $rules[] = new Rule( + id: 'pg.connection_saturation', + name: 'Connection saturation', + category: 'Connections', + severity: Rule::SEVERITY_ALERT, + formula: 'active_connections / max_connections * 100', + test: 'value > 80', + issue: 'More than 80 % of max_connections are in use.', + recommendation: 'Raise max_connections, deploy a connection pooler (e.g. PgBouncer), or shorten idle timeouts.', + justification: '%s%% of max_connections in use.', + justificationFormula: 'value', + apply: new ApplyDescriptor( + variable: 'max_connections', + recommendedValue: static fn (Snapshot $s) => (string)max( + 200, + (int)((float)($s->variables['max_connections'] ?? 100) * 1.5), + ), + runtimeWritable: false, + configKey: 'max_connections', + configFile: 'postgresql.conf', + note: 'max_connections requires a server restart to take effect.', + ), + requires: ['active_connections', 'max_connections'], + ); + + $rules[] = new Rule( + id: 'pg.long_running_queries', + name: 'Long-running queries', + category: 'Performance', + severity: Rule::SEVERITY_WARNING, + formula: 'long_running_queries', + test: 'value > 0', + issue: 'One or more queries have been running for more than 5 minutes.', + recommendation: 'Inspect pg_stat_activity for the offending queries; consider statement_timeout.', + justification: '%s query/queries running longer than 5 minutes.', + justificationFormula: 'value', + requires: ['long_running_queries'], + ); + + // Nextcloud schema-integrity checks (missing indices / columns / + // primary keys) apply to Postgres as much as MySQL. + foreach ($this->nextcloudSchemaRules() as $rule) { + $rules[] = $rule; + } + + return $rules; + } +} diff --git a/lib/Database/Snapshot.php b/lib/Database/Snapshot.php new file mode 100644 index 00000000..b78192f8 --- /dev/null +++ b/lib/Database/Snapshot.php @@ -0,0 +1,82 @@ + $status + * @param array $variables + * @param array $derived + * @param array> $details Non-scalar facts that + * can't live in the expression context (which is numbers-only): + * lists of names backing a numeric metric, keyed by the same + * name as that metric (e.g. the table names behind the + * `tables_without_index_use` count). Rules opt in via + * {@see \OCA\ServerInfo\Database\Rule::$detailsKey}. + */ + public function __construct( + public readonly string $flavour, + public readonly string $version, + public readonly array $status, + public readonly array $variables, + public readonly array $derived, + public readonly array $details = [], + ) { + } + + /** + * @return array + */ + public function context(): array { + return $this->derived + $this->variables + $this->status; + } + + public function has(string $key): bool { + return array_key_exists($key, $this->status) + || array_key_exists($key, $this->variables) + || array_key_exists($key, $this->derived); + } + + public function get(string $key): mixed { + if (array_key_exists($key, $this->derived)) { + return $this->derived[$key]; + } + if (array_key_exists($key, $this->variables)) { + return $this->variables[$key]; + } + return $this->status[$key] ?? null; + } + + public function isSupported(): bool { + return $this->flavour !== self::FLAVOUR_UNSUPPORTED; + } +} diff --git a/src/components/DatabaseLiveCharts.vue b/src/components/DatabaseLiveCharts.vue new file mode 100644 index 00000000..e4cc9815 --- /dev/null +++ b/src/components/DatabaseLiveCharts.vue @@ -0,0 +1,256 @@ + + + + + + diff --git a/src/components/DatabaseRuleCard.vue b/src/components/DatabaseRuleCard.vue new file mode 100644 index 00000000..d0bfe7c4 --- /dev/null +++ b/src/components/DatabaseRuleCard.vue @@ -0,0 +1,253 @@ + + + + + + diff --git a/src/components/DatabaseSettingsDialog.vue b/src/components/DatabaseSettingsDialog.vue new file mode 100644 index 00000000..a8a2c25f --- /dev/null +++ b/src/components/DatabaseSettingsDialog.vue @@ -0,0 +1,165 @@ + + + + + + diff --git a/src/components/DatabaseSnippetDialog.vue b/src/components/DatabaseSnippetDialog.vue new file mode 100644 index 00000000..2373bee9 --- /dev/null +++ b/src/components/DatabaseSnippetDialog.vue @@ -0,0 +1,116 @@ + + + + + + diff --git a/src/components/SettingsNavigation.vue b/src/components/SettingsNavigation.vue index cb5fca74..376a83cd 100644 --- a/src/components/SettingsNavigation.vue +++ b/src/components/SettingsNavigation.vue @@ -15,6 +15,12 @@ + + + +