Fix standalone database "restarting" status flickering and add restart tracking

- Fix status flickering: Track databases in active/transient states (restarting, starting, created, paused) not just running
- Add isActiveOrTransient() helper to distinguish between active states and terminal states (exited, dead)
- Add safeguard: Protect updateNotFoundDatabaseStatus() from marking as exited when containers collection is empty
- Add restart_count tracking: New migration adds restart_count, last_restart_at, last_restart_type to all standalone database tables
- Update 8 database models with $casts for new restart tracking fields
- Update GetContainersStatus to extract RestartCount from Docker and update database models
- Reset restart tracking when database exits completely

🤖 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 2025-12-17 16:25:41 +01:00
parent 20c6f61858
commit 6d47d24169
13 changed files with 194 additions and 17 deletions

View file

@ -199,10 +199,33 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
$isPublic = data_get($database, 'is_public');
$foundDatabases[] = $database->id;
$statusFromDb = $database->status;
// Track restart count for databases (single-container)
$restartCount = data_get($container, 'RestartCount', 0);
$previousRestartCount = $database->restart_count ?? 0;
if ($statusFromDb !== $containerStatus) {
$database->update(['status' => $containerStatus]);
$updateData = ['status' => $containerStatus];
// Update restart tracking if restart count increased
if ($restartCount > $previousRestartCount) {
$updateData['restart_count'] = $restartCount;
$updateData['last_restart_at'] = now();
$updateData['last_restart_type'] = 'crash';
}
$database->update($updateData);
} else {
$database->update(['last_online_at' => now()]);
$updateData = ['last_online_at' => now()];
// Update restart tracking even if status unchanged
if ($restartCount > $previousRestartCount) {
$updateData['restart_count'] = $restartCount;
$updateData['last_restart_at'] = now();
$updateData['last_restart_type'] = 'crash';
}
$database->update($updateData);
}
if ($isPublic) {
@ -365,7 +388,13 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
if (str($database->status)->startsWith('exited')) {
continue;
}
$database->update(['status' => 'exited']);
// Reset restart tracking when database exits completely
$database->update([
'status' => 'exited',
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
$name = data_get($database, 'name');
$fqdn = data_get($database, 'fqdn');

View file

@ -237,8 +237,9 @@ public function handle()
$this->foundProxy = true;
} elseif ($type === 'service' && $this->isRunning($containerStatus)) {
} else {
if ($this->allDatabaseUuids->contains($uuid) && $this->isRunning($containerStatus)) {
if ($this->allDatabaseUuids->contains($uuid) && $this->isActiveOrTransient($containerStatus)) {
$this->foundDatabaseUuids->push($uuid);
// TCP proxy should only be started/managed when database is actually running
if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) {
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true);
} else {
@ -503,20 +504,28 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta
private function updateNotFoundDatabaseStatus()
{
$notFoundDatabaseUuids = $this->allDatabaseUuids->diff($this->foundDatabaseUuids);
if ($notFoundDatabaseUuids->isNotEmpty()) {
$notFoundDatabaseUuids->each(function ($databaseUuid) {
$database = $this->databases->where('uuid', $databaseUuid)->first();
if ($database) {
if ($database->status !== 'exited') {
$database->status = 'exited';
$database->save();
}
if ($database->is_public) {
StopDatabaseProxy::dispatch($database);
}
}
});
if ($notFoundDatabaseUuids->isEmpty()) {
return;
}
// Only protection: Verify we received any container data at all
// If containers collection is completely empty, Sentinel might have failed
if ($this->containers->isEmpty()) {
return;
}
$notFoundDatabaseUuids->each(function ($databaseUuid) {
$database = $this->databases->where('uuid', $databaseUuid)->first();
if ($database) {
if (! str($database->status)->startsWith('exited')) {
$database->status = 'exited';
$database->save();
}
if ($database->is_public) {
StopDatabaseProxy::dispatch($database);
}
}
});
}
private function updateServiceSubStatus(string $serviceId, string $subType, string $subId, string $containerStatus)
@ -576,6 +585,23 @@ private function isRunning(string $containerStatus)
return str($containerStatus)->contains('running');
}
/**
* Check if container is in an active or transient state.
* Active states: running
* Transient states: restarting, starting, created, paused
*
* These states indicate the container exists and should be tracked.
* Terminal states (exited, dead, removing) should NOT be tracked.
*/
private function isActiveOrTransient(string $containerStatus): bool
{
return str($containerStatus)->contains('running') ||
str($containerStatus)->contains('restarting') ||
str($containerStatus)->contains('starting') ||
str($containerStatus)->contains('created') ||
str($containerStatus)->contains('paused');
}
private function checkLogDrainContainer()
{
if ($this->server->isLogDrainEnabled() && $this->foundLogDrainContainer === false) {

View file

@ -18,6 +18,8 @@ class StandaloneClickhouse extends BaseModel
protected $casts = [
'clickhouse_password' => 'encrypted',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
];
protected static function booted()
@ -247,6 +249,7 @@ protected function internalDbUrl(): Attribute
$encodedUser = rawurlencode($this->clickhouse_admin_user);
$encodedPass = rawurlencode($this->clickhouse_admin_password);
$database = $this->clickhouse_db ?? 'default';
return "clickhouse://{$encodedUser}:{$encodedPass}@{$this->uuid}:9000/{$database}";
},
);
@ -264,6 +267,7 @@ protected function externalDbUrl(): Attribute
$encodedUser = rawurlencode($this->clickhouse_admin_user);
$encodedPass = rawurlencode($this->clickhouse_admin_password);
$database = $this->clickhouse_db ?? 'default';
return "clickhouse://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$database}";
}

View file

@ -18,6 +18,8 @@ class StandaloneDragonfly extends BaseModel
protected $casts = [
'dragonfly_password' => 'encrypted',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
];
protected static function booted()

View file

@ -18,6 +18,8 @@ class StandaloneKeydb extends BaseModel
protected $casts = [
'keydb_password' => 'encrypted',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
];
protected static function booted()

View file

@ -19,6 +19,8 @@ class StandaloneMariadb extends BaseModel
protected $casts = [
'mariadb_password' => 'encrypted',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
];
protected static function booted()

View file

@ -16,6 +16,11 @@ class StandaloneMongodb extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
];
protected static function booted()
{
static::created(function ($database) {

View file

@ -19,6 +19,8 @@ class StandaloneMysql extends BaseModel
protected $casts = [
'mysql_password' => 'encrypted',
'mysql_root_password' => 'encrypted',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
];
protected static function booted()

View file

@ -19,6 +19,8 @@ class StandalonePostgresql extends BaseModel
protected $casts = [
'init_scripts' => 'array',
'postgres_password' => 'encrypted',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
];
protected static function booted()

View file

@ -16,6 +16,11 @@ class StandaloneRedis extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
];
protected static function booted()
{
static::created(function ($database) {

View file

@ -0,0 +1,66 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* The standalone database tables to add restart tracking columns to.
*/
private array $tables = [
'standalone_postgresqls',
'standalone_mysqls',
'standalone_mariadbs',
'standalone_redis',
'standalone_mongodbs',
'standalone_keydbs',
'standalone_dragonflies',
'standalone_clickhouses',
];
/**
* Run the migrations.
*/
public function up(): void
{
foreach ($this->tables as $table) {
if (! Schema::hasColumn($table, 'restart_count')) {
Schema::table($table, function (Blueprint $blueprint) {
$blueprint->integer('restart_count')->default(0)->after('status');
});
}
if (! Schema::hasColumn($table, 'last_restart_at')) {
Schema::table($table, function (Blueprint $blueprint) {
$blueprint->timestamp('last_restart_at')->nullable()->after('restart_count');
});
}
if (! Schema::hasColumn($table, 'last_restart_type')) {
Schema::table($table, function (Blueprint $blueprint) {
$blueprint->string('last_restart_type', 10)->nullable()->after('last_restart_at');
});
}
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$columns = ['restart_count', 'last_restart_at', 'last_restart_type'];
foreach ($this->tables as $table) {
foreach ($columns as $column) {
if (Schema::hasColumn($table, $column)) {
Schema::table($table, function (Blueprint $blueprint) use ($column) {
$blueprint->dropColumn($column);
});
}
}
}
}
};

View file

@ -3953,6 +3953,22 @@
"logo": "svgs/default.webp",
"minversion": "0.0.0"
},
"soju": {
"documentation": "https://soju.im/?utm_source=coolify.io",
"slogan": "A user-friendly IRC bouncer with a modern web interface",
"compose": "c2VydmljZXM6CiAgc29qdToKICAgIGltYWdlOiAnY29kZWJlcmcub3JnL2VtZXJzaW9uL3NvanU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc29qdS1kYjovZGInCiAgICAgIC0gJ3NvanUtdXBsb2FkczovdXBsb2FkcycKICAgICAgLSAnc29qdS1ydW46L3J1bi9zb2p1JwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9zb2p1L2NvbmZpZwogICAgICAgIHRhcmdldDogL3NvanUtY29uZmlnCiAgICAgICAgY29udGVudDogImRiIHNxbGl0ZTMgL2RiL21haW4uZGJcbm1lc3NhZ2Utc3RvcmUgZGJcbmZpbGUtdXBsb2FkIGZzIC91cGxvYWRzL1xubGlzdGVuIGlyYytpbnNlY3VyZTovLzAuMC4wLjA6NjY2N1xubGlzdGVuIHdzK2luc2VjdXJlOi8vMC4wLjAuMDo4MFxubGlzdGVuIHVuaXgrYWRtaW46Ly8vcnVuL3NvanUvYWRtaW5cbiIKICAgIG5ldHdvcmtzOgogICAgICBkZWZhdWx0OgogICAgICAgIGFsaWFzZXM6CiAgICAgICAgICAtIGdhbWphLWJhY2tlbmQKICBnYW1qYToKICAgIGltYWdlOiAnY29kZWJlcmcub3JnL2VtZXJzaW9uL2dhbWphOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9HQU1KQV84MAogICAgZGVwZW5kc19vbjoKICAgICAgLSBzb2p1CnZvbHVtZXM6CiAgc29qdS1kYjogbnVsbAogIHNvanUtdXBsb2FkczogbnVsbAogIHNvanUtcnVuOiBudWxsCg==",
"tags": [
"irc",
"bouncer",
"chat",
"messaging",
"relay"
],
"category": "communication",
"logo": "svgs/soju.svg",
"minversion": "0.0.0",
"port": "80"
},
"soketi": {
"documentation": "https://docs.soketi.app?utm_source=coolify.io",
"slogan": "Soketi is your simple, fast, and resilient open-source WebSockets server.",

View file

@ -3953,6 +3953,22 @@
"logo": "svgs/default.webp",
"minversion": "0.0.0"
},
"soju": {
"documentation": "https://soju.im/?utm_source=coolify.io",
"slogan": "A user-friendly IRC bouncer with a modern web interface",
"compose": "c2VydmljZXM6CiAgc29qdToKICAgIGltYWdlOiAnY29kZWJlcmcub3JnL2VtZXJzaW9uL3NvanU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc29qdS1kYjovZGInCiAgICAgIC0gJ3NvanUtdXBsb2FkczovdXBsb2FkcycKICAgICAgLSAnc29qdS1ydW46L3J1bi9zb2p1JwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9zb2p1L2NvbmZpZwogICAgICAgIHRhcmdldDogL3NvanUtY29uZmlnCiAgICAgICAgY29udGVudDogImRiIHNxbGl0ZTMgL2RiL21haW4uZGJcbm1lc3NhZ2Utc3RvcmUgZGJcbmZpbGUtdXBsb2FkIGZzIC91cGxvYWRzL1xubGlzdGVuIGlyYytpbnNlY3VyZTovLzAuMC4wLjA6NjY2N1xubGlzdGVuIHdzK2luc2VjdXJlOi8vMC4wLjAuMDo4MFxubGlzdGVuIHVuaXgrYWRtaW46Ly8vcnVuL3NvanUvYWRtaW5cbiIKICAgIG5ldHdvcmtzOgogICAgICBkZWZhdWx0OgogICAgICAgIGFsaWFzZXM6CiAgICAgICAgICAtIGdhbWphLWJhY2tlbmQKICBnYW1qYToKICAgIGltYWdlOiAnY29kZWJlcmcub3JnL2VtZXJzaW9uL2dhbWphOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9HQU1KQV84MAogICAgZGVwZW5kc19vbjoKICAgICAgLSBzb2p1CnZvbHVtZXM6CiAgc29qdS1kYjogbnVsbAogIHNvanUtdXBsb2FkczogbnVsbAogIHNvanUtcnVuOiBudWxsCg==",
"tags": [
"irc",
"bouncer",
"chat",
"messaging",
"relay"
],
"category": "communication",
"logo": "svgs/soju.svg",
"minversion": "0.0.0",
"port": "80"
},
"soketi": {
"documentation": "https://docs.soketi.app?utm_source=coolify.io",
"slogan": "Soketi is your simple, fast, and resilient open-source WebSockets server.",