fix: prevent metric charts from freezing when navigating with wire:navigate

Wraps inline chart initialization scripts in IIFEs to create local scope for variables. This prevents "Identifier has already been declared" errors when Livewire's SPA navigation re-executes scripts, allowing smooth navigation between metrics pages without page refresh.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-01-02 12:36:17 +01:00
parent 8be1a9b5de
commit a8aa452475
16 changed files with 569 additions and 940 deletions

View File

@@ -6,6 +6,7 @@ use App\Enums\ApplicationDeploymentStatus;
use App\Services\ConfigurationGenerator; use App\Services\ConfigurationGenerator;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasConfiguration; use App\Traits\HasConfiguration;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -111,7 +112,7 @@ use Visus\Cuid2\Cuid2;
class Application extends BaseModel class Application extends BaseModel
{ {
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
private static $parserVersion = '5'; private static $parserVersion = '5';
@@ -1977,54 +1978,6 @@ class Application extends BaseModel
return []; return [];
} }
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
if ($server->isMetricsEnabled()) {
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
if ($server->isMetricsEnabled()) {
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
}
public function getLimits(): array public function getLimits(): array
{ {
return [ return [

View File

@@ -16,6 +16,7 @@ use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable; use App\Notifications\Server\Unreachable;
use App\Services\ConfigurationRepository; use App\Services\ConfigurationRepository;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -103,7 +104,7 @@ use Visus\Cuid2\Cuid2;
class Server extends BaseModel class Server extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, SchemalessAttributesTrait, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes;
public static $batch_counter = 0; public static $batch_counter = 0;
@@ -667,141 +668,6 @@ $schema://$host {
CheckAndStartSentinelJob::dispatch($this); CheckAndStartSentinelJob::dispatch($this);
} }
public function getCpuMetrics(int $mins = 5)
{
if ($this->isMetricsEnabled()) {
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$cpu = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$this->settings->sentinel_token}\" http://localhost:8888/api/cpu/history?from=$from'"], $this, false);
if (str($cpu)->contains('error')) {
$error = json_decode($cpu, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$cpu = json_decode($cpu, true);
$metrics = collect($cpu)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
})->toArray();
// Downsample for intervals > 60 minutes to prevent browser freeze
if ($mins > 60 && count($metrics) > 1000) {
$metrics = $this->downsampleLTTB($metrics, 1000);
}
return collect($metrics);
}
}
public function getMemoryMetrics(int $mins = 5)
{
if ($this->isMetricsEnabled()) {
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$memory = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$this->settings->sentinel_token}\" http://localhost:8888/api/memory/history?from=$from'"], $this, false);
if (str($memory)->contains('error')) {
$error = json_decode($memory, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$memory = json_decode($memory, true);
$metrics = collect($memory)->map(function ($metric) {
$usedPercent = $metric['usedPercent'] ?? 0.0;
return [(int) $metric['time'], (float) $usedPercent];
})->toArray();
// Downsample for intervals > 60 minutes to prevent browser freeze
if ($mins > 60 && count($metrics) > 1000) {
$metrics = $this->downsampleLTTB($metrics, 1000);
}
return collect($metrics);
}
}
/**
* Downsample metrics using the Largest-Triangle-Three-Buckets (LTTB) algorithm.
* This preserves the visual shape of the data better than simple averaging.
*
* @param array $data Array of [timestamp, value] pairs
* @param int $threshold Target number of points
* @return array Downsampled data
*/
private function downsampleLTTB(array $data, int $threshold): array
{
$dataLength = count($data);
// Return unchanged if threshold >= data length, or if threshold <= 2
// (threshold <= 2 would cause division by zero in bucket calculation)
if ($threshold >= $dataLength || $threshold <= 2) {
return $data;
}
$sampled = [];
$sampled[] = $data[0]; // Always keep first point
$bucketSize = ($dataLength - 2) / ($threshold - 2);
$a = 0; // Index of previous selected point
for ($i = 0; $i < $threshold - 2; $i++) {
// Calculate bucket range
$bucketStart = (int) floor(($i + 1) * $bucketSize) + 1;
$bucketEnd = (int) floor(($i + 2) * $bucketSize) + 1;
$bucketEnd = min($bucketEnd, $dataLength - 1);
// Calculate average point for next bucket (used as reference)
$nextBucketStart = (int) floor(($i + 2) * $bucketSize) + 1;
$nextBucketEnd = (int) floor(($i + 3) * $bucketSize) + 1;
$nextBucketEnd = min($nextBucketEnd, $dataLength - 1);
$avgX = 0;
$avgY = 0;
$nextBucketCount = $nextBucketEnd - $nextBucketStart + 1;
if ($nextBucketCount > 0) {
for ($j = $nextBucketStart; $j <= $nextBucketEnd; $j++) {
$avgX += $data[$j][0];
$avgY += $data[$j][1];
}
$avgX /= $nextBucketCount;
$avgY /= $nextBucketCount;
}
// Find point in current bucket with largest triangle area
$maxArea = -1;
$maxAreaIndex = $bucketStart;
$pointAX = $data[$a][0];
$pointAY = $data[$a][1];
for ($j = $bucketStart; $j <= $bucketEnd; $j++) {
// Triangle area calculation
$area = abs(
($pointAX - $avgX) * ($data[$j][1] - $pointAY) -
($pointAX - $data[$j][0]) * ($avgY - $pointAY)
) * 0.5;
if ($area > $maxArea) {
$maxArea = $area;
$maxAreaIndex = $j;
}
}
$sampled[] = $data[$maxAreaIndex];
$a = $maxAreaIndex;
}
$sampled[] = $data[$dataLength - 1]; // Always keep last point
return $sampled;
}
public function getDiskUsage(): ?string public function getDiskUsage(): ?string
{ {
return instant_remote_process(['df / --output=pcent | tr -cd 0-9'], $this, false); return instant_remote_process(['df / --output=pcent | tr -cd 0-9'], $this, false);

View File

@@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneClickhouse extends BaseModel class StandaloneClickhouse extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = []; protected $guarded = [];
@@ -320,50 +321,6 @@ class StandaloneClickhouse extends BaseModel
return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
} }
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable() public function isBackupSolutionAvailable()
{ {
return false; return false;

View File

@@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneDragonfly extends BaseModel class StandaloneDragonfly extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = []; protected $guarded = [];
@@ -316,50 +317,6 @@ class StandaloneDragonfly extends BaseModel
return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
} }
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable() public function isBackupSolutionAvailable()
{ {
return false; return false;

View File

@@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneKeydb extends BaseModel class StandaloneKeydb extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = []; protected $guarded = [];
@@ -316,50 +317,6 @@ class StandaloneKeydb extends BaseModel
return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
} }
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable() public function isBackupSolutionAvailable()
{ {
return false; return false;

View File

@@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMariadb extends BaseModel class StandaloneMariadb extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = []; protected $guarded = [];
@@ -319,50 +320,6 @@ class StandaloneMariadb extends BaseModel
return $this->morphMany(SslCertificate::class, 'resource'); return $this->morphMany(SslCertificate::class, 'resource');
} }
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable() public function isBackupSolutionAvailable()
{ {
return true; return true;

View File

@@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMongodb extends BaseModel class StandaloneMongodb extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = []; protected $guarded = [];
@@ -341,50 +342,6 @@ class StandaloneMongodb extends BaseModel
return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
} }
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable() public function isBackupSolutionAvailable()
{ {
return true; return true;

View File

@@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMysql extends BaseModel class StandaloneMysql extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = []; protected $guarded = [];
@@ -320,50 +321,6 @@ class StandaloneMysql extends BaseModel
return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
} }
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable() public function isBackupSolutionAvailable()
{ {
return true; return true;

View File

@@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandalonePostgresql extends BaseModel class StandalonePostgresql extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = []; protected $guarded = [];
@@ -337,51 +338,4 @@ class StandalonePostgresql extends BaseModel
{ {
return true; return true;
} }
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [
(int) $metric['time'],
(float) ($metric['percent'] ?? 0.0),
];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
} }

