Use Dokploy projects in dashboard widget
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-29 10:40:10 +01:00
parent b8bb7926c0
commit 78af7838bf
5 changed files with 581 additions and 50 deletions

View File

@@ -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)

View File

@@ -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));

View File

@@ -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) ?? [],
];

View File

@@ -5,38 +5,213 @@
$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
<x-filament-widgets::widget>
<x-filament::section heading="Infra Status (Dokploy)">
@if(!empty($projects))
<div {{ $cardsGrid }}>
@forelse($projects as $project)
<x-filament::card :heading="$project['label']" :description="$project['name'] ?? ''">
<x-slot name="afterHeader">
<x-filament::badge
:color="$statusColors[$project['status']] ?? 'gray'"
:icon="$statusIcons[$project['status']] ?? Heroicon::QuestionMarkCircle"
>
{{ ucfirst($project['status']) }}
</x-filament::badge>
<x-filament::badge color="gray" :icon="Heroicon::Identification">
{{ $project['project_id'] }}
</x-filament::badge>
</x-slot>
<div {{ $stacked }}>
@if(isset($project['error']))
<x-filament::badge color="danger" :icon="Heroicon::ExclamationTriangle">
{{ $project['error'] }}
</x-filament::badge>
@else
@if(!empty($project['description']))
<x-filament::badge color="gray" :icon="Heroicon::InformationCircle">
{{ $project['description'] }}
</x-filament::badge>
@endif
<div {{ $detailsGrid }}>
<x-filament::badge color="gray" :icon="Heroicon::RectangleStack">
Apps: {{ $project['applications_count'] ?? 0 }}
</x-filament::badge>
<x-filament::badge color="gray" :icon="Heroicon::ServerStack">
Services: {{ $project['services_count'] ?? 0 }}
</x-filament::badge>
<x-filament::badge color="gray" :icon="Heroicon::Clock">
Last update:
{{ $project['updated_at'] ? \Illuminate\Support\Carbon::parse($project['updated_at'])->diffForHumans() : '—' }}
</x-filament::badge>
</div>
<div {{ $stacked }}>
<x-filament::badge color="gray" :icon="Heroicon::RectangleStack">
Applications
</x-filament::badge>
@forelse($project['applications'] as $application)
<div {{ $stacked }}>
<x-filament::badge
:color="$statusColors[$application['status']] ?? 'gray'"
:icon="$statusIcons[$application['status']] ?? Heroicon::QuestionMarkCircle"
>
{{ $application['name'] ?? 'App' }}
@if(!empty($application['status']))
({{ strtoupper($application['status']) }})
@endif
</x-filament::badge>
<div {{ $detailsGrid }}>
@if(!empty($application['id']))
<x-filament::badge color="gray" :icon="Heroicon::Identification">
{{ $application['id'] }}
</x-filament::badge>
@endif
@if(!empty($application['repository']))
<x-filament::badge color="gray" :icon="Heroicon::CodeBracket">
{{ $application['repository'] }}
</x-filament::badge>
@endif
@if(!empty($application['branch']))
<x-filament::badge color="gray" :icon="Heroicon::ChevronDown">
{{ $application['branch'] }}
</x-filament::badge>
@endif
@if(!empty($application['server']))
<x-filament::badge color="gray" :icon="Heroicon::Server">
{{ $application['server'] }}
</x-filament::badge>
@endif
@if(!empty($application['url']))
<x-filament::badge color="gray" :icon="Heroicon::GlobeAlt">
{{ $application['url'] }}
</x-filament::badge>
@endif
<x-filament::badge color="gray" :icon="Heroicon::Clock">
Last deploy:
{{ $application['last_deploy'] ? \Illuminate\Support\Carbon::parse($application['last_deploy'])->diffForHumans() : '—' }}
</x-filament::badge>
</div>
@if(!empty($application['monitoring']))
<div {{ $detailsGrid }}>
@foreach($application['monitoring'] as $metric)
<x-filament::badge color="gray" :icon="Heroicon::ChartBar">
{{ $metric['label'] }}: {{ $metric['value'] }}
</x-filament::badge>
@endforeach
</div>
@endif
</div>
@empty
<x-filament::badge color="gray" :icon="Heroicon::QuestionMarkCircle">
No applications reported.
</x-filament::badge>
@endforelse
</div>
<div {{ $stacked }}>
<x-filament::badge color="gray" :icon="Heroicon::ServerStack">
Services
</x-filament::badge>
<div {{ $serviceGrid }}>
@forelse($project['services'] as $service)
<x-filament::badge
:color="$serviceColors[$service['status']] ?? 'gray'"
:icon="Heroicon::Server"
>
{{ strtoupper($service['type'] ?? 'service') }}
@if(!empty($service['name']))
- {{ $service['name'] }}
@endif
@if(!empty($service['status']))
({{ strtoupper($service['status']) }})
@endif
</x-filament::badge>
<div {{ $detailsGrid }}>
@if(!empty($service['id']))
<x-filament::badge color="gray" :icon="Heroicon::Identification">
{{ $service['id'] }}
</x-filament::badge>
@endif
@if(!empty($service['version']))
<x-filament::badge color="gray" :icon="Heroicon::Cube">
{{ $service['version'] }}
</x-filament::badge>
@endif
@if(!empty($service['external_port']))
<x-filament::badge color="gray" :icon="Heroicon::Signal">
Port {{ $service['external_port'] }}
</x-filament::badge>
@endif
</div>
@empty
<x-filament::badge color="gray" :icon="Heroicon::QuestionMarkCircle">
No services reported.
</x-filament::badge>
@endforelse
</div>
</div>
@endif
</div>
</x-filament::card>
@empty
<x-filament::empty-state
heading="No Dokploy projects configured."
:icon="Heroicon::QuestionMarkCircle"
/>
@endforelse
</div>
@else
<div {{ $cardsGrid }}>
@forelse($composes as $compose)
<x-filament::card
@@ -87,5 +262,6 @@
/>
@endforelse
</div>
@endif
</x-filament::section>
</x-filament-widgets::widget>

View File

@@ -0,0 +1,114 @@
<?php
namespace Tests\Feature;
use App\Filament\Widgets\DokployPlatformHealth;
use App\Services\Dokploy\DokployClient;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
class DokployPlatformHealthWidgetTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_widget_uses_projects_when_configured(): void
{
config()->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');
}
}