Files
api/services/nginx/app/classes/module_usage_service.php
T
Jeppe Bandopenhands a71194d46e refactor(api): centralise truthy-string -> bool coercion in a shared trait (#368)
## What

Centralises the truthy-string → bool coercion that six different classes
were reimplementing (and which two implementations disagreed about).

The shared helper lives in
`services/nginx/app/traits/boolean_normalization_t.php`:

```php
namespace traits;
trait boolean_normalization_t {
    public static function normalizeBoolean(mixed $value): bool {
        if (is_bool($value)) return $value;
        return in_array(strtolower(trim((string)$value)), ['1','true','yes','on'], true);
    }
}
```

`traits/module_config_variable_t::inputToBool` now delegates to it. The
seven call sites that previously inlined the same expression (or wrapped
it in a private `toBool`/`boolValue`/`isEnabled`) are reduced to a
single `self::normalizeBoolean(...)` call:

| Class | Old helper | New |
| --- | --- | --- |
| `classes/cron_worker.php` | inline in `boolOption` |
`self::normalizeBoolean(...)` (after empty-value short-circuit) |
| `classes/replica_failover_manager.php` | `boolValue` |
`self::normalizeBoolean(...)` |
| `classes/release_manager.php` | `toBool` |
`self::normalizeBoolean(...)` |
| `classes/releasemanager.php` | `isEnabled` |
`self::normalizeBoolean(...)` |
| `classes/superuser_system_status_service.php` | inline in
`parseModuleConfigValue` | `self::normalizeBoolean(...)` |
| `classes/module_usage_service.php` | `toBool` |
`self::normalizeBoolean(...)` |
| `classes/account_deletion_service.php` | inline in `apiEnabled` |
`self::normalizeBoolean(...)` |
| `traits/module_config_variable_t.php` | `inputToBool` (narrow set) |
`self::normalizeBoolean(...)` (full set) |

## Why

The pre-PR repo had two silent bugs:

1. **`inputToBool` accepted only `'true'`/`'1'`** while the six inline
copies accepted the wider `['1','true','yes','on']` set. Config values
such as `"yes"` or `" ON "` would round-trip to `false` through
`inputToBool` but `true` through any of the inline copies. This PR picks
the wider set as the single source of truth; the change is a strict
superset, so no caller flips from truthy to falsy.
2. **Six copies of the same expression** to drift in any of the seven
places (whitespace handling, case sensitivity, empty-string semantics).
One trait replaces them.

## Tests

* `tests/Unit/Traits/BooleanNormalizationTest.php` — Pest, runs the
helper directly through anonymous-class composition (no DB/HTTP).
* `tests/Smoke/boolean_normalization_smoke.php` — standalone PHP smoke
runner for environments without composer installed. Verified locally:
7/7 consumer wiring checks pass, 19/19 normalization cases pass (`true`,
`false`, `1`, `0`, `'true'`, `'TRUE'`, `'1'`, `'yes'`, `'YES'`, `'on'`,
`' ON '`, `'false'`, `'no'`, `'off'`, `''`, `null`, `'0'`, `[]`,
stdClass).
* All eight touched files pass `php -l` syntax check.

## Risk

* Behavioural change is a strict superset for the shared expression
path, so no caller can flip from truthy → falsy. The only consumer that
saw a behaviour change for *negative* inputs is `inputToBool` itself,
which previously rejected `'yes'`/`'on'`. Worth a CI pass on the
unit/integration suites before merge.

## Co-author

Co-authored-by: openhands <openhands@all-hands.dev>

---

_This PR was generated by an AI agent (OpenHands) on behalf of
copenhagentruckwash._

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-12 20:20:03 +02:00

1000 lines
41 KiB
PHP

<?php
namespace classes;
use Exception;
use mysqli_result;
use Throwable;
use traits\boolean_normalization_t;
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class module_usage_service
{
use boolean_normalization_t;
private module_usage_registry $registry;
public function __construct(?module_usage_registry $registry = null)
{
module_usage_schema_bootstrap::ensureTables();
$this->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));
}
}