feat: able to add dynamic configurations from proxy dashboard

This commit is contained in:
Andras Bacsai 2024-02-22 13:29:28 +01:00
parent 4d88638d4d
commit 154b1b05e4
15 changed files with 319 additions and 131 deletions

View File

@ -0,0 +1,28 @@
<?php
namespace App\Livewire\Server\Proxy;
use App\Models\Server;
use Livewire\Component;
class DynamicConfigurationNavbar extends Component
{
public $server_id;
public $fileName = '';
public $value = '';
public $newFile = false;
public function delete(string $fileName)
{
$server = Server::ownedByCurrentTeam()->whereId($this->server_id)->first();
$proxy_path = get_proxy_path();
$file = str_replace('|', '.', $fileName);
instant_remote_process(["rm -f {$proxy_path}/dynamic/{$file}"], $server);
$this->dispatch('success', 'Success', 'File deleted.');
$this->dispatch('loadDynamicConfigurations');
$this->dispatch('refresh');
}
public function render()
{
return view('livewire.server.proxy.dynamic-configuration-navbar');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Livewire\Server\Proxy;
use App\Models\Server;
use Illuminate\Support\Collection;
use Livewire\Component;
class DynamicConfigurations extends Component
{
public ?Server $server = null;
public $parameters = [];
public Collection $contents;
protected $listeners = ['loadDynamicConfigurations', 'refresh' => '$refresh'];
protected $rules = [
'contents.*' => 'nullable|string',
];
public function loadDynamicConfigurations()
{
$proxy_path = get_proxy_path();
$files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic"], $this->server);
$files = collect(explode("\n", $files))->filter(fn ($file) => !empty($file));
$files = $files->map(fn ($file) => trim($file));
$files = $files->sort();
$files = $files->filter(fn ($file) => $file !== 'coolify.yaml')->prepend('coolify.yaml');
$contents = collect([]);
foreach ($files as $file) {
$without_extension = str_replace('.', '|', $file);
$contents[$without_extension] = instant_remote_process(["cat {$proxy_path}/dynamic/{$file}"], $this->server);
}
$this->contents = $contents;
}
public function mount()
{
$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');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.proxy.dynamic-configurations');
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Livewire\Server\Proxy;
use App\Models\Server;
use Illuminate\Routing\Route;
use Livewire\Component;
use Symfony\Component\Yaml\Yaml;
class NewDynamicConfiguration extends Component
{
public string $fileName = '';
public string $value = '';
public bool $newFile = false;
public Server $server;
public $server_id;
public $parameters = [];
public function mount()
{
$this->parameters = get_route_parameters();
if ($this->fileName !== '') {
$this->fileName = str_replace('|', '.', $this->fileName);
}
}
public function addDynamicConfiguration()
{
try {
$this->validate([
'fileName' => 'required',
'value' => 'required',
]);
if (data_get($this->parameters, 'server_uuid')) {
$this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first();
}
if (!is_null($this->server_id)) {
$this->server = Server::ownedByCurrentTeam()->whereId($this->server_id)->first();
}
if (is_null($this->server)) {
return redirect()->route('server.index');
}
if (!str($this->fileName)->endsWith('.yaml') && !str($this->fileName)->endsWith('.yml')) {
$this->fileName = "{$this->fileName}.yaml";
}
if ($this->fileName === 'coolify.yaml') {
$this->dispatch('error', 'Error', 'File name is reserved.');
return;
}
$proxy_path = get_proxy_path();
$file = "{$proxy_path}/dynamic/{$this->fileName}";
if ($this->newFile) {
$exists = instant_remote_process(["test -f $file && echo 1 || echo 0"], $this->server);
if ($exists == 1) {
$this->dispatch('error', 'Error', 'File already exists');
return;
}
}
$yaml = Yaml::parse($this->value);
$yaml = Yaml::dump($yaml, 10, 2);
$this->value = $yaml;
$base64_value = base64_encode($this->value);
instant_remote_process(["echo '{$base64_value}' | base64 -d > {$file}"], $this->server);
$this->dispatch('loadDynamicConfigurations');
$this->dispatch('dynamic-configuration-added');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.proxy.new-dynamic-configuration');
}
}

View File

@ -104,13 +104,13 @@ function handleError(?Throwable $error = null, ?Livewire\Component $livewire = n
ray($error);
if ($error instanceof TooManyRequestsException) {
if (isset($livewire)) {
return $livewire->dispatch('error', "Too many requests.", "Please try again in {$error->secondsUntilAvailable} seconds.");
return $livewire->dispatch('error', "Error", "Too many requests. Please try again in {$error->secondsUntilAvailable} seconds.");
}
return "Too many requests. Please try again in {$error->secondsUntilAvailable} seconds.";
}
if ($error instanceof UniqueConstraintViolationException) {
if (isset($livewire)) {
return $livewire->dispatch('error', "Duplicate entry found.", "Please use a different name.");
return $livewire->dispatch('error', "Error", "Duplicate entry found. Please use a different name.");
}
return "Duplicate entry found. Please use a different name.";
}
@ -126,7 +126,7 @@ function handleError(?Throwable $error = null, ?Livewire\Component $livewire = n
if (isset($livewire)) {
if (str($message)->length() > 20) {
return $livewire->dispatch('error', 'Error occured', $message);
return $livewire->dispatch('error', 'Error', $message);
}
return $livewire->dispatch('error', $message);
}

View File

@ -1,47 +0,0 @@
@props(['proxy_settings'])
<div class="mt-4">
<label>
<div>Edit config file</div>
<textarea cols="45" rows="6"></textarea>
</label>
</div>
<div class="mt-4">
<label>
Enable dashboard?
<input type="checkbox" />
(auto-save)
</label>
</div>
<div class="mt-4">
<a href="#">Visit Dashboard</a>
</div>
<div class="mt-4">
<label>
<div>Setup hostname for Dashboard</div>
<div class="mt-2"></div>
<label>
<div>Hostname <span class="text-xs"> Eg: dashboard.example.com </span></div>
<input type="text" />
</label>
<button>Update</button>
</label>
</div>
<div class="mt-4">
<label>
<div>Dashboard credentials</div>
<div class="mt-2"></div>
<label>
Username
<input type="text" />
</label>
<label>
Password
<input type="password" />
</label>
<button>Update</button>
</label>
</div>

View File

@ -1,12 +1,16 @@
<div>
@if ($server->isFunctional())
<div class="flex h-full pr-4">
<div class="flex flex-col gap-4 min-w-fit">
<div class="flex flex-col w-48 gap-4 min-w-fit">
<a class="{{ request()->routeIs('server.proxy') ? 'text-white' : '' }}"
href="{{ route('server.proxy', $parameters) }}">
<button>Configuration</button>
</a>
@if (data_get($server, 'proxy.type') !== 'NONE')
<a class="{{ request()->routeIs('server.proxy.dynamic-confs') ? 'text-white' : '' }}"
href="{{ route('server.proxy.dynamic-confs', $parameters) }}">
<button>Dynamic Configurations</button>
</a>
<a class="{{ request()->routeIs('server.proxy.logs') ? 'text-white' : '' }}"
href="{{ route('server.proxy.logs', $parameters) }}">
<button>Logs</button>

View File

@ -18,14 +18,14 @@ class="fixed inset-0 bg-black bg-opacity-60"></div>
x-transition:leave="transform transition ease-in-out duration-100 sm:duration-300"
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
@class([
'max-w-md w-screen' => !$fullScreen,
'max-w-xl w-screen' => !$fullScreen,
'max-w-4xl w-screen' => $fullScreen,
])>
<div
class="flex flex-col h-full py-6 overflow-hidden border-l shadow-lg bg-base-100 border-neutral-800">
<div class="px-4 pb-4 sm:px-5">
<div class="flex items-start justify-between pb-1">
<h2 class="text-3xl leading-6" id="slide-over-title">
<h2 class="text-2xl leading-6" id="slide-over-title">
{{ $title }}</h2>
<div class="flex items-center h-auto ml-3">
<button class="icon" @click="slideOverOpen=false"

View File

@ -1,7 +1,7 @@
<div class="pb-6">
<div class="flex items-end gap-2">
<h1>Team</h1>
<a href="/team/new"><x-forms.button>+ New Team</x-forms.button></a>
<a href="/team/new"><x-forms.button>+ Add Team</x-forms.button></a>
</div>
<nav class="flex pt-2 pb-10">
<ol class="inline-flex items-center">

View File

@ -47,7 +47,7 @@
<div class="flex items-center">
<a class="mx-4 rounded group-hover:text-white hover:no-underline"
href="{{ route('project.resource.create', ['project_uuid' => data_get($project, 'uuid'), 'environment_name' => data_get($project, 'environments.0.name', 'production')]) }}">
<span class="font-bold hover:text-warning">+ New Resource</span>
<span class="font-bold hover:text-warning">+ Add Resource</span>
</a>
<a class="mx-4 rounded group-hover:text-white"
href="{{ route('project.edit', ['project_uuid' => data_get($project, 'uuid')]) }}">

View File

@ -1,82 +1,87 @@
<div x-init="$wire.getLogs">
<div class="flex gap-2">
<h4>Container: {{ $container }}</h4>
@if ($streamLogs)
<span wire:poll.2000ms='getLogs(true)' class="loading loading-xs text-warning loading-spinner"></span>
@endif
</div>
<div class="flex gap-2">
<x-forms.checkbox instantSave label="Stream Logs" id="streamLogs"></x-forms.checkbox>
<x-forms.checkbox instantSave label="Include Timestamps" id="showTimeStamps"></x-forms.checkbox>
</div>
<form wire:submit='getLogs(true)' class="flex items-end gap-2">
<x-forms.input label="Only Show Number of Lines" placeholder="1000" required id="numberOfLines"></x-forms.input>
<x-forms.button type="submit">Refresh</x-forms.button>
</form>
<div id="screen" x-data="{ fullscreen: false, alwaysScroll: false, intervalId: null }" :class="fullscreen ? 'fullscreen' : 'w-full py-4 mx-auto'">
<div class="relative flex flex-col-reverse w-full p-4 pt-6 overflow-y-auto text-white bg-coolgray-100 scrollbar border-coolgray-300"
:class="fullscreen ? '' : 'max-h-[40rem] border border-solid rounded'">
<button title="Minimize" x-show="fullscreen" class="fixed top-4 right-4" x-on:click="makeFullscreen"><svg
class="icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M6 14h4m0 0v4m0-4l-6 6m14-10h-4m0 0V6m0 4l6-6" />
</svg></button>
<button title="Go Top" x-show="fullscreen" class="fixed top-4 right-28" x-on:click="goTop"> <svg
class="icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 5v14m4-10l-4-4M8 9l4-4" />
</svg></button>
<button title="Follow Logs" x-show="fullscreen" :class="alwaysScroll ? 'text-warning' : ''"
class="fixed top-4 right-16" x-on:click="toggleScroll"><svg class="icon" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 5v14m4-4l-4 4m-4-4l4 4" />
</svg></button>
<button title="Fullscreen" x-show="!fullscreen" class="absolute top-2 right-2"
x-on:click="makeFullscreen"><svg class=" icon" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<g fill="none">
<path
d="M24 0v24H0V0h24ZM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01l-.184-.092Z" />
<path fill="currentColor"
d="M9.793 12.793a1 1 0 0 1 1.497 1.32l-.083.094L6.414 19H9a1 1 0 0 1 .117 1.993L9 21H4a1 1 0 0 1-.993-.883L3 20v-5a1 1 0 0 1 1.993-.117L5 15v2.586l4.793-4.793ZM20 3a1 1 0 0 1 .993.883L21 4v5a1 1 0 0 1-1.993.117L19 9V6.414l-4.793 4.793a1 1 0 0 1-1.497-1.32l.083-.094L17.586 5H15a1 1 0 0 1-.117-1.993L15 3h5Z" />
</g>
</svg></button>
<pre id="logs" class="font-mono whitespace-pre-wrap">{{ $outputs }}</pre>
<div>
<div x-init="$wire.getLogs">
<div class="flex gap-2">
<h4>Container: {{ $container }}</h4>
@if ($streamLogs)
<span wire:poll.2000ms='getLogs(true)' class="loading loading-xs text-warning loading-spinner"></span>
@endif
</div>
</div>
<script>
function makeFullscreen() {
this.fullscreen = !this.fullscreen;
if (this.fullscreen === false) {
<div class="flex gap-2">
<x-forms.checkbox instantSave label="Stream Logs" id="streamLogs"></x-forms.checkbox>
<x-forms.checkbox instantSave label="Include Timestamps" id="showTimeStamps"></x-forms.checkbox>
</div>
<form wire:submit='getLogs(true)' class="flex items-end gap-2">
<x-forms.input label="Only Show Number of Lines" placeholder="1000" required
id="numberOfLines"></x-forms.input>
<x-forms.button type="submit">Refresh</x-forms.button>
</form>
<div id="screen" x-data="{ fullscreen: false, alwaysScroll: false, intervalId: null }" :class="fullscreen ? 'fullscreen' : 'w-full py-4 mx-auto'">
<div class="relative flex flex-col-reverse w-full p-4 pt-6 overflow-y-auto text-white bg-coolgray-100 scrollbar border-coolgray-300"
:class="fullscreen ? '' : 'max-h-[40rem] border border-solid rounded'">
<button title="Minimize" x-show="fullscreen" class="fixed top-4 right-4"
x-on:click="makeFullscreen"><svg class="icon" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M6 14h4m0 0v4m0-4l-6 6m14-10h-4m0 0V6m0 4l6-6" />
</svg></button>
<button title="Go Top" x-show="fullscreen" class="fixed top-4 right-28" x-on:click="goTop"> <svg
class="icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 5v14m4-10l-4-4M8 9l4-4" />
</svg></button>
<button title="Follow Logs" x-show="fullscreen" :class="alwaysScroll ? 'text-warning' : ''"
class="fixed top-4 right-16" x-on:click="toggleScroll"><svg class="icon" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 5v14m4-4l-4 4m-4-4l4 4" />
</svg></button>
<button title="Fullscreen" x-show="!fullscreen" class="absolute top-2 right-2"
x-on:click="makeFullscreen"><svg class=" icon" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<g fill="none">
<path
d="M24 0v24H0V0h24ZM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01l-.184-.092Z" />
<path fill="currentColor"
d="M9.793 12.793a1 1 0 0 1 1.497 1.32l-.083.094L6.414 19H9a1 1 0 0 1 .117 1.993L9 21H4a1 1 0 0 1-.993-.883L3 20v-5a1 1 0 0 1 1.993-.117L5 15v2.586l4.793-4.793ZM20 3a1 1 0 0 1 .993.883L21 4v5a1 1 0 0 1-1.993.117L19 9V6.414l-4.793 4.793a1 1 0 0 1-1.497-1.32l.083-.094L17.586 5H15a1 1 0 0 1-.117-1.993L15 3h5Z" />
</g>
</svg></button>
<pre id="logs" class="font-mono whitespace-pre-wrap">{{ $outputs }}</pre>
</div>
</div>
<script>
function makeFullscreen() {
this.fullscreen = !this.fullscreen;
if (this.fullscreen === false) {
this.alwaysScroll = false;
clearInterval(this.intervalId);
}
}
function toggleScroll() {
this.alwaysScroll = !this.alwaysScroll;
if (this.alwaysScroll) {
this.intervalId = setInterval(() => {
const screen = document.getElementById('screen');
const logs = document.getElementById('logs');
if (screen.scrollTop !== logs.scrollHeight) {
screen.scrollTop = logs.scrollHeight;
}
}, 100);
} else {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
function goTop() {
this.alwaysScroll = false;
clearInterval(this.intervalId);
const screen = document.getElementById('screen');
screen.scrollTop = 0;
}
}
</script>
</div>
function toggleScroll() {
this.alwaysScroll = !this.alwaysScroll;
if (this.alwaysScroll) {
this.intervalId = setInterval(() => {
const screen = document.getElementById('screen');
const logs = document.getElementById('logs');
if (screen.scrollTop !== logs.scrollHeight) {
screen.scrollTop = logs.scrollHeight;
}
}, 100);
} else {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
function goTop() {
this.alwaysScroll = false;
clearInterval(this.intervalId);
const screen = document.getElementById('screen');
screen.scrollTop = 0;
}
</script>
</div>

View File

@ -0,0 +1,15 @@
<div class="flex gap-2">
<h3 class="text-white">File: {{ str_replace('|', '.', $fileName) }}</h3>
<div class="flex gap-2">
<x-slide-over>
<x-slot:title>Edit Configuration</x-slot:title>
<x-slot:content>
<livewire:server.proxy.new-dynamic-configuration :server_id="$server_id" :fileName="$fileName" :value="$value"
:newFile="$newFile" wire:key="{{ $fileName }}" />
</x-slot:content>
<button @click="slideOverOpen=true"
class="font-normal text-white normal-case border-none rounded btn btn-primary btn-sm no-animation">Edit</button>
</x-slide-over>
</div>
<x-forms.button isError wire:click="delete('{{ $fileName }}')">Delete</x-forms.button>
</div>

View File

@ -0,0 +1,53 @@
<div>
<x-server.navbar :server="$server" :parameters="$parameters" />
<div class="flex gap-2">
<x-server.sidebar :server="$server" :parameters="$parameters" />
<div class="w-full">
@if ($server->isFunctional())
<div class="flex gap-2">
<div>
<div class="flex gap-2">
<h2>Dynamic Configurations</h2>
<x-forms.button wire:click='loadDynamicConfigurations'>Reload</x-forms.button>
<x-slide-over>
<x-slot:title>New Dynamic Configuration</x-slot:title>
<x-slot:content>
<livewire:server.proxy.new-dynamic-configuration />
</x-slot:content>
<button @click="slideOverOpen=true"
class="font-normal text-white normal-case border-none rounded btn btn-primary btn-sm no-animation">+
Add</button>
</x-slide-over>
</div>
<div class='pb-4'>You can add dynamic Traefik configurations here.</div>
</div>
</div>
<div wire:loading wire:target="loadDynamicConfigurations">
<x-loading text="Loading dynamic configurations..." />
</div>
<div x-init="$wire.loadDynamicConfigurations" class="flex flex-col gap-4">
@if ($contents?->isNotEmpty())
@foreach ($contents as $fileName => $value)
<div class="flex flex-col gap-2 py-2">
@if (str_replace('|', '.', $fileName) === 'coolify.yaml')
<div>
<h3 class="text-white">File: {{ str_replace('|', '.', $fileName) }}</h3>
</div>
<x-forms.textarea disabled name="proxy_settings"
wire:model="contents.{{ $fileName }}" rows="10" />
@else
<livewire:server.proxy.dynamic-configuration-navbar :server_id="$server->id"
:fileName="$fileName" :value="$value" :newFile="false"
wire:key="{{ $fileName }}-{{ $loop->index }}" />
<x-forms.textarea disabled wire:model="contents.{{ $fileName }}"
rows="10" />
@endif
</div>
@endforeach
@endif
</div>
@endif
</div>
</div>
</div>

View File

@ -3,6 +3,7 @@
<div class="flex gap-2">
<x-server.sidebar :server="$server" :parameters="$parameters" />
<div class="w-full">
<h2 class="pb-4">Logs</h2>
<livewire:project.shared.get-logs :server="$server" container="coolify-proxy" />
</div>
</div>

View File

@ -0,0 +1,5 @@
<form wire:submit.prevent="addDynamicConfiguration" class="flex flex-col gap-4">
<x-forms.input id="fileName" label="Filename (.yaml or .yml)" required />
<x-forms.textarea id="value" label="Configuration" required rows="20" />
<x-forms.button type="submit" @click="slideOverOpen=false">Save</x-forms.button>
</form>

View File

@ -67,6 +67,7 @@
use App\Livewire\Server\Destination\Show as DestinationShow;
use App\Livewire\Server\LogDrains;
use App\Livewire\Server\PrivateKey\Show as PrivateKeyShow;
use App\Livewire\Server\Proxy\DynamicConfigurations as ProxyDynamicConfigurations;
use App\Livewire\Server\Proxy\Show as ProxyShow;
use App\Livewire\Server\Proxy\Logs as ProxyLogs;
use App\Livewire\Source\Github\Change as GitHubChange;
@ -177,6 +178,7 @@
Route::get('/', ServerShow::class)->name('server.show');
Route::get('/resources', ResourcesShow::class)->name('server.resources');
Route::get('/proxy', ProxyShow::class)->name('server.proxy');
Route::get('/proxy/dynamic', ProxyDynamicConfigurations::class)->name('server.proxy.dynamic-confs');
Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs');
Route::get('/private-key', PrivateKeyShow::class)->name('server.private-key');
Route::get('/destinations', DestinationShow::class)->name('server.destinations');