admin widget zu dokploy geswitched
This commit is contained in:
10
.env.example
10
.env.example
@@ -132,11 +132,11 @@ PHOTOBOOTH_IMPORT_ROOT=/var/www/storage/app/photobooth
|
||||
PHOTOBOOTH_IMPORT_MAX_FILES=50
|
||||
PHOTOBOOTH_ALLOWED_EXTENSIONS=jpg,jpeg,png,webp
|
||||
|
||||
COOLIFY_API_BASE_URL=
|
||||
COOLIFY_API_TOKEN=
|
||||
COOLIFY_WEB_URL=
|
||||
COOLIFY_API_TIMEOUT=5
|
||||
COOLIFY_SERVICE_IDS={"app":"svc_app","queue":"svc_queue","scheduler":"svc_scheduler","ftp":"svc_ftp","control":"svc_control"}
|
||||
DOKPLOY_API_BASE_URL=
|
||||
DOKPLOY_API_KEY=
|
||||
DOKPLOY_WEB_URL=
|
||||
DOKPLOY_API_TIMEOUT=10
|
||||
DOKPLOY_APPLICATION_IDS={"app":"app_xxx","queue":"app_queue","scheduler":"app_scheduler","ftp":"app_ftp"}
|
||||
|
||||
GUEST_ACHIEVEMENT_MILESTONES=10,25,50
|
||||
|
||||
|
||||
@@ -112,14 +112,16 @@ class PostResource extends Resource
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2),
|
||||
]),
|
||||
TextInput::make('slug')
|
||||
->label('Slug')
|
||||
->required()
|
||||
->unique(BlogPost::class, 'slug', ignoreRecord: true)
|
||||
->maxLength(255),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Bild und Kategorie')
|
||||
->schema([
|
||||
TextInput::make('slug')
|
||||
->label('Slug')
|
||||
->required()
|
||||
->unique(BlogPost::class, 'slug', ignoreRecord: true)
|
||||
->maxLength(255)
|
||||
->columnSpanFull(),
|
||||
FileUpload::make('featured_image')
|
||||
->label('Featured Image')
|
||||
->image()
|
||||
@@ -194,6 +196,8 @@ class PostResource extends Resource
|
||||
->label('Titel (DE)')
|
||||
->getStateUsing(fn ($record) => $record->getTranslation('title', 'de'))
|
||||
->searchable()
|
||||
->limit(50)
|
||||
->url(fn ($record) => static::getUrl('edit', ['record' => $record]))
|
||||
->sortable(),
|
||||
TextColumn::make('category_label')
|
||||
->label('Kategorie')
|
||||
@@ -225,10 +229,10 @@ class PostResource extends Resource
|
||||
->trueIcon('heroicon-o-check-circle')
|
||||
->falseIcon('heroicon-o-x-circle'),
|
||||
TextColumn::make('published_at')
|
||||
->label('Veröffentlicht am')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
->label('Veröffentlichungsdatum')
|
||||
->icon('heroicon-o-calendar')
|
||||
->dateTime('d.m.Y H:i')
|
||||
->sortable(),
|
||||
TextColumn::make('created_at')
|
||||
->label('Erstellt am')
|
||||
->dateTime()
|
||||
@@ -240,9 +244,9 @@ class PostResource extends Resource
|
||||
->label('Veröffentlicht'),
|
||||
])
|
||||
->actions([
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
DeleteAction::make()
|
||||
->icon('heroicon-o-trash')
|
||||
->label(''),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\CoolifyActionLogs\Pages;
|
||||
|
||||
use App\Filament\Resources\CoolifyActionLogs\CoolifyActionLogResource;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ManageCoolifyActionLogs extends ManageRecords
|
||||
{
|
||||
protected static string $resource = CoolifyActionLogResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\CoolifyActionLogs;
|
||||
namespace App\Filament\Resources\InfrastructureActionLogs;
|
||||
|
||||
use App\Filament\Resources\CoolifyActionLogs\Pages\ManageCoolifyActionLogs;
|
||||
use App\Models\CoolifyActionLog;
|
||||
use App\Filament\Resources\InfrastructureActionLogs\Pages\ManageInfrastructureActionLogs;
|
||||
use App\Models\InfrastructureActionLog;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
@@ -11,9 +11,9 @@ use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
|
||||
class CoolifyActionLogResource extends Resource
|
||||
class InfrastructureActionLogResource extends Resource
|
||||
{
|
||||
protected static ?string $model = CoolifyActionLog::class;
|
||||
protected static ?string $model = InfrastructureActionLog::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||
|
||||
@@ -64,7 +64,7 @@ class CoolifyActionLogResource extends Resource
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ManageCoolifyActionLogs::route('/'),
|
||||
'index' => ManageInfrastructureActionLogs::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\InfrastructureActionLogs\Pages;
|
||||
|
||||
use App\Filament\Resources\InfrastructureActionLogs\InfrastructureActionLogResource;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ManageInfrastructureActionLogs extends ManageRecords
|
||||
{
|
||||
protected static string $resource = InfrastructureActionLogResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\SuperAdmin\Pages;
|
||||
|
||||
use App\Models\CoolifyActionLog;
|
||||
use App\Services\Coolify\CoolifyClient;
|
||||
use BackedEnum;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class CoolifyDeployments extends Page
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
|
||||
|
||||
protected static ?string $navigationLabel = 'Infrastructure';
|
||||
|
||||
protected static ?string $title = 'Infrastructure Controls';
|
||||
|
||||
protected string $view = 'filament.super-admin.pages.coolify-deployments';
|
||||
|
||||
public array $services = [];
|
||||
|
||||
public array $recentLogs = [];
|
||||
|
||||
public ?string $coolifyWebUrl = null;
|
||||
|
||||
public function mount(CoolifyClient $client): void
|
||||
{
|
||||
$this->coolifyWebUrl = config('coolify.web_url');
|
||||
$this->refreshServices($client);
|
||||
$this->refreshLogs();
|
||||
}
|
||||
|
||||
public function restart(string $serviceId): void
|
||||
{
|
||||
$this->performAction($serviceId, 'restart');
|
||||
}
|
||||
|
||||
public function redeploy(string $serviceId): void
|
||||
{
|
||||
$this->performAction($serviceId, 'redeploy');
|
||||
}
|
||||
|
||||
protected function performAction(string $serviceId, string $action): void
|
||||
{
|
||||
$client = app(CoolifyClient::class);
|
||||
|
||||
if (! $this->isKnownService($serviceId)) {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Unknown service')
|
||||
->body("The service ID {$serviceId} is not configured.")
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$action === 'restart'
|
||||
? $client->restartService($serviceId, auth()->user())
|
||||
: $client->redeployService($serviceId, auth()->user());
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(ucfirst($action).' requested')
|
||||
->body("Coolify accepted the {$action} action for {$serviceId}.")
|
||||
->send();
|
||||
} catch (\Throwable $exception) {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Coolify request failed')
|
||||
->body($exception->getMessage())
|
||||
->send();
|
||||
}
|
||||
|
||||
$this->refreshServices($client);
|
||||
$this->refreshLogs();
|
||||
}
|
||||
|
||||
protected function refreshServices(CoolifyClient $client): void
|
||||
{
|
||||
$serviceMap = config('coolify.services', []);
|
||||
$results = [];
|
||||
|
||||
foreach ($serviceMap as $label => $id) {
|
||||
try {
|
||||
$status = $client->serviceStatus($id);
|
||||
$results[] = [
|
||||
'label' => ucfirst($label),
|
||||
'service_id' => $id,
|
||||
'status' => Arr::get($status, 'data.status', 'unknown'),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$results[] = [
|
||||
'label' => ucfirst($label),
|
||||
'service_id' => $id,
|
||||
'status' => 'error',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->services = $results;
|
||||
}
|
||||
|
||||
protected function refreshLogs(): void
|
||||
{
|
||||
$this->recentLogs = CoolifyActionLog::query()
|
||||
->with('user')
|
||||
->latest()
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($log) => [
|
||||
'created_at' => $log->created_at->diffForHumans(),
|
||||
'user' => $log->user?->name ?? 'System',
|
||||
'service_id' => $log->service_id,
|
||||
'action' => $log->action,
|
||||
'status_code' => $log->status_code,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
protected function isKnownService(string $serviceId): bool
|
||||
{
|
||||
return in_array($serviceId, array_values(config('coolify.services', [])), true);
|
||||
}
|
||||
}
|
||||
129
app/Filament/SuperAdmin/Pages/DokployDeployments.php
Normal file
129
app/Filament/SuperAdmin/Pages/DokployDeployments.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\SuperAdmin\Pages;
|
||||
|
||||
use App\Models\InfrastructureActionLog;
|
||||
use App\Services\Dokploy\DokployClient;
|
||||
use BackedEnum;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class DokployDeployments extends Page
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
|
||||
|
||||
protected static ?string $navigationLabel = 'Infrastructure';
|
||||
|
||||
protected static ?string $title = 'Infrastructure Controls';
|
||||
|
||||
protected string $view = 'filament.super-admin.pages.dokploy-deployments';
|
||||
|
||||
public array $applications = [];
|
||||
|
||||
public array $recentLogs = [];
|
||||
|
||||
public ?string $dokployWebUrl = null;
|
||||
|
||||
public function mount(DokployClient $client): void
|
||||
{
|
||||
$this->dokployWebUrl = config('dokploy.web_url');
|
||||
$this->refreshApplications($client);
|
||||
$this->refreshLogs();
|
||||
}
|
||||
|
||||
public function reload(string $applicationId): void
|
||||
{
|
||||
$this->performAction($applicationId, 'reload');
|
||||
}
|
||||
|
||||
public function redeploy(string $applicationId): void
|
||||
{
|
||||
$this->performAction($applicationId, 'redeploy');
|
||||
}
|
||||
|
||||
protected function performAction(string $applicationId, string $action): void
|
||||
{
|
||||
$client = app(DokployClient::class);
|
||||
|
||||
if (! $this->isKnownApplication($applicationId)) {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Unknown service')
|
||||
->body("The application ID {$applicationId} is not configured.")
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$action === 'reload'
|
||||
? $client->reloadApplication($applicationId, auth()->user())
|
||||
: $client->redeployApplication($applicationId, auth()->user());
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(ucfirst($action).' requested')
|
||||
->body("Dokploy accepted the {$action} action for {$applicationId}.")
|
||||
->send();
|
||||
} catch (\Throwable $exception) {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Dokploy request failed')
|
||||
->body($exception->getMessage())
|
||||
->send();
|
||||
}
|
||||
|
||||
$this->refreshApplications($client);
|
||||
$this->refreshLogs();
|
||||
}
|
||||
|
||||
protected function refreshApplications(DokployClient $client): void
|
||||
{
|
||||
$applicationMap = config('dokploy.applications', []);
|
||||
$results = [];
|
||||
|
||||
foreach ($applicationMap as $label => $id) {
|
||||
try {
|
||||
$status = $client->applicationStatus($id);
|
||||
$application = Arr::get($status, 'application', []);
|
||||
|
||||
$results[] = [
|
||||
'label' => ucfirst($label),
|
||||
'application_id' => $id,
|
||||
'status' => Arr::get($application, 'applicationStatus', 'unknown'),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$results[] = [
|
||||
'label' => ucfirst($label),
|
||||
'application_id' => $id,
|
||||
'status' => 'error',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->applications = $results;
|
||||
}
|
||||
|
||||
protected function refreshLogs(): void
|
||||
{
|
||||
$this->recentLogs = InfrastructureActionLog::query()
|
||||
->with('user')
|
||||
->latest()
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($log) => [
|
||||
'created_at' => $log->created_at->diffForHumans(),
|
||||
'user' => $log->user?->name ?? 'System',
|
||||
'service_id' => $log->service_id,
|
||||
'action' => $log->action,
|
||||
'status_code' => $log->status_code,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
protected function isKnownApplication(string $applicationId): bool
|
||||
{
|
||||
return in_array($applicationId, array_values(config('dokploy.applications', [])), true);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Services\Coolify\CoolifyClient;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class CoolifyPlatformHealth extends Widget
|
||||
{
|
||||
protected string $view = 'filament.widgets.coolify-platform-health';
|
||||
|
||||
protected ?string $pollingInterval = '60s';
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
return [
|
||||
'services' => $this->loadServices(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function loadServices(): array
|
||||
{
|
||||
$client = app(CoolifyClient::class);
|
||||
$serviceMap = config('coolify.services', []);
|
||||
$results = [];
|
||||
|
||||
foreach ($serviceMap as $label => $serviceId) {
|
||||
try {
|
||||
$status = $client->serviceStatus($serviceId);
|
||||
$results[] = [
|
||||
'label' => ucfirst($label),
|
||||
'service_id' => $serviceId,
|
||||
'status' => Arr::get($status, 'data.status', 'unknown'),
|
||||
'cpu' => Arr::get($status, 'data.metrics.cpu_percent'),
|
||||
'memory' => Arr::get($status, 'data.metrics.memory_percent'),
|
||||
'last_deploy' => Arr::get($status, 'data.last_deployment.finished_at'),
|
||||
];
|
||||
} catch (\Throwable $exception) {
|
||||
$results[] = [
|
||||
'label' => ucfirst($label),
|
||||
'service_id' => $serviceId,
|
||||
'status' => 'unreachable',
|
||||
'error' => $exception->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($results)) {
|
||||
return [
|
||||
[
|
||||
'label' => 'Coolify',
|
||||
'service_id' => '-',
|
||||
'status' => 'unconfigured',
|
||||
'error' => 'Set COOLIFY_SERVICE_IDS in .env to enable monitoring.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
91
app/Filament/Widgets/DokployPlatformHealth.php
Normal file
91
app/Filament/Widgets/DokployPlatformHealth.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Services\Dokploy\DokployClient;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class DokployPlatformHealth extends Widget
|
||||
{
|
||||
protected string $view = 'filament.widgets.dokploy-platform-health';
|
||||
|
||||
protected ?string $pollingInterval = '60s';
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
return [
|
||||
'applications' => $this->loadApplications(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function loadApplications(): array
|
||||
{
|
||||
$client = app(DokployClient::class);
|
||||
$applicationMap = config('dokploy.applications', []);
|
||||
$results = [];
|
||||
|
||||
foreach ($applicationMap as $label => $applicationId) {
|
||||
try {
|
||||
$status = $client->applicationStatus($applicationId);
|
||||
$deployments = $client->recentDeployments($applicationId, 1);
|
||||
$application = Arr::get($status, 'application', []);
|
||||
$monitoring = Arr::get($status, 'monitoring', []);
|
||||
|
||||
$results[] = [
|
||||
'label' => ucfirst($label),
|
||||
'application_id' => $applicationId,
|
||||
'app_name' => Arr::get($application, 'appName') ?? Arr::get($application, 'name'),
|
||||
'status' => Arr::get($application, 'applicationStatus', 'unknown'),
|
||||
'cpu' => $this->extractMetric($monitoring, [
|
||||
'metrics.cpuPercent',
|
||||
'metrics.cpu_percent',
|
||||
'cpuPercent',
|
||||
'cpu_percent',
|
||||
]),
|
||||
'memory' => $this->extractMetric($monitoring, [
|
||||
'metrics.memoryPercent',
|
||||
'metrics.memory_percent',
|
||||
'memoryPercent',
|
||||
'memory_percent',
|
||||
]),
|
||||
'last_deploy' => Arr::get($deployments, '0.createdAt')
|
||||
?? Arr::get($deployments, '0.created_at')
|
||||
?? Arr::get($application, 'updatedAt')
|
||||
?? Arr::get($application, 'lastDeploymentAt'),
|
||||
];
|
||||
} catch (\Throwable $exception) {
|
||||
$results[] = [
|
||||
'label' => ucfirst($label),
|
||||
'application_id' => $applicationId,
|
||||
'status' => 'unreachable',
|
||||
'error' => $exception->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($results)) {
|
||||
return [
|
||||
[
|
||||
'label' => 'Dokploy',
|
||||
'application_id' => '-',
|
||||
'status' => 'unconfigured',
|
||||
'error' => 'Set DOKPLOY_APPLICATION_IDS in .env to enable monitoring.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
protected function extractMetric(array $source, array $candidates): mixed
|
||||
{
|
||||
foreach ($candidates as $key) {
|
||||
if (Arr::has($source, $key)) {
|
||||
return Arr::get($source, $key);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CoolifyActionLog extends Model
|
||||
class InfrastructureActionLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
@@ -4,10 +4,10 @@ namespace App\Providers\Filament;
|
||||
|
||||
use App\Filament\Blog\Resources\CategoryResource;
|
||||
use App\Filament\Blog\Resources\PostResource;
|
||||
use App\Filament\Resources\CoolifyActionLogs\CoolifyActionLogResource;
|
||||
use App\Filament\Resources\InfrastructureActionLogs\InfrastructureActionLogResource;
|
||||
use App\Filament\Resources\LegalPageResource;
|
||||
use App\Filament\Widgets\CoolifyPlatformHealth;
|
||||
use App\Filament\Widgets\CreditAlertsWidget;
|
||||
use App\Filament\Widgets\DokployPlatformHealth;
|
||||
use App\Filament\Widgets\PlatformStatsWidget;
|
||||
use App\Filament\Widgets\RevenueTrendWidget;
|
||||
use App\Filament\Widgets\TopTenantsByRevenue;
|
||||
@@ -61,7 +61,7 @@ class SuperAdminPanelProvider extends PanelProvider
|
||||
TopTenantsByRevenue::class,
|
||||
TopTenantsByUploads::class,
|
||||
\App\Filament\Widgets\StorageCapacityWidget::class,
|
||||
CoolifyPlatformHealth::class,
|
||||
DokployPlatformHealth::class,
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
@@ -87,7 +87,7 @@ class SuperAdminPanelProvider extends PanelProvider
|
||||
PostResource::class,
|
||||
CategoryResource::class,
|
||||
LegalPageResource::class,
|
||||
CoolifyActionLogResource::class,
|
||||
InfrastructureActionLogResource::class,
|
||||
])
|
||||
->authGuard('web');
|
||||
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Coolify;
|
||||
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CoolifyClient
|
||||
{
|
||||
public function __construct(private readonly HttpFactory $http) {}
|
||||
|
||||
public function serviceStatus(string $serviceId): array
|
||||
{
|
||||
return $this->cached("coolify.service.$serviceId", fn () => $this->get("/services/{$serviceId}"), 30);
|
||||
}
|
||||
|
||||
public function recentDeployments(string $serviceId, int $limit = 5): array
|
||||
{
|
||||
return $this->cached("coolify.deployments.$serviceId", function () use ($serviceId, $limit) {
|
||||
$response = $this->get("/services/{$serviceId}/deployments?per_page={$limit}");
|
||||
|
||||
return Arr::get($response, 'data', []);
|
||||
}, 60);
|
||||
}
|
||||
|
||||
public function restartService(string $serviceId, ?Authenticatable $actor = null): array
|
||||
{
|
||||
return $this->dispatchAction($serviceId, 'restart', function () use ($serviceId) {
|
||||
return $this->post("/services/{$serviceId}/actions/restart");
|
||||
}, $actor);
|
||||
}
|
||||
|
||||
public function redeployService(string $serviceId, ?Authenticatable $actor = null): array
|
||||
{
|
||||
return $this->dispatchAction($serviceId, 'redeploy', function () use ($serviceId) {
|
||||
return $this->post("/services/{$serviceId}/actions/redeploy");
|
||||
}, $actor);
|
||||
}
|
||||
|
||||
protected function cached(string $key, callable $callback, int $seconds): mixed
|
||||
{
|
||||
return Cache::remember($key, now()->addSeconds($seconds), $callback);
|
||||
}
|
||||
|
||||
protected function get(string $path): array
|
||||
{
|
||||
$response = $this->request()->get($path);
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logFailure('GET', $path, $response);
|
||||
throw new RequestException($response);
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
}
|
||||
|
||||
protected function post(string $path, array $payload = []): array
|
||||
{
|
||||
$response = $this->request()->post($path, $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logFailure('POST', $path, $response);
|
||||
throw new RequestException($response);
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
}
|
||||
|
||||
protected function request(): PendingRequest
|
||||
{
|
||||
$baseUrl = config('coolify.api.base_url');
|
||||
$token = config('coolify.api.token');
|
||||
$timeout = config('coolify.api.timeout', 5);
|
||||
|
||||
if (! $baseUrl || ! $token) {
|
||||
throw new \RuntimeException('Coolify API is not configured.');
|
||||
}
|
||||
|
||||
return $this->http
|
||||
->baseUrl($baseUrl)
|
||||
->timeout($timeout)
|
||||
->acceptJson()
|
||||
->withToken($token);
|
||||
}
|
||||
|
||||
protected function logFailure(string $method, string $path, \Illuminate\Http\Client\Response $response): void
|
||||
{
|
||||
Log::error('[Coolify] API request failed', [
|
||||
'method' => $method,
|
||||
'path' => $path,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function dispatchAction(string $serviceId, string $action, callable $callback, ?Authenticatable $actor = null): array
|
||||
{
|
||||
$payload = [];
|
||||
|
||||
try {
|
||||
$response = $callback();
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logAction($serviceId, $action, $payload, [
|
||||
'error' => $exception->getMessage(),
|
||||
], null, $actor);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$this->logAction($serviceId, $action, $payload, $response, $response['status'] ?? null, $actor);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function logAction(
|
||||
string $serviceId,
|
||||
string $action,
|
||||
array $payload,
|
||||
array $response,
|
||||
?int $status,
|
||||
?Authenticatable $actor = null,
|
||||
): void {
|
||||
CoolifyActionLog::create([
|
||||
'user_id' => $actor?->getAuthIdentifier() ?? auth()->id(),
|
||||
'service_id' => $serviceId,
|
||||
'action' => $action,
|
||||
'payload' => $payload,
|
||||
'response' => $response,
|
||||
'status_code' => $status,
|
||||
]);
|
||||
}
|
||||
}
|
||||
use App\Models\CoolifyActionLog;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
222
app/Services/Dokploy/DokployClient.php
Normal file
222
app/Services/Dokploy/DokployClient.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Dokploy;
|
||||
|
||||
use App\Models\InfrastructureActionLog;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DokployClient
|
||||
{
|
||||
public function __construct(private readonly HttpFactory $http) {}
|
||||
|
||||
public function applicationStatus(string $applicationId): array
|
||||
{
|
||||
return $this->cached($this->applicationCacheKey($applicationId), function () use ($applicationId) {
|
||||
$application = $this->get('/application.one', [
|
||||
'applicationId' => $applicationId,
|
||||
]);
|
||||
|
||||
$appName = Arr::get($application, 'appName') ?? Arr::get($application, 'name');
|
||||
$monitoring = [];
|
||||
|
||||
if ($appName) {
|
||||
$monitoring = $this->optionalGet('/application.readAppMonitoring', [
|
||||
'appName' => $appName,
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'application' => $application,
|
||||
'monitoring' => $monitoring,
|
||||
'appName' => $appName,
|
||||
];
|
||||
}, 30);
|
||||
}
|
||||
|
||||
public function recentDeployments(string $applicationId, int $limit = 5): array
|
||||
{
|
||||
return $this->cached($this->deploymentCacheKey($applicationId), function () use ($applicationId, $limit) {
|
||||
$deployments = $this->get('/deployment.all', [
|
||||
'applicationId' => $applicationId,
|
||||
]);
|
||||
|
||||
if (! is_array($deployments)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_slice($deployments, 0, $limit);
|
||||
}, 60);
|
||||
}
|
||||
|
||||
public function reloadApplication(string $applicationId, ?Authenticatable $actor = null): array
|
||||
{
|
||||
$status = $this->applicationStatus($applicationId);
|
||||
$appName = Arr::get($status, 'appName');
|
||||
|
||||
if (! $appName) {
|
||||
throw new \RuntimeException('Dokploy application name is required to reload the service.');
|
||||
}
|
||||
|
||||
return $this->dispatchAction(
|
||||
$applicationId,
|
||||
'reload',
|
||||
[
|
||||
'applicationId' => $applicationId,
|
||||
'appName' => $appName,
|
||||
],
|
||||
fn (array $payload) => $this->post('/application.reload', $payload),
|
||||
$actor,
|
||||
);
|
||||
}
|
||||
|
||||
public function redeployApplication(string $applicationId, ?Authenticatable $actor = null): array
|
||||
{
|
||||
return $this->dispatchAction(
|
||||
$applicationId,
|
||||
'redeploy',
|
||||
[
|
||||
'applicationId' => $applicationId,
|
||||
],
|
||||
fn (array $payload) => $this->post('/application.redeploy', $payload),
|
||||
$actor,
|
||||
);
|
||||
}
|
||||
|
||||
protected function cached(string $key, callable $callback, int $seconds): mixed
|
||||
{
|
||||
return Cache::remember($key, now()->addSeconds($seconds), $callback);
|
||||
}
|
||||
|
||||
protected function optionalGet(string $path, array $query = []): array
|
||||
{
|
||||
try {
|
||||
return $this->get($path, $query);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('[Dokploy] Optional GET failed', [
|
||||
'path' => $path,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
protected function get(string $path, array $query = []): array
|
||||
{
|
||||
$response = $this->request()->get($this->normalizePath($path), $query);
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logFailure('GET', $path, $response);
|
||||
throw new RequestException($response);
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
}
|
||||
|
||||
protected function post(string $path, array $payload = []): Response
|
||||
{
|
||||
$response = $this->request()->post($this->normalizePath($path), $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logFailure('POST', $path, $response);
|
||||
throw new RequestException($response);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function request(): PendingRequest
|
||||
{
|
||||
$baseUrl = config('dokploy.api.base_url');
|
||||
$token = config('dokploy.api.token');
|
||||
$timeout = config('dokploy.api.timeout', 10);
|
||||
|
||||
if (! $baseUrl || ! $token) {
|
||||
throw new \RuntimeException('Dokploy API is not configured.');
|
||||
}
|
||||
|
||||
return $this->http
|
||||
->baseUrl($baseUrl)
|
||||
->timeout($timeout)
|
||||
->acceptJson()
|
||||
->withHeaders([
|
||||
'x-api-key' => $token,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function normalizePath(string $path): string
|
||||
{
|
||||
return '/'.ltrim($path, '/');
|
||||
}
|
||||
|
||||
protected function logFailure(string $method, string $path, Response $response): void
|
||||
{
|
||||
Log::error('[Dokploy] API request failed', [
|
||||
'method' => $method,
|
||||
'path' => $path,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function dispatchAction(
|
||||
string $applicationId,
|
||||
string $action,
|
||||
array $payload,
|
||||
callable $callback,
|
||||
?Authenticatable $actor = null,
|
||||
): array {
|
||||
try {
|
||||
$response = $callback($payload);
|
||||
$body = $response->json() ?? [];
|
||||
$status = $response->status();
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logAction($applicationId, $action, $payload, [
|
||||
'error' => $exception->getMessage(),
|
||||
], null, $actor);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$this->logAction($applicationId, $action, $payload, $body, $status, $actor);
|
||||
Cache::forget($this->applicationCacheKey($applicationId));
|
||||
Cache::forget($this->deploymentCacheKey($applicationId));
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
protected function logAction(
|
||||
string $applicationId,
|
||||
string $action,
|
||||
array $payload,
|
||||
array $response,
|
||||
?int $status,
|
||||
?Authenticatable $actor = null,
|
||||
): void {
|
||||
InfrastructureActionLog::create([
|
||||
'user_id' => $actor?->getAuthIdentifier() ?? auth()->id(),
|
||||
'service_id' => $applicationId,
|
||||
'action' => $action,
|
||||
'payload' => $payload,
|
||||
'response' => $response,
|
||||
'status_code' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function applicationCacheKey(string $applicationId): string
|
||||
{
|
||||
return "dokploy.application.{$applicationId}";
|
||||
}
|
||||
|
||||
protected function deploymentCacheKey(string $applicationId): string
|
||||
{
|
||||
return "dokploy.deployments.{$applicationId}";
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'api' => [
|
||||
'base_url' => env('COOLIFY_API_BASE_URL'),
|
||||
'token' => env('COOLIFY_API_TOKEN'),
|
||||
'timeout' => (int) env('COOLIFY_API_TIMEOUT', 5),
|
||||
],
|
||||
'web_url' => env('COOLIFY_WEB_URL'),
|
||||
'services' => json_decode(env('COOLIFY_SERVICE_IDS', '{}'), true) ?? [],
|
||||
];
|
||||
11
config/dokploy.php
Normal file
11
config/dokploy.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'api' => [
|
||||
'base_url' => rtrim(env('DOKPLOY_API_BASE_URL', ''), '/'),
|
||||
'token' => env('DOKPLOY_API_KEY'),
|
||||
'timeout' => (int) env('DOKPLOY_API_TIMEOUT', 10),
|
||||
],
|
||||
'web_url' => env('DOKPLOY_WEB_URL'),
|
||||
'applications' => json_decode(env('DOKPLOY_APPLICATION_IDS', '{}'), true) ?? [],
|
||||
];
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('coolify_action_logs')) {
|
||||
Schema::rename('coolify_action_logs', 'infrastructure_action_logs');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasTable('infrastructure_action_logs')) {
|
||||
Schema::rename('infrastructure_action_logs', 'coolify_action_logs');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,93 +0,0 @@
|
||||
# Coolify Deployment Guide
|
||||
|
||||
Coolify provides a managed Docker host with service orchestration, logs, metrics, CI hooks, and secret management. This document outlines how to run Fotospiel (including the Photobooth FTP stack) on Coolify and how to prepare for SuperAdmin observability.
|
||||
|
||||
## 1. Services to deploy
|
||||
|
||||
| Service | Notes |
|
||||
|---------|-------|
|
||||
| **Laravel App** | Build from this repo. Expose port 8080. Attach environment variables from `.env`. |
|
||||
| **Scheduler** | Clone the app container; command `php artisan schedule:work`. |
|
||||
| **Queue workers** | Use `docs/queue-supervisor/queue-worker.sh` scripts (default, media-storage, media-security). |
|
||||
| **Horizon (optional)** | Add service executing `docs/queue-supervisor/horizon.sh`. |
|
||||
| **Redis / Database** | Use Coolify managed services or bring your own (RDS/Aurora). |
|
||||
| **vsftpd container** | Host FTP on port 2121 and mount the shared Photobooth volume. |
|
||||
| **Photobooth Control Service** | Lightweight API (Go/Node/Laravel Octane) that Coolify can redeploy alongside vsftpd. |
|
||||
|
||||
### Volumes
|
||||
|
||||
- `storage-app` (Laravel `storage`, uploads, compiled views).
|
||||
- `photobooth` (shared between vsftpd, control-service, and Laravel).
|
||||
- Database/Redis volumes if self-hosted.
|
||||
|
||||
Mount these volumes in Coolify under “Persistent Storage” for each service.
|
||||
|
||||
## 2. Environment & Secrets
|
||||
|
||||
Configure the following keys inside Coolify’s “Environment Variables” panel:
|
||||
|
||||
- All standard Laravel vars (`APP_KEY`, `DB_*`, `QUEUE_CONNECTION`, `AWS_*` etc.).
|
||||
- Photobooth block (as documented in `.env.example`): `PHOTOBOOTH_CONTROL_*`, `PHOTOBOOTH_FTP_HOST/PORT`, `PHOTOBOOTH_IMPORT_*`.
|
||||
- New Coolify integration vars (planned for SuperAdmin widgets):
|
||||
|
||||
```
|
||||
COOLIFY_API_BASE_URL=https://coolify.example.com/api/v1
|
||||
COOLIFY_API_TOKEN=... # generated per project
|
||||
COOLIFY_SERVICE_IDS={"app":"svc_xxx","ftp":"svc_yyy"}
|
||||
```
|
||||
|
||||
Store the JSON mapping so Laravel knows which Coolify “service” controls the app, queue, vsftpd, etc.
|
||||
|
||||
## 3. Deploy steps
|
||||
|
||||
1. Add the Git repository to Coolify (build hook). Configure the Dockerfile build args if needed.
|
||||
2. Define services:
|
||||
- **App**: HTTP worker (build & run). Health check `/up`.
|
||||
- **Scheduler**: same image, command `php artisan schedule:work`.
|
||||
- **Queue**: command `/var/www/html/docs/queue-supervisor/queue-worker.sh default`.
|
||||
- Additional queue types as separate services.
|
||||
3. Configure networks so all services share the same internal bridge, allowing Redis/DB connectivity.
|
||||
4. Attach the `photobooth` volume to both vsftpd and the Laravel app.
|
||||
5. Run `php artisan migrate --force` from Coolify’s “One-off command” console after the first deploy.
|
||||
6. Seed storage targets if necessary (`php artisan db:seed --class=MediaStorageTargetSeeder --force`).
|
||||
|
||||
## 4. Metrics & Controls for SuperAdmin
|
||||
|
||||
To surface Coolify data inside the platform:
|
||||
|
||||
1. **API Token** – create a Coolify PAT with read access to services and optional “actions” scope.
|
||||
2. **Laravel config** – introduce `config/coolify.php` with base URL, token, and service IDs.
|
||||
3. **Service client** – wrap Coolify endpoints:
|
||||
- `GET /services/{id}` → CPU/RAM, status, last deploy, git SHA.
|
||||
- `POST /services/{id}/actions/restart` for restart buttons.
|
||||
- `GET /deployments/{id}/logs` for tailing last deploy logs.
|
||||
4. **Filament widgets** – in SuperAdmin dashboard add:
|
||||
- **Platform Health**: per service status (App, Queue, Scheduler, vsftpd, Control Service).
|
||||
- **Recent Deploys**: table of the last Coolify deployments and commit messages.
|
||||
- **Actions**: buttons (with confirmations) to restart vsftpd or re-run `photobooth:ingest` service.
|
||||
|
||||
Ensure all requests are audited (database table) and require SuperAdmin role.
|
||||
|
||||
## 5. FTP container controls
|
||||
|
||||
Coolify makes it easier to:
|
||||
|
||||
- View vsftpd metrics (CPU, memory, network) directly; replicate those values in SuperAdmin via the API.
|
||||
- Trigger redeploys of the vsftpd service when Photobooth settings change (Laravel can call Coolify’s redeploy endpoint).
|
||||
- Inspect container logs from SuperAdmin by proxying `GET /services/{id}/logs?tail=200`.
|
||||
|
||||
## 6. Monitoring & Alerts
|
||||
|
||||
- Configure Coolify Webhooks (Deploy succeeded/failed, service unhealthy) → point to a Laravel route, mark incidents in `photobooth_metadata`.
|
||||
- Use Coolify’s built-in notifications (Slack, email) for infrastructure-level alerts; complement with Laravel notifications for application-level events (e.g., ingest failures).
|
||||
|
||||
## 7. Production readiness checklist
|
||||
|
||||
1. All services built and running in Coolify with health checks.
|
||||
2. Volumes backed up (database snapshots + `storage` tarball).
|
||||
3. Photobooth shared volume mounted & writeable by vsftpd + Laravel.
|
||||
4. Environment variables set (APP_KEY, DB creds, Photobooth block, Coolify API token).
|
||||
5. Scheduler & queue services logging to Coolify.
|
||||
6. SuperAdmin Filament widgets wired to Coolify API (optional but recommended).
|
||||
|
||||
With this setup you can manage deployments, restarts, and metrics centrally while still using Laravel’s built-in scheduler and worker scripts. The next step is implementing the `CoolifyClient` + Filament widgets described above.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This guide describes the recommended, repeatable way to run the Fotospiel platform in Docker for production or high-fidelity staging environments. It pairs a multi-stage build (PHP-FPM + asset pipeline) with a Compose stack that includes Nginx, worker processes, Redis, and MySQL.
|
||||
|
||||
> **Coolify users:** see `docs/deployment/coolify.md` for service definitions, secrets, and how to wire the same containers (web, queue, scheduler, vsftpd) inside Coolify. That document builds on the base Docker instructions below.
|
||||
> **Dokploy users:** see `docs/deployment/dokploy.md` for service definitions, secrets, and how to wire the same containers (web, queue, scheduler, vsftpd) inside Dokploy. That document builds on the base Docker instructions below.
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
|
||||
122
docs/deployment/dokploy.md
Normal file
122
docs/deployment/dokploy.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Dokploy Deployment Guide
|
||||
|
||||
Dokploy is our self-hosted PaaS for orchestrating the Fotospiel stack (Laravel app, scheduler, queue workers, Horizon, and the Photobooth FTP pipeline). This guide explains how to provision the services in Dokploy and how to wire the SuperAdmin observability widgets that now talk to the Dokploy API.
|
||||
|
||||
## 1. Services to provision
|
||||
|
||||
| Service | Notes |
|
||||
|---------|-------|
|
||||
| **Laravel App** | Build from this repository. Expose port 8080 (or Dokploy HTTP service). Attach the production `.env`. Health check `/up`. |
|
||||
| **Scheduler** | Clone the app container; command `php artisan schedule:work`. |
|
||||
| **Queue workers** | Use `docs/queue-supervisor/queue-worker.sh` scripts (default, media-storage, media-security). Deploy each as a dedicated Dokploy application or Docker service. |
|
||||
| **Horizon (optional)** | Run `docs/queue-supervisor/horizon.sh` for dashboard + metrics. |
|
||||
| **Redis / Database** | Use managed offerings or self-host in Dokploy. Configure network access for the app + workers. |
|
||||
| **vsftpd container** | Expose port 2121 and mount the shared Photobooth volume. |
|
||||
| **Photobooth Control Service** | Lightweight API (Go/Node/Laravel Octane) that can be redeployed together with vsftpd for ingest controls. |
|
||||
|
||||
### Volumes
|
||||
|
||||
Create persistent volumes inside Dokploy and mount them across the services:
|
||||
|
||||
- `storage-app` – Laravel `storage`, uploads, compiled views.
|
||||
- `photobooth` – shared by vsftpd, the control service, and Laravel for ingest.
|
||||
- Database / Redis volumes if you self-manage those containers.
|
||||
|
||||
## 2. Environment & secrets
|
||||
|
||||
Every Dokploy application should include the regular Laravel secrets (see `.env.example`). Important blocks:
|
||||
|
||||
- `APP_KEY`, `APP_URL`, `DB_*`, `CACHE_DRIVER`, `QUEUE_CONNECTION`, `MAIL_*`.
|
||||
- Photobooth integration (`PHOTOBOOTH_CONTROL_*`, `PHOTOBOOTH_FTP_*`, `PHOTOBOOTH_IMPORT_*`).
|
||||
- AWS / S3 credentials if the tenant media is stored remotely.
|
||||
|
||||
### Dokploy integration variables
|
||||
|
||||
Add the infrastructure observability variables to the Laravel app environment:
|
||||
|
||||
```
|
||||
DOKPLOY_API_BASE_URL=https://dokploy.example.com/api
|
||||
DOKPLOY_API_KEY=pat_xxxxxxxxxxxxxxxxx
|
||||
DOKPLOY_WEB_URL=https://dokploy.example.com
|
||||
DOKPLOY_APPLICATION_IDS={"app":"app_123","queue":"app_456","scheduler":"app_789","ftp":"app_abc"}
|
||||
DOKPLOY_API_TIMEOUT=10
|
||||
```
|
||||
|
||||
- `DOKPLOY_APPLICATION_IDS` is a JSON object mapping human labels to Dokploy `applicationId` values. Those IDs drive the SuperAdmin widget buttons.
|
||||
- The API key needs permission to read the project, query deployments, and trigger `application.redeploy` / `application.reload`.
|
||||
|
||||
## 3. Project & server setup
|
||||
|
||||
1. **Register the Docker host** in Dokploy (`Servers → Add Server`). Install the Dokploy agent on the target VM.
|
||||
2. **Create a Project** (e.g., `fotospiel-prod`) to group all services.
|
||||
3. **Attach repositories** using Dokploy Git providers (GitHub / Gitea / GitLab / Bitbucket) or Docker images. Fotospiel uses the source build (Dockerfile at repo root).
|
||||
4. **Networking** – keep all services on the same internal network so they can talk to Redis/DB. Expose the public HTTP service only for the Laravel app (behind Traefik/Let’s Encrypt).
|
||||
|
||||
## 4. Deploy applications
|
||||
|
||||
Follow these steps for each component:
|
||||
|
||||
1. **Laravel HTTP app**
|
||||
- Build from the repo.
|
||||
- `Dockerfile` already exposes port `8080`.
|
||||
- Set branch (e.g. `main`) for automatic deployments.
|
||||
- Add health check `/up`.
|
||||
- Mount `storage-app` and `photobooth` volumes.
|
||||
|
||||
2. **Scheduler**
|
||||
- Duplicate the image.
|
||||
- Override command: `php artisan schedule:work`.
|
||||
- Disable HTTP exposure.
|
||||
|
||||
3. **Queue workers**
|
||||
- Duplicate the image.
|
||||
- Commands:
|
||||
- `docs/queue-supervisor/queue-worker.sh default`
|
||||
- `docs/queue-supervisor/queue-worker.sh media-storage`
|
||||
- `docs/queue-supervisor/queue-worker.sh media-security`
|
||||
- Optionally create a dedicated container for Horizon using `docs/queue-supervisor/horizon.sh`.
|
||||
|
||||
4. **vsftpd + Photobooth control**
|
||||
- Deploy the ftp image (see `docker-compose` setup) or reuse Dokploy’s Docker Compose support.
|
||||
- Mount `photobooth` volume read-write.
|
||||
|
||||
5. **Database/Redis**
|
||||
- Dokploy can provision standard MySQL/Postgres/Redis apps. Configure credentials to match `.env`.
|
||||
|
||||
6. **Apply migrations**
|
||||
- Use Dokploy one-off command to run `php artisan migrate --force` on first deploy.
|
||||
- Seed storage targets if required: `php artisan db:seed --class=MediaStorageTargetSeeder --force`.
|
||||
|
||||
## 5. SuperAdmin observability (Dokploy API)
|
||||
|
||||
The SuperAdmin dashboard now uses the Dokploy API to fetch health data and trigger actions:
|
||||
|
||||
1. **Config file** – `config/dokploy.php` reads the environment variables above.
|
||||
2. **Client** – `App\Services\Dokploy\DokployClient` wraps key endpoints:
|
||||
- `GET /application.one?applicationId=...` → status + metadata.
|
||||
- `GET /application.readAppMonitoring?appName=...` → CPU & memory metrics.
|
||||
- `GET /deployment.all?applicationId=...` → latest deployments for history.
|
||||
- `POST /application.reload` (requires `applicationId` + `appName`).
|
||||
- `POST /application.redeploy` (redeploy latest commit).
|
||||
3. **Widgets / pages** – `DokployPlatformHealth` widget displays the mapped applications, and the `DokployDeployments` page exposes reload/redeploy buttons plus a log table (`InfrastructureActionLog`).
|
||||
4. **Auditing** – all actions persist to `infrastructure_action_logs` with user, payload, response, and status code.
|
||||
|
||||
Only SuperAdmins should have access to these widgets. If you rotate the API key, update the `.env` and deploy the app to refresh the cache.
|
||||
|
||||
## 6. Monitoring & alerts
|
||||
|
||||
- Dokploy already produces container metrics and deployment logs. Surface the most important ones (CPU, memory, last deployment) through the widget using the monitoring endpoint.
|
||||
- Configure Dokploy webhooks (Deploy succeeded/failed, health alerts) to call a Laravel route that records incidents in `photobooth_metadata` or sends notifications.
|
||||
- Use Dokploy’s Slack/email integrations for infrastructure-level alerts. Application-specific alerts (e.g., ingest failures) still live inside Laravel notifications.
|
||||
|
||||
## 7. Production readiness checklist
|
||||
|
||||
1. All applications deployed in Dokploy with health checks and attached volumes.
|
||||
2. `photobooth` volume mounted for Laravel + vsftpd + control service.
|
||||
3. Database/Redis backups scheduled (Dokploy snapshot or external tooling).
|
||||
4. `.env` contains the Dokploy API credentials and application ID mapping.
|
||||
5. Scheduler, workers, and Horizon logging visible in Dokploy.
|
||||
6. SuperAdmin widgets show green health states and allow reload/redeploy actions.
|
||||
7. Webhooks/alerts configured for failed deployments or unhealthy containers.
|
||||
|
||||
With this setup the Fotospiel team can manage deployments, restarts, and metrics centrally through Dokploy while Laravel’s scheduler and workers continue to run within the same infrastructure.
|
||||
@@ -118,14 +118,14 @@ When deploying new code:
|
||||
3. Recreate worker/horizon containers: `docker compose up -d --force-recreate queue-worker media-storage-worker horizon`.
|
||||
4. Tail logs to confirm workers boot cleanly and start consuming jobs.
|
||||
|
||||
### 8. Running inside Coolify
|
||||
### 8. Running inside Dokploy
|
||||
|
||||
If you host Fotospiel on Coolify:
|
||||
If you host Fotospiel on Dokploy:
|
||||
|
||||
- Create separate Coolify “services” for each worker type using the same image and command snippets above (`queue-worker.sh default`, `media-storage`, etc.).
|
||||
- Create separate Dokploy applications for each worker type using the same image and command snippets above (`queue-worker.sh default`, `media-storage`, etc.).
|
||||
- Attach the same environment variables and storage volumes defined for the main app.
|
||||
- Use Coolify’s “One-off command” feature to run migrations or `queue:retry`.
|
||||
- Expose the Horizon service through Coolify’s HTTP proxy (or keep it internal and access via SSH tunnel).
|
||||
- Enable health checks so Coolify restarts workers automatically if they exit unexpectedly.
|
||||
- Use Dokploy’s one-off command feature to run migrations or `queue:retry`.
|
||||
- Expose the Horizon service through the Dokploy HTTP proxy (or keep it internal and access via SSH tunnel).
|
||||
- Enable health checks so Dokploy restarts workers automatically if they exit unexpectedly.
|
||||
|
||||
These services can be observed and restarted from Coolify’s dashboard; the upcoming SuperAdmin integration will surface the same metrics/actions through a dedicated Filament widget.
|
||||
These services can be observed, redeployed, or reloaded from Dokploy’s dashboard and from the SuperAdmin integration powered by the Dokploy API.
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
<x-filament::section heading="Service Controls">
|
||||
<x-filament::section heading="Application Controls">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
@foreach($services as $service)
|
||||
@foreach($applications as $application)
|
||||
<div class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 shadow-sm dark:border-white/10 dark:bg-slate-900/60">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200">{{ $service['label'] }}</p>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">{{ $service['service_id'] }}</p>
|
||||
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200">{{ $application['label'] }}</p>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">{{ $application['application_id'] }}</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-100">
|
||||
{{ ucfirst($service['status'] ?? 'unknown') }}
|
||||
{{ ucfirst($application['status'] ?? 'unknown') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<x-filament::button size="sm" color="warning" wire:click="restart('{{ $service['service_id'] }}')">
|
||||
Restart
|
||||
<x-filament::button size="sm" color="warning" wire:click="reload('{{ $application['application_id'] }}')">
|
||||
Reload
|
||||
</x-filament::button>
|
||||
<x-filament::button size="sm" color="gray" wire:click="redeploy('{{ $service['service_id'] }}')">
|
||||
<x-filament::button size="sm" color="gray" wire:click="redeploy('{{ $application['application_id'] }}')">
|
||||
Redeploy
|
||||
</x-filament::button>
|
||||
@if($coolifyWebUrl)
|
||||
<x-filament::button tag="a" size="sm" color="gray" href="{{ $coolifyWebUrl }}/services/{{ $service['service_id'] }}" target="_blank">
|
||||
Open in Coolify
|
||||
@if($dokployWebUrl)
|
||||
<x-filament::button tag="a" size="sm" color="gray" href="{{ rtrim($dokployWebUrl, '/') }}/applications/{{ $application['application_id'] }}" target="_blank">
|
||||
Open in Dokploy
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@
|
||||
<tr class="text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<th class="px-3 py-2">When</th>
|
||||
<th class="px-3 py-2">User</th>
|
||||
<th class="px-3 py-2">Service</th>
|
||||
<th class="px-3 py-2">Application</th>
|
||||
<th class="px-3 py-2">Action</th>
|
||||
<th class="px-3 py-2">Status</th>
|
||||
</tr>
|
||||
@@ -61,7 +61,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<x-filament::link href="{{ route('filament.superadmin.resources.coolify-action-logs.index') }}" class="mt-3 inline-flex text-sm text-primary-600">
|
||||
<x-filament::link href="{{ route('filament.superadmin.resources.infrastructure-action-logs.index') }}" class="mt-3 inline-flex text-sm text-primary-600">
|
||||
View full log →
|
||||
</x-filament::link>
|
||||
</x-filament::section>
|
||||
@@ -1,47 +1,52 @@
|
||||
<x-filament-widgets::widget>
|
||||
<x-filament::section heading="Infra Status (Coolify)">
|
||||
<x-filament::section heading="Infra Status (Dokploy)">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
@forelse($services as $service)
|
||||
@forelse($applications as $application)
|
||||
<div class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 shadow-sm dark:border-white/10 dark:bg-slate-900/60">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-600 dark:text-slate-200">{{ $service['label'] }}</p>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">{{ $service['service_id'] }}</p>
|
||||
<p class="text-sm font-semibold text-slate-600 dark:text-slate-200">{{ $application['label'] }}</p>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">{{ $application['app_name'] ?? $application['application_id'] }}</p>
|
||||
<p class="text-[11px] text-slate-400 dark:text-slate-500">{{ $application['application_id'] }}</p>
|
||||
</div>
|
||||
<span @class([
|
||||
'rounded-full px-3 py-1 text-xs font-semibold',
|
||||
'bg-emerald-100 text-emerald-800' => $service['status'] === 'running',
|
||||
'bg-amber-100 text-amber-800' => $service['status'] === 'deploying',
|
||||
'bg-rose-100 text-rose-800' => $service['status'] === 'unreachable' || $service['status'] === 'error',
|
||||
'bg-slate-100 text-slate-600' => ! in_array($service['status'], ['running', 'deploying', 'unreachable', 'error']),
|
||||
'bg-emerald-100 text-emerald-800' => $application['status'] === 'running',
|
||||
'bg-amber-100 text-amber-800' => in_array($application['status'], ['deploying', 'idle']),
|
||||
'bg-rose-100 text-rose-800' => in_array($application['status'], ['unreachable', 'error']),
|
||||
'bg-slate-100 text-slate-600' => ! in_array($application['status'], ['running', 'deploying', 'idle', 'unreachable', 'error']),
|
||||
])>
|
||||
{{ ucfirst($service['status']) }}
|
||||
{{ ucfirst($application['status']) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if(isset($service['error']))
|
||||
<p class="mt-3 text-xs text-rose-600 dark:text-rose-400">{{ $service['error'] }}</p>
|
||||
@if(isset($application['error']))
|
||||
<p class="mt-3 text-xs text-rose-600 dark:text-rose-400">{{ $application['error'] }}</p>
|
||||
@else
|
||||
<dl class="mt-3 grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<dt class="text-slate-500 dark:text-slate-400">CPU</dt>
|
||||
<dd class="font-semibold text-slate-900 dark:text-white">{{ $service['cpu'] ?? '—' }}%</dd>
|
||||
<dd class="font-semibold text-slate-900 dark:text-white">
|
||||
{{ isset($application['cpu']) ? $application['cpu'].'%' : '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500 dark:text-slate-400">Memory</dt>
|
||||
<dd class="font-semibold text-slate-900 dark:text-white">{{ $service['memory'] ?? '—' }}%</dd>
|
||||
<dd class="font-semibold text-slate-900 dark:text-white">
|
||||
{{ isset($application['memory']) ? $application['memory'].'%' : '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500 dark:text-slate-400">Last Deploy</dt>
|
||||
<dd class="font-semibold text-slate-900 dark:text-white">
|
||||
{{ $service['last_deploy'] ? \Illuminate\Support\Carbon::parse($service['last_deploy'])->diffForHumans() : '—' }}
|
||||
{{ $application['last_deploy'] ? \Illuminate\Support\Carbon::parse($application['last_deploy'])->diffForHumans() : '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-slate-500 dark:text-slate-300">No Coolify services configured.</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-300">No Dokploy applications configured.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</x-filament::section>
|
||||
Reference in New Issue
Block a user