diff --git a/app/app/Models/Activity.php b/app/app/Models/Activity.php new file mode 100644 index 0000000..d0779b4 --- /dev/null +++ b/app/app/Models/Activity.php @@ -0,0 +1,27 @@ + 'array', + 'occurred_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/app/Models/Backup.php b/app/app/Models/Backup.php new file mode 100644 index 0000000..57a8dac --- /dev/null +++ b/app/app/Models/Backup.php @@ -0,0 +1,31 @@ + 'datetime', + ]; + + public function site(): BelongsTo + { + return $this->belongsTo(Site::class); + } + + public function getSizeMbAttribute(): float + { + return round($this->size_bytes / 1024 / 1024, 1); + } +} diff --git a/app/app/Models/Certificate.php b/app/app/Models/Certificate.php new file mode 100644 index 0000000..f4cb077 --- /dev/null +++ b/app/app/Models/Certificate.php @@ -0,0 +1,43 @@ + 'datetime', + 'expires_at' => 'datetime', + 'auto_renew' => 'bool', + ]; + + public function site(): BelongsTo + { + return $this->belongsTo(Site::class); + } + + public function getDaysToExpiryAttribute(): int + { + return $this->expires_at ? max(0, (int) now()->diffInDays($this->expires_at, false)) : 0; + } + + public function getStatusAttribute(): string + { + $days = $this->days_to_expiry; + return match (true) { + $days <= 7 => 'critical', + $days <= 30 => 'warning', + default => 'ok', + }; + } +} diff --git a/app/app/Models/Cluster.php b/app/app/Models/Cluster.php new file mode 100644 index 0000000..b87e607 --- /dev/null +++ b/app/app/Models/Cluster.php @@ -0,0 +1,31 @@ + 'bool', + ]; + + public function servers(): HasMany + { + return $this->hasMany(Server::class); + } + + public function getMonthlyPriceAttribute(): float + { + return $this->monthly_price_cents / 100; + } +} diff --git a/app/app/Models/Cron.php b/app/app/Models/Cron.php new file mode 100644 index 0000000..fa8a716 --- /dev/null +++ b/app/app/Models/Cron.php @@ -0,0 +1,27 @@ + 'datetime', + 'enabled' => 'bool', + ]; + + public function site(): BelongsTo + { + return $this->belongsTo(Site::class); + } +} diff --git a/app/app/Models/Invitation.php b/app/app/Models/Invitation.php new file mode 100644 index 0000000..5bf6177 --- /dev/null +++ b/app/app/Models/Invitation.php @@ -0,0 +1,42 @@ + 'datetime', + 'accepted_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + + protected static function booted(): void + { + static::creating(function (Invitation $i) { + $i->token = $i->token ?: Str::random(48); + $i->expires_at = $i->expires_at ?: now()->addDays(7); + }); + } + + public function invitedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by_user_id'); + } + + public function getIsPendingAttribute(): bool + { + return is_null($this->accepted_at) && (! $this->expires_at || $this->expires_at->isFuture()); + } +} diff --git a/app/app/Models/SecurityEvent.php b/app/app/Models/SecurityEvent.php new file mode 100644 index 0000000..46cab08 --- /dev/null +++ b/app/app/Models/SecurityEvent.php @@ -0,0 +1,28 @@ + 'bool', + 'meta' => 'array', + 'occurred_at' => 'datetime', + ]; + + public function site(): BelongsTo + { + return $this->belongsTo(Site::class); + } +} diff --git a/app/app/Models/Server.php b/app/app/Models/Server.php new file mode 100644 index 0000000..236ab2a --- /dev/null +++ b/app/app/Models/Server.php @@ -0,0 +1,30 @@ +belongsTo(Cluster::class); + } + + public function sites(): HasMany + { + return $this->hasMany(Site::class); + } +} diff --git a/app/app/Models/Site.php b/app/app/Models/Site.php new file mode 100644 index 0000000..01f0c66 --- /dev/null +++ b/app/app/Models/Site.php @@ -0,0 +1,64 @@ + 'bool', + 'cache_enabled' => 'bool', + 'cdn_enabled' => 'bool', + 'maintenance_mode' => 'bool', + ]; + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function backups(): HasMany + { + return $this->hasMany(Backup::class); + } + + public function latestBackup(): HasOne + { + return $this->hasOne(Backup::class)->latestOfMany('completed_at'); + } + + public function certificate(): HasOne + { + return $this->hasOne(Certificate::class); + } + + public function crons(): HasMany + { + return $this->hasMany(Cron::class); + } + + public function securityEvents(): HasMany + { + return $this->hasMany(SecurityEvent::class); + } + + public function getInitialAttribute(): string + { + return mb_strtoupper(mb_substr($this->name ?? $this->domain, 0, 1)); + } +} diff --git a/app/app/Models/TeamMember.php b/app/app/Models/TeamMember.php new file mode 100644 index 0000000..d2cf8eb --- /dev/null +++ b/app/app/Models/TeamMember.php @@ -0,0 +1,26 @@ + 'bool', + 'last_active_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/database/factories/ActivityFactory.php b/app/database/factories/ActivityFactory.php new file mode 100644 index 0000000..54f2e17 --- /dev/null +++ b/app/database/factories/ActivityFactory.php @@ -0,0 +1,24 @@ + null, + 'type' => $this->faker->randomElement(['deploy', 'backup', 'update', 'security', 'system']), + 'level' => $this->faker->randomElement(['info', 'success', 'success', 'warning', 'error']), + 'message' => $this->faker->sentence(6), + 'subject' => $this->faker->domainName(), + 'meta' => null, + 'occurred_at' => $this->faker->dateTimeBetween('-3 days', 'now'), + ]; + } +} diff --git a/app/database/factories/BackupFactory.php b/app/database/factories/BackupFactory.php new file mode 100644 index 0000000..aae4895 --- /dev/null +++ b/app/database/factories/BackupFactory.php @@ -0,0 +1,30 @@ +faker->dateTimeBetween('-7 days', 'now'); + return [ + 'site_id' => Site::factory(), + 'type' => $this->faker->randomElement(['hourly', 'daily', 'manual', 'snapshot']), + 'size_bytes' => $this->faker->numberBetween(50_000_000, 2_000_000_000), + 'status' => $this->faker->randomElement(['ok', 'ok', 'ok', 'failed']), + 'storage_location' => 's3://clupilot-backups', + 'completed_at' => $completedAt, + ]; + } + + public function failed(): self + { + return $this->state(['status' => 'failed']); + } +} diff --git a/app/database/factories/CertificateFactory.php b/app/database/factories/CertificateFactory.php new file mode 100644 index 0000000..fe95541 --- /dev/null +++ b/app/database/factories/CertificateFactory.php @@ -0,0 +1,30 @@ +faker->dateTimeBetween('-60 days', '-1 days'); + return [ + 'site_id' => Site::factory(), + 'domain' => $this->faker->domainName(), + 'issuer' => "Let's Encrypt", + 'issued_at' => $issued, + 'expires_at' => (clone $issued)->modify('+90 days'), + 'auto_renew' => true, + ]; + } + + public function expiringSoon(): self + { + return $this->state(['expires_at' => now()->addDays(5)]); + } +} diff --git a/app/database/factories/ClusterFactory.php b/app/database/factories/ClusterFactory.php new file mode 100644 index 0000000..3d9ac09 --- /dev/null +++ b/app/database/factories/ClusterFactory.php @@ -0,0 +1,26 @@ +faker->unique()->company(); + return [ + 'name' => $name, + 'slug' => Str::slug($name), + 'region' => $this->faker->randomElement(['eu-central-1', 'eu-west-1', 'us-east-1', 'ap-south-1']), + 'env' => $this->faker->randomElement(['prod', 'staging', 'edge', 'dev']), + 'monthly_price_cents' => $this->faker->numberBetween(2900, 49900), + 'node_count' => $this->faker->numberBetween(1, 6), + 'backups_enabled' => true, + ]; + } +} diff --git a/app/database/factories/CronFactory.php b/app/database/factories/CronFactory.php new file mode 100644 index 0000000..4cf05b3 --- /dev/null +++ b/app/database/factories/CronFactory.php @@ -0,0 +1,25 @@ + Site::factory(), + 'name' => $this->faker->randomElement(['Backup nightly', 'Cache flush', 'WP cron', 'Sitemap rebuild']), + 'expression' => $this->faker->randomElement(['0 * * * *', '*/15 * * * *', '0 2 * * *', '*/5 * * * *']), + 'command' => 'php /var/www/html/artisan ' . $this->faker->randomElement(['queue:work', 'cache:clear', 'schedule:run']), + 'last_run_at' => $this->faker->dateTimeBetween('-1 hour', 'now'), + 'status' => $this->faker->randomElement(['ok', 'ok', 'ok', 'failing']), + 'enabled' => true, + ]; + } +} diff --git a/app/database/factories/InvitationFactory.php b/app/database/factories/InvitationFactory.php new file mode 100644 index 0000000..8f218b1 --- /dev/null +++ b/app/database/factories/InvitationFactory.php @@ -0,0 +1,23 @@ + $this->faker->unique()->safeEmail(), + 'role' => $this->faker->randomElement(['admin', 'developer', 'viewer']), + 'invited_by_user_id' => User::factory(), + 'sent_at' => now(), + 'expires_at' => now()->addDays(7), + ]; + } +} diff --git a/app/database/factories/SecurityEventFactory.php b/app/database/factories/SecurityEventFactory.php new file mode 100644 index 0000000..d5a7829 --- /dev/null +++ b/app/database/factories/SecurityEventFactory.php @@ -0,0 +1,32 @@ + Site::factory(), + 'type' => $this->faker->randomElement(['brute_force', 'tor_exit', 'malware', 'firewall', 'scan']), + 'severity' => $this->faker->randomElement(['info', 'low', 'medium', 'high', 'critical']), + 'blocked' => true, + 'source_ip' => $this->faker->ipv4(), + 'country' => $this->faker->countryCode(), + 'message' => $this->faker->sentence(8), + 'meta' => null, + 'occurred_at' => $this->faker->dateTimeBetween('-24 hours', 'now'), + ]; + } + + public function critical(): self + { + return $this->state(['severity' => 'critical']); + } +} diff --git a/app/database/factories/ServerFactory.php b/app/database/factories/ServerFactory.php new file mode 100644 index 0000000..4e55114 --- /dev/null +++ b/app/database/factories/ServerFactory.php @@ -0,0 +1,32 @@ +faker->randomElement(['prod', 'staging', 'edge']); + return [ + 'cluster_id' => Cluster::factory(), + 'name' => $env . '-' . $this->faker->unique()->numerify('node-##'), + 'region' => $this->faker->randomElement(['eu-central-1', 'eu-west-1', 'us-east-1', 'ap-south-1']), + 'country_flag' => $this->faker->randomElement(['🇩🇪', '🇳🇱', '🇮🇪', '🇺🇸', '🇮🇳']), + 'env' => $env, + 'cpu_cores' => $this->faker->randomElement([4, 8, 16]), + 'ram_gb' => $this->faker->randomElement([16, 32, 64, 128]), + 'disk_gb' => $this->faker->randomElement([160, 320, 640, 1280]), + 'cpu_load_pct' => $this->faker->numberBetween(8, 78), + 'ram_used_pct' => $this->faker->numberBetween(15, 80), + 'disk_used_pct' => $this->faker->numberBetween(20, 75), + 'status' => $this->faker->randomElement(['ok', 'ok', 'ok', 'warn']), + 'ip' => $this->faker->ipv4(), + ]; + } +} diff --git a/app/database/factories/SiteFactory.php b/app/database/factories/SiteFactory.php new file mode 100644 index 0000000..b02bdaf --- /dev/null +++ b/app/database/factories/SiteFactory.php @@ -0,0 +1,33 @@ +faker->unique()->domainName(); + return [ + 'server_id' => Server::factory(), + 'domain' => $domain, + 'name' => ucfirst(explode('.', $domain)[0]), + 'wp_version' => $this->faker->randomElement(['6.4', '6.5', '6.6']), + 'php_version' => $this->faker->randomElement(['8.1', '8.2', '8.3', '8.4']), + 'status' => $this->faker->randomElement(['ok', 'ok', 'ok', 'warn', 'bad']), + 'visitors_24h' => $this->faker->numberBetween(40, 9800), + 'response_ms' => $this->faker->numberBetween(80, 480), + 'https_enabled' => true, + 'cache_enabled' => $this->faker->boolean(85), + 'cdn_enabled' => $this->faker->boolean(45), + 'maintenance_mode' => false, + 'pagespeed_score' => $this->faker->numberBetween(60, 99), + 'updates_pending' => $this->faker->numberBetween(0, 6), + ]; + } +} diff --git a/app/database/factories/TeamMemberFactory.php b/app/database/factories/TeamMemberFactory.php new file mode 100644 index 0000000..3ba16cc --- /dev/null +++ b/app/database/factories/TeamMemberFactory.php @@ -0,0 +1,23 @@ + User::factory(), + 'role' => $this->faker->randomElement(['admin', 'developer', 'developer', 'viewer']), + 'scope' => $this->faker->randomElement(['All clusters', 'eu-prod-*', 'edge-*', 'staging-*']), + 'two_fa_enabled' => $this->faker->boolean(70), + 'last_active_at' => $this->faker->dateTimeBetween('-7 days', 'now'), + ]; + } +} diff --git a/app/database/migrations/2026_05_31_200000_create_clusters_table.php b/app/database/migrations/2026_05_31_200000_create_clusters_table.php new file mode 100644 index 0000000..b0b9c7f --- /dev/null +++ b/app/database/migrations/2026_05_31_200000_create_clusters_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('region', 32); // e.g. eu-central-1 + $table->enum('env', ['prod', 'staging', 'edge', 'dev'])->default('prod'); + $table->unsignedInteger('monthly_price_cents')->default(0); + $table->unsignedSmallInteger('node_count')->default(1); + $table->boolean('backups_enabled')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('clusters'); + } +}; diff --git a/app/database/migrations/2026_05_31_200001_create_servers_table.php b/app/database/migrations/2026_05_31_200001_create_servers_table.php new file mode 100644 index 0000000..3fb1862 --- /dev/null +++ b/app/database/migrations/2026_05_31_200001_create_servers_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('cluster_id')->nullable()->constrained()->nullOnDelete(); + $table->string('name', 64); // eu-prod-01 + $table->string('region', 32); // eu-central-1 + $table->string('country_flag', 4)->default('🇩🇪'); // emoji + $table->enum('env', ['prod', 'staging', 'edge', 'dev'])->default('prod'); + $table->unsignedSmallInteger('cpu_cores')->default(4); + $table->unsignedSmallInteger('ram_gb')->default(16); + $table->unsignedInteger('disk_gb')->default(160); + $table->unsignedTinyInteger('cpu_load_pct')->default(0); + $table->unsignedTinyInteger('ram_used_pct')->default(0); + $table->unsignedTinyInteger('disk_used_pct')->default(0); + $table->enum('status', ['ok', 'warn', 'bad', 'maintenance'])->default('ok'); + $table->string('ip', 45)->nullable(); + $table->timestamps(); + $table->index(['env', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('servers'); + } +}; diff --git a/app/database/migrations/2026_05_31_200002_create_sites_table.php b/app/database/migrations/2026_05_31_200002_create_sites_table.php new file mode 100644 index 0000000..7289bc8 --- /dev/null +++ b/app/database/migrations/2026_05_31_200002_create_sites_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('server_id')->constrained()->cascadeOnDelete(); + $table->string('domain')->unique(); + $table->string('name')->nullable(); + $table->string('wp_version', 16)->default('6.4'); + $table->string('php_version', 8)->default('8.3'); + $table->enum('status', ['ok', 'warn', 'bad', 'maintenance'])->default('ok'); + $table->unsignedInteger('visitors_24h')->default(0); + $table->unsignedInteger('response_ms')->default(0); + $table->boolean('https_enabled')->default(true); + $table->boolean('cache_enabled')->default(true); + $table->boolean('cdn_enabled')->default(false); + $table->boolean('maintenance_mode')->default(false); + $table->unsignedTinyInteger('pagespeed_score')->default(85); + $table->unsignedSmallInteger('updates_pending')->default(0); + $table->timestamps(); + $table->index(['status']); + $table->index(['server_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('sites'); + } +}; diff --git a/app/database/migrations/2026_05_31_200003_create_backups_table.php b/app/database/migrations/2026_05_31_200003_create_backups_table.php new file mode 100644 index 0000000..35488f5 --- /dev/null +++ b/app/database/migrations/2026_05_31_200003_create_backups_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('site_id')->constrained()->cascadeOnDelete(); + $table->enum('type', ['hourly', 'daily', 'manual', 'snapshot'])->default('daily'); + $table->unsignedBigInteger('size_bytes')->default(0); + $table->enum('status', ['queued', 'running', 'ok', 'failed'])->default('queued'); + $table->string('storage_location')->nullable(); // e.g. s3://clupilot-backups + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + $table->index(['site_id', 'completed_at']); + $table->index(['status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('backups'); + } +}; diff --git a/app/database/migrations/2026_05_31_200004_create_team_members_table.php b/app/database/migrations/2026_05_31_200004_create_team_members_table.php new file mode 100644 index 0000000..d7ef4e4 --- /dev/null +++ b/app/database/migrations/2026_05_31_200004_create_team_members_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->enum('role', ['owner', 'admin', 'developer', 'viewer'])->default('viewer'); + $table->string('scope')->nullable(); // e.g. "All clusters", "eu-prod-*" + $table->boolean('two_fa_enabled')->default(false); + $table->timestamp('last_active_at')->nullable(); + $table->timestamps(); + $table->unique('user_id'); + $table->index(['role']); + }); + } + + public function down(): void + { + Schema::dropIfExists('team_members'); + } +}; diff --git a/app/database/migrations/2026_05_31_200005_create_invitations_table.php b/app/database/migrations/2026_05_31_200005_create_invitations_table.php new file mode 100644 index 0000000..815ac36 --- /dev/null +++ b/app/database/migrations/2026_05_31_200005_create_invitations_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('email'); + $table->enum('role', ['admin', 'developer', 'viewer'])->default('developer'); + $table->foreignId('invited_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('token', 64)->unique(); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('accepted_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + $table->index(['email']); + }); + } + + public function down(): void + { + Schema::dropIfExists('invitations'); + } +}; diff --git a/app/database/migrations/2026_05_31_200006_create_security_events_table.php b/app/database/migrations/2026_05_31_200006_create_security_events_table.php new file mode 100644 index 0000000..a1ac6e5 --- /dev/null +++ b/app/database/migrations/2026_05_31_200006_create_security_events_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('site_id')->nullable()->constrained()->cascadeOnDelete(); + $table->enum('type', ['brute_force', 'tor_exit', 'malware', 'cert_expiring', 'firewall', 'scan']) + ->default('firewall'); + $table->enum('severity', ['info', 'low', 'medium', 'high', 'critical'])->default('low'); + $table->boolean('blocked')->default(true); + $table->string('source_ip', 45)->nullable(); + $table->string('country', 4)->nullable(); + $table->string('message')->nullable(); + $table->json('meta')->nullable(); + $table->timestamp('occurred_at'); + $table->timestamps(); + $table->index(['severity', 'occurred_at']); + $table->index(['site_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('security_events'); + } +}; diff --git a/app/database/migrations/2026_05_31_200007_create_certificates_table.php b/app/database/migrations/2026_05_31_200007_create_certificates_table.php new file mode 100644 index 0000000..a4616c0 --- /dev/null +++ b/app/database/migrations/2026_05_31_200007_create_certificates_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('site_id')->constrained()->cascadeOnDelete(); + $table->string('domain'); + $table->string('issuer', 64)->default("Let's Encrypt"); + $table->timestamp('issued_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->boolean('auto_renew')->default(true); + $table->timestamps(); + $table->index(['expires_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('certificates'); + } +}; diff --git a/app/database/migrations/2026_05_31_200008_create_activities_table.php b/app/database/migrations/2026_05_31_200008_create_activities_table.php new file mode 100644 index 0000000..93b3138 --- /dev/null +++ b/app/database/migrations/2026_05_31_200008_create_activities_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->enum('type', ['deploy', 'backup', 'update', 'security', 'system', 'login'])->default('system'); + $table->enum('level', ['info', 'success', 'warning', 'error'])->default('info'); + $table->string('message'); + $table->string('subject')->nullable(); // domain / server name + $table->json('meta')->nullable(); + $table->timestamp('occurred_at'); + $table->timestamps(); + $table->index(['occurred_at']); + $table->index(['level']); + }); + } + + public function down(): void + { + Schema::dropIfExists('activities'); + } +}; diff --git a/app/database/migrations/2026_05_31_200009_create_crons_table.php b/app/database/migrations/2026_05_31_200009_create_crons_table.php new file mode 100644 index 0000000..32647be --- /dev/null +++ b/app/database/migrations/2026_05_31_200009_create_crons_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('site_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('expression', 64)->default('0 * * * *'); // cron expr + $table->string('command'); + $table->timestamp('last_run_at')->nullable(); + $table->enum('status', ['ok', 'failing', 'paused'])->default('ok'); + $table->boolean('enabled')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('crons'); + } +}; diff --git a/app/database/seeders/DatabaseSeeder.php b/app/database/seeders/DatabaseSeeder.php index 4a4cb0d..9b74b41 100644 --- a/app/database/seeders/DatabaseSeeder.php +++ b/app/database/seeders/DatabaseSeeder.php @@ -2,33 +2,152 @@ namespace Database\Seeders; +use App\Models\Activity; +use App\Models\Backup; +use App\Models\Certificate; +use App\Models\Cluster; +use App\Models\Cron; +use App\Models\Invitation; +use App\Models\SecurityEvent; +use App\Models\Server; +use App\Models\Site; +use App\Models\TeamMember; use App\Models\User; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { - use WithoutModelEvents; - - /** - * Seed the application's database. - */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); - - User::updateOrCreate( - ['email' => 'admin@clupilot.test'], + /* ───── primary user (matches template) ───── */ + $owner = User::updateOrCreate( + ['email' => 'm.weber@acme-cluster.de'], [ - 'name' => 'Admin', - 'password' => bcrypt('clupilot'), + 'name' => 'Marie Weber', + 'password' => bcrypt('clupilot'), 'email_verified_at' => now(), ] ); + + TeamMember::updateOrCreate( + ['user_id' => $owner->id], + [ + 'role' => 'owner', + 'scope' => 'All clusters', + 'two_fa_enabled' => true, + 'last_active_at' => now(), + ] + ); + + /* ───── dev login (kept from previous seed) ───── */ + User::updateOrCreate( + ['email' => 'admin@clupilot.test'], + [ + 'name' => 'Admin', + 'password' => bcrypt('clupilot'), + 'email_verified_at' => now(), + ] + ); + + /* ───── 1 cluster: Acme Cluster ───── */ + $cluster = Cluster::firstOrCreate( + ['slug' => 'acme-cluster'], + [ + 'name' => 'Acme Cluster', + 'region' => 'eu-central-1', + 'env' => 'prod', + 'monthly_price_cents' => 24900, + 'node_count' => 6, + 'backups_enabled' => true, + ] + ); + + /* ───── 6 servers ───── */ + $servers = [ + ['name' => 'eu-prod-01', 'region' => 'eu-central-1', 'flag' => '🇩🇪', 'env' => 'prod', 'status' => 'ok', 'cpu' => 42, 'ram' => 58, 'disk' => 47], + ['name' => 'eu-prod-02', 'region' => 'eu-central-1', 'flag' => '🇩🇪', 'env' => 'prod', 'status' => 'ok', 'cpu' => 36, 'ram' => 64, 'disk' => 51], + ['name' => 'edge-ams', 'region' => 'eu-west-1', 'flag' => '🇳🇱', 'env' => 'edge', 'status' => 'ok', 'cpu' => 21, 'ram' => 45, 'disk' => 38], + ['name' => 'us-east-01', 'region' => 'us-east-1', 'flag' => '🇺🇸', 'env' => 'prod', 'status' => 'warn', 'cpu' => 78, 'ram' => 71, 'disk' => 54], + ['name' => 'staging', 'region' => 'eu-central-1', 'flag' => '🇩🇪', 'env' => 'staging', 'status' => 'ok', 'cpu' => 18, 'ram' => 32, 'disk' => 22], + ['name' => 'ap-bom-01', 'region' => 'ap-south-1', 'flag' => '🇮🇳', 'env' => 'prod', 'status' => 'ok', 'cpu' => 28, 'ram' => 52, 'disk' => 43], + ]; + + $serverModels = []; + foreach ($servers as $s) { + $serverModels[] = Server::firstOrCreate( + ['name' => $s['name']], + [ + 'cluster_id' => $cluster->id, + 'region' => $s['region'], + 'country_flag' => $s['flag'], + 'env' => $s['env'], + 'cpu_cores' => 8, + 'ram_gb' => 32, + 'disk_gb' => 320, + 'cpu_load_pct' => $s['cpu'], + 'ram_used_pct' => $s['ram'], + 'disk_used_pct' => $s['disk'], + 'status' => $s['status'], + 'ip' => fake()->ipv4(), + ] + ); + } + + /* ───── 87 sites distributed across servers ───── */ + $distribution = [23, 19, 14, 18, 6, 7]; // sums to 87 + + foreach ($serverModels as $i => $server) { + $target = $distribution[$i] ?? 5; + $existing = $server->sites()->count(); + if ($existing < $target) { + Site::factory()->count($target - $existing) + ->create(['server_id' => $server->id]); + } + } + + $allSites = Site::query()->get(); + + /* ───── Certificates ───── */ + foreach ($allSites as $site) { + if (! $site->certificate) { + Certificate::factory()->create([ + 'site_id' => $site->id, + 'domain' => '*.' . $site->domain, + ]); + } + } + + /* ───── Backups (sample) ───── */ + $backupSites = $allSites->random(min(28, $allSites->count())); + foreach ($backupSites as $site) { + Backup::factory()->count(rand(2, 6))->create(['site_id' => $site->id]); + } + + /* ───── Crons (sample) ───── */ + foreach ($allSites->random(min(20, $allSites->count())) as $site) { + Cron::factory()->count(rand(1, 4))->create(['site_id' => $site->id]); + } + + /* ───── Security events: 128 in last 24h (matches template stat) ───── */ + SecurityEvent::factory()->count(128) + ->create(['site_id' => $allSites->random()->id]); + + /* ───── Team members (4 + owner) ───── */ + if (TeamMember::count() < 5) { + User::factory()->count(4)->create()->each(function (User $u) { + TeamMember::factory()->create(['user_id' => $u->id]); + }); + } + + /* ───── Pending invitations ───── */ + if (Invitation::count() < 3) { + Invitation::factory()->count(3)->create([ + 'invited_by_user_id' => $owner->id, + 'accepted_at' => null, + ]); + } + + /* ───── Activity feed (30 recent items) ───── */ + Activity::factory()->count(30)->create(['user_id' => $owner->id]); } }