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' => '
{{ __('admin.console') }}
+ + + @auth +{{ __('admin.customers_sub') }}
+| {{ __('admin.col.customer') }} | +{{ __('admin.col.plan') }} | +{{ __('admin.col.mrr') }} | +{{ __('admin.col.status') }} | +
|---|---|---|---|
|
+ {{ $r['name'] }} +{{ $r['instance'] }}.clupilot.com + |
+ {{ $r['plan'] }} | +{{ $r['mrr'] }} | +
{{ __('admin.hosts_sub') }}
+{{ $h['name'] }}
+{{ $h['ip'] }} · {{ $h['instances'] }} {{ __('admin.instances_label') }}
+{{ __('admin.instances_sub') }}
+| {{ __('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.overview_sub') }}
+{{ $kpi['label'] }}
++ {{ $kpi['value'] }} + @isset($kpi['delta']){{ $kpi['delta'] }}@endisset +
+ @isset($kpi['sub']){{ $kpi['sub'] }}
@endisset +{{ __('admin.fleet_growth_sub') }}
+{{ __('admin.host_load_sub') }}
+{{ $r['customer'] }}
+{{ $r['step'] }}
+{{ __('admin.provisioning_sub') }}
+| {{ __('admin.col.customer') }} | +{{ __('admin.col.step') }} | +{{ __('admin.col.attempt') }} | +{{ __('admin.col.status') }} | +
|---|---|---|---|
| {{ $r['customer'] }} | +{{ $r['step'] }} {{ $r['n'] }} | +{{ $r['attempt'] }} | +
ordination-fux · customer
+{{ __('admin.revenue_sub') }}
+{{ $kpi['label'] }}
++ {{ $kpi['value'] }} + {{ $kpi['delta'] }} +
+