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 @@
namespace App\Livewire;
use App\Models\AuditLog;
use App\Models\BillingPeriod;
use App\Models\Instance;
use App\Models\PlatformFee;
use App\Services\HealthCheckService;
use Livewire\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()
{
$totalInstances = Instance::where('status', '!=', 'deleted')->count();
$activeInstances = Instance::where('status', 'active')->count();
$trialInstances = Instance::where('status', 'trial')->count();
$suspendedInstances = Instance::where('status', 'suspended')->count();
$failedInstances = Instance::where('status', 'failed')->count();
$currentMonth = now()->startOfMonth();
$monthlyFeesDue = BillingPeriod::where('period_start', '>=', $currentMonth)
......@@ -33,16 +46,27 @@ public function render()
->limit(5)
->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', [
'totalInstances' => $totalInstances,
'activeInstances' => $activeInstances,
'trialInstances' => $trialInstances,
'suspendedInstances' => $suspendedInstances,
'failedInstances' => $failedInstances,
'monthlyFeesDue' => $monthlyFeesDue,
'monthlyFeesPaid' => $monthlyFeesPaid,
'platformFeesThisMonth' => $platformFeesThisMonth,
'overdue' => $overdue,
'recentInstances' => $recentInstances,
'recentActivity' => $recentActivity,
'totalRevenue' => $totalRevenue,
])->layout('layouts.app', ['title' => 'Dashboard']);
}
}
......@@ -4,6 +4,7 @@
use App\Models\Instance;
use App\Services\CapRoverService;
use App\Services\HealthCheckService;
use App\Services\InstanceProvisionerService;
use Livewire\Attributes\Url;
use Livewire\Component;
......@@ -19,6 +20,9 @@ class InstanceList extends Component
#[Url]
public string $status = '';
public array $healthStatuses = [];
public bool $healthLoaded = false;
public function updatedSearch(): void
{
$this->resetPage();
......@@ -29,6 +33,24 @@ public function updatedStatus(): void
$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
{
$instance = Instance::findOrFail($id);
......
......@@ -2,8 +2,10 @@
namespace App\Livewire;
use App\Models\AuditLog;
use App\Models\Instance;
use App\Services\CapRoverService;
use App\Services\HealthCheckService;
use App\Services\InstanceProvisionerService;
use Livewire\Component;
......@@ -16,15 +18,31 @@ class InstanceShow extends Component
public bool $stateLoaded = false;
public ?string $stateError = 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
{
$this->instance = $instance;
if (!in_array($instance->status, ['deleted', 'failed'])) {
$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
{
try {
......@@ -43,6 +61,7 @@ public function loadState(): void
'versions' => array_slice($info['versions'] ?? [], -5),
'envVarCount' => count($info['envVars'] ?? []),
'volumeCount' => count($info['volumes'] ?? []),
'envVars' => $info['envVars'] ?? [],
];
$this->stateLoaded = true;
$this->stateError = null;
......@@ -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
{
try {
......@@ -80,6 +159,7 @@ public function forceBuild(): void
{
try {
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.');
} catch (\Throwable $e) {
session()->flash('error', 'Build failed: ' . $e->getMessage());
......@@ -87,6 +167,51 @@ public function forceBuild(): void
$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
{
app(InstanceProvisionerService::class)->suspend($this->instance, 'Manual suspension from dashboard');
......@@ -99,6 +224,7 @@ public function resume(): void
app(InstanceProvisionerService::class)->resume($this->instance);
$this->instance->refresh();
$this->stateLoaded = false;
$this->checkHealth();
}
public function delete(): void
......@@ -115,7 +241,14 @@ public function render()
$q->orderByDesc('created_at')->limit(10);
}]);
return view('livewire.instance-show')
->layout('layouts.app', ['title' => $this->instance->academy_name_en ?? $this->instance->app_name]);
$timeline = AuditLog::where('instance_id', $this->instance->id)
->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
]);
}
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
{
$this->caprover->setEnvVars($instance->app_name, $vars);
......
<!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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title ?? 'El Captain Manager' }}</title>
<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">
<style>
body { font-family: 'Inter', system-ui, sans-serif; }
......@@ -12,10 +15,10 @@
</style>
@livewireStyles
</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 }">
{{-- 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'">
<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">
......@@ -64,13 +67,25 @@ class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium {{ req
{{-- Main content --}}
<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">
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden p-2 -ml-2 rounded-lg hover:bg-gray-100">
<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>
<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 dark:hover:bg-gray-800">
<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>
<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">
<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>
</header>
<main class="p-4 sm:p-6">
......
<div>
{{-- Stats Grid --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
......@@ -11,18 +11,21 @@
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg>
</div>
</div>
<div class="mt-3 flex gap-3 text-xs">
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<span class="text-green-600">{{ $activeInstances }} active</span>
<span class="text-yellow-600">{{ $trialInstances }} trial</span>
<span class="text-red-600">{{ $suspendedInstances }} suspended</span>
@if($failedInstances > 0)
<span class="text-red-700 font-medium">{{ $failedInstances }} failed</span>
@endif
</div>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">MRR (Monthly Fees)</p>
<p class="text-2xl font-bold text-gray-900 mt-1">{{ number_format($monthlyFeesDue / 100, 0) }} EGP</p>
<p class="text-sm text-gray-500">MRR</p>
<p class="text-2xl font-bold text-gray-900 mt-1">{{ number_format($monthlyFeesDue / 100, 0) }} <span class="text-sm font-normal text-gray-500">EGP</span></p>
</div>
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"/></svg>
......@@ -36,15 +39,30 @@
<div class="bg-white rounded-xl border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Platform Fees (This Month)</p>
<p class="text-2xl font-bold text-gray-900 mt-1">{{ number_format($platformFeesThisMonth / 100, 0) }} EGP</p>
<p class="text-sm text-gray-500">Platform Fees</p>
<p class="text-2xl font-bold text-gray-900 mt-1">{{ number_format($platformFeesThisMonth / 100, 0) }} <span class="text-sm font-normal text-gray-500">EGP</span></p>
</div>
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
</div>
</div>
<div class="mt-3 text-xs text-gray-500">
From transaction %
This month (from tx %)
</div>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Total Revenue</p>
<p class="text-2xl font-bold text-gray-900 mt-1">{{ number_format($totalRevenue / 100, 0) }} <span class="text-sm font-normal text-gray-500">EGP</span></p>
</div>
<div class="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z"/></svg>
</div>
</div>
<div class="mt-3 text-xs text-gray-500">
All time (fees + subscriptions)
</div>
</div>
......@@ -64,7 +82,49 @@
</div>
</div>
{{-- Actions + Recent --}}
{{-- Health Check Banner --}}
<div class="bg-white rounded-xl border border-gray-200 p-5 mb-6">
<div class="flex items-center justify-between mb-3">
<h2 class="font-semibold text-gray-900">Instance Health</h2>
<button wire:click="checkAllHealth" wire:loading.attr="disabled" wire:target="checkAllHealth"
class="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition">
<svg wire:loading.remove wire:target="checkAllHealth" class="w-3.5 h-3.5" 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="checkAllHealth" class="w-3.5 h-3.5 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>
<span wire:loading.remove wire:target="checkAllHealth">Check All</span>
<span wire:loading wire:target="checkAllHealth">Checking...</span>
</button>
</div>
@if($healthLoaded)
<div class="flex flex-wrap gap-2">
@php
$healthy = collect($healthStatuses)->where('status', 'healthy')->count();
$degraded = collect($healthStatuses)->where('status', 'degraded')->count();
$down = collect($healthStatuses)->where('status', 'down')->count();
@endphp
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
<span class="w-2 h-2 rounded-full bg-green-500"></span>
{{ $healthy }} Healthy
</span>
@if($degraded > 0)
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700">
<span class="w-2 h-2 rounded-full bg-yellow-500"></span>
{{ $degraded }} Degraded
</span>
@endif
@if($down > 0)
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-red-100 text-red-700">
<span class="w-2 h-2 rounded-full bg-red-500"></span>
{{ $down }} Down
</span>
@endif
<span class="text-xs text-gray-400 self-center">avg latency: {{ round(collect($healthStatuses)->avg('latency_ms')) }}ms</span>
</div>
@else
<p class="text-sm text-gray-400">Click "Check All" to ping all active instances.</p>
@endif
</div>
{{-- Actions + Recent + Activity --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Quick Actions --}}
<div class="bg-white rounded-xl border border-gray-200 p-5">
......@@ -85,48 +145,82 @@ class="flex items-center gap-3 w-full px-4 py-3 bg-gray-100 text-gray-700 rounde
<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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
View Suspended
</a>
@if($failedInstances > 0)
<a href="{{ route('instances.index') }}?status=failed" wire:navigate
class="flex items-center gap-3 w-full px-4 py-3 bg-red-50 text-red-700 rounded-lg hover:bg-red-100 transition font-medium text-sm border border-red-200">
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
{{ $failedInstances }} Failed (needs attention)
</a>
@endif
</div>
</div>
{{-- Recent Instances --}}
<div class="lg:col-span-2 bg-white rounded-xl border border-gray-200 p-5">
<h2 class="font-semibold text-gray-900 mb-4">Recent Instances</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100">
<th class="text-left py-2 font-medium text-gray-500">Academy</th>
<th class="text-left py-2 font-medium text-gray-500">Client</th>
<th class="text-left py-2 font-medium text-gray-500">Plan</th>
<th class="text-left py-2 font-medium text-gray-500">Status</th>
</tr>
</thead>
<tbody>
@forelse($recentInstances as $instance)
<tr class="border-b border-gray-50">
<td class="py-2.5">
<a href="{{ route('instances.show', $instance) }}" wire:navigate class="text-blue-600 hover:underline font-medium">
{{ $instance->academy_name_ar }}
</a>
</td>
<td class="py-2.5 text-gray-600">{{ $instance->client->name }}</td>
<td class="py-2.5 text-gray-600">{{ $instance->plan->name }}</td>
<td class="py-2.5">
@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'];
@endphp
<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full {{ $colors[$instance->status] ?? 'bg-gray-100 text-gray-700' }}">
{{ $instance->status }}
</span>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="py-8 text-center text-gray-400">No instances yet. Deploy your first one!</td>
</tr>
@endforelse
</tbody>
</table>
<div class="bg-white rounded-xl border border-gray-200 p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-gray-900">Recent Instances</h2>
<a href="{{ route('instances.index') }}" wire:navigate class="text-xs text-blue-600 hover:underline">View all</a>
</div>
<div class="space-y-3">
@forelse($recentInstances as $instance)
<a href="{{ route('instances.show', $instance) }}" wire:navigate
class="flex items-center justify-between p-2.5 rounded-lg hover:bg-gray-50 transition -mx-2">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{{ $instance->academy_name_ar }}</p>
<p class="text-xs text-gray-500 truncate">{{ $instance->client->name }} &middot; {{ $instance->plan->name }}</p>
</div>
@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', 'failed' => 'bg-red-100 text-red-700'];
@endphp
<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full {{ $colors[$instance->status] ?? 'bg-gray-100 text-gray-700' }} shrink-0 ml-2">
{{ $instance->status }}
</span>
</a>
@empty
<p class="text-sm text-gray-400 text-center py-6">No instances yet. Deploy your first one!</p>
@endforelse
</div>
</div>
{{-- Recent Activity --}}
<div class="bg-white rounded-xl border border-gray-200 p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-gray-900">Recent Activity</h2>
<a href="{{ route('audit.index') }}" wire:navigate class="text-xs text-blue-600 hover:underline">View all</a>
</div>
<div class="space-y-3">
@forelse($recentActivity as $event)
@php
$actionColors = [
'deployed' => 'bg-green-500',
'resumed' => 'bg-green-500',
'force_build' => 'bg-orange-500',
'retry_deploy' => 'bg-blue-500',
'suspended' => 'bg-red-500',
'deleted' => 'bg-red-700',
'env_updated' => 'bg-purple-500',
'maintenance_on' => 'bg-yellow-500',
'maintenance_off' => 'bg-green-500',
'payment_received' => 'bg-emerald-500',
];
@endphp
<div class="flex items-start gap-3">
<div class="w-2 h-2 rounded-full mt-1.5 shrink-0 {{ $actionColors[$event->action] ?? 'bg-gray-400' }}"></div>
<div class="min-w-0 flex-1">
<p class="text-sm text-gray-900 truncate">
{{ str_replace('_', ' ', ucfirst($event->action)) }}
@if($event->instance)
<span class="text-gray-500">{{ $event->instance->academy_name_ar }}</span>
@endif
</p>
<p class="text-xs text-gray-400">
{{ $event->user?->name ?? 'System' }} &middot; {{ $event->created_at->diffForHumans() }}
</p>
</div>
</div>
@empty
<p class="text-sm text-gray-400 text-center py-6">No activity yet.</p>
@endforelse
</div>
</div>
</div>
......
......@@ -9,7 +9,7 @@
{{-- Header --}}
<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..."
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">
......@@ -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="suspended">Suspended</option>
<option value="provisioning">Provisioning</option>
<option value="failed">Failed</option>
</select>
</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."
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">
......@@ -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">
<thead class="bg-gray-50 border-b border-gray-200">
<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">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 sm:table-cell">Client</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">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>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($instances as $instance)
<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">
<a href="{{ route('instances.show', $instance) }}" wire:navigate class="font-medium text-blue-600 hover:underline">
{{ $instance->academy_name_ar }}
</a>
<p class="text-xs text-gray-400">{{ $instance->app_name }}.{{ config('manager.caprover_root_domain') }}</p>
</td>
<td class="px-4 py-3 text-gray-600">{{ $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 sm:table-cell">{{ $instance->client->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">
@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
<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 }}
......@@ -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>
@endif
</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">
<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 }}?"
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>
</button>
@endif
<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>
</a>
......@@ -97,7 +129,7 @@ class="p-1.5 text-gray-400 hover:text-green-600 rounded" title="Resume">
</tr>
@empty
<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>.
</td>
</tr>
......
......@@ -11,17 +11,52 @@
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
<div>
<div class="flex items-center gap-3">
{{-- Health Indicator --}}
@if(!empty($healthStatus))
@php
$hColors = ['healthy' => 'bg-green-500', 'degraded' => 'bg-yellow-500', 'down' => 'bg-red-500', 'suspended' => 'bg-gray-400'];
$hLabels = ['healthy' => 'Healthy', 'degraded' => 'Degraded', 'down' => 'Down', 'suspended' => 'Suspended'];
@endphp
<span class="relative flex h-3 w-3" title="{{ $hLabels[$healthStatus['status']] ?? 'Unknown' }}{{ isset($healthStatus['latency_ms']) ? ' (' . $healthStatus['latency_ms'] . 'ms)' : '' }}">
@if($healthStatus['status'] === 'healthy')
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
@endif
<span class="relative inline-flex rounded-full h-3 w-3 {{ $hColors[$healthStatus['status']] ?? 'bg-gray-400' }}"></span>
</span>
@endif
<h1 class="text-xl font-bold text-gray-900">{{ $instance->academy_name_ar }}</h1>
@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', 'deleted' => 'bg-gray-100 text-gray-500'];
$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', 'deleted' => 'bg-gray-100 text-gray-500'];
@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' }}">
{{ $instance->status }}
</span>
</div>
<p class="text-sm text-gray-500 mt-0.5">{{ $instance->academy_name_en }} &middot; {{ $instance->app_name }}</p>
@if(!empty($healthStatus['latency_ms']))
<p class="text-xs text-gray-400 mt-0.5">Response: {{ $healthStatus['latency_ms'] }}ms &middot; HTTP {{ $healthStatus['http_code'] ?? '?' }}</p>
@elseif(!empty($healthStatus['error']))
<p class="text-xs text-red-400 mt-0.5">{{ $healthStatus['error'] }}</p>
@endif
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 flex-wrap">
@if($instance->status === 'failed')
<button wire:click="retryDeploy" wire:confirm="Retry provisioning this instance from scratch?"
wire:loading.attr="disabled" wire:target="retryDeploy"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition">
<svg wire:loading.remove wire:target="retryDeploy" 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 wire:loading wire:target="retryDeploy" 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>
Retry Deploy
</button>
@endif
@if(!in_array($instance->status, ['deleted', 'failed']))
<button wire:click="checkHealth" wire:loading.attr="disabled" wire:target="checkHealth"
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition">
<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>
Ping
</button>
<button wire:click="forceBuild" wire:confirm="Trigger a fresh build? CapRover will pull from git and rebuild."
wire:loading.attr="disabled" wire:target="forceBuild"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-orange-700 bg-orange-50 border border-orange-200 rounded-lg hover:bg-orange-100 transition">
......@@ -47,6 +82,7 @@ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-green-7
Resume
</button>
@endif
@endif
<button wire:click="delete" wire:confirm="DELETE this instance permanently? This destroys the database and CapRover app. This cannot be undone."
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg hover:bg-red-50 hover:text-red-700 hover:border-red-200 transition">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
......@@ -107,8 +143,20 @@ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-50
<div class="bg-white rounded-xl border border-gray-200 p-5">
<h3 class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Environment</h3>
<p class="text-sm text-gray-500 mb-3">Environment variables are managed via CapRover.</p>
<p class="text-xs text-gray-400 font-mono">DB: {{ $instance->db_name }}</p>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500">Database</dt>
<dd class="font-mono text-xs text-gray-700">{{ $instance->db_name }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Env Vars</dt>
<dd class="font-medium text-gray-900">{{ $appState['envVarCount'] ?? '?' }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Volumes</dt>
<dd class="font-medium text-gray-900">{{ $appState['volumeCount'] ?? '?' }}</dd>
</div>
</dl>
@if($instance->suspended_at)
<div class="mt-3 p-2 bg-red-50 rounded-lg">
<p class="text-xs text-red-700 font-medium">Suspended {{ $instance->suspended_at->format('M d, Y') }}</p>
......@@ -117,6 +165,12 @@ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-50
@endif
</div>
@endif
@if($instance->caprover_deploy_log && $instance->status === 'failed')
<div class="mt-3 p-2 bg-red-50 rounded-lg">
<p class="text-xs text-red-700 font-medium">Deploy Error:</p>
<p class="text-xs text-red-600 mt-0.5 font-mono break-all">{{ $instance->caprover_deploy_log }}</p>
</div>
@endif
</div>
</div>
......@@ -124,13 +178,23 @@ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-50
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden mb-6">
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900">Container State</h3>
<button wire:click="loadState" wire:loading.attr="disabled" wire:target="loadState"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition">
<svg wire:loading.remove wire:target="loadState" class="w-3.5 h-3.5" 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 wire:loading wire:target="loadState" class="w-3.5 h-3.5 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>
<span wire:loading.remove wire:target="loadState">{{ $stateLoaded ? 'Refresh' : 'Load State' }}</span>
<span wire:loading wire:target="loadState">Loading...</span>
</button>
<div class="flex items-center gap-2">
@if($stateLoaded && !in_array($instance->status, ['deleted', 'failed']))
<button wire:click="toggleMaintenance"
wire:confirm="{{ ($appState['instanceCount'] ?? 0) > 0 ? 'Put instance into maintenance mode? (0 replicas, returns 502)' : 'Bring instance back online? (1 replica)' }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium {{ ($appState['instanceCount'] ?? 0) > 0 ? 'text-yellow-700 bg-yellow-50 hover:bg-yellow-100' : 'text-green-700 bg-green-50 hover:bg-green-100' }} rounded-lg transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
{{ ($appState['instanceCount'] ?? 0) > 0 ? 'Maintenance' : 'Bring Online' }}
</button>
@endif
<button wire:click="loadState" wire:loading.attr="disabled" wire:target="loadState"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition">
<svg wire:loading.remove wire:target="loadState" class="w-3.5 h-3.5" 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 wire:loading wire:target="loadState" class="w-3.5 h-3.5 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>
<span wire:loading.remove wire:target="loadState">{{ $stateLoaded ? 'Refresh' : 'Load State' }}</span>
<span wire:loading wire:target="loadState">Loading...</span>
</button>
</div>
</div>
@if($stateError)
......@@ -210,6 +274,75 @@ class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gra
@endif
</div>
{{-- Environment Variables Editor --}}
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden mb-6">
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900">Environment Variables</h3>
@if($stateLoaded)
<button wire:click="toggleEnvEditor"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium {{ $showEnvEditor ? 'text-red-600 bg-red-50 hover:bg-red-100' : 'text-blue-600 bg-blue-50 hover:bg-blue-100' }} rounded-lg transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@if($showEnvEditor)
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
@else
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
@endif
</svg>
{{ $showEnvEditor ? 'Cancel' : 'Edit' }}
</button>
@endif
</div>
@if($showEnvEditor)
<div class="px-5 py-4">
<div class="space-y-2 max-h-96 overflow-y-auto mb-4">
@foreach($envVars as $index => $var)
<div class="flex items-center gap-2">
<input type="text" value="{{ $var['key'] }}" readonly
class="w-1/3 rounded-lg border-gray-200 bg-gray-50 text-xs font-mono px-2 py-1.5">
<input type="text" wire:change="updateEnvVar({{ $index }}, $event.target.value)"
value="{{ $var['value'] }}"
class="flex-1 rounded-lg border-gray-300 text-xs font-mono px-2 py-1.5 focus:border-blue-500 focus:ring-blue-500">
<button wire:click="removeEnvVar({{ $index }})" class="p-1 text-gray-400 hover:text-red-600 rounded">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
@endforeach
</div>
{{-- Add new var --}}
<div class="flex items-center gap-2 pt-3 border-t border-gray-200">
<input type="text" wire:model="newEnvKey" placeholder="KEY"
class="w-1/3 rounded-lg border-gray-300 text-xs font-mono px-2 py-1.5 focus:border-blue-500 focus:ring-blue-500">
<input type="text" wire:model="newEnvValue" placeholder="value"
class="flex-1 rounded-lg border-gray-300 text-xs font-mono px-2 py-1.5 focus:border-blue-500 focus:ring-blue-500">
<button wire:click="addEnvVar" class="px-3 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100">
Add
</button>
</div>
{{-- Save --}}
<div class="flex items-center justify-between mt-4 pt-3 border-t border-gray-200">
<p class="text-xs text-gray-500">Changes require a build to take effect.</p>
<button wire:click="saveEnvVars" wire:loading.attr="disabled" wire:target="saveEnvVars"
wire:confirm="Save environment variables? This will update CapRover's stored config."
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition">
<svg wire:loading wire:target="saveEnvVars" 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>
Save Env Vars
</button>
</div>
</div>
@elseif(!$stateLoaded)
<div class="px-5 py-6 text-center text-gray-400 text-sm">
Load container state first to view environment variables.
</div>
@else
<div class="px-5 py-4">
<p class="text-sm text-gray-500">{{ $appState['envVarCount'] ?? 0 }} variables configured. Click "Edit" to modify.</p>
</div>
@endif
</div>
{{-- Logs --}}
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden mb-6">
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
......@@ -258,6 +391,63 @@ class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gra
@endif
</div>
{{-- Activity Timeline --}}
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden mb-6">
<div class="px-5 py-4 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-900">Activity Timeline</h3>
</div>
@if($timeline->count())
<div class="px-5 py-4">
<div class="relative">
<div class="absolute left-4 top-0 bottom-0 w-px bg-gray-200"></div>
<div class="space-y-4">
@foreach($timeline as $event)
@php
$actionColors = [
'deployed' => 'bg-green-500',
'resumed' => 'bg-green-500',
'force_build' => 'bg-orange-500',
'retry_deploy' => 'bg-blue-500',
'suspended' => 'bg-red-500',
'deleted' => 'bg-red-700',
'env_updated' => 'bg-purple-500',
'maintenance_on' => 'bg-yellow-500',
'maintenance_off' => 'bg-green-500',
];
$actionIcons = [
'deployed' => 'M5 13l4 4L19 7',
'resumed' => 'M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z',
'force_build' => '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',
'suspended' => 'M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z',
'deleted' => 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16',
];
@endphp
<div class="relative flex items-start gap-3 pl-8">
<div class="absolute left-2.5 top-1 w-3 h-3 rounded-full {{ $actionColors[$event->action] ?? 'bg-gray-400' }} ring-2 ring-white"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-medium text-gray-900">
{{ str_replace('_', ' ', ucfirst($event->action)) }}
</p>
<time class="text-xs text-gray-400 whitespace-nowrap">{{ $event->created_at->diffForHumans() }}</time>
</div>
<p class="text-xs text-gray-500 mt-0.5">
by {{ $event->user?->name ?? 'System' }}
@if(!empty($event->details))
&middot; {{ collect($event->details)->map(fn($v, $k) => "$k: " . (is_array($v) ? implode(', ', $v) : $v))->implode(' | ') }}
@endif
</p>
</div>
</div>
@endforeach
</div>
</div>
</div>
@else
<div class="px-5 py-8 text-center text-gray-400 text-sm">No activity recorded yet.</div>
@endif
</div>
{{-- Billing Periods --}}
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden mb-6">
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
......@@ -305,7 +495,7 @@ class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gra
{{-- Platform Fees --}}
@if($instance->platformFees->count())
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden mb-6">
<div class="px-5 py-4 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-900">Recent Platform Fees</h3>
</div>
......
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