From 1aa7535fc407655014f7ab1a7ad997d5637c82b7 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 08:35:03 +0200 Subject: [PATCH] feat(admin): dark Tactical-Terminal operator console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate admin console at /admin, gated to is_admin users (EnsureAdmin middleware + migration/seed; a plain customer user is seeded to prove the gate). Distinct dark aesthetic achieved purely by token scoping: .theme-admin overrides every CSS design token to a dark graphite / signal-orange palette, so ALL shared components (button, card, badge, table, stat, chart) render dark with zero new markup (R3). Chart island now reads tokens from its own element, so charts are theme-aware. Sections (each full-page class-based Livewire, English routes R13, localized DE/EN): - Overview: fleet KPIs, fleet-growth line, host-load bars, MRR bars, active runs, alerts. - Customers: table + plan doughnut. Instances: fleet table (vmid/host/storage). - Hosts: capacity cards (storage/CPU bars). Provisioning: runs table + live stepper. - Revenue: MRR/ARR/ARPU/churn KPIs, MRR line, plan doughnut, recent payments. - Locale-aware month labels/currency (Carbon/Number). 18 new Pest tests (guest redirect / non-admin 403 / admin render per section) → 44 green. R12 browser: all six admin pages HTTP 200, ZERO console errors (Chart.js dark). Codex (R15) — clean. Co-Authored-By: Claude Opus 4.8 --- app/Http/Middleware/EnsureAdmin.php | 20 ++++ app/Livewire/Admin/Customers.php | 44 +++++++++ app/Livewire/Admin/Hosts.php | 22 +++++ app/Livewire/Admin/Instances.php | 25 +++++ app/Livewire/Admin/Overview.php | 87 +++++++++++++++++ app/Livewire/Admin/Provisioning.php | 35 +++++++ app/Livewire/Admin/Revenue.php | 64 +++++++++++++ app/Models/User.php | 3 +- bootstrap/app.php | 4 +- ..._25_062321_add_is_admin_to_users_table.php | 28 ++++++ database/seeders/DatabaseSeeder.php | 11 +++ lang/de/admin.php | 91 ++++++++++++++++++ lang/en/admin.php | 91 ++++++++++++++++++ resources/css/admin-tokens.css | 33 +++++++ resources/css/app.css | 1 + resources/js/app.js | 4 +- resources/views/components/ui/icon.blade.php | 5 + resources/views/layouts/admin.blade.php | 96 +++++++++++++++++++ .../views/livewire/admin/customers.blade.php | 41 ++++++++ .../views/livewire/admin/hosts.blade.php | 36 +++++++ .../views/livewire/admin/instances.blade.php | 35 +++++++ .../views/livewire/admin/overview.blade.php | 80 ++++++++++++++++ .../livewire/admin/provisioning.blade.php | 40 ++++++++ .../views/livewire/admin/revenue.blade.php | 44 +++++++++ routes/web.php | 11 +++ tests/Feature/Admin/AdminConsoleTest.php | 23 +++++ 26 files changed, 971 insertions(+), 3 deletions(-) create mode 100644 app/Http/Middleware/EnsureAdmin.php create mode 100644 app/Livewire/Admin/Customers.php create mode 100644 app/Livewire/Admin/Hosts.php create mode 100644 app/Livewire/Admin/Instances.php create mode 100644 app/Livewire/Admin/Overview.php create mode 100644 app/Livewire/Admin/Provisioning.php create mode 100644 app/Livewire/Admin/Revenue.php create mode 100644 database/migrations/2026_07_25_062321_add_is_admin_to_users_table.php create mode 100644 lang/de/admin.php create mode 100644 lang/en/admin.php create mode 100644 resources/css/admin-tokens.css create mode 100644 resources/views/layouts/admin.blade.php create mode 100644 resources/views/livewire/admin/customers.blade.php create mode 100644 resources/views/livewire/admin/hosts.blade.php create mode 100644 resources/views/livewire/admin/instances.blade.php create mode 100644 resources/views/livewire/admin/overview.blade.php create mode 100644 resources/views/livewire/admin/provisioning.blade.php create mode 100644 resources/views/livewire/admin/revenue.blade.php create mode 100644 tests/Feature/Admin/AdminConsoleTest.php 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);