Support collected invoice economic PDF downloads
This commit is contained in:
@@ -7196,6 +7196,45 @@ paths:
|
|||||||
'500':
|
'500':
|
||||||
$ref: '#/components/responses/InternalServerError'
|
$ref: '#/components/responses/InternalServerError'
|
||||||
|
|
||||||
|
/collected-invoices/economic/pdf:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Invoices
|
||||||
|
summary: Download a collected invoice e-conomic PDF
|
||||||
|
description: |
|
||||||
|
Resolves the requested collected invoice context and returns a presigned URL for the
|
||||||
|
draft or booked e-conomic invoice PDF.
|
||||||
|
operationId: downloadCollectedInvoiceEconomicPdf
|
||||||
|
parameters:
|
||||||
|
- name: collected_invoice_id
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: The internal collected invoice ID
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
- name: type
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: Which e-conomic invoice PDF to download
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- draft
|
||||||
|
- booked
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: PDF URL resolved successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CollectedInvoiceEconomicPdfResponse'
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||||
|
|
||||||
/collected-invoices/economic/v2/details:
|
/collected-invoices/economic/v2/details:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -17750,6 +17789,31 @@ components:
|
|||||||
- collected_invoice_id
|
- collected_invoice_id
|
||||||
- internal_total
|
- internal_total
|
||||||
|
|
||||||
|
CollectedInvoiceEconomicPdfResponse:
|
||||||
|
type: object
|
||||||
|
description: Presigned PDF URL for a draft or booked e-conomic invoice attached to a collected invoice
|
||||||
|
properties:
|
||||||
|
collected_invoice_id:
|
||||||
|
type: integer
|
||||||
|
example: 123
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- draft
|
||||||
|
- booked
|
||||||
|
example: booked
|
||||||
|
economic_invoice_id:
|
||||||
|
type: integer
|
||||||
|
example: 28368
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
required:
|
||||||
|
- collected_invoice_id
|
||||||
|
- type
|
||||||
|
- economic_invoice_id
|
||||||
|
- url
|
||||||
|
|
||||||
CollectedInvoiceEconomicV2DetailsResponse:
|
CollectedInvoiceEconomicV2DetailsResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use fxratesapi\actions\convert_rate_a;
|
|||||||
use fxratesapi\fxratesapi_c;
|
use fxratesapi\fxratesapi_c;
|
||||||
use interfaces\fxratesapi_i;
|
use interfaces\fxratesapi_i;
|
||||||
use objects\fxratesapi_conversion_rates_o;
|
use objects\fxratesapi_conversion_rates_o;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class fxratesapi implements fxratesapi_i
|
class fxratesapi implements fxratesapi_i
|
||||||
{
|
{
|
||||||
@@ -117,10 +118,10 @@ class fxratesapi implements fxratesapi_i
|
|||||||
// Validate the base and target currencies
|
// Validate the base and target currencies
|
||||||
self::requireValidCurrency($base);
|
self::requireValidCurrency($base);
|
||||||
self::requireValidCurrency($target);
|
self::requireValidCurrency($target);
|
||||||
// Validate the daily limit
|
|
||||||
self::requireDailyLimitNotExceeded();
|
|
||||||
// Validate the secret key
|
// Validate the secret key
|
||||||
self::requireValidSecretKey();
|
self::requireValidSecretKey();
|
||||||
|
// Reserve quota for the outbound provider call. Cached conversion reads return before this point.
|
||||||
|
$this->reserveRateFetchQuota($base, $target, $endpoint, $method);
|
||||||
// Send the request
|
// Send the request
|
||||||
$response = match ($method) {
|
$response = match ($method) {
|
||||||
'GET' => self::sendGetRequest($base, $target, $endpoint, $data),
|
'GET' => self::sendGetRequest($base, $target, $endpoint, $data),
|
||||||
@@ -167,7 +168,7 @@ class fxratesapi implements fxratesapi_i
|
|||||||
function requireDailyLimitNotExceeded(): void
|
function requireDailyLimitNotExceeded(): void
|
||||||
{
|
{
|
||||||
// Check if the daily limit is exceeded
|
// Check if the daily limit is exceeded
|
||||||
if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) {
|
if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) {
|
||||||
throw new Exception('Daily limit exceeded');
|
throw new Exception('Daily limit exceeded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,12 +178,30 @@ class fxratesapi implements fxratesapi_i
|
|||||||
*/
|
*/
|
||||||
function getDailyRequestCounter(): int
|
function getDailyRequestCounter(): int
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
|
return (new module_usage_service())->currentUsedQuantity('fxratesapi', 'rate_fetch_calls');
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
|
||||||
// Count the rows from the fxratesapi request log that was made today
|
// Count the rows from the fxratesapi request log that was made today
|
||||||
$fxratesapi_lookups = new fxratesapi_conversion_rates_o();
|
$fxratesapi_lookups = new fxratesapi_conversion_rates_o();
|
||||||
$fxratesapi_lookups->getTodayCount();
|
$fxratesapi_lookups->getTodayCount();
|
||||||
return $fxratesapi_lookups->getTodayCount();
|
return $fxratesapi_lookups->getTodayCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function reserveRateFetchQuota(string $base, string $target, string $endpoint, string $method): void
|
||||||
|
{
|
||||||
|
(new module_usage_service())->reserveOrFail('fxratesapi', 'rate_fetch_calls', 1, [
|
||||||
|
'base' => $base,
|
||||||
|
'target' => $target,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'method' => strtoupper($method),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritDoc
|
* @inheritDoc
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class invoice_store implements minio_invoices_i
|
|||||||
return count($objects['Contents'] ?? []) > 0;
|
return count($objects['Contents'] ?? []) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getInvoiceDownloadUrl(int $id): string
|
public function getInvoiceDownloadUrl(int|string $id): string
|
||||||
{
|
{
|
||||||
return self::getPresignedUrl('invoice_' . $id . '.pdf');
|
return self::getPresignedUrl('invoice_' . $id . '.pdf');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
class module_usage_registry
|
||||||
|
{
|
||||||
|
public const DEFAULT_SOFT_LIMIT_PERCENT = 90.0;
|
||||||
|
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
fn(array $descriptor): array => $this->withDefaults($descriptor),
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'module_key' => 'motorapi',
|
||||||
|
'module_label' => 'MotorAPI',
|
||||||
|
'metric_key' => 'lookup_calls',
|
||||||
|
'metric_label' => 'License plate lookups',
|
||||||
|
'unit' => 'calls',
|
||||||
|
'period' => 'day',
|
||||||
|
'source' => 'internal_counter',
|
||||||
|
'default_enforce_mode' => 'block',
|
||||||
|
'config_module' => 'motorapi',
|
||||||
|
'config_variable' => 'daily_limit',
|
||||||
|
'config_type' => 'int',
|
||||||
|
'legacy_count_table' => 'motorapi_lookups',
|
||||||
|
'primary' => true,
|
||||||
|
'writable_limit' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'module_key' => 'motorapi',
|
||||||
|
'module_label' => 'MotorAPI',
|
||||||
|
'metric_key' => 'provider_usage',
|
||||||
|
'metric_label' => 'Provider usage',
|
||||||
|
'unit' => 'calls',
|
||||||
|
'period' => 'provider',
|
||||||
|
'source' => 'provider_snapshot',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'module_key' => 'fxratesapi',
|
||||||
|
'module_label' => 'FXRatesAPI',
|
||||||
|
'metric_key' => 'rate_fetch_calls',
|
||||||
|
'metric_label' => 'Currency rate fetches',
|
||||||
|
'unit' => 'calls',
|
||||||
|
'period' => 'day',
|
||||||
|
'source' => 'internal_counter',
|
||||||
|
'default_enforce_mode' => 'block',
|
||||||
|
'config_module' => 'fxratesapi',
|
||||||
|
'config_variable' => 'daily_limit',
|
||||||
|
'config_type' => 'int',
|
||||||
|
'legacy_count_table' => 'fxratesapi_conversion_rates',
|
||||||
|
'primary' => true,
|
||||||
|
'writable_limit' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'module_key' => 'virkdata',
|
||||||
|
'module_label' => 'VirkData',
|
||||||
|
'metric_key' => 'company_search_calls',
|
||||||
|
'metric_label' => 'Company searches',
|
||||||
|
'unit' => 'calls',
|
||||||
|
'period' => 'month',
|
||||||
|
'source' => 'internal_counter',
|
||||||
|
'default_enforce_mode' => 'observe',
|
||||||
|
'config_module' => 'virkdata',
|
||||||
|
'config_variable' => 'monthly_limit',
|
||||||
|
'config_type' => 'int',
|
||||||
|
'legacy_log_module' => 'VIRKDATA',
|
||||||
|
'legacy_log_action' => 'VIRKDATA_SEARCH',
|
||||||
|
'primary' => true,
|
||||||
|
'writable_limit' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'module_key' => 'licenseplaterecognizer',
|
||||||
|
'module_label' => 'License Plate Recognizer',
|
||||||
|
'metric_key' => 'plate_recognition_calls',
|
||||||
|
'metric_label' => 'Plate recognition calls',
|
||||||
|
'unit' => 'calls',
|
||||||
|
'period' => 'provider',
|
||||||
|
'source' => 'provider_snapshot',
|
||||||
|
'default_enforce_mode' => 'observe',
|
||||||
|
'primary' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'module_key' => 'email',
|
||||||
|
'module_label' => 'Email',
|
||||||
|
'metric_key' => 'mailersend_messages',
|
||||||
|
'metric_label' => 'MailerSend messages',
|
||||||
|
'unit' => 'messages',
|
||||||
|
'period' => 'provider',
|
||||||
|
'source' => 'provider_snapshot',
|
||||||
|
],
|
||||||
|
['module_key' => 'openai', 'module_label' => 'OpenAI', 'metric_key' => 'api_calls', 'metric_label' => 'API calls', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'openai', 'module_label' => 'OpenAI', 'metric_key' => 'total_tokens', 'metric_label' => 'Total tokens', 'unit' => 'tokens', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'weatherapi', 'module_label' => 'WeatherAPI', 'metric_key' => 'requests', 'metric_label' => 'Weather requests', 'unit' => 'calls', 'period' => 'day', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'gatewayapi', 'module_label' => 'GatewayAPI', 'metric_key' => 'sms_messages', 'metric_label' => 'SMS messages', 'unit' => 'messages', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'bird', 'module_label' => 'Bird', 'metric_key' => 'messages_and_calls', 'metric_label' => 'Messages and calls', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'ocrspace', 'module_label' => 'OCRSpace', 'metric_key' => 'ocr_requests', 'metric_label' => 'OCR requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'limble', 'module_label' => 'Limble', 'metric_key' => 'api_requests', 'metric_label' => 'API requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'workfeed', 'module_label' => 'Workfeed', 'metric_key' => 'api_requests', 'metric_label' => 'API requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'stripe', 'module_label' => 'Stripe', 'metric_key' => 'payment_events', 'metric_label' => 'Payment events', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'coolify', 'module_label' => 'Coolify', 'metric_key' => 'operations', 'metric_label' => 'Operations', 'unit' => 'events', 'period' => 'month', 'source' => 'derived'],
|
||||||
|
['module_key' => 'backups', 'module_label' => 'Backups', 'metric_key' => 'backup_jobs', 'metric_label' => 'Backup jobs', 'unit' => 'jobs', 'period' => 'month', 'source' => 'derived'],
|
||||||
|
['module_key' => 'backups', 'module_label' => 'Backups', 'metric_key' => 'stored_bytes', 'metric_label' => 'Stored backup data', 'unit' => 'bytes', 'period' => 'all_time', 'source' => 'derived'],
|
||||||
|
['module_key' => 'selfserve', 'module_label' => 'Self Serve', 'metric_key' => 'wash_sessions', 'metric_label' => 'Wash sessions', 'unit' => 'sessions', 'period' => 'month', 'source' => 'derived'],
|
||||||
|
['module_key' => 'selfserve', 'module_label' => 'Self Serve', 'metric_key' => 'lane_commands', 'metric_label' => 'Lane commands', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter', 'legacy_log_module' => 'SELFSERVE'],
|
||||||
|
['module_key' => 'xlvask', 'module_label' => 'XLVask', 'metric_key' => 'usage_rows', 'metric_label' => 'Usage rows', 'unit' => 'rows', 'period' => 'month', 'source' => 'derived'],
|
||||||
|
['module_key' => 'attachments', 'module_label' => 'Attachments', 'metric_key' => 'stored_files', 'metric_label' => 'Stored files', 'unit' => 'files', 'period' => 'all_time', 'source' => 'derived'],
|
||||||
|
['module_key' => 'dynamicimages', 'module_label' => 'Dynamic Images', 'metric_key' => 'renders', 'metric_label' => 'Image renders', 'unit' => 'renders', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'html2pdf', 'module_label' => 'HTML2PDF', 'metric_key' => 'pdf_jobs', 'metric_label' => 'PDF jobs', 'unit' => 'jobs', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'forms', 'module_label' => 'Forms', 'metric_key' => 'submissions', 'metric_label' => 'Submissions', 'unit' => 'submissions', 'period' => 'month', 'source' => 'derived'],
|
||||||
|
['module_key' => 'notifications', 'module_label' => 'Notifications', 'metric_key' => 'notification_sends', 'metric_label' => 'Notification sends', 'unit' => 'notifications', 'period' => 'month', 'source' => 'derived'],
|
||||||
|
['module_key' => 'shelly', 'module_label' => 'Shelly', 'metric_key' => 'relay_commands', 'metric_label' => 'Relay commands', 'unit' => 'commands', 'period' => 'month', 'source' => 'internal_counter', 'legacy_log_module' => 'SHELLY'],
|
||||||
|
['module_key' => 'edgegateway', 'module_label' => 'Edge Gateway', 'metric_key' => 'relay_commands', 'metric_label' => 'Relay commands', 'unit' => 'commands', 'period' => 'month', 'source' => 'derived'],
|
||||||
|
['module_key' => 'system', 'module_label' => 'System', 'metric_key' => 'cron_runs', 'metric_label' => 'Cron runs', 'unit' => 'runs', 'period' => 'day', 'source' => 'derived'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(string $moduleKey, string $metricKey): ?array
|
||||||
|
{
|
||||||
|
$moduleKey = $this->normalizeModuleKey($moduleKey);
|
||||||
|
$metricKey = $this->normalizeMetricKey($metricKey);
|
||||||
|
|
||||||
|
foreach ($this->all() as $descriptor) {
|
||||||
|
if ($descriptor['module_key'] === $moduleKey && $descriptor['metric_key'] === $metricKey) {
|
||||||
|
return $descriptor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forModule(string $moduleKey): array
|
||||||
|
{
|
||||||
|
$moduleKey = $this->normalizeModuleKey($moduleKey);
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->all(),
|
||||||
|
static fn(array $descriptor): bool => $descriptor['module_key'] === $moduleKey
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeModuleKey(string $moduleKey): string
|
||||||
|
{
|
||||||
|
return strtolower(trim($moduleKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeMetricKey(string $metricKey): string
|
||||||
|
{
|
||||||
|
return strtolower(trim($metricKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function withDefaults(array $descriptor): array
|
||||||
|
{
|
||||||
|
$descriptor['module_key'] = $this->normalizeModuleKey((string)$descriptor['module_key']);
|
||||||
|
$descriptor['metric_key'] = $this->normalizeMetricKey((string)$descriptor['metric_key']);
|
||||||
|
$descriptor['module_label'] = (string)($descriptor['module_label'] ?? $descriptor['module_key']);
|
||||||
|
$descriptor['metric_label'] = (string)($descriptor['metric_label'] ?? $descriptor['metric_key']);
|
||||||
|
$descriptor['unit'] = (string)($descriptor['unit'] ?? 'count');
|
||||||
|
$descriptor['period'] = (string)($descriptor['period'] ?? 'all_time');
|
||||||
|
$descriptor['scope_type'] = (string)($descriptor['scope_type'] ?? 'global');
|
||||||
|
$descriptor['scope_id'] = (string)($descriptor['scope_id'] ?? '');
|
||||||
|
$descriptor['source'] = (string)($descriptor['source'] ?? 'internal_counter');
|
||||||
|
$descriptor['default_enforce_mode'] = (string)($descriptor['default_enforce_mode'] ?? 'observe');
|
||||||
|
$descriptor['soft_limit_percent'] = (float)($descriptor['soft_limit_percent'] ?? self::DEFAULT_SOFT_LIMIT_PERCENT);
|
||||||
|
$descriptor['writable_limit'] = (bool)($descriptor['writable_limit'] ?? false);
|
||||||
|
$descriptor['primary'] = (bool)($descriptor['primary'] ?? false);
|
||||||
|
return $descriptor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
class module_usage_schema_bootstrap
|
||||||
|
{
|
||||||
|
private static bool $initialized = false;
|
||||||
|
|
||||||
|
public static function ensureTables(): void
|
||||||
|
{
|
||||||
|
if (self::$initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS module_usage_counters (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
module_key VARCHAR(64) NOT NULL,
|
||||||
|
metric_key VARCHAR(128) NOT NULL,
|
||||||
|
scope_type VARCHAR(32) NOT NULL DEFAULT 'global',
|
||||||
|
scope_id VARCHAR(191) NOT NULL DEFAULT '',
|
||||||
|
period_key VARCHAR(32) NOT NULL DEFAULT 'all_time',
|
||||||
|
period_start DATETIME NOT NULL,
|
||||||
|
period_end DATETIME NULL,
|
||||||
|
unit VARCHAR(32) NOT NULL DEFAULT 'count',
|
||||||
|
used_quantity DECIMAL(20,4) NOT NULL DEFAULT 0,
|
||||||
|
limit_quantity DECIMAL(20,4) NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'ok',
|
||||||
|
metadata_json LONGTEXT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_module_usage_counter (module_key, metric_key, scope_type, scope_id, period_key, period_start),
|
||||||
|
KEY idx_module_usage_counters_module_period (module_key, period_key, period_start),
|
||||||
|
KEY idx_module_usage_counters_status (status, updated_at),
|
||||||
|
KEY idx_module_usage_counters_scope (scope_type, scope_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS module_usage_snapshots (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
module_key VARCHAR(64) NOT NULL,
|
||||||
|
metric_key VARCHAR(128) NOT NULL,
|
||||||
|
source VARCHAR(32) NOT NULL DEFAULT 'provider',
|
||||||
|
period_key VARCHAR(32) NOT NULL DEFAULT 'provider',
|
||||||
|
period_start DATETIME NULL,
|
||||||
|
period_end DATETIME NULL,
|
||||||
|
unit VARCHAR(32) NOT NULL DEFAULT 'count',
|
||||||
|
used_quantity DECIMAL(20,4) NULL,
|
||||||
|
limit_quantity DECIMAL(20,4) NULL,
|
||||||
|
remaining_quantity DECIMAL(20,4) NULL,
|
||||||
|
usage_percent DECIMAL(8,4) NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'unknown',
|
||||||
|
raw_payload_json LONGTEXT NULL,
|
||||||
|
checked_at DATETIME NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_module_usage_snapshots_module_checked (module_key, metric_key, checked_at),
|
||||||
|
KEY idx_module_usage_snapshots_status (status, checked_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS module_quota_settings (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
module_key VARCHAR(64) NOT NULL,
|
||||||
|
metric_key VARCHAR(128) NOT NULL,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
enforce_mode VARCHAR(16) NOT NULL DEFAULT 'observe',
|
||||||
|
soft_limit_percent DECIMAL(6,2) NOT NULL DEFAULT 90.00,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_module_quota_setting (module_key, metric_key),
|
||||||
|
KEY idx_module_quota_settings_mode (enforce_mode, enabled)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS module_usage_logs (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
module VARCHAR(64) NOT NULL,
|
||||||
|
action VARCHAR(64) NOT NULL,
|
||||||
|
status_code INT NOT NULL DEFAULT 0,
|
||||||
|
data LONGTEXT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_module_usage_logs_module_created (module, created_at),
|
||||||
|
KEY idx_module_usage_logs_action_created (action, created_at),
|
||||||
|
KEY idx_module_usage_logs_status_created (status_code, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
|
||||||
|
self::ensureColumn('module_quota_settings', 'enabled', "TINYINT(1) NOT NULL DEFAULT 1 AFTER metric_key");
|
||||||
|
self::ensureColumn('module_quota_settings', 'soft_limit_percent', "DECIMAL(6,2) NOT NULL DEFAULT 90.00 AFTER enforce_mode");
|
||||||
|
self::$initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||||
|
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||||
|
if ($table === '' || $column === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||||||
|
if ($result && $result->num_rows > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,997 @@
|
|||||||
|
<?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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use motorapi\actions\license_plate_lookup_a;
|
|||||||
use motorapi\helpers\motorapi_vehicle_types;
|
use motorapi\helpers\motorapi_vehicle_types;
|
||||||
use motorapi\motorapi_c;
|
use motorapi\motorapi_c;
|
||||||
use objects\motorapi_lookups_o;
|
use objects\motorapi_lookups_o;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class motorapi implements motorapi_i
|
class motorapi implements motorapi_i
|
||||||
{
|
{
|
||||||
@@ -182,10 +183,10 @@ class motorapi implements motorapi_i
|
|||||||
self::requireModuleEnabled();
|
self::requireModuleEnabled();
|
||||||
// Validate the license plate
|
// Validate the license plate
|
||||||
self::requireValidLicensePlate($licensePlate);
|
self::requireValidLicensePlate($licensePlate);
|
||||||
// Validate the daily limit
|
|
||||||
self::requireDailyLimitNotExceeded();
|
|
||||||
// Validate the secret key
|
// Validate the secret key
|
||||||
self::requireValidSecretKey();
|
self::requireValidSecretKey();
|
||||||
|
// Reserve quota for the outbound provider call. Cache hits return before this point.
|
||||||
|
$this->reserveLookupQuota($licensePlate, $endpoint, $method);
|
||||||
// Send the request
|
// Send the request
|
||||||
$response = match ($method) {
|
$response = match ($method) {
|
||||||
'GET' => self::sendGetRequest($licensePlate, $endpoint, $data),
|
'GET' => self::sendGetRequest($licensePlate, $endpoint, $data),
|
||||||
@@ -217,7 +218,7 @@ class motorapi implements motorapi_i
|
|||||||
function requireDailyLimitNotExceeded(): void
|
function requireDailyLimitNotExceeded(): void
|
||||||
{
|
{
|
||||||
// Check if the daily limit is exceeded
|
// Check if the daily limit is exceeded
|
||||||
if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) {
|
if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) {
|
||||||
throw new Exception('Daily limit exceeded');
|
throw new Exception('Daily limit exceeded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,12 +228,29 @@ class motorapi implements motorapi_i
|
|||||||
*/
|
*/
|
||||||
function getDailyRequestCounter(): int
|
function getDailyRequestCounter(): int
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
|
return (new module_usage_service())->currentUsedQuantity('motorapi', 'lookup_calls');
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
|
||||||
// Count the rows from the motorapi request log that was made today
|
// Count the rows from the motorapi request log that was made today
|
||||||
$motorapi_lookups = new motorapi_lookups_o();
|
$motorapi_lookups = new motorapi_lookups_o();
|
||||||
$motorapi_lookups->getTodayCount();
|
$motorapi_lookups->getTodayCount();
|
||||||
return $motorapi_lookups->getTodayCount();
|
return $motorapi_lookups->getTodayCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function reserveLookupQuota(string $licensePlate, string $endpoint, string $method): void
|
||||||
|
{
|
||||||
|
(new module_usage_service())->reserveOrFail('motorapi', 'lookup_calls', 1, [
|
||||||
|
'license_plate' => $licensePlate,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'method' => strtoupper($method),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritDoc
|
* @inheritDoc
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ class release_manager
|
|||||||
private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api';
|
private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api';
|
||||||
private const CRON_WORKER_APP = 'cron';
|
private const CRON_WORKER_APP = 'cron';
|
||||||
private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker';
|
private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker';
|
||||||
|
private const CRON_WORKER_DESIRED_COUNT = 1;
|
||||||
|
private const CRON_WORKER_HEARTBEAT_GRACE_SECONDS = 180;
|
||||||
private const RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES = ['truckwash.io'];
|
private const RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES = ['truckwash.io'];
|
||||||
private const RELEASE_GATE_MAX_PATHS = 10;
|
private const RELEASE_GATE_MAX_PATHS = 10;
|
||||||
private const RELEASE_GATE_MAX_ASSETS = 50;
|
private const RELEASE_GATE_MAX_ASSETS = 50;
|
||||||
@@ -4413,16 +4415,65 @@ class release_manager
|
|||||||
{
|
{
|
||||||
$this->ensureSchema();
|
$this->ensureSchema();
|
||||||
$channel = $this->channelFromInputOrDefault($input);
|
$channel = $this->channelFromInputOrDefault($input);
|
||||||
$target = $this->cronWorkerTargetForChannel((int)$channel['id']);
|
$channelId = (int)$channel['id'];
|
||||||
|
$apiTarget = $this->deploymentTargetFromInput(['channel_id' => $channelId], $channelId, 'api');
|
||||||
|
$target = $this->cronWorkerTargetForChannel($channelId);
|
||||||
|
$publicTarget = $target !== null ? $this->publicDeploymentTarget($target) : null;
|
||||||
|
$publicApiTarget = $apiTarget !== null ? $this->publicDeploymentTarget($apiTarget) : null;
|
||||||
|
$workerRows = (new cron_worker())->listWorkers();
|
||||||
|
$workers = $this->cronWorkersForTarget(
|
||||||
|
is_array($workerRows['workers'] ?? null) ? $workerRows['workers'] : [],
|
||||||
|
$channelId,
|
||||||
|
$target
|
||||||
|
);
|
||||||
|
$summary = $this->cronWorkerSummary($workers);
|
||||||
|
$recentDeployments = $this->cronWorkerDeployments($channelId);
|
||||||
|
$latestDeployment = $recentDeployments[0] ?? null;
|
||||||
|
$providerStatus = $this->cronWorkerProviderStatus($target, $input);
|
||||||
|
$health = $this->cronWorkerHealth($apiTarget, $target, $workers, $summary, $latestDeployment);
|
||||||
|
$readiness = $this->cronWorkerDeploymentReadiness($apiTarget, $target, $providerStatus);
|
||||||
|
$issues = $this->cronWorkerMergeIssues(
|
||||||
|
is_array($health['issues'] ?? null) ? $health['issues'] : [],
|
||||||
|
is_array($readiness['issues'] ?? null) ? $readiness['issues'] : []
|
||||||
|
);
|
||||||
|
$channelPayload = [
|
||||||
|
'id' => $channelId,
|
||||||
|
'slug' => (string)($channel['slug'] ?? ''),
|
||||||
|
'name' => (string)($channel['name'] ?? ''),
|
||||||
|
];
|
||||||
|
$deploymentPayload = [
|
||||||
|
'ok' => true,
|
||||||
|
'state' => $health['state'],
|
||||||
|
'desired_workers' => self::CRON_WORKER_DESIRED_COUNT,
|
||||||
|
'channel' => $channelPayload,
|
||||||
|
'api_target' => $publicApiTarget,
|
||||||
|
'target' => $publicTarget,
|
||||||
|
'latest_deployment' => $latestDeployment,
|
||||||
|
'provider' => $providerStatus,
|
||||||
|
'action' => $readiness['action'],
|
||||||
|
'can_deploy' => $readiness['can_deploy'],
|
||||||
|
'issues' => $issues,
|
||||||
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'channel' => [
|
'state' => $health['state'],
|
||||||
'id' => (int)$channel['id'],
|
'desired_workers' => self::CRON_WORKER_DESIRED_COUNT,
|
||||||
'slug' => (string)($channel['slug'] ?? ''),
|
'channel' => $channelPayload,
|
||||||
'name' => (string)($channel['name'] ?? ''),
|
'channels' => $this->cronWorkerChannels(),
|
||||||
|
'api_target' => $publicApiTarget,
|
||||||
|
'cron_target' => $publicTarget,
|
||||||
|
'target' => $publicTarget,
|
||||||
|
'workers' => $workers,
|
||||||
|
'summary' => $summary + [
|
||||||
|
'desired' => self::CRON_WORKER_DESIRED_COUNT,
|
||||||
|
'state' => $health['state'],
|
||||||
],
|
],
|
||||||
'target' => $target !== null ? $this->publicDeploymentTarget($target) : null,
|
'latest_deployment' => $latestDeployment,
|
||||||
|
'recent_deployments' => $recentDeployments,
|
||||||
|
'provider' => $providerStatus,
|
||||||
|
'issues' => $issues,
|
||||||
|
'deployment' => $deploymentPayload,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4431,6 +4482,8 @@ class release_manager
|
|||||||
$this->ensureSchema();
|
$this->ensureSchema();
|
||||||
$dryRun = $this->toBool($input['dry_run'] ?? false);
|
$dryRun = $this->toBool($input['dry_run'] ?? false);
|
||||||
$apiTarget = null;
|
$apiTarget = null;
|
||||||
|
$channel = null;
|
||||||
|
$existingCronTarget = null;
|
||||||
|
|
||||||
$targetId = $this->nullablePositiveInt($input['api_target_id'] ?? $input['target_id'] ?? null);
|
$targetId = $this->nullablePositiveInt($input['api_target_id'] ?? $input['target_id'] ?? null);
|
||||||
if ($targetId !== null) {
|
if ($targetId !== null) {
|
||||||
@@ -4440,7 +4493,12 @@ class release_manager
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$channel = $this->channelFromInputOrDefault($input);
|
$channel = $this->channelFromInputOrDefault($input);
|
||||||
$apiTarget = $this->deploymentTargetFromInput(['channel_id' => (int)$channel['id']], (int)$channel['id'], 'api');
|
$channelId = (int)$channel['id'];
|
||||||
|
$existingCronTarget = $this->cronWorkerTargetForChannel($channelId);
|
||||||
|
$apiTarget = $this->deploymentTargetFromInput(['channel_id' => $channelId], $channelId, 'api');
|
||||||
|
if ($apiTarget === null && $this->cronWorkerTargetCanDeployWithoutApiTarget($existingCronTarget)) {
|
||||||
|
$apiTarget = $this->cronWorkerSourceFromCronTarget($existingCronTarget, $channel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($apiTarget === null) {
|
if ($apiTarget === null) {
|
||||||
@@ -4451,6 +4509,16 @@ class release_manager
|
|||||||
return $this->deployCronWorkerForApiTarget($apiTarget, $commitSha !== '' ? $commitSha : null, $actorUserId, $dryRun);
|
return $this->deployCronWorkerForApiTarget($apiTarget, $commitSha !== '' ? $commitSha : null, $actorUserId, $dryRun);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function cronWorkerSourceFromCronTarget(array $target, ?array $channel = null): array
|
||||||
|
{
|
||||||
|
return array_replace($target, [
|
||||||
|
'app' => self::CRON_WORKER_APP,
|
||||||
|
'channel_id' => (int)($target['channel_id'] ?? $channel['id'] ?? 0),
|
||||||
|
'channel_slug' => (string)($target['channel_slug'] ?? $channel['slug'] ?? ''),
|
||||||
|
'channel_name' => (string)($target['channel_name'] ?? $channel['name'] ?? ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
private function deployCronWorkerForApiTarget(
|
private function deployCronWorkerForApiTarget(
|
||||||
array $apiTarget,
|
array $apiTarget,
|
||||||
?string $commitSha = null,
|
?string $commitSha = null,
|
||||||
@@ -4479,19 +4547,41 @@ class release_manager
|
|||||||
$repository = trim((string)($apiTarget['repository'] ?? ''));
|
$repository = trim((string)($apiTarget['repository'] ?? ''));
|
||||||
}
|
}
|
||||||
$branch = trim((string)($apiTarget['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH;
|
$branch = trim((string)($apiTarget['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH;
|
||||||
$serviceUuid = trim((string)($existing['coolify_service_uuid'] ?? '')) ?: null;
|
$serviceUuid = trim((string)($existing['coolify_service_uuid'] ?? ''));
|
||||||
|
$providerStatus = $existing !== null
|
||||||
|
? $this->cronWorkerProviderStatus($existing, ['include_provider' => true])
|
||||||
|
: ['configured' => false, 'missing' => false];
|
||||||
|
$repairMissingResource = $existing !== null
|
||||||
|
&& $serviceUuid !== ''
|
||||||
|
&& $this->toBool($providerStatus['missing'] ?? false);
|
||||||
|
$sourceApp = (string)($apiTarget['app'] ?? 'api');
|
||||||
|
$sourceTargetId = (int)($apiTarget['id'] ?? 0);
|
||||||
|
if ($repairMissingResource) {
|
||||||
|
$context['cron_worker_orphaned_coolify_service_uuid'] = $serviceUuid;
|
||||||
|
$context['cron_worker_orphaned_at'] = date('Y-m-d H:i:s');
|
||||||
|
$context['cron_worker_repair_reason'] = 'coolify_resource_missing';
|
||||||
|
$serviceUuid = '';
|
||||||
|
}
|
||||||
|
$planAction = $targetId > 0
|
||||||
|
? ($repairMissingResource ? 'repair' : ($serviceUuid === '' ? 'create' : 'update'))
|
||||||
|
: 'create';
|
||||||
$plan = [
|
$plan = [
|
||||||
'type' => 'deploy_cron_worker',
|
'type' => 'deploy_cron_worker',
|
||||||
'channel_id' => $channelId,
|
'channel_id' => $channelId,
|
||||||
'channel_slug' => (string)($channel['slug'] ?? ''),
|
'channel_slug' => (string)($channel['slug'] ?? ''),
|
||||||
'api_target_id' => (int)($apiTarget['id'] ?? 0),
|
'api_target_id' => $sourceApp === 'api' && $sourceTargetId > 0 ? $sourceTargetId : null,
|
||||||
|
'source_target_id' => $sourceTargetId > 0 ? $sourceTargetId : null,
|
||||||
|
'source_app' => $sourceApp,
|
||||||
'target_id' => $targetId > 0 ? $targetId : null,
|
'target_id' => $targetId > 0 ? $targetId : null,
|
||||||
'action' => $targetId > 0 ? 'update' : 'create',
|
'action' => $planAction,
|
||||||
'repository' => $repository,
|
'repository' => $repository,
|
||||||
'branch' => $branch,
|
'branch' => $branch,
|
||||||
'commit_sha' => $commitSha,
|
'commit_sha' => $commitSha,
|
||||||
'coolify_instance_id' => (int)$apiTarget['coolify_instance_id'],
|
'coolify_instance_id' => (int)$apiTarget['coolify_instance_id'],
|
||||||
'coolify_service_uuid' => $serviceUuid,
|
'coolify_service_uuid' => $serviceUuid !== '' ? $serviceUuid : null,
|
||||||
|
'orphaned_coolify_service_uuid' => $repairMissingResource
|
||||||
|
? (string)($existing['coolify_service_uuid'] ?? '')
|
||||||
|
: null,
|
||||||
'start_command' => self::CRON_WORKER_START_COMMAND,
|
'start_command' => self::CRON_WORKER_START_COMMAND,
|
||||||
'dry_run' => $dryRun,
|
'dry_run' => $dryRun,
|
||||||
];
|
];
|
||||||
@@ -4503,25 +4593,38 @@ class release_manager
|
|||||||
'mutated' => false,
|
'mutated' => false,
|
||||||
'planned' => [$plan],
|
'planned' => [$plan],
|
||||||
'target' => $existing !== null ? $this->publicDeploymentTarget($existing) : null,
|
'target' => $existing !== null ? $this->publicDeploymentTarget($existing) : null,
|
||||||
|
'worker_status' => $this->cronWorkerStatus(['channel_id' => $channelId]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cronDeploymentId = $this->createCronWorkerDeploymentRecord(
|
||||||
|
$channelId,
|
||||||
|
$targetId > 0 ? $targetId : null,
|
||||||
|
$repository,
|
||||||
|
$branch,
|
||||||
|
$commitSha,
|
||||||
|
$actorUserId,
|
||||||
|
['plan' => $plan, 'parent_deployment_id' => $parentDeploymentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
if ($targetId > 0) {
|
if ($targetId > 0) {
|
||||||
$this->execute(
|
$this->execute(
|
||||||
"UPDATE release_deployment_targets
|
"UPDATE release_deployment_targets
|
||||||
SET coolify_instance_id = ?, repository = ?, branch = ?, auto_deploy = 0,
|
SET coolify_instance_id = ?, coolify_service_uuid = ?, repository = ?, branch = ?, auto_deploy = 0,
|
||||||
health_url = NULL, deploy_context_json = ?
|
health_url = NULL, deploy_context_json = ?
|
||||||
WHERE id = ? AND deleted_at IS NULL",
|
WHERE id = ? AND deleted_at IS NULL",
|
||||||
'isssi',
|
'issssi',
|
||||||
[
|
[
|
||||||
(int)$apiTarget['coolify_instance_id'],
|
(int)$apiTarget['coolify_instance_id'],
|
||||||
|
$serviceUuid !== '' ? $serviceUuid : null,
|
||||||
$repository,
|
$repository,
|
||||||
$branch,
|
$branch,
|
||||||
self::jsonEncode($context),
|
self::jsonEncode($context),
|
||||||
$targetId,
|
$targetId,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
$action = 'cron_worker_target_updated';
|
$action = $repairMissingResource ? 'cron_worker_target_repaired' : 'cron_worker_target_updated';
|
||||||
} else {
|
} else {
|
||||||
$this->execute(
|
$this->execute(
|
||||||
"INSERT INTO release_deployment_targets (
|
"INSERT INTO release_deployment_targets (
|
||||||
@@ -4533,7 +4636,7 @@ class release_manager
|
|||||||
$channelId,
|
$channelId,
|
||||||
self::CRON_WORKER_APP,
|
self::CRON_WORKER_APP,
|
||||||
(int)$apiTarget['coolify_instance_id'],
|
(int)$apiTarget['coolify_instance_id'],
|
||||||
$serviceUuid,
|
$serviceUuid !== '' ? $serviceUuid : null,
|
||||||
$repository,
|
$repository,
|
||||||
$branch,
|
$branch,
|
||||||
self::jsonEncode($context),
|
self::jsonEncode($context),
|
||||||
@@ -4546,6 +4649,7 @@ class release_manager
|
|||||||
'si',
|
'si',
|
||||||
[self::jsonEncode($context), $targetId]
|
[self::jsonEncode($context), $targetId]
|
||||||
);
|
);
|
||||||
|
$this->execute('UPDATE release_deployments SET target_id = ? WHERE id = ?', 'ii', [$targetId, $cronDeploymentId]);
|
||||||
$action = 'cron_worker_target_created';
|
$action = 'cron_worker_target_created';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4556,11 +4660,20 @@ class release_manager
|
|||||||
'commit_sha' => $commitSha ?? '',
|
'commit_sha' => $commitSha ?? '',
|
||||||
]);
|
]);
|
||||||
$deployment = $this->deployCoolifyReleaseTarget($deployTarget);
|
$deployment = $this->deployCoolifyReleaseTarget($deployTarget);
|
||||||
|
$providerOperationId = $this->coolifyDeploymentOperationId($deployment);
|
||||||
|
$this->execute(
|
||||||
|
"UPDATE release_deployments
|
||||||
|
SET status = 'deployed', provider_operation_id = ?, result_json = ?, completed_at = NOW()
|
||||||
|
WHERE id = ?",
|
||||||
|
'ssi',
|
||||||
|
[$providerOperationId, self::jsonEncode(self::redactPayload($deployment)), $cronDeploymentId]
|
||||||
|
);
|
||||||
|
|
||||||
$this->audit($channelId, $parentDeploymentId, $action, $actorUserId, 'info', [
|
$this->audit($channelId, $cronDeploymentId, $action, $actorUserId, 'info', [
|
||||||
'api_target_id' => (int)($apiTarget['id'] ?? 0),
|
'api_target_id' => (int)($apiTarget['id'] ?? 0),
|
||||||
'cron_target_id' => $targetId,
|
'cron_target_id' => $targetId,
|
||||||
'commit_sha' => $commitSha,
|
'commit_sha' => $commitSha,
|
||||||
|
'parent_deployment_id' => $parentDeploymentId,
|
||||||
'deployment' => $deployment,
|
'deployment' => $deployment,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -4571,11 +4684,46 @@ class release_manager
|
|||||||
'planned' => [$plan],
|
'planned' => [$plan],
|
||||||
'applied' => [[
|
'applied' => [[
|
||||||
'target_id' => $targetId,
|
'target_id' => $targetId,
|
||||||
|
'deployment_id' => $cronDeploymentId,
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
'deployment' => $deployment,
|
'deployment' => $deployment,
|
||||||
]],
|
]],
|
||||||
|
'deployment' => $this->publicDeployment($this->getDeployment($cronDeploymentId)),
|
||||||
'target' => $this->publicDeploymentTarget($this->getDeploymentTarget($targetId)),
|
'target' => $this->publicDeploymentTarget($this->getDeploymentTarget($targetId)),
|
||||||
|
'worker_status' => $this->cronWorkerStatus(['channel_id' => $channelId]),
|
||||||
];
|
];
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$this->execute(
|
||||||
|
"UPDATE release_deployments
|
||||||
|
SET status = 'failed', result_json = ?, error_message = ?, completed_at = NOW()
|
||||||
|
WHERE id = ?",
|
||||||
|
'ssi',
|
||||||
|
[
|
||||||
|
self::jsonEncode([
|
||||||
|
'message' => 'Cron worker deployment failed before a worker heartbeat was observed.',
|
||||||
|
'failure_summary' => self::deploymentFailureSummary($throwable, [
|
||||||
|
'app' => self::CRON_WORKER_APP,
|
||||||
|
'repository' => $repository,
|
||||||
|
'branch' => $branch,
|
||||||
|
'commit_sha' => $commitSha,
|
||||||
|
'target_id' => $targetId > 0 ? $targetId : null,
|
||||||
|
'coolify_instance_id' => (int)($apiTarget['coolify_instance_id'] ?? 0),
|
||||||
|
'coolify_service_uuid' => $serviceUuid,
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
$throwable->getMessage(),
|
||||||
|
$cronDeploymentId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$this->audit($channelId, $cronDeploymentId, 'cron_worker_deploy_failed', $actorUserId, 'warning', [
|
||||||
|
'api_target_id' => (int)($apiTarget['id'] ?? 0),
|
||||||
|
'cron_target_id' => $targetId > 0 ? $targetId : null,
|
||||||
|
'commit_sha' => $commitSha,
|
||||||
|
'parent_deployment_id' => $parentDeploymentId,
|
||||||
|
'error' => $throwable->getMessage(),
|
||||||
|
]);
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function cronWorkerTargetForChannel(int $channelId): ?array
|
private function cronWorkerTargetForChannel(int $channelId): ?array
|
||||||
@@ -4593,6 +4741,430 @@ class release_manager
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function createCronWorkerDeploymentRecord(
|
||||||
|
int $channelId,
|
||||||
|
?int $targetId,
|
||||||
|
string $repository,
|
||||||
|
string $branch,
|
||||||
|
?string $commitSha,
|
||||||
|
?int $actorUserId,
|
||||||
|
array $requestedPayload
|
||||||
|
): int {
|
||||||
|
$this->execute(
|
||||||
|
"INSERT INTO release_deployments (
|
||||||
|
channel_id, target_id, deployment_kind, app, provider, repository, branch,
|
||||||
|
commit_sha, status, actor_user_id, requested_payload_json, started_at
|
||||||
|
) VALUES (?, ?, 'cron_worker', ?, 'coolify', ?, ?, ?, 'deploying', ?, ?, NOW())",
|
||||||
|
'iissssis',
|
||||||
|
[
|
||||||
|
$channelId,
|
||||||
|
$targetId,
|
||||||
|
self::CRON_WORKER_APP,
|
||||||
|
$repository,
|
||||||
|
$branch,
|
||||||
|
$commitSha,
|
||||||
|
$actorUserId,
|
||||||
|
self::jsonEncode(self::redactPayload($requestedPayload)),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->insertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cronWorkerDeployments(int $channelId, int $limit = 5): array
|
||||||
|
{
|
||||||
|
$limit = max(1, min(25, $limit));
|
||||||
|
return array_map(
|
||||||
|
fn(array $deployment): array => $this->publicDeployment($deployment),
|
||||||
|
$this->selectRows(
|
||||||
|
"SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url
|
||||||
|
FROM release_deployments d
|
||||||
|
INNER JOIN release_channels c ON c.id = d.channel_id
|
||||||
|
LEFT JOIN release_versions v ON v.id = d.version_id
|
||||||
|
WHERE d.channel_id = ? AND d.app = ? AND d.deployment_kind = 'cron_worker'
|
||||||
|
ORDER BY d.id DESC
|
||||||
|
LIMIT $limit",
|
||||||
|
'is',
|
||||||
|
[$channelId, self::CRON_WORKER_APP]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cronWorkerChannels(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn(array $channel): array => [
|
||||||
|
'id' => (int)($channel['id'] ?? 0),
|
||||||
|
'slug' => (string)($channel['slug'] ?? ''),
|
||||||
|
'name' => (string)($channel['name'] ?? ''),
|
||||||
|
'default_channel' => (bool)((int)($channel['default_channel'] ?? 0)),
|
||||||
|
],
|
||||||
|
$this->selectRows(
|
||||||
|
"SELECT id, slug, name, default_channel
|
||||||
|
FROM release_channels
|
||||||
|
WHERE deleted_at IS NULL AND enabled = 1
|
||||||
|
ORDER BY default_channel DESC, slug"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cronWorkersForTarget(array $workers, int $channelId, ?array $target): array
|
||||||
|
{
|
||||||
|
$targetId = $target !== null ? (int)($target['id'] ?? 0) : 0;
|
||||||
|
$resourceUuid = $target !== null ? trim((string)($target['coolify_service_uuid'] ?? '')) : '';
|
||||||
|
$matched = [];
|
||||||
|
foreach ($workers as $worker) {
|
||||||
|
if (!is_array($worker)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workerChannelId = (int)($worker['release_channel_id'] ?? 0);
|
||||||
|
$workerTargetId = (int)($worker['release_target_id'] ?? 0);
|
||||||
|
$workerResourceUuid = trim((string)($worker['coolify_resource_uuid'] ?? ''));
|
||||||
|
if (
|
||||||
|
($workerChannelId > 0 && $workerChannelId === $channelId)
|
||||||
|
|| ($targetId > 0 && $workerTargetId === $targetId)
|
||||||
|
|| ($resourceUuid !== '' && $workerResourceUuid === $resourceUuid)
|
||||||
|
) {
|
||||||
|
$matched[] = $worker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cronWorkerSummary(array $workers): array
|
||||||
|
{
|
||||||
|
$running = 0;
|
||||||
|
$stale = 0;
|
||||||
|
$failed = 0;
|
||||||
|
foreach ($workers as $worker) {
|
||||||
|
$status = (string)($worker['status'] ?? '');
|
||||||
|
$isStale = (bool)($worker['stale'] ?? false);
|
||||||
|
if ($status === 'running' && !$isStale) {
|
||||||
|
$running++;
|
||||||
|
}
|
||||||
|
if ($isStale) {
|
||||||
|
$stale++;
|
||||||
|
}
|
||||||
|
if ($status === 'failed') {
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => count($workers),
|
||||||
|
'running' => $running,
|
||||||
|
'stale' => $stale,
|
||||||
|
'failed' => $failed,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cronWorkerHealth(
|
||||||
|
?array $apiTarget,
|
||||||
|
?array $target,
|
||||||
|
array $workers,
|
||||||
|
array $summary,
|
||||||
|
?array $latestDeployment
|
||||||
|
): array {
|
||||||
|
$issues = [];
|
||||||
|
if ($apiTarget === null) {
|
||||||
|
$issues[] = $this->cronWorkerIssue('missing_api_target', 'danger', 'No API deployment target is configured for this release channel.');
|
||||||
|
return ['state' => 'needs_deploy', 'issues' => $issues];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($target === null) {
|
||||||
|
$issues[] = $this->cronWorkerIssue('missing_cron_target', 'warning', 'No cron worker Coolify target exists for this release channel.');
|
||||||
|
return ['state' => 'needs_deploy', 'issues' => $issues];
|
||||||
|
}
|
||||||
|
|
||||||
|
$deploymentStatus = (string)($latestDeployment['status'] ?? '');
|
||||||
|
if ($deploymentStatus === 'failed') {
|
||||||
|
$issues[] = $this->cronWorkerIssue(
|
||||||
|
'latest_deployment_failed',
|
||||||
|
'danger',
|
||||||
|
(string)($latestDeployment['error_message'] ?? 'Latest cron worker deployment failed.')
|
||||||
|
);
|
||||||
|
return ['state' => 'failed', 'issues' => $issues];
|
||||||
|
}
|
||||||
|
if (in_array($deploymentStatus, ['queued', 'deploying'], true)) {
|
||||||
|
$issues[] = $this->cronWorkerIssue('deployment_in_progress', 'info', 'Cron worker deployment is in progress.');
|
||||||
|
return ['state' => 'deploying', 'issues' => $issues];
|
||||||
|
}
|
||||||
|
|
||||||
|
$running = (int)($summary['running'] ?? 0);
|
||||||
|
$stale = (int)($summary['stale'] ?? 0);
|
||||||
|
$failed = (int)($summary['failed'] ?? 0);
|
||||||
|
if ($running >= self::CRON_WORKER_DESIRED_COUNT && $stale === 0 && $failed === 0) {
|
||||||
|
return ['state' => 'healthy', 'issues' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($running > 0) {
|
||||||
|
if ($stale > 0) {
|
||||||
|
$issues[] = $this->cronWorkerIssue('stale_workers_present', 'warning', 'At least one cron worker heartbeat is stale.');
|
||||||
|
}
|
||||||
|
if ($failed > 0) {
|
||||||
|
$issues[] = $this->cronWorkerIssue('failed_workers_present', 'warning', 'At least one cron worker reported a failed loop.');
|
||||||
|
}
|
||||||
|
if ($running < self::CRON_WORKER_DESIRED_COUNT) {
|
||||||
|
$issues[] = $this->cronWorkerIssue('below_desired_worker_count', 'warning', 'Fewer cron workers are running than desired.');
|
||||||
|
}
|
||||||
|
return ['state' => 'degraded', 'issues' => $issues];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($workers === []) {
|
||||||
|
$age = $this->cronWorkerDeploymentAgeSeconds($latestDeployment);
|
||||||
|
if ($deploymentStatus === 'deployed' && ($age === null || $age <= self::CRON_WORKER_HEARTBEAT_GRACE_SECONDS)) {
|
||||||
|
$issues[] = $this->cronWorkerIssue('waiting_for_first_heartbeat', 'info', 'Coolify accepted the deployment; waiting for the worker to write its first heartbeat.');
|
||||||
|
return ['state' => 'waiting_for_heartbeat', 'issues' => $issues];
|
||||||
|
}
|
||||||
|
|
||||||
|
$issues[] = $this->cronWorkerIssue('no_worker_heartbeat', 'danger', 'Cron worker target exists, but no worker heartbeat has been recorded.');
|
||||||
|
return ['state' => $deploymentStatus === 'deployed' ? 'failed' : 'degraded', 'issues' => $issues];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stale > 0 || $failed > 0) {
|
||||||
|
$issues[] = $this->cronWorkerIssue('no_fresh_running_worker', 'danger', 'Cron workers exist, but none have a fresh running heartbeat.');
|
||||||
|
return ['state' => 'failed', 'issues' => $issues];
|
||||||
|
}
|
||||||
|
|
||||||
|
$issues[] = $this->cronWorkerIssue('worker_not_running', 'warning', 'Cron worker is not currently running.');
|
||||||
|
return ['state' => 'degraded', 'issues' => $issues];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cronWorkerDeploymentReadiness(?array $apiTarget, ?array $target, array $providerStatus): array
|
||||||
|
{
|
||||||
|
$issues = [];
|
||||||
|
if ($target === null) {
|
||||||
|
if ($apiTarget === null) {
|
||||||
|
$issues[] = $this->cronWorkerIssue(
|
||||||
|
'missing_api_target',
|
||||||
|
'danger',
|
||||||
|
'No API deployment target is configured for this release channel.'
|
||||||
|
);
|
||||||
|
return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['action' => 'create', 'can_deploy' => true, 'issues' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$resourceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
|
||||||
|
$canUseCronTargetContext = $this->cronWorkerTargetCanDeployWithoutApiTarget($target);
|
||||||
|
|
||||||
|
if ($resourceUuid === '') {
|
||||||
|
if ($apiTarget !== null || $canUseCronTargetContext) {
|
||||||
|
if ($apiTarget === null) {
|
||||||
|
$issues[] = $this->cronWorkerIssue(
|
||||||
|
'repairable_cron_target',
|
||||||
|
'info',
|
||||||
|
'No API target is configured, but the cron target has enough deployment context to deploy a worker.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ['action' => 'create', 'can_deploy' => true, 'issues' => $issues];
|
||||||
|
}
|
||||||
|
|
||||||
|
$issues[] = $this->cronWorkerIssue(
|
||||||
|
'missing_api_target',
|
||||||
|
'danger',
|
||||||
|
'No API deployment target is configured for this release channel.'
|
||||||
|
);
|
||||||
|
return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->toBool($providerStatus['missing'] ?? false)) {
|
||||||
|
$issues[] = $this->cronWorkerIssue(
|
||||||
|
'missing_coolify_worker_resource',
|
||||||
|
'danger',
|
||||||
|
'The stored Coolify cron worker resource was not found and must be recreated.'
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($apiTarget !== null || $canUseCronTargetContext) {
|
||||||
|
if ($apiTarget === null) {
|
||||||
|
$issues[] = $this->cronWorkerIssue(
|
||||||
|
'repairable_cron_target',
|
||||||
|
'info',
|
||||||
|
'No API target is configured, but the cron target has enough deployment context to repair itself.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ['action' => 'repair', 'can_deploy' => true, 'issues' => $issues];
|
||||||
|
}
|
||||||
|
|
||||||
|
$issues[] = $this->cronWorkerIssue(
|
||||||
|
'missing_api_target',
|
||||||
|
'danger',
|
||||||
|
'No API deployment target is configured for this release channel.'
|
||||||
|
);
|
||||||
|
return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($apiTarget === null && !$canUseCronTargetContext) {
|
||||||
|
$issues[] = $this->cronWorkerIssue(
|
||||||
|
'missing_api_target',
|
||||||
|
'danger',
|
||||||
|
'No API deployment target is configured for this release channel.'
|
||||||
|
);
|
||||||
|
return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['action' => 'update', 'can_deploy' => true, 'issues' => $issues];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cronWorkerTargetCanDeployWithoutApiTarget(?array $target): bool
|
||||||
|
{
|
||||||
|
if ($target === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$repository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? ''));
|
||||||
|
if ($repository === '') {
|
||||||
|
$repository = trim((string)($target['repository'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->nullablePositiveInt($target['coolify_instance_id'] ?? null) !== null
|
||||||
|
&& $repository !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cronWorkerMergeIssues(array ...$issueGroups): array
|
||||||
|
{
|
||||||
|
$merged = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($issueGroups as $issues) {
|
||||||
|
foreach ($issues as $issue) {
|
||||||
|
if (!is_array($issue)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = trim((string)($issue['code'] ?? ''));
|
||||||
|
if ($key === '') {
|
||||||
|
$key = trim((string)($issue['message'] ?? ''));
|
||||||
|
}
|
||||||
|
if ($key !== '' && isset($seen[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($key !== '') {
|
||||||
|
$seen[$key] = true;
|
||||||
|
}
|
||||||
|
$merged[] = $issue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cronWorkerIssue(string $code, string $severity, string $message): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'code' => $code,
|
||||||
|
'severity' => $severity,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cronWorkerDeploymentAgeSeconds(?array $deployment): ?int
|
||||||
|
{
|
||||||
|
if ($deployment === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['completed_at', 'started_at', 'created_at'] as $key) {
|
||||||
|
$value = trim((string)($deployment[$key] ?? ''));
|
||||||
|
if ($value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$timestamp = strtotime($value);
|
||||||
|
if ($timestamp !== false) {
|
||||||
|
return max(0, time() - $timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cronWorkerProviderStatus(?array $target, array $input): array
|
||||||
|
{
|
||||||
|
if ($target === null) {
|
||||||
|
return [
|
||||||
|
'configured' => false,
|
||||||
|
'resource' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = self::jsonDecode($target['deploy_context_json'] ?? null);
|
||||||
|
$resourceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
|
||||||
|
$resourceType = trim((string)($context['coolify_resource_type'] ?? 'application')) ?: 'application';
|
||||||
|
$status = [
|
||||||
|
'configured' => $resourceUuid !== '',
|
||||||
|
'instance_id' => isset($target['coolify_instance_id']) ? (int)$target['coolify_instance_id'] : null,
|
||||||
|
'instance_label' => $target['coolify_instance_label'] ?? null,
|
||||||
|
'resource_uuid' => $resourceUuid !== '' ? $resourceUuid : null,
|
||||||
|
'resource_type' => $resourceType,
|
||||||
|
'resource' => null,
|
||||||
|
'checked' => false,
|
||||||
|
'missing' => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$this->toBool($input['include_provider'] ?? $input['include_provider_status'] ?? false) || $resourceUuid === '') {
|
||||||
|
return $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$status['checked'] = true;
|
||||||
|
$instance = $this->selectOne(
|
||||||
|
'SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL',
|
||||||
|
'i',
|
||||||
|
[(int)($target['coolify_instance_id'] ?? 0)]
|
||||||
|
);
|
||||||
|
if ($instance === null) {
|
||||||
|
throw new RuntimeException('Coolify instance for cron worker target was not found.');
|
||||||
|
}
|
||||||
|
$client = $this->coolifyClientForInstance($instance, 3);
|
||||||
|
$resource = $resourceType === 'service'
|
||||||
|
? $client->getService($resourceUuid)
|
||||||
|
: $client->getApplication($resourceUuid);
|
||||||
|
$status['resource'] = [
|
||||||
|
'ok' => true,
|
||||||
|
'uuid' => $resource['uuid'] ?? $resourceUuid,
|
||||||
|
'name' => $resource['name'] ?? null,
|
||||||
|
'status' => $resource['status'] ?? $resource['state'] ?? null,
|
||||||
|
'fqdn' => $resource['fqdn'] ?? $resource['domains'] ?? null,
|
||||||
|
];
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$status['checked'] = true;
|
||||||
|
$status['missing'] = self::coolifyResourceMissing($throwable);
|
||||||
|
$status['resource'] = [
|
||||||
|
'ok' => false,
|
||||||
|
'error' => $throwable->getMessage(),
|
||||||
|
'missing' => $status['missing'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function coolifyResourceMissing(Throwable $throwable): bool
|
||||||
|
{
|
||||||
|
$message = strtolower($throwable->getMessage());
|
||||||
|
return str_contains($message, 'http 404')
|
||||||
|
|| str_contains($message, 'not found')
|
||||||
|
|| preg_match('/\b404\b/', $message) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function coolifyDeploymentOperationId(array $deployment): ?string
|
||||||
|
{
|
||||||
|
foreach ([$deployment, $deployment['deployment'] ?? null, $deployment['data'] ?? null] as $candidate) {
|
||||||
|
if (!is_array($candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (['deployment_uuid', 'operation_id', 'deployment_id', 'uuid', 'id'] as $key) {
|
||||||
|
$value = trim((string)($candidate[$key] ?? ''));
|
||||||
|
if ($value !== '') {
|
||||||
|
return substr($value, 0, 128);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private function cronWorkerDeployContext(array $apiTarget, ?array $existing, ?string $commitSha, int $targetId): array
|
private function cronWorkerDeployContext(array $apiTarget, ?array $existing, ?string $commitSha, int $targetId): array
|
||||||
{
|
{
|
||||||
$apiContext = self::jsonDecode($apiTarget['deploy_context_json'] ?? null);
|
$apiContext = self::jsonDecode($apiTarget['deploy_context_json'] ?? null);
|
||||||
@@ -4632,7 +5204,13 @@ class release_manager
|
|||||||
$context['coolify_is_auto_deploy_enabled'] = false;
|
$context['coolify_is_auto_deploy_enabled'] = false;
|
||||||
$context['coolify_service_name'] = $workerName;
|
$context['coolify_service_name'] = $workerName;
|
||||||
$context['cron_worker_autoprovision'] = true;
|
$context['cron_worker_autoprovision'] = true;
|
||||||
|
if ((string)($apiTarget['app'] ?? 'api') === 'api') {
|
||||||
$context['cron_worker_source_api_target_id'] = (int)($apiTarget['id'] ?? 0);
|
$context['cron_worker_source_api_target_id'] = (int)($apiTarget['id'] ?? 0);
|
||||||
|
unset($context['cron_worker_source_cron_target_id']);
|
||||||
|
} else {
|
||||||
|
$context['cron_worker_source_cron_target_id'] = (int)($apiTarget['id'] ?? 0);
|
||||||
|
unset($context['cron_worker_source_api_target_id']);
|
||||||
|
}
|
||||||
unset(
|
unset(
|
||||||
$context['coolify_domain'],
|
$context['coolify_domain'],
|
||||||
$context['coolify_public_url'],
|
$context['coolify_public_url'],
|
||||||
@@ -7415,6 +7993,7 @@ class release_manager
|
|||||||
throw new RuntimeException('Coolify did not return a resource UUID for the release target.');
|
throw new RuntimeException('Coolify did not return a resource UUID for the release target.');
|
||||||
}
|
}
|
||||||
$context['coolify_resource_type'] = $resourceType;
|
$context['coolify_resource_type'] = $resourceType;
|
||||||
|
$target['coolify_service_uuid'] = $serviceUuid;
|
||||||
$this->execute(
|
$this->execute(
|
||||||
'UPDATE release_deployment_targets SET coolify_service_uuid = ?, deploy_context_json = ? WHERE id = ?',
|
'UPDATE release_deployment_targets SET coolify_service_uuid = ?, deploy_context_json = ? WHERE id = ?',
|
||||||
'ssi',
|
'ssi',
|
||||||
@@ -7636,6 +8215,12 @@ class release_manager
|
|||||||
$env = array_replace($env, $contextEnv);
|
$env = array_replace($env, $contextEnv);
|
||||||
$env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true';
|
$env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true';
|
||||||
$env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? ''));
|
$env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? ''));
|
||||||
|
if ($app === self::CRON_WORKER_APP) {
|
||||||
|
$resourceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
|
||||||
|
if ($resourceUuid !== '') {
|
||||||
|
$env['CRON_WORKER_COOLIFY_RESOURCE_UUID'] = $resourceUuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
|
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
|
||||||
return $this->normalizeCoolifyRuntimeEnv($env);
|
return $this->normalizeCoolifyRuntimeEnv($env);
|
||||||
}
|
}
|
||||||
@@ -11577,6 +12162,7 @@ class release_manager
|
|||||||
'active_channel_app_key' => $deployment['active_channel_app_key'] ?? null,
|
'active_channel_app_key' => $deployment['active_channel_app_key'] ?? null,
|
||||||
'active_current' => trim((string)($deployment['active_channel_app_key'] ?? '')) !== '',
|
'active_current' => trim((string)($deployment['active_channel_app_key'] ?? '')) !== '',
|
||||||
'provider' => (string)($deployment['provider'] ?? 'coolify'),
|
'provider' => (string)($deployment['provider'] ?? 'coolify'),
|
||||||
|
'provider_operation_id' => $deployment['provider_operation_id'] ?? null,
|
||||||
'repository' => $deployment['repository'] ?? null,
|
'repository' => $deployment['repository'] ?? null,
|
||||||
'branch' => $deployment['branch'] ?? null,
|
'branch' => $deployment['branch'] ?? null,
|
||||||
'commit_sha' => $deployment['commit_sha'] ?? null,
|
'commit_sha' => $deployment['commit_sha'] ?? null,
|
||||||
|
|||||||
@@ -537,6 +537,7 @@ class superuser_system_status_service
|
|||||||
$result['configured'] = false;
|
$result['configured'] = false;
|
||||||
$result['status'] = 'disabled';
|
$result['status'] = 'disabled';
|
||||||
$result = array_merge($result, $this->moduleReason('module_disabled', [], 'Module is disabled.'));
|
$result = array_merge($result, $this->moduleReason('module_disabled', [], 'Module is disabled.'));
|
||||||
|
$result = $this->attachModuleUsageMetrics($result);
|
||||||
$results[] = $result;
|
$results[] = $result;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -548,6 +549,7 @@ class superuser_system_status_service
|
|||||||
$result['status_reason_params'] = isset($configuration['reason_params']) && is_array($configuration['reason_params'])
|
$result['status_reason_params'] = isset($configuration['reason_params']) && is_array($configuration['reason_params'])
|
||||||
? $configuration['reason_params']
|
? $configuration['reason_params']
|
||||||
: ['variables' => implode(', ', $missingRequired), 'variables_list' => $missingRequired];
|
: ['variables' => implode(', ', $missingRequired), 'variables_list' => $missingRequired];
|
||||||
|
$result = $this->attachModuleUsageMetrics($result);
|
||||||
$results[] = $result;
|
$results[] = $result;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -563,6 +565,7 @@ class superuser_system_status_service
|
|||||||
'Configuration is present, but no safe read-only probe is available.'
|
'Configuration is present, but no safe read-only probe is available.'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
$result = $this->attachModuleUsageMetrics($result);
|
||||||
$results[] = $result;
|
$results[] = $result;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -578,6 +581,7 @@ class superuser_system_status_service
|
|||||||
if (isset($probeResult['usage']) && is_array($probeResult['usage'])) {
|
if (isset($probeResult['usage']) && is_array($probeResult['usage'])) {
|
||||||
$result['usage'] = $probeResult['usage'];
|
$result['usage'] = $probeResult['usage'];
|
||||||
}
|
}
|
||||||
|
$result = $this->attachModuleUsageMetrics($result, isset($probeResult['usage']) && is_array($probeResult['usage']) ? $probeResult['usage'] : null);
|
||||||
$results[] = $result;
|
$results[] = $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,6 +600,60 @@ class superuser_system_status_service
|
|||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function attachModuleUsageMetrics(array $moduleResult, ?array $probeUsage = null): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$service = new module_usage_service();
|
||||||
|
$metrics = $service->metricsForModule((string)($moduleResult['key'] ?? ''));
|
||||||
|
|
||||||
|
if ($probeUsage !== null) {
|
||||||
|
$probeMetric = $service->recordProviderSnapshotFromLegacyUsage(
|
||||||
|
(string)($moduleResult['key'] ?? ''),
|
||||||
|
$probeUsage,
|
||||||
|
['source' => 'system_status_probe']
|
||||||
|
);
|
||||||
|
if ($probeMetric !== null) {
|
||||||
|
$metrics = $this->replaceModuleUsageMetric($metrics, $probeMetric);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($metrics !== []) {
|
||||||
|
$moduleResult['usage_metrics'] = $metrics;
|
||||||
|
if (!isset($moduleResult['usage'])) {
|
||||||
|
$primaryUsage = $service->primarySystemUsage($metrics);
|
||||||
|
if ($primaryUsage !== null) {
|
||||||
|
$moduleResult['usage'] = $primaryUsage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$moduleResult['usage_metrics_error'] = $throwable->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $moduleResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function replaceModuleUsageMetric(array $metrics, array $replacement): array
|
||||||
|
{
|
||||||
|
$replaced = false;
|
||||||
|
foreach ($metrics as $index => $metric) {
|
||||||
|
if (
|
||||||
|
($metric['module_key'] ?? null) === ($replacement['module_key'] ?? null)
|
||||||
|
&& ($metric['metric_key'] ?? null) === ($replacement['metric_key'] ?? null)
|
||||||
|
) {
|
||||||
|
$metrics[$index] = $replacement;
|
||||||
|
$replaced = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$replaced) {
|
||||||
|
$metrics[] = $replacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $metrics;
|
||||||
|
}
|
||||||
|
|
||||||
protected function resolveModuleProbeResult(array $descriptor, array $moduleConfig, bool $force, array &$warnings): array
|
protected function resolveModuleProbeResult(array $descriptor, array $moduleConfig, bool $force, array &$warnings): array
|
||||||
{
|
{
|
||||||
$cacheKey = self::MODULE_PROBE_CACHE_KEY_PREFIX . $descriptor['key'];
|
$cacheKey = self::MODULE_PROBE_CACHE_KEY_PREFIX . $descriptor['key'];
|
||||||
@@ -956,7 +1014,11 @@ class superuser_system_status_service
|
|||||||
'Authorization: Bearer ' . $apiKey,
|
'Authorization: Bearer ' . $apiKey,
|
||||||
'Accept: application/json',
|
'Accept: application/json',
|
||||||
],
|
],
|
||||||
'MailerSend API'
|
'MailerSend API',
|
||||||
|
null,
|
||||||
|
'GET',
|
||||||
|
null,
|
||||||
|
fn(array $httpResponse, string $label): array => $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, 'email')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1033,7 +1095,11 @@ class superuser_system_status_service
|
|||||||
return $this->performHttpProbe(
|
return $this->performHttpProbe(
|
||||||
$this->buildUrlWithQuery('https://v1.motorapi.dk', '/usage'),
|
$this->buildUrlWithQuery('https://v1.motorapi.dk', '/usage'),
|
||||||
['X-AUTH-TOKEN: ' . $secretKey],
|
['X-AUTH-TOKEN: ' . $secretKey],
|
||||||
'MotorAPI'
|
'MotorAPI',
|
||||||
|
null,
|
||||||
|
'GET',
|
||||||
|
null,
|
||||||
|
fn(array $httpResponse, string $label): array => $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, 'motorapi')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1502,30 +1568,31 @@ class superuser_system_status_service
|
|||||||
|
|
||||||
$decoded = json_decode((string)($httpResponse['body'] ?? ''), true);
|
$decoded = json_decode((string)($httpResponse['body'] ?? ''), true);
|
||||||
if (!is_array($decoded)) {
|
if (!is_array($decoded)) {
|
||||||
return [
|
return $this->providerQuotaUnavailableProbeResult(
|
||||||
'status' => 'degraded',
|
$httpResponse,
|
||||||
'status_reason' => $label . ' returned an unreadable usage payload.',
|
$label,
|
||||||
'status_reason_key' => 'licenseplaterecognizer_usage_unreadable',
|
'licenseplaterecognizer',
|
||||||
'status_reason_params' => ['label' => $label],
|
'unreadable_payload',
|
||||||
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
[]
|
||||||
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
);
|
||||||
'http_status' => $httpResponse['http_status'] ?? null,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$usage = is_array($decoded['usage'] ?? null) ? $decoded['usage'] : [];
|
$usage = is_array($decoded['usage'] ?? null) ? $decoded['usage'] : [];
|
||||||
$callsUsedRaw = $usage['calls'] ?? null;
|
$callsUsedRaw = $usage['calls'] ?? null;
|
||||||
$quotaCallsRaw = $decoded['total_calls'] ?? null;
|
$quotaCallsRaw = $decoded['total_calls'] ?? null;
|
||||||
if (!is_numeric($callsUsedRaw) || !is_numeric($quotaCallsRaw) || (int)$quotaCallsRaw <= 0) {
|
if (!is_numeric($callsUsedRaw) || !is_numeric($quotaCallsRaw) || (int)$quotaCallsRaw <= 0) {
|
||||||
return [
|
$reason = !is_numeric($quotaCallsRaw) ? 'missing_limit' : 'missing_usage';
|
||||||
'status' => 'degraded',
|
if (is_numeric($quotaCallsRaw) && (int)$quotaCallsRaw <= 0) {
|
||||||
'status_reason' => $label . ' did not return usable quota values.',
|
$reason = 'invalid_limit';
|
||||||
'status_reason_key' => 'licenseplaterecognizer_usage_missing',
|
}
|
||||||
'status_reason_params' => ['label' => $label],
|
|
||||||
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
return $this->providerQuotaUnavailableProbeResult(
|
||||||
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
$httpResponse,
|
||||||
'http_status' => $httpResponse['http_status'] ?? null,
|
$label,
|
||||||
];
|
'licenseplaterecognizer',
|
||||||
|
$reason,
|
||||||
|
$this->payloadKeyPaths($decoded)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$callsUsed = max(0, (int)$callsUsedRaw);
|
$callsUsed = max(0, (int)$callsUsedRaw);
|
||||||
@@ -1586,6 +1653,176 @@ class superuser_system_status_service
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function evaluateProviderQuotaProbeResponse(array $httpResponse, string $label, string $moduleKey): array
|
||||||
|
{
|
||||||
|
$classified = $this->classifyHttpProbeResult($httpResponse, $label);
|
||||||
|
if (($classified['status'] ?? 'down') !== 'ok') {
|
||||||
|
return $classified;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode((string)($httpResponse['body'] ?? ''), true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
return $this->providerQuotaUnavailableProbeResult(
|
||||||
|
$httpResponse,
|
||||||
|
$label,
|
||||||
|
$moduleKey,
|
||||||
|
'unreadable_payload',
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$used = $this->findFirstNumericPayloadValue($decoded, ['used', 'usage', 'calls', 'messages_used', 'used_messages', 'sent', 'total_used']);
|
||||||
|
$limit = $this->findFirstNumericPayloadValue($decoded, ['limit', 'quota', 'total', 'total_calls', 'messages_limit', 'max', 'allowed']);
|
||||||
|
$remaining = $this->findFirstNumericPayloadValue($decoded, ['remaining', 'left', 'available', 'calls_remaining', 'messages_remaining']);
|
||||||
|
|
||||||
|
if ($used === null && $limit !== null && $remaining !== null) {
|
||||||
|
$used = max(0.0, $limit - $remaining);
|
||||||
|
}
|
||||||
|
if ($remaining === null && $used !== null && $limit !== null) {
|
||||||
|
$remaining = max(0.0, $limit - $used);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($limit === null) {
|
||||||
|
return $this->providerQuotaUnavailableProbeResult(
|
||||||
|
$httpResponse,
|
||||||
|
$label,
|
||||||
|
$moduleKey,
|
||||||
|
'missing_limit',
|
||||||
|
$this->payloadKeyPaths($decoded)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($limit <= 0) {
|
||||||
|
return $this->providerQuotaUnavailableProbeResult(
|
||||||
|
$httpResponse,
|
||||||
|
$label,
|
||||||
|
$moduleKey,
|
||||||
|
'invalid_limit',
|
||||||
|
$this->payloadKeyPaths($decoded)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($used === null) {
|
||||||
|
return $this->providerQuotaUnavailableProbeResult(
|
||||||
|
$httpResponse,
|
||||||
|
$label,
|
||||||
|
$moduleKey,
|
||||||
|
'missing_usage',
|
||||||
|
$this->payloadKeyPaths($decoded)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$usagePercent = round(($used / $limit) * 100, 2);
|
||||||
|
$usagePayload = [
|
||||||
|
'provider' => $moduleKey,
|
||||||
|
'calls_used' => $used,
|
||||||
|
'quota_calls' => $limit,
|
||||||
|
'calls_remaining' => $remaining,
|
||||||
|
'usage_percent' => $usagePercent,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (($remaining !== null && $remaining <= 0) || $usagePercent >= 100.0) {
|
||||||
|
return [
|
||||||
|
'status' => 'down',
|
||||||
|
'status_reason' => $label . ' quota is exhausted.',
|
||||||
|
'status_reason_key' => 'provider_quota_exhausted',
|
||||||
|
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
|
||||||
|
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||||
|
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||||
|
'http_status' => $httpResponse['http_status'] ?? null,
|
||||||
|
'usage' => $usagePayload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($usagePercent >= 90.0) {
|
||||||
|
return [
|
||||||
|
'status' => 'degraded',
|
||||||
|
'status_reason' => $label . ' quota usage is near the limit.',
|
||||||
|
'status_reason_key' => 'provider_quota_near_limit',
|
||||||
|
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
|
||||||
|
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||||
|
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||||
|
'http_status' => $httpResponse['http_status'] ?? null,
|
||||||
|
'usage' => $usagePayload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'ok',
|
||||||
|
'status_reason' => $label . ' quota usage is available.',
|
||||||
|
'status_reason_key' => 'provider_quota_available',
|
||||||
|
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
|
||||||
|
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||||
|
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||||
|
'http_status' => $httpResponse['http_status'] ?? null,
|
||||||
|
'usage' => $usagePayload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function providerQuotaUnavailableProbeResult(array $httpResponse, string $label, string $moduleKey, string $reason, array $detectedKeys = []): array
|
||||||
|
{
|
||||||
|
$usagePayload = [
|
||||||
|
'provider' => $moduleKey,
|
||||||
|
'usage_available' => false,
|
||||||
|
'status' => 'unknown',
|
||||||
|
'calls_used' => null,
|
||||||
|
'quota_calls' => null,
|
||||||
|
'calls_remaining' => null,
|
||||||
|
'usage_percent' => null,
|
||||||
|
'unavailable_reason' => $reason,
|
||||||
|
'detected_keys' => $detectedKeys,
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'degraded',
|
||||||
|
'status_reason' => $label . ' responded, but quota usage could not be read.',
|
||||||
|
'status_reason_key' => 'provider_quota_unavailable',
|
||||||
|
'status_reason_params' => [
|
||||||
|
'label' => $label,
|
||||||
|
'reason' => $reason,
|
||||||
|
'detected_keys' => implode(', ', array_slice($detectedKeys, 0, 12)),
|
||||||
|
],
|
||||||
|
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||||
|
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||||
|
'http_status' => $httpResponse['http_status'] ?? null,
|
||||||
|
'usage' => $usagePayload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function findFirstNumericPayloadValue(array $payload, array $keys): ?float
|
||||||
|
{
|
||||||
|
foreach ($payload as $key => $value) {
|
||||||
|
$normalizedKey = strtolower((string)$key);
|
||||||
|
if (in_array($normalizedKey, $keys, true) && is_numeric($value)) {
|
||||||
|
return (float)$value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$nested = $this->findFirstNumericPayloadValue($value, $keys);
|
||||||
|
if ($nested !== null) {
|
||||||
|
return $nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function payloadKeyPaths(array $payload, string $prefix = ''): array
|
||||||
|
{
|
||||||
|
$paths = [];
|
||||||
|
foreach ($payload as $key => $value) {
|
||||||
|
$path = $prefix === '' ? (string)$key : $prefix . '.' . (string)$key;
|
||||||
|
$paths[] = $path;
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
array_push($paths, ...$this->payloadKeyPaths($value, $path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($paths));
|
||||||
|
}
|
||||||
|
|
||||||
protected function evaluateRecaptchaProbeResponse(array $httpResponse, string $label): array
|
protected function evaluateRecaptchaProbeResponse(array $httpResponse, string $label): array
|
||||||
{
|
{
|
||||||
$classified = $this->classifyHttpProbeResult($httpResponse, $label);
|
$classified = $this->classifyHttpProbeResult($httpResponse, $label);
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ class virkdata implements virkdata_i
|
|||||||
self::requireModuleEnabled();
|
self::requireModuleEnabled();
|
||||||
// Validate the secret key
|
// Validate the secret key
|
||||||
self::requireValidSecretKey();
|
self::requireValidSecretKey();
|
||||||
|
// VirkData has a monthly limit config, but defaults to observe mode until explicitly switched to block.
|
||||||
|
$this->reserveCompanySearchQuota($search, $endpoint, $method);
|
||||||
// Send the request
|
// Send the request
|
||||||
$response = match ($method) {
|
$response = match ($method) {
|
||||||
'GET' => self::sendGetRequest($search, $endpoint, $data),
|
'GET' => self::sendGetRequest($search, $endpoint, $data),
|
||||||
@@ -128,6 +130,18 @@ class virkdata implements virkdata_i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function reserveCompanySearchQuota(string $search, string $endpoint, string $method): void
|
||||||
|
{
|
||||||
|
(new module_usage_service())->reserveOrFail('virkdata', 'company_search_calls', 1, [
|
||||||
|
'search' => $search,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'method' => strtoupper($method),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
|
|||||||
+32
-4
@@ -9,21 +9,49 @@ class economic_invoices_pdf_endpoint
|
|||||||
use economic_endpoint_t;
|
use economic_endpoint_t;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a PDF of a booked invoice
|
* Get a PDF of a booked invoice.
|
||||||
|
*
|
||||||
|
* Kept as the legacy alias used by existing invoice download flows.
|
||||||
* @param int $id
|
* @param int $id
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public function get(int $id): string
|
public function get(int $id): string
|
||||||
|
{
|
||||||
|
return $this->getBooked($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a PDF of a booked invoice.
|
||||||
|
* @param int $id
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getBooked(int $id): string
|
||||||
|
{
|
||||||
|
return $this->downloadPdf('/invoices/booked/' . $id . '/pdf', 'booked_invoice_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a PDF of a draft invoice.
|
||||||
|
* @param int $id
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getDraft(int $id): string
|
||||||
|
{
|
||||||
|
return $this->downloadPdf('/invoices/drafts/' . $id . '/pdf', 'draft_invoice_');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function downloadPdf(string $path, string $prefix): string
|
||||||
{
|
{
|
||||||
$unique_id = uniqid();
|
$unique_id = uniqid();
|
||||||
|
$file_path = '/tmp/' . $prefix . $unique_id . '.pdf';
|
||||||
$this->send_file_download_request(
|
$this->send_file_download_request(
|
||||||
'/invoices/booked/' . $id . '/pdf',
|
$path,
|
||||||
'GET',
|
'GET',
|
||||||
'',
|
'',
|
||||||
false,
|
false,
|
||||||
'/tmp/invoice_' . $unique_id . '.pdf'
|
$file_path
|
||||||
);
|
);
|
||||||
return '/tmp/invoice_' . $unique_id . '.pdf';
|
return $file_path;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace objects;
|
namespace objects;
|
||||||
|
|
||||||
use classes\db;
|
use classes\db;
|
||||||
|
use classes\module_usage_schema_bootstrap;
|
||||||
use classes\object_property;
|
use classes\object_property;
|
||||||
use Exception;
|
use Exception;
|
||||||
use traits\db_object_t;
|
use traits\db_object_t;
|
||||||
@@ -40,6 +41,7 @@ class module_action_logs_o extends db
|
|||||||
|
|
||||||
public function structure(): void
|
public function structure(): void
|
||||||
{
|
{
|
||||||
|
module_usage_schema_bootstrap::ensureTables();
|
||||||
$this->setTable('module_usage_logs');
|
$this->setTable('module_usage_logs');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+398
-10
@@ -7624,6 +7624,45 @@ paths:
|
|||||||
'500':
|
'500':
|
||||||
$ref: '#/components/responses/InternalServerError'
|
$ref: '#/components/responses/InternalServerError'
|
||||||
|
|
||||||
|
/collected-invoices/economic/pdf:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Invoices
|
||||||
|
summary: Download a collected invoice e-conomic PDF
|
||||||
|
description: |
|
||||||
|
Resolves the requested collected invoice context and returns a presigned URL for the
|
||||||
|
draft or booked e-conomic invoice PDF.
|
||||||
|
operationId: downloadCollectedInvoiceEconomicPdf
|
||||||
|
parameters:
|
||||||
|
- name: collected_invoice_id
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: The internal collected invoice ID
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
- name: type
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: Which e-conomic invoice PDF to download
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- draft
|
||||||
|
- booked
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: PDF URL resolved successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CollectedInvoiceEconomicPdfResponse'
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||||
|
|
||||||
/collected-invoices/economic/v2/details:
|
/collected-invoices/economic/v2/details:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -10044,6 +10083,118 @@ paths:
|
|||||||
'403':
|
'403':
|
||||||
$ref: '#/components/responses/Forbidden'
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
|
||||||
|
/modules/usage/summary:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Modules
|
||||||
|
summary: Module usage and quota summary
|
||||||
|
description: Returns registered module usage metrics, quota status, provider snapshots, and derived operational statistics.
|
||||||
|
operationId: getModuleUsageSummary
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: module
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: period
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [all, day, month, provider, all_time]
|
||||||
|
- in: query
|
||||||
|
name: status
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [ok, near_limit, exhausted, unlimited, unknown, disabled]
|
||||||
|
- in: query
|
||||||
|
name: date
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Module usage summary returned successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ModuleUsageSummary'
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
|
||||||
|
/modules/usage/{moduleKey}:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Modules
|
||||||
|
summary: Module usage details
|
||||||
|
operationId: getModuleUsageDetail
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: moduleKey
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: limit
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 200
|
||||||
|
- in: query
|
||||||
|
name: date
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Module usage detail returned successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ModuleUsageDetail'
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
|
||||||
|
/modules/quotas/{moduleKey}/{metricKey}:
|
||||||
|
patch:
|
||||||
|
tags:
|
||||||
|
- Modules
|
||||||
|
summary: Update module quota setting
|
||||||
|
description: Updates quota enforcement settings and writable module-config hard limits for a registered metric.
|
||||||
|
operationId: updateModuleQuotaSetting
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: moduleKey
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: path
|
||||||
|
name: metricKey
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ModuleQuotaUpdateRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Module quota setting updated successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ModuleUsageMetric'
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
'409': { $ref: '#/components/responses/Conflict' }
|
||||||
|
'422': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
|
||||||
# Module - Self-Serve Endpoints
|
# Module - Self-Serve Endpoints
|
||||||
/modules/self-serve/lane/status:
|
/modules/self-serve/lane/status:
|
||||||
get:
|
get:
|
||||||
@@ -16507,34 +16658,242 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum: [disabled, not_configured, configured, ok, degraded, down]
|
enum: [disabled, not_configured, configured, ok, degraded, down]
|
||||||
|
|
||||||
|
ModuleUsageStatusEnum:
|
||||||
|
type: string
|
||||||
|
enum: [ok, near_limit, exhausted, unlimited, unknown, disabled]
|
||||||
|
|
||||||
|
ModuleUsageMetric:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
module_key:
|
||||||
|
type: string
|
||||||
|
module_label:
|
||||||
|
type: string
|
||||||
|
metric_key:
|
||||||
|
type: string
|
||||||
|
metric_label:
|
||||||
|
type: string
|
||||||
|
unit:
|
||||||
|
type: string
|
||||||
|
period:
|
||||||
|
type: string
|
||||||
|
enum: [day, month, provider, all_time]
|
||||||
|
scope_type:
|
||||||
|
type: string
|
||||||
|
scope_id:
|
||||||
|
type: string
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
enum: [internal_counter, provider_snapshot, derived]
|
||||||
|
primary:
|
||||||
|
type: boolean
|
||||||
|
writable_limit:
|
||||||
|
type: boolean
|
||||||
|
limit_source:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [module_config, provider]
|
||||||
|
config_module:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
config_variable:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
used:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
nullable: true
|
||||||
|
limit:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
nullable: true
|
||||||
|
remaining:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
nullable: true
|
||||||
|
usage_percent:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
nullable: true
|
||||||
|
status:
|
||||||
|
$ref: '#/components/schemas/ModuleUsageStatusEnum'
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
enforce_mode:
|
||||||
|
type: string
|
||||||
|
enum: [observe, block]
|
||||||
|
soft_limit_percent:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
window:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
start:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
end:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
timezone:
|
||||||
|
type: string
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
history_available:
|
||||||
|
type: boolean
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
usage_available:
|
||||||
|
type: boolean
|
||||||
|
description: False when a provider responded but quota fields could not be read.
|
||||||
|
unavailable_reason:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [unreadable_payload, missing_limit, missing_usage, invalid_limit]
|
||||||
|
detected_keys:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- module_key
|
||||||
|
- metric_key
|
||||||
|
- unit
|
||||||
|
- period
|
||||||
|
- source
|
||||||
|
- status
|
||||||
|
- enforce_mode
|
||||||
|
|
||||||
|
ModuleUsageSummary:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
generated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
filters:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
modules:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
$ref: '#/components/schemas/ModuleUsageStatusEnum'
|
||||||
|
metrics:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ModuleUsageMetric'
|
||||||
|
metrics:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ModuleUsageMetric'
|
||||||
|
required:
|
||||||
|
- generated_at
|
||||||
|
- modules
|
||||||
|
- metrics
|
||||||
|
|
||||||
|
ModuleUsageDetail:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
generated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
module_key:
|
||||||
|
type: string
|
||||||
|
metrics:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ModuleUsageMetric'
|
||||||
|
history:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
ModuleQuotaUpdateRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
enforce_mode:
|
||||||
|
type: string
|
||||||
|
enum: [observe, block]
|
||||||
|
soft_limit_percent:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
limit:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
|
||||||
SuperuserModuleUsage:
|
SuperuserModuleUsage:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
provider:
|
provider:
|
||||||
type: string
|
type: string
|
||||||
enum: [licenseplaterecognizer]
|
metric_key:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
unit:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
period:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
calls_used:
|
calls_used:
|
||||||
type: integer
|
type: number
|
||||||
|
format: float
|
||||||
minimum: 0
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
quota_calls:
|
quota_calls:
|
||||||
type: integer
|
type: number
|
||||||
minimum: 1
|
format: float
|
||||||
calls_remaining:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
calls_remaining:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
usage_percent:
|
usage_percent:
|
||||||
type: number
|
type: number
|
||||||
format: float
|
format: float
|
||||||
minimum: 0
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enforce_mode:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
version:
|
version:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
usage_available:
|
||||||
|
type: boolean
|
||||||
|
nullable: true
|
||||||
|
unavailable_reason:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [unreadable_payload, missing_limit, missing_usage, invalid_limit]
|
||||||
|
detected_keys:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
required:
|
required:
|
||||||
- provider
|
- provider
|
||||||
- calls_used
|
|
||||||
- quota_calls
|
|
||||||
- calls_remaining
|
|
||||||
- usage_percent
|
|
||||||
|
|
||||||
SuperuserRuntimeMetric:
|
SuperuserRuntimeMetric:
|
||||||
type: object
|
type: object
|
||||||
@@ -16653,6 +17012,10 @@ components:
|
|||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
usage:
|
usage:
|
||||||
$ref: '#/components/schemas/SuperuserModuleUsage'
|
$ref: '#/components/schemas/SuperuserModuleUsage'
|
||||||
|
usage_metrics:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ModuleUsageMetric'
|
||||||
checked_at:
|
checked_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -19324,6 +19687,31 @@ components:
|
|||||||
- collected_invoice_id
|
- collected_invoice_id
|
||||||
- internal_total
|
- internal_total
|
||||||
|
|
||||||
|
CollectedInvoiceEconomicPdfResponse:
|
||||||
|
type: object
|
||||||
|
description: Presigned PDF URL for a draft or booked e-conomic invoice attached to a collected invoice
|
||||||
|
properties:
|
||||||
|
collected_invoice_id:
|
||||||
|
type: integer
|
||||||
|
example: 123
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- draft
|
||||||
|
- booked
|
||||||
|
example: booked
|
||||||
|
economic_invoice_id:
|
||||||
|
type: integer
|
||||||
|
example: 28368
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
required:
|
||||||
|
- collected_invoice_id
|
||||||
|
- type
|
||||||
|
- economic_invoice_id
|
||||||
|
- url
|
||||||
|
|
||||||
CollectedInvoiceEconomicV2DetailsResponse:
|
CollectedInvoiceEconomicV2DetailsResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -43,16 +43,22 @@ class cronRoute
|
|||||||
|
|
||||||
$this->requireClassicSuperuserPermission('superuser_cron_view');
|
$this->requireClassicSuperuserPermission('superuser_cron_view');
|
||||||
$parameters = $this->getParametersAsArray();
|
$parameters = $this->getParametersAsArray();
|
||||||
$workers = (new cron_worker())->listWorkers();
|
|
||||||
try {
|
try {
|
||||||
$workers['deployment'] = (new release_manager())->cronWorkerStatus($parameters);
|
$response->success((new release_manager())->cronWorkerStatus($parameters));
|
||||||
} catch (Throwable $throwable) {
|
} catch (Throwable $throwable) {
|
||||||
|
$workers = (new cron_worker())->listWorkers();
|
||||||
$workers['deployment'] = [
|
$workers['deployment'] = [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'error' => $throwable->getMessage(),
|
'error' => $throwable->getMessage(),
|
||||||
];
|
];
|
||||||
}
|
$workers['state'] = 'failed';
|
||||||
|
$workers['issues'] = [[
|
||||||
|
'code' => 'status_load_failed',
|
||||||
|
'severity' => 'danger',
|
||||||
|
'message' => $throwable->getMessage(),
|
||||||
|
]];
|
||||||
$response->success($workers);
|
$response->success($workers);
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
'superuser_cron_view' => 'View cron worker deployment and heartbeat state',
|
'superuser_cron_view' => 'View cron worker deployment and heartbeat state',
|
||||||
]);
|
]);
|
||||||
@@ -68,7 +74,7 @@ class cronRoute
|
|||||||
$this->actorUserId()
|
$this->actorUserId()
|
||||||
);
|
);
|
||||||
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_WORKER_DEPLOY', 'Deployed cron worker');
|
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_WORKER_DEPLOY', 'Deployed cron worker');
|
||||||
$response->success($result);
|
$response->success($result, 202);
|
||||||
} catch (Throwable $throwable) {
|
} catch (Throwable $throwable) {
|
||||||
$response->error(['message' => $throwable->getMessage()], 409);
|
$response->error(['message' => $throwable->getMessage()], 409);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace routes;
|
||||||
|
|
||||||
|
use classes\module_usage_service;
|
||||||
|
use Exception;
|
||||||
|
use traits\route_t;
|
||||||
|
|
||||||
|
class moduleUsageRoute
|
||||||
|
{
|
||||||
|
use route_t;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->get('/modules/usage/summary', function () {
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
$this->requirePermission('modules_usage_view');
|
||||||
|
$response->success((new module_usage_service())->summary([
|
||||||
|
'module' => $this->getParameter('module'),
|
||||||
|
'period' => $this->getParameter('period'),
|
||||||
|
'status' => $this->getParameter('status'),
|
||||||
|
'date' => $this->getParameter('date'),
|
||||||
|
]));
|
||||||
|
}, [
|
||||||
|
'modules_usage_view' => 'View module usage, quota, and statistics summaries',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get('/modules/usage/{moduleKey}', function () {
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
$this->requirePermission('modules_usage_view');
|
||||||
|
$moduleKey = (string)($this->fromRoute('moduleKey') ?? '');
|
||||||
|
$response->success((new module_usage_service())->moduleDetail($moduleKey, [
|
||||||
|
'period' => $this->getParameter('period'),
|
||||||
|
'date' => $this->getParameter('date'),
|
||||||
|
'limit' => $this->getParameter('limit'),
|
||||||
|
]));
|
||||||
|
}, [
|
||||||
|
'modules_usage_view' => 'View detailed module usage, quota, and statistics history',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->patch('/modules/quotas/{moduleKey}/{metricKey}', function () {
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
$this->requirePermission('modules_quotas_manage');
|
||||||
|
$moduleKey = (string)($this->fromRoute('moduleKey') ?? '');
|
||||||
|
$metricKey = (string)($this->fromRoute('metricKey') ?? '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response->success((new module_usage_service())->updateQuotaSetting(
|
||||||
|
$moduleKey,
|
||||||
|
$metricKey,
|
||||||
|
$response->getAllRequestParameters()
|
||||||
|
));
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
if ($exception->getMessage() === 'quota_not_writable') {
|
||||||
|
$response->error([
|
||||||
|
'message' => 'Quota limit is not writable for this provider or derived metric.',
|
||||||
|
'code' => 'quota_not_writable',
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->error($exception->getMessage(), 422);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
'modules_quotas_manage' => 'Manage module quota enforcement and writable hard limits',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use classes\economic_transfer_queue_details_summary;
|
|||||||
use classes\economic_v2_compare_engine;
|
use classes\economic_v2_compare_engine;
|
||||||
use classes\economic_v2_line_normalizer;
|
use classes\economic_v2_line_normalizer;
|
||||||
use classes\economic_v2_revenue_statistics_service;
|
use classes\economic_v2_revenue_statistics_service;
|
||||||
|
use classes\invoice_store;
|
||||||
use classes\invoice_collection_bulk_action_service;
|
use classes\invoice_collection_bulk_action_service;
|
||||||
use classes\invoicing_period_utils;
|
use classes\invoicing_period_utils;
|
||||||
use classes\response;
|
use classes\response;
|
||||||
@@ -249,6 +250,26 @@ class orderInvoicesRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Collected order invoices > E-conomic PDF > GET */
|
||||||
|
$this->get('/collected-invoices/economic/pdf', function () {
|
||||||
|
global $response;
|
||||||
|
self::requirePermission('download_collected_invoice_economic_pdf');
|
||||||
|
$collected_invoice_id = $this->requireCollectedInvoiceId();
|
||||||
|
self::requireParameters(['type']);
|
||||||
|
|
||||||
|
$type = strtolower((string)self::getParameter('type'));
|
||||||
|
if (!in_array($type, ['draft', 'booked'], true)) {
|
||||||
|
$response->error('type must be either draft or booked', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->downloadCollectedEconomicInvoicePdf($collected_invoice_id, $type);
|
||||||
|
$response->success($payload);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'download_collected_invoice_economic_pdf' => 'Download draft/booked e-conomic PDF for a collected order invoice.',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
/** Collected order invoices > E-conomic V2 compare > GET */
|
/** Collected order invoices > E-conomic V2 compare > GET */
|
||||||
$this->get('/collected-invoices/economic/v2/compare', function () {
|
$this->get('/collected-invoices/economic/v2/compare', function () {
|
||||||
global $response;
|
global $response;
|
||||||
@@ -2105,6 +2126,61 @@ class orderInvoicesRoute
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function downloadCollectedEconomicInvoicePdf(int $collected_invoice_id, string $type): array
|
||||||
|
{
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
$invoice = (new collected_order_invoices_o())->select($collected_invoice_id);
|
||||||
|
$invoice->requireSelected();
|
||||||
|
$this->requireCollectedInvoiceContextAccess($invoice);
|
||||||
|
|
||||||
|
$economic_invoice_id = null;
|
||||||
|
try {
|
||||||
|
$economic_invoice_id = $type === 'draft'
|
||||||
|
? $invoice->getInvoiceDraftId()
|
||||||
|
: $invoice->getInvoiceBookedId();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$response->error('No ' . $type . ' e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$economic_invoice_id = (int)$economic_invoice_id;
|
||||||
|
if ($economic_invoice_id <= 0) {
|
||||||
|
$response->error('No ' . $type . ' e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$economic = new economic();
|
||||||
|
$invoice_path_file = $type === 'draft'
|
||||||
|
? $economic->invoices->pdf->getDraft($economic_invoice_id)
|
||||||
|
: $economic->invoices->pdf->getBooked($economic_invoice_id);
|
||||||
|
|
||||||
|
$store_key_id = 'economic_' . $type . '_' . $economic_invoice_id;
|
||||||
|
$invoice_store = new invoice_store();
|
||||||
|
try {
|
||||||
|
$invoice_store->uploadFile('invoice_' . $store_key_id . '.pdf', $invoice_path_file);
|
||||||
|
} finally {
|
||||||
|
if (is_string($invoice_path_file) && file_exists($invoice_path_file)) {
|
||||||
|
unlink($invoice_path_file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
(new logs_o())->add(
|
||||||
|
'orderInvoices',
|
||||||
|
'global',
|
||||||
|
1,
|
||||||
|
$user ? (int)$user->id : 0,
|
||||||
|
'DOWNLOAD_COLLECTED_ECONOMIC_INVOICE_PDF',
|
||||||
|
'Downloaded ' . $type . ' e-conomic invoice PDF for collected invoice ' . $collected_invoice_id
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'collected_invoice_id' => $collected_invoice_id,
|
||||||
|
'type' => $type,
|
||||||
|
'economic_invoice_id' => $economic_invoice_id,
|
||||||
|
'url' => $invoice_store->getInvoiceDownloadUrl($store_key_id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function requireCollectedInvoiceContextAccess(collected_order_invoices_o $invoice): void
|
private function requireCollectedInvoiceContextAccess(collected_order_invoices_o $invoice): void
|
||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ it('wires cron workers through schema, CLI, scheduler, and superuser routes', fu
|
|||||||
|
|
||||||
expect($route)->toContain('/superuser/cron/workers');
|
expect($route)->toContain('/superuser/cron/workers');
|
||||||
expect($route)->toContain('/superuser/cron/workers/deploy');
|
expect($route)->toContain('/superuser/cron/workers/deploy');
|
||||||
|
expect($route)->toContain('$response->success($result, 202)');
|
||||||
expect($route)->toContain('queueTaskRun(');
|
expect($route)->toContain('queueTaskRun(');
|
||||||
expect($route)->toContain('$response->success($run, 202)');
|
expect($route)->toContain('$response->success($run, 202)');
|
||||||
expect($route)->toContain('superuser_cron_view');
|
expect($route)->toContain('superuser_cron_view');
|
||||||
@@ -42,6 +43,9 @@ it('wires cron workers through schema, CLI, scheduler, and superuser routes', fu
|
|||||||
expect($manager)->toContain("private const CRON_WORKER_APP = 'cron'");
|
expect($manager)->toContain("private const CRON_WORKER_APP = 'cron'");
|
||||||
expect($manager)->toContain("private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker'");
|
expect($manager)->toContain("private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker'");
|
||||||
expect($manager)->toContain('deployCronWorkerAfterApiDeployment');
|
expect($manager)->toContain('deployCronWorkerAfterApiDeployment');
|
||||||
|
expect($manager)->toContain('deployment_kind = \'cron_worker\'');
|
||||||
|
expect($manager)->toContain('createCronWorkerDeploymentRecord');
|
||||||
|
expect($manager)->toContain('waiting_for_heartbeat');
|
||||||
expect($manager)->toContain('cronWorkerAutoprovisionRequired');
|
expect($manager)->toContain('cronWorkerAutoprovisionRequired');
|
||||||
expect($manager)->toContain('cron_worker_autoprovision_disabled');
|
expect($manager)->toContain('cron_worker_autoprovision_disabled');
|
||||||
expect($manager)->toContain('cron_worker_deploy_failed');
|
expect($manager)->toContain('cron_worker_deploy_failed');
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('registers collected-invoice economic pdf download with draft and booked support', function (): void {
|
||||||
|
$routeFile = app_path('routes/orderInvoicesRoute.php');
|
||||||
|
$content = file_get_contents($routeFile);
|
||||||
|
|
||||||
|
expect($content)->not->toBeFalse();
|
||||||
|
expect($content)->toContain('/collected-invoices/economic/pdf');
|
||||||
|
expect($content)->toContain("requirePermission('download_collected_invoice_economic_pdf')");
|
||||||
|
expect($content)->toContain("self::requireParameters(['type'])");
|
||||||
|
expect($content)->toContain('downloadCollectedEconomicInvoicePdf');
|
||||||
|
expect($content)->toContain('getInvoiceDraftId()');
|
||||||
|
expect($content)->toContain('getInvoiceBookedId()');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports draft and booked pdf downloads in the e-conomic pdf endpoint wrapper', function (): void {
|
||||||
|
$endpointFile = app_path('modules/economic/endpoints/invoices/economic_invoices_pdf_endpoint.php');
|
||||||
|
$content = file_get_contents($endpointFile);
|
||||||
|
|
||||||
|
expect($content)->not->toBeFalse();
|
||||||
|
expect($content)->toContain('function getBooked(int $id): string');
|
||||||
|
expect($content)->toContain('function getDraft(int $id): string');
|
||||||
|
expect($content)->toContain("'/invoices/booked/' . \$id . '/pdf'");
|
||||||
|
expect($content)->toContain("'/invoices/drafts/' . \$id . '/pdf'");
|
||||||
|
});
|
||||||
@@ -31,6 +31,7 @@ it('documents economic v2 invoice paths in openapi', function (): void {
|
|||||||
$content = economic_v2_openapi_content_or_skip();
|
$content = economic_v2_openapi_content_or_skip();
|
||||||
|
|
||||||
expect($content)->not->toBeFalse();
|
expect($content)->not->toBeFalse();
|
||||||
|
expect($content)->toContain('/collected-invoices/economic/pdf:');
|
||||||
expect($content)->toContain('/collected-invoices/economic/v2/details:');
|
expect($content)->toContain('/collected-invoices/economic/v2/details:');
|
||||||
expect($content)->toContain('/collected-invoices/economic/v2/compare:');
|
expect($content)->toContain('/collected-invoices/economic/v2/compare:');
|
||||||
expect($content)->toContain('/collected-invoices/economic/v2/compare/bulk:');
|
expect($content)->toContain('/collected-invoices/economic/v2/compare/bulk:');
|
||||||
@@ -69,6 +70,7 @@ it('defines new reusable v2 schemas for normalization comparison versioning and
|
|||||||
|
|
||||||
expect($content)->not->toBeFalse();
|
expect($content)->not->toBeFalse();
|
||||||
expect($content)->toContain('EconomicV2NormalizedLineItem:');
|
expect($content)->toContain('EconomicV2NormalizedLineItem:');
|
||||||
|
expect($content)->toContain('CollectedInvoiceEconomicPdfResponse:');
|
||||||
expect($content)->toContain('EconomicV2Comparison:');
|
expect($content)->toContain('EconomicV2Comparison:');
|
||||||
expect($content)->toContain('CollectedInvoiceEconomicV2CustomerSummary:');
|
expect($content)->toContain('CollectedInvoiceEconomicV2CustomerSummary:');
|
||||||
expect($content)->toContain('CollectedInvoiceEconomicV2RevenueStatisticsResponse:');
|
expect($content)->toContain('CollectedInvoiceEconomicV2RevenueStatisticsResponse:');
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('documents module usage summary, detail, and quota update endpoints in openapi', function (): void {
|
||||||
|
$content = file_get_contents(app_path('openapi.yaml'));
|
||||||
|
|
||||||
|
expect($content)->toContain('/modules/usage/summary:')
|
||||||
|
->and($content)->toContain('operationId: getModuleUsageSummary')
|
||||||
|
->and($content)->toContain('/modules/usage/{moduleKey}:')
|
||||||
|
->and($content)->toContain('operationId: getModuleUsageDetail')
|
||||||
|
->and($content)->toContain('/modules/quotas/{moduleKey}/{metricKey}:')
|
||||||
|
->and($content)->toContain('operationId: updateModuleQuotaSetting')
|
||||||
|
->and($content)->toContain('ModuleUsageSummary:')
|
||||||
|
->and($content)->toContain('ModuleUsageDetail:')
|
||||||
|
->and($content)->toContain('ModuleUsageMetric:')
|
||||||
|
->and($content)->toContain('ModuleQuotaUpdateRequest:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('documents unavailable provider quota fields in openapi', function (): void {
|
||||||
|
$content = file_get_contents(app_path('openapi.yaml'));
|
||||||
|
|
||||||
|
expect($content)->toContain('usage_available:')
|
||||||
|
->and($content)->toContain('unavailable_reason:')
|
||||||
|
->and($content)->toContain('enum: [unreadable_payload, missing_limit, missing_usage, invalid_limit]')
|
||||||
|
->and($content)->toContain('detected_keys:');
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
app_require('classes/module_usage_registry.php');
|
||||||
|
app_require('classes/module_usage_service.php');
|
||||||
|
|
||||||
|
use classes\module_usage_registry;
|
||||||
|
use classes\module_usage_service;
|
||||||
|
|
||||||
|
it('registers existing writable quota metrics with their current enforcement defaults', function (): void {
|
||||||
|
$registry = new module_usage_registry();
|
||||||
|
|
||||||
|
$motorApi = $registry->find('motorapi', 'lookup_calls');
|
||||||
|
$fxRates = $registry->find('fxratesapi', 'rate_fetch_calls');
|
||||||
|
$virkData = $registry->find('virkdata', 'company_search_calls');
|
||||||
|
|
||||||
|
expect($motorApi)->not->toBeNull()
|
||||||
|
->and($motorApi['config_module'])->toBe('motorapi')
|
||||||
|
->and($motorApi['config_variable'])->toBe('daily_limit')
|
||||||
|
->and($motorApi['default_enforce_mode'])->toBe('block')
|
||||||
|
->and($motorApi['writable_limit'])->toBeTrue()
|
||||||
|
->and($fxRates)->not->toBeNull()
|
||||||
|
->and($fxRates['config_variable'])->toBe('daily_limit')
|
||||||
|
->and($fxRates['default_enforce_mode'])->toBe('block')
|
||||||
|
->and($virkData)->not->toBeNull()
|
||||||
|
->and($virkData['config_variable'])->toBe('monthly_limit')
|
||||||
|
->and($virkData['default_enforce_mode'])->toBe('observe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes provider usage payloads into module usage metrics without requiring a database', function (): void {
|
||||||
|
$previousDb = $GLOBALS['db'] ?? null;
|
||||||
|
unset($GLOBALS['db']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$metric = (new module_usage_service())->recordProviderSnapshotFromLegacyUsage('licenseplaterecognizer', [
|
||||||
|
'calls_used' => 2250,
|
||||||
|
'quota_calls' => 2500,
|
||||||
|
'calls_remaining' => 250,
|
||||||
|
'usage_percent' => 90.0,
|
||||||
|
'version' => '1.54.0',
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if ($previousDb !== null) {
|
||||||
|
$GLOBALS['db'] = $previousDb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($metric)->not->toBeNull()
|
||||||
|
->and($metric['module_key'])->toBe('licenseplaterecognizer')
|
||||||
|
->and($metric['metric_key'])->toBe('plate_recognition_calls')
|
||||||
|
->and($metric['used'])->toBe(2250.0)
|
||||||
|
->and($metric['limit'])->toBe(2500.0)
|
||||||
|
->and($metric['remaining'])->toBe(250.0)
|
||||||
|
->and($metric['usage_percent'])->toBe(90.0)
|
||||||
|
->and($metric['status'])->toBe('near_limit')
|
||||||
|
->and($metric['version'])->toBe('1.54.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes unavailable provider quota payloads into placeholder metrics without requiring a database', function (): void {
|
||||||
|
$previousDb = $GLOBALS['db'] ?? null;
|
||||||
|
unset($GLOBALS['db']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$metric = (new module_usage_service())->recordProviderSnapshotFromLegacyUsage('email', [
|
||||||
|
'usage_available' => false,
|
||||||
|
'status' => 'unknown',
|
||||||
|
'calls_used' => null,
|
||||||
|
'quota_calls' => null,
|
||||||
|
'calls_remaining' => null,
|
||||||
|
'usage_percent' => null,
|
||||||
|
'unavailable_reason' => 'missing_limit',
|
||||||
|
'detected_keys' => ['plan', 'usage.calls'],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if ($previousDb !== null) {
|
||||||
|
$GLOBALS['db'] = $previousDb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($metric)->not->toBeNull()
|
||||||
|
->and($metric['module_key'])->toBe('email')
|
||||||
|
->and($metric['metric_key'])->toBe('mailersend_messages')
|
||||||
|
->and($metric['used'])->toBeNull()
|
||||||
|
->and($metric['limit'])->toBeNull()
|
||||||
|
->and($metric['usage_percent'])->toBeNull()
|
||||||
|
->and($metric['status'])->toBe('unknown')
|
||||||
|
->and($metric['usage_available'])->toBeFalse()
|
||||||
|
->and($metric['unavailable_reason'])->toBe('missing_limit')
|
||||||
|
->and($metric['detected_keys'])->toBe(['plan', 'usage.calls']);
|
||||||
|
});
|
||||||
@@ -470,6 +470,127 @@ it('requires Coolify cron worker autoprovisioning for API deployments by default
|
|||||||
|
|
||||||
$managerSource = file_get_contents(app_path('classes/release_manager.php'));
|
$managerSource = file_get_contents(app_path('classes/release_manager.php'));
|
||||||
expect($managerSource)->toContain('Cron worker deployment is required for API deployments');
|
expect($managerSource)->toContain('Cron worker deployment is required for API deployments');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies cron worker deployment and heartbeat lifecycle states', function (): void {
|
||||||
|
$manager = new release_manager();
|
||||||
|
$healthMethod = new ReflectionMethod(release_manager::class, 'cronWorkerHealth');
|
||||||
|
$healthMethod->setAccessible(true);
|
||||||
|
|
||||||
|
expect($healthMethod->invoke($manager, null, null, [], ['running' => 0, 'stale' => 0, 'failed' => 0], null)['state'])
|
||||||
|
->toBe('needs_deploy');
|
||||||
|
expect($healthMethod->invoke($manager, ['id' => 4], null, [], ['running' => 0, 'stale' => 0, 'failed' => 0], null)['state'])
|
||||||
|
->toBe('needs_deploy');
|
||||||
|
expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [
|
||||||
|
'status' => 'deploying',
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
])['state'])->toBe('deploying');
|
||||||
|
expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [
|
||||||
|
'status' => 'deployed',
|
||||||
|
'completed_at' => date('Y-m-d H:i:s'),
|
||||||
|
])['state'])->toBe('waiting_for_heartbeat');
|
||||||
|
expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [
|
||||||
|
['status' => 'running', 'stale' => false],
|
||||||
|
], ['running' => 1, 'stale' => 0, 'failed' => 0], [
|
||||||
|
'status' => 'deployed',
|
||||||
|
])['state'])->toBe('healthy');
|
||||||
|
expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [
|
||||||
|
'status' => 'deployed',
|
||||||
|
'completed_at' => '2020-01-01 00:00:00',
|
||||||
|
])['state'])->toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts Coolify cron deployment operation identifiers from provider payloads', function (): void {
|
||||||
|
$manager = new release_manager();
|
||||||
|
$operationMethod = new ReflectionMethod(release_manager::class, 'coolifyDeploymentOperationId');
|
||||||
|
$operationMethod->setAccessible(true);
|
||||||
|
|
||||||
|
expect($operationMethod->invoke($manager, ['deployment' => ['deployment_uuid' => 'deployment-123']]))
|
||||||
|
->toBe('deployment-123');
|
||||||
|
expect($operationMethod->invoke($manager, ['data' => ['uuid' => 'operation-456']]))
|
||||||
|
->toBe('operation-456');
|
||||||
|
expect($operationMethod->invoke($manager, ['message' => 'queued']))
|
||||||
|
->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects missing Coolify cron worker resources from provider errors', function (): void {
|
||||||
|
$method = new ReflectionMethod(release_manager::class, 'coolifyResourceMissing');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
expect($method->invoke(null, new RuntimeException('Coolify API request failed: HTTP 404')))->toBeTrue();
|
||||||
|
expect($method->invoke(null, new RuntimeException('Application not found')))->toBeTrue();
|
||||||
|
expect($method->invoke(null, new RuntimeException('Coolify API request failed: HTTP 401')))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies missing Coolify cron worker resources as repairable', function (): void {
|
||||||
|
$manager = new release_manager();
|
||||||
|
$readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');
|
||||||
|
$readiness->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $readiness->invoke($manager, [
|
||||||
|
'id' => 17,
|
||||||
|
'app' => 'api',
|
||||||
|
'coolify_instance_id' => 3,
|
||||||
|
'repository' => 'copenhagentruckwash/api',
|
||||||
|
], [
|
||||||
|
'id' => 71,
|
||||||
|
'app' => 'cron',
|
||||||
|
'coolify_instance_id' => 3,
|
||||||
|
'coolify_service_uuid' => 'missing-cron-worker',
|
||||||
|
'repository' => 'copenhagentruckwash/api',
|
||||||
|
'branch' => 'master',
|
||||||
|
], [
|
||||||
|
'configured' => true,
|
||||||
|
'missing' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($result['action'])->toBe('repair');
|
||||||
|
expect($result['can_deploy'])->toBeTrue();
|
||||||
|
expect(array_column($result['issues'], 'code'))->toContain('missing_coolify_worker_resource');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repairs from an existing cron target when the API target is absent', function (): void {
|
||||||
|
$manager = new release_manager();
|
||||||
|
$readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');
|
||||||
|
$readiness->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $readiness->invoke($manager, null, [
|
||||||
|
'id' => 71,
|
||||||
|
'app' => 'cron',
|
||||||
|
'coolify_instance_id' => 3,
|
||||||
|
'coolify_service_uuid' => 'missing-cron-worker',
|
||||||
|
'repository' => 'copenhagentruckwash/api',
|
||||||
|
'branch' => 'master',
|
||||||
|
], [
|
||||||
|
'configured' => true,
|
||||||
|
'missing' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($result['action'])->toBe('repair');
|
||||||
|
expect($result['can_deploy'])->toBeTrue();
|
||||||
|
expect(array_column($result['issues'], 'code'))->toContain('repairable_cron_target');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks cron worker deployment without an API target or deployable cron context', function (): void {
|
||||||
|
$manager = new release_manager();
|
||||||
|
$readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');
|
||||||
|
$readiness->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $readiness->invoke($manager, null, [
|
||||||
|
'id' => 71,
|
||||||
|
'app' => 'cron',
|
||||||
|
'coolify_instance_id' => null,
|
||||||
|
'coolify_service_uuid' => 'missing-cron-worker',
|
||||||
|
'repository' => '',
|
||||||
|
'branch' => 'master',
|
||||||
|
], [
|
||||||
|
'configured' => true,
|
||||||
|
'missing' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($result['action'])->toBe('blocked');
|
||||||
|
expect($result['can_deploy'])->toBeFalse();
|
||||||
|
expect(array_column($result['issues'], 'code'))->toContain('missing_api_target');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds explicit Coolify application route labels for release API targets', function (): void {
|
it('builds explicit Coolify application route labels for release API targets', function (): void {
|
||||||
@@ -778,6 +899,7 @@ it('builds cron worker runtime environment from API runtime keys and cron contex
|
|||||||
$env = $runtimeEnv->invoke($manager, [
|
$env = $runtimeEnv->invoke($manager, [
|
||||||
'app' => 'cron',
|
'app' => 'cron',
|
||||||
'commit_sha' => $selectedCommit,
|
'commit_sha' => $selectedCommit,
|
||||||
|
'coolify_service_uuid' => 'cron-worker-resource-uuid',
|
||||||
], [
|
], [
|
||||||
'coolify_env' => [
|
'coolify_env' => [
|
||||||
'CRON_WORKER_NAME' => 'release-internal-cron-worker',
|
'CRON_WORKER_NAME' => 'release-internal-cron-worker',
|
||||||
@@ -789,6 +911,7 @@ it('builds cron worker runtime environment from API runtime keys and cron contex
|
|||||||
expect($env['CONFIG_DB_HOST'])->toBe('db.example.test');
|
expect($env['CONFIG_DB_HOST'])->toBe('db.example.test');
|
||||||
expect($env['CRON_WORKER_NAME'])->toBe('release-internal-cron-worker');
|
expect($env['CRON_WORKER_NAME'])->toBe('release-internal-cron-worker');
|
||||||
expect($env['CRON_WORKER_SOURCE'])->toBe('coolify_worker');
|
expect($env['CRON_WORKER_SOURCE'])->toBe('coolify_worker');
|
||||||
|
expect($env['CRON_WORKER_COOLIFY_RESOURCE_UUID'])->toBe('cron-worker-resource-uuid');
|
||||||
expect($env['CRON_WORKER_COMMIT_SHA'])->toBe($selectedCommit);
|
expect($env['CRON_WORKER_COMMIT_SHA'])->toBe($selectedCommit);
|
||||||
expect($env['API_COMMIT_SHA'])->toBe($selectedCommit);
|
expect($env['API_COMMIT_SHA'])->toBe($selectedCommit);
|
||||||
expect($env['COMMIT_SHA'])->toBe($selectedCommit);
|
expect($env['COMMIT_SHA'])->toBe($selectedCommit);
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ class SuperuserSystemStatusServiceProbeDouble extends superuser_system_status_se
|
|||||||
return $this->evaluateLicensePlateRecognizerProbeResponse($httpResponse, $label);
|
return $this->evaluateLicensePlateRecognizerProbeResponse($httpResponse, $label);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function evaluateProviderQuotaProbeResponsePublic(array $httpResponse, string $label, string $moduleKey): array
|
||||||
|
{
|
||||||
|
return $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, $moduleKey);
|
||||||
|
}
|
||||||
|
|
||||||
public function normalizeWarningEntriesPublic(array $warnings): array
|
public function normalizeWarningEntriesPublic(array $warnings): array
|
||||||
{
|
{
|
||||||
return $this->normalizeWarningEntries($warnings);
|
return $this->normalizeWarningEntries($warnings);
|
||||||
@@ -393,7 +398,7 @@ it('builds the expected http probe requests for newly supported modules', functi
|
|||||||
null,
|
null,
|
||||||
'GET',
|
'GET',
|
||||||
null,
|
null,
|
||||||
false,
|
true,
|
||||||
],
|
],
|
||||||
'motorapi' => [
|
'motorapi' => [
|
||||||
'probeMotorApiModulePublic',
|
'probeMotorApiModulePublic',
|
||||||
@@ -403,7 +408,7 @@ it('builds the expected http probe requests for newly supported modules', functi
|
|||||||
null,
|
null,
|
||||||
'GET',
|
'GET',
|
||||||
null,
|
null,
|
||||||
false,
|
true,
|
||||||
],
|
],
|
||||||
'fxratesapi' => [
|
'fxratesapi' => [
|
||||||
'probeFxRatesApiModulePublic',
|
'probeFxRatesApiModulePublic',
|
||||||
@@ -581,8 +586,17 @@ it('evaluates license plate recognizer usage and quota health from the info payl
|
|||||||
'unreadable payload' => [
|
'unreadable payload' => [
|
||||||
'not-json',
|
'not-json',
|
||||||
'degraded',
|
'degraded',
|
||||||
'licenseplaterecognizer_usage_unreadable',
|
'provider_quota_unavailable',
|
||||||
null,
|
[
|
||||||
|
'provider' => 'licenseplaterecognizer',
|
||||||
|
'usage_available' => false,
|
||||||
|
'status' => 'unknown',
|
||||||
|
'calls_used' => null,
|
||||||
|
'quota_calls' => null,
|
||||||
|
'calls_remaining' => null,
|
||||||
|
'usage_percent' => null,
|
||||||
|
'unavailable_reason' => 'unreadable_payload',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'missing quota' => [
|
'missing quota' => [
|
||||||
json_encode([
|
json_encode([
|
||||||
@@ -590,9 +604,51 @@ it('evaluates license plate recognizer usage and quota health from the info payl
|
|||||||
'usage' => ['calls' => 10],
|
'usage' => ['calls' => 10],
|
||||||
]),
|
]),
|
||||||
'degraded',
|
'degraded',
|
||||||
'licenseplaterecognizer_usage_missing',
|
'provider_quota_unavailable',
|
||||||
null,
|
[
|
||||||
|
'provider' => 'licenseplaterecognizer',
|
||||||
|
'usage_available' => false,
|
||||||
|
'status' => 'unknown',
|
||||||
|
'calls_used' => null,
|
||||||
|
'quota_calls' => null,
|
||||||
|
'calls_remaining' => null,
|
||||||
|
'usage_percent' => null,
|
||||||
|
'unavailable_reason' => 'missing_limit',
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('degrades provider quota probes when the provider response does not expose readable quota fields', function (
|
||||||
|
string $body,
|
||||||
|
string $expectedReason
|
||||||
|
): void {
|
||||||
|
$service = new SuperuserSystemStatusServiceProbeDouble();
|
||||||
|
|
||||||
|
$result = $service->evaluateProviderQuotaProbeResponsePublic([
|
||||||
|
'checked_at' => '2026-04-08T17:00:00+00:00',
|
||||||
|
'latency_ms' => 7.5,
|
||||||
|
'http_status' => 200,
|
||||||
|
'body' => $body,
|
||||||
|
'error' => '',
|
||||||
|
], 'MailerSend', 'email');
|
||||||
|
|
||||||
|
expect($result['status'])->toBe('degraded')
|
||||||
|
->and($result['status_reason_key'])->toBe('provider_quota_unavailable')
|
||||||
|
->and($result['usage'])->toMatchArray([
|
||||||
|
'provider' => 'email',
|
||||||
|
'usage_available' => false,
|
||||||
|
'status' => 'unknown',
|
||||||
|
'calls_used' => null,
|
||||||
|
'quota_calls' => null,
|
||||||
|
'calls_remaining' => null,
|
||||||
|
'usage_percent' => null,
|
||||||
|
'unavailable_reason' => $expectedReason,
|
||||||
|
]);
|
||||||
|
})->with([
|
||||||
|
'invalid json' => ['not-json', 'unreadable_payload'],
|
||||||
|
'missing limit' => [json_encode(['usage' => ['calls' => 12], 'plan' => 'pro']), 'missing_limit'],
|
||||||
|
'missing usage' => [json_encode(['quota' => 100, 'plan' => 'pro']), 'missing_usage'],
|
||||||
|
'invalid limit' => [json_encode(['used' => 12, 'quota' => 0]), 'invalid_limit'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
it('carries license plate recognizer usage from probe results into module status rows', function (): void {
|
it('carries license plate recognizer usage from probe results into module status rows', function (): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user