Match the panel to the approved template, measured rather than assumed
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 <noreply@anthropic.com>feat/portal-design
parent
85fcc154b8
commit
435a202fdd
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Backup;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Datacenter;
|
||||
use App\Models\Host;
|
||||
use App\Models\Instance;
|
||||
use App\Models\InstanceMetric;
|
||||
use App\Models\InstanceTraffic;
|
||||
use App\Models\Order;
|
||||
use App\Models\Seat;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
/**
|
||||
* One complete customer, written as real rows.
|
||||
*
|
||||
* The panel used to be judged against fixtures baked into the views, and every
|
||||
* round of that hid a different gap: a card with no data source, a figure that
|
||||
* came from a translation string, a chart drawn from an array in a Blade file.
|
||||
* A real customer with real rows makes the panel tell the truth about itself —
|
||||
* whatever is missing shows up as missing.
|
||||
*
|
||||
* Everything hangs off one User, so removing the demo is one deletion:
|
||||
*
|
||||
* php artisan db:seed --class=DemoCustomerSeeder --force
|
||||
* php artisan tinker --execute="App\Models\User::where('email','demo@clupilot.com')->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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
|
||||
@foreach ($groups as $group)
|
||||
@if (! empty($group['label']))
|
||||
<p class="px-3 pb-2 pt-5 font-mono text-[11px] uppercase tracking-[0.07em] text-muted">{{ $group['label'] }}</p>
|
||||
<p class="px-3 pb-2 pt-5 lbl">{{ $group['label'] }}</p>
|
||||
@endif
|
||||
<nav class="space-y-0.5">
|
||||
@foreach ($group['items'] as [$route, $icon, $key, $capability])
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@
|
|||
'href' => null,
|
||||
])
|
||||
@php
|
||||
$base = 'inline-flex items-center justify-center gap-2 rounded font-semibold transition select-none disabled:opacity-50 disabled:pointer-events-none';
|
||||
$base = 'inline-flex items-center justify-center gap-2 rounded-[10px] font-semibold leading-none transition select-none disabled:opacity-50 disabled:pointer-events-none';
|
||||
|
||||
$sizes = [
|
||||
'sm' => 'px-3 py-1.5 text-sm',
|
||||
'md' => 'px-4 py-2.5 text-sm',
|
||||
'sm' => 'min-h-8 px-3 text-[13px]',
|
||||
'md' => 'min-h-10 px-[18px] text-[14px]',
|
||||
'lg' => 'min-h-[46px] px-[22px] text-[15px]',
|
||||
];
|
||||
|
||||
// Accent buttons use the AA-safe darker fill (#c2560a) so white text passes
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@
|
|||
ring, a bar or a sparkline — and so a metric with nothing to draw simply
|
||||
passes none instead of getting a decorative one.
|
||||
--}}
|
||||
<article {{ $attributes->merge(['class' => 'overflow-hidden rounded-lg border border-line bg-surface p-5 shadow-xs']) }}>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="font-mono text-[11px] uppercase tracking-[0.07em] text-muted">{{ $label }}</span>
|
||||
<article {{ $attributes->merge(['class' => 'overflow-hidden rounded-lg border border-line bg-surface px-5 py-[18px] shadow-xs']) }}>
|
||||
<div class="flex items-center justify-between gap-2.5">
|
||||
<span class="lbl">{{ $label }}</span>
|
||||
@if ($href)
|
||||
<a href="{{ $href }}" wire:navigate class="text-muted transition hover:text-ink" aria-label="{{ $label }}">
|
||||
<x-ui.icon name="chevron-down" class="size-3.5 -rotate-90" />
|
||||
|
|
@ -28,14 +28,14 @@
|
|||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-end justify-between gap-4">
|
||||
<div class="mt-1.5 flex items-end justify-between gap-3.5">
|
||||
<div class="min-w-0">
|
||||
<p class="flex flex-wrap items-baseline gap-x-1.5 font-mono text-2xl font-semibold leading-tight tracking-tight text-ink">
|
||||
<p class="mt-[9px] flex flex-wrap items-baseline gap-x-1.5 font-mono text-[25px] font-semibold leading-tight tracking-[-0.02em] text-ink">
|
||||
{{ $value }}
|
||||
@if ($unit)<span class="text-sm font-normal text-muted">{{ $unit }}</span>@endif
|
||||
@if ($unit)<span class="text-[13px] font-[450] text-muted">{{ $unit }}</span>@endif
|
||||
</p>
|
||||
@if ($foot)
|
||||
<p class="mt-1.5 text-xs leading-snug text-muted">{{ $foot }}</p>
|
||||
<p class="mt-1.5 text-[12px] leading-snug text-muted">{{ $foot }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@
|
|||
$o = (int) round($c * (1 - min(100, max(0, (float) $percent)) / 100));
|
||||
@endphp
|
||||
<div class="relative grid place-items-center">
|
||||
<svg class="ring" viewBox="0 0 62 62" style="--c:{{ $c }};--o:{{ $o }};width:{{ $size }}px;height:{{ $size }}px" aria-hidden="true">
|
||||
<svg class="metric-ring" viewBox="0 0 62 62" style="--c:{{ $c }};--o:{{ $o }};width:{{ $size }}px;height:{{ $size }}px" aria-hidden="true">
|
||||
<circle class="track" cx="31" cy="31" r="25" />
|
||||
<circle class="value" cx="31" cy="31" r="25" />
|
||||
</svg>
|
||||
<b class="absolute font-mono text-xs font-semibold text-ink">{{ (int) round($percent) }}%</b>
|
||||
<b class="absolute font-mono text-[13px] font-medium text-ink">{{ (int) round($percent) }}%</b>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('admin.nav.customers') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin.nav.customers') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('admin.customers_sub') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('datacenters.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('datacenters.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('datacenters.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<a href="{{ route('admin.hosts') }}" wire:navigate class="inline-flex items-center gap-1.5 text-xs font-medium text-muted hover:text-body">
|
||||
<x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }}
|
||||
</a>
|
||||
<h1 class="mt-2 text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('hosts.create_title') }}</h1>
|
||||
<h1 class="mt-2 text-2xl font-bold tracking-tight text-ink">{{ __('hosts.create_title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('hosts.create_sub') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }}
|
||||
</a>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
<h1 class="font-mono text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ $host->name }}</h1>
|
||||
<h1 class="font-mono text-2xl font-bold tracking-tight text-ink">{{ $host->name }}</h1>
|
||||
<x-ui.badge :status="$badge">{{ __('hosts.status.'.$host->status) }}</x-ui.badge>
|
||||
</div>
|
||||
<p class="mt-1 font-mono text-xs text-muted">{{ $host->public_ip }} · {{ $host->datacenter }}</p>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<div class="space-y-5">
|
||||
<div class="flex items-start justify-between gap-4 animate-rise">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('hosts.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('hosts.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('hosts.subtitle') }}</p>
|
||||
</div>
|
||||
<a href="{{ route('admin.hosts.create') }}" wire:navigate>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('admin.nav.instances') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin.nav.instances') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('admin.instances_sub') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="space-y-6">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('maintenance.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('maintenance.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('maintenance.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="space-y-5">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('admin.overview_title') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin.overview_title') }}</h1>
|
||||
{{-- Derived from the notice list below, never asserted: a green badge
|
||||
that is not computed is the most expensive kind of decoration. --}}
|
||||
@if ($noticeCount === 0)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<x-ui.icon name="arrow-left" class="size-3.5" />
|
||||
{{ __('plans.back') }}
|
||||
</a>
|
||||
<h1 class="mt-2 text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ $family->name }}</h1>
|
||||
<h1 class="mt-2 text-2xl font-bold tracking-tight text-ink">{{ $family->name }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('plans.versions_subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('plans.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('plans.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('plans.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="space-y-5" @if ($hasActive) wire:poll.4s @endif>
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('admin.nav.provisioning') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin.nav.provisioning') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('admin.provisioning_sub') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('admin.nav.revenue') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin.nav.revenue') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('admin.revenue_sub') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('secrets.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('secrets.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('secrets.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('admin_settings.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin_settings.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
seconds. Traffic figures can wait the minute someone needs to copy a key. --}}
|
||||
<div class="space-y-5" @if (! $newConfig) wire:poll.5s="refreshPeers" @endif>
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('vpn.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('vpn.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('vpn.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<div class="space-y-6" x-data="{ msgs: @js(['restore' => __('backups.restore_toast')]) }">
|
||||
<div class="flex flex-wrap items-center gap-3 animate-rise">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('backups.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('backups.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('backups.subtitle') }}</p>
|
||||
</div>
|
||||
<span class="ml-auto rounded-pill bg-success-bg px-3 py-1.5 text-xs font-semibold text-success">{{ __('backups.schedule') }}</span>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
@php $loc = app()->getLocale(); $eur = fn ($c) => Number::currency($c / 100, in: 'EUR', locale: $loc); @endphp
|
||||
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('billing.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('billing.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('billing.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="space-y-6" x-data="{ msgs: @js(['soon' => __('cloud.action_toast')]) }">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('cloud.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('cloud.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('cloud.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,35 +5,34 @@
|
|||
$locale = app()->getLocale();
|
||||
$money = fn (int $cents, string $currency) => Number::currency($cents / 100, in: $currency, locale: $locale);
|
||||
@endphp
|
||||
<div class="space-y-5">
|
||||
<div class="space-y-3.5">
|
||||
{{-- Page head. The same pill the public site uses to label a section, so a
|
||||
customer meets one vocabulary on both sides of the login. --}}
|
||||
<header class="flex flex-wrap items-end gap-4 animate-rise">
|
||||
<header class="mb-2 flex flex-wrap items-center gap-3.5 animate-rise">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-mono text-[11px] uppercase tracking-[0.07em] text-muted">
|
||||
<p class="lbl">
|
||||
{{ __('dashboard.sheet', ['when' => $asOf->locale($locale)->isoFormat('LL, LT')]) }}
|
||||
</p>
|
||||
<h1 class="mt-2.5 text-2xl font-bold tracking-tight text-ink sm:text-3xl">
|
||||
<h1 class="mt-[7px] text-[23px] font-bold leading-[1.12] tracking-[-0.03em] text-ink min-[901px]:text-[30px]">
|
||||
{{ $instance !== null ? __('dashboard.title_running') : __('dashboard.title_pending') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@if ($instance !== null)
|
||||
<div class="flex flex-wrap items-center gap-2.5">
|
||||
<span @class([
|
||||
'inline-flex items-center gap-2 rounded-pill border px-3.5 py-1.5 text-xs font-semibold',
|
||||
'border-success-border bg-success-bg text-success' => $instance->status === 'active',
|
||||
'border-warning-border bg-warning-bg text-warning' => $instance->status !== 'active',
|
||||
])>
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ __('dashboard.instance_status.'.$instance->status) }}
|
||||
</span>
|
||||
@if ($domain)
|
||||
<x-ui.button variant="ink" :href="'https://'.$domain" target="_blank" rel="noopener">
|
||||
<x-ui.icon name="external-link" class="size-4" />{{ __('dashboard.cloud.open') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
</div>
|
||||
<span @class([
|
||||
'inline-flex shrink-0 items-center gap-[7px] rounded-pill border px-[11px] py-1 text-xs font-medium',
|
||||
'border-success-border bg-success-bg text-success' => $instance->status === 'active',
|
||||
'border-warning-border bg-warning-bg text-warning' => $instance->status !== 'active',
|
||||
])>
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ __('dashboard.instance_status.'.$instance->status) }}
|
||||
</span>
|
||||
|
||||
@if ($domain)
|
||||
<x-ui.button variant="ink" class="shrink-0" :href="'https://'.$domain" target="_blank" rel="noopener">
|
||||
<x-ui.icon name="external-link" class="size-4" />{{ __('dashboard.cloud.open') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
@endif
|
||||
</header>
|
||||
|
||||
|
|
@ -58,7 +57,7 @@
|
|||
beside it, a line of context, the visual — ring, bar, sparkline.
|
||||
Every number below is measured; nothing is drawn at a level nobody
|
||||
read. --}}
|
||||
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<section class="grid gap-3.5 min-[561px]:grid-cols-2 min-[1101px]:grid-cols-4">
|
||||
@if ($disk)
|
||||
<x-ui.metric
|
||||
:label="__('dashboard.master.storage')"
|
||||
|
|
@ -69,7 +68,7 @@
|
|||
:href="route('cloud')"
|
||||
class="animate-rise [animation-delay:60ms]"
|
||||
>
|
||||
<x-slot:visual><x-ui.ring :percent="$disk['percent']" :size="58" /></x-slot:visual>
|
||||
<x-slot:visual><x-ui.ring :percent="$disk['percent']" :size="62" /></x-slot:visual>
|
||||
</x-ui.metric>
|
||||
@elseif ($instance->quota_gb)
|
||||
<x-ui.metric
|
||||
|
|
@ -90,7 +89,7 @@
|
|||
class="animate-rise [animation-delay:100ms]"
|
||||
>
|
||||
@if ($seats['total'])
|
||||
<div class="mt-4 h-1.5 overflow-hidden rounded-pill bg-surface-hover">
|
||||
<div class="mt-3 h-[5px] overflow-hidden rounded-pill bg-surface-hover">
|
||||
{{-- R4: width is data, not decoration. --}}
|
||||
<div class="h-full rounded-pill bg-muted"
|
||||
style="width: {{ min(100, (int) round($seats['used'] / max(1, $seats['total']) * 100)) }}%"></div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="space-y-6" x-data="{ msgs: @js(['download' => __('invoices.download_toast')]) }">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('invoices.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('invoices.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('invoices.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('settings.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('settings.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('settings.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<div class="space-y-6" x-data="{ open: null, msgs: @js(['sent' => __('support.new_toast')]) }">
|
||||
<div class="flex flex-wrap items-center gap-3 animate-rise">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('support.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('support.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('support.subtitle') }}</p>
|
||||
</div>
|
||||
<x-ui.button variant="primary" class="ml-auto" @click="$dispatch('notify', { message: msgs.sent })">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4 animate-rise">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('users.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('users.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('users.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
|
|
@ -81,8 +81,20 @@
|
|||
<button type="button" wire:click="resend('{{ $seat->uuid }}')" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:bg-surface-hover">{{ __('users.resend') }}</button>
|
||||
@endif
|
||||
@if ($seat->role !== 'owner')
|
||||
<button type="button" wire:click="revoke('{{ $seat->uuid }}')" aria-label="{{ __('users.revoke') }}"
|
||||
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-danger hover:text-danger">
|
||||
{{-- Pause first, remove second. Somebody
|
||||
leaving needs their access stopped
|
||||
today; deleting the seat also throws
|
||||
away who held it, which is the half
|
||||
an audit asks about. --}}
|
||||
<button type="button" wire:click="suspend('{{ $seat->uuid }}')"
|
||||
aria-label="{{ $seat->status === 'suspended' ? __('users.reactivate') : __('users.suspend') }}"
|
||||
title="{{ $seat->status === 'suspended' ? __('users.reactivate') : __('users.suspend') }}"
|
||||
class="grid size-9 place-items-center rounded-md border border-line text-muted hover:border-warning hover:text-warning">
|
||||
<x-ui.icon :name="$seat->status === 'suspended' ? 'unlock' : 'lock'" class="size-4" />
|
||||
</button>
|
||||
<button type="button" wire:click="revoke('{{ $seat->uuid }}')"
|
||||
aria-label="{{ __('users.revoke') }}" title="{{ __('users.revoke') }}"
|
||||
class="grid size-9 place-items-center rounded-md border border-line text-muted hover:border-danger hover:text-danger">
|
||||
<x-ui.icon name="trash-2" class="size-4" />
|
||||
</button>
|
||||
@endif
|
||||
|
|
|
|||
Loading…
Reference in New Issue