Merge pull request #3937 from coollabsio/ui

UI
This commit is contained in:
Andras Bacsai
2024-10-17 15:45:12 +02:00
committed by GitHub
29 changed files with 615 additions and 392 deletions

View File

@@ -5,13 +5,13 @@ namespace App\Actions\Server;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class RemoveServer
class DeleteServer
{
use AsAction;
public function handle(Server $server)
{
StopSentinel::run($server);
$server->delete();
$server->forceDelete();
}
}

View File

@@ -34,9 +34,9 @@ class StartSentinel
'COLLECTOR_RETENTION_PERIOD_DAYS' => $metrics_history,
];
if (isDev()) {
data_set($environments, 'DEBUG', 'true');
// data_set($environments, 'DEBUG', 'true');
$mount_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel';
$image = 'sentinel';
// $image = 'sentinel';
}
$docker_environments = '-e "' . implode('" -e "', array_map(fn($key, $value) => "$key=$value", array_keys($environments), $environments)) . '"';

View File

@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api;
use App\Actions\Server\RemoveServer;
use App\Actions\Server\DeleteServer;
use App\Actions\Server\ValidateServer;
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
@@ -726,7 +726,8 @@ class ServersController extends Controller
if ($server->definedResources()->count() > 0) {
return response()->json(['message' => 'Server has resources, so you need to delete them before.'], 400);
}
RemoveServer::dispatch($server);
$server->delete();
DeleteServer::dispatch($server);
return response()->json(['message' => 'Server deleted.']);
}

View File

@@ -66,7 +66,7 @@ class Show extends Component
return ! $alreadyAddedNetworks->contains('network', $network['Name']);
});
if ($this->networks->count() === 0) {
$this->dispatch('success', 'No new networks found.');
$this->dispatch('success', 'No new destinations found on this server.');
return;
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Livewire\Server;
use App\Models\Server;
use Livewire\Component;
class Advanced extends Component
{
public Server $server;
protected $rules = [
'server.settings.concurrent_builds' => 'required|integer|min:1',
'server.settings.dynamic_timeout' => 'required|integer|min:1',
'server.settings.force_docker_cleanup' => 'required|boolean',
'server.settings.docker_cleanup_frequency' => 'required_if:server.settings.force_docker_cleanup,true|string',
'server.settings.docker_cleanup_threshold' => 'required_if:server.settings.force_docker_cleanup,false|integer|min:1|max:100',
'server.settings.delete_unused_volumes' => 'boolean',
'server.settings.delete_unused_networks' => 'boolean',
];
protected $validationAttributes = [
'server.settings.concurrent_builds' => 'Concurrent Builds',
'server.settings.dynamic_timeout' => 'Dynamic Timeout',
'server.settings.force_docker_cleanup' => 'Force Docker Cleanup',
'server.settings.docker_cleanup_frequency' => 'Docker Cleanup Frequency',
'server.settings.docker_cleanup_threshold' => 'Docker Cleanup Threshold',
'server.settings.delete_unused_volumes' => 'Delete Unused Volumes',
'server.settings.delete_unused_networks' => 'Delete Unused Networks',
];
public function instantSave()
{
try {
$this->validate();
$this->server->settings->save();
$this->dispatch('success', 'Server updated.');
$this->dispatch('refreshServerShow');
} catch (\Throwable $e) {
$this->server->settings->refresh();
return handleError($e, $this);
}
}
public function submit()
{
try {
$frequency = $this->server->settings->docker_cleanup_frequency;
if (empty($frequency) || ! validate_cron_expression($frequency)) {
$this->server->settings->docker_cleanup_frequency = '*/10 * * * *';
throw new \Exception('Invalid Cron / Human expression for Docker Cleanup Frequency. Resetting to default 10 minutes.');
}
$this->server->settings->save();
$this->dispatch('success', 'Server updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.advanced');
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Livewire\Server;
use App\Models\Server;
use Livewire\Component;
class CloudflareTunnels extends Component
{
public Server $server;
protected $rules = [
'server.settings.is_cloudflare_tunnel' => 'required|boolean',
];
protected $validationAttributes = [
'server.settings.is_cloudflare_tunnel' => 'Cloudflare Tunnel',
];
public function instantSave()
{
try {
$this->validate();
$this->server->settings->save();
$this->dispatch('success', 'Server updated.');
$this->dispatch('refreshServerShow');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function manualCloudflareConfig()
{
$this->server->settings->is_cloudflare_tunnel = true;
$this->server->settings->save();
$this->server->refresh();
$this->dispatch('success', 'Cloudflare Tunnels enabled.');
}
public function render()
{
return view('livewire.server.cloudflare-tunnels');
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Livewire\Server;
use App\Actions\Server\RemoveServer;
use App\Actions\Server\DeleteServer;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@@ -28,8 +28,8 @@ class Delete extends Component
return;
}
RemoveServer::run($this->server);
$this->server->delete();
DeleteServer::dispatch($this->server);
return redirect()->route('server.index');
} catch (\Throwable $e) {
return handleError($e, $this);

View File

@@ -7,7 +7,6 @@ use App\Actions\Server\StopSentinel;
use App\Jobs\DockerCleanupJob;
use App\Jobs\PullSentinelImageJob;
use App\Models\Server;
use Illuminate\Support\Facades\Http;
use Livewire\Component;
class Form extends Component
@@ -47,27 +46,19 @@ class Form extends Component
'server.ip' => 'required',
'server.user' => 'required',
'server.port' => 'required',
'server.settings.is_cloudflare_tunnel' => 'required|boolean',
'wildcard_domain' => 'nullable|url',
'server.settings.is_reachable' => 'required',
'server.settings.is_swarm_manager' => 'required|boolean',
'server.settings.is_swarm_worker' => 'required|boolean',
'server.settings.is_build_server' => 'required|boolean',
'server.settings.concurrent_builds' => 'required|integer|min:1',
'server.settings.dynamic_timeout' => 'required|integer|min:1',
'server.settings.is_metrics_enabled' => 'required|boolean',
'server.settings.sentinel_token' => 'required',
'server.settings.sentinel_metrics_refresh_rate_seconds' => 'required|integer|min:1',
'server.settings.sentinel_metrics_history_days' => 'required|integer|min:1',
'server.settings.sentinel_push_interval_seconds' => 'required|integer|min:10',
'wildcard_domain' => 'nullable|url',
'server.settings.sentinel_custom_url' => 'nullable|url',
'server.settings.is_sentinel_enabled' => 'required|boolean',
'server.settings.server_timezone' => 'required|string|timezone',
'server.settings.force_docker_cleanup' => 'required|boolean',
'server.settings.docker_cleanup_frequency' => 'required_if:server.settings.force_docker_cleanup,true|string',
'server.settings.docker_cleanup_threshold' => 'required_if:server.settings.force_docker_cleanup,false|integer|min:1|max:100',
'server.settings.delete_unused_volumes' => 'boolean',
'server.settings.delete_unused_networks' => 'boolean',
];
protected $validationAttributes = [
@@ -76,23 +67,18 @@ class Form extends Component
'server.ip' => 'IP address/Domain',
'server.user' => 'User',
'server.port' => 'Port',
'server.settings.is_cloudflare_tunnel' => 'Cloudflare Tunnel',
'server.settings.is_reachable' => 'Is reachable',
'server.settings.is_swarm_manager' => 'Swarm Manager',
'server.settings.is_swarm_worker' => 'Swarm Worker',
'server.settings.is_build_server' => 'Build Server',
'server.settings.concurrent_builds' => 'Concurrent Builds',
'server.settings.dynamic_timeout' => 'Dynamic Timeout',
'server.settings.is_metrics_enabled' => 'Metrics',
'server.settings.sentinel_token' => 'Metrics Token',
'server.settings.sentinel_metrics_refresh_rate_seconds' => 'Metrics Interval',
'server.settings.sentinel_metrics_history_days' => 'Metrics History',
'server.settings.sentinel_push_interval_seconds' => 'Push Interval',
'server.settings.is_sentinel_enabled' => 'Server API',
'server.settings.sentinel_custom_url' => 'Sentinel URL',
'server.settings.sentinel_custom_url' => 'Coolify URL',
'server.settings.server_timezone' => 'Server Timezone',
'server.settings.delete_unused_volumes' => 'Delete Unused Volumes',
'server.settings.delete_unused_networks' => 'Delete Unused Networks',
];
public function mount(Server $server)
@@ -100,13 +86,10 @@ class Form extends Component
$this->server = $server;
$this->timezones = collect(timezone_identifiers_list())->sort()->values()->toArray();
$this->wildcard_domain = $this->server->settings->wildcard_domain;
$this->server->settings->docker_cleanup_threshold = $this->server->settings->docker_cleanup_threshold;
$this->server->settings->docker_cleanup_frequency = $this->server->settings->docker_cleanup_frequency;
$this->server->settings->delete_unused_volumes = $server->settings->delete_unused_volumes;
$this->server->settings->delete_unused_networks = $server->settings->delete_unused_networks;
}
public function checkSyncStatus(){
public function checkSyncStatus()
{
$this->server->refresh();
$this->server->settings->refresh();
}
@@ -114,9 +97,10 @@ class Form extends Component
public function regenerateSentinelToken()
{
try {
$this->server->generateSentinelToken();
$this->server->settings->generateSentinelToken();
$this->server->settings->refresh();
$this->dispatch('success', 'Sentinel token regenerated. Please restart your Sentinel.');
$this->restartSentinel(notification: false);
$this->dispatch('success', 'Token regenerated & Sentinel restarted.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -152,25 +136,35 @@ class Form extends Component
$this->dispatch('proxyStatusUpdated');
}
public function updatedServerSettingsIsSentinelEnabled($value){
if($value === false){
public function updatedServerSettingsIsSentinelEnabled($value)
{
$this->validate();
$this->validate([
'server.settings.sentinel_custom_url' => 'required|url',
]);
if ($value === false) {
StopSentinel::dispatch($this->server);
$this->server->settings->is_metrics_enabled = false;
$this->server->settings->save();
$this->server->sentinelHeartbeat(isReset: true);
} else {
try {
StartSentinel::run($this->server);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
}
public function updatedServerSettingsIsMetricsEnabled(){
public function updatedServerSettingsIsMetricsEnabled()
{
$this->restartSentinel();
}
public function instantSave()
{
try {
$this->validate();
refresh_server_connection($this->server->privateKey);
$this->validateServer(false);
@@ -179,6 +173,14 @@ class Form extends Component
$this->dispatch('success', 'Server updated.');
$this->dispatch('refreshServerShow');
// if ($this->server->isSentinelEnabled()) {
// StartSentinel::run($this->server);
// } else {
// StopSentinel::run($this->server);
// $this->server->settings->is_metrics_enabled = false;
// $this->server->settings->save();
// $this->server->sentinelHeartbeat(isReset: true);
// }
// if ($this->server->isSentinelEnabled()) {
// PullSentinelImageJob::dispatchSync($this->server);
// ray('Sentinel is enabled');
@@ -196,16 +198,24 @@ class Form extends Component
// $this->checkPortForServerApi();
} catch (\Throwable $e) {
$this->server->settings->refresh();
return handleError($e, $this);
}
}
public function restartSentinel()
public function restartSentinel($notification = true)
{
try {
$this->validate();
$this->validate([
'server.settings.sentinel_custom_url' => 'required|url',
]);
$version = get_latest_sentinel_version();
StartSentinel::run($this->server, $version, true);
if ($notification) {
$this->dispatch('success', 'Sentinel started.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Livewire\Server\Proxy;
use App\Models\Server;
use Livewire\Component;
class Modal extends Component
{
public Server $server;
public function proxyStatusUpdated()
{
$this->dispatch('proxyStatusUpdated');
}
}

View File

@@ -15,7 +15,9 @@ class Resources extends Component
public $parameters = [];
public Collection $unmanagedContainers;
public Collection $containers;
public $activeTab = 'managed';
public function getListeners()
{
@@ -50,14 +52,29 @@ class Resources extends Component
public function refreshStatus()
{
$this->server->refresh();
if ($this->activeTab === 'managed') {
$this->loadManagedContainers();
} else {
$this->loadUnmanagedContainers();
}
$this->dispatch('success', 'Resource statuses refreshed.');
}
public function loadManagedContainers()
{
try {
$this->activeTab = 'managed';
$this->containers = $this->server->refresh()->definedResources();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function loadUnmanagedContainers()
{
$this->activeTab = 'unmanaged';
try {
$this->unmanagedContainers = $this->server->loadUnmanagedContainers();
$this->containers = $this->server->loadUnmanagedContainers();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -65,13 +82,14 @@ class Resources extends Component
public function mount()
{
$this->unmanagedContainers = collect();
$this->containers = collect();
$this->parameters = get_route_parameters();
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->first();
if (is_null($this->server)) {
return redirect()->route('server.index');
}
$this->loadManagedContainers();
} catch (\Throwable $e) {
return handleError($e, $this);
}

View File

@@ -2,7 +2,6 @@
namespace App\Livewire\Server;
use App\Models\PrivateKey;
use App\Models\Server;
use Livewire\Component;
@@ -14,15 +13,29 @@ class ShowPrivateKey extends Component
public $parameters;
public function mount()
{
$this->parameters = get_route_parameters();
}
public function setPrivateKey($privateKeyId)
{
$originalPrivateKeyId = $this->server->getOriginal('private_key_id');
try {
$privateKey = PrivateKey::findOrFail($privateKeyId);
$this->server->update(['private_key_id' => $privateKey->id]);
$this->server->refresh();
$this->server->update(['private_key_id' => $privateKeyId]);
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
if ($uptime) {
$this->dispatch('success', 'Private key updated successfully.');
} else {
throw new \Exception('Server is not reachable.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help.<br><br>Error: '.$error);
}
} catch (\Exception $e) {
$this->server->update(['private_key_id' => $originalPrivateKeyId]);
$this->server->validateConnection();
$this->dispatch('error', 'Failed to update private key: '.$e->getMessage());
} finally {
$this->dispatch('refreshServerShow');
$this->server->refresh();
}
}
@@ -33,18 +46,16 @@ class ShowPrivateKey extends Component
if ($uptime) {
$this->dispatch('success', 'Server is reachable.');
} else {
ray($error);
$this->dispatch('error', 'Server is not reachable.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help.<br><br>Error: '.$error);
return;
}
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->dispatch('refreshServerShow');
$this->server->refresh();
}
}
public function mount()
{
$this->parameters = get_route_parameters();
}
}

View File

@@ -7,6 +7,7 @@ use App\Enums\ProxyTypes;
use App\Jobs\PullSentinelImageJob;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -17,7 +18,6 @@ use OpenApi\Attributes as OA;
use Spatie\SchemalessAttributes\Casts\SchemalessAttributes;
use Spatie\SchemalessAttributes\SchemalessAttributesTrait;
use Spatie\Url\Url;
use Illuminate\Support\Str;
use Symfony\Component\Yaml\Yaml;
#[OA\Schema(
@@ -45,7 +45,7 @@ use Symfony\Component\Yaml\Yaml;
class Server extends BaseModel
{
use SchemalessAttributesTrait;
use SchemalessAttributesTrait,SoftDeletes;
public static $batch_counter = 0;
@@ -97,7 +97,8 @@ class Server extends BaseModel
}
}
});
static::deleting(function ($server) {
static::forceDeleting(function ($server) {
$server->destinations()->each(function ($destination) {
$destination->delete();
});
@@ -527,34 +528,6 @@ $schema://$host {
Storage::disk('ssh-mux')->delete($this->muxFilename());
}
public function generateSentinelUrl() {
if ($this->isLocalhost()) {
return 'http://host.docker.internal:8000';
}
$settings = InstanceSettings::get();
if ($settings->fqdn) {
return $settings->fqdn;
}
if ($settings->ipv4) {
return $settings->ipv4 . ':8000';
}
if ($settings->ipv6) {
return $settings->ipv6 . ':8000';
}
return null;
}
public function generateSentinelToken()
{
$data = [
'server_uuid' => $this->uuid,
];
$token = json_encode($data);
$encrypted = encrypt($token);
$this->settings->sentinel_token = $encrypted;
$this->settings->save();
return $encrypted;
}
public function sentinelHeartbeat(bool $isReset = false)
{
@@ -568,7 +541,7 @@ $schema://$host {
public function isSentinelEnabled()
{
return $this->isMetricsEnabled() || $this->isServerApiEnabled() || !$this->isBuildServer();
return ($this->isMetricsEnabled() || $this->isServerApiEnabled()) && !$this->isBuildServer();
}
public function isMetricsEnabled()

View File

@@ -59,10 +59,59 @@ class ServerSetting extends Model
protected static function booted()
{
static::creating(function ($setting) {
try {
if (str($setting->sentinel_token)->isEmpty()) {
$setting->generateSentinelToken(save: false);
}
if (str($setting->sentinel_custom_url)->isEmpty()) {
$url = $setting->generateSentinelUrl(save: false);
if (str($url)->isEmpty()) {
$setting->is_sentinel_enabled = false;
} else {
$setting->is_sentinel_enabled = true;
}
}
} catch (\Throwable $e) {
loggy('Error creating server setting: ' . $e->getMessage());
}
});
}
public function generateSentinelToken(bool $save = true)
{
$data = [
'server_uuid' => $this->server->uuid,
];
$token = json_encode($data);
$encrypted = encrypt($token);
$this->sentinel_token = $encrypted;
if ($save) {
$this->save();
}
return $encrypted;
}
public function generateSentinelUrl(bool $save = true)
{
$domain = null;
$settings = InstanceSettings::get();
if ($this->server->isLocalhost()) {
$domain = 'http://host.docker.internal:8000';
} else if ($settings->fqdn) {
$domain = $settings->fqdn;
} else if ($settings->ipv4) {
$domain = $settings->ipv4 . ':8000';
} else if ($settings->ipv6) {
$domain = $settings->ipv6 . ':8000';
}
$this->sentinel_custom_url = $domain;
if ($save) {
$this->save();
}
return $domain;
}
public function server()
{
return $this->belongsTo(Server::class);

View File

@@ -126,7 +126,7 @@ function refreshSession(?Team $team = null): void
}
function handleError(?Throwable $error = null, ?Livewire\Component $livewire = null, ?string $customErrorMessage = null)
{
ray($error);
loggy($error);
if ($error instanceof TooManyRequestsException) {
if (isset($livewire)) {
return $livewire->dispatch('error', "Too many requests. Please try again in {$error->secondsUntilAvailable} seconds.");

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};

View File

@@ -9,22 +9,23 @@ class SentinelSeeder extends Seeder
{
public function run()
{
try {
Server::chunk(100, function ($servers) {
foreach ($servers as $server) {
try {
if (str($server->settings->sentinel_token)->isEmpty()) {
$server->generateSentinelToken();
$server->settings->generateSentinelToken();
}
if (str($server->settings->sentinel_custom_url)->isEmpty()) {
$url = $server->generateSentinelUrl();
$server->settings->sentinel_custom_url = $url;
$url = $server->settings->generateSentinelUrl();
if (str($url)->isEmpty()) {
$server->settings->is_sentinel_enabled = false;
$server->settings->save();
}
}
});
} catch (\Throwable $e) {
echo "Error: {$e->getMessage()}\n";
ray($e->getMessage());
loggy("Error: {$e->getMessage()}\n");
}
}
});
}
}

View File

@@ -1,5 +1,14 @@
<div class="pb-6">
<livewire:server.proxy.modal :server="$server" />
<x-modal modalId="startProxy">
<x-slot:modalBody>
<livewire:activity-monitor header="Proxy Startup Logs" />
</x-slot:modalBody>
<x-slot:modalSubmit>
<x-forms.button onclick="startProxy.close()" type="submit">
Close
</x-forms.button>
</x-slot:modalSubmit>
</x-modal>
<div class="flex items-center gap-2">
<h1>Server</h1>
@if ($server->proxySet())
@@ -13,20 +22,9 @@
href="{{ route('server.show', [
'server_uuid' => data_get($parameters, 'server_uuid'),
]) }}">
<button>General</button>
</a>
<a class="{{ request()->routeIs('server.private-key') ? 'dark:text-white' : '' }}"
href="{{ route('server.private-key', [
'server_uuid' => data_get($parameters, 'server_uuid'),
]) }}">
<button>Private Key</button>
</a>
<a class="{{ request()->routeIs('server.resources') ? 'dark:text-white' : '' }}"
href="{{ route('server.resources', [
'server_uuid' => data_get($parameters, 'server_uuid'),
]) }}">
<button>Resources</button>
<button>Configuration</button>
</a>
@if (!$server->isSwarmWorker() && !$server->settings->is_build_server)
<a class="{{ request()->routeIs('server.proxy') ? 'dark:text-white' : '' }}"
href="{{ route('server.proxy', [
@@ -34,18 +32,6 @@
]) }}">
<button>Proxy</button>
</a>
<a class="{{ request()->routeIs('server.destinations') ? 'dark:text-white' : '' }}"
href="{{ route('server.destinations', [
'server_uuid' => data_get($parameters, 'server_uuid'),
]) }}">
<button>Destinations</button>
</a>
<a class="{{ request()->routeIs('server.log-drains') ? 'dark:text-white' : '' }}"
href="{{ route('server.log-drains', [
'server_uuid' => data_get($parameters, 'server_uuid'),
]) }}">
<button>Log Drains</button>
</a>
@endif
</nav>
<div class="order-first sm:order-last">

