registry = $registry ?? new module_usage_registry(); } public function summary(array $filters = []): array { $moduleFilter = isset($filters['module']) ? $this->registry->normalizeModuleKey((string)$filters['module']) : ''; $periodFilter = isset($filters['period']) ? strtolower(trim((string)$filters['period'])) : ''; $statusFilter = isset($filters['status']) ? strtolower(trim((string)$filters['status'])) : ''; $date = isset($filters['date']) ? (string)$filters['date'] : null; $metrics = []; foreach ($this->registry->all() as $descriptor) { if ($moduleFilter !== '' && $descriptor['module_key'] !== $moduleFilter) { continue; } if ($periodFilter !== '' && $periodFilter !== 'all' && $descriptor['period'] !== $periodFilter) { continue; } $metric = $this->currentMetric($descriptor, $date); if ($statusFilter !== '' && $metric['status'] !== $statusFilter) { continue; } $metrics[] = $metric; } $modules = []; foreach ($metrics as $metric) { $moduleKey = $metric['module_key']; if (!isset($modules[$moduleKey])) { $modules[$moduleKey] = [ 'key' => $moduleKey, 'label' => $metric['module_label'], 'status' => 'ok', 'metrics' => [], ]; } $modules[$moduleKey]['metrics'][] = $metric; $modules[$moduleKey]['status'] = $this->worseStatus($modules[$moduleKey]['status'], $metric['status']); } return [ 'generated_at' => date('c'), 'filters' => [ 'module' => $moduleFilter !== '' ? $moduleFilter : null, 'period' => $periodFilter !== '' ? $periodFilter : null, 'status' => $statusFilter !== '' ? $statusFilter : null, 'date' => $date, ], 'modules' => array_values($modules), 'metrics' => $metrics, ]; } public function moduleDetail(string $moduleKey, array $filters = []): array { $moduleKey = $this->registry->normalizeModuleKey($moduleKey); $descriptors = $this->registry->forModule($moduleKey); $date = isset($filters['date']) ? (string)$filters['date'] : null; $metrics = array_map(fn(array $descriptor): array => $this->currentMetric($descriptor, $date), $descriptors); return [ 'generated_at' => date('c'), 'module_key' => $moduleKey, 'metrics' => $metrics, 'history' => $this->historyForModule($moduleKey, $filters), ]; } public function metricsForModule(string $moduleKey): array { $moduleKey = $this->registry->normalizeModuleKey($moduleKey); return array_map( fn(array $descriptor): array => $this->currentMetric($descriptor), $this->registry->forModule($moduleKey) ); } /** * Atomically records usage and enforces blocking quotas when the metric is configured for block mode. * * @throws Exception */ public function reserveOrFail(string $moduleKey, string $metricKey, float $quantity = 1.0, array $metadata = []): array { $descriptor = $this->requireDescriptor($moduleKey, $metricKey); $quantity = max(0.0, $quantity); if ($quantity <= 0.0) { return $this->currentMetric($descriptor); } $setting = $this->settingFor($descriptor); if (($setting['enabled'] ?? true) !== true || ($setting['enforce_mode'] ?? 'observe') !== 'block') { return $this->recordUsage($moduleKey, $metricKey, $quantity, $metadata); } $limit = $this->resolveLimitQuantity($descriptor); if ($limit === null) { return $this->recordUsage($moduleKey, $metricKey, $quantity, $metadata); } $period = $this->periodWindow((string)$descriptor['period']); $this->insertCounterIfMissing($descriptor, $period, $limit); global $db; $where = $this->counterWhereSql($descriptor, $period); $quantitySql = $this->numberSql($quantity); $limitSql = $this->numberSql($limit); $metadataSql = $this->jsonSql($metadata); $db->query( "UPDATE module_usage_counters SET used_quantity = used_quantity + {$quantitySql}, limit_quantity = {$limitSql}, metadata_json = {$metadataSql}, status = CASE WHEN {$limitSql} <= 0 OR ((used_quantity + {$quantitySql}) >= {$limitSql}) THEN 'exhausted' WHEN ((used_quantity + {$quantitySql}) / {$limitSql}) * 100 >= " . module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT . " THEN 'near_limit' ELSE 'ok' END WHERE {$where} AND (used_quantity + {$quantitySql}) <= {$limitSql}" ); if ((int)$db->conn()->affected_rows <= 0) { throw new Exception($this->quotaExceededMessage($descriptor)); } return $this->currentMetric($descriptor); } public function recordUsage(string $moduleKey, string $metricKey, float $quantity = 1.0, array $metadata = []): array { $descriptor = $this->requireDescriptor($moduleKey, $metricKey); $quantity = max(0.0, $quantity); if ($quantity <= 0.0 || !$this->databaseReady()) { return $this->currentMetric($descriptor); } $limit = $this->resolveLimitQuantity($descriptor); $setting = $this->settingFor($descriptor); $period = $this->periodWindow((string)$descriptor['period']); $this->insertCounterIfMissing($descriptor, $period, $limit); global $db; $where = $this->counterWhereSql($descriptor, $period); $quantitySql = $this->numberSql($quantity); $limitSql = $this->nullableNumberSql($limit); $metadataSql = $this->jsonSql($metadata); $softLimitSql = $this->numberSql((float)$setting['soft_limit_percent']); $statusSql = (($setting['enabled'] ?? true) !== true) ? $this->sqlString('disabled') : "CASE WHEN {$limitSql} IS NULL THEN 'unlimited' WHEN {$limitSql} <= 0 AND (used_quantity + {$quantitySql}) > 0 THEN 'exhausted' WHEN {$limitSql} <= 0 THEN 'ok' WHEN (used_quantity + {$quantitySql}) >= {$limitSql} THEN 'exhausted' WHEN ((used_quantity + {$quantitySql}) / {$limitSql}) * 100 >= {$softLimitSql} THEN 'near_limit' ELSE 'ok' END"; $db->query( "UPDATE module_usage_counters SET used_quantity = used_quantity + {$quantitySql}, limit_quantity = {$limitSql}, metadata_json = {$metadataSql}, status = {$statusSql} WHERE {$where}" ); return $this->currentMetric($descriptor); } public function currentUsedQuantity(string $moduleKey, string $metricKey): int { $descriptor = $this->requireDescriptor($moduleKey, $metricKey); $metric = $this->currentMetric($descriptor); return (int)floor((float)($metric['used'] ?? 0)); } public function updateQuotaSetting(string $moduleKey, string $metricKey, array $payload): array { $descriptor = $this->requireDescriptor($moduleKey, $metricKey); $setting = $this->settingFor($descriptor); $enabled = array_key_exists('enabled', $payload) ? $this->toBool($payload['enabled']) : (bool)$setting['enabled']; $enforceMode = array_key_exists('enforce_mode', $payload) ? strtolower(trim((string)$payload['enforce_mode'])) : (string)$setting['enforce_mode']; if (!in_array($enforceMode, ['observe', 'block'], true)) { throw new Exception('Invalid enforce mode.'); } $softLimitPercent = array_key_exists('soft_limit_percent', $payload) ? (float)$payload['soft_limit_percent'] : (float)$setting['soft_limit_percent']; if ($softLimitPercent < 1.0 || $softLimitPercent > 100.0) { throw new Exception('Soft limit percent must be between 1 and 100.'); } if (array_key_exists('limit', $payload) || array_key_exists('hard_limit', $payload) || array_key_exists('hard_limit_quantity', $payload)) { if (empty($descriptor['writable_limit']) || empty($descriptor['config_module']) || empty($descriptor['config_variable'])) { throw new Exception('quota_not_writable'); } $limitValue = $payload['limit'] ?? $payload['hard_limit'] ?? $payload['hard_limit_quantity']; if (!is_numeric($limitValue) || (int)$limitValue < 0) { throw new Exception('Limit must be a non-negative integer.'); } $this->writeConfigLimit($descriptor, (int)$limitValue); } if ($this->databaseReady()) { global $db; $moduleKeySql = $this->sqlString((string)$descriptor['module_key']); $metricKeySql = $this->sqlString((string)$descriptor['metric_key']); $enabledSql = $enabled ? '1' : '0'; $modeSql = $this->sqlString($enforceMode); $softLimitSql = $this->numberSql($softLimitPercent); $db->query( "INSERT INTO module_quota_settings (module_key, metric_key, enabled, enforce_mode, soft_limit_percent) VALUES ({$moduleKeySql}, {$metricKeySql}, {$enabledSql}, {$modeSql}, {$softLimitSql}) ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), enforce_mode = VALUES(enforce_mode), soft_limit_percent = VALUES(soft_limit_percent)" ); } return $this->currentMetric($descriptor); } public function recordProviderSnapshotFromLegacyUsage(string $moduleKey, array $usage, array $rawPayload = []): ?array { $moduleKey = $this->registry->normalizeModuleKey($moduleKey); $descriptor = null; foreach ($this->registry->forModule($moduleKey) as $candidate) { if (($candidate['source'] ?? '') === 'provider_snapshot') { $descriptor = $candidate; break; } } if ($descriptor === null) { return null; } $used = $this->firstNumeric($usage, ['used', 'calls_used', 'messages_used']); $limit = $this->firstNumeric($usage, ['limit', 'quota', 'quota_calls', 'total_calls']); $remaining = $this->firstNumeric($usage, ['remaining', 'calls_remaining', 'messages_remaining']); $percent = $this->firstNumeric($usage, ['usage_percent', 'percent']); $usageAvailable = !array_key_exists('usage_available', $usage) || $this->toBool($usage['usage_available']); $unavailableReason = trim((string)($usage['unavailable_reason'] ?? '')); if ($used === null && $limit === null && $usageAvailable && $unavailableReason === '') { return null; } if ($remaining === null && $used !== null && $limit !== null) { $remaining = max(0.0, $limit - $used); } if ($percent === null && $used !== null && $limit !== null && $limit > 0) { $percent = round(($used / $limit) * 100, 4); } $status = (!$usageAvailable || $unavailableReason !== '') ? 'unknown' : $this->statusForUsage($used, $limit, $this->settingFor($descriptor)); $payload = array_merge($rawPayload, ['usage' => $this->redactPayload($usage)]); if ($this->databaseReady()) { try { global $db; $db->query( "INSERT INTO module_usage_snapshots (module_key, metric_key, source, period_key, unit, used_quantity, limit_quantity, remaining_quantity, usage_percent, status, raw_payload_json, checked_at) VALUES ( " . $this->sqlString((string)$descriptor['module_key']) . ", " . $this->sqlString((string)$descriptor['metric_key']) . ", 'provider', 'provider', " . $this->sqlString((string)$descriptor['unit']) . ", " . $this->nullableNumberSql($used) . ", " . $this->nullableNumberSql($limit) . ", " . $this->nullableNumberSql($remaining) . ", " . $this->nullableNumberSql($percent) . ", " . $this->sqlString($status) . ", " . $this->jsonSql($payload) . ", " . $this->sqlString(date('Y-m-d H:i:s')) . " )" ); } catch (Throwable) { // Provider snapshots are observability data. They must never break probes. } } return $this->providerMetricFromValues($descriptor, $used, $limit, $remaining, $percent, $status, date('c'), $usage); } public function primarySystemUsage(array $metrics): ?array { $metrics = array_values(array_filter( $metrics, static fn(array $metric): bool => ($metric['limit'] ?? null) !== null || ($metric['used'] ?? null) !== null )); if ($metrics === []) { return null; } usort($metrics, function (array $left, array $right): int { if (($left['primary'] ?? false) !== ($right['primary'] ?? false)) { return ($right['primary'] ?? false) <=> ($left['primary'] ?? false); } $leftRank = $this->statusRank((string)($left['status'] ?? 'unknown')); $rightRank = $this->statusRank((string)($right['status'] ?? 'unknown')); return $rightRank <=> $leftRank; }); $metric = $metrics[0]; return [ 'provider' => $metric['module_key'], 'metric_key' => $metric['metric_key'], 'unit' => $metric['unit'], 'period' => $metric['period'], 'calls_used' => $metric['used'], 'quota_calls' => $metric['limit'], 'calls_remaining' => $metric['remaining'], 'usage_percent' => $metric['usage_percent'], 'status' => $metric['status'], 'source' => $metric['source'], 'enforce_mode' => $metric['enforce_mode'], ]; } public function currentMetric(array $descriptor, ?string $date = null): array { $descriptor = $this->normalizeDescriptor($descriptor); $setting = $this->settingFor($descriptor); $window = $this->periodWindow((string)$descriptor['period'], $date); $limit = $this->resolveLimitQuantity($descriptor); $used = null; $updatedAt = null; $historyAvailable = false; $snapshotExtra = []; if (($descriptor['source'] ?? '') === 'provider_snapshot') { $snapshot = $this->latestProviderSnapshot($descriptor); if ($snapshot !== null) { $used = $snapshot['used_quantity']; $limit = $snapshot['limit_quantity']; $updatedAt = $snapshot['checked_at']; $snapshotExtra = $snapshot['extra']; $historyAvailable = true; } } else { $counter = $this->counterFor($descriptor, $window); if ($counter !== null) { $used = $counter['used_quantity']; $limit = $counter['limit_quantity'] ?? $limit; $updatedAt = $counter['updated_at'] ?? $counter['created_at'] ?? null; $historyAvailable = true; } else { $derived = $this->derivedOrLegacyUsage($descriptor, $window); if ($derived !== null) { $used = $derived; $historyAvailable = true; } elseif (($descriptor['source'] ?? '') === 'internal_counter') { $used = 0.0; $historyAvailable = true; } } } $status = $this->statusForUsage($used, $limit, $setting); $remaining = ($used !== null && $limit !== null) ? max(0.0, $limit - $used) : null; $usagePercent = ($used !== null && $limit !== null && $limit > 0) ? round(($used / $limit) * 100, 2) : null; return array_merge([ 'module_key' => $descriptor['module_key'], 'module_label' => $descriptor['module_label'], 'metric_key' => $descriptor['metric_key'], 'metric_label' => $descriptor['metric_label'], 'unit' => $descriptor['unit'], 'period' => $descriptor['period'], 'scope_type' => $descriptor['scope_type'], 'scope_id' => $descriptor['scope_id'], 'source' => $descriptor['source'], 'primary' => (bool)$descriptor['primary'], 'writable_limit' => (bool)$descriptor['writable_limit'], 'limit_source' => isset($descriptor['config_variable']) ? 'module_config' : (($descriptor['source'] ?? '') === 'provider_snapshot' ? 'provider' : null), 'config_module' => $descriptor['config_module'] ?? null, 'config_variable' => $descriptor['config_variable'] ?? null, 'used' => $used, 'limit' => $limit, 'remaining' => $remaining, 'usage_percent' => $usagePercent, 'status' => $status, 'enabled' => (bool)$setting['enabled'], 'enforce_mode' => $setting['enforce_mode'], 'soft_limit_percent' => (float)$setting['soft_limit_percent'], 'window' => [ 'start' => $window['start_c'], 'end' => $window['end_c'], 'timezone' => date_default_timezone_get(), ], 'updated_at' => $updatedAt, 'history_available' => $historyAvailable, ], $snapshotExtra); } private function requireDescriptor(string $moduleKey, string $metricKey): array { $descriptor = $this->registry->find($moduleKey, $metricKey); if ($descriptor === null) { throw new Exception('Unknown module usage metric.'); } return $descriptor; } private function normalizeDescriptor(array $descriptor): array { $found = $this->registry->find((string)$descriptor['module_key'], (string)$descriptor['metric_key']); return $found ?? $descriptor; } private function settingFor(array $descriptor): array { $default = [ 'enabled' => true, 'enforce_mode' => (string)($descriptor['default_enforce_mode'] ?? 'observe'), 'soft_limit_percent' => (float)($descriptor['soft_limit_percent'] ?? module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT), ]; if (!$this->databaseReady()) { return $default; } try { global $db; $result = $db->query( "SELECT enabled, enforce_mode, soft_limit_percent FROM module_quota_settings WHERE module_key = " . $this->sqlString((string)$descriptor['module_key']) . " AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . " LIMIT 1" ); $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; if (!is_array($row)) { return $default; } return [ 'enabled' => (bool)((int)($row['enabled'] ?? 1)), 'enforce_mode' => in_array((string)($row['enforce_mode'] ?? ''), ['observe', 'block'], true) ? (string)$row['enforce_mode'] : $default['enforce_mode'], 'soft_limit_percent' => is_numeric($row['soft_limit_percent'] ?? null) ? (float)$row['soft_limit_percent'] : $default['soft_limit_percent'], ]; } catch (Throwable) { return $default; } } private function resolveLimitQuantity(array $descriptor): ?float { if (empty($descriptor['config_module']) || empty($descriptor['config_variable']) || !$this->databaseReady()) { return null; } try { global $db; $result = $db->query( "SELECT value FROM module_config WHERE module = " . $this->sqlString((string)$descriptor['config_module']) . " AND variable = " . $this->sqlString((string)$descriptor['config_variable']) . " LIMIT 1" ); $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; if (!is_array($row) || !is_numeric($row['value'] ?? null)) { return null; } return max(0.0, (float)$row['value']); } catch (Throwable) { return null; } } private function writeConfigLimit(array $descriptor, int $limit): void { if (!$this->databaseReady()) { return; } global $db; $module = (string)$descriptor['config_module']; $variable = (string)$descriptor['config_variable']; $type = (string)($descriptor['config_type'] ?? 'int'); $existing = $db->query( "SELECT id FROM module_config WHERE module = " . $this->sqlString($module) . " AND variable = " . $this->sqlString($variable) . " LIMIT 1" ); if ($existing instanceof mysqli_result && $existing->num_rows > 0) { $db->query( "UPDATE module_config SET value = " . $this->sqlString((string)$limit) . ", type = " . $this->sqlString($type) . " WHERE module = " . $this->sqlString($module) . " AND variable = " . $this->sqlString($variable) ); } else { $db->query( "INSERT INTO module_config (module, variable, value, type) VALUES (" . $this->sqlString($module) . ", " . $this->sqlString($variable) . ", " . $this->sqlString((string)$limit) . ", " . $this->sqlString($type) . ")" ); } system_search_cache::markDirtyTable('module_config'); } private function insertCounterIfMissing(array $descriptor, array $period, ?float $limit): void { if (!$this->databaseReady()) { return; } global $db; $baseline = $this->derivedOrLegacyUsage($descriptor, $period); $baseline = $baseline === null ? 0.0 : max(0.0, (float)$baseline); $metadata = [ 'created_from' => $baseline > 0 ? 'legacy_or_derived_baseline' : 'counter', ]; $db->query( "INSERT IGNORE INTO module_usage_counters (module_key, metric_key, scope_type, scope_id, period_key, period_start, period_end, unit, used_quantity, limit_quantity, status, metadata_json) VALUES ( " . $this->sqlString((string)$descriptor['module_key']) . ", " . $this->sqlString((string)$descriptor['metric_key']) . ", " . $this->sqlString((string)$descriptor['scope_type']) . ", " . $this->sqlString((string)$descriptor['scope_id']) . ", " . $this->sqlString($period['key']) . ", " . $this->sqlString($period['start_sql']) . ", " . ($period['end_sql'] === null ? 'NULL' : $this->sqlString($period['end_sql'])) . ", " . $this->sqlString((string)$descriptor['unit']) . ", " . $this->numberSql($baseline) . ", " . $this->nullableNumberSql($limit) . ", " . $this->sqlString($this->statusForUsage($baseline, $limit, $this->settingFor($descriptor))) . ", " . $this->jsonSql($metadata) . " )" ); } private function counterFor(array $descriptor, array $period): ?array { if (!$this->databaseReady()) { return null; } try { global $db; $result = $db->query( "SELECT used_quantity, limit_quantity, status, created_at, updated_at FROM module_usage_counters WHERE " . $this->counterWhereSql($descriptor, $period) . " LIMIT 1" ); $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; if (!is_array($row)) { return null; } return [ 'used_quantity' => (float)$row['used_quantity'], 'limit_quantity' => $row['limit_quantity'] === null ? null : (float)$row['limit_quantity'], 'status' => (string)$row['status'], 'created_at' => $row['created_at'] ?? null, 'updated_at' => $row['updated_at'] ?? null, ]; } catch (Throwable) { return null; } } private function latestProviderSnapshot(array $descriptor): ?array { if (!$this->databaseReady()) { return null; } try { global $db; $result = $db->query( "SELECT used_quantity, limit_quantity, remaining_quantity, usage_percent, status, raw_payload_json, checked_at FROM module_usage_snapshots WHERE module_key = " . $this->sqlString((string)$descriptor['module_key']) . " AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . " ORDER BY checked_at DESC, id DESC LIMIT 1" ); $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; if (!is_array($row)) { return null; } $raw = json_decode((string)($row['raw_payload_json'] ?? ''), true); $usage = is_array($raw) && isset($raw['usage']) && is_array($raw['usage']) ? $raw['usage'] : []; $extra = []; if (isset($usage['version'])) { $extra['version'] = (string)$usage['version']; } if (array_key_exists('usage_available', $usage)) { $extra['usage_available'] = $this->toBool($usage['usage_available']); } if (isset($usage['unavailable_reason'])) { $extra['unavailable_reason'] = (string)$usage['unavailable_reason']; } if (isset($usage['detected_keys']) && is_array($usage['detected_keys'])) { $extra['detected_keys'] = array_values(array_map('strval', $usage['detected_keys'])); } return [ 'used_quantity' => $row['used_quantity'] === null ? null : (float)$row['used_quantity'], 'limit_quantity' => $row['limit_quantity'] === null ? null : (float)$row['limit_quantity'], 'remaining_quantity' => $row['remaining_quantity'] === null ? null : (float)$row['remaining_quantity'], 'usage_percent' => $row['usage_percent'] === null ? null : (float)$row['usage_percent'], 'status' => (string)$row['status'], 'checked_at' => $row['checked_at'] ? date('c', strtotime((string)$row['checked_at'])) : null, 'extra' => $extra, ]; } catch (Throwable) { return null; } } private function providerMetricFromValues(array $descriptor, ?float $used, ?float $limit, ?float $remaining, ?float $percent, string $status, string $checkedAt, array $usage): array { return [ 'module_key' => $descriptor['module_key'], 'module_label' => $descriptor['module_label'], 'metric_key' => $descriptor['metric_key'], 'metric_label' => $descriptor['metric_label'], 'unit' => $descriptor['unit'], 'period' => $descriptor['period'], 'scope_type' => $descriptor['scope_type'], 'scope_id' => $descriptor['scope_id'], 'source' => $descriptor['source'], 'primary' => (bool)$descriptor['primary'], 'writable_limit' => false, 'limit_source' => 'provider', 'used' => $used, 'limit' => $limit, 'remaining' => $remaining, 'usage_percent' => $percent, 'status' => $status, 'enabled' => true, 'enforce_mode' => 'observe', 'soft_limit_percent' => module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT, 'window' => ['start' => null, 'end' => null, 'timezone' => date_default_timezone_get()], 'updated_at' => $checkedAt, 'history_available' => true, 'version' => isset($usage['version']) ? (string)$usage['version'] : null, 'usage_available' => array_key_exists('usage_available', $usage) ? $this->toBool($usage['usage_available']) : true, 'unavailable_reason' => isset($usage['unavailable_reason']) ? (string)$usage['unavailable_reason'] : null, 'detected_keys' => isset($usage['detected_keys']) && is_array($usage['detected_keys']) ? array_values(array_map('strval', $usage['detected_keys'])) : [], ]; } private function derivedOrLegacyUsage(array $descriptor, array $period): ?float { try { if (isset($descriptor['legacy_count_table'])) { return $this->countRowsInPeriod((string)$descriptor['legacy_count_table'], 'created_at', $period); } if (isset($descriptor['legacy_log_module'])) { return $this->countActionLogs( (string)$descriptor['legacy_log_module'], isset($descriptor['legacy_log_action']) ? (string)$descriptor['legacy_log_action'] : null, $period ); } return match ($descriptor['module_key'] . '.' . $descriptor['metric_key']) { 'backups.backup_jobs' => $this->countRowsInPeriod('backup_jobs', 'created_at', $period), 'backups.stored_bytes' => $this->sumColumn('backup_records', 'total_bytes'), 'coolify.operations' => $this->countRowsInPeriod('coolify_operations', 'created_at', $period), 'selfserve.wash_sessions' => $this->countRowsInPeriod('selfserve_wash_sessions', 'created_at', $period), 'xlvask.usage_rows' => $this->countRowsInPeriod('xlvask_usage_logs', 'StartTime', $period), 'attachments.stored_files' => $this->countRowsInPeriod('object_attachments', null, $period), 'forms.submissions' => $this->countRowsInPeriod('form_submissions', 'created_at', $period), 'notifications.notification_sends' => $this->countRowsInPeriod('notifications', 'created_at', $period), 'edgegateway.relay_commands' => $this->countRowsInPeriod('edge_gateway_operations', 'created_at', $period), 'system.cron_runs' => $this->countRowsInPeriod('cron_task_runs', 'created_at', $period), default => null, }; } catch (Throwable) { return null; } } private function countActionLogs(string $module, ?string $action, array $period): ?float { if (!$this->tableExists('module_usage_logs')) { return null; } global $db; $where = "UPPER(module) = " . $this->sqlString(strtoupper($module)); if ($action !== null && $action !== '') { $where .= " AND UPPER(action) = " . $this->sqlString(strtoupper($action)); } $where .= $this->periodWhereSql('created_at', $period); $result = $db->query("SELECT COUNT(*) AS usage_count FROM module_usage_logs WHERE {$where}"); $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; return is_array($row) ? (float)$row['usage_count'] : null; } private function countRowsInPeriod(string $table, ?string $dateColumn, array $period): ?float { if (!$this->tableExists($table)) { return null; } if ($dateColumn !== null && !$this->columnExists($table, $dateColumn)) { return null; } global $db; $where = '1=1'; if ($dateColumn !== null) { $where .= $this->periodWhereSql($dateColumn, $period); } elseif ($period['key'] !== 'all_time') { return null; } $result = $db->query("SELECT COUNT(*) AS usage_count FROM `{$table}` WHERE {$where}"); $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; return is_array($row) ? (float)$row['usage_count'] : null; } private function sumColumn(string $table, string $column): ?float { if (!$this->tableExists($table) || !$this->columnExists($table, $column)) { return null; } global $db; $result = $db->query("SELECT COALESCE(SUM(`{$column}`), 0) AS usage_sum FROM `{$table}`"); $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; return is_array($row) ? (float)$row['usage_sum'] : null; } private function historyForModule(string $moduleKey, array $filters): array { if (!$this->databaseReady()) { return []; } $limit = isset($filters['limit']) && is_numeric($filters['limit']) ? max(1, min(200, (int)$filters['limit'])) : 100; $rows = []; try { global $db; $result = $db->query( "SELECT module_key, metric_key, period_key, period_start, period_end, used_quantity, limit_quantity, status, updated_at, created_at FROM module_usage_counters WHERE module_key = " . $this->sqlString($moduleKey) . " ORDER BY period_start DESC, id DESC LIMIT {$limit}" ); if ($result instanceof mysqli_result) { while ($row = $result->fetch_assoc()) { $rows[] = $row; } } } catch (Throwable) { return []; } return $rows; } private function statusForUsage(?float $used, ?float $limit, array $setting): string { if (($setting['enabled'] ?? true) !== true) { return 'disabled'; } if ($used === null) { return 'unknown'; } if ($limit === null) { return 'unlimited'; } if ($limit <= 0.0) { return $used > 0.0 ? 'exhausted' : 'ok'; } $percent = ($used / $limit) * 100; if ($used >= $limit || $percent >= 100.0) { return 'exhausted'; } if ($percent >= (float)($setting['soft_limit_percent'] ?? module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT)) { return 'near_limit'; } return 'ok'; } private function worseStatus(string $current, string $candidate): string { return $this->statusRank($candidate) > $this->statusRank($current) ? $candidate : $current; } private function statusRank(string $status): int { return match ($status) { 'exhausted' => 5, 'near_limit' => 4, 'unknown' => 3, 'disabled' => 2, 'unlimited' => 1, 'ok' => 0, default => 0, }; } private function quotaExceededMessage(array $descriptor): string { return match ((string)$descriptor['period']) { 'day' => 'Daily limit exceeded', 'month' => 'Monthly limit exceeded', default => 'Quota limit exceeded', }; } private function periodWindow(string $period, ?string $date = null): array { $timestamp = $date ? strtotime($date) : time(); if ($timestamp === false) { $timestamp = time(); } return match ($period) { 'day' => $this->periodFromTimestamps('day', strtotime(date('Y-m-d 00:00:00', $timestamp)), strtotime(date('Y-m-d 00:00:00', $timestamp) . ' +1 day')), 'month' => $this->periodFromTimestamps('month', strtotime(date('Y-m-01 00:00:00', $timestamp)), strtotime(date('Y-m-01 00:00:00', $timestamp) . ' +1 month')), 'provider' => ['key' => 'provider', 'start_sql' => date('Y-m-d 00:00:00', $timestamp), 'end_sql' => null, 'start_c' => null, 'end_c' => null], default => ['key' => 'all_time', 'start_sql' => '1970-01-01 00:00:00', 'end_sql' => null, 'start_c' => null, 'end_c' => null], }; } private function periodFromTimestamps(string $key, int $start, int $end): array { return [ 'key' => $key, 'start_sql' => date('Y-m-d H:i:s', $start), 'end_sql' => date('Y-m-d H:i:s', $end), 'start_c' => date('c', $start), 'end_c' => date('c', $end), ]; } private function periodWhereSql(string $dateColumn, array $period): string { if ($period['key'] === 'all_time' || $period['key'] === 'provider') { return ''; } $dateColumn = preg_replace('/[^a-zA-Z0-9_]/', '', $dateColumn); if ($dateColumn === '') { return ''; } return " AND `{$dateColumn}` >= " . $this->sqlString($period['start_sql']) . " AND `{$dateColumn}` < " . $this->sqlString((string)$period['end_sql']); } private function counterWhereSql(array $descriptor, array $period): string { return "module_key = " . $this->sqlString((string)$descriptor['module_key']) . " AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . " AND scope_type = " . $this->sqlString((string)$descriptor['scope_type']) . " AND scope_id = " . $this->sqlString((string)$descriptor['scope_id']) . " AND period_key = " . $this->sqlString($period['key']) . " AND period_start = " . $this->sqlString($period['start_sql']); } private function databaseReady(): bool { global $db; return isset($db) && is_object($db) && method_exists($db, 'query') && method_exists($db, 'conn'); } private function tableExists(string $table): bool { if (!$this->databaseReady()) { return false; } try { global $db; $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table); if ($table === '') { return false; } $result = $db->query("SHOW TABLES LIKE " . $this->sqlString($table)); return $result instanceof mysqli_result && $result->num_rows > 0; } catch (Throwable) { return false; } } private function columnExists(string $table, string $column): bool { if (!$this->databaseReady()) { return false; } try { global $db; $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table); $column = preg_replace('/[^a-zA-Z0-9_]/', '', $column); if ($table === '' || $column === '') { return false; } $result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE " . $this->sqlString($column)); return $result instanceof mysqli_result && $result->num_rows > 0; } catch (Throwable) { return false; } } private function firstNumeric(array $payload, array $keys): ?float { foreach ($keys as $key) { if (isset($payload[$key]) && is_numeric($payload[$key])) { return (float)$payload[$key]; } } return null; } private function redactPayload(array $payload): array { $redacted = []; foreach ($payload as $key => $value) { $normalized = strtolower((string)$key); if (str_contains($normalized, 'key') || str_contains($normalized, 'token') || str_contains($normalized, 'secret')) { $redacted[$key] = '[redacted]'; continue; } $redacted[$key] = is_array($value) ? $this->redactPayload($value) : $value; } return $redacted; } private function toBool(mixed $value): bool { return self::normalizeBoolean($value); } private function sqlString(string $value): string { global $db; return "'" . $db->escape_string($value) . "'"; } private function numberSql(float $value): string { return rtrim(rtrim(sprintf('%.4F', $value), '0'), '.') ?: '0'; } private function nullableNumberSql(?float $value): string { return $value === null ? 'NULL' : $this->numberSql($value); } private function jsonSql(array $value): string { return $this->sqlString(json_encode($this->redactPayload($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } }