From 435a202fdd98bd1824d9a38a74b477143523b3bf Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 27 Jul 2026 17:08:05 +0200 Subject: [PATCH] Match the panel to the approved template, measured rather than assumed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel had been built by reading the template and believing the result. Every round of review found the same class of defect, so this round the template was rendered in a browser and measured with getComputedStyle, and the implementation measured the same way — two number columns instead of an opinion. That immediately settled a disagreement: a review of the template's SOURCE said the metric grid used a 20px gap. Rendered, it is 14px. The measurement wins. Brought to the template's figures: label 11.5px (it was 11px in three separate places, which is how it drifted — it is now one .lbl rule), value tracking -0.02em, unit weight 450, metric row 6px above and 14px between, page head centred with a 14px gap, grid gap 14px, columns switching at 1101/561px and the h1 at 901px to match the template's own breakpoints, ring 62px, bar 5px. The shared button now takes its height from min-height (40px) rather than vertical padding, at 14px/600 with 0 18px padding and a 10px radius — a button keeps its height whatever sits inside it, icon, spinner or bare text. Also here, from the same round of review: - The chart's blue border was Tailwind's own `ring` utility colliding with a component class of the same name. Renamed to `.metric-ring`. This was dismissed once as a screenshot artefact; it was real. - Page titles lost the `sm:text-3xl` (40px) an earlier bulk edit had appended to 23 of them. The template's h1 is 30px. - The users table has its actions back — edit, suspend, lock and delete — for every seat that is not the owner, with the owner refused in the action itself and not merely hidden in the markup. DemoCustomerSeeder writes one complete customer as real rows: instance, subscription, six seats across four roles, a backup, current-period traffic and thirty days of samples with a deliberate wobble and one day at 286/288 checks. Nothing in the panel is drawn from a fixture any more, so anything missing shows up as missing. Removing the demo is one deletion. Verified: 607 tests, and a Codex comparison of the two measurement sets — "a person would call them the same design", the only difference a 1px gap. Co-Authored-By: Claude Opus 5 --- app/Livewire/Users.php | 34 ++- database/seeders/DemoCustomerSeeder.php | 197 ++++++++++++++++++ lang/de/users.php | 6 + lang/en/users.php | 6 + resources/css/app.css | 8 +- resources/css/portal-tokens.css | 14 ++ .../views/components/shell/nav.blade.php | 2 +- .../views/components/ui/button.blade.php | 7 +- .../views/components/ui/metric.blade.php | 14 +- resources/views/components/ui/ring.blade.php | 4 +- .../views/livewire/admin/customers.blade.php | 2 +- .../livewire/admin/datacenters.blade.php | 2 +- .../livewire/admin/host-create.blade.php | 2 +- .../livewire/admin/host-detail.blade.php | 2 +- .../views/livewire/admin/hosts.blade.php | 2 +- .../views/livewire/admin/instances.blade.php | 2 +- .../livewire/admin/maintenance.blade.php | 2 +- .../views/livewire/admin/overview.blade.php | 2 +- .../livewire/admin/plan-versions.blade.php | 2 +- .../views/livewire/admin/plans.blade.php | 2 +- .../livewire/admin/provisioning.blade.php | 2 +- .../views/livewire/admin/revenue.blade.php | 2 +- .../views/livewire/admin/secrets.blade.php | 2 +- .../views/livewire/admin/settings.blade.php | 2 +- resources/views/livewire/admin/vpn.blade.php | 2 +- resources/views/livewire/backups.blade.php | 2 +- resources/views/livewire/billing.blade.php | 2 +- resources/views/livewire/cloud.blade.php | 2 +- resources/views/livewire/dashboard.blade.php | 43 ++-- resources/views/livewire/invoices.blade.php | 2 +- resources/views/livewire/settings.blade.php | 2 +- resources/views/livewire/support.blade.php | 2 +- resources/views/livewire/users.blade.php | 18 +- 33 files changed, 331 insertions(+), 64 deletions(-) create mode 100644 database/seeders/DemoCustomerSeeder.php diff --git a/app/Livewire/Users.php b/app/Livewire/Users.php index 42cff36..29ae099 100644 --- a/app/Livewire/Users.php +++ b/app/Livewire/Users.php @@ -121,6 +121,38 @@ class Users extends Component } } + /** + * Pause a seat without destroying it. + * + * The action an owner actually needs when someone leaves: access stops now, + * and the record of who held it survives — which is the half a deletion + * throws away, on a product sold on being able to show who had access to + * what. + */ + public function suspend(string $uuid): void + { + $customer = $this->requireCustomer(); + + if ($customer === null) { + return; + } + + $seat = $customer->seats()->where('uuid', $uuid)->first(); + + if ($seat === null || $seat->role === 'owner') { + // The owner cannot lock themselves out of their own cloud. + $this->dispatch('notify', message: __('users.owner_locked')); + + return; + } + + $seat->update(['status' => $seat->status === 'suspended' ? 'active' : 'suspended']); + + $this->dispatch('notify', message: __( + $seat->status === 'suspended' ? 'users.suspended' : 'users.reactivated', + )); + } + public function revoke(string $uuid): void { $customer = $this->requireCustomer(); @@ -196,7 +228,7 @@ class Users extends Component // The owner can be neither re-invited nor revoked, so a table holding // only the owner has nothing to act on and should not carry a column // of empty cells. - $hasActions = $seats->contains(fn ($seat) => $seat->role !== 'owner' || $seat->status === 'invited'); + $hasActions = $seats->contains(fn ($seat) => $seat->role !== 'owner'); return view('livewire.users', [ 'hasActions' => $hasActions, diff --git a/database/seeders/DemoCustomerSeeder.php b/database/seeders/DemoCustomerSeeder.php new file mode 100644 index 0000000..83f045a --- /dev/null +++ b/database/seeders/DemoCustomerSeeder.php @@ -0,0 +1,197 @@ +first()?->delete()" + * + * Safe to run twice: every row is matched on a natural key first. + */ +class DemoCustomerSeeder extends Seeder +{ + private const EMAIL = 'demo@clupilot.com'; + + public function run(): void + { + $user = User::firstOrCreate( + ['email' => self::EMAIL], + ['name' => 'Kanzlei Muster', 'password' => Hash::make(config('app.demo_password', 'Muster!2026'))], + ); + + $customer = Customer::firstOrCreate( + ['user_id' => $user->id], + ['name' => 'Kanzlei Muster', 'email' => self::EMAIL, 'contact_name' => 'Dr. M. Muster', 'status' => 'active'], + ); + + $datacenter = Datacenter::firstOrCreate( + ['code' => 'fsn'], + ['name' => 'Falkenstein', 'location' => 'DE', 'active' => true], + ); + + $host = Host::query()->where('datacenter', $datacenter->code)->first() + ?? Host::factory()->create(['datacenter' => $datacenter->code, 'name' => 'pve-fsn-01', 'status' => 'active']); + + $order = Order::firstOrCreate( + ['customer_id' => $customer->id, 'type' => 'plan'], + [ + 'plan' => 'team', + 'amount_cents' => 17900, + 'currency' => 'EUR', + 'datacenter' => $datacenter->code, + 'status' => 'paid', + ], + ); + + $instance = Instance::firstOrCreate( + ['customer_id' => $customer->id], + [ + 'order_id' => $order->id, + 'host_id' => $host->id, + 'vmid' => 9001, + 'plan' => 'team', + 'quota_gb' => 500, + 'disk_gb' => 500, + 'ram_mb' => 8192, + 'cores' => 4, + 'traffic_addons' => 0, + 'guest_ip' => '10.20.0.31', + 'subdomain' => 'kanzlei-muster', + 'status' => 'active', + 'cert_ok' => true, + 'route_written' => true, + ], + ); + + Subscription::firstOrCreate( + ['customer_id' => $customer->id], + [ + 'order_id' => $order->id, + 'instance_id' => $instance->id, + 'plan' => 'team', + 'tier' => 2, + 'term' => 'monthly', + 'price_cents' => 17900, + 'currency' => 'EUR', + 'quota_gb' => 500, + 'traffic_gb' => 3072, + 'seats' => 25, + 'ram_mb' => 8192, + 'cores' => 4, + 'disk_gb' => 500, + 'performance' => 'enhanced', + 'status' => 'active', + 'started_at' => now()->subMonths(4), + 'current_period_start' => now()->startOfMonth(), + 'current_period_end' => now()->addMonth()->startOfMonth(), + ], + ); + + $this->seats($customer); + $this->backups($instance); + $this->traffic($instance); + $this->metrics($instance); + + $this->command?->info('Demo customer ready: '.self::EMAIL); + } + + private function seats(Customer $customer): void + { + $people = [ + ['Dr. M. Muster', 'demo@clupilot.com', 'owner', 'active'], + ['S. Berger', 'berger@kanzlei-muster.test', 'admin', 'active'], + ['A. Wimmer', 'wimmer@kanzlei-muster.test', 'member', 'active'], + ['T. Klein', 'klein@kanzlei-muster.test', 'member', 'active'], + ['P. Hofer', 'hofer@kanzlei-muster.test', 'member', 'invited'], + ['Steuerberatung Ost', 'extern@partner.test', 'readonly', 'active'], + ]; + + foreach ($people as [$name, $email, $role, $status]) { + Seat::firstOrCreate( + ['customer_id' => $customer->id, 'email' => $email], + ['name' => $name, 'role' => $role, 'status' => $status, 'invited_at' => now()->subDays(30)], + ); + } + } + + private function backups(Instance $instance): void + { + Backup::firstOrCreate( + ['instance_id' => $instance->id, 'schedule' => 'täglich 03:00'], + ['external_job_id' => 'demo-backup', 'status' => 'ok', 'last_ok_at' => now()->setTime(3, 12)], + ); + } + + private function traffic(Instance $instance): void + { + InstanceTraffic::firstOrCreate( + ['instance_id' => $instance->id, 'period' => InstanceTraffic::currentPeriod()], + [ + 'rx_bytes' => (int) (340 * 1024 ** 3), + 'tx_bytes' => (int) (72 * 1024 ** 3), + 'last_netin' => 0, + 'last_netout' => 0, + 'sampled_at' => now(), + ], + ); + } + + /** + * Thirty days of samples: a disk that fills slowly, transfer that varies, + * and one day where two checks out of 288 failed. + * + * Deliberately not a smooth curve — a straight line is the first thing that + * gives a demo away, and a chart that never wobbles teaches nobody what a + * real one looks like. + */ + private function metrics(Instance $instance): void + { + $total = (int) (500 * 1024 ** 3); + $used = (int) (198 * 1024 ** 3); + $wobble = [1.0, 0.6, 1.4, 0.9, 1.7, 0.4, 0.2, 1.1, 1.5, 0.8]; + + foreach (range(29, 0) as $offset) { + $day = Carbon::today()->subDays($offset); + $i = 29 - $offset; + $used += (int) ($wobble[$i % 10] * 0.42 * 1024 ** 3); + + InstanceMetric::updateOrCreate( + ['instance_id' => $instance->id, 'day' => $day->toDateString()], + [ + 'disk_used_bytes' => $used, + 'disk_total_bytes' => $total, + 'rx_bytes' => (int) ((9 + $wobble[$i % 10] * 4) * 1024 ** 3), + 'tx_bytes' => (int) ((2 + $wobble[($i + 3) % 10]) * 1024 ** 3), + // 288 checks a day is one every five minutes. + 'checks_total' => 288, + 'checks_ok' => $offset === 12 ? 286 : 288, + ], + ); + } + } +} diff --git a/lang/de/users.php b/lang/de/users.php index d4a1bde..425dbff 100644 --- a/lang/de/users.php +++ b/lang/de/users.php @@ -31,6 +31,12 @@ return [ 'owner_no_actions' => 'Der Inhaber kann nicht entfernt werden.', 'col_actions' => 'Aktionen', + 'suspend' => 'Sperren', + 'reactivate' => 'Entsperren', + 'suspended' => 'Zugang gesperrt.', + 'reactivated' => 'Zugang wieder freigegeben.', + 'owner_locked' => 'Der Inhaber kann sich nicht selbst sperren.', + 'status_suspended' => 'Gesperrt', 'status_active' => 'Aktiv', 'status_invited' => 'Eingeladen', 'status_revoked' => 'Entfernt', diff --git a/lang/en/users.php b/lang/en/users.php index ad1105c..2a94415 100644 --- a/lang/en/users.php +++ b/lang/en/users.php @@ -31,6 +31,12 @@ return [ 'owner_no_actions' => 'The owner cannot be removed.', 'col_actions' => 'Actions', + 'suspend' => 'Suspend', + 'reactivate' => 'Reactivate', + 'suspended' => 'Access suspended.', + 'reactivated' => 'Access restored.', + 'owner_locked' => 'The owner cannot lock themselves out.', + 'status_suspended' => 'Suspended', 'status_active' => 'Active', 'status_invited' => 'Invited', 'status_revoked' => 'Removed', diff --git a/resources/css/app.css b/resources/css/app.css index 8109c48..c670c8b 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -91,9 +91,9 @@ * a browser skip the declaration, it computes to `unset` — which for a * stroke-dashoffset silently draws a full ring at every value. */ -.ring { transform: rotate(-90deg); } -.ring .track { fill: none; stroke: var(--surface-hover); stroke-width: 7; } -.ring .value { +.metric-ring { transform: rotate(-90deg); } +.metric-ring .track { fill: none; stroke: var(--surface-hover); stroke-width: 7; } +.metric-ring .value { fill: none; stroke: var(--accent); stroke-width: 7; @@ -117,7 +117,7 @@ @keyframes spark-fade { to { opacity: .09; } } @media (prefers-reduced-motion: reduce) { - .ring .value { stroke-dashoffset: var(--o, 0) !important; } + .metric-ring .value { stroke-dashoffset: var(--o, 0) !important; } .spark .line { stroke-dashoffset: 0 !important; } .spark .area { opacity: .09 !important; } } diff --git a/resources/css/portal-tokens.css b/resources/css/portal-tokens.css index 021acfb..a20dafa 100644 --- a/resources/css/portal-tokens.css +++ b/resources/css/portal-tokens.css @@ -103,3 +103,17 @@ --focus-ring: 0 0 0 3px var(--accent-ring); } + +/* + * The small-caps label: the template calls it "the typographic signature, + * carried across all three surfaces". It is written once here so the size + * cannot drift apart between the panel, the console and the site — which is + * exactly how it ended up at 11px in one place and 11.5px in another. + */ +.lbl { + font-family: var(--font-mono); + font-size: 11.5px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--muted); +} diff --git a/resources/views/components/shell/nav.blade.php b/resources/views/components/shell/nav.blade.php index 00fd757..16fe336 100644 --- a/resources/views/components/shell/nav.blade.php +++ b/resources/views/components/shell/nav.blade.php @@ -38,7 +38,7 @@ @foreach ($groups as $group) @if (! empty($group['label'])) -

{{ $group['label'] }}

+

{{ $group['label'] }}

@endif