Merge the panel design conversion into main
commit
186c2e46d1
|
|
@ -7,6 +7,7 @@ use App\Models\Customer;
|
|||
use App\Models\Order;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Billing\AddonCatalogue;
|
||||
use App\Services\Billing\DowngradeCheck;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Layout;
|
||||
|
|
@ -34,7 +35,7 @@ class Billing extends Component
|
|||
$addons = (array) config('provisioning.addons');
|
||||
|
||||
[$plan, $amount, $addonKey] = match ($type) {
|
||||
'upgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null],
|
||||
'upgrade', 'downgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null],
|
||||
'storage' => [$currentPlan, (int) config('provisioning.storage_addon.price_cents', 0), null],
|
||||
'traffic' => [$currentPlan, (int) config('provisioning.traffic.addon.price_cents', 0), null],
|
||||
'addon' => [$currentPlan, (int) ($addons[$key]['price_cents'] ?? 0), $key],
|
||||
|
|
@ -48,6 +49,13 @@ class Billing extends Component
|
|||
// one and charge for the privilege.
|
||||
'upgrade' => isset($plans[$key])
|
||||
&& (int) ($plans[$key]['tier'] ?? 0) > (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0),
|
||||
// Re-checked here and not only in the view: the button can be
|
||||
// hidden and the action still called — a stale tab, a second
|
||||
// window, anyone with the component name. A limit enforced only in
|
||||
// markup is not enforced.
|
||||
'downgrade' => isset($plans[$key])
|
||||
&& (int) ($plans[$key]['tier'] ?? 0) < (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0)
|
||||
&& DowngradeCheck::for($customer, $instance, $plans[$key])->allowed,
|
||||
'storage' => true,
|
||||
// Always available: running out of traffic is exactly when someone
|
||||
// needs to be able to buy more, whatever plan they are on.
|
||||
|
|
@ -73,11 +81,13 @@ class Billing extends Component
|
|||
$replaced = DB::transaction(function () use ($customer, $type, $plan, $addonKey, $amount, $datacenter) {
|
||||
Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
|
||||
|
||||
$replaced = $type === 'upgrade'
|
||||
// Up and down are the same kind of change and contradict each
|
||||
// other just as much, so either replaces a pending one of both.
|
||||
$replaced = in_array($type, ['upgrade', 'downgrade'], true)
|
||||
? Order::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->where('status', 'pending')
|
||||
->where('type', 'upgrade')
|
||||
->whereIn('type', ['upgrade', 'downgrade'])
|
||||
->delete()
|
||||
: 0;
|
||||
|
||||
|
|
@ -147,6 +157,16 @@ class Billing extends Component
|
|||
// customer immediately for losing resources.
|
||||
$currentTier = (int) ($instance?->subscription?->tier ?? $plans[$currentKey]['tier'] ?? 0);
|
||||
|
||||
// Smaller plans, each with the reason it cannot be taken today. Shown
|
||||
// WITH the reason rather than hidden: a customer who cannot downgrade
|
||||
// needs to know what to delete, and a plan that silently disappears
|
||||
// reads as "not offered any more".
|
||||
$downgrades = collect($plans)
|
||||
->filter(fn ($p) => (int) ($p['tier'] ?? 0) < $currentTier)
|
||||
->sortByDesc(fn ($p) => (int) ($p['tier'] ?? 0))
|
||||
->map(fn ($p, $k) => $p + ['check' => DowngradeCheck::for($customer, $instance, $p)])
|
||||
->all();
|
||||
|
||||
$upgrades = collect($plans)
|
||||
->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier)
|
||||
->keys()->all();
|
||||
|
|
@ -162,6 +182,7 @@ class Billing extends Component
|
|||
'plans' => $plans,
|
||||
'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(),
|
||||
'upgrades' => $upgrades,
|
||||
'downgrades' => $downgrades,
|
||||
'storage' => (array) config('provisioning.storage_addon'),
|
||||
'trafficAddon' => (array) config('provisioning.traffic.addon'),
|
||||
// Resolved once: the cart and the plan cards must not state
|
||||
|
|
|
|||
|
|
@ -54,8 +54,6 @@ class Cloud extends Component
|
|||
'price' => (int) round(($contract?->monthlyPriceCents() ?? 0) / 100),
|
||||
]),
|
||||
'location' => __('cloud.datacenter'),
|
||||
'version' => 'Nextcloud 31.0.4',
|
||||
'php' => 'PHP 8.3 · MariaDB 11.4',
|
||||
'storageUsed' => $used,
|
||||
'storageQuota' => $quota,
|
||||
'seats' => __('billing.seats_count', ['count' => $contract?->seats ?? 0]),
|
||||
|
|
|
|||
|
|
@ -3,12 +3,27 @@
|
|||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\ResolvesCustomer;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Datacenter;
|
||||
use App\Models\Instance;
|
||||
use App\Models\InstanceMetric;
|
||||
use App\Models\MaintenanceWindow;
|
||||
use App\Models\Seat;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Traffic\TrafficMeter;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Number;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* The customer's Betriebsblatt: what they have, and the evidence that it is
|
||||
* being looked after.
|
||||
*
|
||||
* Every figure on this page comes from a record. Where something is not
|
||||
* measured yet — storage consumption, restore tests — the page says nothing
|
||||
* rather than showing a plausible number: this is the sheet a customer forwards
|
||||
* to their auditor, and one invented line on it is worse than a short register.
|
||||
*/
|
||||
#[Layout('layouts.portal-app')]
|
||||
class Dashboard extends Component
|
||||
{
|
||||
|
|
@ -16,118 +31,290 @@ class Dashboard extends Component
|
|||
|
||||
public function render()
|
||||
{
|
||||
// Real numbers where they exist: traffic is metered, unlike the
|
||||
// fixtures below, and a customer needs to see it before it runs out.
|
||||
$instance = $this->customer()?->instances()->latest('id')->first();
|
||||
$traffic = $instance !== null ? TrafficMeter::for($instance) : null;
|
||||
$customer = $this->customer();
|
||||
|
||||
// Locale-aware date / number formatting for the fixture values below.
|
||||
$locale = app()->getLocale();
|
||||
$date = fn (string $iso, string $fmt) => Carbon::parse($iso)->locale($locale)->isoFormat($fmt);
|
||||
$gb = fn (float $v) => Number::format($v, precision: 1, locale: $locale).' GB';
|
||||
// Fixture data until the provisioning engine (separate handoff) supplies
|
||||
// real instances / metrics. Tenant-specific values stand in for DB rows.
|
||||
$storageUsed = 235;
|
||||
$storageQuota = 500;
|
||||
$storagePercent = (int) round($storageUsed / max(1, $storageQuota) * 100);
|
||||
// The instance that is actually in service. A cancelled one still has
|
||||
// rows, and would otherwise keep filling this page after it is gone.
|
||||
$instance = $customer?->instances()
|
||||
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
// ~30 days of availability, gently trending near 100%.
|
||||
$availability = [99.95, 99.97, 99.96, 99.98, 99.99, 99.97, 99.98, 99.99, 99.99, 99.98,
|
||||
99.97, 99.99, 100, 99.98, 99.99, 99.99, 99.97, 99.98, 99.99, 100,
|
||||
99.98, 99.99, 99.99, 99.98, 100, 99.99, 99.98, 99.99, 100, 99.98];
|
||||
$contract = $instance?->subscription;
|
||||
$maintenance = MaintenanceWindow::forInstance($instance)->first();
|
||||
|
||||
return view('livewire.dashboard', [
|
||||
'traffic' => $traffic,
|
||||
'storageUsed' => $storageUsed,
|
||||
'storageQuota' => $storageQuota,
|
||||
'storagePercent' => $storagePercent,
|
||||
'usersActive' => 8,
|
||||
'usersQuota' => 20,
|
||||
'lastBackup' => '03:12',
|
||||
'availability' => Number::format(99.98, precision: 2, locale: $locale),
|
||||
|
||||
'storageChart' => [
|
||||
'type' => 'doughnut',
|
||||
'data' => [
|
||||
'datasets' => [[
|
||||
'data' => [$storagePercent, 100 - $storagePercent],
|
||||
'backgroundColor' => ['token:accent', 'token:surface-2'],
|
||||
'borderWidth' => 0,
|
||||
]],
|
||||
],
|
||||
'options' => [
|
||||
'cutout' => '76%',
|
||||
'plugins' => [
|
||||
'legend' => ['display' => false],
|
||||
'tooltip' => ['enabled' => false],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'availabilityChart' => [
|
||||
'type' => 'line',
|
||||
'data' => [
|
||||
'labels' => array_fill(0, count($availability), ''),
|
||||
'datasets' => [[
|
||||
'data' => $availability,
|
||||
'borderColor' => 'token:success-bright',
|
||||
'backgroundColor' => 'token:success-bright/0.12',
|
||||
'fill' => true,
|
||||
'tension' => 0.4,
|
||||
'pointRadius' => 0,
|
||||
'borderWidth' => 2,
|
||||
]],
|
||||
],
|
||||
'options' => [
|
||||
'scales' => [
|
||||
'x' => ['display' => false],
|
||||
'y' => ['display' => false, 'min' => 99.9, 'max' => 100],
|
||||
],
|
||||
'plugins' => [
|
||||
'legend' => ['display' => false],
|
||||
'tooltip' => ['enabled' => false],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'cloud' => [
|
||||
'name' => 'Cloud der Kanzlei Berger',
|
||||
'domain' => 'cloud.kanzlei-berger.at',
|
||||
'package' => __('dashboard.cloud.plan_line', ['plan' => 'CluPilot Cloud Team', 'price' => 179]),
|
||||
'since' => $date('2026-03-14', 'LL'),
|
||||
'location' => __('dashboard.cloud.datacenter'),
|
||||
'version' => 'Nextcloud 31.0.4',
|
||||
],
|
||||
|
||||
'onboarding' => [
|
||||
['label' => __('dashboard.onboarding.instance'), 'done' => true, 'date' => $date('2026-03-14', 'D. MMMM')],
|
||||
['label' => __('dashboard.onboarding.domain'), 'done' => true, 'date' => $date('2026-03-15', 'D. MMMM')],
|
||||
['label' => __('dashboard.onboarding.branding'), 'done' => true, 'date' => $date('2026-03-15', 'D. MMMM')],
|
||||
['label' => __('dashboard.onboarding.users'), 'done' => true, 'date' => $date('2026-03-16', 'D. MMMM')],
|
||||
['label' => __('dashboard.onboarding.migration'), 'done' => false, 'date' => null],
|
||||
['label' => __('dashboard.onboarding.training'), 'done' => false, 'date' => null],
|
||||
['label' => __('dashboard.onboarding.docs'), 'done' => false, 'date' => null],
|
||||
],
|
||||
|
||||
'backups' => [
|
||||
['when' => __('dashboard.today').', 03:12', 'size' => $gb(18.4)],
|
||||
['when' => __('dashboard.yesterday').', 03:09', 'size' => $gb(18.4)],
|
||||
['when' => $date('2026-07-21', 'D. MMMM').', 03:11', 'size' => $gb(18.3)],
|
||||
],
|
||||
|
||||
'modules' => [
|
||||
['icon' => 'cloud', 'name' => 'Nextcloud + Collabora', 'desc' => __('dashboard.modules.nextcloud'), 'status' => 'active'],
|
||||
['icon' => 'shield-check', 'name' => 'Vaultwarden', 'desc' => __('dashboard.modules.vaultwarden'), 'status' => 'active'],
|
||||
['icon' => 'receipt', 'name' => 'Paperless-ngx', 'desc' => __('dashboard.modules.paperless'), 'status' => 'available'],
|
||||
['icon' => 'pen', 'name' => __('dashboard.modules.signature_name'), 'desc' => __('dashboard.modules.signature'), 'status' => 'soon'],
|
||||
],
|
||||
|
||||
'activity' => [
|
||||
['icon' => 'download', 'text' => __('dashboard.activity_items.backup_ok'), 'time' => '03:12'],
|
||||
['icon' => 'shield-check', 'text' => __('dashboard.activity_items.security'), 'time' => '04:01'],
|
||||
['icon' => 'users', 'text' => __('dashboard.activity_items.upload'), 'time' => '07:56'],
|
||||
['icon' => 'shield-check', 'text' => __('dashboard.activity_items.cert'), 'time' => __('dashboard.yesterday').' 22:10'],
|
||||
'customer' => $customer,
|
||||
'instance' => $instance,
|
||||
'contract' => $contract,
|
||||
'domain' => $this->domain($instance),
|
||||
'location' => $this->location($instance),
|
||||
'seats' => $this->seats($customer, $contract),
|
||||
'traffic' => $instance !== null ? TrafficMeter::for($instance) : null,
|
||||
// The measured series behind the template's ring and trend. Null
|
||||
// where nothing has been sampled yet — a new instance has no
|
||||
// fortnight, and drawing one at zero would tell its owner their
|
||||
// data had vanished.
|
||||
'disk' => $instance !== null ? $this->disk($instance) : null,
|
||||
'availability' => $instance !== null ? InstanceMetric::availability($instance) : null,
|
||||
'availabilityTrend' => $instance !== null ? InstanceMetric::availabilitySeries($instance) : [],
|
||||
'seatBreakdown' => $this->seatBreakdown($customer),
|
||||
'trend' => $instance !== null
|
||||
? InstanceMetric::series($instance)->map(fn (InstanceMetric $m) => $m->rx_bytes + $m->tx_bytes)->all()
|
||||
: [],
|
||||
'proofs' => $this->proofs($instance, $maintenance),
|
||||
'openTasks' => $instance?->onboardingTasks()->where('done', false)->count() ?? 0,
|
||||
// What the plan costs is true whether or not another invoice is
|
||||
// coming — a customer who has given notice still pays until the
|
||||
// term ends and still needs the figure on their master record.
|
||||
'planPrice' => $contract === null ? null : [
|
||||
'cents' => (int) $contract->price_cents,
|
||||
'currency' => (string) ($contract->currency ?: 'EUR'),
|
||||
'term' => (string) $contract->term,
|
||||
],
|
||||
'nextInvoice' => $this->nextInvoice($contract, $instance),
|
||||
'asOf' => Carbon::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who holds the seats, so the card can say something true underneath the
|
||||
* figure rather than repeating it.
|
||||
*
|
||||
* @return array<string, int> role => count, roles with nobody omitted
|
||||
*/
|
||||
private function seatBreakdown(?Customer $customer): array
|
||||
{
|
||||
if ($customer === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Seat::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->whereIn('status', ['active', 'invited'])
|
||||
->selectRaw('role, COUNT(*) as n')
|
||||
->groupBy('role')
|
||||
->pluck('n', 'role')
|
||||
->filter()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* How full the instance is, as measured — not as sold.
|
||||
*
|
||||
* Null until the sampler has managed a reading. The card then states the
|
||||
* contractual allowance, which is true, rather than a ring at a level
|
||||
* nobody measured.
|
||||
*
|
||||
* @return array{used: int, total: int, percent: float, week_delta: int|null}|null
|
||||
*/
|
||||
private function disk(Instance $instance): ?array
|
||||
{
|
||||
$metric = InstanceMetric::latestDisk($instance);
|
||||
|
||||
if ($metric === null || ! $metric->disk_total_bytes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// What it grew by this week, which is the line the template carries
|
||||
// under the ring. Null when there is no reading a week back to compare
|
||||
// against — an instance three days old has no weekly trend, and
|
||||
// inventing "+0 GB" would read as "nothing happened".
|
||||
$weekAgo = InstanceMetric::query()
|
||||
->where('instance_id', $instance->id)
|
||||
->whereNotNull('disk_used_bytes')
|
||||
->where('day', '<=', now()->subDays(7)->toDateString())
|
||||
->orderByDesc('day')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'used' => $metric->disk_used_bytes,
|
||||
'total' => $metric->disk_total_bytes,
|
||||
'percent' => round($metric->disk_used_bytes / max(1, $metric->disk_total_bytes) * 100, 1),
|
||||
'week_delta' => $weekAgo !== null ? $metric->disk_used_bytes - $weekAgo->disk_used_bytes : null,
|
||||
];
|
||||
}
|
||||
|
||||
/** The address the customer actually reaches their cloud at. */
|
||||
private function domain(?Instance $instance): ?string
|
||||
{
|
||||
if ($instance === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $instance->custom_domain
|
||||
?: ($instance->subdomain
|
||||
? $instance->subdomain.'.'.config('provisioning.dns.zone', 'clupilot.com')
|
||||
: null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the data physically sits.
|
||||
*
|
||||
* Named from the datacenter register rather than from a translation string,
|
||||
* because this is the line a customer copies into a processing record — it
|
||||
* has to be the place their instance really runs on, not the place we
|
||||
* usually sell.
|
||||
*
|
||||
* @return array{name: string, note: null}|null
|
||||
*/
|
||||
private function location(?Instance $instance): ?array
|
||||
{
|
||||
$code = $instance?->host?->datacenter;
|
||||
|
||||
if ($code === null || $code === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$datacenter = Datacenter::query()->where('code', $code)->first();
|
||||
|
||||
// The country, not the site. "Falkenstein" is how an operator places an
|
||||
// instance; a customer's processing record says which jurisdiction the
|
||||
// data sits in, and naming the building means editing customer-facing
|
||||
// copy every time the estate grows.
|
||||
return [
|
||||
'name' => $datacenter?->location ?: $code,
|
||||
'note' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seats in use against seats bought.
|
||||
*
|
||||
* Invited seats count as used: the licence is committed the moment the
|
||||
* invitation goes out, and showing them as free is how a customer finds out
|
||||
* at the worst moment that they cannot add the person in front of them.
|
||||
*
|
||||
* @return array{used: int, total: int|null}
|
||||
*/
|
||||
private function seats(?Customer $customer, ?Subscription $contract): array
|
||||
{
|
||||
return [
|
||||
'used' => $customer === null ? 0 : Seat::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->whereIn('status', ['active', 'invited'])
|
||||
->count(),
|
||||
'total' => $contract?->seats,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The proof register — what was done for this customer, and when.
|
||||
*
|
||||
* Only entries backed by a record. A row here is something the customer can
|
||||
* be asked to evidence, so "we back up nightly" is not one; "the last backup
|
||||
* completed at 03:12 and reported ok" is.
|
||||
*
|
||||
* @return array<int, array{key: string, at: \Illuminate\Support\Carbon|null, state: string, note: string|null}>
|
||||
*/
|
||||
private function proofs(?Instance $instance, ?MaintenanceWindow $maintenance): array
|
||||
{
|
||||
if ($instance === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$proofs = [];
|
||||
|
||||
// A failing job outranks a succeeding one, whatever their dates.
|
||||
//
|
||||
// Ordering by last_ok_at alone hides exactly the case this register
|
||||
// exists for: last night's job failed and so has no success time at
|
||||
// all, an older row that DID succeed sorts above it, and the sheet
|
||||
// reports backups as fine. On the page a customer shows their auditor,
|
||||
// a problem has to beat a date.
|
||||
$backups = $instance->backups()->get();
|
||||
$backup = $backups->first(fn ($b) => $b->status !== 'ok')
|
||||
?? $backups->sortByDesc('last_ok_at')->first();
|
||||
|
||||
if ($backup !== null) {
|
||||
$proofs[] = [
|
||||
'key' => 'backup',
|
||||
'at' => $backup->last_ok_at,
|
||||
// The job's own verdict, not "there is a row, so it worked".
|
||||
'state' => $backup->status === 'ok' ? 'ok' : 'attention',
|
||||
'note' => $backup->schedule,
|
||||
];
|
||||
}
|
||||
|
||||
// A certificate that stopped renewing is the failure a customer meets as
|
||||
// "my browser says my own cloud is unsafe", so it is stated either way.
|
||||
$proofs[] = [
|
||||
'key' => 'certificate',
|
||||
'at' => null,
|
||||
'state' => $instance->cert_ok ? 'ok' : 'attention',
|
||||
'note' => null,
|
||||
];
|
||||
|
||||
if ($maintenance !== null) {
|
||||
$proofs[] = [
|
||||
'key' => 'maintenance',
|
||||
'at' => $maintenance->starts_at,
|
||||
'state' => 'planned',
|
||||
'note' => $maintenance->title,
|
||||
];
|
||||
}
|
||||
|
||||
if ($instance->service_ends_at !== null) {
|
||||
$proofs[] = [
|
||||
'key' => 'service_ends',
|
||||
'at' => $instance->service_ends_at,
|
||||
'state' => 'attention',
|
||||
'note' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// Newest first, but an entry with no time of its own — the certificate —
|
||||
// stays at the top rather than sinking to the bottom of the register.
|
||||
usort($proofs, fn (array $a, array $b) => ($b['at']?->timestamp ?? PHP_INT_MAX) <=> ($a['at']?->timestamp ?? PHP_INT_MAX));
|
||||
|
||||
return $proofs;
|
||||
}
|
||||
|
||||
/**
|
||||
* What is owed next, from the contract rather than from a price list.
|
||||
*
|
||||
* The plan and the modules are kept apart. A customer with modules booked
|
||||
* would otherwise read the plan's price as their bill and be short every
|
||||
* month — and putting the combined figure next to the word "Paket" on the
|
||||
* master record would be just as wrong in the other direction.
|
||||
*
|
||||
* Add-ons carry a MONTHLY price (SubscriptionAddon::monthlyCents), while the
|
||||
* plan's price_cents is the price of a whole term. On a yearly contract the
|
||||
* modules therefore multiply by twelve to land on the same invoice.
|
||||
*
|
||||
* Nothing at all once the customer has given notice. Cancelling leaves the
|
||||
* subscription `active` until the term runs out — that is deliberate, the
|
||||
* service keeps running — but `current_period_end` is then the day service
|
||||
* ENDS, not the day the next charge falls. Billing a customer, on the sheet
|
||||
* they check their invoices against, for a renewal that will never happen is
|
||||
* the worst single line this page could carry. The end date is still stated;
|
||||
* it is in the proof register as "contract ends".
|
||||
*
|
||||
* @return array{plan: int, addons: int, total: int, currency: string, due: \Illuminate\Support\Carbon|null, term: string}|null
|
||||
*/
|
||||
private function nextInvoice(?Subscription $contract, ?Instance $instance): ?array
|
||||
{
|
||||
if ($contract === null || $contract->status === 'cancelled') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($instance?->cancel_requested_at !== null || $instance?->service_ends_at !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$months = $contract->isYearly() ? 12 : 1;
|
||||
|
||||
$addons = $contract->addons
|
||||
->whereNull('cancelled_at')
|
||||
->sum(fn ($addon) => $addon->monthlyCents()) * $months;
|
||||
|
||||
return [
|
||||
'plan' => (int) $contract->price_cents,
|
||||
'addons' => (int) $addons,
|
||||
'total' => (int) $contract->price_cents + (int) $addons,
|
||||
'currency' => (string) ($contract->currency ?: 'EUR'),
|
||||
'due' => $contract->current_period_end,
|
||||
'term' => (string) $contract->term,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -11,15 +10,7 @@ class Support extends Component
|
|||
{
|
||||
public function render()
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
$date = fn (string $iso) => Carbon::parse($iso)->locale($locale)->isoFormat('LL');
|
||||
|
||||
return view('livewire.support', [
|
||||
'tickets' => [
|
||||
['subject' => __('support.ticket_migration'), 'ref' => '#4821', 'date' => $date('2026-07-22'), 'status' => 'open'],
|
||||
['subject' => __('support.ticket_training'), 'ref' => '#4790', 'date' => $date('2026-07-18'), 'status' => 'progress'],
|
||||
['subject' => __('support.ticket_smtp'), 'ref' => '#4712', 'date' => $date('2026-07-04'), 'status' => 'closed'],
|
||||
],
|
||||
'faqs' => [
|
||||
['q' => __('support.faq_q1'), 'a' => __('support.faq_a1')],
|
||||
['q' => __('support.faq_q2'), 'a' => __('support.faq_a2')],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -193,7 +225,13 @@ class Users extends Component
|
|||
$customer = $this->customer();
|
||||
$seats = $customer ? $customer->seats()->orderByRaw("role = 'owner' desc")->orderBy('email')->get() : collect();
|
||||
|
||||
// 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');
|
||||
|
||||
return view('livewire.users', [
|
||||
'hasActions' => $hasActions,
|
||||
'seats' => $seats,
|
||||
'used' => $customer ? $this->usedSeats($customer) : 0,
|
||||
'limit' => $customer ? $this->seatLimit($customer) : 0,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* A day in the life of one instance: how full it was, and what it moved.
|
||||
*
|
||||
* Read by the customer panel to draw the storage ring and the transfer trend.
|
||||
* Written by CollectInstanceTraffic, which already visits every active
|
||||
* instance — a second scheduler entry hitting the same Proxmox API would only
|
||||
* double the load and the failure modes.
|
||||
*/
|
||||
class InstanceMetric extends Model
|
||||
{
|
||||
protected $fillable = ['instance_id', 'day', 'disk_used_bytes', 'disk_total_bytes', 'rx_bytes', 'tx_bytes', 'checks_total', 'checks_ok'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'day' => 'date',
|
||||
'disk_used_bytes' => 'integer',
|
||||
'disk_total_bytes' => 'integer',
|
||||
'rx_bytes' => 'integer',
|
||||
'tx_bytes' => 'integer',
|
||||
'checks_total' => 'integer',
|
||||
'checks_ok' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function instance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Instance::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The last $days days, oldest first, with gaps left as gaps.
|
||||
*
|
||||
* A missing day is not a zero. Filling it would draw a cliff into the chart
|
||||
* on every day the sampler could not reach the host, and the customer would
|
||||
* read an outage that never happened.
|
||||
*
|
||||
* @return Collection<int, self>
|
||||
*/
|
||||
public static function series(Instance $instance, int $days = 14): Collection
|
||||
{
|
||||
return self::query()
|
||||
->where('instance_id', $instance->id)
|
||||
->where('day', '>=', Carbon::today()->subDays($days - 1))
|
||||
->orderBy('day')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Availability over the window, as a percentage, or null.
|
||||
*
|
||||
* Null when nothing was ever checked. Reporting an unmonitored instance as
|
||||
* 100 % is the single most dishonest number this application could print —
|
||||
* it is the figure a customer would quote to their own auditor.
|
||||
*/
|
||||
public static function availability(Instance $instance, int $days = 30): ?float
|
||||
{
|
||||
$row = self::query()
|
||||
->where('instance_id', $instance->id)
|
||||
->where('day', '>=', Carbon::today()->subDays($days - 1))
|
||||
->selectRaw('SUM(checks_total) as total, SUM(checks_ok) as ok')
|
||||
->first();
|
||||
|
||||
$total = (int) ($row->total ?? 0);
|
||||
|
||||
return $total === 0 ? null : round((int) $row->ok / $total * 100, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily availability for the trend, oldest first.
|
||||
*
|
||||
* Days without a single check are skipped rather than drawn at zero: our
|
||||
* monitoring being down is not the customer's cloud being down.
|
||||
*
|
||||
* @return array<int, float>
|
||||
*/
|
||||
public static function availabilitySeries(Instance $instance, int $days = 30): array
|
||||
{
|
||||
return self::query()
|
||||
->where('instance_id', $instance->id)
|
||||
->where('day', '>=', Carbon::today()->subDays($days - 1))
|
||||
->where('checks_total', '>', 0)
|
||||
->orderBy('day')
|
||||
->get()
|
||||
->map(fn (self $m) => round($m->checks_ok / max(1, $m->checks_total) * 100, 2))
|
||||
->all();
|
||||
}
|
||||
|
||||
/** The most recent day that carries a disk reading, or null. */
|
||||
public static function latestDisk(Instance $instance): ?self
|
||||
{
|
||||
return self::query()
|
||||
->where('instance_id', $instance->id)
|
||||
->whereNotNull('disk_used_bytes')
|
||||
->orderByDesc('day')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Provisioning\Jobs;
|
||||
|
||||
use App\Models\Instance;
|
||||
use App\Models\InstanceMetric;
|
||||
use App\Models\InstanceTraffic;
|
||||
use App\Services\Proxmox\ProxmoxClient;
|
||||
use App\Services\Traffic\TrafficMeter;
|
||||
|
|
@ -101,9 +102,14 @@ class CollectInstanceTraffic implements ShouldBeUnique, ShouldQueue
|
|||
|
||||
// A fresh row starts from this sample: whatever the VM transferred
|
||||
// before we started counting this month is not ours to bill.
|
||||
$rxDelta = 0;
|
||||
$txDelta = 0;
|
||||
|
||||
if (! $row->wasRecentlyCreated) {
|
||||
$row->rx_bytes += $this->delta($netin, $row->last_netin);
|
||||
$row->tx_bytes += $this->delta($netout, $row->last_netout);
|
||||
$rxDelta = $this->delta($netin, $row->last_netin);
|
||||
$txDelta = $this->delta($netout, $row->last_netout);
|
||||
$row->rx_bytes += $rxDelta;
|
||||
$row->tx_bytes += $txDelta;
|
||||
$row->last_netin = $netin;
|
||||
$row->last_netout = $netout;
|
||||
$row->sampled_at = now();
|
||||
|
|
@ -111,6 +117,79 @@ class CollectInstanceTraffic implements ShouldBeUnique, ShouldQueue
|
|||
}
|
||||
|
||||
$this->enforce($client, $instance, $row);
|
||||
$this->recordDay($client, $instance, $rxDelta ?? 0, $txDelta ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's row for the customer panel's chart.
|
||||
*
|
||||
* Kept apart from the billing row on purpose: instance_traffic answers "how
|
||||
* much of the allowance is gone this period", which must not be disturbed
|
||||
* by anything drawn on a screen. This answers "what did the last fortnight
|
||||
* look like", and a failure here must never cost someone their allowance —
|
||||
* hence the try/catch around a job that has already done its real work.
|
||||
*/
|
||||
private function recordDay($client, Instance $instance, int $rxDelta, int $txDelta): void
|
||||
{
|
||||
try {
|
||||
$disk = $this->diskUsage($client, $instance);
|
||||
|
||||
$metric = InstanceMetric::query()->firstOrCreate(
|
||||
['instance_id' => $instance->id, 'day' => now()->toDateString()],
|
||||
);
|
||||
|
||||
// Accumulated, because the sampler runs several times a day.
|
||||
$metric->rx_bytes += max(0, $rxDelta);
|
||||
$metric->tx_bytes += max(0, $txDelta);
|
||||
|
||||
// Overwritten rather than accumulated: a fill level is a state, not
|
||||
// a flow. And left untouched when the reading failed, so yesterday's
|
||||
// figure stands instead of the chart dropping to zero.
|
||||
if ($disk !== null) {
|
||||
$metric->disk_used_bytes = $disk['used'];
|
||||
$metric->disk_total_bytes = $disk['total'];
|
||||
}
|
||||
|
||||
$metric->save();
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('daily metric failed', [
|
||||
'instance' => $instance->uuid, 'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How full the instance's data disk is, via the guest agent.
|
||||
*
|
||||
* Proxmox's own `disk` figure for a VM is the allocated image, not what is
|
||||
* used inside it — it would show a customer 500 GB from the first day.
|
||||
* `df` in the guest is the number they recognise.
|
||||
*
|
||||
* @return array{used: int, total: int}|null
|
||||
*/
|
||||
private function diskUsage($client, Instance $instance): ?array
|
||||
{
|
||||
$node = $instance->host->node ?? 'pve';
|
||||
|
||||
if (! $client->guestAgentPing($node, $instance->vmid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// -B1 so the numbers are bytes and no locale can reinterpret them.
|
||||
$result = $client->guestExec($node, $instance->vmid, 'df -B1 --output=size,used /var/www 2>/dev/null || df -B1 --output=size,used /');
|
||||
$out = trim((string) ($result['out-data'] ?? $result['output'] ?? ''));
|
||||
|
||||
// Header line, then one line of two integers. Anything else is a shell
|
||||
// that answered something we did not ask for, and is discarded rather
|
||||
// than parsed hopefully.
|
||||
$lines = preg_split('/\r?\n/', $out) ?: [];
|
||||
$last = trim((string) end($lines));
|
||||
|
||||
if (! preg_match('/^(\d+)\s+(\d+)$/', $last, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['total' => (int) $m[1], 'used' => (int) $m[2]];
|
||||
}
|
||||
|
||||
private function releasePreviousPeriodThrottle($client, Instance $instance): void
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Provisioning\Jobs;
|
||||
|
||||
use App\Models\InstanceMetric;
|
||||
use App\Models\MonitoringTarget;
|
||||
use App\Services\Monitoring\MonitoringClient;
|
||||
use Illuminate\Bus\Queueable;
|
||||
|
|
@ -29,6 +30,30 @@ class SyncMonitoringStatus implements ShouldQueue
|
|||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* One tick of the availability figure the customer panel shows.
|
||||
*
|
||||
* Written per day so thirty days is thirty rows, and so a gap in our own
|
||||
* monitoring shows up as a day with fewer checks rather than as downtime
|
||||
* that never happened.
|
||||
*/
|
||||
private function countCheck(MonitoringTarget $target, ?bool $healthy): void
|
||||
{
|
||||
if ($healthy === null || $target->instance_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$metric = InstanceMetric::query()->firstOrCreate(
|
||||
['instance_id' => $target->instance_id, 'day' => now()->toDateString()],
|
||||
);
|
||||
|
||||
$metric->increment('checks_total');
|
||||
|
||||
if ($healthy) {
|
||||
$metric->increment('checks_ok');
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(MonitoringClient $monitoring): void
|
||||
{
|
||||
MonitoringTarget::query()
|
||||
|
|
@ -60,6 +85,12 @@ class SyncMonitoringStatus implements ShouldQueue
|
|||
return;
|
||||
}
|
||||
|
||||
// Counted before the early return below, so a day's denominator grows
|
||||
// only when a real answer arrived. An unanswered check is not a failed
|
||||
// one — folding the two together turns a monitoring outage into the
|
||||
// customer's outage, on the one figure they are asked to trust.
|
||||
$this->countCheck($target, $healthy);
|
||||
|
||||
if ($healthy === null) {
|
||||
// No answer. The previous verdict stays and is allowed to go stale,
|
||||
// which readers show as "not monitored" rather than as health.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\InstanceMetric;
|
||||
use App\Models\Seat;
|
||||
use App\Support\Bytes;
|
||||
|
||||
/**
|
||||
* Whether a customer may move down to a smaller plan, and if not, why.
|
||||
*
|
||||
* A downgrade is not the mirror of an upgrade. Going up always fits; going
|
||||
* down can ask an instance to hold more than the target plan allows, and the
|
||||
* failure has to be explained in the customer's own numbers — "you have 31
|
||||
* users, Start allows 10" — rather than as a disabled button. A greyed-out
|
||||
* control that does not say what is in the way is the thing people ring about.
|
||||
*
|
||||
* Checked against what is MEASURED, not what was sold: a customer sitting on
|
||||
* 480 GB of a 500 GB plan cannot move to a 200 GB one, whatever their contract
|
||||
* says the allowance is.
|
||||
*/
|
||||
final readonly class DowngradeCheck
|
||||
{
|
||||
private function __construct(
|
||||
public bool $allowed,
|
||||
/** @var array<int, array{key: string, current: string, limit: string}> */
|
||||
public array $blockers,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $target the catalogue entry being moved to
|
||||
*/
|
||||
public static function for(Customer $customer, ?Instance $instance, array $target): self
|
||||
{
|
||||
$blockers = [];
|
||||
|
||||
$seats = Seat::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->whereIn('status', ['active', 'invited'])
|
||||
->count();
|
||||
|
||||
$seatLimit = (int) ($target['seats'] ?? 0);
|
||||
|
||||
if ($seatLimit > 0 && $seats > $seatLimit) {
|
||||
$blockers[] = [
|
||||
'key' => 'seats',
|
||||
'current' => (string) $seats,
|
||||
'limit' => (string) $seatLimit,
|
||||
];
|
||||
}
|
||||
|
||||
// Storage is checked against the last real reading. Falling back to the
|
||||
// contractual allowance would refuse a downgrade to anyone who has ever
|
||||
// bought a large plan, however empty their instance is.
|
||||
$quotaGb = (int) ($target['quota_gb'] ?? 0);
|
||||
$metric = $instance !== null ? InstanceMetric::latestDisk($instance) : null;
|
||||
|
||||
if ($quotaGb > 0 && $metric !== null && $metric->disk_used_bytes > $quotaGb * 1024 ** 3) {
|
||||
$blockers[] = [
|
||||
'key' => 'storage',
|
||||
'current' => Bytes::human($metric->disk_used_bytes),
|
||||
'limit' => $quotaGb.' GB',
|
||||
];
|
||||
}
|
||||
|
||||
return new self($blockers === [], $blockers);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* The navigation of both shells, in one place.
|
||||
*
|
||||
* The sidebar and the breadcrumb describe the same structure, and when each
|
||||
* built its own copy the breadcrumb quietly said "Übersicht" on every page —
|
||||
* the layout had no way to know which entry was current, so it fell back to the
|
||||
* first one. One definition, two readers.
|
||||
*
|
||||
* Each entry is [route name, icon, translation key, capability or null].
|
||||
*/
|
||||
final class Navigation
|
||||
{
|
||||
/** @return array<int, array{label: ?string, items: array<int, array{0:string,1:string,2:string,3:?string}>}> */
|
||||
public static function portal(): array
|
||||
{
|
||||
return [
|
||||
['label' => null, 'items' => [
|
||||
['dashboard', 'gauge', 'overview', null],
|
||||
['cloud', 'cloud', 'cloud', null],
|
||||
['users', 'users', 'users', null],
|
||||
['backups', 'database', 'backups', null],
|
||||
]],
|
||||
['label' => __('dashboard.nav_group.contract'), 'items' => [
|
||||
['billing', 'box', 'billing', null],
|
||||
['invoices', 'receipt', 'invoices', null],
|
||||
['settings', 'settings', 'settings', null],
|
||||
['support', 'life-buoy', 'support', null],
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int, array{label: ?string, items: array<int, array{0:string,1:string,2:string,3:?string}>}> */
|
||||
public static function console(): array
|
||||
{
|
||||
return [
|
||||
['label' => null, 'items' => [
|
||||
['admin.overview', 'gauge', 'overview', null],
|
||||
['admin.customers', 'users', 'customers', null],
|
||||
['admin.instances', 'box', 'instances', null],
|
||||
['admin.hosts', 'server', 'hosts', null],
|
||||
['admin.datacenters', 'database', 'datacenters', null],
|
||||
['admin.plans', 'tag', 'plans', 'plans.manage'],
|
||||
]],
|
||||
['label' => __('admin.nav_group.operations'), 'items' => [
|
||||
['admin.provisioning', 'activity', 'provisioning', null],
|
||||
['admin.maintenance', 'alert-triangle', 'maintenance', null],
|
||||
['admin.vpn', 'shield', 'vpn', null],
|
||||
['admin.revenue', 'trending-up', 'revenue', null],
|
||||
]],
|
||||
['label' => __('admin.nav_group.system'), 'items' => [
|
||||
['admin.secrets', 'lock', 'secrets', 'secrets.manage'],
|
||||
['admin.settings', 'settings', 'settings', null],
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The label of the entry the current request belongs to.
|
||||
*
|
||||
* Matched on the route rather than the path, because the console's path
|
||||
* changes between host-bound and fallback mode while its route names do
|
||||
* not. Null when the page is not in the navigation at all — a detail view,
|
||||
* a modal — and the caller then supplies its own title.
|
||||
*/
|
||||
public static function currentLabel(bool $console = false): ?string
|
||||
{
|
||||
$prefix = $console ? 'admin.nav.' : 'dashboard.nav.';
|
||||
|
||||
foreach ($console ? self::console() : self::portal() as $group) {
|
||||
foreach ($group['items'] as [$route, , $key]) {
|
||||
$matches = $console
|
||||
? AdminArea::routeIs($route)
|
||||
: request()->routeIs($route);
|
||||
|
||||
if ($matches) {
|
||||
return __($prefix.$key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* One row per instance per day.
|
||||
*
|
||||
* instance_traffic keeps a single row per BILLING period, which is what the
|
||||
* allowance needs — but it means there is no series to draw, and the customer
|
||||
* panel could only ever show a number. Storage consumption was not sampled at
|
||||
* all, so the panel stated the contractual allowance and nothing about what is
|
||||
* actually in use.
|
||||
*
|
||||
* A day is the right grain: fine enough for a fortnight of trend, coarse enough
|
||||
* that a year of an instance is 365 rows.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('instance_metrics', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('instance_id')->constrained()->cascadeOnDelete();
|
||||
$table->date('day');
|
||||
|
||||
// Nullable, and deliberately: a sample that could not be taken must
|
||||
// be distinguishable from a sample that read zero. Charting a failed
|
||||
// read as 0 GB is how a panel tells a customer their data is gone.
|
||||
$table->unsignedBigInteger('disk_used_bytes')->nullable();
|
||||
$table->unsignedBigInteger('disk_total_bytes')->nullable();
|
||||
$table->unsignedBigInteger('rx_bytes')->default(0);
|
||||
$table->unsignedBigInteger('tx_bytes')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
// The sampler runs more often than daily and accumulates into the
|
||||
// current day's row; the constraint is what makes that safe under
|
||||
// two workers.
|
||||
$table->unique(['instance_id', 'day']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('instance_metrics');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Availability, counted from our own checks.
|
||||
*
|
||||
* Uptime Kuma knows the figure but the REST bridge in front of it only answers
|
||||
* "healthy, yes or no" — and waiting for the bridge to grow an endpoint would
|
||||
* leave the panel without the number indefinitely. The monitoring sync already
|
||||
* asks that question on a schedule; counting the answers gives a percentage
|
||||
* that is ours, explainable, and available today.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('instance_metrics', function (Blueprint $table) {
|
||||
$table->unsignedInteger('checks_total')->default(0);
|
||||
$table->unsignedInteger('checks_ok')->default(0);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('instance_metrics', function (Blueprint $table) {
|
||||
$table->dropColumn(['checks_total', 'checks_ok']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
<?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;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
// The demo account is a real login on a panel that is reachable from the
|
||||
// public internet, so it never gets a password written in a source file.
|
||||
// DEMO_PASSWORD if the operator set one, otherwise a fresh random one
|
||||
// printed once — a weak default would sit on production forever, and
|
||||
// "it is only the demo" is how the first account gets taken.
|
||||
$password = (string) (env('DEMO_PASSWORD') ?: Str::password(24));
|
||||
|
||||
$user = User::firstOrCreate(
|
||||
['email' => self::EMAIL],
|
||||
['name' => 'Kanzlei Muster', 'password' => Hash::make($password)],
|
||||
);
|
||||
|
||||
if ($user->wasRecentlyCreated) {
|
||||
$this->command?->warn('Demo password (shown once): '.$password);
|
||||
}
|
||||
|
||||
$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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,11 @@ return [
|
|||
'badge' => 'Admin',
|
||||
'to_portal' => 'Zum Kundenportal',
|
||||
|
||||
'nav_group' => [
|
||||
'operations' => 'Betrieb',
|
||||
'system' => 'System',
|
||||
],
|
||||
|
||||
'nav' => [
|
||||
'overview' => 'Übersicht',
|
||||
'customers' => 'Kunden',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Kleinere Pakete werden auch dann gezeigt, wenn sie gerade nicht gehen —
|
||||
// mit dem Grund in den Zahlen des Kunden.
|
||||
'downgrade' => 'Kleineres Paket',
|
||||
'downgrade_hint' => 'Sie können jederzeit herunterwechseln, solange Ihr Bestand in das kleinere Paket passt.',
|
||||
'downgrade_to' => 'Auf :plan wechseln',
|
||||
'downgrade_blocked' => 'Derzeit nicht möglich',
|
||||
'downgrade_blocker' => [
|
||||
'seats' => 'Sie haben :current Benutzer, dieses Paket erlaubt :limit. Entfernen Sie Zugänge unter „Benutzer“.',
|
||||
'storage' => 'Sie belegen :current, dieses Paket bietet :limit. Löschen Sie Daten oder leeren Sie den Papierkorb.',
|
||||
],
|
||||
'per_month' => 'netto pro Monat',
|
||||
|
||||
'net_reverse_charge' => 'netto — Reverse Charge',
|
||||
'net_per_month' => 'netto pro Monat',
|
||||
'net_once' => 'netto, einmalig',
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ return [
|
|||
'title' => 'Meine Cloud',
|
||||
'subtitle' => 'Details und Ressourcen Ihrer Instanz.',
|
||||
'plan_line' => ':plan · :price €/Monat',
|
||||
'datacenter' => 'EU · Rechenzentrum Falkenstein',
|
||||
'datacenter' => 'EU',
|
||||
'now' => 'jetzt',
|
||||
'instance_label' => 'Instanz',
|
||||
'open' => 'Cloud öffnen',
|
||||
'status_active' => 'Aktiv',
|
||||
'status_provisioning' => 'Bereitstellung',
|
||||
|
|
|
|||
|
|
@ -3,12 +3,104 @@
|
|||
return [
|
||||
'traffic' => [
|
||||
'title' => 'Datenvolumen diesen Monat',
|
||||
'label' => 'Datenvolumen',
|
||||
'of' => 'von :total',
|
||||
'resets' => 'setzt sich in :days Tagen zurück',
|
||||
'remaining' => ':amount übrig',
|
||||
'almost' => ':percent % verbraucht — es wird knapp.',
|
||||
'exhausted' => 'Kontingent aufgebraucht. Ihre Nextcloud läuft weiter, aber langsamer, bis Sie nachbuchen oder der Monat wechselt.',
|
||||
'buy' => 'Volumen nachbuchen',
|
||||
],
|
||||
// Das Betriebsblatt: was der Kunde hat, und der Nachweis, dass es betreut
|
||||
// wird. Jede Zeile kommt aus einem Datensatz — was nicht gemessen wird,
|
||||
// steht hier auch nicht.
|
||||
'sheet' => 'Betriebsblatt · Stand :when',
|
||||
'title_running' => 'Ihre Cloud läuft.',
|
||||
'title_pending' => 'Ihre Cloud wird eingerichtet.',
|
||||
|
||||
'instance_status' => [
|
||||
'active' => 'Alle Dienste aktiv',
|
||||
'provisioning' => 'Wird eingerichtet',
|
||||
'cancellation_scheduled' => 'Kündigung vorgemerkt',
|
||||
],
|
||||
|
||||
'no_instance_label' => 'Noch keine Instanz',
|
||||
'no_instance_body' => 'Für dieses Konto läuft derzeit keine Cloud. Sobald eine Bestellung bezahlt ist, richten wir sie ein — dieser Bereich füllt sich dann von selbst.',
|
||||
'no_instance_cta' => 'Paket wählen',
|
||||
|
||||
'master' => [
|
||||
'label' => 'Stammblatt',
|
||||
'package' => 'Paket',
|
||||
'operation' => 'Betriebsform',
|
||||
'last_backup' => 'Letzte Sicherung',
|
||||
'availability' => 'Verfügbarkeit',
|
||||
'availability_note' => 'letzte 30 Tage · gemessen',
|
||||
'location' => 'Serverstandort',
|
||||
'users' => 'Benutzer',
|
||||
'address' => 'Adresse',
|
||||
'storage' => 'Speicher',
|
||||
'storage_used' => ':percent % belegt',
|
||||
'storage_week' => ':amount diese Woche',
|
||||
'storage_note' => 'vertraglich zugesichert',
|
||||
'location_note' => 'kein Drittlandtransfer',
|
||||
'gb' => ':n GB',
|
||||
'of_seats' => 'von :total Plätzen',
|
||||
'of_total' => ':used von :total',
|
||||
'per_month' => ':amount netto / Monat',
|
||||
'per_year' => ':amount netto / Jahr',
|
||||
],
|
||||
|
||||
'proofs' => [
|
||||
'label' => 'Nachweise',
|
||||
'all' => 'Alle ansehen',
|
||||
'empty' => 'Sobald die erste Sicherung gelaufen ist, steht sie hier.',
|
||||
'when' => 'Zeitpunkt',
|
||||
'what' => 'Vorgang',
|
||||
'result' => 'Ergebnis',
|
||||
'item' => [
|
||||
'backup' => 'Sicherung',
|
||||
'certificate' => 'Verschlüsselte Verbindung',
|
||||
'maintenance' => 'Geplante Wartung',
|
||||
'service_ends' => 'Vertragsende',
|
||||
],
|
||||
'state' => [
|
||||
'ok' => 'Geprüft',
|
||||
'planned' => 'Geplant',
|
||||
'attention' => 'Prüfen',
|
||||
],
|
||||
],
|
||||
|
||||
'invoice' => [
|
||||
'label' => 'Nächste Abrechnung',
|
||||
'plan' => 'Paket',
|
||||
'addons' => 'Module',
|
||||
'amount' => 'Betrag',
|
||||
'net' => 'zzgl. USt.',
|
||||
'due' => 'Fällig',
|
||||
'term' => 'Abrechnung',
|
||||
'all' => 'Rechnungen ansehen',
|
||||
],
|
||||
|
||||
'term' => [
|
||||
'monthly' => 'monatlich',
|
||||
'yearly' => 'jährlich',
|
||||
],
|
||||
|
||||
'setup' => [
|
||||
'label' => 'Einrichtung',
|
||||
'open' => '{1} Ein Schritt ist noch offen.|[2,*] :count Schritte sind noch offen.',
|
||||
'continue' => 'Einrichtung fortsetzen',
|
||||
],
|
||||
|
||||
'quick' => [
|
||||
'add_user_title' => 'Benutzer hinzufügen',
|
||||
'add_user_body' => 'Zugang für neue Mitarbeiter anlegen',
|
||||
'restore_title' => 'Datei wiederherstellen',
|
||||
'restore_body' => 'Aus den vorhandenen Sicherungen',
|
||||
'grow_title' => 'Paket erweitern',
|
||||
'grow_body' => 'Speicher, Benutzer oder Datenvolumen',
|
||||
],
|
||||
|
||||
'title' => 'Übersicht',
|
||||
'subtitle' => 'Status Ihrer Cloud auf einen Blick.',
|
||||
'greeting' => 'Guten Morgen, :name',
|
||||
|
|
@ -26,6 +118,13 @@ return [
|
|||
'support' => 'Support',
|
||||
],
|
||||
|
||||
// Zwei Gruppen: was der Kunde betreibt, und was im Vertrag steht.
|
||||
'nav_group' => [
|
||||
'contract' => 'Vertrag',
|
||||
],
|
||||
'nav_footer' => "Betreut aus Österreich\nAntwort am selben Werktag",
|
||||
'signed_in_as' => 'Angemeldet als',
|
||||
|
||||
'open_nav' => 'Navigation öffnen',
|
||||
'close_nav' => 'Navigation schließen',
|
||||
'logout' => 'Abmelden',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ return [
|
|||
'contact_hours_val' => 'Mo–Fr, 08–18 Uhr',
|
||||
'contact_email' => 'E-Mail',
|
||||
'contact_sla' => 'Reaktionszeit',
|
||||
'contact_sla_val' => 'Team-Paket · binnen 4 Std.',
|
||||
'contact_sla_val' => 'Antwort am selben Werktag',
|
||||
'tickets' => 'Ihre Anfragen',
|
||||
'status_open' => 'Offen',
|
||||
'status_progress' => 'In Bearbeitung',
|
||||
|
|
|
|||
|
|
@ -16,10 +16,27 @@ return [
|
|||
'role_member' => 'Mitglied',
|
||||
'role_readonly' => 'Nur Lesen',
|
||||
|
||||
// Counted next to the seat figure, so the card says something true
|
||||
// underneath the number instead of repeating it.
|
||||
'role_count' => [
|
||||
'owner' => '{1} :count Inhaber|[2,*] :count Inhaber',
|
||||
'admin' => '{1} :count Administrator|[2,*] :count Administratoren',
|
||||
'member' => '{1} :count Mitglied|[2,*] :count Mitglieder',
|
||||
'readonly' => '{1} :count Nur Lesen|[2,*] :count Nur Lesen',
|
||||
'guest' => '{1} :count Gastzugang|[2,*] :count Gastzugänge',
|
||||
],
|
||||
|
||||
'col_person' => 'Person',
|
||||
'col_status' => 'Status',
|
||||
'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',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ return [
|
|||
'badge' => 'Admin',
|
||||
'to_portal' => 'To customer portal',
|
||||
|
||||
'nav_group' => [
|
||||
'operations' => 'Operations',
|
||||
'system' => 'System',
|
||||
],
|
||||
|
||||
'nav' => [
|
||||
'overview' => 'Overview',
|
||||
'customers' => 'Customers',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Smaller plans are listed even when they cannot be taken, with the reason
|
||||
// in the customer's own numbers.
|
||||
'downgrade' => 'Smaller package',
|
||||
'downgrade_hint' => 'You can move down at any time, as long as what you hold fits the smaller package.',
|
||||
'downgrade_to' => 'Move to :plan',
|
||||
'downgrade_blocked' => 'Not possible right now',
|
||||
'downgrade_blocker' => [
|
||||
'seats' => 'You have :current users, this package allows :limit. Remove access under "Users".',
|
||||
'storage' => 'You are using :current, this package offers :limit. Delete data or empty the trash.',
|
||||
],
|
||||
'per_month' => 'net per month',
|
||||
|
||||
'net_reverse_charge' => 'net — reverse charge',
|
||||
'net_per_month' => 'net per month',
|
||||
'net_once' => 'net, one-off',
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ return [
|
|||
'title' => 'My cloud',
|
||||
'subtitle' => 'Details and resources of your instance.',
|
||||
'plan_line' => ':plan · €:price/mo',
|
||||
'datacenter' => 'EU · Falkenstein data center',
|
||||
'datacenter' => 'EU',
|
||||
'now' => 'now',
|
||||
'instance_label' => 'Instance',
|
||||
'open' => 'Open cloud',
|
||||
'status_active' => 'Active',
|
||||
'status_provisioning' => 'Provisioning',
|
||||
|
|
|
|||
|
|
@ -3,12 +3,104 @@
|
|||
return [
|
||||
'traffic' => [
|
||||
'title' => 'Data transfer this month',
|
||||
'label' => 'Data transfer',
|
||||
'of' => 'of :total',
|
||||
'resets' => 'resets in :days days',
|
||||
'remaining' => ':amount left',
|
||||
'almost' => ':percent % used — getting tight.',
|
||||
'exhausted' => 'Allowance used up. Your Nextcloud keeps running, but slower, until you top up or the month rolls over.',
|
||||
'buy' => 'Add data volume',
|
||||
],
|
||||
// The operating sheet: what the customer has, and the evidence it is being
|
||||
// looked after. Every line comes from a record — anything not measured does
|
||||
// not appear here at all.
|
||||
'sheet' => 'Operating sheet · as of :when',
|
||||
'title_running' => 'Your cloud is running.',
|
||||
'title_pending' => 'Your cloud is being set up.',
|
||||
|
||||
'instance_status' => [
|
||||
'active' => 'All services running',
|
||||
'provisioning' => 'Being set up',
|
||||
'cancellation_scheduled' => 'Cancellation scheduled',
|
||||
],
|
||||
|
||||
'no_instance_label' => 'No instance yet',
|
||||
'no_instance_body' => 'No cloud is running for this account at the moment. As soon as an order is paid we set one up — this area then fills itself.',
|
||||
'no_instance_cta' => 'Choose a package',
|
||||
|
||||
'master' => [
|
||||
'label' => 'Master record',
|
||||
'package' => 'Package',
|
||||
'operation' => 'Operating mode',
|
||||
'last_backup' => 'Last backup',
|
||||
'availability' => 'Availability',
|
||||
'availability_note' => 'last 30 days · measured',
|
||||
'location' => 'Server location',
|
||||
'users' => 'Users',
|
||||
'address' => 'Address',
|
||||
'storage' => 'Storage',
|
||||
'storage_used' => ':percent % used',
|
||||
'storage_week' => ':amount this week',
|
||||
'storage_note' => 'contractually guaranteed',
|
||||
'location_note' => 'no third-country transfer',
|
||||
'gb' => ':n GB',
|
||||
'of_seats' => 'of :total seats',
|
||||
'of_total' => ':used of :total',
|
||||
'per_month' => ':amount net / month',
|
||||
'per_year' => ':amount net / year',
|
||||
],
|
||||
|
||||
'proofs' => [
|
||||
'label' => 'Evidence',
|
||||
'all' => 'View all',
|
||||
'empty' => 'Once the first backup has run, it appears here.',
|
||||
'when' => 'Time',
|
||||
'what' => 'Event',
|
||||
'result' => 'Result',
|
||||
'item' => [
|
||||
'backup' => 'Backup',
|
||||
'certificate' => 'Encrypted connection',
|
||||
'maintenance' => 'Scheduled maintenance',
|
||||
'service_ends' => 'Contract ends',
|
||||
],
|
||||
'state' => [
|
||||
'ok' => 'Verified',
|
||||
'planned' => 'Scheduled',
|
||||
'attention' => 'Check',
|
||||
],
|
||||
],
|
||||
|
||||
'invoice' => [
|
||||
'label' => 'Next invoice',
|
||||
'plan' => 'Package',
|
||||
'addons' => 'Modules',
|
||||
'amount' => 'Amount',
|
||||
'net' => 'plus VAT',
|
||||
'due' => 'Due',
|
||||
'term' => 'Billed',
|
||||
'all' => 'View invoices',
|
||||
],
|
||||
|
||||
'term' => [
|
||||
'monthly' => 'monthly',
|
||||
'yearly' => 'yearly',
|
||||
],
|
||||
|
||||
'setup' => [
|
||||
'label' => 'Setup',
|
||||
'open' => '{1} One step is still open.|[2,*] :count steps are still open.',
|
||||
'continue' => 'Continue setup',
|
||||
],
|
||||
|
||||
'quick' => [
|
||||
'add_user_title' => 'Add a user',
|
||||
'add_user_body' => 'Create access for a new colleague',
|
||||
'restore_title' => 'Restore a file',
|
||||
'restore_body' => 'From the backups on record',
|
||||
'grow_title' => 'Extend the package',
|
||||
'grow_body' => 'Storage, users or data allowance',
|
||||
],
|
||||
|
||||
'title' => 'Overview',
|
||||
'subtitle' => 'Your cloud status at a glance.',
|
||||
'greeting' => 'Good morning, :name',
|
||||
|
|
@ -26,6 +118,13 @@ return [
|
|||
'support' => 'Support',
|
||||
],
|
||||
|
||||
// Two groups: what the customer operates, and what the contract says.
|
||||
'nav_group' => [
|
||||
'contract' => 'Contract',
|
||||
],
|
||||
'nav_footer' => "Operated from Austria\nAnswered the same working day",
|
||||
'signed_in_as' => 'Signed in as',
|
||||
|
||||
'open_nav' => 'Open navigation',
|
||||
'close_nav' => 'Close navigation',
|
||||
'logout' => 'Sign out',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ return [
|
|||
'contact_hours_val' => 'Mon–Fri, 8am–6pm',
|
||||
'contact_email' => 'Email',
|
||||
'contact_sla' => 'Response time',
|
||||
'contact_sla_val' => 'Team plan · within 4 hrs',
|
||||
'contact_sla_val' => 'Answer on the same working day',
|
||||
'tickets' => 'Your requests',
|
||||
'status_open' => 'Open',
|
||||
'status_progress' => 'In progress',
|
||||
|
|
|
|||
|
|
@ -16,10 +16,27 @@ return [
|
|||
'role_member' => 'Member',
|
||||
'role_readonly' => 'Read-only',
|
||||
|
||||
// Counted next to the seat figure, so the card says something true
|
||||
// underneath the number instead of repeating it.
|
||||
'role_count' => [
|
||||
'owner' => '{1} :count owner|[2,*] :count owner',
|
||||
'admin' => '{1} :count administrator|[2,*] :count administrators',
|
||||
'member' => '{1} :count member|[2,*] :count members',
|
||||
'readonly' => '{1} :count read-only|[2,*] :count read-only',
|
||||
'guest' => '{1} :count guest|[2,*] :count guests',
|
||||
],
|
||||
|
||||
'col_person' => 'Person',
|
||||
'col_status' => 'Status',
|
||||
'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',
|
||||
|
|
|
|||
|
|
@ -1,33 +1,40 @@
|
|||
/* 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. */
|
||||
/* CluPilot operator console — density, not a second palette.
|
||||
*
|
||||
* This file used to hold a complete dark "Tactical Terminal" set: its own
|
||||
* surfaces, its own orange, its own status triad. That made the console a
|
||||
* second product. Two colours for "healthy", two oranges, two ideas of what a
|
||||
* card is — and every shared component had to work in both.
|
||||
*
|
||||
* The console is now the same light system as everything else. What genuinely
|
||||
* differs is DENSITY: an operator scans forty rows, a customer reads four. So
|
||||
* this file changes spacing and type size, and nothing about colour.
|
||||
*
|
||||
* Dark mode is a preference, not the operator aesthetic. Light stays the
|
||||
* canonical expression on every surface.
|
||||
*/
|
||||
.theme-admin {
|
||||
--bg: #0b0e13;
|
||||
--surface: #12161d;
|
||||
--surface-2: #1a2029;
|
||||
--surface-hover: #1c232d;
|
||||
/* One step down across the board. The scale is the same scale — a console
|
||||
that used different type sizes as well as different spacing stops being
|
||||
recognisably the same application. */
|
||||
--text-base: 13.5px;
|
||||
--text-md: 15px;
|
||||
--text-2xl: 26px;
|
||||
--text-3xl: 32px;
|
||||
|
||||
--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);
|
||||
/* Tighter corners on a denser grid: a 16px radius around a 40px-high row
|
||||
looks inflated when there are twenty of them. */
|
||||
--radius-lg: 13px;
|
||||
--radius-xl: 16px;
|
||||
}
|
||||
|
||||
/* Table rows an operator scans rather than reads: 40–44px, against the 48–56px
|
||||
the portal uses. Written here rather than in the views so a future table
|
||||
inherits it instead of restating it. */
|
||||
.theme-admin table td {
|
||||
padding-top: 11px;
|
||||
padding-bottom: 11px;
|
||||
}
|
||||
|
||||
.theme-admin table th {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,8 @@
|
|||
@import '@fontsource/ibm-plex-mono/400.css';
|
||||
@import '@fontsource/ibm-plex-mono/500.css';
|
||||
@import '@fontsource/ibm-plex-mono/600.css';
|
||||
/* Serif is the display voice — headlines only, matching the marketing site. */
|
||||
@import '@fontsource/ibm-plex-serif/400.css';
|
||||
@import '@fontsource/ibm-plex-serif/600.css';
|
||||
/* No serif: a serif headline made every page read as a document, which is
|
||||
exactly the impression the redesign removes. One family, used assertively. */
|
||||
|
||||
/* Design tokens, then Tailwind v3 layers. */
|
||||
@import './portal-tokens.css';
|
||||
|
|
@ -53,48 +52,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Registration marks — the corner crosses on a printed form. A pseudo
|
||||
element rather than markup: they are decoration, and a screen reader
|
||||
announcing "plus, plus" on every panel would be noise. Tailwind cannot
|
||||
express ::before content, so this is the one place it lives in CSS. */
|
||||
.doc-marks {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.doc-marks::before,
|
||||
.doc-marks::after {
|
||||
content: "+";
|
||||
position: absolute;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1;
|
||||
color: var(--border-strong);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.doc-marks::before {
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
}
|
||||
|
||||
.doc-marks::after {
|
||||
bottom: -6px;
|
||||
right: -6px;
|
||||
}
|
||||
|
||||
/* The rule that precedes an eyebrow label, in the accent. Same reasoning:
|
||||
decorative, and it has to sit on the text baseline of a flex row. */
|
||||
.doc-eyebrow::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 1px;
|
||||
background: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* iOS zooms the whole page when a field smaller than 16px takes focus, and
|
||||
* then leaves the page zoomed — every tap into a form throws the layout about.
|
||||
*
|
||||
|
|
@ -124,3 +81,43 @@
|
|||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Metric visuals ───────────────────────────────────────────────────────
|
||||
* The ring and the sparkline from the approved template. They live here
|
||||
* rather than as utilities because both need keyframes and a per-element
|
||||
* custom property, and Tailwind expresses neither.
|
||||
*
|
||||
* Every var() carries a fallback: an undefined custom property does not make
|
||||
* a browser skip the declaration, it computes to `unset` — which for a
|
||||
* stroke-dashoffset silently draws a full ring at every 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;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: var(--c, 157);
|
||||
stroke-dashoffset: var(--c, 157);
|
||||
animation: ring-sweep 1.1s cubic-bezier(.2, .7, .2, 1) .2s forwards;
|
||||
}
|
||||
@keyframes ring-sweep { to { stroke-dashoffset: var(--o, 0); } }
|
||||
|
||||
.spark path { fill: none; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.spark .line {
|
||||
stroke: var(--accent);
|
||||
stroke-dasharray: 400;
|
||||
stroke-dashoffset: 400;
|
||||
animation: spark-draw 1.3s cubic-bezier(.4, .1, .2, 1) .2s forwards;
|
||||
}
|
||||
.spark.muted .line { stroke: var(--text-muted); }
|
||||
.spark .area { fill: var(--accent); stroke: none; opacity: 0; animation: spark-fade .8s ease .9s forwards; }
|
||||
@keyframes spark-draw { to { stroke-dashoffset: 0; } }
|
||||
@keyframes spark-fade { to { opacity: .09; } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.metric-ring .value { stroke-dashoffset: var(--o, 0) !important; }
|
||||
.spark .line { stroke-dashoffset: 0 !important; }
|
||||
.spark .area { opacity: .09 !important; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,75 +1,119 @@
|
|||
/* CluPilot customer-portal design tokens — refined enterprise, light, single
|
||||
orange accent. Framework-neutral CSS custom properties (work in Tailwind v3
|
||||
or v4). Values calibrated for AA contrast. See design handoff §6. */
|
||||
/* CluPilot design tokens — one set for every surface.
|
||||
*
|
||||
* Light, confident, one warm accent. The public site, the customer portal and
|
||||
* the operator console draw from this file and differ only in density: same
|
||||
* colours, same radii, same button hierarchy, same status vocabulary. A
|
||||
* customer who signs in must not feel handed to a different product, and an
|
||||
* operator switching between the two should not have to relearn anything.
|
||||
*
|
||||
* Framework-neutral custom properties (work in Tailwind v3 or v4). Contrast
|
||||
* ratios are noted where they were the reason for the value.
|
||||
*/
|
||||
:root {
|
||||
/* Surfaces (light, slightly cool) */
|
||||
--bg: #f6f7f9;
|
||||
/* ── Surfaces ─────────────────────────────────────────────────────────
|
||||
The ground is a shade darker than the cards, so a card lifts off the
|
||||
page without a shadow doing the work. */
|
||||
--bg: #f6f6f8;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f1f3f5;
|
||||
--surface-hover: #f8f9fb;
|
||||
--surface-2: #fafafb;
|
||||
--surface-hover: #f2f2f5;
|
||||
|
||||
/* Lines */
|
||||
--border: #e4e7ec;
|
||||
--border-strong: #d0d5dd;
|
||||
/* Lines carry the structure. Depth is used sparingly and warmly. */
|
||||
--border: #e9e9ee;
|
||||
--border-strong: #dcdce4;
|
||||
|
||||
/* Text */
|
||||
--text-strong: #101828;
|
||||
--text: #344054;
|
||||
--text-muted: #667085;
|
||||
--text-faint: #98a2b3;
|
||||
/* ── Text ─────────────────────────────────────────────────────────────
|
||||
Near-black rather than pure black: on a light ground pure black reads
|
||||
as a printing error, not as emphasis. */
|
||||
--text-strong: #17171c; /* headings — 16.4:1 on white */
|
||||
--text: #43434e; /* body — 9.8:1 */
|
||||
--text-muted: #6e6e7a; /* secondary copy — 5.0:1, AA for body text */
|
||||
--text-faint: #b4b4be; /* decoration only: rules, placeholder glyphs */
|
||||
|
||||
/* Accent = the single brand / interaction tone: orange */
|
||||
--accent: #f97316; /* brand tone: decorative fills, borders, tints, focus */
|
||||
--accent-hover: #ea6a0c;
|
||||
--accent-active: #c2560a; /* AA-safe fill for white text on orange (~5:1) */
|
||||
--accent-press: #a8480a; /* darker press/hover for accent-fill buttons */
|
||||
--accent-subtle: #fff4ec;
|
||||
--accent-border: #fed7aa;
|
||||
--accent-ring: rgba(249, 115, 22, .35);
|
||||
/* ── Accent ───────────────────────────────────────────────────────────
|
||||
Orange appears in FEW places, but one of them is allowed to be large —
|
||||
a whole closing panel. Frequency low, area high; the reverse reads as
|
||||
editorial annotation rather than as brand. */
|
||||
--accent: #f97316; /* rings, bars, chart strokes, the logo mark */
|
||||
--accent-hover: #d95f0c;
|
||||
--accent-active: #b8500a; /* solid fills and text — 5.0:1 with white */
|
||||
--accent-press: #974208;
|
||||
--accent-subtle: #fff3e9;
|
||||
--accent-border: #e8cdb2;
|
||||
--accent-ring: rgba(249, 115, 22, .38);
|
||||
--on-accent: #ffffff;
|
||||
/* Accent as TEXT on light surfaces (links, small labels) must be dark enough
|
||||
for AA — #f97316 on white is only ~2.9:1, so text uses --accent-active. */
|
||||
--accent-text: #c2560a;
|
||||
/* #f97316 as TEXT on white is only 2.9:1, so anything read uses the
|
||||
darker tone. This is the single most common way the palette gets
|
||||
broken, which is why the two have different names. */
|
||||
--accent-text: #b8500a;
|
||||
|
||||
/* The ink plate: the dark surface used where the brand speaks rather than
|
||||
the product — the sign-in panel, the closing block of the public site.
|
||||
Warm rather than blue-black, so it reads as ink, not as chrome. */
|
||||
--plate: #17140f;
|
||||
--plate-2: #211d16;
|
||||
--plate-rule: #39332a;
|
||||
--plate-text: #ded7c9;
|
||||
--plate-muted: #93897a;
|
||||
/* ── Ink fields ───────────────────────────────────────────────────────
|
||||
The dark surfaces: the site's contrast bands, the sign-in panel, the
|
||||
operator sidebar. Neutral-warm, never pure black. */
|
||||
--plate: #16151b;
|
||||
--plate-2: #201f27;
|
||||
--plate-rule: #33323d;
|
||||
--plate-text: #c9c7d2;
|
||||
--plate-muted: #918f9d; /* 4.6:1 on --plate */
|
||||
|
||||
/* Status semantics — only in badges/alerts, never a second decorative tone */
|
||||
--success: #067a48; --success-bg: #ecfdf3; --success-border: #abefc6;
|
||||
--success-bright: #30b15c; /* dots/rings/charts only (large marks, not small text) */
|
||||
--warning: #b54708; --warning-bg: #fffaeb; --warning-border: #fedf89;
|
||||
--danger: #b42318; --danger-bg: #fef3f2; --danger-border: #fecdca;
|
||||
--info: #175cd3; --info-bg: #eff8ff; --info-border: #b2ddff;
|
||||
/* ── Status ───────────────────────────────────────────────────────────
|
||||
Independent of the brand. Amber must keep meaning "this needs you", so
|
||||
"scheduled" and "informational" are blue — next to an orange brand the
|
||||
two are otherwise indistinguishable. */
|
||||
--success: #1c7c50; --success-bg: #eaf6f0; --success-border: #c3e3d3;
|
||||
--success-bright: #30b15c; /* dots, rings and charts — never small text */
|
||||
--warning: #9a5b0b; --warning-bg: #fdf4e7; --warning-border: #eed9b3;
|
||||
--danger: #a8302a; --danger-bg: #fbeeed; --danger-border: #eec9c6;
|
||||
--info: #1e5aa8; --info-bg: #eef4fa; --info-border: #c3d6ea;
|
||||
|
||||
/* Typography */
|
||||
/* ── Type ─────────────────────────────────────────────────────────────
|
||||
One family, used assertively. Mono is for operational data only —
|
||||
storage, dates, addresses, versions, invoice figures — where tabular
|
||||
figures let a column of numbers line up. Never for headings. */
|
||||
--font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
|
||||
/* Display voice for headlines, shared with the public site so the two do
|
||||
not look like two different companies. Never used for body copy. */
|
||||
--font-serif: "IBM Plex Serif", Georgia, "Times New Roman", serif;
|
||||
/* Kept for compatibility; the display voice is now the sans at weight 700.
|
||||
A serif headline made every page read as a document. */
|
||||
--font-serif: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
--text-xs: 12px; --text-sm: 13px; --text-base: 14px; --text-md: 15px;
|
||||
--text-lg: 18px; --text-xl: 22px; --text-2xl: 28px; --text-3xl: 34px;
|
||||
--text-xs: 12px; --text-sm: 13px; --text-base: 14.5px; --text-md: 16px;
|
||||
--text-lg: 18px; --text-xl: 22px; --text-2xl: 30px; --text-3xl: 40px;
|
||||
|
||||
--lh-tight: 1.2; --lh-normal: 1.5;
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-label: 0.06em;
|
||||
--lh-tight: 1.12; --lh-normal: 1.55;
|
||||
--tracking-tight: -0.03em;
|
||||
--tracking-label: 0.07em;
|
||||
|
||||
/* Radius / shadow / motion / focus */
|
||||
--radius-sm: 6px; --radius: 8px; --radius-lg: 12px; --radius-xl: 20px; --radius-pill: 999px;
|
||||
/* ── Radius ───────────────────────────────────────────────────────────
|
||||
Scaled to object size. One value everywhere is what makes an interface
|
||||
read as a design-system specification rather than as a product. */
|
||||
--radius-sm: 9px; /* chips, small controls */
|
||||
--radius: 11px; /* buttons, inputs */
|
||||
--radius-lg: 16px; /* cards */
|
||||
--radius-xl: 22px; /* full-width panels, product frames */
|
||||
--radius-pill: 999px;
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(16, 24, 40, .05);
|
||||
--shadow-sm: 0 1px 3px rgba(16, 24, 40, .10), 0 1px 2px rgba(16, 24, 40, .06);
|
||||
--shadow-md: 0 4px 8px -2px rgba(16, 24, 40, .10), 0 2px 4px -2px rgba(16, 24, 40, .06);
|
||||
/* Warm, broad and low — a neutral grey drop shadow belongs to a different
|
||||
design language and reads as cold against this ground. */
|
||||
--shadow-xs: 0 1px 2px rgba(40, 24, 15, .04);
|
||||
--shadow-sm: 0 2px 6px rgba(40, 24, 15, .06);
|
||||
--shadow-md: 0 16px 44px -18px rgba(40, 24, 15, .20);
|
||||
|
||||
--dur-fast: 120ms; --dur: 180ms; --dur-slow: 240ms;
|
||||
--ease: cubic-bezier(.2, .6, .2, 1);
|
||||
--dur-fast: 120ms; --dur: 180ms; --dur-slow: 260ms;
|
||||
--ease: cubic-bezier(.2, .7, .2, 1);
|
||||
|
||||
--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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,66 +19,69 @@
|
|||
--}}
|
||||
@verbatim
|
||||
<style>
|
||||
@font-face{font-family:"Plex Serif";src:url("/fonts/ibm-plex-serif-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
|
||||
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
|
||||
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
|
||||
@font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
|
||||
|
||||
:root{
|
||||
--paper:#f7f5f1; --card:#fffefb; --ink:#17140f; --ink-soft:#413a31;
|
||||
--muted:#7b7367; --rule:#e3ded3; --rule-mid:#cbc3b4;
|
||||
--accent:#f97316; --accent-text:#b8500a;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
/* The accent has to lighten too: #b8500a is chosen for contrast against
|
||||
paper, and on the ink plate it is the small text that stops being
|
||||
readable first. */
|
||||
:root{--paper:#17140f; --card:#211d16; --ink:#fffefb; --ink-soft:#ded7c9;
|
||||
--muted:#93897a; --rule:#39332a; --rule-mid:#4a4238;
|
||||
--accent-text:#f0a06a;}
|
||||
--bg:#f6f6f8; --card:#fff; --card-2:#fafafb;
|
||||
--ink:#17171c; --body:#43434e; --muted:#6e6e7a;
|
||||
--line:#e9e9ee; --line-2:#dcdce4;
|
||||
--accent:#f97316; --accent-ink:#b8500a; --accent-wash:#fff3e9;
|
||||
--plate:#16151b;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{
|
||||
font-family:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
|
||||
background:var(--paper);color:var(--ink-soft);line-height:1.6;
|
||||
background:var(--bg);color:var(--body);line-height:1.55;
|
||||
min-height:100vh;display:grid;place-items:center;padding:clamp(24px,6vw,64px);
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
main{max-width:520px;width:100%}
|
||||
.plate{border:1px solid var(--rule-mid);border-radius:3px;background:var(--card);position:relative}
|
||||
/* Registration marks, as on the rest of the site. */
|
||||
.plate::before,.plate::after{content:"+";position:absolute;font-family:"Plex Mono",monospace;font-size:.8rem;color:var(--rule-mid);line-height:1}
|
||||
.plate::before{top:-6px;left:-6px}
|
||||
.plate::after{bottom:-6px;right:-6px}
|
||||
.head{
|
||||
display:flex;justify-content:space-between;align-items:center;gap:14px;
|
||||
padding:13px 20px;border-bottom:1px solid var(--rule);
|
||||
font-family:"Plex Mono",monospace;font-size:.7rem;text-transform:uppercase;letter-spacing:.15em;color:var(--muted);
|
||||
}
|
||||
.head b{color:var(--accent-text);font-weight:400;display:inline-flex;align-items:center;gap:7px}
|
||||
.dot{width:6px;height:6px;border-radius:50%;background:var(--accent);animation:pulse 1.8s ease-in-out infinite}
|
||||
main{max-width:560px;width:100%}
|
||||
.card{border:1px solid var(--line);border-radius:16px;background:var(--card);
|
||||
box-shadow:0 16px 50px rgba(40,24,15,.10);overflow:hidden}
|
||||
.head{display:flex;align-items:center;justify-content:space-between;gap:14px;
|
||||
padding:16px 24px;border-bottom:1px solid var(--line);background:var(--card-2);
|
||||
font-size:13px;font-weight:600;color:var(--muted)}
|
||||
.head b{color:var(--accent-ink);font-family:"Plex Mono",monospace;font-weight:500}
|
||||
.body{padding:30px 24px 28px}
|
||||
.brand{display:inline-flex;align-items:center;gap:9px;font-size:17px;font-weight:700;
|
||||
letter-spacing:-.02em;color:var(--ink)}
|
||||
.brand i{font-style:normal;color:var(--accent-ink)}
|
||||
.mark{width:26px;height:26px;border-radius:7px;flex:none;
|
||||
background:linear-gradient(135deg,#fb923c,#f97316 55%,#c2560a)}
|
||||
h1{margin-top:18px;font-weight:700;font-size:clamp(1.6rem,4vw,2.1rem);
|
||||
letter-spacing:-.032em;line-height:1.1;color:var(--ink)}
|
||||
p{margin-top:12px;color:var(--muted);font-size:15px}
|
||||
.hint{margin-top:18px;padding-top:16px;border-top:1px solid var(--line);font-size:14px}
|
||||
.acts{display:flex;flex-wrap:wrap;gap:10px;margin-top:26px}
|
||||
.btn{display:inline-flex;align-items:center;min-height:46px;padding:0 22px;border-radius:12px;
|
||||
font-size:15px;font-weight:600;text-decoration:none;border:1.5px solid transparent;
|
||||
transition:background .18s,border-color .18s}
|
||||
.ink{background:var(--accent-ink);color:#fff}
|
||||
.ink:hover{background:#974208}
|
||||
.line{border-color:var(--line-2);color:var(--ink)}
|
||||
.line:hover{border-color:var(--ink)}
|
||||
.foot{margin-top:12px;font-size:14px}
|
||||
.foot a{color:var(--accent-ink);font-weight:600;text-decoration:none}
|
||||
.foot a:hover{text-decoration:underline}
|
||||
.mark-b{margin-top:22px;font-size:12.5px;color:var(--muted);text-align:center}
|
||||
.dot{width:7px;height:7px;border-radius:50%;background:var(--accent);animation:pulse 1.8s ease-in-out infinite}
|
||||
@keyframes pulse{50%{opacity:.35}}
|
||||
@media (prefers-reduced-motion: reduce){.dot{animation:none}}
|
||||
.body{padding:30px 20px 28px}
|
||||
.brand{font-family:"Plex Serif",Georgia,serif;font-weight:600;font-size:1.1rem;letter-spacing:-.02em;color:var(--ink)}
|
||||
.brand i{font-style:normal;color:var(--accent-text)}
|
||||
h1{margin-top:16px;font-family:"Plex Serif",Georgia,serif;font-weight:600;
|
||||
font-size:clamp(1.5rem,4vw,2rem);letter-spacing:-.025em;line-height:1.15;color:var(--ink)}
|
||||
p{margin-top:12px;color:var(--muted);font-size:.95rem}
|
||||
.foot{margin-top:18px;padding-top:16px;border-top:1px solid var(--rule);font-size:.86rem}
|
||||
.foot a{color:var(--accent-text);font-weight:500;text-decoration:none}
|
||||
.foot a:hover{text-decoration:underline}
|
||||
</style>
|
||||
@endverbatim
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="plate">
|
||||
<div class="card">
|
||||
<div class="head">
|
||||
<span>{{ __('coming_soon.label') }}</span>
|
||||
<b><span class="dot" aria-hidden="true"></span>{{ __('coming_soon.state') }}</b>
|
||||
<b style="display:inline-flex;align-items:center;gap:8px"><span class="dot" aria-hidden="true"></span>{{ __('coming_soon.state') }}</b>
|
||||
</div>
|
||||
<div class="body">
|
||||
<span class="brand">Clu<i>Pilot</i> Cloud</span>
|
||||
<span class="brand"><span class="mark" aria-hidden="true"></span>Clu<i>Pilot</i> Cloud</span>
|
||||
<h1>{{ __('coming_soon.title') }}</h1>
|
||||
<p>{{ __('coming_soon.body') }}</p>
|
||||
<p class="foot">{{ __('coming_soon.contact') }}
|
||||
|
|
|
|||
|
|
@ -14,24 +14,20 @@
|
|||
would only push it under the fold.
|
||||
--}}
|
||||
<aside class="relative hidden w-[44%] shrink-0 overflow-hidden bg-plate lg:flex lg:flex-col lg:justify-between lg:p-12 xl:p-14">
|
||||
{{-- Survey grid, faded out towards the bottom. --}}
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 opacity-60"
|
||||
style="background-image:linear-gradient(var(--plate-rule) 1px,transparent 1px),linear-gradient(90deg,var(--plate-rule) 1px,transparent 1px);
|
||||
background-size:76px 76px;
|
||||
mask-image:radial-gradient(ellipse 78% 62% at 30% 8%,#000 0%,transparent 72%);
|
||||
-webkit-mask-image:radial-gradient(ellipse 78% 62% at 30% 8%,#000 0%,transparent 72%);"></div>
|
||||
{{-- One warm glow, the same one the public site's dark bands carry. --}}
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0"
|
||||
style="background:radial-gradient(ellipse 52% 46% at 24% 18%,rgba(249,115,22,.20),transparent 68%);"></div>
|
||||
|
||||
<div class="relative flex items-center gap-2.5">
|
||||
<x-ui.logo class="size-8" />
|
||||
<span class="font-serif text-xl font-semibold tracking-tight text-white">CluPilot</span>
|
||||
<span class="text-xl font-bold tracking-tight text-white">CluPilot</span>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<p class="mb-6 flex items-center gap-3 font-mono text-[11px] uppercase tracking-[0.16em] text-plate-muted">
|
||||
<span class="h-px w-6 bg-accent"></span>{{ __('auth.brand_eyebrow') }}
|
||||
</p>
|
||||
<p class="mb-7 inline-flex items-center gap-2 rounded-pill px-3.5 py-1.5 text-xs font-semibold"
|
||||
style="background:rgba(249,115,22,.16);color:#ffb27a">{{ __('auth.brand_eyebrow') }}</p>
|
||||
|
||||
<h2 class="max-w-md font-serif text-4xl font-semibold leading-[1.08] tracking-tight text-white xl:text-[2.9rem]">{{ $headline }}</h2>
|
||||
<h2 class="max-w-md text-4xl font-bold leading-[1.08] tracking-tight text-white xl:text-[2.9rem]">{{ $headline }}</h2>
|
||||
<p class="mt-5 max-w-sm leading-relaxed text-plate-text">{{ $sub }}</p>
|
||||
|
||||
@if ($facts !== [])
|
||||
|
|
@ -46,5 +42,5 @@
|
|||
@endif
|
||||
</div>
|
||||
|
||||
<p class="relative font-mono text-[11px] uppercase tracking-[0.14em] text-plate-muted">{{ __('auth.brand_footer') }}</p>
|
||||
<p class="relative text-xs text-plate-muted">{{ __('auth.brand_footer') }}</p>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
@props(['title' => null])
|
||||
{{--
|
||||
The document head both shells share. Written once because a divergence here
|
||||
is invisible until something is missing on exactly one of them — a favicon,
|
||||
the CSRF token, a viewport that lets iOS zoom.
|
||||
--}}
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="apple-touch-icon" href="/favicon.svg">
|
||||
<title>{{ $title ?? config('app.name') }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@livewireStyles
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
@props([
|
||||
// [[route, icon, translation key], …] or [['label' => …, 'items' => [...]], …]
|
||||
'groups',
|
||||
// dot-prefix for the labels, e.g. 'dashboard.nav' or 'admin.nav'
|
||||
'prefix',
|
||||
// console routes are matched through AdminArea so recovery hostnames still
|
||||
// highlight; the portal uses the router directly
|
||||
'console' => false,
|
||||
'footer' => null,
|
||||
])
|
||||
{{--
|
||||
The sidebar, shared by both shells.
|
||||
|
||||
Light, like everything else. The console used to be dark, which made it a
|
||||
second product: two ideas of what a card is, two oranges, two status
|
||||
palettes. What actually differs between an operator and a customer is
|
||||
density, and that lives in the tokens — not here.
|
||||
--}}
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-50 flex w-[15.5rem] flex-col overflow-y-auto border-r border-line bg-surface px-3 py-4 shadow-md transition-transform lg:static lg:h-screen lg:translate-x-0 lg:shadow-none"
|
||||
:class="nav ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 px-2.5 pb-5">
|
||||
<a href="{{ $console ? \App\Support\AdminArea::home() : route('dashboard') }}" class="flex items-center gap-2.5">
|
||||
<x-ui.logo class="size-7" />
|
||||
{{-- One element: as separate flex children the gap lands inside the
|
||||
word and reads as "Clu Pilot". --}}
|
||||
<span class="whitespace-nowrap text-lg font-bold tracking-tight text-ink">Clu<span class="text-accent-text">Pilot</span></span>
|
||||
@if ($console)
|
||||
<span class="rounded-sm bg-accent-subtle px-1.5 py-0.5 font-mono text-[9.5px] font-semibold uppercase tracking-[0.1em] text-accent-text">{{ __('admin.badge') }}</span>
|
||||
@endif
|
||||
</a>
|
||||
<button type="button" class="lg:hidden -mr-1 inline-flex min-h-11 min-w-11 items-center justify-center rounded text-muted"
|
||||
@click="nav = false" aria-label="{{ __('dashboard.close_nav') }}">
|
||||
<x-ui.icon name="x" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@foreach ($groups as $group)
|
||||
@if (! empty($group['label']))
|
||||
<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])
|
||||
@continue($capability !== null && ! auth()->user()?->can($capability))
|
||||
<x-ui.nav-item
|
||||
:href="route($route)"
|
||||
:active="$console ? \App\Support\AdminArea::routeIs($route) : request()->routeIs($route)"
|
||||
@click="nav = false"
|
||||
>
|
||||
<x-slot:icon><x-ui.icon :name="$icon" class="size-[1.15rem]" /></x-slot:icon>
|
||||
{{ __($prefix.'.'.$key) }}
|
||||
</x-ui.nav-item>
|
||||
@endforeach
|
||||
</nav>
|
||||
@endforeach
|
||||
|
||||
@if ($footer)
|
||||
<p class="mt-auto px-3 pt-6 font-mono text-[11px] leading-relaxed text-muted">{{ $footer }}</p>
|
||||
@endif
|
||||
</aside>
|
||||
|
||||
{{-- Backdrop. Tapping it closes the drawer, which is the behaviour every
|
||||
phone user tries first. --}}
|
||||
<div x-show="nav" x-cloak @click="nav = false" class="fixed inset-0 z-40 bg-[color-mix(in_srgb,var(--plate)_42%,transparent)] lg:hidden"></div>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
@props(['crumbs' => []])
|
||||
{{--
|
||||
The bar the content scrolls under. Translucent with a blur rather than a
|
||||
solid strip: on a light ground a solid bar reads as chrome bolted on top,
|
||||
and the blur is the one borrowed detail worth keeping.
|
||||
--}}
|
||||
<header class="sticky top-0 z-30 flex h-[3.75rem] shrink-0 items-center gap-3 border-b border-line bg-[color-mix(in_srgb,var(--surface)_82%,transparent)] px-4 backdrop-blur-lg backdrop-saturate-150 lg:px-6">
|
||||
<button type="button" class="lg:hidden inline-flex min-h-11 min-w-11 items-center justify-center rounded text-muted hover:bg-surface-hover"
|
||||
@click="nav = true" aria-label="{{ __('dashboard.open_nav') }}">
|
||||
<x-ui.icon name="menu" />
|
||||
</button>
|
||||
|
||||
{{-- On a phone only the leaf survives: the trail is a desktop affordance,
|
||||
and the intermediate crumbs push the page name off screen. --}}
|
||||
<nav class="flex min-w-0 items-center gap-2 text-sm text-muted" aria-label="Breadcrumb">
|
||||
@foreach ($crumbs as $i => $crumb)
|
||||
@if ($i > 0)
|
||||
<x-ui.icon name="chevron-down" class="size-3.5 -rotate-90 text-faint {{ $loop->last ? 'hidden sm:block' : 'hidden sm:block' }}" />
|
||||
@endif
|
||||
<span @class([
|
||||
'min-w-0 truncate',
|
||||
'font-semibold text-ink' => $loop->last,
|
||||
'hidden sm:inline' => ! $loop->last,
|
||||
])>{{ $crumb }}</span>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
{{ $slot }}
|
||||
</header>
|
||||
|
|
@ -7,6 +7,6 @@
|
|||
'danger' => 'bg-danger-bg border-danger-border text-danger',
|
||||
];
|
||||
@endphp
|
||||
<div role="alert" {{ $attributes->merge(['class' => 'rounded border px-3 py-2 text-sm '.($variants[$variant] ?? $variants['info'])]) }}>
|
||||
<div role="alert" {{ $attributes->merge(['class' => 'rounded border px-4 py-3 text-sm '.($variants[$variant] ?? $variants['info'])]) }}>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
'info' => 'bg-info-bg border-info-border text-info',
|
||||
][$tone];
|
||||
@endphp
|
||||
<span {{ $attributes->merge(['class' => 'inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium '.$classes]) }}>
|
||||
<span {{ $attributes->merge(['class' => 'inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-1 text-xs font-semibold '.$classes]) }}>
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ $slot }}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -3,13 +3,18 @@
|
|||
'size' => 'md',
|
||||
'type' => 'button',
|
||||
'loading' => false,
|
||||
// Given an href this renders an anchor instead. A link that looks like a
|
||||
// button must still BE a link: middle-click, "open in new tab" and the
|
||||
// status bar all come from the element, not from the styling.
|
||||
'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
|
||||
|
|
@ -19,21 +24,30 @@
|
|||
'secondary' => 'border border-line-strong bg-surface text-body hover:bg-surface-hover',
|
||||
'ghost' => 'text-body hover:bg-surface-hover',
|
||||
'danger' => 'bg-danger text-on-accent hover:opacity-90',
|
||||
// The document primary: ink on paper, warming to the accent on hover.
|
||||
// Orange is the accent, not the loudest thing on the page — on a sheet
|
||||
// full of hairlines a filled orange button is the only thing anyone
|
||||
// sees, and it is rarely the most important thing on the page.
|
||||
'ink' => 'bg-ink text-bg hover:bg-accent-text',
|
||||
];
|
||||
|
||||
$classes = $base.' '.($sizes[$size] ?? $sizes['md']).' '.($variants[$variant] ?? $variants['primary']);
|
||||
@endphp
|
||||
<button
|
||||
type="{{ $type }}"
|
||||
{{ $attributes->merge(['class' => $classes]) }}
|
||||
@disabled($loading)
|
||||
@if ($loading) aria-busy="true" @endif
|
||||
>
|
||||
@if ($loading)
|
||||
<svg class="size-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4z"></path>
|
||||
</svg>
|
||||
@endif
|
||||
{{ $slot }}
|
||||
</button>
|
||||
@if ($href !== null)
|
||||
<a href="{{ $href }}" {{ $attributes->merge(['class' => $classes]) }}>{{ $slot }}</a>
|
||||
@else
|
||||
<button
|
||||
type="{{ $type }}"
|
||||
{{ $attributes->merge(['class' => $classes]) }}
|
||||
@disabled($loading)
|
||||
@if ($loading) aria-busy="true" @endif
|
||||
>
|
||||
@if ($loading)
|
||||
<svg class="size-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4z"></path>
|
||||
</svg>
|
||||
@endif
|
||||
{{ $slot }}
|
||||
</button>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
@props([
|
||||
'title' => null,
|
||||
'padding' => 'p-6',
|
||||
'padding' => 'p-5',
|
||||
])
|
||||
{{--
|
||||
The unit every panel is built from. Depth is one warm, broad shadow — a
|
||||
neutral grey drop shadow belongs to a different design language and reads
|
||||
as cold against this ground.
|
||||
--}}
|
||||
<div {{ $attributes->merge(['class' => 'rounded-lg border border-line bg-surface shadow-xs']) }}>
|
||||
@if ($title)
|
||||
<div class="border-b border-line px-6 py-3">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $title }}</h2>
|
||||
{{-- A real heading, not a small-caps label. It was set in --text-faint,
|
||||
which is 2.8:1 on white: a decoration token used for something a
|
||||
reader has to read. --}}
|
||||
<div class="border-b border-line px-5 py-4">
|
||||
<h2 class="text-md font-semibold text-ink">{{ $title }}</h2>
|
||||
</div>
|
||||
@endif
|
||||
<div class="{{ $padding }}">
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
$errors ??= new \Illuminate\Support\ViewErrorBag;
|
||||
$id = $attributes->get('id', $name);
|
||||
$hasError = $errors->has($name);
|
||||
$field = 'block w-full rounded border bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint transition '
|
||||
$field = 'block w-full rounded border bg-surface px-3.5 py-2.5 text-sm text-ink placeholder:text-faint transition '
|
||||
.($hasError ? 'border-danger bg-danger-bg' : 'border-line');
|
||||
@endphp
|
||||
<div class="space-y-1.5">
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
>
|
||||
|
||||
@if ($hint && ! $hasError)
|
||||
<p class="text-xs text-faint">{{ $hint }}</p>
|
||||
<p class="text-xs text-muted">{{ $hint }}</p>
|
||||
@endif
|
||||
|
||||
@error($name)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
@props([
|
||||
'label',
|
||||
// The figure, and the unit that follows it in lighter weight — "235" and
|
||||
// "/ 500 GB" read as one measurement rather than as two numbers.
|
||||
'value',
|
||||
'unit' => null,
|
||||
'foot' => null,
|
||||
// A chevron to the page that explains the figure. Omitted where there is
|
||||
// no such page: a chevron that goes nowhere is worse than none.
|
||||
'href' => null,
|
||||
])
|
||||
{{--
|
||||
One metric card, exactly as the approved template draws it: small caps
|
||||
label with an optional chevron, the figure in tabular monospace with its
|
||||
unit beside it, a line of context underneath, and the visual on the right.
|
||||
|
||||
The visual is a slot so the card does not have to know whether it is a
|
||||
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 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" />
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-1.5 flex items-end justify-between gap-3.5">
|
||||
<div class="min-w-0">
|
||||
<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-[13px] font-[450] text-muted">{{ $unit }}</span>@endif
|
||||
</p>
|
||||
@if ($foot)
|
||||
<p class="mt-1.5 text-[12px] leading-snug text-muted">{{ $foot }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@isset($visual)
|
||||
<div class="shrink-0">{{ $visual }}</div>
|
||||
@endisset
|
||||
</div>
|
||||
|
||||
{{ $slot }}
|
||||
</article>
|
||||
|
|
@ -6,21 +6,26 @@
|
|||
@php
|
||||
// items-center, not baseline: the icon is a block-level svg (Tailwind's
|
||||
// preflight makes it one) and must sit BESIDE the label, never above it.
|
||||
$base = 'flex items-center gap-3 rounded border-l-2 px-3 py-2 text-sm font-medium min-h-11';
|
||||
// R18.
|
||||
$base = 'relative flex min-h-11 items-center gap-3 rounded px-3 text-sm';
|
||||
|
||||
// The label is a flex row of its own so that an icon handed to the default
|
||||
// slot instead of the icon slot still lands next to the text. Passing it in
|
||||
// the default slot put a display:block svg into an inline span and pushed
|
||||
// the label onto a second line — the sidebar entry then stood twice as tall
|
||||
// as every other one. Layout must not depend on which slot was used.
|
||||
// The label is a flex row of its own so an icon handed to the default slot
|
||||
// instead of the icon slot still lands next to the text. Passing it in the
|
||||
// default slot put a display:block svg into an inline span and pushed the
|
||||
// label onto a second line — the entry then stood twice as tall as every
|
||||
// other one. Layout must not depend on which slot was used.
|
||||
$label = 'flex min-w-0 items-center gap-3';
|
||||
|
||||
// Active is a soft pill with a 3px accent tab flush at its left edge — a
|
||||
// physical "you are here" marker rather than a colour wash. The tab is a
|
||||
// pseudo-element via before:, so it cannot push the label around.
|
||||
$state = $active
|
||||
? 'bg-accent-subtle text-accent-text border-accent'
|
||||
: 'text-muted border-transparent hover:bg-surface-hover hover:text-body';
|
||||
? 'bg-surface-hover font-semibold text-ink before:absolute before:left-0 before:top-2.5 before:bottom-2.5 before:w-[3px] before:rounded-r before:bg-accent before:content-[\'\']'
|
||||
: 'font-medium text-muted hover:bg-surface-hover hover:text-ink';
|
||||
@endphp
|
||||
@if ($disabled)
|
||||
{{-- Section not built yet: render a non-interactive item, never a dead link. --}}
|
||||
<span aria-disabled="true" {{ $attributes->merge(['class' => $base.' border-transparent text-faint cursor-not-allowed']) }}>
|
||||
{{-- Section not built yet: a non-interactive item, never a dead link. --}}
|
||||
<span aria-disabled="true" {{ $attributes->merge(['class' => $base.' cursor-not-allowed font-medium text-faint']) }}>
|
||||
@isset($icon)<span class="shrink-0">{{ $icon }}</span>@endisset
|
||||
<span class="{{ $label }}">{{ $slot }}</span>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
@props(['percent' => 0, 'size' => 62])
|
||||
@php
|
||||
// Circumference of r=25 in the 62-unit box. The offset is what is NOT
|
||||
// filled, so a full ring is 0 and an empty one is the whole circumference.
|
||||
$c = 157;
|
||||
$o = (int) round($c * (1 - min(100, max(0, (float) $percent)) / 100));
|
||||
@endphp
|
||||
<div class="relative grid place-items-center">
|
||||
<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-[13px] font-medium text-ink">{{ (int) round($percent) }}%</b>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
@props([
|
||||
/** @var array<int, int|float> the series, in the order it happened */
|
||||
'points' => [],
|
||||
// muted where the figure is observed, accent where it can be acted on.
|
||||
// Four accent charts in a row is how an accent stops meaning anything.
|
||||
'tone' => 'accent',
|
||||
'area' => false,
|
||||
])
|
||||
@php
|
||||
$values = array_values(array_filter($points, 'is_numeric'));
|
||||
$min = $values ? min($values) : 0;
|
||||
$max = $values ? max($values) : 0;
|
||||
$span = max(0.0001, $max - $min);
|
||||
$step = count($values) > 1 ? 96 / (count($values) - 1) : 96;
|
||||
|
||||
$path = '';
|
||||
foreach ($values as $i => $v) {
|
||||
$x = round($i * $step, 1);
|
||||
$y = round(30 - (($v - $min) / $span) * 26, 1);
|
||||
$path .= ($i === 0 ? 'M' : ' ').$x.' '.$y;
|
||||
}
|
||||
@endphp
|
||||
@if ($path !== '')
|
||||
<svg class="spark {{ $tone === 'accent' ? '' : 'muted' }}" viewBox="0 0 96 34" preserveAspectRatio="none"
|
||||
style="width:80px;height:32px" aria-hidden="true">
|
||||
@if ($area)
|
||||
<path class="area" d="{{ $path }} L96 34 L0 34 Z" />
|
||||
@endif
|
||||
<path class="line" d="{{ $path }}" />
|
||||
</svg>
|
||||
@endif
|
||||
|
|
@ -37,62 +37,66 @@
|
|||
--}}
|
||||
@verbatim
|
||||
<style>
|
||||
@font-face{font-family:"Plex Serif";src:url("/fonts/ibm-plex-serif-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
|
||||
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
|
||||
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
|
||||
@font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
|
||||
|
||||
:root{
|
||||
--paper:#f7f5f1; --card:#fffefb; --ink:#17140f; --ink-soft:#413a31;
|
||||
--muted:#7b7367; --rule:#e3ded3; --rule-mid:#cbc3b4;
|
||||
--accent:#f97316; --accent-text:#b8500a;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
/* The accent has to lighten too: #b8500a is chosen for contrast against
|
||||
paper, and on the ink plate it is the small text that stops being
|
||||
readable first. */
|
||||
:root{--paper:#17140f; --card:#211d16; --ink:#fffefb; --ink-soft:#ded7c9;
|
||||
--muted:#93897a; --rule:#39332a; --rule-mid:#4a4238;
|
||||
--accent-text:#f0a06a;}
|
||||
--bg:#f6f6f8; --card:#fff; --card-2:#fafafb;
|
||||
--ink:#17171c; --body:#43434e; --muted:#6e6e7a;
|
||||
--line:#e9e9ee; --line-2:#dcdce4;
|
||||
--accent:#f97316; --accent-ink:#b8500a; --accent-wash:#fff3e9;
|
||||
--plate:#16151b;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{
|
||||
font-family:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
|
||||
background:var(--paper);color:var(--ink-soft);line-height:1.6;
|
||||
background:var(--bg);color:var(--body);line-height:1.55;
|
||||
min-height:100vh;display:grid;place-items:center;padding:clamp(24px,6vw,64px);
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
main{max-width:520px;width:100%}
|
||||
.plate{border:1px solid var(--rule-mid);border-radius:3px;background:var(--card);position:relative}
|
||||
/* Registration marks, as on the rest of the site. */
|
||||
.plate::before,.plate::after{content:"+";position:absolute;font-family:"Plex Mono",monospace;font-size:.8rem;color:var(--rule-mid);line-height:1}
|
||||
.plate::before{top:-6px;left:-6px}
|
||||
.plate::after{bottom:-6px;right:-6px}
|
||||
.head{
|
||||
display:flex;justify-content:space-between;align-items:center;gap:14px;
|
||||
padding:13px 20px;border-bottom:1px solid var(--rule);
|
||||
font-family:"Plex Mono",monospace;font-size:.7rem;text-transform:uppercase;letter-spacing:.15em;color:var(--muted);
|
||||
}
|
||||
.head b{color:var(--accent-text);font-weight:400}
|
||||
.body{padding:28px 20px 26px}
|
||||
h1{font-family:"Plex Serif",Georgia,serif;font-weight:600;font-size:clamp(1.5rem,4vw,2rem);
|
||||
letter-spacing:-.025em;line-height:1.15;color:var(--ink)}
|
||||
p{margin-top:12px;color:var(--muted);font-size:.95rem}
|
||||
.hint{margin-top:16px;padding-top:14px;border-top:1px solid var(--rule);font-size:.86rem}
|
||||
.acts{display:flex;flex-wrap:wrap;gap:10px;margin-top:24px}
|
||||
.btn{display:inline-flex;align-items:center;border-radius:3px;font-size:.88rem;font-weight:500;padding:10px 18px;text-decoration:none;transition:background .18s,border-color .18s}
|
||||
.ink{background:var(--ink);color:var(--paper)}
|
||||
.ink:hover{background:var(--accent-text);color:#fff}
|
||||
.line{border:1px solid var(--rule-mid);color:var(--ink)}
|
||||
main{max-width:560px;width:100%}
|
||||
.card{border:1px solid var(--line);border-radius:16px;background:var(--card);
|
||||
box-shadow:0 16px 50px rgba(40,24,15,.10);overflow:hidden}
|
||||
.head{display:flex;align-items:center;justify-content:space-between;gap:14px;
|
||||
padding:16px 24px;border-bottom:1px solid var(--line);background:var(--card-2);
|
||||
font-size:13px;font-weight:600;color:var(--muted)}
|
||||
.head b{color:var(--accent-ink);font-family:"Plex Mono",monospace;font-weight:500}
|
||||
.body{padding:30px 24px 28px}
|
||||
.brand{display:inline-flex;align-items:center;gap:9px;font-size:17px;font-weight:700;
|
||||
letter-spacing:-.02em;color:var(--ink)}
|
||||
.brand i{font-style:normal;color:var(--accent-ink)}
|
||||
.mark{width:26px;height:26px;border-radius:7px;flex:none;
|
||||
background:linear-gradient(135deg,#fb923c,#f97316 55%,#c2560a)}
|
||||
h1{margin-top:18px;font-weight:700;font-size:clamp(1.6rem,4vw,2.1rem);
|
||||
letter-spacing:-.032em;line-height:1.1;color:var(--ink)}
|
||||
p{margin-top:12px;color:var(--muted);font-size:15px}
|
||||
.hint{margin-top:18px;padding-top:16px;border-top:1px solid var(--line);font-size:14px}
|
||||
.acts{display:flex;flex-wrap:wrap;gap:10px;margin-top:26px}
|
||||
.btn{display:inline-flex;align-items:center;min-height:46px;padding:0 22px;border-radius:12px;
|
||||
font-size:15px;font-weight:600;text-decoration:none;border:1.5px solid transparent;
|
||||
transition:background .18s,border-color .18s}
|
||||
.ink{background:var(--accent-ink);color:#fff}
|
||||
.ink:hover{background:#974208}
|
||||
.line{border-color:var(--line-2);color:var(--ink)}
|
||||
.line:hover{border-color:var(--ink)}
|
||||
.mark{margin-top:22px;font-family:"Plex Mono",monospace;font-size:.72rem;color:var(--muted);text-align:center}
|
||||
.foot{margin-top:12px;font-size:14px}
|
||||
.foot a{color:var(--accent-ink);font-weight:600;text-decoration:none}
|
||||
.foot a:hover{text-decoration:underline}
|
||||
.mark-b{margin-top:22px;font-size:12.5px;color:var(--muted);text-align:center}
|
||||
.dot{width:7px;height:7px;border-radius:50%;background:var(--accent);animation:pulse 1.8s ease-in-out infinite}
|
||||
@keyframes pulse{50%{opacity:.35}}
|
||||
@media (prefers-reduced-motion: reduce){.dot{animation:none}}
|
||||
</style>
|
||||
@endverbatim
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="plate">
|
||||
<div class="card">
|
||||
<div class="head"><span>{{ __('errors.heading') }}</span><b>{{ $code }}</b></div>
|
||||
<div class="body">
|
||||
<span class="brand"><span class="mark" aria-hidden="true"></span>Clu<i>Pilot</i></span>
|
||||
<h1>{{ $title }}</h1>
|
||||
@if ($body)
|
||||
<p>{{ $body }}</p>
|
||||
|
|
@ -108,7 +112,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mark">CluPilot Cloud</p>
|
||||
<p class="mark-b">CluPilot Cloud</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,103 +1,48 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="apple-touch-icon" href="/favicon.svg">
|
||||
<title>{{ $title ?? __('admin.console') }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@livewireStyles
|
||||
<x-shell.head :title="$title ?? __('admin.console')" />
|
||||
</head>
|
||||
{{-- theme-admin swaps every design token to the dark Tactical-Terminal set. --}}
|
||||
<body class="theme-admin min-h-full bg-bg text-body antialiased" x-data="{ nav: false }">
|
||||
<div class="flex min-h-screen lg:h-screen lg:overflow-hidden">
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 flex w-60 flex-col overflow-y-auto border-r border-line bg-surface px-3 py-4 transition-transform lg:static lg:h-screen lg:translate-x-0"
|
||||
:class="nav ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<div class="flex items-center justify-between px-3 pb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-ui.logo class="size-7" />
|
||||
<span class="font-mono text-lg font-semibold tracking-tight text-ink">CluPilot</span>
|
||||
<span class="rounded bg-accent-subtle px-1.5 py-0.5 font-mono text-[0.6rem] font-semibold uppercase tracking-wider text-accent-text">{{ __('admin.badge') }}</span>
|
||||
</div>
|
||||
<button type="button" class="lg:hidden text-muted" @click="nav = false" aria-label="{{ __('dashboard.close_nav') }}">
|
||||
<x-ui.icon name="chevron-down" class="size-5 rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="space-y-1">
|
||||
@foreach ([
|
||||
['admin.overview', 'gauge', 'overview', null],
|
||||
['admin.customers', 'users', 'customers', null],
|
||||
['admin.instances', 'box', 'instances', null],
|
||||
['admin.hosts', 'server', 'hosts', null],
|
||||
['admin.datacenters', 'database', 'datacenters', null],
|
||||
['admin.plans', 'tag', 'plans', 'plans.manage'],
|
||||
['admin.provisioning', 'activity', 'provisioning', null],
|
||||
['admin.maintenance', 'alert-triangle', 'maintenance', null],
|
||||
['admin.vpn', 'shield', 'vpn', null],
|
||||
['admin.revenue', 'trending-up', 'revenue', null],
|
||||
] as [$route, $icon, $key, $capability])
|
||||
@continue($capability !== null && ! auth()->user()?->can($capability))
|
||||
<x-ui.nav-item :href="route($route)" :active="\App\Support\AdminArea::routeIs($route)">
|
||||
<x-slot:icon><x-ui.icon :name="$icon" /></x-slot:icon>
|
||||
{{ __('admin.nav.'.$key) }}
|
||||
</x-ui.nav-item>
|
||||
@endforeach
|
||||
</nav>
|
||||
<div class="mt-auto space-y-1 border-t border-line pt-4">
|
||||
@can('secrets.manage')
|
||||
{{-- Only where the capability is held: "can open the console"
|
||||
must not mean "can read the payment key". --}}
|
||||
<x-ui.nav-item :href="route('admin.secrets')" :active="\App\Support\AdminArea::routeIs('admin.secrets')">
|
||||
<x-slot:icon><x-ui.icon name="lock" /></x-slot:icon>
|
||||
{{ __('admin.nav.secrets') }}
|
||||
</x-ui.nav-item>
|
||||
@endcan
|
||||
<x-ui.nav-item :href="route('admin.settings')" :active="\App\Support\AdminArea::routeIs('admin.settings')">
|
||||
<x-slot:icon><x-ui.icon name="settings" /></x-slot:icon>
|
||||
{{ __('admin.nav.settings') }}
|
||||
</x-ui.nav-item>
|
||||
{{-- What is actually deployed, so a bug report names the right
|
||||
code. `-dev` means somewhere after the release, not on it. --}}
|
||||
@php $release = App\Services\Deployment\Release::current(); @endphp
|
||||
<p class="px-3 pt-2 font-mono text-[11px] leading-tight text-faint"
|
||||
title="{{ __('admin.version_hint') }}">{{ $release->label() }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
{{--
|
||||
theme-admin no longer swaps the palette. It used to load a complete dark set
|
||||
— its own surfaces, its own orange, its own status triad — which made the
|
||||
console a second product and forced every shared component to work in two
|
||||
worlds. It now carries density only: smaller type, tighter rows, tighter
|
||||
corners. Colour comes from one place.
|
||||
--}}
|
||||
<body class="theme-admin min-h-full bg-bg text-body antialiased" x-data="{ nav: false }" :class="nav && 'overflow-hidden lg:overflow-auto'">
|
||||
@php $release = App\Services\Deployment\Release::current(); @endphp
|
||||
|
||||
<div x-show="nav" x-cloak @click="nav = false" class="fixed inset-0 z-30 bg-black/40 lg:hidden"></div>
|
||||
<div class="flex min-h-screen lg:h-screen lg:overflow-hidden">
|
||||
<x-shell.nav
|
||||
prefix="admin.nav"
|
||||
console
|
||||
:footer="$release->label()"
|
||||
:groups="\App\Support\Navigation::console()"
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col lg:h-screen lg:overflow-hidden">
|
||||
<header class="flex h-14 shrink-0 items-center justify-between border-b border-line bg-surface px-4">
|
||||
<button type="button" class="lg:hidden inline-flex min-h-11 min-w-11 items-center justify-center rounded text-muted hover:bg-surface-hover" @click="nav = true" aria-label="{{ __('dashboard.open_nav') }}">
|
||||
<x-ui.icon name="menu" />
|
||||
</button>
|
||||
<p class="hidden font-mono text-xs text-faint sm:block">{{ __('admin.console') }}</p>
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<x-shell.topbar :crumbs="array_values(array_filter([__('admin.console'), $title ?? \App\Support\Navigation::currentLabel(console: true)]))">
|
||||
@auth
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
|
||||
<button type="button" class="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">
|
||||
<span class="flex size-8 items-center justify-center rounded-pill bg-accent-subtle text-sm font-semibold text-accent-text">{{ \Illuminate\Support\Str::upper(\Illuminate\Support\Str::substr(auth()->user()->name, 0, 1)) }}</span>
|
||||
<span class="hidden text-sm text-body sm:block">{{ auth()->user()->name }}</span>
|
||||
<button type="button" class="flex min-h-11 items-center gap-2.5 rounded px-2 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">
|
||||
<span class="grid size-8 place-items-center rounded-pill bg-accent-subtle text-sm font-bold text-accent-text">{{ \Illuminate\Support\Str::upper(\Illuminate\Support\Str::substr(auth()->user()->name, 0, 1)) }}</span>
|
||||
<span class="hidden text-sm font-medium text-ink sm:block">{{ auth()->user()->name }}</span>
|
||||
<x-ui.icon name="chevron-down" class="size-4 text-muted" />
|
||||
</button>
|
||||
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 mt-1 w-48 rounded-lg border border-line bg-surface py-1 shadow-md">
|
||||
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 mt-1 w-48 overflow-hidden rounded-lg border border-line bg-surface py-1 shadow-md">
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-body hover:bg-surface-hover">
|
||||
<button type="submit" class="flex min-h-11 w-full items-center gap-2.5 px-3.5 text-left text-sm text-body hover:bg-surface-hover">
|
||||
<x-ui.icon name="log-out" class="size-4" />{{ __('dashboard.logout') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endauth
|
||||
</header>
|
||||
</x-shell.topbar>
|
||||
|
||||
<main class="mx-auto w-full max-w-[1240px] flex-1 p-6 lg:overflow-y-auto lg:p-8">
|
||||
<main class="mx-auto w-full max-w-[1320px] flex-1 px-4 py-6 lg:overflow-y-auto lg:px-7 lg:py-7">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -114,13 +59,12 @@
|
|||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 translate-y-12"
|
||||
role="status" aria-live="polite"
|
||||
class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill border border-line bg-surface-2 px-5 py-3 text-sm font-medium text-ink shadow-md"
|
||||
class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill bg-plate px-5 py-3 text-sm font-semibold text-white shadow-md"
|
||||
>
|
||||
<span x-text="msg"></span>
|
||||
</div>
|
||||
|
||||
@livewire('wire-elements-modal')
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="apple-touch-icon" href="/favicon.svg">
|
||||
<title>{{ $title ?? config('app.name') }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@livewireStyles
|
||||
<x-shell.head :title="$title ?? config('app.name')" />
|
||||
</head>
|
||||
<body class="min-h-full bg-bg text-body antialiased" x-data="{ nav: false }">
|
||||
<body class="min-h-full bg-bg text-body antialiased" x-data="{ nav: false }" :class="nav && 'overflow-hidden lg:overflow-auto'">
|
||||
@php
|
||||
$maintenanceWindows = collect();
|
||||
if (auth()->check()) {
|
||||
|
|
@ -21,12 +14,13 @@
|
|||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
@if (auth()->check() && ! ($portalCustomer ?? null))
|
||||
{{-- No customer behind this login (e.g. an operator account): say so, or
|
||||
every customer action on these pages looks like a dead button. --}}
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warning-border bg-warning-bg px-4 py-2 text-center text-sm text-ink">
|
||||
<x-ui.icon name="alert-triangle" class="size-4 shrink-0 text-warning" />
|
||||
<span class="font-medium">{{ __('dashboard.no_customer_title') }}</span>
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warning-border bg-warning-bg px-4 py-2.5 text-center text-sm text-ink">
|
||||
<x-ui.icon name="alert-triangle" class="size-4 text-warning" />
|
||||
<span class="font-semibold">{{ __('dashboard.no_customer_title') }}</span>
|
||||
<span class="text-muted">{{ __('dashboard.no_customer_hint') }}</span>
|
||||
@if (auth()->user()->can('console.view'))
|
||||
<a href="{{ route('admin.customers') }}" class="font-semibold text-accent-text hover:underline">{{ __('dashboard.no_customer_cta') }}</a>
|
||||
|
|
@ -35,54 +29,20 @@
|
|||
@endif
|
||||
|
||||
<div class="flex min-h-screen lg:h-screen lg:overflow-hidden">
|
||||
{{-- Sidebar: fixed drawer on small screens, fixed full-height column on large (R7). --}}
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 flex w-60 flex-col overflow-y-auto border-r border-line bg-surface px-3 py-4 transition-transform lg:static lg:h-screen lg:translate-x-0"
|
||||
:class="nav ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<div class="flex items-center justify-between px-3 pb-4">
|
||||
<span class="flex items-center gap-2">
|
||||
<x-ui.logo class="size-7" />
|
||||
<span class="font-mono text-lg font-semibold tracking-tight text-ink">CluPilot</span>
|
||||
</span>
|
||||
<button type="button" class="lg:hidden text-muted" @click="nav = false" aria-label="{{ __('dashboard.close_nav') }}">
|
||||
<x-ui.icon name="chevron-down" class="size-5 rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="space-y-1">
|
||||
@foreach ([
|
||||
['dashboard', 'gauge', 'overview'],
|
||||
['cloud', 'cloud', 'cloud'],
|
||||
['users', 'users', 'users'],
|
||||
['backups', 'database', 'backups'],
|
||||
['invoices', 'receipt', 'invoices'],
|
||||
['billing', 'box', 'billing'],
|
||||
['settings', 'settings', 'settings'],
|
||||
['support', 'life-buoy', 'support'],
|
||||
] as [$route, $icon, $key])
|
||||
<x-ui.nav-item :href="route($route)" :active="request()->routeIs($route)">
|
||||
<x-slot:icon><x-ui.icon :name="$icon" /></x-slot:icon>
|
||||
{{ __('dashboard.nav.'.$key) }}
|
||||
</x-ui.nav-item>
|
||||
@endforeach
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{{-- Mobile backdrop --}}
|
||||
<div x-show="nav" x-cloak @click="nav = false" class="fixed inset-0 z-30 bg-ink/20 lg:hidden"></div>
|
||||
<x-shell.nav
|
||||
prefix="dashboard.nav"
|
||||
:groups="\App\Support\Navigation::portal()"
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col lg:h-screen lg:overflow-hidden">
|
||||
<header class="flex h-14 shrink-0 items-center justify-between border-b border-line bg-surface px-4">
|
||||
<button type="button" class="lg:hidden inline-flex min-h-11 min-w-11 items-center justify-center rounded text-muted hover:bg-surface-hover" @click="nav = true" aria-label="{{ __('dashboard.open_nav') }}">
|
||||
<x-ui.icon name="menu" />
|
||||
</button>
|
||||
{{-- Impersonation is a MODE, not a notice: it stays visible rather
|
||||
than hiding in a dropdown, because acting as someone else
|
||||
without noticing is how mistakes happen. --}}
|
||||
<x-shell.topbar :crumbs="array_values(array_filter([$portalCustomer?->name, $title ?? \App\Support\Navigation::currentLabel()]))">
|
||||
{{-- Impersonation is a MODE, not a notice: it stays visible
|
||||
rather than hiding in a dropdown, because acting as someone
|
||||
else without noticing is how mistakes happen. --}}
|
||||
@if (session('impersonator_id'))
|
||||
<div class="ml-2 flex min-w-0 items-center gap-2 rounded-pill border border-warning-border bg-warning-bg px-3 py-1">
|
||||
<x-ui.icon name="users" class="size-4 shrink-0 text-warning" />
|
||||
<span class="truncate text-xs font-medium text-ink">{{ __('impersonate.banner', ['name' => auth()->user()->name]) }}</span>
|
||||
<div class="flex min-w-0 items-center gap-2 rounded-pill border border-warning-border bg-warning-bg px-3 py-1">
|
||||
<x-ui.icon name="users" class="size-4 text-warning" />
|
||||
<span class="truncate text-xs font-semibold text-ink">{{ __('impersonate.banner', ['name' => auth()->user()->name]) }}</span>
|
||||
<form method="POST" action="{{ route('impersonate.leave') }}" class="inline shrink-0">
|
||||
@csrf
|
||||
<button type="submit" class="inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-xs font-semibold text-warning hover:underline">
|
||||
|
|
@ -93,28 +53,25 @@
|
|||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{{-- Notices --}}
|
||||
@if ($maintenanceWindows->isNotEmpty())
|
||||
<div class="relative mr-1" x-data="{ open: false }" @keydown.escape="open = false">
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
|
||||
<button type="button" @click="open = !open" :aria-expanded="open" aria-haspopup="menu"
|
||||
class="relative grid size-9 place-items-center rounded-md text-muted transition hover:bg-surface-hover hover:text-ink"
|
||||
class="relative grid size-10 place-items-center rounded text-muted transition hover:bg-surface-hover hover:text-ink"
|
||||
aria-label="{{ __('maintenance.notices') }}">
|
||||
<x-ui.icon name="bell" class="size-5" />
|
||||
<span class="absolute right-1 top-1 grid size-4 place-items-center rounded-pill bg-warning text-[10px] font-bold text-white">{{ $maintenanceWindows->count() }}</span>
|
||||
<x-ui.icon name="bell" />
|
||||
<span class="absolute right-1.5 top-1.5 grid size-4 place-items-center rounded-pill bg-accent text-[10px] font-bold text-white ring-2 ring-surface">{{ $maintenanceWindows->count() }}</span>
|
||||
</button>
|
||||
|
||||
<div x-show="open" x-cloak @click.outside="open = false"
|
||||
class="absolute right-0 z-50 mt-1 w-80 overflow-hidden rounded-lg border border-line bg-surface shadow-md">
|
||||
<p class="border-b border-line px-4 py-2.5 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('maintenance.notices') }}</p>
|
||||
<p class="border-b border-line px-4 py-3 text-sm font-semibold text-ink">{{ __('maintenance.notices') }}</p>
|
||||
<div class="max-h-80 divide-y divide-line overflow-y-auto">
|
||||
@foreach ($maintenanceWindows as $mw)
|
||||
@php $isActive = now()->betweenIncluded($mw->starts_at, $mw->ends_at); @endphp
|
||||
<div class="flex gap-2.5 px-4 py-3">
|
||||
<x-ui.icon name="alert-triangle" class="mt-0.5 size-4 shrink-0 {{ $isActive ? 'text-warning' : 'text-info' }}" />
|
||||
<x-ui.icon name="alert-triangle" class="mt-0.5 size-4 {{ $isActive ? 'text-warning' : 'text-info' }}" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-ink">{{ $mw->title }}</p>
|
||||
<p class="text-sm font-semibold text-ink">{{ $mw->title }}</p>
|
||||
<p class="mt-0.5 text-xs leading-relaxed text-muted">
|
||||
{{ $isActive
|
||||
? __('maintenance.banner_active', ['end' => $mw->ends_at->isoFormat('LT')])
|
||||
|
|
@ -130,15 +87,15 @@
|
|||
|
||||
@auth
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
|
||||
<button type="button" class="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">
|
||||
<span class="flex size-8 items-center justify-center rounded-pill bg-accent-subtle text-sm font-semibold text-accent-text">{{ \Illuminate\Support\Str::upper(\Illuminate\Support\Str::substr(auth()->user()->name, 0, 1)) }}</span>
|
||||
<span class="hidden text-sm text-body sm:block">{{ auth()->user()->name }}</span>
|
||||
<button type="button" class="flex min-h-11 items-center gap-2.5 rounded px-2 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">
|
||||
<span class="grid size-8 place-items-center rounded-pill bg-accent-subtle text-sm font-bold text-accent-text">{{ \Illuminate\Support\Str::upper(\Illuminate\Support\Str::substr(auth()->user()->name, 0, 1)) }}</span>
|
||||
<span class="hidden text-sm font-medium text-ink sm:block">{{ auth()->user()->name }}</span>
|
||||
<x-ui.icon name="chevron-down" class="size-4 text-muted" />
|
||||
</button>
|
||||
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 mt-1 w-48 rounded-lg border border-line bg-surface py-1 shadow-md">
|
||||
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 mt-1 w-48 overflow-hidden rounded-lg border border-line bg-surface py-1 shadow-md">
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-body hover:bg-surface-hover">
|
||||
<button type="submit" class="flex min-h-11 w-full items-center gap-2.5 px-3.5 text-left text-sm text-body hover:bg-surface-hover">
|
||||
<x-ui.icon name="log-out" class="size-4" />
|
||||
{{ __('dashboard.logout') }}
|
||||
</button>
|
||||
|
|
@ -146,9 +103,9 @@
|
|||
</div>
|
||||
</div>
|
||||
@endauth
|
||||
</header>
|
||||
</x-shell.topbar>
|
||||
|
||||
<main class="mx-auto w-full max-w-[1200px] flex-1 p-6 lg:overflow-y-auto lg:p-8">
|
||||
<main class="mx-auto w-full max-w-[1240px] flex-1 px-4 py-6 lg:overflow-y-auto lg:px-8 lg:py-8">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -168,7 +125,7 @@
|
|||
x-transition:leave-end="opacity-0 translate-y-12"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill bg-ink px-5 py-3 text-sm font-medium text-on-accent shadow-md"
|
||||
class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill bg-plate px-5 py-3 text-sm font-semibold text-white shadow-md"
|
||||
>
|
||||
<span x-text="msg"></span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div class="rounded-xl bg-surface p-6">
|
||||
<div class="rounded-lg bg-surface p-6">
|
||||
@if ($hostCount > 0)
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div class="rounded-xl bg-surface p-6">
|
||||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-danger-bg text-danger">
|
||||
<x-ui.icon name="alert-triangle" class="size-5" />
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
<div class="space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('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>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_300px] lg:items-start">
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.col.plan') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.col.mrr') }}</th>
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin.by_plan') }}</h2>
|
||||
<div class="mt-3 h-56" wire:ignore><x-ui.chart :config="$plansChart" class="h-56" :label="__('admin.by_plan')" /></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
<div class="space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('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>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
|
||||
{{-- List --}}
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
@if ($datacenters->isEmpty())
|
||||
<p class="p-8 text-center text-sm text-muted">{{ __('datacenters.empty') }}</p>
|
||||
@else
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('datacenters.code') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('datacenters.name') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('datacenters.hosts') }}</th>
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
<tr wire:key="dc-{{ $dc->uuid }}" class="border-b border-line last:border-0">
|
||||
<td class="px-4 py-3 font-mono font-semibold text-ink">{{ $dc->code }}</td>
|
||||
<td class="px-4 py-3 text-body">{{ $dc->name }}
|
||||
@if ($dc->location)<span class="text-xs text-faint">· {{ $countries[$dc->location] ?? $dc->location }}</span>@endif
|
||||
@if ($dc->location)<span class="text-xs text-muted">· {{ $countries[$dc->location] ?? $dc->location }}</span>@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $dc->hosts_count }}</td>
|
||||
<td class="px-4 py-3">
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
</div>
|
||||
|
||||
{{-- Add form --}}
|
||||
<form wire:submit="save" class="space-y-4 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<form wire:submit="save" class="space-y-4 rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('datacenters.add') }}</h2>
|
||||
<x-ui.input name="code" wire:model="code" :label="__('datacenters.code')" :hint="__('datacenters.code_hint')" />
|
||||
<x-ui.input name="name" wire:model="name" :label="__('datacenters.name')" placeholder="Falkenstein" />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div class="rounded-xl bg-surface p-6">
|
||||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('datacenters.edit_title') }}</h3>
|
||||
<span class="rounded bg-surface-2 px-2 py-0.5 font-mono text-xs text-muted">{{ $code }}</span>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
<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-semibold tracking-tight text-ink">{{ __('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>
|
||||
|
||||
<form wire:submit="save" class="space-y-5 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<form wire:submit="save" class="space-y-5 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<x-ui.input name="name" wire:model="name" :label="__('hosts.field.name')" :hint="__('hosts.field.name_hint')" autofocus />
|
||||
|
||||
<div class="space-y-1.5">
|
||||
|
|
|
|||
|
|
@ -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-semibold tracking-tight text-ink">{{ $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>
|
||||
|
|
@ -41,8 +41,8 @@
|
|||
@endphp
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3 animate-rise">
|
||||
{{-- Health --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('hosts.detail.health') }}</p>
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs">
|
||||
<p class="text-xs font-semibold text-muted">{{ __('hosts.detail.health') }}</p>
|
||||
<p class="mt-2 flex items-center gap-2 text-lg font-semibold {{ $htone }}">
|
||||
<span class="size-2.5 rounded-pill {{ $hdot }} {{ $health === 'online' ? 'animate-pulse' : '' }}" aria-hidden="true"></span>
|
||||
{{ __('hosts.health.'.$health) }}
|
||||
|
|
@ -53,8 +53,8 @@
|
|||
</div>
|
||||
|
||||
{{-- Storage capacity --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('hosts.detail.storage') }}</p>
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs">
|
||||
<p class="text-xs font-semibold text-muted">{{ __('hosts.detail.storage') }}</p>
|
||||
@if ($host->total_gb)
|
||||
<p class="mt-2 font-mono text-lg font-semibold text-ink">{{ $host->availableGb() }} <span class="text-sm font-normal text-muted">/ {{ $host->freeGb() }} GB {{ __('hosts.free') }}</span></p>
|
||||
<div class="mt-2 h-2 overflow-hidden rounded-pill bg-surface-2">
|
||||
|
|
@ -73,8 +73,8 @@
|
|||
</div>
|
||||
|
||||
{{-- Compute --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('hosts.detail.compute') }}</p>
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs">
|
||||
<p class="text-xs font-semibold text-muted">{{ __('hosts.detail.compute') }}</p>
|
||||
<div class="mt-2 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p class="font-mono text-lg font-semibold text-ink">{{ $host->cpu_cores ?? '—' }}</p>
|
||||
|
|
@ -96,7 +96,7 @@
|
|||
['meta.version', $host->pve_version ?? __('hosts.unknown')],
|
||||
['detail.instances', (string) $instances->count()],
|
||||
] as [$key, $value])
|
||||
<div class="rounded-xl border border-line bg-surface p-4 shadow-xs">
|
||||
<div class="rounded-lg border border-line bg-surface p-4 shadow-xs">
|
||||
<p class="text-xs text-muted">{{ __('hosts.'.$key) }}</p>
|
||||
<p class="mt-1 truncate font-mono text-sm font-semibold text-ink">{{ $value }}</p>
|
||||
</div>
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
</div>
|
||||
|
||||
{{-- Hosted instances --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:100ms]">
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:100ms]">
|
||||
<h2 class="mb-3 text-sm font-semibold text-ink">{{ __('hosts.detail.hosted') }}</h2>
|
||||
@if ($instances->isEmpty())
|
||||
<p class="text-sm text-muted">{{ __('hosts.detail.no_instances') }}</p>
|
||||
|
|
@ -128,7 +128,7 @@
|
|||
</div>
|
||||
|
||||
@if ($run && $run->status === 'failed')
|
||||
<div class="flex items-start gap-3 rounded-xl border border-danger-border bg-danger-bg p-4 animate-rise">
|
||||
<div class="flex items-start gap-3 rounded-lg border border-danger-border bg-danger-bg p-4 animate-rise">
|
||||
<x-ui.icon name="alert-triangle" class="size-5 shrink-0 text-danger" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-danger">{{ __('hosts.error_title') }}</p>
|
||||
|
|
@ -138,7 +138,7 @@
|
|||
@endif
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-5">
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise lg:col-span-3">
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise lg:col-span-3">
|
||||
<h2 class="mb-4 text-sm font-semibold text-ink">{{ __('hosts.progress') }}</h2>
|
||||
@if ($run)
|
||||
<x-ui.progress-stepper :steps="$steps" />
|
||||
|
|
@ -147,7 +147,7 @@
|
|||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise lg:col-span-2">
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise lg:col-span-2">
|
||||
<h2 class="mb-4 text-sm font-semibold text-ink">{{ __('hosts.events') }}</h2>
|
||||
@if ($events->isEmpty())
|
||||
<p class="text-sm text-muted">{{ __('hosts.no_run') }}</p>
|
||||
|
|
@ -161,7 +161,7 @@
|
|||
<span class="mt-1.5 size-1.5 shrink-0 rounded-pill bg-current {{ $tone }}" aria-hidden="true"></span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-body">{{ __('hosts.step.'.$event->step) }}</p>
|
||||
<p class="text-xs text-faint">
|
||||
<p class="text-xs text-muted">
|
||||
{{ __('hosts.outcome.'.$event->outcome) }}
|
||||
· {{ __('hosts.attempt') }} {{ $event->attempt }}
|
||||
@if ($event->message) · <span class="break-words">{{ $event->message }}</span> @endif
|
||||
|
|
|
|||
|
|
@ -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-semibold tracking-tight text-ink">{{ __('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>
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
<div class="flex flex-wrap items-center gap-2 animate-rise [animation-delay:40ms]">
|
||||
<div class="relative min-w-48 flex-1">
|
||||
<input type="search" wire:model.live.debounce.300ms="search" placeholder="{{ __('hosts.search_placeholder') }}"
|
||||
class="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint" />
|
||||
class="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-muted" />
|
||||
</div>
|
||||
<select wire:model.live="datacenter" class="rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
|
||||
<option value="">{{ __('hosts.all_datacenters') }}</option>
|
||||
|
|
@ -34,16 +34,16 @@
|
|||
</div>
|
||||
|
||||
@if ($hosts->isEmpty())
|
||||
<div class="rounded-xl border border-dashed border-line-strong bg-surface p-10 text-center animate-rise">
|
||||
<div class="rounded-lg border border-dashed border-line-strong bg-surface p-10 text-center animate-rise">
|
||||
<span class="mx-auto grid size-12 place-items-center rounded-lg bg-surface-2 text-muted"><x-ui.icon name="server" class="size-6" /></span>
|
||||
<p class="mt-3 text-sm text-muted">{{ $total === 0 ? __('hosts.empty') : __('hosts.no_matches') }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:80ms]">
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:80ms]">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.host') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.datacenter') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.health') }}</th>
|
||||
|
|
@ -86,7 +86,7 @@
|
|||
<span class="font-mono text-xs text-muted">{{ $host->availableGb() }} / {{ $host->freeGb() }} GB</span>
|
||||
</div>
|
||||
@else
|
||||
<span class="text-xs text-faint">{{ __('hosts.unknown') }}</span>
|
||||
<span class="text-xs text-muted">{{ __('hosts.unknown') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3"><x-ui.badge :status="$badge">{{ __('hosts.status.'.$host->status) }}</x-ui.badge></td>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="mt-3 text-xs text-faint">{{ __('instances.admin_once') }}</p>
|
||||
<p class="mt-3 text-xs text-muted">{{ __('instances.admin_once') }}</p>
|
||||
|
||||
<div class="mt-5 flex justify-end">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.close') }}</x-ui.button>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
<div class="space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('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>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.col.address') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.col.host') }}</th>
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
<td class="px-4 py-3"><x-ui.badge :status="$r['status']">{{ $r['status_label'] }}</x-ui.badge></td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-sm text-faint">{{ __('admin.instances_empty') }}</td></tr>
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-sm text-muted">{{ __('admin.instances_empty') }}</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
<div class="space-y-6">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('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>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_420px] lg:items-start">
|
||||
{{-- Windows list --}}
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
@if ($windows->isEmpty())
|
||||
<p class="p-8 text-center text-sm text-muted">{{ __('maintenance.empty') }}</p>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('maintenance.col_window') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('maintenance.col_when') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('maintenance.col_impact') }}</th>
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
<td class="px-4 py-3 text-xs text-body">{{ $w['starts_at']->isoFormat('DD.MM. HH:mm') }} – {{ $w['ends_at']->isoFormat('HH:mm') }}</td>
|
||||
<td class="px-4 py-3 text-xs text-muted whitespace-nowrap">
|
||||
{{ trans_choice('maintenance.impact_hosts', $w['hosts'], ['count' => $w['hosts']]) }}
|
||||
<span class="text-faint">·</span>
|
||||
<span class="text-muted">·</span>
|
||||
{{ trans_choice('maintenance.impact_customers', $w['affected'], ['count' => $w['affected']]) }}
|
||||
</td>
|
||||
<td class="px-4 py-3"><x-ui.badge :status="$tone">{{ __('maintenance.state_'.$w['state']) }}</x-ui.badge></td>
|
||||
|
|
@ -69,7 +69,7 @@
|
|||
</div>
|
||||
|
||||
{{-- Create form --}}
|
||||
<form wire:submit="publish" class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<form wire:submit="publish" class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div class="border-b border-line bg-surface-2 px-5 py-3.5">
|
||||
<h2 class="font-semibold text-ink">{{ __('maintenance.new_title') }}</h2>
|
||||
<p class="mt-0.5 text-xs text-muted">{{ __('maintenance.new_sub') }}</p>
|
||||
|
|
@ -81,14 +81,14 @@
|
|||
<div>
|
||||
<label class="text-sm font-medium text-body" for="publicDescription">{{ __('maintenance.field_public') }}</label>
|
||||
<textarea id="publicDescription" wire:model="publicDescription" rows="2" placeholder="{{ __('maintenance.field_public_ph') }}"
|
||||
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"></textarea>
|
||||
<p class="mt-1 text-xs text-faint">{{ __('maintenance.field_public_hint') }}</p>
|
||||
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"></textarea>
|
||||
<p class="mt-1 text-xs text-muted">{{ __('maintenance.field_public_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium text-body" for="internalNotes">{{ __('maintenance.field_internal') }}</label>
|
||||
<textarea id="internalNotes" wire:model="internalNotes" rows="2" placeholder="{{ __('maintenance.field_internal_ph') }}"
|
||||
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"></textarea>
|
||||
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
<div>
|
||||
<label class="text-sm font-medium text-body" for="{{ $field }}">{{ __('maintenance.'.$key) }}</label>
|
||||
<div class="relative mt-1.5">
|
||||
<x-ui.icon name="calendar" class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-faint" />
|
||||
<x-ui.icon name="calendar" class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" />
|
||||
<input id="{{ $field }}" type="datetime-local" wire:model.live="{{ $field }}"
|
||||
class="w-full rounded-md border border-line-strong bg-surface py-2 pl-9 pr-3 text-sm text-ink focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 [color-scheme:dark]" />
|
||||
</div>
|
||||
|
|
@ -109,7 +109,7 @@
|
|||
{{-- Typing an end time by hand is the fiddliest part of this form;
|
||||
these set it from the start time. --}}
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="text-xs text-faint">{{ __('maintenance.duration') }}</span>
|
||||
<span class="text-xs text-muted">{{ __('maintenance.duration') }}</span>
|
||||
@foreach ([30, 60, 120, 240] as $minutes)
|
||||
<button type="button" wire:click="setDuration({{ $minutes }})"
|
||||
class="rounded-pill border px-2.5 py-0.5 text-xs font-medium transition
|
||||
|
|
@ -127,7 +127,7 @@
|
|||
<div>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<label class="text-sm font-medium text-body">{{ __('maintenance.field_hosts') }}</label>
|
||||
<span class="font-mono text-xs {{ count($hostIds) > 0 ? 'text-accent-text' : 'text-faint' }}">{{ __('maintenance.selected', ['n' => count($hostIds)]) }}</span>
|
||||
<span class="font-mono text-xs {{ count($hostIds) > 0 ? 'text-accent-text' : 'text-muted' }}">{{ __('maintenance.selected', ['n' => count($hostIds)]) }}</span>
|
||||
</div>
|
||||
@error('hostIds')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
<div class="mt-1.5 max-h-64 divide-y divide-line overflow-y-auto rounded-lg border border-line-strong bg-surface-2">
|
||||
|
|
@ -139,7 +139,7 @@
|
|||
@if ($dcHosts->isNotEmpty())
|
||||
<div class="p-2.5">
|
||||
<div class="flex items-center justify-between px-1.5 pb-1.5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
<p class="text-xs font-semibold text-muted">
|
||||
{{ $dc->name }}
|
||||
@if ($dcSelected)
|
||||
<span class="ml-1 font-mono text-accent-text">{{ $dcSelected }}/{{ $dcHosts->count() }}</span>
|
||||
|
|
@ -158,7 +158,7 @@
|
|||
<input type="checkbox" wire:model.live="hostIds" value="{{ $host->id }}"
|
||||
class="size-4 rounded border-line-strong bg-surface text-accent-active focus:ring-2 focus:ring-accent/30" />
|
||||
<span class="font-mono">{{ $host->name }}</span>
|
||||
<span class="ml-auto font-mono text-xs {{ $checked ? 'text-accent-text' : 'text-faint' }}">{{ $host->public_ip }}</span>
|
||||
<span class="ml-auto font-mono text-xs {{ $checked ? 'text-accent-text' : 'text-muted' }}">{{ $host->public_ip }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
|
|
@ -170,7 +170,7 @@
|
|||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3.5">
|
||||
<p class="mr-auto max-w-[14rem] text-xs text-faint">{{ __('maintenance.mail_note') }}</p>
|
||||
<p class="mr-auto max-w-[14rem] text-xs text-muted">{{ __('maintenance.mail_note') }}</p>
|
||||
<x-ui.button variant="secondary" type="button" size="sm" wire:click="saveDraft" wire:loading.attr="disabled" wire:target="saveDraft">{{ __('maintenance.save_draft') }}</x-ui.button>
|
||||
<x-ui.button variant="primary" type="submit" size="sm" wire:loading.attr="disabled" wire:target="publish">{{ __('maintenance.publish_notify') }}</x-ui.button>
|
||||
</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-semibold tracking-tight text-ink">{{ __('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)
|
||||
|
|
@ -19,8 +19,8 @@
|
|||
@php $kd = ['[animation-delay:40ms]', '[animation-delay:80ms]', '[animation-delay:120ms]', '[animation-delay:160ms]']; @endphp
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@foreach ($kpis as $i => $kpi)
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise {{ $kd[$i] ?? '' }}">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $kpi['label'] }}</p>
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise {{ $kd[$i] ?? '' }}">
|
||||
<p class="text-xs font-semibold text-muted">{{ $kpi['label'] }}</p>
|
||||
<p class="mt-2 font-mono text-2xl font-semibold text-ink">{{ $kpi['value'] }}</p>
|
||||
@if (($kpi['sub'] ?? null) !== null)
|
||||
<p class="mt-1 text-xs text-muted">{{ $kpi['sub'] }}</p>
|
||||
|
|
@ -31,18 +31,18 @@
|
|||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
{{-- New instances per month --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] lg:col-span-2">
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] lg:col-span-2">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin.new_instances') }}</h2>
|
||||
<p class="text-sm text-muted">{{ __('admin.new_instances_sub') }}</p>
|
||||
@if ($newInstances['empty'])
|
||||
<p class="mt-8 text-center text-sm text-faint">{{ __('admin.no_data_yet') }}</p>
|
||||
<p class="mt-8 text-center text-sm text-muted">{{ __('admin.no_data_yet') }}</p>
|
||||
@else
|
||||
<div class="mt-4 h-56" wire:ignore><x-ui.chart :config="$newInstances['config']" class="h-56" :label="__('admin.new_instances')" /></div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Committed storage per host --}}
|
||||
<div class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div class="flex flex-col rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin.host_load') }}</h2>
|
||||
<p class="text-sm text-muted">{{ __('admin.host_load_sub') }}</p>
|
||||
<ul class="mt-4 flex-1 space-y-3.5">
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
</div>
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-sm text-faint">{{ __('admin.no_hosts_yet') }}</li>
|
||||
<li class="text-sm text-muted">{{ __('admin.no_hosts_yet') }}</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -66,7 +66,7 @@
|
|||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{{-- Open provisioning runs --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="flex items-center">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin.active_runs') }}</h2>
|
||||
<a href="{{ route('admin.provisioning') }}" class="ml-auto text-xs font-semibold text-accent-text hover:underline">{{ __('admin.view_all') }} ›</a>
|
||||
|
|
@ -82,13 +82,13 @@
|
|||
<x-ui.badge :status="$r['status'] === 'failed' ? 'error' : 'provisioning'">{{ __('admin.run_status.'.$r['status']) }}</x-ui.badge>
|
||||
</li>
|
||||
@empty
|
||||
<li class="py-3 text-sm text-faint">{{ __('admin.no_open_runs') }}</li>
|
||||
<li class="py-3 text-sm text-muted">{{ __('admin.no_open_runs') }}</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{{-- Notices --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin.alerts') }}</h2>
|
||||
<ul class="mt-2 space-y-2">
|
||||
@forelse ($notices as $a)
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
<span class="text-body">{{ $a['text'] }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="py-1 text-sm text-faint">{{ __('admin.no_notices') }}</li>
|
||||
<li class="py-1 text-sm text-muted">{{ __('admin.no_notices') }}</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<x-ui.icon name="arrow-left" class="size-3.5" />
|
||||
{{ __('plans.back') }}
|
||||
</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold tracking-tight text-ink">{{ $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>
|
||||
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
$live = $windowOpen && $family->sales_enabled;
|
||||
$scheduled = $version->isPublished() && $version->available_from->greaterThan($now);
|
||||
@endphp
|
||||
<div wire:key="v-{{ $version->uuid }}" class="rounded-xl border border-line bg-surface p-5 shadow-xs
|
||||
<div wire:key="v-{{ $version->uuid }}" class="rounded-lg border border-line bg-surface p-5 shadow-xs
|
||||
{{ $live ? 'border-success-border' : '' }}">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
|
|
@ -105,7 +105,7 @@
|
|||
['plans.f_template', $version->template_vmid ?? '—'],
|
||||
] as [$label, $value])
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-faint">{{ __($label) }}</dt>
|
||||
<dt class="text-xs font-semibold text-muted">{{ __($label) }}</dt>
|
||||
<dd class="text-body">{{ $value }}</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
|
|
@ -114,9 +114,9 @@
|
|||
<div class="mt-4 flex flex-wrap gap-4 border-t border-line pt-3 text-sm">
|
||||
@foreach ($version->prices->sortBy('term') as $price)
|
||||
<span class="text-body">
|
||||
<span class="text-xs uppercase tracking-wide text-faint">{{ __('plans.term_'.$price->term) }}</span>
|
||||
<span class="text-xs font-semibold text-muted">{{ __('plans.term_'.$price->term) }}</span>
|
||||
<span class="ml-1.5 font-semibold text-ink">{{ $eur($price->amount_cents) }}</span>
|
||||
<span class="text-xs text-faint">{{ __('plans.net') }}</span>
|
||||
<span class="text-xs text-muted">{{ __('plans.net') }}</span>
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
|
|
@ -132,14 +132,14 @@
|
|||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<p class="rounded-xl border border-line bg-surface p-8 text-center text-sm text-muted shadow-xs">
|
||||
<p class="rounded-lg border border-line bg-surface p-8 text-center text-sm text-muted shadow-xs">
|
||||
{{ __('plans.no_versions') }}
|
||||
</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- New draft --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<h2 class="text-base font-semibold text-ink">{{ __('plans.draft_title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('plans.draft_body') }}</p>
|
||||
|
||||
|
|
@ -162,7 +162,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<p class="pt-1 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('plans.infra') }}</p>
|
||||
<p class="pt-1 text-xs font-semibold text-muted">{{ __('plans.infra') }}</p>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<x-ui.input name="ramMb" type="number" min="512" max="4194304" :label="__('plans.f_ram')" wire:model="ramMb" />
|
||||
<x-ui.input name="cores" type="number" min="1" max="512" :label="__('plans.f_cores')" wire:model="cores" />
|
||||
|
|
@ -170,13 +170,13 @@
|
|||
<x-ui.input name="templateVmid" type="number" min="100" max="999999999" :label="__('plans.f_template')" wire:model="templateVmid" />
|
||||
</div>
|
||||
|
||||
<p class="pt-1 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('plans.pricing') }}</p>
|
||||
<p class="pt-1 text-xs font-semibold text-muted">{{ __('plans.pricing') }}</p>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<x-ui.input name="monthlyPrice" inputmode="decimal" :label="__('plans.term_monthly')" :hint="__('plans.euro_hint')" wire:model="monthlyPrice" />
|
||||
<x-ui.input name="yearlyPrice" inputmode="decimal" :label="__('plans.term_yearly')" :hint="__('plans.euro_hint')" wire:model="yearlyPrice" />
|
||||
</div>
|
||||
|
||||
<p class="pt-1 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('plans.features') }}</p>
|
||||
<p class="pt-1 text-xs font-semibold text-muted">{{ __('plans.features') }}</p>
|
||||
<div class="space-y-2">
|
||||
@foreach ($featureKeys as $key)
|
||||
<div wire:key="feat-{{ $key }}">
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
<div class="space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('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>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
|
||||
{{-- Plan lines --}}
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
@if ($families->isEmpty())
|
||||
<p class="p-8 text-center text-sm text-muted">{{ __('plans.empty') }}</p>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('plans.plan') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('plans.tier') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('plans.live_version') }}</th>
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
<tr wire:key="plan-{{ $family['uuid'] }}" class="border-b border-line last:border-0">
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-semibold text-ink">{{ $family['name'] }}</span>
|
||||
<span class="ml-1.5 font-mono text-xs text-faint">{{ $family['key'] }}</span>
|
||||
<span class="ml-1.5 font-mono text-xs text-muted">{{ $family['key'] }}</span>
|
||||
@if ($family['drafts'] > 0)
|
||||
<span class="ml-2 rounded-pill border border-line-strong bg-surface-2 px-2 py-0.5 text-xs text-muted">
|
||||
{{ trans_choice('plans.drafts', $family['drafts'], ['count' => $family['drafts']]) }}
|
||||
|
|
@ -38,12 +38,12 @@
|
|||
@if ($family['live'])
|
||||
v{{ $family['live']->version }}
|
||||
@if ($family['next'])
|
||||
<span class="block text-xs text-faint">
|
||||
<span class="block text-xs text-muted">
|
||||
{{ __('plans.next_from', ['version' => $family['next']->version, 'date' => $family['next']->available_from->isoFormat('L')]) }}
|
||||
</span>
|
||||
@endif
|
||||
@else
|
||||
<span class="text-faint">{{ __('plans.no_live_version') }}</span>
|
||||
<span class="text-muted">{{ __('plans.no_live_version') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
|
|
@ -71,7 +71,7 @@
|
|||
</div>
|
||||
|
||||
{{-- New plan line --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<h2 class="text-base font-semibold text-ink">{{ __('plans.new_title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('plans.new_body') }}</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
<div class="space-y-5" @if ($hasActive) wire:poll.4s @endif>
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('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>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_300px] lg:items-start">
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
@if (count($rows) === 0)
|
||||
<p class="p-8 text-center text-sm text-muted">{{ __('admin.no_runs') }}</p>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.col.step') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.col.attempt') }}</th>
|
||||
|
|
@ -25,17 +25,17 @@
|
|||
@php $tone = ['running' => 'provisioning', 'done' => 'active', 'failed' => 'failed'][$r['state']] ?? 'info'; @endphp
|
||||
<tr wire:key="run-{{ $r['uuid'] }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
|
||||
<td class="px-4 py-3 font-medium text-ink">{{ $r['customer'] }}
|
||||
<span class="ml-1 font-mono text-xs text-faint">{{ $r['pipeline'] }}</span></td>
|
||||
<span class="ml-1 font-mono text-xs text-muted">{{ $r['pipeline'] }}</span></td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="text-xs text-body">{{ $r['step'] }}</span>
|
||||
<span class="font-mono text-xs text-faint">{{ $r['n'] }}</span>
|
||||
<span class="font-mono text-xs text-muted">{{ $r['n'] }}</span>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<div class="h-1 w-24 overflow-hidden rounded-pill bg-surface-2">
|
||||
<div class="h-full rounded-pill {{ $r['failed'] ? 'bg-danger' : ($r['stale'] ? 'bg-warning' : 'bg-accent') }} transition-[width] duration-500" style="width: {{ $r['percent'] }}%"></div>
|
||||
</div>
|
||||
<span class="font-mono text-[0.65rem] {{ $r['stale'] ? 'text-warning' : 'text-faint' }}">{{ $r['activity'] }}</span>
|
||||
<span class="font-mono text-[0.65rem] {{ $r['stale'] ? 'text-warning' : 'text-muted' }}">{{ $r['activity'] }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['attempt'] }}</td>
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
<x-ui.icon name="rotate-ccw" class="size-3.5" />{{ __('admin.retry') }}
|
||||
</button>
|
||||
@else
|
||||
<span class="text-xs text-faint">—</span>
|
||||
<span class="text-xs text-muted">—</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -60,9 +60,9 @@
|
|||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-faint">{{ __('admin.run_detail') }}</h2>
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted">{{ __('admin.run_detail') }}</h2>
|
||||
@if ($panel)
|
||||
<span class="font-mono text-xs {{ $panel['failed'] ? 'text-danger' : 'text-muted' }}">{{ $panel['percent'] }}%</span>
|
||||
@endif
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
<p class="mt-4 text-sm text-muted">{{ __('admin.no_runs') }}</p>
|
||||
@else
|
||||
<p class="mt-2 truncate font-semibold text-ink">{{ $panel['subject'] }}</p>
|
||||
<p class="font-mono text-xs text-faint">{{ $panel['pipeline'] }} · {{ __('admin.run_attempt', ['n' => $panel['attempt']]) }}</p>
|
||||
<p class="font-mono text-xs text-muted">{{ $panel['pipeline'] }} · {{ __('admin.run_attempt', ['n' => $panel['attempt']]) }}</p>
|
||||
|
||||
{{-- Progress bar (width is the one allowed inline style, R4) --}}
|
||||
<div class="mt-4 h-1.5 overflow-hidden rounded-pill bg-surface-2">
|
||||
|
|
@ -83,9 +83,9 @@
|
|||
|
||||
<dl class="mt-3 space-y-1 text-xs">
|
||||
@if ($panel['started'])
|
||||
<div class="flex justify-between"><dt class="text-faint">{{ __('admin.run_started') }}</dt><dd class="font-mono text-muted">{{ $panel['started'] }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-muted">{{ __('admin.run_started') }}</dt><dd class="font-mono text-muted">{{ $panel['started'] }}</dd></div>
|
||||
@endif
|
||||
<div class="flex justify-between"><dt class="text-faint">{{ __('admin.run_activity') }}</dt><dd class="font-mono {{ $panel['stale'] ? 'text-warning' : 'text-muted' }}">{{ $panel['activity'] }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-muted">{{ __('admin.run_activity') }}</dt><dd class="font-mono {{ $panel['stale'] ? 'text-warning' : 'text-muted' }}">{{ $panel['activity'] }}</dd></div>
|
||||
</dl>
|
||||
|
||||
@if ($panel['stale'] && ! $panel['failed'])
|
||||
|
|
@ -106,7 +106,7 @@
|
|||
</div>
|
||||
@elseif ($panel['current'])
|
||||
<div class="mt-4">
|
||||
<p class="text-[0.65rem] font-semibold uppercase tracking-wider text-faint">{{ __('admin.run_current') }}</p>
|
||||
<p class="text-[0.65rem] font-semibold uppercase tracking-wider text-muted">{{ __('admin.run_current') }}</p>
|
||||
<div class="mt-1.5 flex items-center gap-2.5">
|
||||
<span class="size-2 shrink-0 rounded-pill bg-info motion-safe:animate-pulse" aria-hidden="true"></span>
|
||||
<span class="text-sm font-medium text-ink">{{ $panel['current'] }}</span>
|
||||
|
|
@ -116,7 +116,7 @@
|
|||
|
||||
@if ($panel['next'] && ! $panel['failed'])
|
||||
<div class="mt-4">
|
||||
<p class="text-[0.65rem] font-semibold uppercase tracking-wider text-faint">{{ __('admin.run_next') }}</p>
|
||||
<p class="text-[0.65rem] font-semibold uppercase tracking-wider text-muted">{{ __('admin.run_next') }}</p>
|
||||
<div class="mt-1.5 flex items-center gap-2.5">
|
||||
<span class="size-2 shrink-0 rounded-pill border border-line-strong" aria-hidden="true"></span>
|
||||
<span class="text-sm text-muted">{{ $panel['next'] }}</span>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
<div class="space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('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>
|
||||
|
||||
@php $rd = ['[animation-delay:40ms]', '[animation-delay:80ms]', '[animation-delay:120ms]', '[animation-delay:160ms]']; @endphp
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@foreach ($kpis as $i => $kpi)
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise {{ $rd[$i] ?? '' }}">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $kpi['label'] }}</p>
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise {{ $rd[$i] ?? '' }}">
|
||||
<p class="text-xs font-semibold text-muted">{{ $kpi['label'] }}</p>
|
||||
<p class="mt-2 font-mono text-2xl font-semibold text-ink">{{ $kpi['value'] }}</p>
|
||||
<p class="mt-1 text-xs text-muted">{{ $kpi['sub'] }}</p>
|
||||
</div>
|
||||
|
|
@ -16,32 +16,32 @@
|
|||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin.mrr_by_plan') }}</h2>
|
||||
@forelse ($planCharts as $chart)
|
||||
{{-- One ring per currency: a doughnut sums its slices, and a
|
||||
total mixing euros with francs is not an amount. --}}
|
||||
@if (count($planCharts) > 1)
|
||||
<p class="mt-3 font-mono text-xs uppercase tracking-wide text-faint">{{ $chart['currency'] }}</p>
|
||||
<p class="mt-3 font-mono text-xs font-semibold text-muted">{{ $chart['currency'] }}</p>
|
||||
@endif
|
||||
<div class="mt-3 h-56" wire:ignore><x-ui.chart :config="$chart['config']" class="h-56" :label="__('admin.mrr_by_plan').' — '.$chart['currency']" /></div>
|
||||
@empty
|
||||
<p class="mt-8 text-center text-sm text-faint">{{ __('admin.no_data_yet') }}</p>
|
||||
<p class="mt-8 text-center text-sm text-muted">{{ __('admin.no_data_yet') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms] lg:col-span-2">
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms] lg:col-span-2">
|
||||
<div class="border-b border-line px-5 py-3"><h2 class="font-semibold text-ink">{{ __('admin.recent_payments') }}</h2></div>
|
||||
<ul class="divide-y divide-line">
|
||||
@forelse ($payments as $p)
|
||||
<li class="flex items-center gap-3 px-5 py-3 text-sm">
|
||||
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-surface-2 text-success"><x-ui.icon name="check" class="size-4" /></span>
|
||||
<span class="min-w-0 flex-1 truncate font-medium text-ink">{{ $p['customer'] }}</span>
|
||||
<span class="font-mono text-xs text-faint">{{ $p['when'] }}</span>
|
||||
<span class="font-mono text-xs text-muted">{{ $p['when'] }}</span>
|
||||
<span class="font-mono text-body">{{ $p['amount'] }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-5 py-8 text-center text-sm text-faint">{{ __('admin.no_payments_yet') }}</li>
|
||||
<li class="px-5 py-8 text-center text-sm text-muted">{{ __('admin.no_payments_yet') }}</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('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>
|
||||
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
{{-- The second gate. Being signed in is not enough to read a payment
|
||||
key: the realistic threat is an unlocked machine, and a session is
|
||||
exactly what that hands over. --}}
|
||||
<form wire:submit="confirmPassword" class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<form wire:submit="confirmPassword" class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<h2 class="font-semibold text-ink">{{ __('secrets.locked_title') }}</h2>
|
||||
<p class="mt-1.5 max-w-xl text-sm text-muted">{{ __('secrets.locked_body') }}</p>
|
||||
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
</div>
|
||||
|
||||
@foreach ($entries as $entry)
|
||||
<div wire:key="secret-{{ $entry['key'] }}" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div wire:key="secret-{{ $entry['key'] }}" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ $entry['label'] }}</h2>
|
||||
|
|
@ -99,7 +99,7 @@
|
|||
@endforeach
|
||||
|
||||
@if ($check !== null)
|
||||
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<h2 class="font-semibold text-ink">{{ __('secrets.check_title') }}</h2>
|
||||
|
||||
@if (! $check['ok'])
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('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>
|
||||
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
{{-- Polls itself: an operator watching an update run should not have to
|
||||
reload the page to find out whether it finished. Fast while
|
||||
something is happening, slow when nothing is. --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise"
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise"
|
||||
wire:poll.{{ $update['running'] ? '3s' : '30s' }}>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
|
|
@ -130,7 +130,7 @@
|
|||
|
||||
{{-- My account --}}
|
||||
@if ($canManageSite)
|
||||
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -144,7 +144,7 @@
|
|||
<p class="mt-1.5 max-w-xl text-sm text-muted">
|
||||
{{ $sitePublic ? __('admin_settings.site_public_body') : __('admin_settings.site_hidden_body') }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-faint">
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
{{ __('admin_settings.site_your_view', ['ip' => request()->ip()]) }}
|
||||
<span class="{{ $viewerOnVpn ? 'text-success' : '' }}">
|
||||
{{ $viewerOnVpn ? __('admin_settings.site_via_vpn') : __('admin_settings.site_not_vpn') }}
|
||||
|
|
@ -161,7 +161,7 @@
|
|||
|
||||
{{-- Who may reach this console --}}
|
||||
@if ($canManageSite)
|
||||
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:30ms]">
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:30ms]">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -175,7 +175,7 @@
|
|||
<p class="mt-1 max-w-xl text-sm text-muted">
|
||||
{{ $consoleRestricted ? __('admin_settings.console_locked_body') : __('admin_settings.console_open_body') }}
|
||||
</p>
|
||||
<p class="mt-2 font-mono text-xs text-faint">
|
||||
<p class="mt-2 font-mono text-xs text-muted">
|
||||
{{ __('admin_settings.console_your_ip', ['ip' => $viewerIp]) }}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -187,15 +187,15 @@
|
|||
</div>
|
||||
|
||||
<div class="mt-5 space-y-2 border-t border-line pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('admin_settings.console_always') }}</p>
|
||||
<p class="text-xs font-semibold text-muted">{{ __('admin_settings.console_always') }}</p>
|
||||
@foreach ($consoleVpnRanges as $range)
|
||||
<div class="flex items-center justify-between rounded-lg border border-line-strong bg-surface-2 px-3 py-2">
|
||||
<span class="font-mono text-sm text-body">{{ $range }}</span>
|
||||
<span class="text-xs text-faint">{{ __('admin_settings.console_vpn_note') }}</span>
|
||||
<span class="text-xs text-muted">{{ __('admin_settings.console_vpn_note') }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<p class="pt-3 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('admin_settings.console_extra') }}</p>
|
||||
<p class="pt-3 text-xs font-semibold text-muted">{{ __('admin_settings.console_extra') }}</p>
|
||||
@forelse ($consoleIps as $ip)
|
||||
<div wire:key="cip-{{ $ip }}" class="flex items-center justify-between rounded-lg border border-line-strong bg-surface-2 px-3 py-2">
|
||||
<span class="font-mono text-sm text-body">{{ $ip }}</span>
|
||||
|
|
@ -217,7 +217,7 @@
|
|||
</div>
|
||||
<x-ui.button type="submit" wire:loading.attr="disabled" wire:target="addConsoleIp">{{ __('admin_settings.console_add') }}</x-ui.button>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-faint">{{ __('admin_settings.console_ip_hint') }}</p>
|
||||
<p class="mt-1.5 text-xs text-muted">{{ __('admin_settings.console_ip_hint') }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -226,7 +226,7 @@
|
|||
|
||||
{{-- Own password. There was no way to change one at all: an account created
|
||||
with a generated password kept it until someone opened a shell. --}}
|
||||
<form wire:submit="updateOwnPassword" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]">
|
||||
<form wire:submit="updateOwnPassword" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.password_title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p>
|
||||
|
|
@ -246,13 +246,13 @@
|
|||
</div>
|
||||
</form>
|
||||
|
||||
<form wire:submit="saveAccount" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<form wire:submit="saveAccount" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.account_title') }}</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<x-ui.input name="name" wire:model="name" :label="__('admin_settings.name')" />
|
||||
<x-ui.input name="email" wire:model="email" :label="__('admin_settings.email')" type="email" />
|
||||
</div>
|
||||
<p class="text-xs text-faint">{{ __('admin_settings.twofa_hint') }}</p>
|
||||
<p class="text-xs text-muted">{{ __('admin_settings.twofa_hint') }}</p>
|
||||
<div class="flex justify-end">
|
||||
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveAccount">{{ __('admin_settings.save') }}</x-ui.button>
|
||||
</div>
|
||||
|
|
@ -260,7 +260,7 @@
|
|||
|
||||
{{-- Staff & access (Owner-only) --}}
|
||||
@if ($canManageStaff)
|
||||
<div class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.staff_title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.staff_sub') }}</p>
|
||||
|
|
@ -293,7 +293,7 @@
|
|||
<x-ui.icon name="plus" class="size-4" />{{ __('admin_settings.invite') }}
|
||||
</x-ui.button>
|
||||
</form>
|
||||
<p class="text-xs text-faint">{{ __('admin_settings.invite_hint') }}</p>
|
||||
<p class="text-xs text-muted">{{ __('admin_settings.invite_hint') }}</p>
|
||||
|
||||
@if ($invitedPassword)
|
||||
<div class="rounded-lg border border-warning-border bg-warning-bg p-4">
|
||||
|
|
@ -307,11 +307,11 @@
|
|||
@endif
|
||||
|
||||
{{-- Staff list --}}
|
||||
<div class="overflow-hidden rounded-xl border border-line">
|
||||
<div class="overflow-hidden rounded-lg border border-line">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin_settings.col_person') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin_settings.role') }}</th>
|
||||
<th class="px-4 py-3 text-right font-semibold">{{ __('admin_settings.col_actions') }}</th>
|
||||
|
|
@ -322,7 +322,7 @@
|
|||
<tr wire:key="staff-{{ $s['id'] }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-medium text-ink">{{ $s['name'] }}
|
||||
@if ($s['self'])<span class="ml-1 text-xs text-faint">({{ __('admin_settings.you') }})</span>@endif
|
||||
@if ($s['self'])<span class="ml-1 text-xs text-muted">({{ __('admin_settings.you') }})</span>@endif
|
||||
</p>
|
||||
<p class="text-xs text-muted">{{ $s['email'] }}</p>
|
||||
</td>
|
||||
|
|
@ -344,7 +344,7 @@
|
|||
yourself — and an empty cell reads as a bug rather
|
||||
than as a rule. --}}
|
||||
@if ($s['self'])
|
||||
<span class="text-xs text-faint">—</span>
|
||||
<span class="text-xs text-muted">—</span>
|
||||
@endif
|
||||
@unless ($s['self'])
|
||||
<button type="button" wire:click="revokeStaff({{ $s['id'] }})" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:border-danger hover:text-danger">{{ __('admin_settings.revoke') }}</button>
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
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-semibold tracking-tight text-ink">{{ __('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>
|
||||
|
||||
@if ($hubEndpoint === '' || $hubPublicKey === '')
|
||||
<div class="flex items-start gap-3 rounded-xl border border-warning-border bg-warning-bg p-4 animate-rise">
|
||||
<div class="flex items-start gap-3 rounded-lg border border-warning-border bg-warning-bg p-4 animate-rise">
|
||||
<x-ui.icon name="alert-triangle" class="mt-0.5 size-5 shrink-0 text-warning" />
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-ink">{{ __('vpn.hub_incomplete_title') }}</p>
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
{{-- Shown once. The private key exists only in this response; a reload loses it. --}}
|
||||
@if ($newConfig)
|
||||
<div class="rounded-xl border border-accent-border bg-accent-bg p-5 animate-rise">
|
||||
<div class="rounded-lg border border-accent-border bg-accent-bg p-5 animate-rise">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ __('vpn.config_ready', ['name' => $newConfigName]) }}</h2>
|
||||
|
|
@ -61,10 +61,10 @@
|
|||
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
|
||||
{{-- Peer list --}}
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="flex items-center justify-between border-b border-line px-4 py-3">
|
||||
<h2 class="text-sm font-semibold text-ink">{{ __('vpn.peers') }}</h2>
|
||||
<span class="text-xs text-faint">
|
||||
<span class="text-xs text-muted">
|
||||
{{ $lastSync ? __('vpn.last_sync', ['time' => \Illuminate\Support\Carbon::parse($lastSync)->diffForHumans()]) : __('vpn.never_synced') }}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -120,7 +120,7 @@
|
|||
<span class="rounded-pill border border-accent-border bg-accent-subtle px-1.5 py-0.5 text-[10px] font-medium text-accent-text">{{ __('vpn.you') }}</span>
|
||||
@endif
|
||||
@elseif (! $peer->host)
|
||||
<span class="text-sm text-faint">{{ __('vpn.no_owner') }}</span>
|
||||
<span class="text-sm text-muted">{{ __('vpn.no_owner') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
|
@ -129,7 +129,7 @@
|
|||
<div class="mt-1.5 flex flex-wrap items-center gap-x-5 gap-y-1 font-mono text-xs text-muted">
|
||||
<span class="text-body">{{ $peer->allowed_ip }}</span>
|
||||
@if ($peer->endpoint)
|
||||
<span class="text-faint">{{ $peer->endpoint }}</span>
|
||||
<span class="text-muted">{{ $peer->endpoint }}</span>
|
||||
@endif
|
||||
<span class="whitespace-nowrap">↓ {{ \App\Support\Bytes::human($peer->rx_bytes) }} ↑ {{ \App\Support\Bytes::human($peer->tx_bytes) }}</span>
|
||||
<span class="whitespace-nowrap">{{ __('vpn.handshake') }}: {{ $peer->last_handshake_at?->diffForHumans() ?? __('vpn.never') }}</span>
|
||||
|
|
@ -147,7 +147,7 @@
|
|||
nothing to hand out, and saying so beats an absent button. --}}
|
||||
@if (! $peer->hasStoredConfig() && $peer->kind === \App\Models\VpnPeer::KIND_STAFF && $peer->user_id === auth()->id())
|
||||
<span title="{{ __('vpn.no_stored_config') }}"
|
||||
class="grid size-8 cursor-help place-items-center rounded-md border border-dashed border-line text-faint">
|
||||
class="grid size-8 cursor-help place-items-center rounded-md border border-dashed border-line text-muted">
|
||||
<x-ui.icon name="download" class="size-4" />
|
||||
</span>
|
||||
@endif
|
||||
|
|
@ -193,7 +193,7 @@
|
|||
<div class="space-y-5">
|
||||
{{-- New access --}}
|
||||
@if ($canManage)
|
||||
<form wire:submit="create" class="space-y-4 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<form wire:submit="create" class="space-y-4 rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('vpn.add') }}</h2>
|
||||
<x-ui.input name="name" wire:model="name" :label="__('vpn.name')" placeholder="Notebook Boban" />
|
||||
|
||||
|
|
@ -231,18 +231,18 @@
|
|||
@endif
|
||||
|
||||
{{-- Hub facts an operator needs when configuring a peer by hand --}}
|
||||
<div class="space-y-3 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
|
||||
<div class="space-y-3 rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('vpn.hub') }}</h2>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-faint">{{ __('vpn.endpoint') }}</div>
|
||||
<div class="text-xs font-semibold text-muted">{{ __('vpn.endpoint') }}</div>
|
||||
<div class="mt-0.5 break-all font-mono text-xs text-body">{{ $hubEndpoint ?: '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-faint">{{ __('vpn.hub_key') }}</div>
|
||||
<div class="text-xs font-semibold text-muted">{{ __('vpn.hub_key') }}</div>
|
||||
<div class="mt-0.5 break-all font-mono text-xs text-body">{{ $hubPublicKey ?: '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-faint">{{ __('vpn.subnet') }}</div>
|
||||
<div class="text-xs font-semibold text-muted">{{ __('vpn.subnet') }}</div>
|
||||
<div class="mt-0.5 font-mono text-xs text-body">{{ config('provisioning.wireguard.subnet') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@
|
|||
<div class="w-full max-w-sm animate-rise">
|
||||
<div class="mb-9 flex items-center gap-2.5 lg:hidden">
|
||||
<x-ui.logo class="size-8" />
|
||||
<span class="font-serif text-lg font-semibold tracking-tight text-ink">CluPilot</span>
|
||||
<span class="text-lg font-bold tracking-tight text-ink">Clu<span class="text-accent-text">Pilot</span></span>
|
||||
</div>
|
||||
|
||||
<h1 class="font-serif text-[1.75rem] font-semibold leading-tight tracking-tight text-ink">{{ __('auth.login_title') }}</h1>
|
||||
<h1 class="text-3xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.login_title') }}</h1>
|
||||
<p class="mt-2 text-sm text-muted">{{ __('auth.login_subtitle') }}</p>
|
||||
|
||||
@if (session('status'))
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@
|
|||
<div class="w-full max-w-sm animate-rise">
|
||||
<div class="mb-9 flex items-center gap-2.5 lg:hidden">
|
||||
<x-ui.logo class="size-8" />
|
||||
<span class="font-serif text-lg font-semibold tracking-tight text-ink">CluPilot</span>
|
||||
<span class="text-lg font-bold tracking-tight text-ink">Clu<span class="text-accent-text">Pilot</span></span>
|
||||
</div>
|
||||
|
||||
<h1 class="font-serif text-[1.75rem] font-semibold leading-tight tracking-tight text-ink">{{ __('auth.register_title') }}</h1>
|
||||
<h1 class="text-3xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.register_title') }}</h1>
|
||||
<p class="mt-2 text-sm text-muted">{{ __('auth.register_subtitle') }}</p>
|
||||
|
||||
<form method="POST" action="{{ route('register.store') }}" class="mt-7 space-y-5">
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
<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-semibold tracking-tight text-ink">{{ __('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>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('backups.size_trend') }}</h2>
|
||||
<div class="mt-3 h-52" wire:ignore>
|
||||
<x-ui.chart :config="$sizeChart" class="h-52" :label="__('backups.size_trend')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('backups.col_when') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('backups.col_size') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('backups.col_status') }}</th>
|
||||
|
|
|
|||
|
|
@ -3,15 +3,15 @@
|
|||
@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-semibold tracking-tight text-ink">{{ __('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>
|
||||
|
||||
{{-- Current plan --}}
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-line bg-surface-2 px-6 py-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-faint">{{ __('billing.current_plan') }}</p>
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-muted">{{ __('billing.current_plan') }}</p>
|
||||
<p class="mt-0.5 text-lg font-semibold text-ink">{{ __('billing.plan.'.$currentKey) }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
{{-- Cart: "5 purchases pending" told nobody what they had ordered, and
|
||||
offered no way to change their mind. --}}
|
||||
@if ($pending->isNotEmpty())
|
||||
<div class="overflow-hidden rounded-xl border border-info-border bg-surface shadow-xs animate-rise">
|
||||
<div class="overflow-hidden rounded-lg border border-info-border bg-surface shadow-xs animate-rise">
|
||||
<div class="flex items-center gap-2 border-b border-line bg-info-bg px-5 py-3">
|
||||
<x-ui.icon name="receipt" class="size-4 shrink-0 text-info" />
|
||||
<h2 class="text-sm font-semibold text-ink">{{ __('billing.cart.title') }}</h2>
|
||||
|
|
@ -56,13 +56,13 @@
|
|||
<li wire:key="order-{{ $order->uuid }}" class="flex flex-wrap items-center gap-3 px-5 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-ink">{{ $order->label() }}</p>
|
||||
<p class="mt-0.5 text-xs text-faint">
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ __('billing.cart.added', ['when' => $order->created_at->isoFormat('LL')]) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-mono text-sm text-body">{{ $eur($order->amount_cents) }}</p>
|
||||
<p class="text-xs text-faint">
|
||||
<p class="text-xs text-muted">
|
||||
{{ __('billing.cart.net') }} ·
|
||||
{{ $order->isRecurring() ? __('billing.cart.per_month') : __('billing.cart.once') }}
|
||||
</p>
|
||||
|
|
@ -103,7 +103,7 @@
|
|||
@if ($recurringGross > 0)
|
||||
{{-- The number that actually matters every month, kept apart
|
||||
from a one-off traffic top-up in the same cart. --}}
|
||||
<p class="pt-1 text-xs text-faint">
|
||||
<p class="pt-1 text-xs text-muted">
|
||||
{{ __('billing.cart.recurring_note', ['amount' => $eur($recurringGross)]) }}
|
||||
</p>
|
||||
@endif
|
||||
|
|
@ -118,7 +118,7 @@
|
|||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ($upgrades as $key)
|
||||
@php $p = $plans[$key]; @endphp
|
||||
<div wire:key="upgrade-{{ $key }}" class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs transition hover:border-accent-border">
|
||||
<div wire:key="upgrade-{{ $key }}" class="flex flex-col rounded-lg border border-line bg-surface p-5 shadow-xs transition hover:border-accent-border">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<p class="font-semibold text-ink">{{ __('billing.plan.'.$key) }}</p>
|
||||
<span class="rounded-pill bg-accent-subtle px-2 py-0.5 text-xs font-semibold text-accent-text">{{ __('billing.perf.'.($p['performance'] ?? 'standard')) }}</span>
|
||||
|
|
@ -133,7 +133,7 @@
|
|||
@endforeach
|
||||
</ul>
|
||||
<p class="mt-4 text-xl font-semibold text-ink">{{ $eur($p['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span></p>
|
||||
<p class="text-xs text-faint">
|
||||
<p class="text-xs text-muted">
|
||||
{{ $tax->reverseCharge
|
||||
? __('billing.net_reverse_charge')
|
||||
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
|
||||
|
|
@ -147,29 +147,82 @@
|
|||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ── Downgrade ──────────────────────────────────────────────────────
|
||||
Smaller plans are listed even when they cannot be taken, with the
|
||||
reason in the customer's own numbers. A plan that quietly disappears
|
||||
reads as "no longer offered"; a greyed-out button that does not say
|
||||
what is in the way is the thing people ring about. --}}
|
||||
@if ($downgrades !== [])
|
||||
<section class="animate-rise">
|
||||
<h2 class="text-md font-semibold text-ink">{{ __('billing.downgrade') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('billing.downgrade_hint') }}</p>
|
||||
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach ($downgrades as $key => $plan)
|
||||
<article class="flex flex-col rounded-lg border border-line bg-surface p-5 shadow-xs">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-md font-semibold text-ink">{{ $plan['name'] ?? $key }}</h3>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ $plan['quota_gb'] ?? 0 }} GB · {{ __('billing.seats_count', ['count' => $plan['seats'] ?? 0]) }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="shrink-0 text-right font-mono text-lg font-semibold text-ink">
|
||||
{{ $eur($plan['price_cents'] ?? 0) }}
|
||||
<span class="mt-0.5 block text-xs font-normal text-muted">{{ __('billing.per_month') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if ($plan['check']->allowed)
|
||||
<x-ui.button variant="secondary" class="mt-5 w-full"
|
||||
wire:click="purchase('downgrade', '{{ $key }}')"
|
||||
wire:loading.attr="disabled" wire:target="purchase('downgrade', '{{ $key }}')">
|
||||
{{ __('billing.downgrade_to', ['plan' => $plan['name'] ?? $key]) }}
|
||||
</x-ui.button>
|
||||
@else
|
||||
<div class="mt-5 rounded border border-warning-border bg-warning-bg p-3">
|
||||
<p class="text-xs font-semibold text-warning">{{ __('billing.downgrade_blocked') }}</p>
|
||||
<ul class="mt-2 space-y-1.5">
|
||||
@foreach ($plan['check']->blockers as $blocker)
|
||||
<li class="text-xs leading-relaxed text-ink">
|
||||
{{ __('billing.downgrade_blocker.'.$blocker['key'], [
|
||||
'current' => $blocker['current'],
|
||||
'limit' => $blocker['limit'],
|
||||
]) }}
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- Extra storage + Add-ons --}}
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3 xl:grid-cols-4 animate-rise [animation-delay:180ms]">
|
||||
{{-- Storage --}}
|
||||
<div class="flex flex-col rounded-xl border border-accent-border bg-accent-subtle p-5">
|
||||
<div class="flex flex-col rounded-lg border border-accent-border bg-accent-subtle p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-ui.icon name="database" class="size-5 text-accent-active" />
|
||||
<p class="font-semibold text-ink">{{ __('billing.storage_title') }}</p>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-body">{{ __('billing.storage_body', ['gb' => $storage['gb'], 'price' => $eur($storage['price_cents'])]) }}</p>
|
||||
<p class="mt-1 text-xs text-faint">{{ __('billing.net_per_month') }}</p>
|
||||
<p class="mt-1 text-xs text-muted">{{ __('billing.net_per_month') }}</p>
|
||||
<x-ui.button variant="primary" size="sm" class="mt-4 w-full" wire:click="purchase('storage')" wire:target="purchase('storage')" wire:loading.attr="disabled">
|
||||
{{ __('billing.storage_cta', ['gb' => $storage['gb']]) }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
|
||||
{{-- Traffic --}}
|
||||
<div class="flex flex-col rounded-xl border {{ $trafficMeter && $trafficMeter->state() !== 'ok' ? 'border-warning-border bg-warning-bg' : 'border-line bg-surface shadow-xs' }} p-5">
|
||||
<div class="flex flex-col rounded-lg border {{ $trafficMeter && $trafficMeter->state() !== 'ok' ? 'border-warning-border bg-warning-bg' : 'border-line bg-surface shadow-xs' }} p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-ui.icon name="activity" class="size-5 {{ $trafficMeter && $trafficMeter->state() !== 'ok' ? 'text-warning' : 'text-muted' }}" />
|
||||
<p class="font-semibold text-ink">{{ __('billing.traffic_title') }}</p>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-body">{{ __('billing.traffic_body', ['gb' => $trafficAddon['gb'], 'price' => $eur($trafficAddon['price_cents'])]) }}</p>
|
||||
<p class="mt-1 text-xs text-faint">{{ __('billing.net_once') }}</p>
|
||||
<p class="mt-1 text-xs text-muted">{{ __('billing.net_once') }}</p>
|
||||
@if ($trafficMeter)
|
||||
<p class="mt-2 font-mono text-xs text-muted">
|
||||
{{ __('billing.traffic_used', [
|
||||
|
|
@ -186,7 +239,7 @@
|
|||
|
||||
{{-- Add-ons --}}
|
||||
@foreach ($addons as $key => $addon)
|
||||
<div wire:key="addon-{{ $key }}" class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs">
|
||||
<div wire:key="addon-{{ $key }}" class="flex flex-col rounded-lg border border-line bg-surface p-5 shadow-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-ui.icon name="box" class="size-5 text-muted" />
|
||||
<p class="font-semibold text-ink">{{ __('billing.addon.'.$key.'.name') }}</p>
|
||||
|
|
@ -200,7 +253,7 @@
|
|||
<span class="text-sm font-normal text-muted">· {{ __('billing.addon_packs', ['count' => $addon['quantity']]) }}</span>
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-xs text-faint">
|
||||
<p class="text-xs text-muted">
|
||||
{{ $tax->reverseCharge
|
||||
? __('billing.net_reverse_charge')
|
||||
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
|
||||
|
|
@ -221,5 +274,5 @@
|
|||
@endforeach
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-faint">{{ __('billing.mock_note') }}</p>
|
||||
<p class="text-xs text-muted">{{ __('billing.mock_note') }}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
<div class="space-y-6" x-data="{ msgs: @js(['soon' => __('cloud.action_toast')]) }">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('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>
|
||||
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="grid size-11 place-items-center rounded-lg bg-ink font-semibold text-on-accent">B</span>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-ink">{{ $instance['name'] }}</p>
|
||||
<a href="https://{{ $instance['domain'] }}" target="_blank" rel="noopener noreferrer" class="font-mono text-sm text-accent-text hover:underline">{{ $instance['domain'] }}</a>
|
||||
|
|
@ -43,19 +42,17 @@
|
|||
['cloud.seats', $instance['seats']],
|
||||
['cloud.performance', $instance['performance']],
|
||||
['cloud.location', $instance['location']],
|
||||
['cloud.version', $instance['version'].' · '.__('cloud.current')],
|
||||
['cloud.runtime', $instance['php']],
|
||||
['cloud.storage', $storageLabel],
|
||||
] as [$key, $val])
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __($key) }}</dt>
|
||||
<dt class="text-xs font-semibold text-muted">{{ __($key) }}</dt>
|
||||
<dd class="mt-1 font-mono text-sm text-body">{{ $val }}</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('cloud.storage_growth') }}</h2>
|
||||
<p class="text-sm text-muted">{{ __('cloud.storage_growth_sub') }}</p>
|
||||
<div class="mt-4 h-56" wire:ignore>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div class="rounded-xl bg-surface p-6">
|
||||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
|
||||
<x-ui.icon name="alert-triangle" class="size-5" />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div class="rounded-xl bg-surface p-6">
|
||||
<div class="rounded-lg bg-surface p-6">
|
||||
@if ($blocked)
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div @if ($polling) wire:poll.4s @endif>
|
||||
@if ($active)
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise">
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="size-2 rounded-pill {{ $failed ? 'bg-danger' : 'bg-info animate-pulse' }}" aria-hidden="true"></span>
|
||||
<h2 class="font-semibold text-ink">{{ __('dashboard.provisioning_title') }}</h2>
|
||||
|
|
|
|||
|
|
@ -1,272 +1,300 @@
|
|||
<div class="space-y-4" x-data="{ msgs: @js(['addon' => __('dashboard.modules.add_toast'), 'restore' => __('dashboard.backups.restore_toast')]) }">
|
||||
{{-- Header --}}
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('dashboard.greeting', ['name' => auth()->user()->name]) }}</h1>
|
||||
<span class="ml-auto inline-flex items-center gap-2 rounded-pill bg-success-bg px-3.5 py-1.5 text-xs font-semibold text-success">
|
||||
<span class="size-2 rounded-pill bg-success-bright" aria-hidden="true"></span>{{ __('dashboard.system_ok') }}
|
||||
</span>
|
||||
<p class="w-full text-sm text-muted">{{ __('dashboard.as_of', ['time' => '08:42']) }}</p>
|
||||
</div>
|
||||
@php
|
||||
use App\Support\Bytes;
|
||||
use Illuminate\Support\Number;
|
||||
|
||||
$locale = app()->getLocale();
|
||||
$money = fn (int $cents, string $currency) => Number::currency($cents / 100, in: $currency, locale: $locale);
|
||||
@endphp
|
||||
<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="mb-2 flex flex-wrap items-center gap-3.5 animate-rise">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="lbl">
|
||||
{{ __('dashboard.sheet', ['when' => $asOf->locale($locale)->isoFormat('LL, LT')]) }}
|
||||
</p>
|
||||
<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)
|
||||
<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>
|
||||
|
||||
{{-- Live provisioning progress (only while a run is in flight) --}}
|
||||
<livewire:customer-provisioning />
|
||||
|
||||
{{-- Traffic: the one allowance that throttles the service when it runs out,
|
||||
so it gets a full-width band rather than a tile in the KPI row. --}}
|
||||
@if ($traffic)
|
||||
@php
|
||||
$state = $traffic->state();
|
||||
$bar = ['ok' => 'bg-accent', 'warn' => 'bg-warning', 'critical' => 'bg-warning', 'exhausted' => 'bg-danger'][$state];
|
||||
$frame = [
|
||||
'ok' => 'border-line',
|
||||
'warn' => 'border-warning-border bg-warning-bg',
|
||||
'critical' => 'border-warning-border bg-warning-bg',
|
||||
'exhausted' => 'border-danger-border bg-danger-bg',
|
||||
][$state];
|
||||
@endphp
|
||||
<div class="rounded-xl border {{ $frame }} bg-surface p-5 shadow-xs animate-rise">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<h2 class="text-sm font-semibold text-ink">{{ __('dashboard.traffic.title') }}</h2>
|
||||
<span class="text-xs text-muted">{{ __('dashboard.traffic.resets', ['days' => $traffic->daysUntilReset()]) }}</span>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-body">
|
||||
{{ \App\Support\Bytes::human($traffic->usedBytes) }}
|
||||
<span class="text-faint">/ {{ \App\Support\Bytes::human($traffic->quotaBytes) }}</span>
|
||||
</p>
|
||||
@if ($instance === null)
|
||||
{{-- Said plainly rather than papered over with an empty record: a page
|
||||
of dashes reads as broken, and "we are still setting it up" and
|
||||
"you have not ordered yet" are different things. --}}
|
||||
<section class="rounded-lg border border-line bg-surface p-8 shadow-xs animate-rise">
|
||||
<h2 class="text-lg font-semibold text-ink">{{ __('dashboard.no_instance_label') }}</h2>
|
||||
<p class="mt-2 max-w-prose text-md text-muted">{{ __('dashboard.no_instance_body') }}</p>
|
||||
<div class="mt-6 flex flex-wrap gap-2.5">
|
||||
<x-ui.button variant="ink" :href="route('billing')" wire:navigate>{{ __('dashboard.no_instance_cta') }}</x-ui.button>
|
||||
<x-ui.button variant="secondary" :href="route('support')" wire:navigate>{{ __('dashboard.nav.support') }}</x-ui.button>
|
||||
</div>
|
||||
</section>
|
||||
@else
|
||||
{{-- ── The four cards, in the template's order and form ──────────
|
||||
Speicher · Benutzer · Datenvolumen · Verfügbarkeit. Figure, unit
|
||||
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-3.5 min-[561px]:grid-cols-2 min-[1101px]:grid-cols-4">
|
||||
@if ($disk)
|
||||
<x-ui.metric
|
||||
:label="__('dashboard.master.storage')"
|
||||
:value="Number::format($disk['used'] / 1024 ** 3, precision: 0, locale: $locale)"
|
||||
:unit="'/ '.Number::format($disk['total'] / 1024 ** 3, precision: 0, locale: $locale).' GB'"
|
||||
:foot="trim(__('dashboard.master.storage_used', ['percent' => Number::format($disk['percent'], precision: 0, locale: $locale)])
|
||||
.($disk['week_delta'] !== null ? ' · '.__('dashboard.master.storage_week', ['amount' => ($disk['week_delta'] >= 0 ? '+' : '−').Bytes::human(abs($disk['week_delta']))]) : ''))"
|
||||
:href="route('cloud')"
|
||||
class="animate-rise [animation-delay:60ms]"
|
||||
>
|
||||
<x-slot:visual><x-ui.ring :percent="$disk['percent']" :size="62" /></x-slot:visual>
|
||||
</x-ui.metric>
|
||||
@elseif ($instance->quota_gb)
|
||||
<x-ui.metric
|
||||
:label="__('dashboard.master.storage')"
|
||||
:value="$instance->quota_gb"
|
||||
unit="GB"
|
||||
:foot="__('dashboard.master.storage_note')"
|
||||
:href="route('cloud')"
|
||||
class="animate-rise [animation-delay:60ms]"
|
||||
/>
|
||||
@endif
|
||||
|
||||
<div class="mt-3 h-2 overflow-hidden rounded-pill bg-surface-2">
|
||||
{{-- R4: width is data, not decoration — the only inline style allowed. --}}
|
||||
<div class="h-full rounded-pill {{ $bar }} transition-all duration-700"
|
||||
style="width: {{ min(100, $traffic->percent()) }}%"></div>
|
||||
</div>
|
||||
<x-ui.metric
|
||||
:label="__('dashboard.master.users')"
|
||||
:value="$seats['used']"
|
||||
:unit="$seats['total'] !== null ? __('dashboard.master.of_seats', ['total' => $seats['total']]) : null"
|
||||
:href="route('users')"
|
||||
class="animate-rise [animation-delay:100ms]"
|
||||
>
|
||||
@if ($seats['total'])
|
||||
<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>
|
||||
</div>
|
||||
@endif
|
||||
@if ($seatBreakdown !== [])
|
||||
<p class="mt-2.5 text-xs text-muted">
|
||||
{{ collect($seatBreakdown)
|
||||
->map(fn (int $n, string $role) => trans_choice('users.role_count.'.$role, $n, ['count' => $n]))
|
||||
->join(' · ') }}
|
||||
</p>
|
||||
@endif
|
||||
</x-ui.metric>
|
||||
|
||||
<div class="mt-2.5 flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-xs {{ $state === 'ok' ? 'text-muted' : 'text-body' }}">
|
||||
@if ($state === 'exhausted')
|
||||
{{ __('dashboard.traffic.exhausted') }}
|
||||
@elseif ($state === 'ok')
|
||||
{{ __('dashboard.traffic.remaining', ['amount' => \App\Support\Bytes::human($traffic->remainingBytes())]) }}
|
||||
@else
|
||||
{{ __('dashboard.traffic.almost', ['percent' => $traffic->percent()]) }}
|
||||
@if ($traffic)
|
||||
@php $state = $traffic->state(); @endphp
|
||||
<x-ui.metric
|
||||
:label="__('dashboard.traffic.label')"
|
||||
:value="Number::format($traffic->usedBytes / 1024 ** 3, precision: 0, locale: $locale)"
|
||||
:unit="'GB / '.Bytes::human($traffic->quotaBytes)"
|
||||
:foot="__('dashboard.traffic.resets', ['days' => $traffic->daysUntilReset()])"
|
||||
:href="route('billing')"
|
||||
class="animate-rise [animation-delay:140ms]"
|
||||
>
|
||||
@if (count($trend) > 2)
|
||||
{{-- Orange because this is the metric with an action
|
||||
attached: when it runs out, something can be done
|
||||
about it. Observed figures stay grey. --}}
|
||||
<x-slot:visual><x-ui.spark :points="$trend" area /></x-slot:visual>
|
||||
@endif
|
||||
</p>
|
||||
@if ($state !== 'ok')
|
||||
{{-- The offer belongs next to the warning: someone reading that
|
||||
they are nearly out should not have to go looking. --}}
|
||||
<a href="{{ route('billing') }}" wire:navigate
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-accent-border bg-accent-bg px-2.5 py-1 text-xs font-semibold text-accent-text transition hover:border-accent">
|
||||
<x-ui.icon name="plus" class="size-3.5" />{{ __('dashboard.traffic.buy') }}
|
||||
</a>
|
||||
|
||||
@if ($state !== 'ok')
|
||||
<a href="{{ route('billing') }}" wire:navigate class="mt-3 inline-flex items-center gap-1.5 text-xs font-semibold text-accent-text hover:underline">
|
||||
<x-ui.icon name="plus" class="size-3.5" />{{ __('dashboard.traffic.buy') }}
|
||||
</a>
|
||||
@endif
|
||||
</x-ui.metric>
|
||||
@endif
|
||||
|
||||
{{-- Availability, counted from our own monitoring answers. Absent
|
||||
rather than 100 % when nothing has been checked: reporting an
|
||||
unmonitored instance as perfect is the single most dishonest
|
||||
number this page could carry. --}}
|
||||
@if ($availability !== null)
|
||||
<x-ui.metric
|
||||
:label="__('dashboard.master.availability')"
|
||||
:value="Number::format($availability, precision: 2, locale: $locale)"
|
||||
unit="%"
|
||||
:foot="__('dashboard.master.availability_note')"
|
||||
:href="route('cloud')"
|
||||
class="animate-rise [animation-delay:180ms]"
|
||||
>
|
||||
@if (count($availabilityTrend) > 2)
|
||||
<x-slot:visual><x-ui.spark :points="$availabilityTrend" tone="muted" /></x-slot:visual>
|
||||
@endif
|
||||
</x-ui.metric>
|
||||
@elseif ($location)
|
||||
<x-ui.metric
|
||||
:label="__('dashboard.master.location')"
|
||||
:value="$location['name']"
|
||||
:foot="__('dashboard.master.location_note')"
|
||||
class="animate-rise [animation-delay:180ms]"
|
||||
/>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] lg:items-start">
|
||||
{{-- ── The proof register ────────────────────────────────────── --}}
|
||||
<article class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:200ms]">
|
||||
<div class="flex items-center justify-between gap-4 border-b border-line px-5 py-4">
|
||||
<h2 class="text-md font-semibold text-ink">{{ __('dashboard.proofs.label') }}</h2>
|
||||
<a href="{{ route('backups') }}" wire:navigate class="text-sm font-semibold text-accent-text hover:underline">{{ __('dashboard.proofs.all') }}</a>
|
||||
</div>
|
||||
|
||||
@if ($proofs === [])
|
||||
<p class="px-5 py-8 text-sm text-muted">{{ __('dashboard.proofs.empty') }}</p>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<tbody>
|
||||
@foreach ($proofs as $proof)
|
||||
<tr class="border-b border-line last:border-0">
|
||||
<td class="px-5 py-4">
|
||||
<span class="font-semibold text-ink">{{ __('dashboard.proofs.item.'.$proof['key']) }}</span>
|
||||
<span class="mt-1 block font-mono text-xs text-muted">
|
||||
{{ $proof['at']?->locale($locale)->isoFormat('DD.MM. HH:mm') ?? '—' }}@if ($proof['note']) · {{ $proof['note'] }}@endif
|
||||
</span>
|
||||
</td>
|
||||
<td class="w-px whitespace-nowrap px-5 py-4 text-right">
|
||||
<span @class([
|
||||
'inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-1 text-xs font-semibold',
|
||||
'border-success-border bg-success-bg text-success' => $proof['state'] === 'ok',
|
||||
'border-info-border bg-info-bg text-info' => $proof['state'] === 'planned',
|
||||
'border-danger-border bg-danger-bg text-danger' => $proof['state'] === 'attention',
|
||||
])>
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ __('dashboard.proofs.state.'.$proof['state']) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
|
||||
<div class="space-y-4">
|
||||
{{-- ── The contract ───────────────────────────────────────── --}}
|
||||
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:240ms]">
|
||||
<h2 class="text-md font-semibold text-ink">{{ __('dashboard.master.label') }}</h2>
|
||||
<dl class="mt-4">
|
||||
<div class="flex items-baseline justify-between gap-4 border-b border-line py-2.5">
|
||||
<dt class="text-sm text-muted">{{ __('dashboard.master.package') }}</dt>
|
||||
<dd class="text-right text-sm font-semibold text-ink">
|
||||
{{ __('billing.plan.'.($instance->plan ?: 'team')) }}
|
||||
@if ($planPrice)
|
||||
<span class="mt-0.5 block font-mono text-xs font-normal text-muted">
|
||||
{{ __('dashboard.master.per_'.($planPrice['term'] === 'yearly' ? 'year' : 'month'), ['amount' => $money($planPrice['cents'], $planPrice['currency'])]) }}
|
||||
</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@if ($contract?->performance)
|
||||
<div class="flex items-baseline justify-between gap-4 border-b border-line py-2.5">
|
||||
<dt class="text-sm text-muted">{{ __('dashboard.master.operation') }}</dt>
|
||||
<dd class="text-right text-sm font-semibold text-ink">{{ __('billing.perf.'.$contract->performance) }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
@if ($domain)
|
||||
<div class="flex items-baseline justify-between gap-4 py-2.5">
|
||||
<dt class="text-sm text-muted">{{ __('dashboard.master.address') }}</dt>
|
||||
<dd class="min-w-0 break-all text-right font-mono text-xs text-ink">{{ $domain }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
@if ($nextInvoice)
|
||||
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:280ms]">
|
||||
<h2 class="text-md font-semibold text-ink">{{ __('dashboard.invoice.label') }}</h2>
|
||||
<dl class="mt-4">
|
||||
@if ($nextInvoice['addons'] > 0)
|
||||
{{-- Broken out only when there is something to
|
||||
break out: a "Module 0,00 €" line on every
|
||||
other customer's sheet is noise. --}}
|
||||
<div class="flex items-baseline justify-between gap-4 border-b border-line py-2.5">
|
||||
<dt class="text-sm text-muted">{{ __('dashboard.invoice.plan') }}</dt>
|
||||
<dd class="font-mono text-sm text-ink">{{ $money($nextInvoice['plan'], $nextInvoice['currency']) }}</dd>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 border-b border-line py-2.5">
|
||||
<dt class="text-sm text-muted">{{ __('dashboard.invoice.addons') }}</dt>
|
||||
<dd class="font-mono text-sm text-ink">{{ $money($nextInvoice['addons'], $nextInvoice['currency']) }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex items-baseline justify-between gap-4 border-b border-line py-2.5">
|
||||
<dt class="text-sm text-muted">{{ __('dashboard.invoice.amount') }}</dt>
|
||||
<dd class="text-right font-mono text-md font-semibold text-ink">
|
||||
{{ $money($nextInvoice['total'], $nextInvoice['currency']) }}
|
||||
<span class="mt-0.5 block text-xs font-normal text-muted">{{ __('dashboard.invoice.net') }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-2.5">
|
||||
<dt class="text-sm text-muted">{{ __('dashboard.invoice.due') }}</dt>
|
||||
<dd class="font-mono text-sm text-ink">{{ $nextInvoice['due']?->locale($locale)->isoFormat('LL') ?? '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<x-ui.button variant="secondary" :href="route('invoices')" wire:navigate class="mt-4 w-full">
|
||||
{{ __('dashboard.invoice.all') }}
|
||||
</x-ui.button>
|
||||
</article>
|
||||
@endif
|
||||
|
||||
@if ($openTasks > 0)
|
||||
{{-- Only while there is something outstanding. A permanently
|
||||
green checklist is furniture; one with work left on it
|
||||
is a reason to open this page. --}}
|
||||
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:320ms]">
|
||||
<h2 class="text-md font-semibold text-ink">{{ __('dashboard.setup.label') }}</h2>
|
||||
<p class="mt-2 text-sm text-body">{{ trans_choice('dashboard.setup.open', $openTasks, ['count' => $openTasks]) }}</p>
|
||||
<a href="{{ route('cloud') }}" wire:navigate class="mt-3 inline-flex items-center gap-1.5 text-sm font-semibold text-accent-text hover:underline">
|
||||
{{ __('dashboard.setup.continue') }}
|
||||
</a>
|
||||
</article>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- ── The three things customers come here to do ─────────────────── --}}
|
||||
<section class="grid gap-px overflow-hidden rounded-lg border border-line bg-line shadow-xs animate-rise [animation-delay:360ms] sm:grid-cols-3">
|
||||
@foreach ([
|
||||
['users', 'users', 'add_user'],
|
||||
['backups', 'rotate-ccw', 'restore'],
|
||||
['billing', 'plus', 'grow'],
|
||||
] as [$route, $icon, $key])
|
||||
<a href="{{ route($route) }}" wire:navigate class="flex min-h-11 items-center gap-3.5 bg-surface px-5 py-4 transition hover:bg-surface-hover">
|
||||
<span class="grid size-9 shrink-0 place-items-center rounded bg-accent-subtle text-accent-text">
|
||||
<x-ui.icon :name="$icon" class="size-[1.05rem]" />
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-sm font-semibold text-ink">{{ __('dashboard.quick.'.$key.'_title') }}</span>
|
||||
<span class="block text-xs text-muted">{{ __('dashboard.quick.'.$key.'_body') }}</span>
|
||||
</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- KPI row --}}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{{-- Storage ring --}}
|
||||
<div class="flex items-center gap-4 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="relative size-24 shrink-0" wire:ignore>
|
||||
<x-ui.chart :config="$storageChart" class="size-24" :label="__('dashboard.kpi.storage')" />
|
||||
<div class="pointer-events-none absolute inset-0 grid place-items-center text-center">
|
||||
<div>
|
||||
<span class="font-mono text-lg font-semibold text-ink">{{ $storagePercent }}%</span>
|
||||
<span class="block text-[0.62rem] text-muted">{{ __('dashboard.occupied') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.storage') }}</p>
|
||||
<p class="mt-1 font-mono text-xl font-semibold text-ink">{{ $storageUsed }}<span class="ml-1 text-sm font-normal text-muted">/ {{ $storageQuota }} GB</span></p>
|
||||
<p class="mt-0.5 text-xs text-muted">{{ __('dashboard.storage_week') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Users --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.users') }}</p>
|
||||
<p class="mt-2 font-mono text-2xl font-semibold text-ink">{{ $usersActive }}<span class="ml-1 text-sm font-normal text-muted">/ {{ $usersQuota }}</span></p>
|
||||
<p class="mt-1 text-xs text-muted">{{ __('dashboard.users_detail') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Last backup --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.backup') }}</p>
|
||||
<p class="mt-2 flex items-center gap-1.5 font-mono text-2xl font-semibold text-success">
|
||||
{{ $lastBackup }}<x-ui.icon name="check" class="size-5" />
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted">{{ __('dashboard.backup_verified') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Availability sparkline --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:240ms]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.availability') }}</p>
|
||||
<p class="mt-2 font-mono text-2xl font-semibold text-ink">{{ $availability }}<span class="ml-1 text-sm font-normal text-muted">%</span></p>
|
||||
<div class="mt-2 h-8" wire:ignore>
|
||||
<x-ui.chart :config="$availabilityChart" class="h-8" :label="__('dashboard.kpi.availability')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Upsell --}}
|
||||
<div class="relative flex flex-wrap items-center gap-4 overflow-hidden rounded-xl border border-accent-border bg-accent-subtle px-5 py-4 animate-rise [animation-delay:300ms]">
|
||||
{{-- Shimmer sweep (left → right), disabled for reduced motion --}}
|
||||
<span aria-hidden="true" class="pointer-events-none absolute inset-0 overflow-hidden motion-reduce:hidden">
|
||||
<span class="absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-surface/60 to-transparent animate-shimmer"></span>
|
||||
</span>
|
||||
<x-ui.icon name="alert-triangle" class="relative size-6 shrink-0 text-accent-active" />
|
||||
<div class="relative min-w-0">
|
||||
<p class="text-sm font-semibold text-ink">{{ __('dashboard.upsell.title', ['percent' => $storagePercent]) }}</p>
|
||||
<p class="text-sm text-muted">{{ __('dashboard.upsell.body') }}</p>
|
||||
</div>
|
||||
<a href="{{ route('billing') }}" wire:navigate class="relative ml-auto">
|
||||
<x-ui.button variant="secondary" size="sm">{{ __('dashboard.upsell.cta') }}</x-ui.button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[1.35fr_1fr] lg:items-start">
|
||||
{{-- Left column --}}
|
||||
<div class="flex flex-col gap-4">
|
||||
{{-- Cloud card --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:360ms]">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="grid size-11 place-items-center rounded-lg bg-ink font-semibold text-on-accent">B</span>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-ink">{{ $cloud['name'] }}</p>
|
||||
<a href="https://{{ $cloud['domain'] }}" target="_blank" rel="noopener noreferrer" class="font-mono text-sm text-accent-text hover:underline">{{ $cloud['domain'] }}</a>
|
||||
</div>
|
||||
<a href="https://{{ $cloud['domain'] }}" target="_blank" rel="noopener noreferrer"
|
||||
class="ml-auto inline-flex items-center gap-2 rounded-pill bg-accent-active px-4 py-2 text-sm font-semibold text-on-accent transition hover:bg-accent-press">
|
||||
<x-ui.icon name="external-link" class="size-4" />{{ __('dashboard.cloud.open') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 border-t border-line pt-4 sm:grid-cols-2">
|
||||
<div><p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.cloud.package') }}</p><p class="mt-1 text-sm text-body">{{ $cloud['package'] }}</p></div>
|
||||
<div><p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.cloud.status') }}</p><p class="mt-1 text-sm text-success">{{ __('dashboard.cloud.active_since', ['date' => $cloud['since']]) }}</p></div>
|
||||
<div><p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.cloud.location') }}</p><p class="mt-1 text-sm text-body">{{ $cloud['location'] }}</p></div>
|
||||
<div><p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.cloud.version') }}</p><p class="mt-1 font-mono text-sm text-body">{{ $cloud['version'] }} · {{ __('dashboard.cloud.current') }}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Onboarding checklist (Alpine) --}}
|
||||
<div
|
||||
class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:420ms]"
|
||||
x-data="{
|
||||
tasks: @js(collect($onboarding)->map(fn ($t) => ['label' => $t['label'], 'done' => $t['done'], 'date' => $t['date']])),
|
||||
total: {{ count($onboarding) }},
|
||||
get doneCount() { return this.tasks.filter(t => t.done).length; },
|
||||
get percent() { return Math.round(this.doneCount / this.total * 100); },
|
||||
complete(i) {
|
||||
if (this.tasks[i].done) return;
|
||||
this.tasks[i].done = true;
|
||||
$dispatch('notify', { message: @js(__('dashboard.onboarding.done_toast')) });
|
||||
},
|
||||
nextIndex() { return this.tasks.findIndex(t => !t.done); },
|
||||
}"
|
||||
>
|
||||
<div class="flex items-baseline gap-3">
|
||||
<h2 class="font-semibold text-ink">{{ __('dashboard.onboarding.title') }}</h2>
|
||||
<span class="ml-auto font-mono text-sm font-semibold text-muted" x-text="doneCount + ' / ' + total"></span>
|
||||
</div>
|
||||
<div class="mt-3 h-1.5 overflow-hidden rounded-pill bg-surface-2">
|
||||
<div class="h-full rounded-pill bg-accent transition-all duration-700" :style="`width: ${percent}%`"></div>
|
||||
</div>
|
||||
<ul class="mt-2">
|
||||
<template x-for="(task, i) in tasks" :key="i">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded-lg px-2.5 py-3 text-left text-sm transition hover:bg-surface-hover"
|
||||
:class="!task.done && i === nextIndex() ? 'bg-accent-subtle' : ''"
|
||||
:disabled="task.done"
|
||||
@click="complete(i)"
|
||||
>
|
||||
<span
|
||||
class="grid size-6 shrink-0 place-items-center rounded-pill border"
|
||||
:class="task.done ? 'border-success-bright bg-success-bright text-on-accent' : (i === nextIndex() ? 'border-accent' : 'border-line-strong')"
|
||||
>
|
||||
<svg x-show="task.done" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" class="size-3.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1" :class="task.done ? 'text-muted line-through' : 'text-body'" x-text="task.label"></span>
|
||||
<span x-show="task.date" class="text-xs text-faint" x-text="task.date"></span>
|
||||
<span x-show="!task.done && i === nextIndex()" class="text-xs font-semibold text-accent-text whitespace-nowrap">{{ __('dashboard.onboarding.review') }} ›</span>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Right column --}}
|
||||
<div class="flex flex-col gap-4">
|
||||
{{-- Backups --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:360ms]">
|
||||
<div class="flex items-center">
|
||||
<h2 class="font-semibold text-ink">{{ __('dashboard.backups.title') }}</h2>
|
||||
<span class="ml-auto rounded-pill bg-success-bg px-2.5 py-1 text-xs font-semibold text-success">{{ __('dashboard.backups.schedule') }}</span>
|
||||
</div>
|
||||
<ul class="mt-2 divide-y divide-line">
|
||||
@foreach ($backups as $b)
|
||||
<li class="flex items-center gap-3 py-2.5 text-sm">
|
||||
<x-ui.icon name="check" class="size-4 text-success" />
|
||||
<span class="font-mono text-xs text-muted">{{ $b['when'] }}</span>
|
||||
<span class="ml-auto font-mono text-xs text-muted">{{ $b['size'] }}</span>
|
||||
<x-ui.button variant="secondary" size="sm" @click="$dispatch('notify', { message: msgs.restore })">{{ __('dashboard.backups.restore') }}</x-ui.button>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<p class="mt-3 flex gap-2 text-xs leading-relaxed text-muted">
|
||||
<x-ui.icon name="shield" class="mt-px size-4 shrink-0 text-accent-active" />
|
||||
<span>{{ __('dashboard.backups.note') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Modules --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:420ms]">
|
||||
<div class="flex items-center">
|
||||
<h2 class="font-semibold text-ink">{{ __('dashboard.modules.title') }}</h2>
|
||||
<span class="ml-auto rounded-pill bg-surface-2 px-2.5 py-1 text-xs font-semibold text-muted">{{ __('dashboard.modules.addon') }}</span>
|
||||
</div>
|
||||
<ul class="mt-2 divide-y divide-line">
|
||||
@foreach ($modules as $m)
|
||||
<li class="flex items-center gap-3 py-2.5 text-sm">
|
||||
<span class="grid size-9 shrink-0 place-items-center rounded-lg bg-surface-2 text-muted"><x-ui.icon :name="$m['icon']" class="size-5" /></span>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-ink">{{ $m['name'] }}</p>
|
||||
<p class="text-xs text-muted">{{ $m['desc'] }}</p>
|
||||
</div>
|
||||
@if ($m['status'] === 'active')
|
||||
<span class="ml-auto rounded-pill bg-success-bg px-2.5 py-1 text-xs font-semibold text-success">{{ __('dashboard.modules.active') }}</span>
|
||||
@elseif ($m['status'] === 'available')
|
||||
<x-ui.button variant="secondary" size="sm" class="ml-auto" @click="$dispatch('notify', { message: msgs.addon })">{{ __('dashboard.modules.add') }}</x-ui.button>
|
||||
@else
|
||||
<span class="ml-auto rounded-pill bg-surface-2 px-2.5 py-1 text-xs font-semibold text-muted">{{ __('dashboard.modules.soon') }}</span>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{{-- Activity feed --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:480ms]">
|
||||
<div class="flex items-center">
|
||||
<h2 class="font-semibold text-ink">{{ __('dashboard.activity') }}</h2>
|
||||
<span class="ml-auto rounded-pill bg-surface-2 px-2.5 py-1 text-xs font-semibold text-muted">{{ __('dashboard.activity_live') }}</span>
|
||||
</div>
|
||||
<ul class="mt-2 divide-y divide-line">
|
||||
@foreach ($activity as $a)
|
||||
<li class="flex items-start gap-3 py-2.5 text-sm">
|
||||
<span class="grid size-7 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-active"><x-ui.icon :name="$a['icon']" class="size-4" /></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-body">{{ $a['text'] }}</p>
|
||||
<p class="font-mono text-xs text-faint">{{ $a['time'] }}</p>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
<div class="space-y-6" x-data="{ msgs: @js(['download' => __('invoices.download_toast')]) }">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('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>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px] lg:items-start">
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_no') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_date') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_amount') }}</th>
|
||||
|
|
@ -33,12 +33,12 @@
|
|||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="rounded-xl border border-accent-border bg-accent-subtle p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('invoices.next_charge') }}</p>
|
||||
<div class="rounded-lg border border-accent-border bg-accent-subtle p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<p class="text-xs font-semibold text-muted">{{ __('invoices.next_charge') }}</p>
|
||||
<p class="mt-1 font-mono text-2xl font-semibold text-ink">{{ $nextAmount }}</p>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('invoices.on_date', ['date' => $nextCharge]) }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('invoices.spend') }}</h2>
|
||||
<div class="mt-3 h-48" wire:ignore>
|
||||
<x-ui.chart :config="$spendChart" class="h-48" :label="__('invoices.spend')" />
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('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>
|
||||
|
||||
{{-- Company / billing profile --}}
|
||||
<form wire:submit="saveProfile" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<form wire:submit="saveProfile" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('settings.company_title') }}</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<x-ui.input name="companyName" wire:model="companyName" :label="__('settings.company_name')" />
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
|
||||
{{-- Own password. There was no way to change one at all — Fortify's
|
||||
updatePasswords feature was switched off. --}}
|
||||
<form wire:submit="updateOwnPassword" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]">
|
||||
<form wire:submit="updateOwnPassword" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.password_title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p>
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
{{-- Two-factor. Every action is re-checked server-side against a recent
|
||||
password confirmation — hiding the buttons stops nobody who can post
|
||||
to /livewire/update. --}}
|
||||
<div class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:150ms]">
|
||||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:150ms]">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
|
|
@ -126,7 +126,7 @@
|
|||
@endif
|
||||
</div>
|
||||
|
||||
<form wire:submit="saveBranding" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<form wire:submit="saveBranding" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ __('settings.branding_title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('settings.branding_sub') }}</p>
|
||||
|
|
@ -162,12 +162,12 @@
|
|||
@elseif ($logoUrl)
|
||||
<img src="{{ $logoUrl }}" alt="" class="max-h-16 max-w-16 object-contain" />
|
||||
@else
|
||||
<span class="text-xs text-faint">{{ __('settings.brand_default') }}</span>
|
||||
<span class="text-xs text-muted">{{ __('settings.brand_default') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<input type="file" wire:model="logo" accept="image/png,image/webp" class="text-sm text-body file:mr-3 file:rounded-md file:border-0 file:bg-surface-2 file:px-3 file:py-1.5 file:text-sm file:font-semibold file:text-body" />
|
||||
<p class="text-xs text-faint">{{ __('settings.brand_logo_hint') }}</p>
|
||||
<p class="text-xs text-muted">{{ __('settings.brand_logo_hint') }}</p>
|
||||
@if ($logoUrl)
|
||||
<button type="button" wire:click="removeLogo" class="self-start text-xs font-semibold text-danger hover:underline">{{ __('settings.brand_logo_remove') }}</button>
|
||||
@endif
|
||||
|
|
@ -177,7 +177,7 @@
|
|||
</div>
|
||||
|
||||
@if ($branding['is_default'] ?? false)
|
||||
<p class="text-xs text-faint">{{ __('settings.brand_using_default') }}</p>
|
||||
<p class="text-xs text-muted">{{ __('settings.brand_using_default') }}</p>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end">
|
||||
|
|
@ -186,7 +186,7 @@
|
|||
</form>
|
||||
|
||||
{{-- Package & account lifecycle --}}
|
||||
<div class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:180ms]">
|
||||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:180ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('settings.package_title') }}</h2>
|
||||
|
||||
@if ($cancellationScheduled)
|
||||
|
|
|
|||
|
|
@ -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-semibold tracking-tight text-ink">{{ __('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 })">
|
||||
|
|
@ -17,10 +17,10 @@
|
|||
['external-link', __('support.contact_email'), 'support@clupilot.com'],
|
||||
['shield-check', __('support.contact_sla'), __('support.contact_sla_val')],
|
||||
] as $i => [$icon, $label, $val])
|
||||
<div class="flex items-center gap-3 rounded-xl border border-line bg-surface p-4 shadow-xs animate-rise {{ $delays[$i] }}">
|
||||
<div class="flex items-center gap-3 rounded-lg border border-line bg-surface p-4 shadow-xs animate-rise {{ $delays[$i] }}">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-active"><x-ui.icon :name="$icon" class="size-5" /></span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $label }}</p>
|
||||
<p class="text-xs font-semibold text-muted">{{ $label }}</p>
|
||||
<p class="mt-0.5 truncate text-sm font-medium text-ink">{{ $val }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -28,29 +28,7 @@
|
|||
</div>
|
||||
|
||||
{{-- Tickets --}}
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:240ms]">
|
||||
<div class="border-b border-line px-5 py-3"><h2 class="font-semibold text-ink">{{ __('support.tickets') }}</h2></div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<tbody>
|
||||
@foreach ($tickets as $t)
|
||||
<tr class="border-b border-line last:border-0 hover:bg-surface-hover">
|
||||
<td class="px-5 py-3">
|
||||
<p class="font-medium text-ink">{{ $t['subject'] }}</p>
|
||||
<p class="font-mono text-xs text-muted">{{ $t['ref'] }} · {{ $t['date'] }}</p>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-right">
|
||||
<x-ui.badge :status="$t['status'] === 'open' ? 'provisioning' : ($t['status'] === 'progress' ? 'warning' : 'active')">{{ __('support.status_'.$t['status']) }}</x-ui.badge>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- FAQ accordion --}}
|
||||
<div class="rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:300ms]">
|
||||
<div class="rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:300ms]">
|
||||
<div class="border-b border-line px-5 py-3"><h2 class="font-semibold text-ink">{{ __('support.faq') }}</h2></div>
|
||||
<ul class="divide-y divide-line">
|
||||
@foreach ($faqs as $i => $f)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4 animate-rise">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('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">
|
||||
<p class="font-mono text-lg font-semibold text-ink">{{ $used }} / {{ $limit }}</p>
|
||||
<p class="text-xs text-muted">{{ __('users.seats_used') }}</p>
|
||||
<p class="text-xs text-faint">{{ __('users.seats_note') }}</p>
|
||||
<p class="text-xs text-muted">{{ __('users.seats_note') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Invite --}}
|
||||
<form wire:submit="invite" class="grid grid-cols-1 gap-3 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
|
||||
<form wire:submit="invite" class="grid grid-cols-1 gap-3 rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-body" for="inviteEmail">{{ __('users.invite_email') }}</label>
|
||||
<input id="inviteEmail" type="email" wire:model="inviteEmail" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
|
||||
|
|
@ -36,15 +36,17 @@
|
|||
</form>
|
||||
|
||||
{{-- Seats --}}
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('users.col_person') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('users.role') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('users.col_status') }}</th>
|
||||
<th class="px-4 py-3 text-right font-semibold">{{ __('users.col_actions') }}</th>
|
||||
@if ($hasActions)
|
||||
<th class="px-4 py-3 text-right font-semibold">{{ __('users.col_actions') }}</th>
|
||||
@endif
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -69,19 +71,36 @@
|
|||
@php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp
|
||||
<x-ui.badge :status="$sb">{{ __('users.status_'.$seat->status) }}</x-ui.badge>
|
||||
</td>
|
||||
@if ($hasActions)
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-1.5">
|
||||
@if ($seat->role === 'owner')
|
||||
<span class="text-muted" title="{{ __('users.owner_no_actions') }}">—</span>
|
||||
@endif
|
||||
@if ($seat->status === 'invited')
|
||||
<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
|
||||
</div>
|
||||
</td>
|
||||
@endif
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -8,27 +8,24 @@
|
|||
<meta name="robots" content="noindex">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
|
||||
<link rel="preload" as="font" type="font/woff2" href="/fonts/ibm-plex-serif-latin-600-normal.woff2" crossorigin>
|
||||
@verbatim
|
||||
<style>
|
||||
@font-face{font-family:"Plex Serif";src:url("/fonts/ibm-plex-serif-latin-600-normal.woff2") format("woff2");font-weight:600;font-style:normal;font-display:swap}
|
||||
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-style:normal;font-display:swap}
|
||||
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-500-normal.woff2") format("woff2");font-weight:500;font-style:normal;font-display:swap}
|
||||
@font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-style:normal;font-display:swap}
|
||||
|
||||
:root{
|
||||
--paper:#f7f5f1; --paper-2:#efebe3; --card:#fffefb;
|
||||
--ink:#17140f; --ink-soft:#413a31; --muted:#7b7367; --faint:#a39a8c;
|
||||
--rule:#e3ded3; --rule-mid:#cbc3b4;
|
||||
--paper:#f6f6f8; --paper-2:#fafafb; --card:#ffffff;
|
||||
--ink:#17171c; --ink-soft:#43434e; --muted:#6e6e7a; --faint:#a39a8c;
|
||||
--rule:#e9e9ee; --rule-mid:#dcdce4;
|
||||
--accent:#f97316; --accent-text:#b8500a;
|
||||
--ok:#2f7d4f; --ok-bg:#eef7f1;
|
||||
--warn:#a2600c; --warn-bg:#fdf5e7;
|
||||
--bad:#a4372a; --bad-bg:#fdf1ef;
|
||||
--unknown:#6c6459; --unknown-bg:#f1eee8;
|
||||
--serif:"Plex Serif",Georgia,serif;
|
||||
--sans:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
|
||||
--mono:"Plex Mono",ui-monospace,monospace;
|
||||
--r:3px;
|
||||
--r:16px;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:var(--sans);background:var(--paper);color:var(--ink-soft);font-size:16px;line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
|
|
@ -37,9 +34,9 @@
|
|||
|
||||
.wrap{max-width:760px;margin:0 auto;padding:0 clamp(20px,5vw,32px)}
|
||||
header{padding:clamp(48px,8vw,80px) 0 0}
|
||||
.mark{font-family:var(--serif);font-weight:600;font-size:1.12rem;letter-spacing:-.02em;color:var(--ink)}
|
||||
.mark{font-weight:700;font-size:1.12rem;letter-spacing:-.02em;color:var(--ink)}
|
||||
.mark i{font-style:normal;color:var(--accent-text)}
|
||||
h1{font-family:var(--serif);font-weight:600;font-size:clamp(2rem,4.5vw,2.8rem);letter-spacing:-.028em;line-height:1.1;color:var(--ink);margin-top:28px}
|
||||
h1{font-weight:700;font-size:clamp(2rem,4.5vw,2.8rem);letter-spacing:-.028em;line-height:1.1;color:var(--ink);margin-top:28px}
|
||||
|
||||
.overall{
|
||||
display:flex;align-items:center;gap:13px;margin-top:22px;padding:16px 20px;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,14 @@ it('forbids non-admin users from the admin console', function (string $route) {
|
|||
it('renders the admin console for admins', function (string $route) {
|
||||
$admin = User::factory()->create(['is_admin' => true]);
|
||||
|
||||
// Asserted on the console badge and the sign-out control rather than on the
|
||||
// wordmark: the wordmark is split across an element so its second half can
|
||||
// carry the accent, so "CluPilot" is not a contiguous string in the markup.
|
||||
// These two say what the assertion actually meant — the console shell, with
|
||||
// its navigation, rendered for this route.
|
||||
$this->actingAs($admin)->get(route($route))
|
||||
->assertOk()
|
||||
->assertSee('CluPilot');
|
||||
->assertSee(__('admin.badge'))
|
||||
->assertSee(__('admin.nav.overview'))
|
||||
->assertSee(__('dashboard.logout'));
|
||||
})->with($adminRoutes);
|
||||
|
|
|
|||
|
|
@ -14,3 +14,37 @@ it('shows the dashboard to authenticated users', function () {
|
|||
->assertSee('CluPilot')
|
||||
->assertSee(__('dashboard.title'));
|
||||
});
|
||||
|
||||
it('shows no next invoice once the customer has given notice', function () {
|
||||
// Cancelling leaves the subscription active until the term runs out, so
|
||||
// `current_period_end` becomes the day service ENDS rather than the day the
|
||||
// next charge falls. Billing a renewal that will never happen, on the sheet
|
||||
// a customer checks their invoices against, is the worst line this page
|
||||
// could carry.
|
||||
$user = App\Models\User::factory()->create();
|
||||
$customer = App\Models\Customer::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$instance = App\Models\Instance::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'status' => 'cancellation_scheduled',
|
||||
'cancel_requested_at' => now(),
|
||||
'service_ends_at' => now()->addMonth(),
|
||||
]);
|
||||
|
||||
App\Models\Subscription::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'instance_id' => $instance->id,
|
||||
'status' => 'active',
|
||||
'price_cents' => 17900,
|
||||
'currency' => 'EUR',
|
||||
'term' => 'monthly',
|
||||
'current_period_end' => now()->addMonth(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(App\Livewire\Dashboard::class)
|
||||
->assertViewHas('nextInvoice', null)
|
||||
// The price is still on the master record — they pay it until the end.
|
||||
->assertViewHas('planPrice', fn (?array $p) => $p !== null && $p['cents'] === 17900)
|
||||
->assertDontSee(__('dashboard.invoice.label'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Billing;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\InstanceMetric;
|
||||
use App\Models\Order;
|
||||
use App\Models\Seat;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\DowngradeCheck;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Moving down to a smaller plan.
|
||||
*
|
||||
* A downgrade is not the mirror of an upgrade: going up always fits, going
|
||||
* down can ask the instance to hold more than the target allows. What matters
|
||||
* as much as refusing it is SAYING WHY, in the customer's own numbers — a
|
||||
* disabled button that does not name the obstacle is the thing people ring
|
||||
* about.
|
||||
*/
|
||||
|
||||
function downgradeCustomer(int $seats = 1, ?int $usedBytes = null): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$customer = Customer::factory()->create(['user_id' => $user->id]);
|
||||
$instance = Instance::factory()->create(['customer_id' => $customer->id, 'status' => 'active', 'quota_gb' => 500]);
|
||||
|
||||
foreach (range(1, $seats) as $n) {
|
||||
Seat::create([
|
||||
'customer_id' => $customer->id,
|
||||
'email' => "seat{$n}@example.test",
|
||||
'role' => $n === 1 ? 'owner' : 'member',
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($usedBytes !== null) {
|
||||
InstanceMetric::create([
|
||||
'instance_id' => $instance->id,
|
||||
'day' => now()->toDateString(),
|
||||
'disk_used_bytes' => $usedBytes,
|
||||
'disk_total_bytes' => 500 * 1024 ** 3,
|
||||
]);
|
||||
}
|
||||
|
||||
return [$user, $customer, $instance];
|
||||
}
|
||||
|
||||
it('allows a downgrade when everything fits', function () {
|
||||
[, $customer, $instance] = downgradeCustomer(seats: 3, usedBytes: 40 * 1024 ** 3);
|
||||
|
||||
$check = DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200]);
|
||||
|
||||
expect($check->allowed)->toBeTrue()->and($check->blockers)->toBe([]);
|
||||
});
|
||||
|
||||
it('names the seats that are in the way, with both numbers', function () {
|
||||
[, $customer, $instance] = downgradeCustomer(seats: 31);
|
||||
|
||||
$check = DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200]);
|
||||
|
||||
expect($check->allowed)->toBeFalse()
|
||||
->and($check->blockers[0]['key'])->toBe('seats')
|
||||
->and($check->blockers[0]['current'])->toBe('31')
|
||||
->and($check->blockers[0]['limit'])->toBe('10');
|
||||
});
|
||||
|
||||
it('measures storage rather than trusting the allowance', function () {
|
||||
// 480 GB really in use. The contract says 500 GB, but what decides whether
|
||||
// 200 GB is enough is what is actually stored.
|
||||
[, $customer, $instance] = downgradeCustomer(seats: 2, usedBytes: 480 * 1024 ** 3);
|
||||
|
||||
$check = DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200]);
|
||||
|
||||
expect($check->allowed)->toBeFalse()
|
||||
->and(collect($check->blockers)->pluck('key')->all())->toBe(['storage']);
|
||||
});
|
||||
|
||||
it('does not refuse a customer who has simply never been measured', function () {
|
||||
// A new instance has no reading. Refusing on the contractual allowance
|
||||
// would block every downgrade for anyone who once bought a large plan.
|
||||
[, $customer, $instance] = downgradeCustomer(seats: 2, usedBytes: null);
|
||||
|
||||
expect(DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200])->allowed)->toBeTrue();
|
||||
});
|
||||
|
||||
it('refuses a blocked downgrade even when the button is never rendered', function () {
|
||||
// The action is callable from a stale tab or a second window. A limit
|
||||
// enforced only in markup is not enforced.
|
||||
[$user, $customer, $instance] = downgradeCustomer(seats: 31);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Billing::class)
|
||||
->call('purchase', 'downgrade', 'start');
|
||||
|
||||
expect(Order::query()->where('customer_id', $customer->id)->where('type', 'downgrade')->count())->toBe(0);
|
||||
});
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Dashboard;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\InstanceMetric;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* The measurements behind the panel's ring and trend.
|
||||
*
|
||||
* The rule they all serve: a reading that could not be taken is not a reading
|
||||
* of zero. Charting a failed sample as 0 GB tells a customer their data is
|
||||
* gone, and charting a missing day as no traffic draws an outage that never
|
||||
* happened.
|
||||
*/
|
||||
|
||||
function metricFor(Instance $instance, string $day, ?int $used, ?int $total, int $rx = 0, int $tx = 0): InstanceMetric
|
||||
{
|
||||
return InstanceMetric::create([
|
||||
'instance_id' => $instance->id,
|
||||
'day' => $day,
|
||||
'disk_used_bytes' => $used,
|
||||
'disk_total_bytes' => $total,
|
||||
'rx_bytes' => $rx,
|
||||
'tx_bytes' => $tx,
|
||||
]);
|
||||
}
|
||||
|
||||
function customerWithInstance(): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$customer = Customer::factory()->create(['user_id' => $user->id]);
|
||||
$instance = Instance::factory()->create(['customer_id' => $customer->id, 'status' => 'active', 'quota_gb' => 500]);
|
||||
|
||||
return [$user, $instance];
|
||||
}
|
||||
|
||||
it('shows the allowance, not a ring, until something has been measured', function () {
|
||||
[$user, $instance] = customerWithInstance();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Dashboard::class)
|
||||
->assertViewHas('disk', null)
|
||||
// The contractual figure is true and says so; a ring at 0 % would read
|
||||
// as "your cloud is empty".
|
||||
->assertSee(__('dashboard.master.storage_note'));
|
||||
});
|
||||
|
||||
it('draws the ring once a reading exists', function () {
|
||||
[$user, $instance] = customerWithInstance();
|
||||
|
||||
metricFor($instance, Carbon::today()->toDateString(), used: 235_000_000_000, total: 500_000_000_000);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Dashboard::class)
|
||||
->assertViewHas('disk', fn (?array $d) => $d !== null && (int) round($d['percent']) === 47);
|
||||
});
|
||||
|
||||
it('keeps the last good reading when a sample could not be taken', function () {
|
||||
[$user, $instance] = customerWithInstance();
|
||||
|
||||
metricFor($instance, Carbon::yesterday()->toDateString(), used: 200_000_000_000, total: 500_000_000_000);
|
||||
// Today's row exists — the traffic half of the sampler wrote it — but the
|
||||
// guest agent did not answer, so the disk columns stayed null.
|
||||
metricFor($instance, Carbon::today()->toDateString(), used: null, total: null, rx: 5_000);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Dashboard::class)
|
||||
->assertViewHas('disk', fn (?array $d) => $d !== null && $d['used'] === 200_000_000_000);
|
||||
});
|
||||
|
||||
it('passes the measured days to the trend, and only those', function () {
|
||||
[$user, $instance] = customerWithInstance();
|
||||
|
||||
metricFor($instance, Carbon::today()->subDays(2)->toDateString(), null, null, rx: 1_000, tx: 500);
|
||||
metricFor($instance, Carbon::today()->subDay()->toDateString(), null, null, rx: 2_000, tx: 500);
|
||||
metricFor($instance, Carbon::today()->toDateString(), null, null, rx: 4_000, tx: 1_000);
|
||||
// Outside the window the panel draws.
|
||||
metricFor($instance, Carbon::today()->subDays(40)->toDateString(), null, null, rx: 9_999_999);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Dashboard::class)
|
||||
->assertViewHas('trend', [1_500, 2_500, 5_000]);
|
||||
});
|
||||
|
|
@ -175,7 +175,7 @@ it('shows the customer what is left, and offers a top-up when it gets tight', fu
|
|||
|
||||
// Comfortable: no upsell in the customer's face.
|
||||
Livewire\Livewire::actingAs($user)->test(Dashboard::class)
|
||||
->assertSee(__('dashboard.traffic.title'))
|
||||
->assertSee(__('dashboard.traffic.label'))
|
||||
->assertDontSee(__('dashboard.traffic.buy'));
|
||||
|
||||
InstanceTraffic::query()->update(['tx_bytes' => (int) (1000 * 1000 ** 3 * 0.9)]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue