460 lines
28 KiB
PHP
460 lines
28 KiB
PHP
<div class="mx-auto max-w-[1120px] space-y-6">
|
|
<div class="animate-rise">
|
|
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('integrations.title') }}</h1>
|
|
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('integrations.subtitle') }}</p>
|
|
</div>
|
|
|
|
{{-- ── The tabs ──────────────────────────────────────────────────────
|
|
This was one narrow column of six cards: an operator connecting DNS
|
|
scrolled past Stripe to reach it, and the raw .env editor sat at the
|
|
bottom of everything. Split by what a section is FOR — services we buy
|
|
from outside, our own machines, the file underneath — which is the same
|
|
axis this page was rebuilt around when it stopped being split by
|
|
storage mechanism.
|
|
|
|
The tab lives in the query string, so a reload or a bookmark lands
|
|
where the operator was. Unlocking survives a switch: the password
|
|
confirmation is a session fact, not a property of a tab. --}}
|
|
<div class="flex flex-wrap items-center gap-x-1 gap-y-2 border-b border-line animate-rise [animation-delay:40ms]" role="tablist">
|
|
@foreach ($tabs as $name)
|
|
<button type="button" role="tab" wire:click="$set('tab', '{{ $name }}')"
|
|
aria-selected="{{ $tab === $name ? 'true' : 'false' }}"
|
|
@class([
|
|
'flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors -mb-px',
|
|
'border-accent-active text-ink' => $tab === $name,
|
|
'border-transparent text-muted hover:text-ink' => $tab !== $name,
|
|
])>
|
|
<x-ui.icon :name="['services' => 'plug', 'platform' => 'server', 'env' => 'file-text'][$name] ?? 'settings'"
|
|
class="size-4" />{{ __('integrations.tab.'.$name) }}
|
|
</button>
|
|
@endforeach
|
|
</div>
|
|
|
|
@if ($canSecrets && ! $usable)
|
|
<x-ui.alert variant="warning">{{ __('secrets.no_key') }}</x-ui.alert>
|
|
@endif
|
|
|
|
{{-- The one lock for everything write-only on this page: every vault
|
|
entry below, and the .env editor further down. Plain settings never
|
|
sit behind this — they were never gated by a password before, and
|
|
nothing about merging the pages makes them more dangerous. --}}
|
|
@if ($canSecrets && ! $unlocked)
|
|
<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>
|
|
|
|
<div class="mt-4 flex flex-wrap items-start gap-2">
|
|
<div class="min-w-56 flex-1">
|
|
<x-ui.input name="confirmablePassword" type="password" autocomplete="current-password"
|
|
:label="__('admin_settings.password_current')" wire:model="confirmablePassword" />
|
|
</div>
|
|
<x-ui.button type="submit" variant="primary" class="mt-7" wire:loading.attr="disabled" wire:target="confirmPassword">
|
|
{{ __('secrets.unlock') }}
|
|
</x-ui.button>
|
|
</div>
|
|
</form>
|
|
@elseif ($canSecrets)
|
|
<div class="flex items-center justify-between rounded-lg border border-accent-border bg-accent-subtle px-4 py-2.5 animate-rise">
|
|
<p class="text-sm text-accent-text">{{ __('secrets.unlocked_note') }}</p>
|
|
<button type="button" wire:click="forgetPasswordConfirmation"
|
|
class="text-xs font-semibold text-accent-text hover:underline">{{ __('secrets.lock_again') }}</button>
|
|
</div>
|
|
@endif
|
|
|
|
@if ($tab === 'services')
|
|
{{-- The switch — above every vault entry, because it governs them: which
|
|
slot of each credential below is in force. Same lock as the vault
|
|
itself (secrets.manage + confirmed password): the mode a stranger
|
|
could type into a form field is only ever applied through
|
|
switchMode()'s own guard, but the button that offers it in the first
|
|
place stays behind the same unlock as everything else on this page. --}}
|
|
@if ($canSecrets)
|
|
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
|
<div>
|
|
<h2 class="font-semibold text-ink">{{ __('integrations.mode_title') }}</h2>
|
|
<p class="mt-1 max-w-xl text-sm text-muted">{{ __('integrations.mode_body') }}</p>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
|
|
{{ $mode->isTest() ? 'border-warning-border bg-warning-bg text-warning' : 'border-success-border bg-success-bg text-success' }}">
|
|
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
|
{{ __('readiness.mode.'.$mode->value) }}
|
|
</span>
|
|
|
|
@if ($unlocked)
|
|
<x-ui.button variant="secondary"
|
|
x-on:click="$dispatch('openModal', { component: 'admin.confirm-switch-mode', arguments: { mode: '{{ $mode->isTest() ? 'live' : 'test' }}' } })">
|
|
{{ $mode->isTest() ? __('integrations.mode_switch_to_live') : __('integrations.mode_switch_to_test') }}
|
|
</x-ui.button>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Spaltenfluss, nicht Raster.
|
|
|
|
Vorher stand hier `grid lg:grid-cols-2 lg:items-start`. Ein Raster legt
|
|
Karte 1 und 2 in EINE Reihe, und die Reihe ist so hoch wie die höhere:
|
|
neben der Zahlungskarte (zwei Zugangsdaten, Hinweise, Knöpfe) stand der
|
|
kurze Katalog-Kasten mit einem halben Bildschirm Leere darunter, und
|
|
dasselbe noch einmal eine Reihe tiefer. Bei fünf Karten sehr
|
|
unterschiedlicher Höhe ist das nicht zu ordnen, indem man sie umsortiert
|
|
— welche wie hoch wird, hängt an Berechtigungen und daran, ob
|
|
entsperrt ist.
|
|
|
|
`columns` füllt stattdessen von oben nach unten und bricht um, wenn die
|
|
Spalte voll ist. `break-inside-avoid` an jeder Karte ist die Zeile, die
|
|
es benutzbar macht: ohne sie zerreißt eine Karte mitten im Formular. --}}
|
|
<div class="columns-1 gap-5 lg:columns-2">
|
|
{{-- Zahlungen (Stripe) — vault only, no plain setting belongs here. --}}
|
|
@if ($canSecrets)
|
|
<div class="mb-5 break-inside-avoid space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
|
<div>
|
|
<h2 class="font-semibold text-ink">{{ __('integrations.payments_title') }}</h2>
|
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.payments_body') }}</p>
|
|
</div>
|
|
<x-admin.secret-field :entry="$entries['stripe.secret']" :unlocked="$unlocked" :mode="$mode" />
|
|
@if ($unlocked)
|
|
<hr class="border-line" />
|
|
{{-- Beide Stripe-Werte auf einer Karte, beide im Tresor, beide
|
|
mit Test-/Live-Platz. Vorher stand hier ein blauer Kasten,
|
|
der erklärte, warum dieser eine Wert in der Serverdatei
|
|
bleiben MUSS — die Begründung hielt nicht (siehe
|
|
App\Support\StripeWebhookSecret), und sie kostete den
|
|
Betreiber einen Wert, den er nur per Dateieditor setzen
|
|
konnte, unter einer Prüfung, die ihn als blockierend
|
|
meldete. --}}
|
|
<x-admin.secret-field :entry="$entries['stripe.webhook_secret']" :unlocked="$unlocked" :mode="$mode" />
|
|
<hr class="border-line" />
|
|
|
|
{{-- Der dritte Wert, den Stripe ausgibt. KEIN Tresoreintrag: er
|
|
ist öffentlich, und ihn hinter „nie wieder vollständig
|
|
angezeigt" zu setzen wäre Theater — man könnte nicht einmal
|
|
nachsehen, welcher eingetragen ist. Also im Klartext, mit
|
|
demselben Platz je Betriebsmodus wie die zwei darüber. --}}
|
|
<div wire:key="stripe-publishable">
|
|
<h3 class="text-sm font-semibold text-ink">{{ __('secrets.item.stripe_publishable_key') }}</h3>
|
|
<p class="mt-0.5 font-mono text-xs text-muted">STRIPE_KEY / STRIPE_KEY_TEST</p>
|
|
|
|
@php $pkMode = \App\Support\OperatingMode::ofStripeKey($stripePublishableKey); @endphp
|
|
@if ($pkMode !== null && $pkMode !== $mode)
|
|
<p class="mt-2 text-xs text-danger">
|
|
{{ __('secrets.publishable_wrong_mode', ['mode' => __('readiness.mode.'.$pkMode->value)]) }}
|
|
</p>
|
|
@endif
|
|
|
|
<div class="mt-3">
|
|
<x-ui.input name="stripePublishableKey" wire:model="stripePublishableKey"
|
|
:label="__('secrets.new_value')" :hint="__('secrets.publishable_hint')" />
|
|
</div>
|
|
|
|
<x-ui.button variant="primary" class="mt-3" wire:click="savePublishableKey"
|
|
wire:loading.attr="disabled" wire:target="savePublishableKey">
|
|
{{ __('secrets.save') }}
|
|
</x-ui.button>
|
|
</div>
|
|
|
|
<x-ui.alert variant="info">{{ __('secrets.webhook_secret_note') }}</x-ui.alert>
|
|
<p class="text-xs leading-relaxed text-muted">{{ __('secrets.publishable_note') }}</p>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Der Katalog-Abgleich. Eigene Karte statt eines Knopfes in der
|
|
Zahlungskarte darüber: er benutzt zwar den Stripe-Schlüssel, ist aber
|
|
nicht dessen Verwaltung — und er hängt an `plans.manage`, nicht am
|
|
Tresor-Schloss. Bis hierher konnte er nur in einer Shell laufen,
|
|
während die Bereitschaftsseite sein Fehlen als blockierend meldete. --}}
|
|
@if ($canCatalogue)
|
|
<div class="mb-5 break-inside-avoid space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:15ms]">
|
|
<div>
|
|
<h2 class="font-semibold text-ink">{{ __('integrations.catalogue_title') }}</h2>
|
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.catalogue_body') }}</p>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-2">
|
|
{{-- Trockenlauf zuerst, und ohne Rückfrage: er ändert nichts. --}}
|
|
<x-ui.button variant="secondary" wire:click="syncCatalogue(true)"
|
|
wire:loading.attr="disabled" wire:target="syncCatalogue">
|
|
{{ __('integrations.catalogue_dry_run') }}
|
|
</x-ui.button>
|
|
<x-ui.button variant="primary"
|
|
x-on:click="$dispatch('openModal', { component: 'admin.confirm-sync-catalogue' })">
|
|
{{ __('integrations.catalogue_sync') }}
|
|
</x-ui.button>
|
|
</div>
|
|
|
|
@if ($catalogueSync === null)
|
|
<p class="text-xs text-muted">{{ __('integrations.catalogue_never_run') }}</p>
|
|
@else
|
|
<div class="rounded-lg border border-line bg-surface-2 p-4">
|
|
<p class="flex flex-wrap items-center gap-2 text-sm">
|
|
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
|
|
{{ $catalogueSync['ok'] ? 'border-success-border bg-success-bg text-success' : 'border-danger-border bg-danger-bg text-danger' }}">
|
|
{{ $catalogueSync['ok'] ? __('integrations.catalogue_ok') : __('integrations.catalogue_failed') }}
|
|
</span>
|
|
<span class="text-muted">
|
|
{{ $catalogueSync['dry_run'] ? __('integrations.catalogue_was_dry_run') : __('integrations.catalogue_was_live_run') }}
|
|
— {{ $catalogueSync['at']->local()->isoFormat('D. MMM YYYY, HH:mm') }},
|
|
{{ $catalogueSync['by'] }}
|
|
</span>
|
|
</p>
|
|
{{-- Wortgleich, nicht zusammengefasst: zwei der Meldungen
|
|
dieses Befehls sind ANWEISUNGEN — die Verweigerung
|
|
nennt drei Spalten, die geleert werden müssen. Wer sie
|
|
zu „fehlgeschlagen" kürzt, schickt den Betreiber
|
|
zurück ins Terminal. Eigener Scrollbereich, weil ein
|
|
erster Lauf eine Zeile je Produkt und Preis druckt. --}}
|
|
<pre class="mt-3 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-xs text-body">{{ $catalogueSync['output'] }}</pre>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
{{-- DNS (Hetzner) — token and the zone/Traefik settings that use it. --}}
|
|
@if ($canSecrets || $canInfra)
|
|
<div class="mb-5 break-inside-avoid space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:30ms]">
|
|
<div>
|
|
<h2 class="font-semibold text-ink">{{ __('integrations.dns_title') }}</h2>
|
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.dns_body') }}</p>
|
|
</div>
|
|
|
|
@if ($canInfra)
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<x-ui.input name="dnsZone" wire:model="dnsZone" :label="__('integrations.dns_zone')" :hint="__('integrations.dns_zone_hint')" />
|
|
<x-ui.input name="traefikDynamicPath" wire:model="traefikDynamicPath" :label="__('integrations.traefik_path')" :hint="__('integrations.traefik_path_hint')" />
|
|
</div>
|
|
@endif
|
|
|
|
@if ($canSecrets)
|
|
@if ($canInfra)<hr class="border-line" />@endif
|
|
<x-admin.secret-field :entry="$entries['dns.token']" :unlocked="$unlocked" :mode="$mode" />
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Das Support-Postfach — IMAP, nicht die mailcow-API: die API verwaltet
|
|
Domains und Postfächer, gelesen wird ein Postfach über IMAP, egal wer
|
|
den Server betreibt. --}}
|
|
@if ($canSecrets || $canInfra)
|
|
<div class="mb-5 break-inside-avoid space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:45ms]">
|
|
<div>
|
|
<h2 class="font-semibold text-ink">{{ __('integrations.inbound_title') }}</h2>
|
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.inbound_body') }}</p>
|
|
</div>
|
|
|
|
@if ($canInfra)
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<x-ui.input name="inboundHost" wire:model="inboundHost" :label="__('integrations.inbound_host')" placeholder="mail.clupilot.com" />
|
|
<x-ui.input name="inboundPort" wire:model="inboundPort" :label="__('integrations.inbound_port')" :hint="__('integrations.inbound_port_hint')" inputmode="numeric" />
|
|
<x-ui.input name="inboundUsername" wire:model="inboundUsername" :label="__('integrations.inbound_username')" placeholder="support@clupilot.com" />
|
|
<x-ui.input name="inboundFolder" wire:model="inboundFolder" :label="__('integrations.inbound_folder')" />
|
|
</div>
|
|
@endif
|
|
|
|
@if ($canSecrets)
|
|
@if ($canInfra)<hr class="border-line" />@endif
|
|
<x-admin.secret-field :entry="$entries['inbound_mail.password']" :unlocked="$unlocked" :mode="$mode" />
|
|
@endif
|
|
|
|
{{-- The one thing an operator wants after typing all of it: did any
|
|
of it work. The button saves first, because testing what is on
|
|
screen while the server still holds the old values would report
|
|
on a mailbox nobody configured. --}}
|
|
@if ($canInfra)
|
|
<div class="flex flex-wrap items-center gap-3 border-t border-line pt-4">
|
|
<x-ui.button wire:click="testInbound" variant="secondary" size="sm"
|
|
wire:loading.attr="disabled" wire:target="testInbound">
|
|
<x-ui.icon name="plug" class="size-4" />{{ __('integrations.inbound_test') }}
|
|
</x-ui.button>
|
|
|
|
{{-- Always said, including "never": a line that appears only
|
|
after a success leaves an operator unable to tell a
|
|
mailbox that has not been checked from one that is fine.
|
|
R19: stored in UTC, read on the wall clock. --}}
|
|
@if ($inboundStatus === null)
|
|
<p class="text-xs text-muted">{{ __('integrations.inbound_never') }}</p>
|
|
@else
|
|
<p @class(['flex items-center gap-1.5 text-xs', 'text-success' => $inboundStatus['ok'], 'text-danger' => ! $inboundStatus['ok']])>
|
|
<x-ui.icon :name="$inboundStatus['ok'] ? 'check' : 'alert-triangle'" class="size-3.5" />
|
|
{{ __($inboundStatus['ok'] ? 'integrations.inbound_last_ok' : 'integrations.inbound_last_failed', [
|
|
'when' => $inboundStatus['at']->local()->isoFormat('D. MMM YYYY, HH:mm'),
|
|
'ago' => $inboundStatus['at']->diffForHumans(),
|
|
]) }}
|
|
</p>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Monitoring — token and where the Kuma bridge is reachable. --}}
|
|
@if ($canSecrets || $canInfra)
|
|
<div class="mb-5 break-inside-avoid space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
|
|
<div>
|
|
<h2 class="font-semibold text-ink">{{ __('integrations.monitoring_title') }}</h2>
|
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.monitoring_body') }}</p>
|
|
</div>
|
|
|
|
@if ($canInfra)
|
|
<x-ui.input name="monitoringUrl" type="url" wire:model="monitoringUrl" :label="__('integrations.monitoring_url')" :hint="__('integrations.monitoring_url_hint')" placeholder="http://kuma-bridge:8080" />
|
|
@endif
|
|
|
|
@if ($canSecrets)
|
|
@if ($canInfra)<hr class="border-line" />@endif
|
|
<x-admin.secret-field :entry="$entries['monitoring.token']" :unlocked="$unlocked" :mode="$mode" />
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
</div>
|
|
@endif
|
|
|
|
@if ($tab === 'platform')
|
|
<div class="columns-1 gap-5 lg:columns-2">
|
|
{{-- VPN/WireGuard — no vault entry: the private hub key stays in .env. --}}
|
|
@if ($canInfra)
|
|
<div class="mb-5 break-inside-avoid 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">{{ __('integrations.vpn_title') }}</h2>
|
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.vpn_body') }}</p>
|
|
</div>
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<x-ui.input name="wgEndpoint" wire:model="wgEndpoint" :label="__('integrations.wg_endpoint')" :hint="__('integrations.wg_endpoint_hint')" placeholder="vpn.clupilot.com:51820" />
|
|
<x-ui.input name="wgHubPubkey" wire:model="wgHubPubkey" :label="__('integrations.wg_hub_pubkey')" :hint="__('integrations.wg_hub_pubkey_hint')" />
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- SSH-Identität — public half a setting, private half the vault entry. --}}
|
|
@if ($canSecrets || $canInfra)
|
|
<div class="mb-5 break-inside-avoid 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">{{ __('integrations.ssh_title') }}</h2>
|
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.ssh_body') }}</p>
|
|
</div>
|
|
|
|
@if ($canInfra)
|
|
<div>
|
|
<label for="sshPublicKey" class="block text-sm font-medium text-body">{{ __('integrations.ssh_public_key') }}</label>
|
|
<textarea id="sshPublicKey" name="sshPublicKey" rows="3" autocomplete="off" spellcheck="false"
|
|
wire:model="sshPublicKey"
|
|
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 font-mono text-xs text-ink placeholder:text-faint transition"
|
|
placeholder="ssh-ed25519 AAAA... clupilot"></textarea>
|
|
<p class="mt-1.5 text-xs text-muted">{{ __('integrations.ssh_public_key_hint') }}</p>
|
|
@error('sshPublicKey')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
|
|
</div>
|
|
@endif
|
|
|
|
@if ($canSecrets)
|
|
@if ($canInfra)<hr class="border-line" />@endif
|
|
<x-admin.secret-field :entry="$entries['ssh.private_key']" :unlocked="$unlocked" :mode="$mode" />
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
</div>
|
|
@endif
|
|
|
|
{{-- One button for both tabs' plain settings. Livewire keeps the values of
|
|
a hidden field, so saving from either tab writes everything the
|
|
operator has typed — which is what they mean by "speichern". --}}
|
|
@if ($canInfra && in_array($tab, ['services', 'platform'], true))
|
|
<div class="flex justify-end">
|
|
<x-ui.button variant="primary" wire:click="saveInfra" wire:loading.attr="disabled" wire:target="saveInfra">
|
|
{{ __('integrations.save_settings') }}
|
|
</x-ui.button>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Part B: the raw .env editor. Everything above has a field; everything
|
|
that does not still needs to be reachable from the console, or an
|
|
operator needing it needs a shell and this whole page misses the
|
|
point. Shares the lock above: secrets.manage AND a confirmed
|
|
password, because it is the one place on this page that can reach
|
|
every credential the vault otherwise keeps write-only. --}}
|
|
@if ($tab === 'env' && $canSecrets)
|
|
<div class="mb-5 break-inside-avoid space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:150ms]">
|
|
<div>
|
|
<h2 class="font-semibold text-ink">{{ __('integrations.env_title') }}</h2>
|
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.env_subtitle') }}</p>
|
|
</div>
|
|
|
|
@if (! $unlocked)
|
|
<p class="flex items-center gap-1.5 text-sm text-muted">
|
|
<x-ui.icon name="lock" class="size-4" />
|
|
{{ __('integrations.env_locked') }}
|
|
</p>
|
|
@else
|
|
<x-ui.alert variant="danger">
|
|
<p class="font-semibold">{{ __('integrations.env_danger_title') }}</p>
|
|
<p class="mt-1">{{ __('integrations.env_danger_body') }}</p>
|
|
</x-ui.alert>
|
|
|
|
<div class="rounded-lg border border-line bg-surface-2 px-4 py-3">
|
|
<p class="text-sm font-semibold text-ink">{{ __('integrations.env_vault_title') }}</p>
|
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.env_vault_body') }}</p>
|
|
<ul class="mt-3 space-y-1.5">
|
|
@foreach ($entries as $entry)
|
|
<li class="flex flex-wrap items-center justify-between gap-2 text-sm">
|
|
<span class="font-mono text-xs text-body">{{ $entry['envKey'] }}</span>
|
|
<span class="text-xs {{ $entry['source'] === 'stored' ? 'font-medium text-warning' : 'text-muted' }}">
|
|
{{ $entry['source'] === 'stored' ? __('integrations.env_vault_active') : __('integrations.env_vault_inactive') }}
|
|
</span>
|
|
</li>
|
|
@endforeach
|
|
</ul>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="envContent" class="block text-sm font-medium text-body">{{ __('integrations.env_label') }}</label>
|
|
<textarea id="envContent" name="envContent" rows="18" autocomplete="off" spellcheck="false"
|
|
wire:model="envContent"
|
|
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 font-mono text-xs text-ink placeholder:text-faint transition"></textarea>
|
|
@error('envContent')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
|
|
</div>
|
|
|
|
<div class="flex justify-end">
|
|
<x-ui.button variant="primary"
|
|
x-on:click="$dispatch('openModal', { component: 'admin.confirm-save-env' })">
|
|
{{ __('integrations.env_save') }}
|
|
</x-ui.button>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
<div class="rounded-lg border border-line bg-surface-2 px-4 py-3">
|
|
<p class="text-sm font-semibold text-ink">{{ __('integrations.env_backup_title') }}</p>
|
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.env_backup_body') }}</p>
|
|
</div>
|
|
{{-- What saving now does automatically (config:clear here,
|
|
the restart via UpdateChannel) — not homework anymore,
|
|
see App\Livewire\Admin\Integrations::saveEnv(). The
|
|
spinner below is this component's own wire:poll, not
|
|
the full-screen deployment overlay: a restart of four
|
|
containers is seconds, not minutes, and `app` — the
|
|
container answering this very poll — is never among
|
|
them, so this page never goes offline for it. --}}
|
|
<div class="rounded-lg border border-line bg-surface-2 px-4 py-3">
|
|
<p class="text-sm font-semibold text-ink">{{ __('integrations.env_restart_title') }}</p>
|
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.env_restart_body') }}</p>
|
|
|
|
@if ($envRestartWatching || $restart['restarting'])
|
|
<p class="mt-2 flex items-center gap-1.5 text-xs font-medium text-accent-text" wire:poll.2s>
|
|
<x-ui.icon name="refresh" class="size-4 animate-spin" />
|
|
{{ __('integrations.env_restarting') }}
|
|
</p>
|
|
@elseif (! $restart['agent_seen'])
|
|
{{-- Honest, not silent: told plainly, with the
|
|
fallback right here — same shape as the update
|
|
card's own admin_settings.update_no_agent. --}}
|
|
<x-ui.alert variant="warning" class="mt-2">{{ __('admin_settings.update_no_agent') }}</x-ui.alert>
|
|
<pre class="mt-2 overflow-x-auto rounded border border-line bg-surface px-3 py-2 font-mono text-[11px] text-body">docker compose restart queue queue-provisioning scheduler reverb</pre>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|