clusev/resources/views/livewire/system/index.blade.php

276 lines
19 KiB
PHP

<div class="mx-auto max-w-5xl space-y-5">
{{-- Header --}}
<div class="flex flex-wrap items-center gap-4 rounded-xl border border-line bg-surface p-5 shadow-panel">
<span class="grid h-14 w-14 shrink-0 place-items-center rounded-xl border border-accent/25 bg-accent/10 text-accent">
<x-icon name="settings" class="h-6 w-6" />
</span>
<div class="min-w-0 flex-1">
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('system.eyebrow') }}</p>
<h2 class="mt-0.5 truncate font-display text-xl font-semibold text-ink">{{ __('system.heading') }}</h2>
<p class="truncate font-mono text-[11px] text-ink-3">{{ __('system.subheading') }}</p>
</div>
<span @class([
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 font-mono text-xs',
'border-online/30 bg-online/10 text-online' => $hasTls,
'border-warning/30 bg-warning/10 text-warning' => ! $hasTls,
])>
<x-status-dot :status="$hasTls ? 'online' : 'warning'" />{{ $hasTls ? __('system.https_active') : __('system.https_plain') }}
</span>
</div>
{{-- ── Critical alerts (full-width, above the grid) ─────────────────────────
Restart-required and bare-IP notices stay prominent banners never buried
in a sidebar. --}}
{{-- Restart-required notice (shown after a successful save). One-click: the button
writes a sentinel a HOST-side watcher reacts to and restarts the stack the
container never gets the Docker socket. No terminal needed. --}}
@if ($restartPending)
<div class="flex items-start gap-3 rounded-xl border border-warning/30 bg-warning/10 p-4 shadow-panel">
<x-icon name="alert" class="mt-0.5 h-5 w-5 shrink-0 text-warning" />
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<p class="font-display text-sm font-semibold text-ink">{{ __('system.restart_title') }}</p>
<p class="mt-0.5 font-mono text-[11px] leading-relaxed text-ink-3">{{ __('system.restart_body') }}</p>
</div>
@if ($restartRequested)
<p class="flex shrink-0 items-center gap-2 font-mono text-[11px] text-accent-text">
<x-icon name="rotate" class="h-3.5 w-3.5 shrink-0" />{{ __('system.restart_running') }}
</p>
@else
<x-btn variant="primary" wire:click="restartNow" wire:loading.attr="disabled" wire:target="restartNow" class="shrink-0">
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('system.restart_now') }}
</x-btn>
@endif
</div>
<p class="mt-2 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('system.restart_watcher_hint') }}</p>
<p class="mt-1 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('system.restart_lockout_hint') }}</p>
</div>
</div>
@endif
{{-- Bare-IP warning: no domain set plaintext HTTP. Full-width call to action. --}}
@unless ($hasTls)
<div class="flex items-start gap-3 rounded-xl border border-warning/25 bg-warning/5 p-4 shadow-panel">
<x-icon name="alert" class="mt-0.5 h-5 w-5 shrink-0 text-warning" />
<div class="min-w-0">
<p class="font-display text-sm font-semibold text-ink">{{ __('system.tls_bare_title') }}</p>
<p class="mt-0.5 font-mono text-[11px] leading-relaxed text-ink-3">{{ __('system.tls_bare_body') }}</p>
</div>
</div>
@endunless
{{-- ── 2-column working area ────────────────────────────────────────────────
LEFT (main): the actionable form domain + Speichern, TLS-mode selector.
RIGHT (side): the status & recovery stack access address, TLS state, SSH reset. --}}
<div class="grid grid-cols-1 gap-5 lg:grid-cols-3">
{{-- LEFT / main column "DO" ────────────────────────────────────────── --}}
<x-panel :title="__('system.tls_title')" :subtitle="__('system.tls_subtitle')" class="lg:col-span-2">
<div class="space-y-6">
{{-- Editable panel domain. Saving persists it and asks for a restart; on restart
app URL, the Reverb endpoint, Caddy's cert and the secure cookie re-derive from it. --}}
<div>
<p class="mb-3 font-mono text-[11px] uppercase tracking-[0.18em] text-ink-4">{{ __('system.section_config') }}</p>
<label for="domainInput" class="mb-1 block text-sm text-ink-2">{{ __('system.domain') }}</label>
<div class="flex flex-col gap-2 sm:flex-row">
<input wire:model="domainInput" id="domainInput" type="text" autocomplete="off"
autocapitalize="off" spellcheck="false"
placeholder="{{ __('system.domain_placeholder') }}"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
<x-modal-trigger variant="primary" action="confirmDomain" class="shrink-0">
<x-icon name="save" class="h-3.5 w-3.5" /> {{ __('common.save') }}
</x-modal-trigger>
</div>
@error('domainInput')
<p class="mt-1.5 font-mono text-[11px] text-offline">{{ $message }}</p>
@enderror
<p class="mt-1.5 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('system.domain_help') }}</p>
</div>
{{-- TLS-mode selector --}}
<div class="space-y-3 border-t border-line pt-5">
<p class="text-sm font-medium text-ink-2">{{ __('system.tls_mode_title') }}</p>
{{-- Segmented control (mirrors the channel selector) --}}
<div role="radiogroup" aria-label="{{ __('system.tls_mode_title') }}" class="flex w-full rounded-md border border-line bg-inset p-1">
<button type="button"
role="radio"
aria-checked="{{ $tlsMode === 'caddy' ? 'true' : 'false' }}"
wire:click="confirmTlsMode('caddy')"
x-data="modalTrigger(null, {}, @js(__('shell.toast_modal_failed')), 3500)"
x-on:click="arm()"
x-bind:disabled="pending"
x-bind:aria-busy="pending"
@class([
'inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded font-mono text-xs uppercase tracking-wider transition-colors',
'bg-accent/15 text-accent-text shadow-[inset_0_0_0_1px_var(--color-accent)]' => $tlsMode === 'caddy',
'text-ink-3 hover:bg-raised hover:text-ink-2' => $tlsMode !== 'caddy',
])>
<svg x-show="pending" x-cloak class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<span x-show="!pending" class="contents">@if ($tlsMode === 'caddy')<x-icon name="shield" class="h-3.5 w-3.5" />@endif{{ __('system.tls_mode_caddy') }}</span>
</button>
<button type="button"
role="radio"
aria-checked="{{ $tlsMode === 'external' ? 'true' : 'false' }}"
wire:click="confirmTlsMode('external')"
x-data="modalTrigger(null, {}, @js(__('shell.toast_modal_failed')), 3500)"
x-on:click="arm()"
x-bind:disabled="pending"
x-bind:aria-busy="pending"
@class([
'inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded font-mono text-xs uppercase tracking-wider transition-colors',
'bg-accent/15 text-accent-text shadow-[inset_0_0_0_1px_var(--color-accent)]' => $tlsMode === 'external',
'text-ink-3 hover:bg-raised hover:text-ink-2' => $tlsMode !== 'external',
])>
<svg x-show="pending" x-cloak class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<span x-show="!pending" class="contents">@if ($tlsMode === 'external')<x-icon name="shield" class="h-3.5 w-3.5" />@endif{{ __('system.tls_mode_external') }}</span>
</button>
</div>
{{-- Per-mode description --}}
<div class="flex items-start gap-3 rounded-md border border-line bg-raised/40 p-3">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-accent-text" />
<div class="min-w-0">
<p class="text-sm text-ink">
@if ($tlsMode === 'caddy')
{{ __('system.tls_mode_caddy_desc') }}
@else
{{ __('system.tls_mode_external_desc') }}
@endif
</p>
</div>
</div>
{{-- External-proxy hint (warning tone) --}}
@if ($tlsMode === 'external')
<div class="flex items-start gap-3 rounded-md border border-warning/25 bg-warning/10 p-3">
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p class="font-mono text-[11px] leading-relaxed text-ink-2">{{ __('system.tls_external_hint') }}</p>
</div>
@endif
</div>
</div>
</x-panel>
{{-- RIGHT / side column — "KNOW / RECOVER" ──────────────────────────── --}}
<x-panel :title="__('system.section_status')" class="lg:col-span-1">
<div class="space-y-4">
{{-- Current access address (read-only status) --}}
<div class="rounded-md border border-line bg-inset/60 p-3">
<p class="font-mono text-[11px] uppercase tracking-wider text-ink-4">{{ __('system.access_address') }}</p>
<p class="mt-1 min-w-0 break-all font-mono text-sm text-ink">
@if ($panelUrl)
<a href="{{ $panelUrl }}" class="text-accent-text hover:underline">{{ $panelUrl }}</a>
@else
{{ __('system.access_bare_ip') }}
@endif
</p>
</div>
{{-- Current TLS state --}}
@if ($hasTls)
<div class="flex items-start gap-3 rounded-md border border-online/25 bg-online/5 p-3">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-online" />
<div class="min-w-0">
<p class="text-sm text-ink">{{ $tlsMode === 'external' ? __('system.tls_active_external_title') : __('system.tls_active_title') }}</p>
<p class="mt-0.5 font-mono text-[11px] leading-relaxed text-ink-3">{{ __('system.tls_active_body_pre') }}
<a href="{{ $panelUrl }}" class="break-all text-accent-text hover:underline">{{ $panelUrl }}</a> ·
{{ $tlsMode === 'external' ? __('system.tls_active_external_post') : __('system.tls_active_body_post') }}</p>
</div>
</div>
@else
<div class="flex items-start gap-3 rounded-md border border-warning/25 bg-warning/5 p-3">
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<div class="min-w-0">
<p class="text-sm text-ink">{{ __('system.tls_bare_title') }}</p>
<p class="mt-0.5 font-mono text-[11px] leading-relaxed text-ink-3">{{ __('system.tls_bare_body') }}</p>
</div>
</div>
@endif
{{-- TLS certificate: explicit DNS check + on-demand issuance trigger + status.
Only in Caddy mode with an active domain; the external proxy owns TLS otherwise. --}}
@if ($showCert)
@php $certState = $certStatus['status'] ?? null; @endphp
<div class="rounded-md border border-line bg-inset/60 p-3">
<p class="font-mono text-[11px] uppercase tracking-wider text-ink-4">{{ __('system.cert_title') }}</p>
<p class="mt-1 font-mono text-[11px] leading-relaxed">
@switch($certState)
@case('issued')
<span class="text-online">{{ __('system.cert_status_issued') }}</span>
@break
@case('dns_mismatch')
<span class="text-warning">{{ __('system.cert_status_dns_mismatch') }}</span>
@break
@case('failed')
<span class="text-offline">{{ __('system.cert_status_failed') }}</span>
@break
@default
<span class="text-ink-3">{{ __('system.cert_status_unknown') }}</span>
@endswitch
</p>
<x-btn variant="secondary" size="sm" class="mt-2.5"
wire:click="requestCertificate" wire:target="requestCertificate" wire:loading.attr="disabled">
<x-icon name="shield" class="h-3.5 w-3.5" wire:loading.remove wire:target="requestCertificate" />
<svg wire:loading wire:target="requestCertificate" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ __('system.cert_button') }}
</x-btn>
</div>
@elseif ($tlsMode === 'external')
<div class="rounded-md border border-line bg-inset/60 p-3">
<p class="font-mono text-[11px] uppercase tracking-wider text-ink-4">{{ __('system.cert_title') }}</p>
<p class="mt-1 font-mono text-[11px] leading-relaxed text-ink-3">{{ __('system.cert_external_note') }}</p>
</div>
@endif
{{-- Last-resort recovery: SSH into the host and reset admin access. --}}
<div class="flex items-start gap-2.5 border-t border-line pt-4 text-ink-4">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0" />
<p class="font-mono text-[11px] leading-relaxed">{{ __('system.ssh_reset_hint') }}</p>
</div>
</div>
</x-panel>
</div>
{{-- ── Release-Kanal (full-width band, below the grid) ──────────────────── --}}
<x-panel :title="__('system.channel_title')" :subtitle="__('system.channel_subtitle')">
<div class="space-y-4">
{{-- Segmented control --}}
<div role="radiogroup" aria-label="{{ __('system.channel_radiogroup_label') }}" class="inline-flex w-full max-w-md rounded-md border border-line bg-inset p-1">
@foreach ($channels as $key => $desc)
{{-- wire:key keeps Livewire's morph from cloning by position, which would drop the
inline Alpine x-data (modalTrigger) scope "pending is not defined". --}}
<button type="button"
wire:key="channel-{{ $key }}"
role="radio"
aria-checked="{{ $channel === $key ? 'true' : 'false' }}"
wire:click="confirmChannel('{{ $key }}')"
x-data="modalTrigger(null, {}, @js(__('shell.toast_modal_failed')), 3500)"
x-on:click="arm()"
x-bind:disabled="pending"
x-bind:aria-busy="pending"
@class([
'inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded font-mono text-xs uppercase tracking-wider transition-colors',
'bg-accent/15 text-accent-text shadow-[inset_0_0_0_1px_var(--color-accent)]' => $channel === $key,
'text-ink-3 hover:bg-raised hover:text-ink-2' => $channel !== $key,
])>
<svg x-show="pending" x-cloak class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<span x-show="!pending" class="contents">@if ($channel === $key)<x-icon name="git-branch" class="h-3.5 w-3.5" />@endif{{ $key }}</span>
</button>
@endforeach
</div>
{{-- Per-channel description --}}
<div class="flex items-start gap-3 rounded-md border border-line bg-raised/40 p-3">
<x-icon name="git-branch" class="mt-0.5 h-4 w-4 shrink-0 text-accent-text" />
<div class="min-w-0">
<p class="text-sm text-ink">{{ __('system.channel_current') }} <span class="font-mono text-accent-text">{{ $channel }}</span></p>
<p class="mt-0.5 font-mono text-[11px] text-ink-3">{{ $channels[$channel] ?? '' }}</p>
</div>
</div>
</div>
</x-panel>
</div>