View File

@@ -5,10 +5,10 @@
<x-modal-input buttonTitle="+ Add" title="New Destination">
<livewire:destination.new.docker :server_id="$server->id" />
</x-modal-input>
<x-forms.button wire:click='scan'>Scan Destinations</x-forms.button>
<x-forms.button wire:click='scan'>Scan for Destinations</x-forms.button>
</div>
<div class="pt-2 pb-6 ">Destinations are used to segregate resources by network.</div>
<div class="flex gap-2 ">
<div>Destinations are used to segregate resources by network.</div>
<div class="flex gap-2 pt-6">
Available for using:
@forelse ($server->standaloneDockers as $docker)
<a href="{{ route('destination.show', ['destination_uuid' => data_get($docker, 'uuid')]) }}">

View File

@@ -0,0 +1,84 @@
<form wire:submit='submit'>
<div>
<div class="flex items-center gap-2">
<h2>Advanced</h2>
<x-forms.button type="submit">Save</x-forms.button>
<x-modal-confirmation title="Confirm Docker Cleanup?" buttonTitle="Trigger Manual Cleanup"
submitAction="manualCleanup" :actions="[
'Permanently deletes all stopped containers managed by Coolify (as containers are non-persistent, no data will be lost)',
'Permanently deletes all unused images',
'Clears build cache',
'Removes old versions of the Coolify helper image',
'Optionally permanently deletes all unused volumes (if enabled in advanced options).',
'Optionally permanently deletes all unused networks (if enabled in advanced options).',
]" :confirmWithText="false" :confirmWithPassword="false"
step2ButtonText="Trigger Docker Cleanup" />
</div>
<div>Advanced configuration for your server.</div>
</div>
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<h3>Docker Cleanup</h3>
</div>
<div class="flex flex-wrap items-center gap-4">
@if ($server->settings->force_docker_cleanup)
<x-forms.input placeholder="*/10 * * * *" id="server.settings.docker_cleanup_frequency"
label="Docker cleanup frequency" required
helper="Cron expression for Docker Cleanup.<br>You can use every_minute, hourly, daily, weekly, monthly, yearly.<br><br>Default is every night at midnight." />
@else
<x-forms.input id="server.settings.docker_cleanup_threshold" label="Docker cleanup threshold (%)"
required
helper="The Docker cleanup tasks will run when the disk usage exceeds this threshold." />
@endif
<div class="w-96">
<x-forms.checkbox
helper="Enabling Force Docker Cleanup or manually triggering a cleanup will perform the following actions:
<ul class='list-disc pl-4 mt-2'>
<li>Removes stopped containers manged by Coolify (as containers are none persistent, no data will be lost).</li>
<li>Deletes unused images.</li>
<li>Clears build cache.</li>
<li>Removes old versions of the Coolify helper image.</li>
<li>Optionally delete unused volumes (if enabled in advanced options).</li>
<li>Optionally remove unused networks (if enabled in advanced options).</li>
</ul>"
instantSave id="server.settings.force_docker_cleanup" label="Force Docker Cleanup" />
</div>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
<span class="dark:text-warning font-bold">Warning: Enable these
options only if you fully understand their implications and
consequences!</span><br>Improper use will result in data loss and could cause
functional issues.
</p>
<div class="w-96">
<x-forms.checkbox instantSave id="server.settings.delete_unused_volumes" label="Delete Unused Volumes"
helper="This option will remove all unused Docker volumes during cleanup.<br><br><strong>Warning: Data form stopped containers will be lost!</strong><br><br>Consequences include:<br>
<ul class='list-disc pl-4 mt-2'>
<li>Volumes not attached to running containers will be deleted and data will be permanently lost (stopped containers are affected).</li>
<li>Data from stopped containers volumes will be permanently lost.</li>
<li>No way to recover deleted volume data.</li>
</ul>" />
<x-forms.checkbox instantSave id="server.settings.delete_unused_networks" label="Delete Unused Networks"
helper="This option will remove all unused Docker networks during cleanup.<br><br><strong>Warning: Functionality may be lost and containers may not be able to communicate with each other!</strong><br><br>Consequences include:<br>
<ul class='list-disc pl-4 mt-2'>
<li>Networks not attached to running containers will be permanently deleted (stopped containers are affected).</li>
<li>Custom networks for stopped containers will be permanently deleted.</li>
<li>Functionality may be lost and containers may not be able to communicate with each other.</li>
</ul>" />
</div>
</div>
<div class="flex flex-col">
<h3>Builds</h3>
<div>Customize the build process.</div>
<div class="flex flex-wrap gap-2 sm:flex-nowrap pt-4">
<x-forms.input id="server.settings.concurrent_builds" label="Number of concurrent builds" required
helper="You can specify the number of simultaneous build processes/deployments that should run concurrently." />
<x-forms.input id="server.settings.dynamic_timeout" label="Deployment timeout (seconds)" required
helper="You can define the maximum duration for a deployment to run before timing it out." />
</div>
</div>
</div>
</form>

