mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-19 18:15:31 +00:00
feat(server): improve private key and cloud link flows
Add server private key generation from the dropdown, prevent deleting private keys that are still in use, and close cloud provider link modals after successful linking.
This commit is contained in:
1 parent
9b8aeb4758
commit
0217e2b0c0
14 files changed
+822
-252
No files matched your search
@@ -1,4 +1,4 @@
|
||||
@if ($authDisabled)
|
||||
@if ($authDisabled || filled($tooltip))
|
||||
<span class="relative inline-flex"
|
||||
x-data="{ visible: false, _t: null }"
|
||||
@mouseenter="_t = setTimeout(() => {
|
||||
@@ -39,9 +39,9 @@
|
||||
@endif
|
||||
@endif
|
||||
</button>
|
||||
@if ($authDisabled)
|
||||
@if ($authDisabled || filled($tooltip))
|
||||
<div x-ref="tip" x-show="visible" x-cloak class="auth-tooltip">
|
||||
You do not have permission to perform this action.
|
||||
{{ $tooltip ?: 'You do not have permission to perform this action.' }}
|
||||
</div>
|
||||
</span>
|
||||
@endif
|
||||
@@ -1,6 +1,47 @@
|
||||
<div x-data="{ open: false }" @click.stop="open = !open" @click.outside="open = false"
|
||||
{{ $attributes->merge(['class' => 'group relative inline-block align-middle']) }}>
|
||||
<div class="info-helper">
|
||||
<div x-data="{
|
||||
open: false,
|
||||
pinned: false,
|
||||
style: '',
|
||||
show(pinned = false) {
|
||||
this.pinned = pinned;
|
||||
this.open = true;
|
||||
this.$nextTick(() => this.position());
|
||||
},
|
||||
hide() {
|
||||
if (!this.pinned) {
|
||||
this.open = false;
|
||||
}
|
||||
},
|
||||
close() {
|
||||
this.pinned = false;
|
||||
this.open = false;
|
||||
},
|
||||
position() {
|
||||
const trigger = this.$refs.trigger;
|
||||
const popup = this.$refs.popup;
|
||||
|
||||
if (!trigger || !popup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const popupRect = popup.getBoundingClientRect();
|
||||
const padding = 8;
|
||||
let top = triggerRect.bottom + padding;
|
||||
let left = triggerRect.right - popupRect.width;
|
||||
|
||||
if (top + popupRect.height > window.innerHeight - padding) {
|
||||
top = triggerRect.top - popupRect.height - padding;
|
||||
}
|
||||
|
||||
left = Math.min(Math.max(padding, left), window.innerWidth - popupRect.width - padding);
|
||||
top = Math.max(padding, top);
|
||||
|
||||
this.style = `top: ${top}px; left: ${left}px;`;
|
||||
}
|
||||
}" @click.outside="close" @keydown.window.escape="close" @resize.window="open && position()" @scroll.window="open && position()"
|
||||
{{ $attributes->merge(['class' => 'inline-block align-middle']) }}>
|
||||
<div x-ref="trigger" class="info-helper" @mouseenter="show(false)" @mouseleave="hide" @click.stop="open && pinned ? close() : show(true)">
|
||||
@isset($icon)
|
||||
{{ $icon }}
|
||||
@else
|
||||
@@ -9,11 +50,13 @@
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
@endisset
|
||||
|
||||
</div>
|
||||
<div class="info-helper-popup" :class="{ 'block': open }">
|
||||
<div class="p-4">
|
||||
{!! $helper !!}
|
||||
<template x-teleport="body">
|
||||
<div x-ref="popup" x-show="open" x-cloak :style="style"
|
||||
class="fixed z-[9999] w-max max-w-[min(20rem,calc(100vw-2rem))] rounded-sm border border-neutral-300 bg-neutral-200 text-xs text-neutral-700 shadow-lg whitespace-normal break-words dark:border-coolgray-500 dark:bg-coolgray-400 dark:text-neutral-300">
|
||||
<div class="p-4">
|
||||
{!! $helper !!}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -6,6 +6,7 @@
|
||||
'buttonFullWidth' => false,
|
||||
'customButton' => null,
|
||||
'disabled' => false,
|
||||
'disabledTooltip' => null,
|
||||
'authDisabled' => false,
|
||||
'dispatchAction' => false,
|
||||
'submitAction' => 'delete',
|
||||
@@ -156,11 +157,11 @@
|
||||
@else
|
||||
@if ($disabled)
|
||||
@if ($buttonFullWidth)
|
||||
<x-forms.button class="w-full" isError disabled :authDisabled="$authDisabled" wire:target>
|
||||
<x-forms.button class="w-full" isError disabled :authDisabled="$authDisabled" :tooltip="$disabledTooltip" wire:target>
|
||||
{{ $buttonTitle }}
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button isError disabled :authDisabled="$authDisabled" wire:target>
|
||||
<x-forms.button isError disabled :authDisabled="$authDisabled" :tooltip="$disabledTooltip" wire:target>
|
||||
{{ $buttonTitle }}
|
||||
</x-forms.button>
|
||||
@endif
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
'content' => null,
|
||||
'closeOutside' => true,
|
||||
'isFullWidth' => false,
|
||||
'wireIgnore' => true,
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -17,7 +18,7 @@
|
||||
<div x-data="{ modalOpen: false }"
|
||||
x-init="$watch('modalOpen', value => { if (!value) { $wire.dispatch('modalClosed') } })"
|
||||
:class="{ 'z-40': modalOpen }" @keydown.window.escape="modalOpen=false"
|
||||
class="relative w-auto h-auto" @close-modal.window="modalOpen=false" wire:ignore>
|
||||
class="relative w-auto h-auto" @close-modal.window="modalOpen=false" @if ($wireIgnore) wire:ignore @endif>
|
||||
@if ($content)
|
||||
<div @click="modalOpen=true">
|
||||
{{ $content }}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
@if (data_get($private_key, 'id') > 0)
|
||||
@can('delete', $private_key)
|
||||
<x-modal-confirmation title="Confirm Private Key Deletion?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="delete({{ $private_key->id }})" :actions="[
|
||||
submitAction="delete({{ $private_key->id }})" :disabled="$isInUse" :disabledTooltip="$deleteDisabledReason" :actions="[
|
||||
'This private key will be permanently deleted.',
|
||||
'All servers connected to this private key will stop working.',
|
||||
'Any git app using this private key will stop working.',
|
||||
|
||||
@@ -9,9 +9,54 @@
|
||||
<div class="flex items-end gap-2">
|
||||
<h2>Private Key</h2>
|
||||
@can('createAnyResource')
|
||||
<x-modal-input buttonTitle="+ Add" title="New Private Key">
|
||||
<livewire:security.private-key.create />
|
||||
</x-modal-input>
|
||||
<div x-data="{ dropdownOpen: false }" class="relative w-fit" @click.outside="dropdownOpen = false">
|
||||
<x-forms.button isHighlighted @click="dropdownOpen = !dropdownOpen" type="button">
|
||||
+ Add
|
||||
<svg class="w-4 h-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
|
||||
</svg>
|
||||
</x-forms.button>
|
||||
|
||||
<div x-show="dropdownOpen" @click.away="dropdownOpen=false" x-transition:enter="ease-out duration-200"
|
||||
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
|
||||
class="absolute top-0 z-50 mt-10 min-w-max" x-cloak>
|
||||
<div
|
||||
class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
|
||||
<div class="flex flex-col gap-1">
|
||||
<a class="dropdown-item" wire:click="generatePrivateKey('ed25519')"
|
||||
@click="dropdownOpen = false">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Generate ED25519
|
||||
</a>
|
||||
<a class="dropdown-item" wire:click="generatePrivateKey('rsa')"
|
||||
@click="dropdownOpen = false">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Generate RSA
|
||||
</a>
|
||||
<x-modal-input title="Add Private Key Manually">
|
||||
<x-slot:content>
|
||||
<div class="dropdown-item" @click="dropdownOpen = false">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Add manually
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<livewire:security.private-key.create />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endcan
|
||||
<x-forms.button canGate="update" :canResource="$server" isHighlighted wire:click.prevent='checkConnection'>
|
||||
Check connection
|
||||
@@ -26,15 +71,21 @@
|
||||
<div class="box-title">{{ $private_key->name }}</div>
|
||||
<div class="box-description">{{ $private_key->description }}</div>
|
||||
</div>
|
||||
@if (data_get($server, 'privateKey.uuid') !== $private_key->uuid)
|
||||
<x-forms.button canGate="update" :canResource="$server" class="w-full" wire:click='setPrivateKey({{ $private_key->id }})'>
|
||||
Use this key
|
||||
<div class="grid w-full grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<x-forms.button class="w-full"
|
||||
@click.prevent="copyPublicKeyToClipboard({{ Js::from($private_key->public_key) }})">
|
||||
Copy public key
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button class="w-full" disabled>
|
||||
Currently used
|
||||
</x-forms.button>
|
||||
@endif
|
||||
@if (data_get($server, 'privateKey.uuid') !== $private_key->uuid)
|
||||
<x-forms.button canGate="update" :canResource="$server" class="w-full" wire:click='setPrivateKey({{ $private_key->id }})'>
|
||||
Use this key
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button class="w-full" disabled>
|
||||
Currently used
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div>No private keys found. </div>
|
||||
@@ -42,4 +93,31 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@script
|
||||
<script>
|
||||
window.copyPublicKeyToClipboard = (publicKey) => {
|
||||
if (!publicKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
Livewire.dispatch('error', ['Failed to copy public key to clipboard.']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(publicKey).then(() => {
|
||||
Livewire.dispatch('success', ['Public key copied to clipboard.']);
|
||||
}).catch(() => {
|
||||
Livewire.dispatch('error', ['Failed to copy public key to clipboard.']);
|
||||
});
|
||||
};
|
||||
|
||||
$wire.on('copyPublicKeyToClipboard', (event) => {
|
||||
const publicKey = event?.detail?.publicKey ?? event?.publicKey;
|
||||
window.copyPublicKeyToClipboard(publicKey);
|
||||
});
|
||||
</script>
|
||||
@endscript
|
||||
</div>
|
||||
@@ -212,6 +212,11 @@
|
||||
<span>Validating...</span>
|
||||
</div>
|
||||
@endif
|
||||
@php
|
||||
$hasLinkableCloudProviders = (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
|
||||
|| (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty())
|
||||
|| (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty());
|
||||
@endphp
|
||||
@if ($server->id === 0)
|
||||
<x-modal-confirmation title="Confirm Server Settings Change?" buttonTitle="Save"
|
||||
submitAction="submit" :actions="[
|
||||
@@ -221,6 +226,296 @@
|
||||
@else
|
||||
<x-forms.button type="submit" canGate="update" :canResource="$server"
|
||||
:disabled="$isValidating">Save</x-forms.button>
|
||||
@if ($hasLinkableCloudProviders)
|
||||
<div x-data="{ dropdownOpen: false }" class="relative w-fit" @click.outside="dropdownOpen = false">
|
||||
<x-forms.button @click="dropdownOpen = !dropdownOpen" type="button" canGate="update"
|
||||
:canResource="$server" :disabled="$isValidating">
|
||||
Link Cloud Provider
|
||||
<svg class="w-4 h-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
|
||||
</svg>
|
||||
</x-forms.button>
|
||||
<div x-show="dropdownOpen" @click.away="dropdownOpen=false"
|
||||
x-transition:enter="ease-out duration-200"
|
||||
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
|
||||
class="absolute top-0 z-50 mt-10 min-w-max" x-cloak>
|
||||
<div
|
||||
class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
|
||||
<div class="flex flex-col gap-1">
|
||||
@if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
|
||||
<x-modal-input title="Link to Hetzner Cloud" :wireIgnore="false">
|
||||
<x-slot:content>
|
||||
<div class="dropdown-item" @click="dropdownOpen = false">
|
||||
<svg class="size-4" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Hetzner
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm dark:text-neutral-400">
|
||||
Link this server to a Hetzner Cloud instance to enable power controls and status monitoring.
|
||||
</p>
|
||||
<div class="w-full">
|
||||
<x-forms.select wire:model.live="selectedHetznerTokenId"
|
||||
label="Hetzner Token" canGate="update" :canResource="$server">
|
||||
<option value="">Select a token...</option>
|
||||
@foreach ($availableHetznerTokens as $token)
|
||||
<option value="{{ $token->id }}">{{ $token->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<x-forms.input wire:model="manualHetznerServerId"
|
||||
label="Server ID" placeholder="e.g., 12345678"
|
||||
helper="Enter the Hetzner Server ID from your Hetzner Cloud console"
|
||||
canGate="update" :canResource="$server" />
|
||||
</div>
|
||||
<x-forms.button wire:click="searchHetznerServerById"
|
||||
wire:loading.attr="disabled" canGate="update"
|
||||
:canResource="$server" :disabled="! $selectedHetznerTokenId">
|
||||
<span wire:loading.remove wire:target="searchHetznerServerById">Search</span>
|
||||
<span wire:loading wire:target="searchHetznerServerById">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm dark:text-neutral-500">
|
||||
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
|
||||
<span>OR</span>
|
||||
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
|
||||
</div>
|
||||
<x-forms.button wire:click="searchHetznerServer"
|
||||
wire:loading.attr="disabled" canGate="update"
|
||||
:canResource="$server" :disabled="! $selectedHetznerTokenId"
|
||||
class="w-full justify-center">
|
||||
<span wire:loading.remove wire:target="searchHetznerServer">Search by IP</span>
|
||||
<span wire:loading wire:target="searchHetznerServer">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@if ($hetznerSearchError)
|
||||
<div class="p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
|
||||
<p class="text-red-600 dark:text-red-400">{{ $hetznerSearchError }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@if ($hetznerNoMatchFound)
|
||||
<div class="p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
|
||||
<p class="text-yellow-600 dark:text-yellow-400">
|
||||
@if ($manualHetznerServerId)
|
||||
No Hetzner server found with ID: {{ $manualHetznerServerId }}
|
||||
@else
|
||||
No Hetzner server found matching IP: {{ $server->ip }}
|
||||
@endif
|
||||
</p>
|
||||
<p class="mt-1 text-sm dark:text-neutral-400">
|
||||
Try a different token, enter the Server ID manually, or verify the details are correct.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
@if ($matchedHetznerServer)
|
||||
<div class="p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
|
||||
<h4 class="mb-2 font-semibold text-green-700 dark:text-green-400">Match Found!</h4>
|
||||
<div class="grid gap-2 mb-4 text-sm lg:grid-cols-2">
|
||||
<div><span class="font-medium">Name:</span> {{ $matchedHetznerServer['name'] }}</div>
|
||||
<div><span class="font-medium">ID:</span> {{ $matchedHetznerServer['id'] }}</div>
|
||||
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedHetznerServer['status']) }}</div>
|
||||
<div><span class="font-medium">Type:</span> {{ data_get($matchedHetznerServer, 'server_type.name', 'Unknown') }}</div>
|
||||
</div>
|
||||
<x-forms.button wire:click="linkToHetzner" isHighlighted canGate="update" :canResource="$server">
|
||||
Link This Server
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-modal-input>
|
||||
@endif
|
||||
@if (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty())
|
||||
<x-modal-input title="Link to DigitalOcean" :wireIgnore="false">
|
||||
<x-slot:content>
|
||||
<div class="dropdown-item" @click="dropdownOpen = false">
|
||||
<svg class="size-4" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
DigitalOcean
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm dark:text-neutral-400">
|
||||
Link this server to a DigitalOcean droplet to enable power controls and status monitoring.
|
||||
</p>
|
||||
<div class="w-full">
|
||||
<x-forms.select wire:model.live="selectedDigitalOceanTokenId"
|
||||
label="DigitalOcean Token" canGate="update" :canResource="$server">
|
||||
<option value="">Select a token...</option>
|
||||
@foreach ($availableDigitalOceanTokens as $token)
|
||||
<option value="{{ $token->id }}">{{ $token->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<x-forms.input wire:model="manualDigitalOceanDropletId"
|
||||
label="Droplet ID" placeholder="e.g., 123456789"
|
||||
helper="Enter the Droplet ID from your DigitalOcean dashboard"
|
||||
canGate="update" :canResource="$server" />
|
||||
</div>
|
||||
<x-forms.button wire:click="searchDigitalOceanDropletById"
|
||||
wire:loading.attr="disabled" canGate="update"
|
||||
:canResource="$server" :disabled="! $selectedDigitalOceanTokenId">
|
||||
<span wire:loading.remove wire:target="searchDigitalOceanDropletById">Search</span>
|
||||
<span wire:loading wire:target="searchDigitalOceanDropletById">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm dark:text-neutral-500">
|
||||
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
|
||||
<span>OR</span>
|
||||
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
|
||||
</div>
|
||||
<x-forms.button wire:click="searchDigitalOceanDroplet"
|
||||
wire:loading.attr="disabled" canGate="update"
|
||||
:canResource="$server" :disabled="! $selectedDigitalOceanTokenId"
|
||||
class="w-full justify-center">
|
||||
<span wire:loading.remove wire:target="searchDigitalOceanDroplet">Search by IP</span>
|
||||
<span wire:loading wire:target="searchDigitalOceanDroplet">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@if ($digitalOceanSearchError)
|
||||
<div class="p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
|
||||
<p class="text-red-600 dark:text-red-400">{{ $digitalOceanSearchError }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@if ($digitalOceanNoMatchFound)
|
||||
<div class="p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
|
||||
<p class="text-yellow-600 dark:text-yellow-400">
|
||||
@if ($manualDigitalOceanDropletId)
|
||||
No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }}
|
||||
@else
|
||||
No DigitalOcean droplet found matching IP: {{ $server->ip }}
|
||||
@endif
|
||||
</p>
|
||||
<p class="mt-1 text-sm dark:text-neutral-400">
|
||||
Try a different token, enter the Droplet ID manually, or verify the details are correct.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
@if ($matchedDigitalOceanDroplet)
|
||||
<div class="p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
|
||||
<h4 class="mb-2 font-semibold text-green-700 dark:text-green-400">Match Found!</h4>
|
||||
<div class="grid gap-2 mb-4 text-sm lg:grid-cols-2">
|
||||
<div><span class="font-medium">Name:</span> {{ $matchedDigitalOceanDroplet['name'] ?? 'Unknown' }}</div>
|
||||
<div><span class="font-medium">ID:</span> {{ $matchedDigitalOceanDroplet['id'] }}</div>
|
||||
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedDigitalOceanDroplet['status'] ?? 'unknown') }}</div>
|
||||
<div><span class="font-medium">Size:</span> {{ data_get($matchedDigitalOceanDroplet, 'size.slug', 'Unknown') }}</div>
|
||||
</div>
|
||||
<x-forms.button wire:click="linkToDigitalOcean" isHighlighted canGate="update" :canResource="$server">
|
||||
Link This Server
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-modal-input>
|
||||
@endif
|
||||
@if (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty())
|
||||
<x-modal-input title="Link to Vultr" :wireIgnore="false">
|
||||
<x-slot:content>
|
||||
<div class="dropdown-item" @click="dropdownOpen = false">
|
||||
<svg class="size-4" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Vultr
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm dark:text-neutral-400">
|
||||
Link this server to a Vultr instance to enable power controls and status monitoring.
|
||||
</p>
|
||||
<div class="w-full">
|
||||
<x-forms.select wire:model.live="selectedVultrTokenId"
|
||||
label="Vultr Token" canGate="update" :canResource="$server">
|
||||
<option value="">Select a token...</option>
|
||||
@foreach ($availableVultrTokens as $token)
|
||||
<option value="{{ $token->id }}">{{ $token->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<x-forms.input wire:model="manualVultrInstanceId"
|
||||
label="Instance ID" placeholder="e.g., 6d4b..."
|
||||
helper="Enter the Vultr Instance ID from your Vultr dashboard"
|
||||
canGate="update" :canResource="$server" />
|
||||
</div>
|
||||
<x-forms.button wire:click="searchVultrInstanceById"
|
||||
wire:loading.attr="disabled" canGate="update"
|
||||
:canResource="$server" :disabled="! $selectedVultrTokenId">
|
||||
<span wire:loading.remove wire:target="searchVultrInstanceById">Search</span>
|
||||
<span wire:loading wire:target="searchVultrInstanceById">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm dark:text-neutral-500">
|
||||
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
|
||||
<span>OR</span>
|
||||
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
|
||||
</div>
|
||||
<x-forms.button wire:click="searchVultrInstance"
|
||||
wire:loading.attr="disabled" canGate="update"
|
||||
:canResource="$server" :disabled="! $selectedVultrTokenId"
|
||||
class="w-full justify-center">
|
||||
<span wire:loading.remove wire:target="searchVultrInstance">Search by IP</span>
|
||||
<span wire:loading wire:target="searchVultrInstance">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@if ($vultrSearchError)
|
||||
<div class="p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
|
||||
<p class="text-red-600 dark:text-red-400">{{ $vultrSearchError }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@if ($vultrNoMatchFound)
|
||||
<div class="p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
|
||||
<p class="text-yellow-600 dark:text-yellow-400">
|
||||
@if ($manualVultrInstanceId)
|
||||
No Vultr instance found with ID: {{ $manualVultrInstanceId }}
|
||||
@else
|
||||
No Vultr instance found matching IP: {{ $server->ip }}
|
||||
@endif
|
||||
</p>
|
||||
<p class="mt-1 text-sm dark:text-neutral-400">
|
||||
Try a different token, enter the Instance ID manually, or verify the details are correct.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
@if ($matchedVultrInstance)
|
||||
<div class="p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
|
||||
<h4 class="mb-2 font-semibold text-green-700 dark:text-green-400">Match Found!</h4>
|
||||
<div class="grid gap-2 mb-4 text-sm lg:grid-cols-2">
|
||||
<div><span class="font-medium">Name:</span> {{ $matchedVultrInstance['label'] ?? $matchedVultrInstance['hostname'] ?? 'Unknown' }}</div>
|
||||
<div><span class="font-medium">ID:</span> {{ $matchedVultrInstance['id'] }}</div>
|
||||
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedVultrInstance['status'] ?? 'unknown') }}</div>
|
||||
<div><span class="font-medium">Plan:</span> {{ $matchedVultrInstance['plan'] ?? 'Unknown' }}</div>
|
||||
</div>
|
||||
<x-forms.button wire:click="linkToVultr" isHighlighted canGate="update" :canResource="$server">
|
||||
Link This Server
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-modal-input>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if ($server->isFunctional())
|
||||
<x-slide-over closeWithX fullScreen>
|
||||
<x-slot:title>Validate & configure</x-slot:title>
|
||||
@@ -482,228 +777,6 @@
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
|
||||
<div class="pt-6">
|
||||
<h3>Link to Hetzner Cloud</h3>
|
||||
<p class="pb-4 text-sm dark:text-neutral-400">
|
||||
Link this server to a Hetzner Cloud instance to enable power controls and status monitoring.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-4 items-end">
|
||||
<div class="w-72">
|
||||
<x-forms.select wire:model="selectedHetznerTokenId" label="Hetzner Token"
|
||||
canGate="update" :canResource="$server">
|
||||
<option value="">Select a token...</option>
|
||||
@foreach ($availableHetznerTokens as $token)
|
||||
<option value="{{ $token->id }}">{{ $token->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<x-forms.input wire:model="manualHetznerServerId"
|
||||
label="Server ID"
|
||||
placeholder="e.g., 12345678"
|
||||
helper="Enter the Hetzner Server ID from your Hetzner Cloud console"
|
||||
canGate="update" :canResource="$server" />
|
||||
</div>
|
||||
<x-forms.button wire:click="searchHetznerServerById"
|
||||
wire:loading.attr="disabled"
|
||||
canGate="update" :canResource="$server">
|
||||
<span wire:loading.remove wire:target="searchHetznerServerById">Search by ID</span>
|
||||
<span wire:loading wire:target="searchHetznerServerById">Searching...</span>
|
||||
</x-forms.button>
|
||||
<div class="self-end pb-2 text-sm dark:text-neutral-500">OR</div>
|
||||
<x-forms.button wire:click="searchHetznerServer"
|
||||
wire:loading.attr="disabled"
|
||||
canGate="update" :canResource="$server">
|
||||
<span wire:loading.remove wire:target="searchHetznerServer">Search by IP</span>
|
||||
<span wire:loading wire:target="searchHetznerServer">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
@if ($hetznerSearchError)
|
||||
<div class="mt-4 p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
|
||||
<p class="text-red-600 dark:text-red-400">{{ $hetznerSearchError }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($hetznerNoMatchFound)
|
||||
<div class="mt-4 p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
|
||||
<p class="text-yellow-600 dark:text-yellow-400">
|
||||
@if ($manualHetznerServerId)
|
||||
No Hetzner server found with ID: {{ $manualHetznerServerId }}
|
||||
@else
|
||||
No Hetzner server found matching IP: {{ $server->ip }}
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-sm dark:text-neutral-400 mt-1">
|
||||
Try a different token, enter the Server ID manually, or verify the details are correct.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($matchedHetznerServer)
|
||||
<div class="mt-4 p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
|
||||
<h4 class="font-semibold text-green-700 dark:text-green-400 mb-2">Match Found!</h4>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
|
||||
<div><span class="font-medium">Name:</span> {{ $matchedHetznerServer['name'] }}</div>
|
||||
<div><span class="font-medium">ID:</span> {{ $matchedHetznerServer['id'] }}</div>
|
||||
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedHetznerServer['status']) }}</div>
|
||||
<div><span class="font-medium">Type:</span> {{ data_get($matchedHetznerServer, 'server_type.name', 'Unknown') }}</div>
|
||||
</div>
|
||||
<x-forms.button wire:click="linkToHetzner" isHighlighted canGate="update" :canResource="$server">
|
||||
Link This Server
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty())
|
||||
<div class="pt-6">
|
||||
<h3>Link to Vultr</h3>
|
||||
<p class="pb-4 text-sm dark:text-neutral-400">
|
||||
Link this server to a Vultr instance to enable power controls and status monitoring.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-4 items-end">
|
||||
<div class="w-72">
|
||||
<x-forms.select wire:model="selectedVultrTokenId" label="Vultr Token"
|
||||
canGate="update" :canResource="$server">
|
||||
<option value="">Select a token...</option>
|
||||
@foreach ($availableVultrTokens as $token)
|
||||
<option value="{{ $token->id }}">{{ $token->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="w-72">
|
||||
<x-forms.input wire:model="manualVultrInstanceId"
|
||||
label="Instance ID"
|
||||
placeholder="e.g., 6d4b..."
|
||||
helper="Enter the Vultr Instance ID from your Vultr dashboard"
|
||||
canGate="update" :canResource="$server" />
|
||||
</div>
|
||||
<x-forms.button wire:click="searchVultrInstanceById"
|
||||
wire:loading.attr="disabled"
|
||||
canGate="update" :canResource="$server">
|
||||
<span wire:loading.remove wire:target="searchVultrInstanceById">Search by ID</span>
|
||||
<span wire:loading wire:target="searchVultrInstanceById">Searching...</span>
|
||||
</x-forms.button>
|
||||
<div class="self-end pb-2 text-sm dark:text-neutral-500">OR</div>
|
||||
<x-forms.button wire:click="searchVultrInstance"
|
||||
wire:loading.attr="disabled"
|
||||
canGate="update" :canResource="$server">
|
||||
<span wire:loading.remove wire:target="searchVultrInstance">Search by IP</span>
|
||||
<span wire:loading wire:target="searchVultrInstance">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
@if ($vultrSearchError)
|
||||
<div class="mt-4 p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
|
||||
<p class="text-red-600 dark:text-red-400">{{ $vultrSearchError }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($vultrNoMatchFound)
|
||||
<div class="mt-4 p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
|
||||
<p class="text-yellow-600 dark:text-yellow-400">
|
||||
@if ($manualVultrInstanceId)
|
||||
No Vultr instance found with ID: {{ $manualVultrInstanceId }}
|
||||
@else
|
||||
No Vultr instance found matching IP: {{ $server->ip }}
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-sm dark:text-neutral-400 mt-1">
|
||||
Try a different token, enter the Instance ID manually, or verify the details are correct.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($matchedVultrInstance)
|
||||
<div class="mt-4 p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
|
||||
<h4 class="font-semibold text-green-700 dark:text-green-400 mb-2">Match Found!</h4>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
|
||||
<div><span class="font-medium">Name:</span> {{ $matchedVultrInstance['label'] ?? $matchedVultrInstance['hostname'] ?? 'Unknown' }}</div>
|
||||
<div><span class="font-medium">ID:</span> {{ $matchedVultrInstance['id'] }}</div>
|
||||
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedVultrInstance['status'] ?? 'unknown') }}</div>
|
||||
<div><span class="font-medium">Plan:</span> {{ $matchedVultrInstance['plan'] ?? 'Unknown' }}</div>
|
||||
</div>
|
||||
<x-forms.button wire:click="linkToVultr" isHighlighted canGate="update" :canResource="$server">
|
||||
Link This Server
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty())
|
||||
<div class="pt-6">
|
||||
<h3>Link to DigitalOcean</h3>
|
||||
<p class="pb-4 text-sm dark:text-neutral-400">
|
||||
Link this server to a DigitalOcean droplet to enable power controls and status monitoring.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-4 items-end">
|
||||
<div class="w-72">
|
||||
<x-forms.select wire:model="selectedDigitalOceanTokenId" label="DigitalOcean Token"
|
||||
canGate="update" :canResource="$server">
|
||||
<option value="">Select a token...</option>
|
||||
@foreach ($availableDigitalOceanTokens as $token)
|
||||
<option value="{{ $token->id }}">{{ $token->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="w-72">
|
||||
<x-forms.input wire:model="manualDigitalOceanDropletId" label="Droplet ID"
|
||||
placeholder="e.g., 123456789"
|
||||
helper="Enter the Droplet ID from your DigitalOcean dashboard"
|
||||
canGate="update" :canResource="$server" />
|
||||
</div>
|
||||
<x-forms.button wire:click="searchDigitalOceanDropletById" wire:loading.attr="disabled"
|
||||
canGate="update" :canResource="$server">
|
||||
<span wire:loading.remove wire:target="searchDigitalOceanDropletById">Search by ID</span>
|
||||
<span wire:loading wire:target="searchDigitalOceanDropletById">Searching...</span>
|
||||
</x-forms.button>
|
||||
<div class="self-end pb-2 text-sm dark:text-neutral-500">OR</div>
|
||||
<x-forms.button wire:click="searchDigitalOceanDroplet" wire:loading.attr="disabled"
|
||||
canGate="update" :canResource="$server">
|
||||
<span wire:loading.remove wire:target="searchDigitalOceanDroplet">Search by IP</span>
|
||||
<span wire:loading wire:target="searchDigitalOceanDroplet">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
@if ($digitalOceanSearchError)
|
||||
<div class="mt-4 p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
|
||||
<p class="text-red-600 dark:text-red-400">{{ $digitalOceanSearchError }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($digitalOceanNoMatchFound)
|
||||
<div class="mt-4 p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
|
||||
<p class="text-yellow-600 dark:text-yellow-400">
|
||||
@if ($manualDigitalOceanDropletId)
|
||||
No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }}
|
||||
@else
|
||||
No DigitalOcean droplet found matching IP: {{ $server->ip }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($matchedDigitalOceanDroplet)
|
||||
<div class="mt-4 p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
|
||||
<h4 class="font-semibold text-green-700 dark:text-green-400 mb-2">Match Found!</h4>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
|
||||
<div><span class="font-medium">Name:</span> {{ $matchedDigitalOceanDroplet['name'] ?? 'Unknown' }}</div>
|
||||
<div><span class="font-medium">ID:</span> {{ $matchedDigitalOceanDroplet['id'] }}</div>
|
||||
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedDigitalOceanDroplet['status'] ?? 'unknown') }}</div>
|
||||
<div><span class="font-medium">Size:</span> {{ data_get($matchedDigitalOceanDroplet, 'size.slug', 'Unknown') }}</div>
|
||||
</div>
|
||||
<x-forms.button wire:click="linkToDigitalOcean" isHighlighted canGate="update" :canResource="$server">
|
||||
Link This Server
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in new issue
Block a user