diff --git a/app/Http/Middleware/EnsureAdmin.php b/app/Http/Middleware/EnsureAdmin.php new file mode 100644 index 0000000..1e098a1 --- /dev/null +++ b/app/Http/Middleware/EnsureAdmin.php @@ -0,0 +1,20 @@ +user()?->is_admin, 403); + + return $next($request); + } +} diff --git a/app/Livewire/Admin/Customers.php b/app/Livewire/Admin/Customers.php new file mode 100644 index 0000000..dd9fb16 --- /dev/null +++ b/app/Livewire/Admin/Customers.php @@ -0,0 +1,44 @@ +getLocale(); + $eur = fn (int $v) => Number::currency($v, in: 'EUR', locale: $locale); + + return view('livewire.admin.customers', [ + 'rows' => [ + ['name' => 'Kanzlei Berger', 'plan' => 'Team', 'mrr' => $eur(198), 'instance' => 'kanzlei-berger', 'status' => 'active'], + ['name' => 'Ordination Dr. Fux', 'plan' => 'Solo', 'mrr' => $eur(79), 'instance' => 'ordination-fux', 'status' => 'provisioning'], + ['name' => 'Architekturbüro Lang', 'plan' => 'Team', 'mrr' => $eur(198), 'instance' => 'buero-lang', 'status' => 'provisioning'], + ['name' => 'Weingut Prantl', 'plan' => 'Solo', 'mrr' => $eur(79), 'instance' => 'weingut-prantl', 'status' => 'provisioning'], + ['name' => 'Steuerberatung Reiss', 'plan' => 'Team', 'mrr' => $eur(198), 'instance' => 'stb-reiss', 'status' => 'active'], + ['name' => 'Notariat Külz', 'plan' => 'Enterprise', 'mrr' => $eur(449), 'instance' => 'notariat-kuelz', 'status' => 'active'], + ['name' => 'Praxis Sommer', 'plan' => 'Solo', 'mrr' => $eur(79), 'instance' => 'praxis-sommer', 'status' => 'suspended'], + ], + 'plansChart' => [ + 'type' => 'doughnut', + 'data' => [ + 'labels' => ['Solo', 'Team', 'Enterprise'], + 'datasets' => [[ + 'data' => [21, 17, 4], + 'backgroundColor' => ['token:info', 'token:accent', 'token:success-bright'], + 'borderWidth' => 0, + ]], + ], + 'options' => [ + 'cutout' => '62%', + 'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]], + ], + ], + ]); + } +} diff --git a/app/Livewire/Admin/Hosts.php b/app/Livewire/Admin/Hosts.php new file mode 100644 index 0000000..89e45c5 --- /dev/null +++ b/app/Livewire/Admin/Hosts.php @@ -0,0 +1,22 @@ + [ + ['name' => 'pve-fsn-1', 'ip' => '10.10.0.11', 'instances' => 12, 'used' => 72, 'cpu' => 58, 'status' => 'active'], + ['name' => 'pve-fsn-2', 'ip' => '10.10.0.12', 'instances' => 11, 'used' => 64, 'cpu' => 47, 'status' => 'active'], + ['name' => 'pve-fsn-3', 'ip' => '10.10.0.13', 'instances' => 13, 'used' => 81, 'cpu' => 74, 'status' => 'warning'], + ['name' => 'pve-hel-1', 'ip' => '10.20.0.11', 'instances' => 3, 'used' => 38, 'cpu' => 22, 'status' => 'active'], + ], + ]); + } +} diff --git a/app/Livewire/Admin/Instances.php b/app/Livewire/Admin/Instances.php new file mode 100644 index 0000000..24cfb5c --- /dev/null +++ b/app/Livewire/Admin/Instances.php @@ -0,0 +1,25 @@ + [ + ['sub' => 'kanzlei-berger', 'host' => 'pve-fsn-1', 'vmid' => 1042, 'storage' => '235 / 500 GB', 'version' => 'NC 31.0.4', 'status' => 'active'], + ['sub' => 'stb-reiss', 'host' => 'pve-fsn-1', 'vmid' => 1051, 'storage' => '88 / 250 GB', 'version' => 'NC 31.0.4', 'status' => 'active'], + ['sub' => 'notariat-kuelz', 'host' => 'pve-fsn-2', 'vmid' => 1067, 'storage' => '612 / 1000 GB', 'version' => 'NC 31.0.4', 'status' => 'active'], + ['sub' => 'ordination-fux', 'host' => 'pve-fsn-2', 'vmid' => 1072, 'storage' => '2 / 100 GB', 'version' => 'NC 31.0.4', 'status' => 'provisioning'], + ['sub' => 'buero-lang', 'host' => 'pve-fsn-3', 'vmid' => 1074, 'storage' => '—', 'version' => 'NC 31.0.4', 'status' => 'provisioning'], + ['sub' => 'weingut-prantl', 'host' => 'pve-hel-1', 'vmid' => 1075, 'storage' => '4 / 100 GB', 'version' => 'NC 31.0.4', 'status' => 'provisioning'], + ['sub' => 'praxis-sommer', 'host' => 'pve-fsn-3', 'vmid' => 1039, 'storage' => '41 / 100 GB', 'version' => 'NC 31.0.4', 'status' => 'suspended'], + ], + ]); + } +} diff --git a/app/Livewire/Admin/Overview.php b/app/Livewire/Admin/Overview.php new file mode 100644 index 0000000..71183b3 --- /dev/null +++ b/app/Livewire/Admin/Overview.php @@ -0,0 +1,87 @@ +getLocale(); + $months = collect(range(11, 0)) + ->map(fn ($i) => Carbon::now()->locale($locale)->startOfMonth()->subMonths($i)->isoFormat('MMM')) + ->all(); + + return view('livewire.admin.overview', [ + 'kpis' => [ + ['label' => __('admin.kpi.customers'), 'value' => '42', 'delta' => '+3', 'tone' => 'success'], + ['label' => __('admin.kpi.instances'), 'value' => '39', 'sub' => __('admin.kpi.instances_sub'), 'tone' => 'success'], + ['label' => __('admin.kpi.hosts'), 'value' => '4', 'sub' => __('admin.kpi.hosts_sub'), 'tone' => 'muted'], + ['label' => __('admin.kpi.mrr'), 'value' => Number::currency(7842, in: 'EUR', locale: $locale), 'delta' => '+6,2 %', 'tone' => 'success'], + ], + 'fleetChart' => [ + 'type' => 'line', + 'data' => [ + 'labels' => $months, + 'datasets' => [[ + 'label' => __('admin.kpi.instances'), + 'data' => [21, 23, 25, 27, 28, 30, 31, 33, 35, 36, 38, 39], + 'borderColor' => 'token:accent', + 'backgroundColor' => 'token:accent/0.12', + 'fill' => true, 'tension' => 0.35, 'pointRadius' => 0, 'borderWidth' => 2, + ]], + ], + 'options' => [ + 'scales' => ['x' => ['grid' => ['display' => false]], 'y' => ['grid' => ['color' => 'token:border']]], + 'plugins' => ['legend' => ['display' => false]], + ], + ], + 'revenueChart' => [ + 'type' => 'bar', + 'data' => [ + 'labels' => $months, + 'datasets' => [[ + 'label' => 'MRR', + 'data' => [4100, 4460, 4900, 5300, 5600, 6050, 6300, 6700, 7050, 7300, 7620, 7842], + 'backgroundColor' => 'token:accent/0.85', 'borderRadius' => 3, + ]], + ], + 'options' => [ + 'scales' => ['x' => ['grid' => ['display' => false]], 'y' => ['grid' => ['color' => 'token:border']]], + 'plugins' => ['legend' => ['display' => false]], + ], + ], + 'hostChart' => [ + 'type' => 'bar', + 'data' => [ + 'labels' => ['pve-fsn-1', 'pve-fsn-2', 'pve-fsn-3', 'pve-hel-1'], + 'datasets' => [[ + 'label' => '%', + 'data' => [72, 64, 81, 38], + 'backgroundColor' => ['token:accent/0.85', 'token:accent/0.85', 'token:danger/0.85', 'token:success-bright/0.85'], + 'borderRadius' => 3, + ]], + ], + 'options' => [ + 'indexAxis' => 'y', + 'scales' => ['x' => ['max' => 100, 'grid' => ['color' => 'token:border']], 'y' => ['grid' => ['display' => false]]], + 'plugins' => ['legend' => ['display' => false]], + ], + ], + 'runs' => [ + ['customer' => 'Ordination Dr. Fux', 'step' => __('admin.run.deploy'), 'state' => 'running'], + ['customer' => 'Architekturbüro Lang', 'step' => __('admin.run.dns'), 'state' => 'running'], + ['customer' => 'Weingut Prantl', 'step' => __('admin.run.acceptance'), 'state' => 'running'], + ], + 'alerts' => [ + ['level' => 'warning', 'text' => __('admin.alert.host_load', ['host' => 'pve-fsn-3'])], + ['level' => 'info', 'text' => __('admin.alert.cert_soon', ['n' => 3])], + ], + ]); + } +} diff --git a/app/Livewire/Admin/Provisioning.php b/app/Livewire/Admin/Provisioning.php new file mode 100644 index 0000000..6ea53aa --- /dev/null +++ b/app/Livewire/Admin/Provisioning.php @@ -0,0 +1,35 @@ + [ + ['customer' => 'Ordination Dr. Fux', 'pipeline' => 'customer', 'step' => 'DeployApplicationStack', 'n' => '8/15', 'attempt' => 1, 'state' => 'running'], + ['customer' => 'Architekturbüro Lang', 'pipeline' => 'customer', 'step' => 'ConfigureDnsAndTls', 'n' => '11/15', 'attempt' => 2, 'state' => 'running'], + ['customer' => 'Weingut Prantl', 'pipeline' => 'customer', 'step' => 'RunAcceptanceChecks', 'n' => '14/15', 'attempt' => 1, 'state' => 'running'], + ['customer' => 'Praxis Sommer', 'pipeline' => 'customer', 'step' => 'CloneVirtualMachine', 'n' => '3/15', 'attempt' => 3, 'state' => 'failed'], + ['customer' => 'Steuerberatung Reiss', 'pipeline' => 'customer', 'step' => 'CompleteProvisioning', 'n' => '15/15', 'attempt' => 1, 'state' => 'done'], + ], + 'steps' => [ + ['label' => 'ValidateOrder', 'state' => 'done'], + ['label' => 'ReserveResources', 'state' => 'done'], + ['label' => 'CloneVirtualMachine', 'state' => 'done'], + ['label' => 'ConfigureCloudInit', 'state' => 'done'], + ['label' => 'StartVirtualMachine', 'state' => 'done'], + ['label' => 'WaitForGuestAgent', 'state' => 'done'], + ['label' => 'ConfigureNetwork', 'state' => 'done'], + ['label' => 'DeployApplicationStack', 'state' => 'running'], + ['label' => 'ConfigureNextcloud', 'state' => 'pending'], + ['label' => 'RunAcceptanceChecks', 'state' => 'pending'], + ], + ]); + } +} diff --git a/app/Livewire/Admin/Revenue.php b/app/Livewire/Admin/Revenue.php new file mode 100644 index 0000000..6bf5ed9 --- /dev/null +++ b/app/Livewire/Admin/Revenue.php @@ -0,0 +1,64 @@ +getLocale(); + $eur = fn (int $v) => Number::currency($v, in: 'EUR', locale: $locale); + $months = collect(range(11, 0)) + ->map(fn ($i) => Carbon::now()->locale($locale)->startOfMonth()->subMonths($i)->isoFormat('MMM')) + ->all(); + + return view('livewire.admin.revenue', [ + 'kpis' => [ + ['label' => __('admin.rev.mrr'), 'value' => $eur(7842), 'delta' => '+6,2 %'], + ['label' => __('admin.rev.arr'), 'value' => $eur(94104), 'delta' => '+18 %'], + ['label' => __('admin.rev.arpu'), 'value' => $eur(187), 'delta' => '+1,4 %'], + ['label' => __('admin.rev.churn'), 'value' => '1,8 %', 'delta' => '−0,3 %'], + ], + 'mrrChart' => [ + 'type' => 'line', + 'data' => [ + 'labels' => $months, + 'datasets' => [[ + 'label' => 'MRR', + 'data' => [4100, 4460, 4900, 5300, 5600, 6050, 6300, 6700, 7050, 7300, 7620, 7842], + 'borderColor' => 'token:accent', 'backgroundColor' => 'token:accent/0.12', + 'fill' => true, 'tension' => 0.35, 'pointRadius' => 0, 'borderWidth' => 2, + ]], + ], + 'options' => [ + 'scales' => ['x' => ['grid' => ['display' => false]], 'y' => ['grid' => ['color' => 'token:border']]], + 'plugins' => ['legend' => ['display' => false]], + ], + ], + 'planChart' => [ + 'type' => 'doughnut', + 'data' => [ + 'labels' => ['Solo', 'Team', 'Enterprise'], + 'datasets' => [[ + 'data' => [1659, 3366, 1796], + 'backgroundColor' => ['token:info', 'token:accent', 'token:success-bright'], + 'borderWidth' => 0, + ]], + ], + 'options' => ['cutout' => '62%', 'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]]], + ], + 'payments' => [ + ['customer' => 'Notariat Külz', 'amount' => $eur(449), 'when' => '01.07.'], + ['customer' => 'Kanzlei Berger', 'amount' => $eur(198), 'when' => '01.07.'], + ['customer' => 'Steuerberatung Reiss', 'amount' => $eur(198), 'when' => '01.07.'], + ['customer' => 'Ordination Dr. Fux', 'amount' => $eur(79), 'when' => '30.06.'], + ], + ]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 04b3b7c..de91f96 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -11,7 +11,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Fortify\TwoFactorAuthenticatable; -#[Fillable(['name', 'email', 'password'])] +#[Fillable(['name', 'email', 'password', 'is_admin'])] #[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])] class User extends Authenticatable { @@ -28,6 +28,7 @@ class User extends Authenticatable return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'is_admin' => 'boolean', ]; } } diff --git a/bootstrap/app.php b/bootstrap/app.php index adfbe54..725857a 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -13,7 +13,9 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'admin' => \App\Http\Middleware\EnsureAdmin::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/database/migrations/2026_07_25_062321_add_is_admin_to_users_table.php b/database/migrations/2026_07_25_062321_add_is_admin_to_users_table.php new file mode 100644 index 0000000..b15a101 --- /dev/null +++ b/database/migrations/2026_07_25_062321_add_is_admin_to_users_table.php @@ -0,0 +1,28 @@ +boolean('is_admin')->default(false)->after('email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('is_admin'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index aa250aa..20f421f 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -23,6 +23,17 @@ class DatabaseSeeder extends Seeder [ 'name' => 'Admin', 'password' => Hash::make(env('SEED_ADMIN_PASSWORD', 'password')), + 'is_admin' => true, + ], + ); + + // A plain customer to exercise the portal (and prove the admin gate). + User::updateOrCreate( + ['email' => 'kunde@clupilot.local'], + [ + 'name' => 'Dr. S. Berger', + 'password' => Hash::make(env('SEED_ADMIN_PASSWORD', 'password')), + 'is_admin' => false, ], ); } diff --git a/lang/de/admin.php b/lang/de/admin.php new file mode 100644 index 0000000..1e81a8b --- /dev/null +++ b/lang/de/admin.php @@ -0,0 +1,91 @@ + 'Operator-Konsole', + 'badge' => 'Admin', + 'to_portal' => 'Zum Kundenportal', + + 'nav' => [ + 'overview' => 'Übersicht', + 'customers' => 'Kunden', + 'instances' => 'Instanzen', + 'hosts' => 'Hosts', + 'provisioning' => 'Provisioning', + 'revenue' => 'Umsatz', + ], + + 'overview_title' => 'Fleet-Übersicht', + 'overview_sub' => 'Zustand der gesamten Plattform auf einen Blick.', + 'systems_ok' => 'Alle Systeme normal', + + 'kpi' => [ + 'customers' => 'Kunden', + 'instances' => 'Aktive Instanzen', + 'instances_sub' => '3 in Bereitstellung', + 'hosts' => 'Hosts', + 'hosts_sub' => '3 · Falkenstein · 1 · Helsinki', + 'mrr' => 'MRR', + ], + + 'fleet_growth' => 'Flotten-Wachstum', + 'fleet_growth_sub' => 'Aktive Instanzen, 12 Monate.', + 'host_load' => 'Host-Auslastung', + 'host_load_sub' => 'Belegter Speicher je Host.', + 'mrr_trend' => 'MRR-Verlauf', + 'active_runs' => 'Laufende Bereitstellungen', + 'view_all' => 'Alle ansehen', + 'alerts' => 'Hinweise', + 'run_detail' => 'Aktueller Lauf', + + 'run' => [ + 'deploy' => 'DeployApplicationStack', + 'dns' => 'ConfigureDnsAndTls', + 'acceptance' => 'RunAcceptanceChecks', + ], + 'alert' => [ + 'host_load' => 'Host :host über 80 % Speicherauslastung.', + 'cert_soon' => ':n Zertifikate laufen in 14 Tagen ab.', + ], + + 'customers_sub' => 'Alle Kunden und ihre Pakete.', + 'by_plan' => 'Nach Paket', + 'instances_sub' => 'Alle bereitgestellten Cloud-Instanzen.', + 'instances_label' => 'Instanzen', + 'hosts_sub' => 'Proxmox-Hosts und Kapazität.', + 'storage_used' => 'Speicher belegt', + 'provisioning_sub' => 'Laufende und abgeschlossene Bereitstellungen.', + 'revenue_sub' => 'Umsatzkennzahlen der Plattform.', + 'mrr_by_plan' => 'MRR nach Paket', + 'recent_payments' => 'Letzte Zahlungen', + + 'col' => [ + 'customer' => 'Kunde', + 'plan' => 'Paket', + 'mrr' => 'MRR', + 'status' => 'Status', + 'subdomain' => 'Subdomain', + 'host' => 'Host', + 'storage' => 'Speicher', + 'version' => 'Version', + 'step' => 'Schritt', + 'attempt' => 'Versuch', + ], + + 'status' => [ + 'active' => 'Aktiv', + 'provisioning' => 'Bereitstellung', + 'suspended' => 'Ausgesetzt', + 'warning' => 'Warnung', + ], + + 'state_running' => 'Läuft', + 'state_done' => 'Fertig', + 'state_failed' => 'Fehlgeschlagen', + + 'rev' => [ + 'mrr' => 'MRR', + 'arr' => 'ARR', + 'arpu' => 'ARPU', + 'churn' => 'Churn (30 T)', + ], +]; diff --git a/lang/en/admin.php b/lang/en/admin.php new file mode 100644 index 0000000..5e28dae --- /dev/null +++ b/lang/en/admin.php @@ -0,0 +1,91 @@ + 'Operator console', + 'badge' => 'Admin', + 'to_portal' => 'To customer portal', + + 'nav' => [ + 'overview' => 'Overview', + 'customers' => 'Customers', + 'instances' => 'Instances', + 'hosts' => 'Hosts', + 'provisioning' => 'Provisioning', + 'revenue' => 'Revenue', + ], + + 'overview_title' => 'Fleet overview', + 'overview_sub' => 'The whole platform at a glance.', + 'systems_ok' => 'All systems normal', + + 'kpi' => [ + 'customers' => 'Customers', + 'instances' => 'Active instances', + 'instances_sub' => '3 provisioning', + 'hosts' => 'Hosts', + 'hosts_sub' => '3 · Falkenstein · 1 · Helsinki', + 'mrr' => 'MRR', + ], + + 'fleet_growth' => 'Fleet growth', + 'fleet_growth_sub' => 'Active instances, 12 months.', + 'host_load' => 'Host load', + 'host_load_sub' => 'Used storage per host.', + 'mrr_trend' => 'MRR trend', + 'active_runs' => 'Active provisioning', + 'view_all' => 'View all', + 'alerts' => 'Alerts', + 'run_detail' => 'Current run', + + 'run' => [ + 'deploy' => 'DeployApplicationStack', + 'dns' => 'ConfigureDnsAndTls', + 'acceptance' => 'RunAcceptanceChecks', + ], + 'alert' => [ + 'host_load' => 'Host :host over 80% storage usage.', + 'cert_soon' => ':n certificates expire in 14 days.', + ], + + 'customers_sub' => 'All customers and their plans.', + 'by_plan' => 'By plan', + 'instances_sub' => 'All provisioned cloud instances.', + 'instances_label' => 'instances', + 'hosts_sub' => 'Proxmox hosts and capacity.', + 'storage_used' => 'Storage used', + 'provisioning_sub' => 'Running and completed provisioning.', + 'revenue_sub' => 'Platform revenue metrics.', + 'mrr_by_plan' => 'MRR by plan', + 'recent_payments' => 'Recent payments', + + 'col' => [ + 'customer' => 'Customer', + 'plan' => 'Plan', + 'mrr' => 'MRR', + 'status' => 'Status', + 'subdomain' => 'Subdomain', + 'host' => 'Host', + 'storage' => 'Storage', + 'version' => 'Version', + 'step' => 'Step', + 'attempt' => 'Attempt', + ], + + 'status' => [ + 'active' => 'Active', + 'provisioning' => 'Provisioning', + 'suspended' => 'Suspended', + 'warning' => 'Warning', + ], + + 'state_running' => 'Running', + 'state_done' => 'Done', + 'state_failed' => 'Failed', + + 'rev' => [ + 'mrr' => 'MRR', + 'arr' => 'ARR', + 'arpu' => 'ARPU', + 'churn' => 'Churn (30d)', + ], +]; diff --git a/resources/css/admin-tokens.css b/resources/css/admin-tokens.css new file mode 100644 index 0000000..7918d29 --- /dev/null +++ b/resources/css/admin-tokens.css @@ -0,0 +1,33 @@ +/* CluPilot admin console — "Tactical Terminal": dark graphite, signal-orange, + ops status triad. Scoped to .theme-admin so it overrides the light portal + tokens only inside the admin layout — every shared component adapts for free. */ +.theme-admin { + --bg: #0b0e13; + --surface: #12161d; + --surface-2: #1a2029; + --surface-hover: #1c232d; + + --border: #232b37; + --border-strong: #313b4a; + + --text-strong: #eef1f6; + --text: #c2cad6; + --text-muted: #8b95a5; + --text-faint: #626d7e; + + /* signal orange — brighter for dark; dark text sits on the fill (AA) */ + --accent: #ff7a2f; + --accent-hover: #ff8f4d; + --accent-active: #ff7a2f; + --accent-press: #e8630f; + --accent-subtle: rgba(255, 122, 47, .14); + --accent-border: rgba(255, 122, 47, .32); + --accent-ring: rgba(255, 122, 47, .45); + --on-accent: #0b0e13; + --accent-text: #ff9152; + + --success: #35d07f; --success-bg: rgba(53, 208, 127, .13); --success-border: rgba(53, 208, 127, .32); --success-bright: #35d07f; + --warning: #e8b931; --warning-bg: rgba(232, 185, 49, .13); --warning-border: rgba(232, 185, 49, .32); + --danger: #ff5247; --danger-bg: rgba(255, 82, 71, .13); --danger-border: rgba(255, 82, 71, .32); + --info: #5b9bff; --info-bg: rgba(91, 155, 255, .13); --info-border: rgba(91, 155, 255, .32); +} diff --git a/resources/css/app.css b/resources/css/app.css index 48805b3..21e39b8 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -10,6 +10,7 @@ /* Design tokens, then Tailwind v3 layers. */ @import './portal-tokens.css'; +@import './admin-tokens.css'; @tailwind base; @tailwind components; diff --git a/resources/js/app.js b/resources/js/app.js index f194162..623a742 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -74,7 +74,9 @@ document.addEventListener('alpine:init', () => { window.Alpine.data('chart', (config) => ({ instance: null, init() { - const css = getComputedStyle(document.documentElement); + // Read tokens from this element so charts honour a scoped theme + // (e.g. .theme-admin dark tokens), not just :root. + const css = getComputedStyle(this.$el); Chart.defaults.font.family = css.getPropertyValue('--font-sans').trim() || 'sans-serif'; Chart.defaults.color = css.getPropertyValue('--text-muted').trim() || '#667085'; Chart.defaults.borderColor = css.getPropertyValue('--border').trim() || '#e4e7ec'; diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php index d2580f6..4a9edb9 100644 --- a/resources/views/components/ui/icon.blade.php +++ b/resources/views/components/ui/icon.blade.php @@ -19,6 +19,11 @@ 'alert-triangle' => '', 'check' => '', 'plus' => '', + 'server' => '', + 'activity' => '', + 'trending-up' => '', + 'box' => '', + 'bell' => '', ]; $body = $icons[$name] ?? ''; @endphp diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php new file mode 100644 index 0000000..901ca62 --- /dev/null +++ b/resources/views/layouts/admin.blade.php @@ -0,0 +1,96 @@ + + + + + + + {{ $title ?? __('admin.console') }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + +{{-- theme-admin swaps every design token to the dark Tactical-Terminal set. --}} + +
+ + +
+ +
+
+ + +
+ + @auth +
+ +
+
+ @csrf + +
+
+
+ @endauth +
+ +
+ {{ $slot }} +
+
+
+ +
+ +
+ + @livewireScripts + + diff --git a/resources/views/livewire/admin/customers.blade.php b/resources/views/livewire/admin/customers.blade.php new file mode 100644 index 0000000..bed1640 --- /dev/null +++ b/resources/views/livewire/admin/customers.blade.php @@ -0,0 +1,41 @@ +
+
+

