fix: prevent metric charts from freezing on page navigation (#7848)

This commit is contained in:
Andras Bacsai 2026-01-02 13:13:45 +01:00 committed by GitHub
commit b448b08058
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 568 additions and 943 deletions

View file

@ -6,6 +6,7 @@
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 @@
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 @@ public static function getDomainsByUuid(string $uuid): array
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\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 @@
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 @@ public function checkSentinel()
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 @@
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 @@ public function scheduledBackups()
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 @@
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 @@ public function scheduledBackups()
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 @@
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 @@ public function scheduledBackups()
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 @@
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 @@ public function sslCertificates()
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 @@
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 @@ public function scheduledBackups()
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 @@
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 @@ public function scheduledBackups()
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 @@
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 @@ public function isBackupSolutionAvailable()
{ {
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 @@
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 @@ public function scheduledBackups()
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,230 +29,179 @@ class="pt-5">
<div wire:ignore id="{!! $chartId !!}-cpu"></div> <div wire:ignore id="{!! $chartId !!}-cpu"></div>
<script> <script>
checkTheme(); (function() {
const optionsServerCpu = { checkTheme();
stroke: { const optionsServerCpu = {
curve: 'straight', stroke: {
width: 2, curve: 'straight',
}, width: 2,
chart: { },
height: '150px', chart: {
id: '{!! $chartId !!}-cpu', height: '150px',
type: 'area', id: '{!! $chartId !!}-cpu',
toolbar: { type: 'area',
show: true, toolbar: {
tools: { show: true,
download: false, tools: {
selection: false, download: false,
zoom: true, selection: false,
zoomin: false, zoom: true,
zoomout: false, zoomin: false,
pan: false, zoomout: false,
reset: true pan: false,
reset: true
},
},
animations: {
enabled: true,
}, },
}, },
animations: { fill: {
enabled: true, type: 'gradient',
}, },
}, dataLabels: {
fill: {
type: 'gradient',
},
dataLabels: {
enabled: false,
offsetY: -10,
style: {
colors: ['#FCD452'],
},
background: {
enabled: false, enabled: false,
} offsetY: -10,
}, style: {
grid: { colors: ['#FCD452'],
show: true, },
borderColor: '', background: {
}, enabled: false,
colors: [cpuColor], }
xaxis: { },
type: 'datetime', grid: {
}, show: true,
series: [{ borderColor: '',
name: "CPU %",
data: []
}],
noData: {
text: 'Loading...',
style: {
color: textColor,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
}, },
custom: function({ series, seriesIndex, dataPointIndex, w }) { colors: [cpuColor],
const value = series[seriesIndex][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const date = new Date(timestamp);
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
date.getUTCFullYear() + '-' +
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
},
legend: {
show: false
}
}
const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`), optionsServerCpu);
serverCpuChart.render();
Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => {
checkTheme();
serverCpuChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [cpuColor],
xaxis: { xaxis: {
type: 'datetime', type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
}, },
yaxis: { series: [{
show: true, name: "CPU %",
labels: { data: []
show: true, }],
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' %';
}
}
},
noData: { noData: {
text: 'Loading...', text: 'Loading...',
style: { style: {
color: textColor, color: textColor,
} }
},
tooltip: {
enabled: true,
marker: {
show: false,
},
custom: function({ series, seriesIndex, dataPointIndex, w }) {
const value = series[seriesIndex][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const date = new Date(timestamp);
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
date.getUTCFullYear() + '-' +
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
},
legend: {
show: false
} }
}
const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`), optionsServerCpu);
serverCpuChart.render();
Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => {
checkTheme();
serverCpuChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [cpuColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
yaxis: {
show: true,
labels: {
show: true,
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' %';
}
}
},
noData: {
text: 'Loading...',
style: {
color: textColor,
}
}
});
}); });
}); })();
</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>
checkTheme(); (function() {
const optionsServerMemory = { checkTheme();
stroke: { const optionsServerMemory = {
curve: 'straight', stroke: {
width: 2, curve: 'straight',
}, width: 2,
chart: { },
height: '150px', chart: {
id: '{!! $chartId !!}-memory', height: '150px',
type: 'area', id: '{!! $chartId !!}-memory',
toolbar: { type: 'area',
show: true, toolbar: {
tools: { show: true,
download: false, tools: {
selection: false, download: false,
zoom: true, selection: false,
zoomin: false, zoom: true,
zoomout: false, zoomin: false,
pan: false, zoomout: false,
reset: true pan: false,
reset: true
},
},
animations: {
enabled: true,
}, },
}, },
animations: { fill: {
enabled: true, type: 'gradient',
}, },
}, dataLabels: {
fill: {
type: 'gradient',
},
dataLabels: {
enabled: false,
offsetY: -10,
style: {
colors: ['#FCD452'],
},
background: {
enabled: false, enabled: false,
} offsetY: -10,
}, style: {
grid: { colors: ['#FCD452'],
show: true, },
borderColor: '', background: {
}, enabled: false,
colors: [ramColor], }
xaxis: { },
type: 'datetime', grid: {
labels: {
show: true, show: true,
style: { borderColor: '',
colors: textColor,
}
}
},
series: [{
name: "Memory (MB)",
data: []
}],
noData: {
text: 'Loading...',
style: {
color: textColor,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
}, },
custom: function({ series, seriesIndex, dataPointIndex, w }) { colors: [ramColor],
const value = series[seriesIndex][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const date = new Date(timestamp);
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
date.getUTCFullYear() + '-' +
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">' + value + ' MB</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
},
legend: {
show: false
}
}
const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`),
optionsServerMemory);
serverMemoryChart.render();
Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => {
checkTheme();
serverMemoryChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [ramColor],
xaxis: { xaxis: {
type: 'datetime', type: 'datetime',
labels: { labels: {
@ -262,27 +211,82 @@ class="pt-5">
} }
} }
}, },
yaxis: { series: [{
min: 0, name: "Memory (MB)",
show: true, data: []
labels: { }],
show: true,
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' MB';
}
}
},
noData: { noData: {
text: 'Loading...', text: 'Loading...',
style: { style: {
color: textColor, color: textColor,
} }
},
tooltip: {
enabled: true,
marker: {
show: false,
},
custom: function({ series, seriesIndex, dataPointIndex, w }) {
const value = series[seriesIndex][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const date = new Date(timestamp);
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
date.getUTCFullYear() + '-' +
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">' + value + ' MB</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
},
legend: {
show: false
} }
}
const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`),
optionsServerMemory);
serverMemoryChart.render();
Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => {
checkTheme();
serverMemoryChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [ramColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
yaxis: {
min: 0,
show: true,
labels: {
show: true,
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' MB';
}
}
},
noData: {
text: 'Loading...',
style: {
color: textColor,
}
}
});
}); });
}); })();
</script> </script>
</div> </div>
</div> </div>

