Commit ccb4028c authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add health checks, env editor, timeline, dark mode, retry deploy

- Health check service pings instance URLs, shows status indicators
- Env var editor with add/remove/save (single CapRover API call)
- Activity timeline on instance detail page (from audit log)
- Retry deploy button for failed instances
- Maintenance mode toggle (scale to 0/1 replicas)
- Dark mode via Tailwind + localStorage toggle
- Dashboard: health check all, recent activity feed, total revenue
- Instance list: health dots, failed filter, responsive columns
- Logout button in header
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 28d57b3e
...@@ -2,19 +2,32 @@ ...@@ -2,19 +2,32 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\AuditLog;
use App\Models\BillingPeriod; use App\Models\BillingPeriod;
use App\Models\Instance; use App\Models\Instance;
use App\Models\PlatformFee; use App\Models\PlatformFee;
use App\Services\HealthCheckService;
use Livewire\Component; use Livewire\Component;
class Dashboard extends Component class Dashboard extends Component
{ {
public array $healthStatuses = [];
public bool $healthLoaded = false;
public function checkAllHealth(): void
{
$instances = Instance::whereIn('status', ['active', 'trial'])->get();
$this->healthStatuses = app(HealthCheckService::class)->checkMultiple($instances);
$this->healthLoaded = true;
}
public function render() public function render()
{ {
$totalInstances = Instance::where('status', '!=', 'deleted')->count(); $totalInstances = Instance::where('status', '!=', 'deleted')->count();
$activeInstances = Instance::where('status', 'active')->count(); $activeInstances = Instance::where('status', 'active')->count();
$trialInstances = Instance::where('status', 'trial')->count(); $trialInstances = Instance::where('status', 'trial')->count();
$suspendedInstances = Instance::where('status', 'suspended')->count(); $suspendedInstances = Instance::where('status', 'suspended')->count();
$failedInstances = Instance::where('status', 'failed')->count();
$currentMonth = now()->startOfMonth(); $currentMonth = now()->startOfMonth();
$monthlyFeesDue = BillingPeriod::where('period_start', '>=', $currentMonth) $monthlyFeesDue = BillingPeriod::where('period_start', '>=', $currentMonth)
...@@ -33,16 +46,27 @@ public function render() ...@@ -33,16 +46,27 @@ public function render()
->limit(5) ->limit(5)
->get(); ->get();
$recentActivity = AuditLog::with(['user', 'instance'])
->orderByDesc('created_at')
->limit(10)
->get();
$totalRevenue = PlatformFee::where('source', 'pull')->sum('fee_amount')
+ BillingPeriod::where('status', 'paid')->sum('total_paid');
return view('livewire.dashboard', [ return view('livewire.dashboard', [
'totalInstances' => $totalInstances, 'totalInstances' => $totalInstances,
'activeInstances' => $activeInstances, 'activeInstances' => $activeInstances,
'trialInstances' => $trialInstances, 'trialInstances' => $trialInstances,
'suspendedInstances' => $suspendedInstances, 'suspendedInstances' => $suspendedInstances,
'failedInstances' => $failedInstances,
'monthlyFeesDue' => $monthlyFeesDue, 'monthlyFeesDue' => $monthlyFeesDue,
'monthlyFeesPaid' => $monthlyFeesPaid, 'monthlyFeesPaid' => $monthlyFeesPaid,
'platformFeesThisMonth' => $platformFeesThisMonth, 'platformFeesThisMonth' => $platformFeesThisMonth,
'overdue' => $overdue, 'overdue' => $overdue,
'recentInstances' => $recentInstances, 'recentInstances' => $recentInstances,
'recentActivity' => $recentActivity,
'totalRevenue' => $totalRevenue,
])->layout('layouts.app', ['title' => 'Dashboard']); ])->layout('layouts.app', ['title' => 'Dashboard']);
} }
} }
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
use App\Models\Instance; use App\Models\Instance;
use App\Services\CapRoverService; use App\Services\CapRoverService;
use App\Services\HealthCheckService;
use App\Services\InstanceProvisionerService; use App\Services\InstanceProvisionerService;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
...@@ -19,6 +20,9 @@ class InstanceList extends Component ...@@ -19,6 +20,9 @@ class InstanceList extends Component
#[Url] #[Url]
public string $status = ''; public string $status = '';
public array $healthStatuses = [];
public bool $healthLoaded = false;
public function updatedSearch(): void public function updatedSearch(): void
{ {
$this->resetPage(); $this->resetPage();
...@@ -29,6 +33,24 @@ public function updatedStatus(): void ...@@ -29,6 +33,24 @@ public function updatedStatus(): void
$this->resetPage(); $this->resetPage();
} }
public function checkHealth(): void
{
$instances = Instance::whereIn('status', ['active', 'trial'])->get();
$this->healthStatuses = app(HealthCheckService::class)->checkMultiple($instances);
$this->healthLoaded = true;
}
public function retryDeploy(int $id): void
{
$instance = Instance::findOrFail($id);
try {
app(InstanceProvisionerService::class)->retryFailed($instance);
session()->flash('success', "Re-provisioning started for {$instance->academy_name_en}.");
} catch (\Throwable $e) {
session()->flash('error', "Retry failed: {$e->getMessage()}");
}
}
public function forceBuild(int $id): void public function forceBuild(int $id): void
{ {
$instance = Instance::findOrFail($id); $instance = Instance::findOrFail($id);
......
...@@ -2,8 +2,10 @@ ...@@ -2,8 +2,10 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\AuditLog;
use App\Models\Instance; use App\Models\Instance;
use App\Services\CapRoverService; use App\Services\CapRoverService;
use App\Services\HealthCheckService;
use App\Services\InstanceProvisionerService; use App\Services\InstanceProvisionerService;
use Livewire\Component; use Livewire\Component;
...@@ -16,15 +18,31 @@ class InstanceShow extends Component ...@@ -16,15 +18,31 @@ class InstanceShow extends Component
public bool $stateLoaded = false; public bool $stateLoaded = false;
public ?string $stateError = null; public ?string $stateError = null;
public ?string $logsError = null; public ?string $logsError = null;
public array $healthStatus = [];
public bool $showEnvEditor = false;
public array $envVars = [];
public string $newEnvKey = '';
public string $newEnvValue = '';
public function mount(Instance $instance): void public function mount(Instance $instance): void
{ {
$this->instance = $instance; $this->instance = $instance;
if (!in_array($instance->status, ['deleted', 'failed'])) { if (!in_array($instance->status, ['deleted', 'failed'])) {
$this->loadState(); $this->loadState();
$this->checkHealth();
} }
} }
public function checkHealth(): void
{
if (in_array($this->instance->status, ['deleted', 'failed', 'suspended'])) {
$this->healthStatus = ['status' => 'suspended', 'checked_at' => now()->toIso8601String()];
return;
}
$this->healthStatus = app(HealthCheckService::class)->check($this->instance);
}
public function loadState(): void public function loadState(): void
{ {
try { try {
...@@ -43,6 +61,7 @@ public function loadState(): void ...@@ -43,6 +61,7 @@ public function loadState(): void
'versions' => array_slice($info['versions'] ?? [], -5), 'versions' => array_slice($info['versions'] ?? [], -5),
'envVarCount' => count($info['envVars'] ?? []), 'envVarCount' => count($info['envVars'] ?? []),
'volumeCount' => count($info['volumes'] ?? []), 'volumeCount' => count($info['volumes'] ?? []),
'envVars' => $info['envVars'] ?? [],
]; ];
$this->stateLoaded = true; $this->stateLoaded = true;
$this->stateError = null; $this->stateError = null;
...@@ -52,6 +71,66 @@ public function loadState(): void ...@@ -52,6 +71,66 @@ public function loadState(): void
} }
} }
public function toggleEnvEditor(): void
{
$this->showEnvEditor = !$this->showEnvEditor;
if ($this->showEnvEditor && !empty($this->appState['envVars'])) {
$this->envVars = $this->appState['envVars'];
}
}
public function updateEnvVar(int $index, string $value): void
{
if (isset($this->envVars[$index])) {
$this->envVars[$index]['value'] = $value;
}
}
public function removeEnvVar(int $index): void
{
array_splice($this->envVars, $index, 1);
}
public function addEnvVar(): void
{
if (empty($this->newEnvKey)) {
return;
}
$this->envVars[] = ['key' => $this->newEnvKey, 'value' => $this->newEnvValue];
$this->newEnvKey = '';
$this->newEnvValue = '';
}
public function saveEnvVars(): void
{
try {
$caprover = app(CapRoverService::class);
$envMap = [];
foreach ($this->envVars as $var) {
if (!empty($var['key'])) {
$envMap[$var['key']] = $var['value'] ?? '';
}
}
$config = ['envVars' => $envMap];
if (isset($this->appState['containerHttpPort'])) {
$config['containerHttpPort'] = $this->appState['containerHttpPort'];
}
if (isset($this->appState['instanceCount'])) {
$config['instanceCount'] = $this->appState['instanceCount'];
}
$caprover->configureAppFull($this->instance->app_name, $config);
AuditLog::record('env_updated', $this->instance, ['count' => count($envMap)]);
session()->flash('success', 'Environment variables saved. Trigger a build to apply changes.');
$this->showEnvEditor = false;
$this->loadState();
} catch (\Throwable $e) {
session()->flash('error', 'Failed to save env vars: ' . $e->getMessage());
}
}
public function fetchLogs(): void public function fetchLogs(): void
{ {
try { try {
...@@ -80,6 +159,7 @@ public function forceBuild(): void ...@@ -80,6 +159,7 @@ public function forceBuild(): void
{ {
try { try {
app(CapRoverService::class)->forceBuild($this->instance->app_name); app(CapRoverService::class)->forceBuild($this->instance->app_name);
AuditLog::record('force_build', $this->instance);
session()->flash('success', 'Build triggered — CapRover is pulling from git and building.'); session()->flash('success', 'Build triggered — CapRover is pulling from git and building.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
session()->flash('error', 'Build failed: ' . $e->getMessage()); session()->flash('error', 'Build failed: ' . $e->getMessage());
...@@ -87,6 +167,51 @@ public function forceBuild(): void ...@@ -87,6 +167,51 @@ public function forceBuild(): void
$this->stateLoaded = false; $this->stateLoaded = false;
} }
public function retryDeploy(): void
{
if ($this->instance->status !== 'failed') {
return;
}
try {
$provisioner = app(InstanceProvisionerService::class);
$provisioner->retryFailed($this->instance);
$this->instance->refresh();
session()->flash('success', 'Re-provisioning started.');
$this->loadState();
$this->checkHealth();
} catch (\Throwable $e) {
session()->flash('error', 'Retry failed: ' . $e->getMessage());
}
}
public function toggleMaintenance(): void
{
try {
$caprover = app(CapRoverService::class);
if (($this->appState['instanceCount'] ?? 0) > 0) {
$caprover->configureAppFull($this->instance->app_name, [
'instanceCount' => 0,
'envVars' => collect($this->appState['envVars'] ?? [])->pluck('value', 'key')->toArray(),
'containerHttpPort' => $this->appState['containerHttpPort'] ?? 80,
]);
AuditLog::record('maintenance_on', $this->instance);
session()->flash('success', 'Instance put into maintenance mode (0 replicas).');
} else {
$caprover->configureAppFull($this->instance->app_name, [
'instanceCount' => 1,
'envVars' => collect($this->appState['envVars'] ?? [])->pluck('value', 'key')->toArray(),
'containerHttpPort' => $this->appState['containerHttpPort'] ?? 80,
]);
AuditLog::record('maintenance_off', $this->instance);
session()->flash('success', 'Instance brought back online (1 replica).');
}
$this->loadState();
} catch (\Throwable $e) {
session()->flash('error', 'Maintenance toggle failed: ' . $e->getMessage());
}
}
public function suspend(): void public function suspend(): void
{ {
app(InstanceProvisionerService::class)->suspend($this->instance, 'Manual suspension from dashboard'); app(InstanceProvisionerService::class)->suspend($this->instance, 'Manual suspension from dashboard');
...@@ -99,6 +224,7 @@ public function resume(): void ...@@ -99,6 +224,7 @@ public function resume(): void
app(InstanceProvisionerService::class)->resume($this->instance); app(InstanceProvisionerService::class)->resume($this->instance);
$this->instance->refresh(); $this->instance->refresh();
$this->stateLoaded = false; $this->stateLoaded = false;
$this->checkHealth();
} }
public function delete(): void public function delete(): void
...@@ -115,7 +241,14 @@ public function render() ...@@ -115,7 +241,14 @@ public function render()
$q->orderByDesc('created_at')->limit(10); $q->orderByDesc('created_at')->limit(10);
}]); }]);
return view('livewire.instance-show') $timeline = AuditLog::where('instance_id', $this->instance->id)
->layout('layouts.app', ['title' => $this->instance->academy_name_en ?? $this->instance->app_name]); ->with('user')
->orderByDesc('created_at')
->limit(20)
->get();
return view('livewire.instance-show', [
'timeline' => $timeline,
])->layout('layouts.app', ['title' => $this->instance->academy_name_en ?? $this->instance->app_name]);
} }
} }
<?php
namespace App\Services;
use App\Models\Instance;
use Illuminate\Support\Facades\Http;
class HealthCheckService
{
public function check(Instance $instance): array
{
$url = $instance->url;
$start = microtime(true);
try {
$response = Http::withoutVerifying()
->timeout(10)
->connectTimeout(5)
->get($url);
$latency = round((microtime(true) - $start) * 1000);
return [
'status' => $response->successful() ? 'healthy' : 'degraded',
'http_code' => $response->status(),
'latency_ms' => $latency,
'checked_at' => now()->toIso8601String(),
];
} catch (\Illuminate\Http\Client\ConnectionException $e) {
return [
'status' => 'down',
'http_code' => null,
'latency_ms' => null,
'error' => 'Connection timeout',
'checked_at' => now()->toIso8601String(),
];
} catch (\Throwable $e) {
return [
'status' => 'down',
'http_code' => null,
'latency_ms' => null,
'error' => class_basename($e) . ': ' . $e->getMessage(),
'checked_at' => now()->toIso8601String(),
];
}
}
public function checkMultiple(iterable $instances): array
{
$results = [];
foreach ($instances as $instance) {
$results[$instance->id] = $this->check($instance);
}
return $results;
}
}
...@@ -219,6 +219,38 @@ public function delete(Instance $instance): void ...@@ -219,6 +219,38 @@ public function delete(Instance $instance): void
]); ]);
} }
public function retryFailed(Instance $instance): void
{
if ($instance->status !== 'failed') {
throw new RuntimeException('Can only retry failed instances.');
}
$originalAppName = preg_replace('/-failed-\d+$/', '', $instance->app_name);
$dbPassword = Crypt::decryptString($instance->db_password_encrypted);
$adminPassword = Str::random(12);
$instance->update([
'app_name' => $originalAppName,
'status' => 'provisioning',
'caprover_deploy_log' => null,
]);
try {
$this->createDatabase($instance->db_name, $instance->db_user, $dbPassword);
$this->deployApp($instance, $dbPassword, $adminPassword);
$instance->update(['status' => $instance->trial_ends_at ? 'trial' : 'active']);
} catch (\Throwable $e) {
$instance->update([
'status' => 'failed',
'app_name' => $originalAppName . '-failed-' . now()->timestamp,
'caprover_deploy_log' => $e->getMessage(),
]);
throw new RuntimeException("Retry failed: {$e->getMessage()}", 0, $e);
}
AuditLog::record('retry_deploy', $instance);
}
public function updateEnvVars(Instance $instance, array $vars): void public function updateEnvVars(Instance $instance, array $vars): void
{ {
$this->caprover->setEnvVars($instance->app_name, $vars); $this->caprover->setEnvVars($instance->app_name, $vars);
......
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="h-full"> <html lang="en" class="h-full" x-data="{ dark: localStorage.getItem('darkMode') === 'true' }" :class="dark ? 'dark' : ''">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title ?? 'El Captain Manager' }}</title> <title>{{ $title ?? 'El Captain Manager' }}</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: 'class' }
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> <style>
body { font-family: 'Inter', system-ui, sans-serif; } body { font-family: 'Inter', system-ui, sans-serif; }
...@@ -12,10 +15,10 @@ ...@@ -12,10 +15,10 @@
</style> </style>
@livewireStyles @livewireStyles
</head> </head>
<body class="h-full bg-gray-50"> <body class="h-full bg-gray-50 dark:bg-gray-950 transition-colors">
<div class="min-h-full" x-data="{ sidebarOpen: false }"> <div class="min-h-full" x-data="{ sidebarOpen: false }">
{{-- Sidebar --}} {{-- Sidebar --}}
<aside class="fixed inset-y-0 left-0 z-30 w-64 bg-gray-900 transform transition-transform lg:translate-x-0" <aside class="fixed inset-y-0 left-0 z-30 w-64 bg-gray-900 dark:bg-gray-950 dark:border-r dark:border-gray-800 transform transition-transform lg:translate-x-0"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"> :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
<div class="flex items-center gap-3 px-6 py-5 border-b border-gray-800"> <div class="flex items-center gap-3 px-6 py-5 border-b border-gray-800">
<div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center"> <div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
...@@ -64,13 +67,25 @@ class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium {{ req ...@@ -64,13 +67,25 @@ class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium {{ req
{{-- Main content --}} {{-- Main content --}}
<div class="lg:pl-64"> <div class="lg:pl-64">
<header class="sticky top-0 z-10 bg-white border-b border-gray-200 px-4 sm:px-6 py-3 flex items-center justify-between"> <header class="sticky top-0 z-10 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-4 sm:px-6 py-3 flex items-center justify-between">
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden p-2 -ml-2 rounded-lg hover:bg-gray-100"> <button @click="sidebarOpen = !sidebarOpen" class="lg:hidden p-2 -ml-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg> <svg class="w-5 h-5 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button> </button>
<h1 class="text-lg font-semibold text-gray-900">{{ $title ?? '' }}</h1> <h1 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{{ $title ?? '' }}</h1>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-sm text-gray-500">{{ auth()->user()?->name ?? 'Admin' }}</span> {{-- Dark mode toggle --}}
<button @click="dark = !dark; localStorage.setItem('darkMode', dark)"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition" title="Toggle dark mode">
<svg x-show="!dark" class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
<svg x-show="dark" x-cloak class="w-5 h-5 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ auth()->user()?->name ?? 'Admin' }}</span>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="text-sm text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition" title="Logout">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
</button>
</form>
</div> </div>
</header> </header>
<main class="p-4 sm:p-6"> <main class="p-4 sm:p-6">
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
{{-- Header --}} {{-- Header --}}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6"> <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 flex-wrap">
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Search instances..." <input type="text" wire:model.live.debounce.300ms="search" placeholder="Search instances..."
class="w-64 rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500"> class="w-64 rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
<select wire:model.live="status" class="rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500"> <select wire:model.live="status" class="rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
...@@ -18,9 +18,16 @@ class="w-64 rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring- ...@@ -18,9 +18,16 @@ class="w-64 rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-
<option value="trial">Trial</option> <option value="trial">Trial</option>
<option value="suspended">Suspended</option> <option value="suspended">Suspended</option>
<option value="provisioning">Provisioning</option> <option value="provisioning">Provisioning</option>
<option value="failed">Failed</option>
</select> </select>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 flex-wrap">
<button wire:click="checkHealth" wire:loading.attr="disabled" wire:target="checkHealth"
class="inline-flex items-center gap-2 px-4 py-2.5 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium">
<svg wire:loading.remove wire:target="checkHealth" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
<svg wire:loading wire:target="checkHealth" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
Health Check
</button>
<button wire:click="forceBuildAll" wire:confirm="Trigger a fresh build on ALL active instances? This will pull from git and rebuild each one." <button wire:click="forceBuildAll" wire:confirm="Trigger a fresh build on ALL active instances? This will pull from git and rebuild each one."
wire:loading.attr="disabled" wire:target="forceBuildAll" wire:loading.attr="disabled" wire:target="forceBuildAll"
class="inline-flex items-center gap-2 px-4 py-2.5 bg-orange-500 text-white rounded-lg hover:bg-orange-600 text-sm font-medium"> class="inline-flex items-center gap-2 px-4 py-2.5 bg-orange-500 text-white rounded-lg hover:bg-orange-600 text-sm font-medium">
...@@ -41,28 +48,46 @@ class="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded ...@@ -41,28 +48,46 @@ class="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200"> <thead class="bg-gray-50 border-b border-gray-200">
<tr> <tr>
@if($healthLoaded)
<th class="w-8 px-2 py-3"></th>
@endif
<th class="text-left px-4 py-3 font-medium text-gray-500">Academy</th> <th class="text-left px-4 py-3 font-medium text-gray-500">Academy</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Client</th> <th class="text-left px-4 py-3 font-medium text-gray-500 hidden sm:table-cell">Client</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Plan</th> <th class="text-left px-4 py-3 font-medium text-gray-500 hidden md:table-cell">Plan</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Status</th> <th class="text-left px-4 py-3 font-medium text-gray-500">Status</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Fee %</th> <th class="text-left px-4 py-3 font-medium text-gray-500 hidden lg:table-cell">Fee %</th>
<th class="text-right px-4 py-3 font-medium text-gray-500">Actions</th> <th class="text-right px-4 py-3 font-medium text-gray-500">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-100"> <tbody class="divide-y divide-gray-100">
@forelse($instances as $instance) @forelse($instances as $instance)
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
@if($healthLoaded)
<td class="px-2 py-3 text-center">
@php
$h = $healthStatuses[$instance->id] ?? null;
$hColor = match($h['status'] ?? 'unknown') {
'healthy' => 'bg-green-500',
'degraded' => 'bg-yellow-500',
'down' => 'bg-red-500',
default => 'bg-gray-300',
};
@endphp
<span class="inline-block w-2.5 h-2.5 rounded-full {{ $hColor }}"
title="{{ $h['status'] ?? 'not checked' }}{{ isset($h['latency_ms']) ? ' (' . $h['latency_ms'] . 'ms)' : '' }}"></span>
</td>
@endif
<td class="px-4 py-3"> <td class="px-4 py-3">
<a href="{{ route('instances.show', $instance) }}" wire:navigate class="font-medium text-blue-600 hover:underline"> <a href="{{ route('instances.show', $instance) }}" wire:navigate class="font-medium text-blue-600 hover:underline">
{{ $instance->academy_name_ar }} {{ $instance->academy_name_ar }}
</a> </a>
<p class="text-xs text-gray-400">{{ $instance->app_name }}.{{ config('manager.caprover_root_domain') }}</p> <p class="text-xs text-gray-400">{{ $instance->app_name }}.{{ config('manager.caprover_root_domain') }}</p>
</td> </td>
<td class="px-4 py-3 text-gray-600">{{ $instance->client->name }}</td> <td class="px-4 py-3 text-gray-600 hidden sm:table-cell">{{ $instance->client->name }}</td>
<td class="px-4 py-3 text-gray-600">{{ $instance->plan->name }}</td> <td class="px-4 py-3 text-gray-600 hidden md:table-cell">{{ $instance->plan->name }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
@php @php
$colors = ['active' => 'bg-green-100 text-green-700', 'trial' => 'bg-yellow-100 text-yellow-700', 'suspended' => 'bg-red-100 text-red-700', 'provisioning' => 'bg-blue-100 text-blue-700']; $colors = ['active' => 'bg-green-100 text-green-700', 'trial' => 'bg-yellow-100 text-yellow-700', 'suspended' => 'bg-red-100 text-red-700', 'provisioning' => 'bg-blue-100 text-blue-700', 'failed' => 'bg-red-100 text-red-700'];
@endphp @endphp
<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full {{ $colors[$instance->status] ?? 'bg-gray-100 text-gray-600' }}"> <span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full {{ $colors[$instance->status] ?? 'bg-gray-100 text-gray-600' }}">
{{ $instance->status }} {{ $instance->status }}
...@@ -71,13 +96,20 @@ class="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded ...@@ -71,13 +96,20 @@ class="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded
<span class="text-xs text-gray-400 block mt-0.5">ends {{ $instance->trial_ends_at->format('M d') }}</span> <span class="text-xs text-gray-400 block mt-0.5">ends {{ $instance->trial_ends_at->format('M d') }}</span>
@endif @endif
</td> </td>
<td class="px-4 py-3 text-gray-600">{{ $instance->platform_fee_percent }}%</td> <td class="px-4 py-3 text-gray-600 hidden lg:table-cell">{{ $instance->platform_fee_percent }}%</td>
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-1">
@if($instance->status === 'failed')
<button wire:click="retryDeploy({{ $instance->id }})" wire:confirm="Retry deploy for {{ $instance->academy_name_en }}?"
class="p-1.5 text-gray-400 hover:text-blue-600 rounded" title="Retry Deploy">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
@else
<button wire:click="forceBuild({{ $instance->id }})" wire:confirm="Trigger build for {{ $instance->academy_name_en }}?" <button wire:click="forceBuild({{ $instance->id }})" wire:confirm="Trigger build for {{ $instance->academy_name_en }}?"
class="p-1.5 text-gray-400 hover:text-orange-600 rounded" title="Force Build"> class="p-1.5 text-gray-400 hover:text-orange-600 rounded" title="Force Build">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button> </button>
@endif
<a href="{{ $instance->url }}" target="_blank" class="p-1.5 text-gray-400 hover:text-blue-600 rounded" title="Open"> <a href="{{ $instance->url }}" target="_blank" class="p-1.5 text-gray-400 hover:text-blue-600 rounded" title="Open">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a> </a>
...@@ -97,7 +129,7 @@ class="p-1.5 text-gray-400 hover:text-green-600 rounded" title="Resume"> ...@@ -97,7 +129,7 @@ class="p-1.5 text-gray-400 hover:text-green-600 rounded" title="Resume">
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="6" class="px-4 py-12 text-center text-gray-400"> <td colspan="{{ $healthLoaded ? 7 : 6 }}" class="px-4 py-12 text-center text-gray-400">
No instances found. <a href="{{ route('instances.deploy') }}" wire:navigate class="text-blue-600 hover:underline">Deploy your first one</a>. No instances found. <a href="{{ route('instances.deploy') }}" wire:navigate class="text-blue-600 hover:underline">Deploy your first one</a>.
</td> </td>
</tr> </tr>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment