998 lines
41 KiB
PHP
998 lines
41 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
use Exception;
|
|
use mysqli_result;
|
|
use Throwable;
|
|
|
|
class module_usage_service
|
|
{
|
|
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
|
|
{
|
|
if (is_bool($value)) {
|
|
return $value;
|
|
}
|
|
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|