@extends("client.layouts.app") @section("title", __('client.hosting.containers.title')) @section("content") {{ $service->product?->name ?? __('client.services.title') }}

{{ __('client.hosting.containers.title') }}

{{ __('client.hosting.containers.subtitle') }}
{{ $policy['max'] < 0 ? $policy['used'].' / ∞' : $policy['used'].' / '.$policy['max'] }}
@if(! $policy['enabled'])
{{ __('client.hosting.containers.plan_disabled') }}
@else {{-- What a customer comes here to do most days is look at the apps they already run - open one, restart it, read its connection details. That sat below a catalogue of ninety-eight, so it may as well not have been there. CSS order puts the running apps first; the markup order is untouched. --}}
@if($policy['can_create'])
{{ __('client.hosting.containers.install_title') }}
@if(empty($templates))
{{ __('client.hosting.containers.no_apps') }}
@else
@csrf {{-- Search first: with 98 apps across nine sections, scrolling to find one is the slow path. Filtering happens in the browser because the whole catalogue is already on the page. --}} @foreach($groups as $g)
{{ __('client.hosting.containers.group_'.$g['key']) }} {{ __('client.hosting.containers.group_'.$g['key'].'_hint') }}
@foreach($g['apps'] as $t) @php // One mark per app, drawn here rather than fetched. // // The catalogue's logo_url points at other people's servers: // most apps have none, and of the ones that do, roughly half // are dead links that render as a broken image. That made the // grid look half-finished and sent every customer's browser to // github/jsdelivr on page load. A letter tile keyed off the // slug is the same for every app, always loads, and leaks // nothing. $ctHue = crc32($t['slug']) % 360; $ctInitial = mb_strtoupper(mb_substr(trim($t['name']) ?: $t['slug'], 0, 1)); // An app that asks for more than the plan allows would install // and then be starved, so say so before it is chosen. $ctTooBig = $resources['memory_mb'] > 0 && $t['min_memory_mb'] > $resources['memory_mb']; @endphp
@if($t['is_popular'])@endif @if(! empty($logos[$t['slug']])) @else
{{ $ctInitial }}
@endif
{{ $t['name'] }}
{{ $t['description'] }}
@if($t['min_memory_mb'] > 0) {{ $t['min_memory_mb'] >= 1024 ? round($t['min_memory_mb']/1024, 1).' GB' : $t['min_memory_mb'].' MB' }} @endif @if($t['min_cpu_percent'] > 0) {{ $t['min_cpu_percent'] }}% @endif @if($t['min_memory_mb'] <= 0 && $t['min_cpu_percent'] <= 0) {{ __('client.hosting.containers.needs_light') }} @endif @if(($t['extra_services'] ?? 0) > 0) {{-- The floor above is the main container's. A template that also starts a database, a cache or an ML worker spends the same allowance on those, so saying "2 GB" on its own would not be true. --}} {{ trans_choice('client.hosting.containers.services', $t['extra_services'] + 1, ['count' => $t['extra_services'] + 1]) }} @endif
@if($ctTooBig)
{{ __('client.hosting.containers.over_plan') }}
@endif
@endforeach
@endforeach {{-- Moved next to whichever app is chosen, rather than living at the bottom of a page with ninety-eight cards on it. Kept as one form so there is a single name field and a single submit, not ninety-eight. --}} @if($resources['memory_mb'] > 0 || $resources['cpu_percent'] > 0)
{{ __('client.hosting.containers.plan_ceiling', [ 'ram' => $resources['memory_mb'] > 0 ? ($resources['memory_mb'] >= 1024 ? round($resources['memory_mb']/1024, 1).' GB' : $resources['memory_mb'].' MB') : __('client.hosting.containers.unlimited'), 'cpu' => $resources['cpu_percent'] > 0 ? $resources['cpu_percent'].'%' : __('client.hosting.containers.unlimited'), ]) }}
@endif
{{ __('client.hosting.containers.install_hint') }}
@endif
@else
{{ __('client.hosting.containers.limit_reached') }} ({{ $policy['used'] }}/{{ $policy['max'] }})
@endif
{{ __('client.hosting.containers.running_title') }}
@if(empty($containers))
{{ __('client.hosting.containers.empty') }}
@else
@foreach($grouped as $g) @php $c = $g['main']; $components = $g['components']; @endphp @php // Installing an app is only half the job: it has to be reachable on // the customer's own address. $domains is an id => name map. $linkedId = null; foreach ($links as $dId => $l) { if (($l['container_id'] ?? '') === $c['id'] && isset($domains[$dId])) { $linkedId = $dId; break; } } $freeDomains = array_diff_key($domains, $links); $acc = $access[$c['id']] ?? null; $logo = \App\Models\DockerApp::forContainer($logos, $c['image'], $c['template']); $running = $c['state'] === 'running'; @endphp
@if($logo) @else {{ strtoupper(substr($c['template'] ?: $c['name'], 0, 1)) }} @endif
{{ $c['name'] }}
{{ $c['image'] }}
@if($running){{ __('client.hosting.containers.running') }} @elseif($c['state'] === 'restarting') {{-- Restarting on a loop means the app is failing to start. Saying "restarting" leaves the customer watching a spinner that never ends. --}} {{ __('client.hosting.containers.crashing') }} @else{{ $c['state'] ?: __('client.hosting.containers.stopped') }}@endif
@if($running)
@csrf
@csrf
@else
@csrf
@endif
@csrf
@if($c['state'] === 'restarting')
{{ __('client.hosting.containers.crashing_hint') }}
@endif @if($components) {{-- The app's helpers (database, cache). Drawn inside the app's card, without their own action buttons: start, stop and delete act on the whole app, and a database cannot be removed from under a running site. --}}
{{ __('client.hosting.containers.components') }} @foreach($components as $comp) {{ ucfirst($comp['role'] ?: $comp['name']) }} @endforeach
@endif
CPU{{ $running ? number_format($c['cpu_percent'], 1).'%' : '—' }}
RAM{{ $running && $c['mem_usage'] >= 1048576 ? number_format($c['mem_usage']/1048576, 0).' MB' : '—' }}@if($running && $c['mem_limit'] > 0) / {{ number_format($c['mem_limit']/1048576, 0) }} MB @endif
{{ __('client.hosting.containers.ports') }} @forelse($c['ports'] as $p){{ $p }}@empty — @endforelse
{{ __('client.hosting.containers.domain') }} @if($linkedId) {{ $domains[$linkedId] }}
@csrf
@elseif($running && $freeDomains)
@csrf
@elseif(! $running) {{ __('client.hosting.containers.domain_needs_running') }} @else {{ __('client.hosting.containers.domain_none') }} @endif
{{-- The footer is not conditional on there being credentials: a plain OS container has none, and it is exactly the one that needs the shell button most. --}}
@if($acc && $acc->accessUrl()) {{-- The address is the first thing anyone wants after installing, so it is a button, not a line inside a disclosure. --}} {{ __('client.hosting.containers.open_app') }} @endif {{-- A plain OS container runs `sleep infinity` and has no SSH in it, so the only way in is a shell through the panel. Root or the image's own user, because a hardened image drops to a non-root user and half the work needs the other one. --}} @if($acc && ($acc->items() || $acc->notes())) @php // The raw list mixed URLs, logins and infrastructure in // whatever order they were stored. Read top to bottom it // should answer "where do I go, who am I, what's the // password" - so: addresses, then usernames, then secrets, // then the plumbing. $accWeight = function (string $label, string $value): int { if (\Illuminate\Support\Str::startsWith($value, ['http://', 'https://'])) return 0; if (preg_match('/(user|login|email)/i', $label) && ! preg_match('/(password|passwd|secret|token)/i', $label)) return 1; if (preg_match('/(password|passwd|secret|token|key)/i', $label)) return 2; if (preg_match('/(dir|path|directory)/i', $label)) return 4; return 3; }; $accItems = collect($acc->items()) ->sortBy(fn ($v, $k) => $accWeight((string) $k, (string) $v)) ->all(); // ENV_STYLE_LABELS read like configuration, not like a page. $accLabel = function (string $label): string { if (! preg_match('/^[A-Z0-9_]+$/', $label)) return $label; $t = ucwords(strtolower(str_replace('_', ' ', $label))); return str_ireplace(['Db ', 'Wp ', 'Url', 'Ftp ', 'Ssh '], ['DB ', 'WP ', 'URL', 'FTP ', 'SSH '], $t); }; @endphp
{{ __('client.hosting.containers.access_title') }}
@foreach($accItems as $label => $value) @php // A generated password should not sit on screen in a // shared office; it is one click away instead. $secret = (bool) preg_match('/(password|passwd|secret|token|key)/i', $label); $isUrl = Str::startsWith($value, ['http://', 'https://']); @endphp
{{ $accLabel($label) }} @if($isUrl) {{ $value }} @else $secret]) data-value="{{ $value }}">{{ $secret ? str_repeat('•', 10) : $value }} @endif
@endforeach @if($acc->notes())
{{ __('client.hosting.containers.notes_title') }}

{{ $acc->notes() }}

@endif {{-- On the customer's own request the same details land in their inbox - passwords included, that is the point. --}}
@csrf
@endif
@endforeach
{{-- A shell inside a container is a real need - installing a plugin by hand, reading a log, running a migration. The panel already has a terminal per container and the hosting account may use it, so this points at it instead of describing it and leaving the customer to find the door. --}}
{{ __('client.hosting.containers.panel_hint') }} {{ __('client.hosting.containers.open_panel') }}
@endif
@endif @endsection