{{ __('admin.nav.customers') }}

+

{{ __('admin.customers_sub') }}

+
+ +
+
+
+ + + + + + + + + + + @foreach ($rows as $r) + + + + + + + @endforeach + +
{{ __('admin.col.customer') }}{{ __('admin.col.plan') }}{{ __('admin.col.mrr') }}{{ __('admin.col.status') }}
+

{{ $r['name'] }}

+

{{ $r['instance'] }}.clupilot.com

+
{{ $r['plan'] }}{{ $r['mrr'] }}{{ __('admin.status.'.$r['status']) }}
+
+
+ +
+

{{ __('admin.by_plan') }}

+
+
+
+
diff --git a/resources/views/livewire/admin/hosts.blade.php b/resources/views/livewire/admin/hosts.blade.php new file mode 100644 index 0000000..f741e23 --- /dev/null +++ b/resources/views/livewire/admin/hosts.blade.php @@ -0,0 +1,36 @@ +
+
+

{{ __('admin.nav.hosts') }}

+

{{ __('admin.hosts_sub') }}

+
+ + @php $hd = ['[animation-delay:60ms]', '[animation-delay:120ms]', '[animation-delay:180ms]', '[animation-delay:240ms]']; @endphp +
+ @foreach ($rows as $i => $h) +
+
+ +
+

{{ $h['name'] }}

+

{{ $h['ip'] }} · {{ $h['instances'] }} {{ __('admin.instances_label') }}

+
+ {{ __('admin.status.'.($h['status'] === 'warning' ? 'warning' : 'active')) }} +
+
+
+
{{ __('admin.storage_used') }}{{ $h['used'] }}%
+
+
+
+
+
+
CPU{{ $h['cpu'] }}%
+
+
+
+
+
+
+ @endforeach +
+
diff --git a/resources/views/livewire/admin/instances.blade.php b/resources/views/livewire/admin/instances.blade.php new file mode 100644 index 0000000..ab9755b --- /dev/null +++ b/resources/views/livewire/admin/instances.blade.php @@ -0,0 +1,35 @@ +
+
+

{{ __('admin.nav.instances') }}

+

{{ __('admin.instances_sub') }}

+
+ +
+
+ + + + + + + + + + + + + @foreach ($rows as $r) + + + + + + + + + @endforeach + +
{{ __('admin.col.subdomain') }}{{ __('admin.col.host') }}VMID{{ __('admin.col.storage') }}{{ __('admin.col.version') }}{{ __('admin.col.status') }}
{{ $r['sub'] }}{{ $r['host'] }}{{ $r['vmid'] }}{{ $r['storage'] }}{{ $r['version'] }}{{ __('admin.status.'.$r['status']) }}
+
+
+
diff --git a/resources/views/livewire/admin/overview.blade.php b/resources/views/livewire/admin/overview.blade.php new file mode 100644 index 0000000..3904319 --- /dev/null +++ b/resources/views/livewire/admin/overview.blade.php @@ -0,0 +1,80 @@ +
+
+

{{ __('admin.overview_title') }}

+ + {{ __('admin.systems_ok') }} + +

{{ __('admin.overview_sub') }}

+
+ + {{-- KPI row --}} + @php $kd = ['[animation-delay:40ms]', '[animation-delay:80ms]', '[animation-delay:120ms]', '[animation-delay:160ms]']; @endphp +
+ @foreach ($kpis as $i => $kpi) +
+

{{ $kpi['label'] }}

+

+ {{ $kpi['value'] }} + @isset($kpi['delta']){{ $kpi['delta'] }}@endisset +

+ @isset($kpi['sub'])

{{ $kpi['sub'] }}

@endisset +
+ @endforeach +
+ + {{-- Charts --}} +
+
+

{{ __('admin.fleet_growth') }}

+

{{ __('admin.fleet_growth_sub') }}

+
+
+
+

{{ __('admin.host_load') }}

+

{{ __('admin.host_load_sub') }}

+
+
+
+ +
+
+

{{ __('admin.mrr_trend') }}

+
+
+ +
+ {{-- Active runs --}} +
+
+

{{ __('admin.active_runs') }}

+ {{ __('admin.view_all') }} › +
+
    + @foreach ($runs as $r) +
  • + +
    +

    {{ $r['customer'] }}

    +

    {{ $r['step'] }}

    +
    + {{ __('admin.state_running') }} +
  • + @endforeach +
+
+ + {{-- Alerts --}} +
+

{{ __('admin.alerts') }}

+
    + @foreach ($alerts as $a) +
  • + + {{ $a['text'] }} +
  • + @endforeach +
+
+
+
+
diff --git a/resources/views/livewire/admin/provisioning.blade.php b/resources/views/livewire/admin/provisioning.blade.php new file mode 100644 index 0000000..7b7ff38 --- /dev/null +++ b/resources/views/livewire/admin/provisioning.blade.php @@ -0,0 +1,40 @@ +
+
+

{{ __('admin.nav.provisioning') }}

+

{{ __('admin.provisioning_sub') }}

+
+ +
+
+
+ + + + + + + + + + + @foreach ($runs as $r) + @php $tone = ['running' => 'provisioning', 'done' => 'active', 'failed' => 'failed'][$r['state']] ?? 'info'; @endphp + + + + + + + @endforeach + +
{{ __('admin.col.customer') }}{{ __('admin.col.step') }}{{ __('admin.col.attempt') }}{{ __('admin.col.status') }}
{{ $r['customer'] }}{{ $r['step'] }} {{ $r['n'] }}{{ $r['attempt'] }}{{ __('admin.state_'.$r['state']) }}
+
+
+ +
+

{{ __('admin.run_detail') }}

+

ordination-fux · customer

+ +
+
+
diff --git a/resources/views/livewire/admin/revenue.blade.php b/resources/views/livewire/admin/revenue.blade.php new file mode 100644 index 0000000..eae9701 --- /dev/null +++ b/resources/views/livewire/admin/revenue.blade.php @@ -0,0 +1,44 @@ +
+
+

{{ __('admin.nav.revenue') }}

+

{{ __('admin.revenue_sub') }}

+
+ + @php $rd = ['[animation-delay:40ms]', '[animation-delay:80ms]', '[animation-delay:120ms]', '[animation-delay:160ms]']; @endphp +
+ @foreach ($kpis as $i => $kpi) +
+

{{ $kpi['label'] }}

+

+ {{ $kpi['value'] }} + {{ $kpi['delta'] }} +

+
+ @endforeach +
+ +
+
+

{{ __('admin.mrr_trend') }}

+
+
+
+

{{ __('admin.mrr_by_plan') }}

+
+
+
+ +
+

{{ __('admin.recent_payments') }}

+
    + @foreach ($payments as $p) +
  • + + {{ $p['customer'] }} + {{ $p['when'] }} + {{ $p['amount'] }} +
  • + @endforeach +
+
+
diff --git a/routes/web.php b/routes/web.php index ff132c8..711acb3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ group(function () { Route::get('/invoices', Invoices::class)->name('invoices'); Route::get('/support', Support::class)->name('support'); }); + +// Admin / operator console — dark theme, gated to is_admin users (R1/R2, R13). +Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () { + Route::get('/', Admin\Overview::class)->name('overview'); + Route::get('/customers', Admin\Customers::class)->name('customers'); + Route::get('/instances', Admin\Instances::class)->name('instances'); + Route::get('/hosts', Admin\Hosts::class)->name('hosts'); + Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); + Route::get('/revenue', Admin\Revenue::class)->name('revenue'); +}); diff --git a/tests/Feature/Admin/AdminConsoleTest.php b/tests/Feature/Admin/AdminConsoleTest.php new file mode 100644 index 0000000..6b53eec --- /dev/null +++ b/tests/Feature/Admin/AdminConsoleTest.php @@ -0,0 +1,23 @@ +get(route($route))->assertRedirect('/login'); +})->with($adminRoutes); + +it('forbids non-admin users from the admin console', function (string $route) { + $user = User::factory()->create(['is_admin' => false]); + + $this->actingAs($user)->get(route($route))->assertForbidden(); +})->with($adminRoutes); + +it('renders the admin console for admins', function (string $route) { + $admin = User::factory()->create(['is_admin' => true]); + + $this->actingAs($admin)->get(route($route)) + ->assertOk() + ->assertSee('CluPilot'); +})->with($adminRoutes);