View File

@@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneRedis extends BaseModel class StandaloneRedis extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = []; protected $guarded = [];
@@ -332,50 +333,6 @@ class StandaloneRedis extends BaseModel
return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
} }
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable() public function isBackupSolutionAvailable()
{ {
return false; return false;

74
app/Traits/HasMetrics.php Normal file
View File

@@ -0,0 +1,74 @@
<?php
namespace App\Traits;
trait HasMetrics
{
public function getCpuMetrics(int $mins = 5): ?array
{
return $this->getMetrics('cpu', $mins, 'percent');
}
public function getMemoryMetrics(int $mins = 5): ?array
{
$field = $this->isServerMetrics() ? 'usedPercent' : 'used';
return $this->getMetrics('memory', $mins, $field);
}
private function getMetrics(string $type, int $mins, string $valueField): ?array
{
$server = $this->getMetricsServer();
if (! $server->isMetricsEnabled()) {
return null;
}
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$endpoint = $this->getMetricsEndpoint($type, $from);
$response = instant_remote_process(
["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" {$endpoint}'"],
$server,
false
);
if (str($response)->contains('error')) {
$error = json_decode($response, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = collect(json_decode($response, true))->map(function ($metric) use ($valueField) {
return [(int) $metric['time'], (float) ($metric[$valueField] ?? 0.0)];
})->toArray();
if ($mins > 60 && count($metrics) > 1000) {
$metrics = downsampleLTTB($metrics, 1000);
}
return $metrics;
}
private function isServerMetrics(): bool
{
return $this instanceof \App\Models\Server;
}
private function getMetricsServer(): \App\Models\Server
{
return $this->isServerMetrics() ? $this : $this->destination->server;
}
private function getMetricsEndpoint(string $type, string $from): string
{
$base = 'http://localhost:8888/api';
if ($this->isServerMetrics()) {
return "{$base}/{$type}/history?from={$from}";
}
return "{$base}/container/{$this->uuid}/{$type}/history?from={$from}";
}
}

View File

@@ -3416,3 +3416,81 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon
return true; return true;
} }
/**
* Downsample metrics using the Largest-Triangle-Three-Buckets (LTTB) algorithm.
* This preserves the visual shape of the data better than simple averaging.
*
* @param array $data Array of [timestamp, value] pairs
* @param int $threshold Target number of points
* @return array Downsampled data
*/
function downsampleLTTB(array $data, int $threshold): array
{
$dataLength = count($data);
// Return unchanged if threshold >= data length, or if threshold <= 2
// (threshold <= 2 would cause division by zero in bucket calculation)
if ($threshold >= $dataLength || $threshold <= 2) {
return $data;
}
$sampled = [];
$sampled[] = $data[0]; // Always keep first point
$bucketSize = ($dataLength - 2) / ($threshold - 2);
$a = 0; // Index of previous selected point
for ($i = 0; $i < $threshold - 2; $i++) {
// Calculate bucket range
$bucketStart = (int) floor(($i + 1) * $bucketSize) + 1;
$bucketEnd = (int) floor(($i + 2) * $bucketSize) + 1;
$bucketEnd = min($bucketEnd, $dataLength - 1);
// Calculate average point for next bucket (used as reference)
$nextBucketStart = (int) floor(($i + 2) * $bucketSize) + 1;
$nextBucketEnd = (int) floor(($i + 3) * $bucketSize) + 1;
$nextBucketEnd = min($nextBucketEnd, $dataLength - 1);
$avgX = 0;
$avgY = 0;
$nextBucketCount = $nextBucketEnd - $nextBucketStart + 1;
if ($nextBucketCount > 0) {
for ($j = $nextBucketStart; $j <= $nextBucketEnd; $j++) {
$avgX += $data[$j][0];
$avgY += $data[$j][1];
}
$avgX /= $nextBucketCount;
$avgY /= $nextBucketCount;
}
// Find point in current bucket with largest triangle area
$maxArea = -1;
$maxAreaIndex = $bucketStart;
$pointAX = $data[$a][0];
$pointAY = $data[$a][1];
for ($j = $bucketStart; $j <= $bucketEnd; $j++) {
// Triangle area calculation
$area = abs(
($pointAX - $avgX) * ($data[$j][1] - $pointAY) -
($pointAX - $data[$j][0]) * ($avgY - $pointAY)
) * 0.5;
if ($area > $maxArea) {
$maxArea = $area;
$maxAreaIndex = $j;
}
}
$sampled[] = $data[$maxAreaIndex];
$a = $maxAreaIndex;
}
$sampled[] = $data[$dataLength - 1]; // Always keep last point
return $sampled;
}

View File

@@ -29,6 +29,7 @@
<div wire:ignore id="{!! $chartId !!}-cpu"></div> <div wire:ignore id="{!! $chartId !!}-cpu"></div>
<script> <script>
(function() {
checkTheme(); checkTheme();
const optionsServerCpu = { const optionsServerCpu = {
stroke: { stroke: {
@@ -149,12 +150,14 @@
} }
}); });
}); });
})();
</script> </script>
<h4>Memory Usage</h4> <h4>Memory Usage</h4>
<div wire:ignore id="{!! $chartId !!}-memory"></div> <div wire:ignore id="{!! $chartId !!}-memory"></div>
<script> <script>
(function() {
checkTheme(); checkTheme();
const optionsServerMemory = { const optionsServerMemory = {
stroke: { stroke: {
@@ -283,6 +286,7 @@
} }
}); });
}); });
})();
</script> </script>
</div> </div>
</div> </div>