View file

@ -23,145 +23,16 @@
<div wire:ignore id="{!! $chartId !!}-cpu"></div> <div wire:ignore id="{!! $chartId !!}-cpu"></div>
<script> <script>
checkTheme(); (function() {
const optionsServerCpu = {
stroke: {
curve: 'straight',
width: 2,
},
chart: {
height: '150px',
id: '{!! $chartId !!}-cpu',
type: 'area',
toolbar: {
show: true,
tools: {
download: false,
selection: false,
zoom: true,
zoomin: false,
zoomout: false,
pan: false,
reset: true
},
},
animations: {
enabled: true,
},
},
fill: {
type: 'gradient',
},
dataLabels: {
enabled: false,
offsetY: -10,
style: {
colors: ['#FCD452'],
},
background: {
enabled: false,
}
},
grid: {
show: true,
borderColor: '',
},
colors: [cpuColor],
xaxis: {
type: 'datetime',
},
series: [{
name: 'CPU %',
data: []
}],
noData: {
text: 'Loading...',
style: {
color: textColor,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
},
custom: function({ series, seriesIndex, dataPointIndex, w }) {
const value = series[seriesIndex][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const date = new Date(timestamp);
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
date.getUTCFullYear() + '-' +
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
},
legend: {
show: false
}
}
const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`),
optionsServerCpu);
serverCpuChart.render();
document.addEventListener('livewire:init', () => {
Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => {
checkTheme();
serverCpuChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [cpuColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
yaxis: {
show: true,
labels: {
show: true,
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' %';
}
}
},
noData: {
text: 'Loading...',
style: {
color: textColor,
}
}
});
});
});
</script>
<div>
<h4>Memory Usage</h4>
<div wire:ignore id="{!! $chartId !!}-memory"></div>
<script>
checkTheme(); checkTheme();
const optionsServerMemory = { const optionsServerCpu = {
stroke: { stroke: {
curve: 'straight', curve: 'straight',
width: 2, width: 2,
}, },
chart: { chart: {
height: '150px', height: '150px',
id: '{!! $chartId !!}-memory', id: '{!! $chartId !!}-cpu',
type: 'area', type: 'area',
toolbar: { toolbar: {
show: true, show: true,
@ -196,18 +67,12 @@
show: true, show: true,
borderColor: '', borderColor: '',
}, },
colors: [ramColor], colors: [cpuColor],
xaxis: { xaxis: {
type: 'datetime', type: 'datetime',
labels: { },
show: true, series: [{
style: { name: 'CPU %',
colors: textColor,
}
}
},
series: [{
name: "Memory (%)",
data: [] data: []
}], }],
noData: { noData: {
@ -232,7 +97,7 @@
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' + String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0'); String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' + return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' + '<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' + '<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>'; '</div>';
} }
@ -241,17 +106,16 @@
show: false show: false
} }
} }
const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`), const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`),
optionsServerMemory); optionsServerCpu);
serverMemoryChart.render(); serverCpuChart.render();
document.addEventListener('livewire:init', () => { Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => {
Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => { checkTheme();
checkTheme(); serverCpuChart.updateOptions({
serverMemoryChart.updateOptions({
series: [{ series: [{
data: chartData[0].seriesData, data: chartData[0].seriesData,
}], }],
colors: [ramColor], colors: [cpuColor],
xaxis: { xaxis: {
type: 'datetime', type: 'datetime',
labels: { labels: {
@ -262,16 +126,15 @@
} }
}, },
yaxis: { yaxis: {
min: 0,
show: true, show: true,
labels: { labels: {
show: true, show: true,
style: { style: {
colors: textColor, colors: textColor,
}, },
formatter: function(value) { formatter: function(value) {
return Math.round(value) + ' %'; return Math.round(value) + ' %';
} }
} }
}, },
noData: { noData: {
@ -282,7 +145,144 @@
} }
}); });
}); });
}); })();
</script>
<div>
<h4>Memory Usage</h4>
<div wire:ignore id="{!! $chartId !!}-memory"></div>
<script>
(function() {
checkTheme();
const optionsServerMemory = {
stroke: {
curve: 'straight',
width: 2,
},
chart: {
height: '150px',
id: '{!! $chartId !!}-memory',
type: 'area',
toolbar: {
show: true,
tools: {
download: false,
selection: false,
zoom: true,
zoomin: false,
zoomout: false,
pan: false,
reset: true
},
},
animations: {
enabled: true,
},
},
fill: {
type: 'gradient',
},
dataLabels: {
enabled: false,
offsetY: -10,
style: {
colors: ['#FCD452'],
},
background: {
enabled: false,
}
},
grid: {
show: true,
borderColor: '',
},
colors: [ramColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
series: [{
name: "Memory (%)",
data: []
}],
noData: {
text: 'Loading...',
style: {
color: textColor,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
},
custom: function({ series, seriesIndex, dataPointIndex, w }) {
const value = series[seriesIndex][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const date = new Date(timestamp);
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
date.getUTCFullYear() + '-' +
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
},
legend: {
show: false
}
}
const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`),
optionsServerMemory);
serverMemoryChart.render();
Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => {
checkTheme();
serverMemoryChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [ramColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
yaxis: {
min: 0,
show: true,
labels: {
show: true,
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' %';
}
}
},
noData: {
text: 'Loading...',
style: {
color: textColor,
}
}
});
});
})();
</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 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 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 @@
$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 @@ function callDownsampleLTTB(array $data, int $threshold): array
[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 @@ function callDownsampleLTTB(array $data, int $threshold): array
[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 @@ function callDownsampleLTTB(array $data, int $threshold): array
$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 @@ function callDownsampleLTTB(array $data, int $threshold): array
$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 @@ function callDownsampleLTTB(array $data, int $threshold): array
$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 @@ function callDownsampleLTTB(array $data, int $threshold): array
} }
$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 @@ function callDownsampleLTTB(array $data, int $threshold): array
$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);