diff --git a/app/Filament/Widgets/DokployPlatformHealth.php b/app/Filament/Widgets/DokployPlatformHealth.php index b88cb97..f582339 100644 --- a/app/Filament/Widgets/DokployPlatformHealth.php +++ b/app/Filament/Widgets/DokployPlatformHealth.php @@ -14,11 +14,63 @@ class DokployPlatformHealth extends Widget protected function getViewData(): array { + $projects = $this->loadProjects(); + return [ - 'composes' => $this->loadComposes(), + 'projects' => $projects, + 'composes' => empty($projects) ? $this->loadComposes() : [], ]; } + protected function loadProjects(): array + { + $client = app(DokployClient::class); + $projectMap = config('dokploy.projects', []); + $results = []; + + if (empty($projectMap)) { + return []; + } + + foreach ($projectMap as $label => $projectId) { + $project = $client->findProject((string) $projectId); + + if (! $project) { + $results[] = [ + 'label' => ucfirst((string) $label), + 'project_id' => (string) $projectId, + 'name' => (string) $projectId, + 'status' => 'unreachable', + 'error' => "Project {$projectId} not found.", + 'applications' => [], + 'services' => [], + 'updated_at' => null, + ]; + + continue; + } + + $applicationsPayload = Arr::get($project, 'applications', []); + $applications = $this->formatApplications(is_array($applicationsPayload) ? $applicationsPayload : [], $client); + $services = $this->formatProjectServices($project); + + $results[] = [ + 'label' => ucfirst((string) $label), + 'project_id' => Arr::get($project, 'projectId', $projectId), + 'name' => Arr::get($project, 'name') ?? Arr::get($project, 'projectName') ?? (string) $projectId, + 'description' => Arr::get($project, 'description'), + 'status' => $this->deriveProjectStatus($applications, $services), + 'applications' => $applications, + 'services' => $services, + 'updated_at' => Arr::get($project, 'updatedAt') ?? Arr::get($project, 'createdAt'), + 'applications_count' => count($applications), + 'services_count' => count($services), + ]; + } + + return $results; + } + protected function loadComposes(): array { $client = app(DokployClient::class); @@ -62,7 +114,7 @@ class DokployPlatformHealth extends Widget 'label' => 'Dokploy', 'compose_id' => '-', 'status' => 'unconfigured', - 'error' => 'Set DOKPLOY_COMPOSE_IDS in .env to enable monitoring.', + 'error' => 'Set DOKPLOY_PROJECT_IDS or DOKPLOY_COMPOSE_IDS in .env to enable monitoring.', ], ]; } @@ -70,6 +122,158 @@ class DokployPlatformHealth extends Widget return $results; } + protected function formatApplications(array $applications, DokployClient $client): array + { + return collect($applications) + ->map(function (array $application) use ($client) { + $applicationId = $this->extractApplicationId($application); + $statusPayload = []; + + if ($applicationId) { + try { + $statusPayload = $client->applicationStatus($applicationId); + } catch (\Throwable $exception) { + $statusPayload = []; + } + } + + $applicationDetails = Arr::get($statusPayload, 'application', []); + $monitoring = Arr::get($statusPayload, 'monitoring', []); + + $status = Arr::get($application, 'applicationStatus') + ?? Arr::get($application, 'status') + ?? Arr::get($applicationDetails, 'applicationStatus') + ?? Arr::get($applicationDetails, 'status') + ?? 'unknown'; + + return [ + 'id' => $applicationId ?? Arr::get($application, 'id'), + 'name' => Arr::get($application, 'name') + ?? Arr::get($application, 'appName') + ?? Arr::get($applicationDetails, 'name') + ?? Arr::get($applicationDetails, 'appName') + ?? $applicationId, + 'status' => $status, + 'repository' => Arr::get($application, 'repository') + ?? Arr::get($applicationDetails, 'repository') + ?? Arr::get($application, 'repo') + ?? Arr::get($applicationDetails, 'repo'), + 'branch' => Arr::get($application, 'branch') + ?? Arr::get($applicationDetails, 'branch') + ?? Arr::get($application, 'gitBranch') + ?? Arr::get($applicationDetails, 'gitBranch'), + 'url' => Arr::get($application, 'url') + ?? Arr::get($applicationDetails, 'url') + ?? Arr::get($application, 'domain') + ?? Arr::get($applicationDetails, 'domain'), + 'server' => Arr::get($application, 'serverName') + ?? Arr::get($applicationDetails, 'serverName') + ?? Arr::get($application, 'server'), + 'last_deploy' => Arr::get($application, 'lastDeploymentAt') + ?? Arr::get($applicationDetails, 'lastDeploymentAt') + ?? Arr::get($application, 'updatedAt') + ?? Arr::get($applicationDetails, 'updatedAt') + ?? Arr::get($application, 'createdAt'), + 'monitoring' => $this->formatMonitoring($monitoring), + ]; + }) + ->filter(fn (array $application) => filled($application['name'])) + ->values() + ->all(); + } + + protected function extractApplicationId(array $application): ?string + { + return Arr::get($application, 'applicationId') + ?? Arr::get($application, 'appId') + ?? Arr::get($application, 'id'); + } + + protected function formatProjectServices(array $project): array + { + return collect([ + ...$this->normalizeServiceList((array) Arr::get($project, 'compose', []), 'compose', 'composeId', 'composeStatus'), + ...$this->normalizeServiceList((array) Arr::get($project, 'mysql', []), 'mysql', 'mysqlId', 'applicationStatus'), + ...$this->normalizeServiceList((array) Arr::get($project, 'postgres', []), 'postgres', 'postgresId', 'applicationStatus'), + ...$this->normalizeServiceList((array) Arr::get($project, 'mariadb', []), 'mariadb', 'mariadbId', 'applicationStatus'), + ...$this->normalizeServiceList((array) Arr::get($project, 'mongo', []), 'mongo', 'mongoId', 'applicationStatus'), + ...$this->normalizeServiceList((array) Arr::get($project, 'redis', []), 'redis', 'redisId', 'applicationStatus'), + ]) + ->filter(fn (array $service) => filled($service['name'])) + ->values() + ->all(); + } + + protected function normalizeServiceList(array $services, string $type, string $idKey, string $statusKey): array + { + return collect($services) + ->map(function (array $service) use ($type, $idKey, $statusKey) { + return [ + 'type' => $type, + 'id' => Arr::get($service, $idKey) ?? Arr::get($service, 'id'), + 'name' => Arr::get($service, 'name') ?? Arr::get($service, 'appName') ?? Arr::get($service, 'serviceName'), + 'status' => Arr::get($service, $statusKey) ?? Arr::get($service, 'status') ?? Arr::get($service, 'composeStatus', 'unknown'), + 'version' => Arr::get($service, 'dockerImage') ?? Arr::get($service, 'image'), + 'external_port' => Arr::get($service, 'externalPort'), + ]; + }) + ->values() + ->all(); + } + + protected function formatMonitoring(array $monitoring): array + { + $metrics = []; + $allowed = [ + 'cpuPercent' => 'CPU', + 'cpu' => 'CPU', + 'memoryPercent' => 'Memory', + 'memory' => 'Memory', + 'uptime' => 'Uptime', + ]; + + foreach ($allowed as $key => $label) { + $value = Arr::get($monitoring, $key); + + if (filled($value) && ! is_array($value)) { + $metrics[] = [ + 'label' => $label, + 'value' => $value, + ]; + } + } + + return $metrics; + } + + protected function deriveProjectStatus(array $applications, array $services): string + { + $statuses = collect($applications) + ->pluck('status') + ->merge(collect($services)->pluck('status')) + ->filter() + ->map(fn ($status) => strtolower((string) $status)) + ->values(); + + if ($statuses->contains(fn ($status) => in_array($status, ['error', 'failed', 'unreachable', 'unhealthy'], true))) { + return 'error'; + } + + if ($statuses->contains(fn ($status) => in_array($status, ['deploying', 'pending', 'starting'], true))) { + return 'deploying'; + } + + if ($statuses->contains(fn ($status) => in_array($status, ['stopped', 'inactive', 'paused'], true))) { + return 'warning'; + } + + if ($statuses->contains(fn ($status) => in_array($status, ['done', 'running', 'healthy'], true))) { + return 'done'; + } + + return 'unknown'; + } + protected function formatServices(array $services): array { return collect($services) diff --git a/app/Services/Dokploy/DokployClient.php b/app/Services/Dokploy/DokployClient.php index 88326e4..fd332cb 100644 --- a/app/Services/Dokploy/DokployClient.php +++ b/app/Services/Dokploy/DokployClient.php @@ -40,6 +40,37 @@ class DokployClient }, 30); } + public function projects(): array + { + return $this->cached($this->projectsCacheKey(), function () { + $projects = $this->get('/project.all'); + + return is_array($projects) ? $projects : []; + }, 60); + } + + public function findProject(string $projectIdOrName): ?array + { + $projects = $this->projects(); + + foreach ($projects as $project) { + if (Arr::get($project, 'projectId') === $projectIdOrName) { + return $project; + } + } + + foreach ($projects as $project) { + if ( + Arr::get($project, 'name') === $projectIdOrName + || Arr::get($project, 'projectName') === $projectIdOrName + ) { + return $project; + } + } + + return null; + } + public function recentDeployments(string $applicationId, int $limit = 5): array { return $this->cached($this->deploymentCacheKey($applicationId), function () use ($applicationId, $limit) { @@ -321,6 +352,11 @@ class DokployClient return "dokploy.compose.deployments.{$composeId}"; } + protected function projectsCacheKey(): string + { + return 'dokploy.projects'; + } + protected function forgetApplicationCaches(string $applicationId): void { Cache::forget($this->applicationCacheKey($applicationId)); diff --git a/config/dokploy.php b/config/dokploy.php index d716635..e3e887b 100644 --- a/config/dokploy.php +++ b/config/dokploy.php @@ -8,5 +8,6 @@ return [ ], 'web_url' => env('DOKPLOY_WEB_URL'), 'applications' => json_decode(env('DOKPLOY_APPLICATION_IDS', '{}'), true) ?? [], + 'projects' => json_decode(env('DOKPLOY_PROJECT_IDS', '{}'), true) ?? [], 'composes' => json_decode(env('DOKPLOY_COMPOSE_IDS', '{}'), true) ?? [], ]; diff --git a/resources/views/filament/widgets/dokploy-platform-health.blade.php b/resources/views/filament/widgets/dokploy-platform-health.blade.php index 64bceb8..a8fd7eb 100644 --- a/resources/views/filament/widgets/dokploy-platform-health.blade.php +++ b/resources/views/filament/widgets/dokploy-platform-health.blade.php @@ -5,87 +5,263 @@ $statusColors = [ 'done' => 'success', + 'healthy' => 'success', + 'running' => 'success', 'deploying' => 'warning', 'pending' => 'warning', + 'starting' => 'warning', + 'warning' => 'warning', 'unreachable' => 'danger', 'error' => 'danger', 'failed' => 'danger', + 'unhealthy' => 'danger', ]; $statusIcons = [ 'done' => Heroicon::CheckCircle, + 'healthy' => Heroicon::CheckCircle, + 'running' => Heroicon::CheckCircle, 'deploying' => Heroicon::ArrowPath, 'pending' => Heroicon::Clock, + 'starting' => Heroicon::Clock, + 'warning' => Heroicon::ExclamationTriangle, 'unreachable' => Heroicon::ExclamationTriangle, 'error' => Heroicon::XCircle, 'failed' => Heroicon::XCircle, + 'unhealthy' => Heroicon::XCircle, ]; $serviceColors = [ 'running' => 'success', 'done' => 'success', + 'healthy' => 'success', 'starting' => 'warning', 'deploying' => 'warning', + 'pending' => 'warning', 'unhealthy' => 'danger', 'error' => 'danger', 'failed' => 'danger', ]; $cardsGrid = (new ComponentAttributeBag())->grid(['default' => 1, 'lg' => 2]); + $stacked = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column); + $detailsGrid = (new ComponentAttributeBag())->grid(['default' => 1, 'md' => 2], GridDirection::Column); $serviceGrid = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column); @endphp -
- @forelse($composes as $compose) - - - - {{ ucfirst($compose['status']) }} - - - {{ $compose['compose_id'] }} - - - - @if(isset($compose['error'])) - - {{ $compose['error'] }} - - @else -
- @forelse($compose['services'] as $service) - - {{ $service['name'] }}: {{ strtoupper($service['status'] ?? 'N/A') }} - - @empty - - No services reported. - - @endforelse - - Last deploy: - {{ $compose['last_deploy'] ? \Illuminate\Support\Carbon::parse($compose['last_deploy'])->diffForHumans() : '—' }} + @if(!empty($projects)) +
+ @forelse($projects as $project) + + + + {{ ucfirst($project['status']) }} + + {{ $project['project_id'] }} + + + +
+ @if(isset($project['error'])) + + {{ $project['error'] }} + + @else + @if(!empty($project['description'])) + + {{ $project['description'] }} + + @endif + +
+ + Apps: {{ $project['applications_count'] ?? 0 }} + + + Services: {{ $project['services_count'] ?? 0 }} + + + Last update: + {{ $project['updated_at'] ? \Illuminate\Support\Carbon::parse($project['updated_at'])->diffForHumans() : '—' }} + +
+ +
+ + Applications + + + @forelse($project['applications'] as $application) +
+ + {{ $application['name'] ?? 'App' }} + @if(!empty($application['status'])) + ({{ strtoupper($application['status']) }}) + @endif + + +
+ @if(!empty($application['id'])) + + {{ $application['id'] }} + + @endif + @if(!empty($application['repository'])) + + {{ $application['repository'] }} + + @endif + @if(!empty($application['branch'])) + + {{ $application['branch'] }} + + @endif + @if(!empty($application['server'])) + + {{ $application['server'] }} + + @endif + @if(!empty($application['url'])) + + {{ $application['url'] }} + + @endif + + Last deploy: + {{ $application['last_deploy'] ? \Illuminate\Support\Carbon::parse($application['last_deploy'])->diffForHumans() : '—' }} + +
+ + @if(!empty($application['monitoring'])) +
+ @foreach($application['monitoring'] as $metric) + + {{ $metric['label'] }}: {{ $metric['value'] }} + + @endforeach +
+ @endif +
+ @empty + + No applications reported. + + @endforelse +
+ +
+ + Services + + +
+ @forelse($project['services'] as $service) + + {{ strtoupper($service['type'] ?? 'service') }} + @if(!empty($service['name'])) + - {{ $service['name'] }} + @endif + @if(!empty($service['status'])) + ({{ strtoupper($service['status']) }}) + @endif + +
+ @if(!empty($service['id'])) + + {{ $service['id'] }} + + @endif + @if(!empty($service['version'])) + + {{ $service['version'] }} + + @endif + @if(!empty($service['external_port'])) + + Port {{ $service['external_port'] }} + + @endif +
+ @empty + + No services reported. + + @endforelse +
+
+ @endif
- @endif -
- @empty - - @endforelse -
+ + @empty + + @endforelse +
+ @else +
+ @forelse($composes as $compose) + + + + {{ ucfirst($compose['status']) }} + + + {{ $compose['compose_id'] }} + + + + @if(isset($compose['error'])) + + {{ $compose['error'] }} + + @else +
+ @forelse($compose['services'] as $service) + + {{ $service['name'] }}: {{ strtoupper($service['status'] ?? 'N/A') }} + + @empty + + No services reported. + + @endforelse + + Last deploy: + {{ $compose['last_deploy'] ? \Illuminate\Support\Carbon::parse($compose['last_deploy'])->diffForHumans() : '—' }} + +
+ @endif +
+ @empty + + @endforelse +
+ @endif diff --git a/tests/Feature/DokployPlatformHealthWidgetTest.php b/tests/Feature/DokployPlatformHealthWidgetTest.php new file mode 100644 index 0000000..eb25448 --- /dev/null +++ b/tests/Feature/DokployPlatformHealthWidgetTest.php @@ -0,0 +1,114 @@ +set('dokploy.projects', [ + 'core' => 'proj_1', + ]); + + $fakeClient = Mockery::mock(DokployClient::class); + $fakeClient->shouldReceive('findProject') + ->with('proj_1') + ->andReturn([ + 'projectId' => 'proj_1', + 'name' => 'Core', + 'description' => 'Main stack', + 'updatedAt' => now()->toIso8601String(), + 'applications' => [ + [ + 'applicationId' => 'app_1', + 'name' => 'API', + 'applicationStatus' => 'running', + 'repository' => 'repo/api', + 'branch' => 'main', + ], + ], + 'redis' => [ + [ + 'redisId' => 'redis_1', + 'name' => 'Redis', + 'applicationStatus' => 'done', + 'externalPort' => 6379, + ], + ], + ]); + + $fakeClient->shouldReceive('applicationStatus') + ->with('app_1') + ->andReturn([ + 'application' => [ + 'name' => 'API', + 'applicationStatus' => 'running', + 'updatedAt' => now()->toIso8601String(), + ], + 'monitoring' => [ + 'cpuPercent' => 12, + ], + ]); + + $this->app->instance(DokployClient::class, $fakeClient); + + Livewire::test(DokployPlatformHealth::class) + ->assertStatus(200) + ->assertSee('Core') + ->assertSee('API') + ->assertSee('Redis'); + } + + public function test_widget_falls_back_to_compose_status_when_projects_missing(): void + { + config()->set('dokploy.projects', []); + config()->set('dokploy.composes', [ + 'stack' => 'cmp_main', + ]); + + $fakeClient = Mockery::mock(DokployClient::class); + $fakeClient->shouldReceive('composeStatus') + ->with('cmp_main') + ->andReturn([ + 'compose' => [ + 'name' => 'Main Stack', + 'composeStatus' => 'done', + 'updatedAt' => now()->toIso8601String(), + ], + 'services' => [ + [ + 'serviceName' => 'web', + 'status' => 'running', + ], + ], + ]); + + $fakeClient->shouldReceive('composeDeployments') + ->with('cmp_main', 1) + ->andReturn([ + [ + 'createdAt' => now()->toIso8601String(), + ], + ]); + + $this->app->instance(DokployClient::class, $fakeClient); + + Livewire::test(DokployPlatformHealth::class) + ->assertStatus(200) + ->assertSee('Main Stack') + ->assertSee('web'); + } +}