View File

@@ -23,6 +23,7 @@
<div wire:ignore id="{!! $chartId !!}-cpu"></div> <div wire:ignore id="{!! $chartId !!}-cpu"></div>
<script> <script>
(function() {
checkTheme(); checkTheme();
const optionsServerCpu = { const optionsServerCpu = {
stroke: { stroke: {
@@ -146,6 +147,7 @@
}); });
}); });
}); });
})();
</script> </script>
<div> <div>
@@ -153,6 +155,7 @@
<div wire:ignore id="{!! $chartId !!}-memory"></div> <div wire:ignore id="{!! $chartId !!}-memory"></div>
<script> <script>
(function() {
checkTheme(); checkTheme();
const optionsServerMemory = { const optionsServerMemory = {
stroke: { stroke: {
@@ -283,6 +286,7 @@
}); });
}); });
}); });
})();
</script> </script>
</div> </div>

View File

@@ -3,7 +3,7 @@
use App\Models\StandaloneClickhouse; use App\Models\StandaloneClickhouse;
test('clickhouse uses clickhouse_db field in internal connection string', function () { test('clickhouse uses clickhouse_db field in internal connection string', function () {
$clickhouse = new StandaloneClickhouse(); $clickhouse = new StandaloneClickhouse;
$clickhouse->clickhouse_admin_user = 'testuser'; $clickhouse->clickhouse_admin_user = 'testuser';
$clickhouse->clickhouse_admin_password = 'testpass'; $clickhouse->clickhouse_admin_password = 'testpass';
$clickhouse->clickhouse_db = 'mydb'; $clickhouse->clickhouse_db = 'mydb';
@@ -18,7 +18,7 @@ test('clickhouse uses clickhouse_db field in internal connection string', functi
}); });
test('clickhouse defaults to default database when clickhouse_db is null', function () { test('clickhouse defaults to default database when clickhouse_db is null', function () {
$clickhouse = new StandaloneClickhouse(); $clickhouse = new StandaloneClickhouse;
$clickhouse->clickhouse_admin_user = 'testuser'; $clickhouse->clickhouse_admin_user = 'testuser';
$clickhouse->clickhouse_admin_password = 'testpass'; $clickhouse->clickhouse_admin_password = 'testpass';
$clickhouse->clickhouse_db = null; $clickhouse->clickhouse_db = null;
@@ -30,7 +30,7 @@ test('clickhouse defaults to default database when clickhouse_db is null', funct
}); });
test('clickhouse external url uses correct database', function () { test('clickhouse external url uses correct database', function () {
$clickhouse = new StandaloneClickhouse(); $clickhouse = new StandaloneClickhouse;
$clickhouse->clickhouse_admin_user = 'admin'; $clickhouse->clickhouse_admin_user = 'admin';
$clickhouse->clickhouse_admin_password = 'secret'; $clickhouse->clickhouse_admin_password = 'secret';
$clickhouse->clickhouse_db = 'production'; $clickhouse->clickhouse_db = 'production';
@@ -38,12 +38,19 @@ test('clickhouse external url uses correct database', function () {
$clickhouse->is_public = true; $clickhouse->is_public = true;
$clickhouse->public_port = 8123; $clickhouse->public_port = 8123;
$clickhouse->destination = new class { $clickhouse->destination = new class
{
public $server; public $server;
public function __construct() {
$this->server = new class { public function __construct()
public function __get($name) { {
if ($name === 'getIp') return '1.2.3.4'; $this->server = new class
{
public function __get($name)
{
if ($name === 'getIp') {
return '1.2.3.4';
}
} }
}; };
} }

View File

@@ -1,19 +1,9 @@
<?php <?php
use App\Models\Server;
/** /**
* Helper to call the private downsampleLTTB method on Server model via reflection. * Tests for the downsampleLTTB helper function used for metrics downsampling.
* This function implements the Largest-Triangle-Three-Buckets algorithm.
*/ */
function callDownsampleLTTB(array $data, int $threshold): array
{
$server = new Server;
$reflection = new ReflectionClass($server);
$method = $reflection->getMethod('downsampleLTTB');
return $method->invoke($server, $data, $threshold);
}
it('returns data unchanged when below threshold', function () { it('returns data unchanged when below threshold', function () {
$data = [ $data = [
[1000, 10.5], [1000, 10.5],
@@ -21,7 +11,7 @@ it('returns data unchanged when below threshold', function () {
[3000, 15.7], [3000, 15.7],
]; ];
$result = callDownsampleLTTB($data, 1000); $result = downsampleLTTB($data, 1000);
expect($result)->toBe($data); expect($result)->toBe($data);
}); });
@@ -35,10 +25,10 @@ it('returns data unchanged when threshold is 2 or less', function () {
[5000, 12.0], [5000, 12.0],
]; ];
$result = callDownsampleLTTB($data, 2); $result = downsampleLTTB($data, 2);
expect($result)->toBe($data); expect($result)->toBe($data);
$result = callDownsampleLTTB($data, 1); $result = downsampleLTTB($data, 1);
expect($result)->toBe($data); expect($result)->toBe($data);
}); });
@@ -52,7 +42,7 @@ it('downsamples to target threshold count', function () {
$data[] = [$i * 1000, mt_rand(0, 100) / 10]; $data[] = [$i * 1000, mt_rand(0, 100) / 10];
} }
$result = callDownsampleLTTB($data, 10); $result = downsampleLTTB($data, 10);
expect(count($result))->toBe(10); expect(count($result))->toBe(10);
}); });
@@ -63,7 +53,7 @@ it('preserves first and last data points', function () {
$data[] = [$i * 1000, $i * 1.5]; $data[] = [$i * 1000, $i * 1.5];
} }
$result = callDownsampleLTTB($data, 20); $result = downsampleLTTB($data, 20);
// First point should be preserved // First point should be preserved
expect($result[0])->toBe($data[0]); expect($result[0])->toBe($data[0]);
@@ -78,7 +68,7 @@ it('maintains chronological order', function () {
$data[] = [$i * 60000, sin($i / 10) * 50 + 50]; // Sine wave pattern $data[] = [$i * 60000, sin($i / 10) * 50 + 50]; // Sine wave pattern
} }
$result = callDownsampleLTTB($data, 50); $result = downsampleLTTB($data, 50);
// Verify all timestamps are in non-decreasing order // Verify all timestamps are in non-decreasing order
$previousTimestamp = -1; $previousTimestamp = -1;
@@ -100,7 +90,7 @@ it('handles large datasets efficiently', function () {
} }
$startTime = microtime(true); $startTime = microtime(true);
$result = callDownsampleLTTB($data, 1000); $result = downsampleLTTB($data, 1000);
$executionTime = microtime(true) - $startTime; $executionTime = microtime(true) - $startTime;
expect(count($result))->toBe(1000); expect(count($result))->toBe(1000);
@@ -121,7 +111,7 @@ it('preserves peaks and valleys in data', function () {
$data[] = [$i * 1000, $value]; $data[] = [$i * 1000, $value];
} }
$result = callDownsampleLTTB($data, 20); $result = downsampleLTTB($data, 20);
// The peak (100) and valley (0) should be preserved due to LTTB algorithm // The peak (100) and valley (0) should be preserved due to LTTB algorithm
$values = array_column($result, 1); $values = array_column($result, 1);