View File

@@ -0,0 +1,42 @@
<div>
<div class="flex gap-1 items-center">
<h2>Cloudflare Tunnels</h2>
<x-helper class="inline-flex"
helper="If you are using Cloudflare Tunnels, enable this. It will proxy all SSH requests to your server through Cloudflare.<br> You then can close your server's SSH port in the firewall of your hosting provider.<br><span class='dark:text-warning'>If you choose manual configuration, Coolify does not install or set up Cloudflare (cloudflared) on your server.</span>" />
</div>
<div class="flex flex-col gap-2 pt-6">
@if ($server->settings->is_cloudflare_tunnel)
<div class="w-64">
<x-forms.checkbox instantSave id="server.settings.is_cloudflare_tunnel"
label="Enabled" />
</div>
@elseif (!$server->isFunctional())
<div
class="p-4 mb-4 w-full text-sm text-yellow-800 bg-yellow-100 rounded dark:bg-yellow-900 dark:text-yellow-300">
To <span class="font-semibold">automatically</span> configure Cloudflare Tunnels, please
validate your server first.</span> Then you will need a Cloudflare token and an SSH
domain configured.
<br />
To <span class="font-semibold">manually</span> configure Cloudflare Tunnels, please
click <span wire:click="manualCloudflareConfig"
class="underline cursor-pointer">here</span>, then you should validate the server.
<br /><br />
For more information, please read our <a
href="https://coolify.io/docs/knowledge-base/cloudflare/tunnels/" target="_blank"
class="font-medium underline hover:text-yellow-600 dark:hover:text-yellow-200">documentation</a>.
</div>
@endif
@if (!$server->settings->is_cloudflare_tunnel && $server->isFunctional())
<x-modal-input buttonTitle="Automated Configuration" title="Cloudflare Tunnels"
class="w-full" :closeOutside="false">
<livewire:server.configure-cloudflare-tunnels :server_id="$server->id" />
</x-modal-input>
@endif
@if ($server->isFunctional() && !$server->settings->is_cloudflare_tunnel)
<div wire:click="manualCloudflareConfig" class="w-full underline cursor-pointer">
I have configured Cloudflare Tunnels manually
</div>
@endif
</div>
</div>

View File

@@ -1,6 +1,6 @@
<div>
@if ($server->id !== 0)
<h2 class="pt-4">Danger Zone</h2>
<h2>Danger Zone</h2>
<div class="">Woah. I hope you know what are you doing.</div>
<h4 class="pt-4">Delete Server</h4>
<div class="pb-4">This will remove this server from Coolify. Beware! There is no coming

View File

@@ -2,6 +2,6 @@
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Server Destinations | Coolify
</x-slot>
<x-server.navbar :server="$server" :parameters="$parameters" />
{{-- <x-server.navbar :server="$server" :parameters="$parameters" /> --}}
<livewire:destination.show :server="$server" />
</div>

View File

@@ -119,55 +119,19 @@
</div>
</div>
<div class="{{ $server->isFunctional() ? 'w-96' : 'w-full' }}">
<div class="w-full">
@if (!$server->isLocalhost())
<div class="w-96">
<x-forms.checkbox instantSave id="server.settings.is_build_server"
label="Use it as a build server?" />
<div class="flex flex-col gap-2 pt-6">
<div class="flex gap-1 items-center">
<h3 class="text-lg font-semibold">Cloudflare Tunnels</h3>
<x-helper class="inline-flex"
helper="If you are using Cloudflare Tunnels, enable this. It will proxy all SSH requests to your server through Cloudflare.<br> You then can close your server's SSH port in the firewall of your hosting provider.<br><span class='dark:text-warning'>If you choose manual configuration, Coolify does not install or set up Cloudflare (cloudflared) on your server.</span>" />
</div>
@if ($server->settings->is_cloudflare_tunnel)
<div class="w-64">
<x-forms.checkbox instantSave id="server.settings.is_cloudflare_tunnel"
label="Enabled" />
</div>
@elseif (!$server->isFunctional())
<div
class="p-4 mb-4 w-full text-sm text-yellow-800 bg-yellow-100 rounded dark:bg-yellow-900 dark:text-yellow-300">
To <span class="font-semibold">automatically</span> configure Cloudflare Tunnels, please
validate your server first.</span> Then you will need a Cloudflare token and an SSH
domain configured.
<br />
To <span class="font-semibold">manually</span> configure Cloudflare Tunnels, please
click <span wire:click="manualCloudflareConfig"
class="underline cursor-pointer">here</span>, then you should validate the server.
<br /><br />
For more information, please read our <a
href="https://coolify.io/docs/knowledge-base/cloudflare/tunnels/" target="_blank"
class="font-medium underline hover:text-yellow-600 dark:hover:text-yellow-200">documentation</a>.
</div>
@endif
@if (!$server->settings->is_cloudflare_tunnel && $server->isFunctional())
<x-modal-input buttonTitle="Automated Configuration" title="Cloudflare Tunnels"
class="w-full" :closeOutside="false">
<livewire:server.configure-cloudflare-tunnels :server_id="$server->id" />
</x-modal-input>
@endif
@if ($server->isFunctional() && !$server->settings->is_cloudflare_tunnel)
<div wire:click="manualCloudflareConfig" class="w-full underline cursor-pointer">
I have configured Cloudflare Tunnels manually
</div>
@endif
</div>
@if (!$server->isBuildServer() && !$server->settings->is_cloudflare_tunnel)
<h3 class="pt-6">Swarm <span class="text-xs text-neutral-500">(experimental)</span></h3>
<div class="pb-4">Read the docs <a class='underline dark:text-white'
href='https://coolify.io/docs/knowledge-base/docker/swarm' target='_blank'>here</a>.
</div>
<div class="w-96">
@if ($server->settings->is_swarm_worker)
<x-forms.checkbox disabled instantSave type="checkbox"
id="server.settings.is_swarm_manager"
@@ -189,92 +153,11 @@
helper="For more information, please read the documentation <a class='dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/swarm' target='_blank'>here</a>."
label="Is it a Swarm Worker?" />
@endif
</div>
@endif
@endif
</div>
</div>
@if ($server->isFunctional())
<h3 class="pt-4">Settings</h3>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-4">
<div class="w-64">
<x-forms.checkbox
helper="Enabling Force Docker Cleanup or manually triggering a cleanup will perform the following actions:
<ul class='list-disc pl-4 mt-2'>
<li>Removes stopped containers manged by Coolify (as containers are none persistent, no data will be lost).</li>
<li>Deletes unused images.</li>
<li>Clears build cache.</li>
<li>Removes old versions of the Coolify helper image.</li>
<li>Optionally delete unused volumes (if enabled in advanced options).</li>
<li>Optionally remove unused networks (if enabled in advanced options).</li>
</ul>"
instantSave id="server.settings.force_docker_cleanup" label="Force Docker Cleanup" />
</div>
<x-modal-confirmation title="Confirm Docker Cleanup?" buttonTitle="Trigger Docker Cleanup"
submitAction="manualCleanup" :actions="[
'Permanently deletes all stopped containers managed by Coolify (as containers are non-persistent, no data will be lost)',
'Permanently deletes all unused images',
'Clears build cache',
'Removes old versions of the Coolify helper image',
'Optionally permanently deletes all unused volumes (if enabled in advanced options).',
'Optionally permanently deletes all unused networks (if enabled in advanced options).',
]" :confirmWithText="false" :confirmWithPassword="false"
step2ButtonText="Trigger Docker Cleanup" />
</div>
@if ($server->settings->force_docker_cleanup)
<x-forms.input placeholder="*/10 * * * *" id="server.settings.docker_cleanup_frequency"
label="Docker cleanup frequency" required
helper="Cron expression for Docker Cleanup.<br>You can use every_minute, hourly, daily, weekly, monthly, yearly.<br><br>Default is every night at midnight." />
@else
<x-forms.input id="server.settings.docker_cleanup_threshold"
label="Docker cleanup threshold (%)" required
helper="The Docker cleanup tasks will run when the disk usage exceeds this threshold." />
@endif
<div x-data="{ open: false }" class="mt-4 max-w-md">
<button @click="open = !open" type="button"
class="flex items-center justify-between w-full text-left text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100">
<span>Advanced Options</span>
<svg :class="{ 'rotate-180': open }" class="w-5 h-5 transition-transform duration-200"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
</button>
<div x-show="open" class="mt-2 space-y-2">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2"><strong>Warning: Enable these
options only if you fully understand their implications and
consequences!</strong><br>Improper use will result in data loss and could cause
functional issues.</p>
<x-forms.checkbox instantSave id="server.settings.delete_unused_volumes"
label="Delete Unused Volumes"
helper="This option will remove all unused Docker volumes during cleanup.<br><br><strong>Warning: Data form stopped containers will be lost!</strong><br><br>Consequences include:<br>
<ul class='list-disc pl-4 mt-2'>
<li>Volumes not attached to running containers will be deleted and data will be permanently lost (stopped containers are affected).</li>
<li>Data from stopped containers volumes will be permanently lost.</li>
<li>No way to recover deleted volume data.</li>
</ul>" />
<x-forms.checkbox instantSave id="server.settings.delete_unused_networks"
label="Delete Unused Networks"
helper="This option will remove all unused Docker networks during cleanup.<br><br><strong>Warning: Functionality may be lost and containers may not be able to communicate with each other!</strong><br><br>Consequences include:<br>
<ul class='list-disc pl-4 mt-2'>
<li>Networks not attached to running containers will be permanently deleted (stopped containers are affected).</li>
<li>Custom networks for stopped containers will be permanently deleted.</li>
<li>Functionality may be lost and containers may not be able to communicate with each other.</li>
</ul>" />
</div>
</div>
</div>
<div class="flex flex-wrap gap-4 sm:flex-nowrap">
<x-forms.input id="server.settings.concurrent_builds" label="Number of concurrent builds" required
helper="You can specify the number of simultaneous build processes/deployments that should run concurrently." />
<x-forms.input id="server.settings.dynamic_timeout" label="Deployment timeout (seconds)" required
helper="You can define the maximum duration for a deployment to run before timing it out." />
</div>
</div>
@if (isDev())
<div class="flex gap-2 items-center pt-4 pb-2">
<h3>Sentinel</h3>
@@ -283,17 +166,17 @@
wire:poll.{{ $server->settings->sentinel_push_interval_seconds }}s="checkSyncStatus">
@if ($server->isSentinelLive())
<x-status.running status="In-sync" noLoading />
<x-forms.button wire:click='restartSentinel'>Restart</x-forms.button>
@else
<x-status.stopped status="Out-of-sync" noLoading />
<x-forms.button wire:click='restartSentinel'>Sync</x-forms.button>
@endif
<x-forms.button wire:click='restartSentinel'>Restart</x-forms.button>
</div>
@endif
</div>
<div class="flex flex-col gap-2">
<div class="w-64">
<x-forms.checkbox instantSave id="server.settings.is_sentinel_enabled"
label="Enable Sentinel" />
<x-forms.checkbox instantSave id="server.settings.is_sentinel_enabled" label="Enable Sentinel" />
@if ($server->isSentinelEnabled())
<x-forms.checkbox instantSave id="server.settings.is_metrics_enabled"
label="Enable Metrics" />
@@ -302,14 +185,16 @@
label="Enable Metrics" />
@endif
</div>
@if ($server->isSentinelEnabled())
<div class="flex flex-wrap gap-2 sm:flex-nowrap items-end">
<x-forms.input type="password" id="server.settings.sentinel_token" label="Sentinel token"
required helper="Token for Sentinel." />
<x-forms.input id="server.settings.sentinel_custom_url" label="Sentinel custom URL"
helper="Custom URL for Sentinel." />
<x-forms.button wire:click="regenerateSentinelToken">Regenerate</x-forms.button>
</div>
<x-forms.input id="server.settings.sentinel_custom_url" required label="Coolify URL"
helper="URL to your Coolify instance. If it is empty that means you do not have a FQDN set for your Coolify instance." />
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2 sm:flex-nowrap">
<x-forms.input id="server.settings.sentinel_metrics_refresh_rate_seconds"
@@ -323,8 +208,9 @@
helper="How many seconds should the metrics data should be pushed to the collector." />
</div>
</div>
@endif
</div>
@endif
@endif
</form>
</div>

View File

@@ -2,7 +2,7 @@
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Server LogDrains | Coolify
</x-slot>
<x-server.navbar :server="$server" :parameters="$parameters" />
{{-- <x-server.navbar :server="$server" :parameters="$parameters" /> --}}
@if ($server->isFunctional())
<h2>Log Drains</h2>
<div class="pb-4">Sends service logs to 3rd party tools.</div>

View File

@@ -2,6 +2,5 @@
<x-slot:title>
Server Connection | Coolify
</x-slot>
<x-server.navbar :server="$server" :parameters="$parameters" />
<livewire:server.show-private-key :server="$server" :privateKeys="$privateKeys" />
</div>

View File

@@ -1,12 +0,0 @@
<div>
<x-modal submitWireAction="proxyStatusUpdated" modalId="startProxy">
<x-slot:modalBody>
<livewire:activity-monitor header="Proxy Startup Logs" />
</x-slot:modalBody>
<x-slot:modalSubmit>
<x-forms.button onclick="startProxy.close()" type="submit">
Close
</x-forms.button>
</x-slot:modalSubmit>
</x-modal>
</div>

View File

@@ -2,24 +2,30 @@
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Server Resources | Coolify
</x-slot>
<x-server.navbar :server="$server" :parameters="$parameters" />
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'managed' }" class="flex flex-col h-full gap-8 md:flex-row">
<div class="flex flex-row gap-4 md:flex-col">
<a :class="activeTab === 'managed' && 'dark:text-white'"
@click.prevent="activeTab = 'managed'; window.location.hash = 'managed'" href="#">Managed</a>
<a :class="activeTab === 'unmanaged' && 'dark:text-white'"
@click.prevent="activeTab = 'unmanaged'; window.location.hash = 'unmanaged'" href="#">Unmanaged</a>
</div>
{{-- <x-server.navbar :server="$server" :parameters="$parameters" /> --}}
<div x-data="{ activeTab: 'managed' }" class="flex flex-col h-full gap-8 md:flex-row">
<div class="w-full">
<div x-cloak x-show="activeTab === 'managed'" class="h-full">
<div class="flex flex-col">
<div class="flex gap-2">
<h2>Resources</h2>
<x-forms.button wire:click="refreshStatus">Refresh</x-forms.button>
</div>
<div class="subtitle">Here you can find all resources that are managed by Coolify.</div>
<div>Here you can find all resources that are managed by Coolify.</div>
<div class="flex flex-row gap-4 py-10">
<div @class([
'box-without-bg cursor-pointer bg-coolgray-100 text-white w-full text-center items-center justify-center',
'bg-coollabs' => $activeTab === 'managed',
]) wire:click="loadManagedContainers">
Managed</div>
<div @class([
'box-without-bg cursor-pointer bg-coolgray-100 text-white w-full text-center items-center justify-center',
'bg-coollabs' => $activeTab === 'unmanaged',
]) wire:click="loadUnmanagedContainers">
Unmanaged</div>
</div>
@if ($server->definedResources()->count() > 0)
</div>
@if ($containers->count() > 0)
@if ($activeTab === 'managed')
<div class="flex flex-col">
<div class="flex flex-col">
<div class="overflow-x-auto">
@@ -78,19 +84,7 @@
</div>
</div>
</div>
@else
<div>No resources found.</div>
@endif
</div>
<div x-cloak x-show="activeTab === 'unmanaged'" class="h-full">
<div class="flex flex-col" x-init="$wire.loadUnmanagedContainers()">
<div class="flex gap-2">
<h2>Resources</h2>
<x-forms.button wire:click="refreshStatus">Refresh</x-forms.button>
</div>
<div class="subtitle">Here you can find all other containers running on the server.</div>
</div>
@if ($unmanagedContainers->count() > 0)
@elseif ($activeTab === 'unmanaged')
<div class="flex flex-col">
<div class="flex flex-col">
<div class="overflow-x-auto">
@@ -114,7 +108,7 @@
</tr>
</thead>
<tbody>
@forelse ($unmanagedContainers->sortBy('name',SORT_NATURAL) as $resource)
@forelse ($containers->sortBy('name',SORT_NATURAL) as $resource)
<tr>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource, 'Names') }}
@@ -152,11 +146,14 @@
</div>
</div>
</div>
</div>
@endif
@else
<div>No resources found.</div>
@if ($activeTab === 'managed')
<div>No managed resources found.</div>
@elseif ($activeTab === 'unmanaged')
<div>No unmanaged resources found.</div>
@endif
@endif
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<div>
<div class="flex items-end gap-2 pb-6 ">
<div class="flex items-end gap-2">
<h2>Private Key</h2>
<x-modal-input buttonTitle="+ Add" title="New Private Key">
<livewire:security.private-key.create />
@@ -9,29 +9,25 @@
</x-forms.button>
</div>
<div class="flex flex-col gap-2 pb-6">
@if (data_get($server, 'privateKey.uuid'))
<div>
Currently attached Private Key:
<a
href="{{ route('security.private-key.show', ['private_key_uuid' => data_get($server, 'privateKey.uuid')]) }}">
<button class="dark:text-white btn-link">{{ data_get($server, 'privateKey.name') }}</button>
</a>
<div class="flex flex-col gap-2">
<div class="pb-4">Change your server's private key.</div>
</div>
@else
<div class="">No private key attached.</div>
@endif
</div>
<h3 class="pb-4">Choose another Key</h3>
<div class="grid grid-cols-3 gap-2">
<div class="grid xl:grid-cols-2 grid-cols-1 gap-2">
@forelse ($privateKeys as $private_key)
<div class="box group cursor-pointer"
wire:click='setPrivateKey({{ $private_key->id }})'>
<div class="box-without-bg justify-between dark:bg-coolgray-100 bg-white items-center">
<div class="flex flex-col ">
<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 wire:click='setPrivateKey({{ $private_key->id }})'>
Use this key
</x-forms.button>
@else
<x-forms.button disabled>
Currently used
</x-forms.button>
@endif
</div>
@empty
<div>No private keys found. </div>

View File

@@ -3,11 +3,75 @@
{{ data_get_str($server, 'name')->limit(10) }} > Server Configurations | Coolify
</x-slot>
<x-server.navbar :server="$server" :parameters="$parameters" />
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col items-start gap-2 min-w-fit">
<a class="menu-item" :class="activeTab === 'general' && 'menu-item-active'"
@click.prevent="activeTab = 'general'; window.location.hash = 'general'" href="#">General</a>
@if ($server->isFunctional())
<a class="menu-item" :class="activeTab === 'advanced' && 'menu-item-active'"
@click.prevent="activeTab = 'advanced'; window.location.hash = 'advanced'" href="#">Advanced
</a>
@endif
<a class="menu-item" :class="activeTab === 'private-key' && 'menu-item-active'"
@click.prevent="activeTab = 'private-key'; window.location.hash = 'private-key'" href="#">Private
Key</a>
@if ($server->isFunctional())
<a class="menu-item" :class="activeTab === 'cloudflare-tunnels' && 'menu-item-active'"
@click.prevent="activeTab = 'cloudflare-tunnels'; window.location.hash = 'cloudflare-tunnels'"
href="#">Cloudflare Tunnels</a>
<a class="menu-item" :class="activeTab === 'resources' && 'menu-item-active'"
@click.prevent="activeTab = 'resources'; window.location.hash = 'resources'"
href="#">Resources</a>
<a class="menu-item" :class="activeTab === 'destinations' && 'menu-item-active'"
@click.prevent="activeTab = 'destinations'; window.location.hash = 'destinations'"
href="#">Destinations</a>
<a class="menu-item" :class="activeTab === 'log-drains' && 'menu-item-active'"
@click.prevent="activeTab = 'log-drains'; window.location.hash = 'log-drains'" href="#">Log
Drains</a>
<a class="menu-item" :class="activeTab === 'metrics' && 'menu-item-active'"
@click.prevent="activeTab = 'metrics'; window.location.hash = 'metrics'" href="#">Metrics</a>
@endif
@if (!$server->isLocalhost())
<a class="menu-item" :class="activeTab === 'danger' && 'menu-item-active'"
@click.prevent="activeTab = 'danger'; window.location.hash = 'danger'" href="#">Danger</a>
@endif
</div>
<div class="w-full">
<div x-cloak x-show="activeTab === 'general'" class="h-full">
<livewire:server.form :server="$server" />
</div>
<div x-cloak x-show="activeTab === 'advanced'" class="h-full">
<livewire:server.advanced :server="$server" />
</div>
<div x-cloak x-show="activeTab === 'private-key'" class="h-full">
<livewire:server.private-key.show :server="$server" />
</div>
<div x-cloak x-show="activeTab === 'cloudflare-tunnels'" class="h-full">
<livewire:server.cloudflare-tunnels :server="$server" />
</div>
<div x-cloak x-show="activeTab === 'resources'" class="h-full">
<livewire:server.resources :server="$server" />
</div>
<div x-cloak x-show="activeTab === 'destinations'" class="h-full">
<livewire:server.destination.show :server="$server" />
</div>
<div x-cloak x-show="activeTab === 'log-drains'" class="h-full">
<livewire:server.log-drains :server="$server" />
</div>
<div x-cloak x-show="activeTab === 'metrics'" class="h-full">
@if ($server->isFunctional() && $server->isMetricsEnabled())
<div class="pt-10">
<livewire:server.charts :server="$server" />
</div>
@else
No metrics available.
@endif
</div>
@if (!$server->isLocalhost())
<div x-cloak x-show="activeTab === 'danger'" class="h-full">
<livewire:server.delete :server="$server" />
</div>
@endif
</div>
</div>
</div>