feat(storage): add read-only volume handling and UI notifications
- Introduced `isReadOnlyVolume` method in `LocalFileVolume` and `LocalPersistentVolume` models to determine if a volume is read-only based on Docker Compose configuration. - Updated `FileStorage` and `Show` components to set `isReadOnly` state during mounting. - Enhanced UI to display notifications for read-only volumes, preventing modification actions in the interface. - Refactored file storage and directory management forms to conditionally enable or disable actions based on read-only status.
This commit is contained in:
parent
0bc7283bd6
commit
b4cfb78f86
7 changed files with 358 additions and 88 deletions
|
|
@ -34,6 +34,8 @@ class FileStorage extends Component
|
||||||
|
|
||||||
public bool $permanently_delete = true;
|
public bool $permanently_delete = true;
|
||||||
|
|
||||||
|
public bool $isReadOnly = false;
|
||||||
|
|
||||||
protected $rules = [
|
protected $rules = [
|
||||||
'fileStorage.is_directory' => 'required',
|
'fileStorage.is_directory' => 'required',
|
||||||
'fileStorage.fs_path' => 'required',
|
'fileStorage.fs_path' => 'required',
|
||||||
|
|
@ -52,6 +54,8 @@ public function mount()
|
||||||
$this->workdir = null;
|
$this->workdir = null;
|
||||||
$this->fs_path = $this->fileStorage->fs_path;
|
$this->fs_path = $this->fileStorage->fs_path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function convertToDirectory()
|
public function convertToDirectory()
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,11 @@ class Show extends Component
|
||||||
'host_path' => 'host',
|
'host_path' => 'host',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public function mount()
|
||||||
|
{
|
||||||
|
$this->isReadOnly = $this->storage->isReadOnlyVolume();
|
||||||
|
}
|
||||||
|
|
||||||
public function submit()
|
public function submit()
|
||||||
{
|
{
|
||||||
$this->authorize('update', $this->resource);
|
$this->authorize('update', $this->resource);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Events\FileStorageChanged;
|
use App\Events\FileStorageChanged;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
class LocalFileVolume extends BaseModel
|
class LocalFileVolume extends BaseModel
|
||||||
{
|
{
|
||||||
|
|
@ -192,4 +193,61 @@ public function scopeWherePlainMountPath($query, $path)
|
||||||
{
|
{
|
||||||
return $query->get()->where('plain_mount_path', $path);
|
return $query->get()->where('plain_mount_path', $path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this volume is read-only by parsing the docker-compose content
|
||||||
|
public function isReadOnlyVolume(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Only check for services
|
||||||
|
$service = $this->service;
|
||||||
|
if (! $service || ! method_exists($service, 'service')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actualService = $service->service;
|
||||||
|
if (! $actualService || ! $actualService->docker_compose_raw) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the docker-compose content
|
||||||
|
$compose = Yaml::parse($actualService->docker_compose_raw);
|
||||||
|
if (! isset($compose['services'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the service that this volume belongs to
|
||||||
|
$serviceName = $service->name;
|
||||||
|
if (! isset($compose['services'][$serviceName]['volumes'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$volumes = $compose['services'][$serviceName]['volumes'];
|
||||||
|
|
||||||
|
// Check each volume to find a match
|
||||||
|
foreach ($volumes as $volume) {
|
||||||
|
// Volume can be string like "host:container:ro" or "host:container"
|
||||||
|
if (is_string($volume)) {
|
||||||
|
$parts = explode(':', $volume);
|
||||||
|
|
||||||
|
// Check if this volume matches our fs_path and mount_path
|
||||||
|
if (count($parts) >= 2) {
|
||||||
|
$hostPath = $parts[0];
|
||||||
|
$containerPath = $parts[1];
|
||||||
|
$options = $parts[2] ?? null;
|
||||||
|
|
||||||
|
// Match based on fs_path and mount_path
|
||||||
|
if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) {
|
||||||
|
return $options === 'ro';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
ray($e->getMessage(), 'Error checking read-only volume');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
class LocalPersistentVolume extends Model
|
class LocalPersistentVolume extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -48,4 +49,69 @@ protected function hostPath(): Attribute
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this volume is read-only by parsing the docker-compose content
|
||||||
|
public function isReadOnlyVolume(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Get the resource (can be application, service, or database)
|
||||||
|
$resource = $this->resource;
|
||||||
|
if (! $resource) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only check for services
|
||||||
|
if (! method_exists($resource, 'service')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actualService = $resource->service;
|
||||||
|
if (! $actualService || ! $actualService->docker_compose_raw) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the docker-compose content
|
||||||
|
$compose = Yaml::parse($actualService->docker_compose_raw);
|
||||||
|
if (! isset($compose['services'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the service that this volume belongs to
|
||||||
|
$serviceName = $resource->name;
|
||||||
|
if (! isset($compose['services'][$serviceName]['volumes'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$volumes = $compose['services'][$serviceName]['volumes'];
|
||||||
|
|
||||||
|
// Check each volume to find a match
|
||||||
|
foreach ($volumes as $volume) {
|
||||||
|
// Volume can be string like "host:container:ro" or "host:container"
|
||||||
|
if (is_string($volume)) {
|
||||||
|
$parts = explode(':', $volume);
|
||||||
|
|
||||||
|
// Check if this volume matches our mount_path
|
||||||
|
if (count($parts) >= 2) {
|
||||||
|
$containerPath = $parts[1];
|
||||||
|
$options = $parts[2] ?? null;
|
||||||
|
|
||||||
|
// Match based on mount_path
|
||||||
|
// Remove leading slash from mount_path if present for comparison
|
||||||
|
$mountPath = str($this->mount_path)->ltrim('/')->toString();
|
||||||
|
$containerPathClean = str($containerPath)->ltrim('/')->toString();
|
||||||
|
|
||||||
|
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
|
||||||
|
return $options === 'ro';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
ray($e->getMessage(), 'Error checking read-only persistent volume');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
<div>
|
<div>
|
||||||
<div class="flex flex-col gap-4 p-4 bg-white border dark:bg-base dark:border-coolgray-300 border-neutral-200">
|
<div class="flex flex-col gap-4 p-4 bg-white border dark:bg-base dark:border-coolgray-300 border-neutral-200">
|
||||||
|
@if ($isReadOnly)
|
||||||
|
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
|
||||||
|
@if ($fileStorage->is_directory)
|
||||||
|
This directory is mounted as read-only and cannot be modified from the UI.
|
||||||
|
@else
|
||||||
|
This file is mounted as read-only and cannot be modified from the UI.
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<div class="flex flex-col justify-center text-sm select-text">
|
<div class="flex flex-col justify-center text-sm select-text">
|
||||||
<div class="flex gap-2 md:flex-row flex-col">
|
<div class="flex gap-2 md:flex-row flex-col">
|
||||||
<x-forms.input label="Source Path" :value="$fileStorage->fs_path" readonly />
|
<x-forms.input label="Source Path" :value="$fileStorage->fs_path" readonly />
|
||||||
|
|
@ -7,58 +16,75 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form wire:submit='submit' class="flex flex-col gap-2">
|
<form wire:submit='submit' class="flex flex-col gap-2">
|
||||||
@can('update', $resource)
|
@if (!$isReadOnly)
|
||||||
<div class="flex gap-2">
|
@can('update', $resource)
|
||||||
@if ($fileStorage->is_directory)
|
<div class="flex gap-2">
|
||||||
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Conversion to File?"
|
@if ($fileStorage->is_directory)
|
||||||
buttonTitle="Convert to file" submitAction="convertToFile" :actions="[
|
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Conversion to File?"
|
||||||
'All files in this directory will be permanently deleted and an empty file will be created in its place.',
|
buttonTitle="Convert to file" submitAction="convertToFile" :actions="[
|
||||||
]"
|
'All files in this directory will be permanently deleted and an empty file will be created in its place.',
|
||||||
confirmationText="{{ $fs_path }}"
|
|
||||||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
|
||||||
shortConfirmationLabel="Filepath" :confirmWithPassword="false" step2ButtonText="Convert to file" />
|
|
||||||
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Deletion?" buttonTitle="Delete"
|
|
||||||
isErrorButton submitAction="delete" :checkboxes="$directoryDeletionCheckboxes" :actions="[
|
|
||||||
'The selected directory and all its contents will be permanently deleted from the container.',
|
|
||||||
]"
|
|
||||||
confirmationText="{{ $fs_path }}"
|
|
||||||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
|
||||||
shortConfirmationLabel="Filepath" />
|
|
||||||
@else
|
|
||||||
@if (!$fileStorage->is_binary)
|
|
||||||
<x-modal-confirmation :ignoreWire="false" title="Confirm File Conversion to Directory?"
|
|
||||||
buttonTitle="Convert to directory" submitAction="convertToDirectory" :actions="[
|
|
||||||
'The selected file will be permanently deleted and an empty directory will be created in its place.',
|
|
||||||
]"
|
]"
|
||||||
confirmationText="{{ $fs_path }}"
|
confirmationText="{{ $fs_path }}"
|
||||||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||||
shortConfirmationLabel="Filepath" :confirmWithPassword="false" step2ButtonText="Convert to directory" />
|
shortConfirmationLabel="Filepath" :confirmWithPassword="false" step2ButtonText="Convert to file" />
|
||||||
|
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Deletion?" buttonTitle="Delete"
|
||||||
|
isErrorButton submitAction="delete" :checkboxes="$directoryDeletionCheckboxes" :actions="[
|
||||||
|
'The selected directory and all its contents will be permanently deleted from the container.',
|
||||||
|
]"
|
||||||
|
confirmationText="{{ $fs_path }}"
|
||||||
|
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||||
|
shortConfirmationLabel="Filepath" />
|
||||||
|
@else
|
||||||
|
@if (!$fileStorage->is_binary)
|
||||||
|
<x-modal-confirmation :ignoreWire="false" title="Confirm File Conversion to Directory?"
|
||||||
|
buttonTitle="Convert to directory" submitAction="convertToDirectory" :actions="[
|
||||||
|
'The selected file will be permanently deleted and an empty directory will be created in its place.',
|
||||||
|
]"
|
||||||
|
confirmationText="{{ $fs_path }}"
|
||||||
|
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||||
|
shortConfirmationLabel="Filepath" :confirmWithPassword="false"
|
||||||
|
step2ButtonText="Convert to directory" />
|
||||||
|
@endif
|
||||||
|
<x-forms.button type="button" wire:click="loadStorageOnServer">Load from
|
||||||
|
server</x-forms.button>
|
||||||
|
<x-modal-confirmation :ignoreWire="false" title="Confirm File Deletion?" buttonTitle="Delete"
|
||||||
|
isErrorButton submitAction="delete" :checkboxes="$fileDeletionCheckboxes" :actions="['The selected file will be permanently deleted from the container.']"
|
||||||
|
confirmationText="{{ $fs_path }}"
|
||||||
|
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||||
|
shortConfirmationLabel="Filepath" />
|
||||||
@endif
|
@endif
|
||||||
<x-forms.button type="button" wire:click="loadStorageOnServer">Load from server</x-forms.button>
|
</div>
|
||||||
<x-modal-confirmation :ignoreWire="false" title="Confirm File Deletion?" buttonTitle="Delete"
|
@endcan
|
||||||
isErrorButton submitAction="delete" :checkboxes="$fileDeletionCheckboxes" :actions="['The selected file will be permanently deleted from the container.']"
|
@if (!$fileStorage->is_directory)
|
||||||
confirmationText="{{ $fs_path }}"
|
@can('update', $resource)
|
||||||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
|
||||||
shortConfirmationLabel="Filepath" />
|
<div class="w-96">
|
||||||
@endif
|
<x-forms.checkbox instantSave label="Is this based on the Git repository?"
|
||||||
</div>
|
id="fileStorage.is_based_on_git"></x-forms.checkbox>
|
||||||
@endcan
|
</div>
|
||||||
@if (!$fileStorage->is_directory)
|
@endif
|
||||||
@can('update', $resource)
|
<x-forms.textarea
|
||||||
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
|
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
|
||||||
<div class="w-96">
|
rows="20" id="fileStorage.content"
|
||||||
<x-forms.checkbox instantSave label="Is this based on the Git repository?"
|
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea>
|
||||||
id="fileStorage.is_based_on_git"></x-forms.checkbox>
|
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
|
||||||
</div>
|
<x-forms.button class="w-full" type="submit">Save</x-forms.button>
|
||||||
@endif
|
@endif
|
||||||
<x-forms.textarea
|
@else
|
||||||
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
|
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
|
||||||
rows="20" id="fileStorage.content"
|
<div class="w-96">
|
||||||
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea>
|
<x-forms.checkbox disabled label="Is this based on the Git repository?"
|
||||||
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
|
id="fileStorage.is_based_on_git"></x-forms.checkbox>
|
||||||
<x-forms.button class="w-full" type="submit">Save</x-forms.button>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@else
|
<x-forms.textarea
|
||||||
|
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
|
||||||
|
rows="20" id="fileStorage.content" disabled></x-forms.textarea>
|
||||||
|
@endcan
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
{{-- Read-only view --}}
|
||||||
|
@if (!$fileStorage->is_directory)
|
||||||
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
|
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
|
||||||
<div class="w-96">
|
<div class="w-96">
|
||||||
<x-forms.checkbox disabled label="Is this based on the Git repository?"
|
<x-forms.checkbox disabled label="Is this based on the Git repository?"
|
||||||
|
|
@ -68,7 +94,7 @@
|
||||||
<x-forms.textarea
|
<x-forms.textarea
|
||||||
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
|
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
|
||||||
rows="20" id="fileStorage.content" disabled></x-forms.textarea>
|
rows="20" id="fileStorage.content" disabled></x-forms.textarea>
|
||||||
@endcan
|
@endif
|
||||||
@endif
|
@endif
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@
|
||||||
volumeModalOpen: false,
|
volumeModalOpen: false,
|
||||||
fileModalOpen: false,
|
fileModalOpen: false,
|
||||||
directoryModalOpen: false
|
directoryModalOpen: false
|
||||||
}" @close-storage-modal.window="
|
}"
|
||||||
|
@close-storage-modal.window="
|
||||||
if ($event.detail === 'volume') volumeModalOpen = false;
|
if ($event.detail === 'volume') volumeModalOpen = false;
|
||||||
if ($event.detail === 'file') fileModalOpen = false;
|
if ($event.detail === 'file') fileModalOpen = false;
|
||||||
if ($event.detail === 'directory') directoryModalOpen = false;
|
if ($event.detail === 'directory') directoryModalOpen = false;
|
||||||
|
|
@ -45,8 +46,7 @@
|
||||||
<div
|
<div
|
||||||
class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
|
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">
|
<div class="flex flex-col gap-1">
|
||||||
<a class="dropdown-item"
|
<a class="dropdown-item" @click="volumeModalOpen = true; dropdownOpen = false">
|
||||||
@click="volumeModalOpen = true; dropdownOpen = false">
|
|
||||||
<svg class="size-4" fill="none" stroke="currentColor"
|
<svg class="size-4" fill="none" stroke="currentColor"
|
||||||
viewBox="0 0 24 24">
|
viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
|
@ -105,31 +105,41 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex items-center justify-center w-auto"
|
<div class="relative flex items-center justify-center w-auto"
|
||||||
x-init="$watch('volumeModalOpen', value => { if(value) { $nextTick(() => { const input = $el.querySelector('input'); input?.focus(); }) } })">
|
x-init="$watch('volumeModalOpen', value => {
|
||||||
<form class="flex flex-col w-full gap-2 rounded-sm" wire:submit='submitPersistentVolume'>
|
if (value) {
|
||||||
|
$nextTick(() => {
|
||||||
|
const input = $el.querySelector('input');
|
||||||
|
input?.focus();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})">
|
||||||
|
<form class="flex flex-col w-full gap-2 rounded-sm"
|
||||||
|
wire:submit='submitPersistentVolume'>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div>Docker Volumes mounted to the container.</div>
|
<div>Docker Volumes mounted to the container.</div>
|
||||||
</div>
|
</div>
|
||||||
@if ($isSwarm)
|
@if ($isSwarm)
|
||||||
<div class="text-warning">Swarm Mode detected: You need to set a shared volume
|
<div class="text-warning">Swarm Mode detected: You need to set a shared
|
||||||
(EFS/NFS/etc) on all the worker nodes if you would like to use a persistent
|
volume
|
||||||
|
(EFS/NFS/etc) on all the worker nodes if you would like to use a
|
||||||
|
persistent
|
||||||
volumes.</div>
|
volumes.</div>
|
||||||
@endif
|
@endif
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<x-forms.input canGate="update" :canResource="$resource" placeholder="pv-name"
|
<x-forms.input canGate="update" :canResource="$resource" placeholder="pv-name"
|
||||||
id="name" label="Name" required helper="Volume name." />
|
id="name" label="Name" required helper="Volume name." />
|
||||||
@if ($isSwarm)
|
@if ($isSwarm)
|
||||||
<x-forms.input canGate="update" :canResource="$resource" placeholder="/root"
|
<x-forms.input canGate="update" :canResource="$resource"
|
||||||
id="host_path" label="Source Path" required
|
placeholder="/root" id="host_path" label="Source Path" required
|
||||||
helper="Directory on the host system." />
|
helper="Directory on the host system." />
|
||||||
@else
|
@else
|
||||||
<x-forms.input canGate="update" :canResource="$resource" placeholder="/root"
|
<x-forms.input canGate="update" :canResource="$resource"
|
||||||
id="host_path" label="Source Path"
|
placeholder="/root" id="host_path" label="Source Path"
|
||||||
helper="Directory on the host system." />
|
helper="Directory on the host system." />
|
||||||
@endif
|
@endif
|
||||||
<x-forms.input canGate="update" :canResource="$resource" placeholder="/tmp/root"
|
<x-forms.input canGate="update" :canResource="$resource"
|
||||||
id="mount_path" label="Destination Path" required
|
placeholder="/tmp/root" id="mount_path" label="Destination Path"
|
||||||
helper="Directory inside the container." />
|
required helper="Directory inside the container." />
|
||||||
<x-forms.button canGate="update" :canResource="$resource" type="submit">
|
<x-forms.button canGate="update" :canResource="$resource" type="submit">
|
||||||
Add
|
Add
|
||||||
</x-forms.button>
|
</x-forms.button>
|
||||||
|
|
@ -169,15 +179,24 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex items-center justify-center w-auto"
|
<div class="relative flex items-center justify-center w-auto"
|
||||||
x-init="$watch('fileModalOpen', value => { if(value) { $nextTick(() => { const input = $el.querySelector('input'); input?.focus(); }) } })">
|
x-init="$watch('fileModalOpen', value => {
|
||||||
<form class="flex flex-col w-full gap-2 rounded-sm" wire:submit='submitFileStorage'>
|
if (value) {
|
||||||
|
$nextTick(() => {
|
||||||
|
const input = $el.querySelector('input');
|
||||||
|
input?.focus();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})">
|
||||||
|
<form class="flex flex-col w-full gap-2 rounded-sm"
|
||||||
|
wire:submit='submitFileStorage'>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div>Actual file mounted from the host system to the container.</div>
|
<div>Actual file mounted from the host system to the container.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<x-forms.input canGate="update" :canResource="$resource"
|
<x-forms.input canGate="update" :canResource="$resource"
|
||||||
placeholder="/etc/nginx/nginx.conf" id="file_storage_path"
|
placeholder="/etc/nginx/nginx.conf" id="file_storage_path"
|
||||||
label="Destination Path" required helper="File location inside the container" />
|
label="Destination Path" required
|
||||||
|
helper="File location inside the container" />
|
||||||
<x-forms.textarea canGate="update" :canResource="$resource" label="Content"
|
<x-forms.textarea canGate="update" :canResource="$resource" label="Content"
|
||||||
id="file_storage_content"></x-forms.textarea>
|
id="file_storage_content"></x-forms.textarea>
|
||||||
<x-forms.button canGate="update" :canResource="$resource" type="submit">
|
<x-forms.button canGate="update" :canResource="$resource" type="submit">
|
||||||
|
|
@ -219,18 +238,27 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex items-center justify-center w-auto"
|
<div class="relative flex items-center justify-center w-auto"
|
||||||
x-init="$watch('directoryModalOpen', value => { if(value) { $nextTick(() => { const input = $el.querySelector('input'); input?.focus(); }) } })">
|
x-init="$watch('directoryModalOpen', value => {
|
||||||
<form class="flex flex-col w-full gap-2 rounded-sm" wire:submit='submitFileStorageDirectory'>
|
if (value) {
|
||||||
|
$nextTick(() => {
|
||||||
|
const input = $el.querySelector('input');
|
||||||
|
input?.focus();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})">
|
||||||
|
<form class="flex flex-col w-full gap-2 rounded-sm"
|
||||||
|
wire:submit='submitFileStorageDirectory'>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div>Directory mounted from the host system to the container.</div>
|
<div>Directory mounted from the host system to the container.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<x-forms.input canGate="update" :canResource="$resource"
|
<x-forms.input canGate="update" :canResource="$resource"
|
||||||
placeholder="{{ application_configuration_dir() }}/{{ $resource->uuid }}/etc/nginx"
|
placeholder="{{ application_configuration_dir() }}/{{ $resource->uuid }}/etc/nginx"
|
||||||
id="file_storage_directory_source" label="Source Directory" required
|
id="file_storage_directory_source" label="Source Directory"
|
||||||
helper="Directory on the host system." />
|
required helper="Directory on the host system." />
|
||||||
<x-forms.input canGate="update" :canResource="$resource" placeholder="/etc/nginx"
|
<x-forms.input canGate="update" :canResource="$resource"
|
||||||
id="file_storage_directory_destination" label="Destination Directory" required
|
placeholder="/etc/nginx" id="file_storage_directory_destination"
|
||||||
|
label="Destination Directory" required
|
||||||
helper="Directory inside the container." />
|
helper="Directory inside the container." />
|
||||||
<x-forms.button canGate="update" :canResource="$resource" type="submit">
|
<x-forms.button canGate="update" :canResource="$resource" type="submit">
|
||||||
Add
|
Add
|
||||||
|
|
@ -270,19 +298,22 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
|
||||||
{{-- Tabs Navigation --}}
|
{{-- Tabs Navigation --}}
|
||||||
<div class="flex gap-2 border-b dark:border-coolgray-300 border-neutral-200">
|
<div class="flex gap-2 border-b dark:border-coolgray-300 border-neutral-200">
|
||||||
<button @click="activeTab = 'volumes'"
|
<button @click="activeTab = 'volumes'"
|
||||||
:class="activeTab === 'volumes' ? 'border-b-2 dark:border-white border-black' : 'border-b-2 border-transparent'"
|
:class="activeTab === 'volumes' ? 'border-b-2 dark:border-white border-black' :
|
||||||
|
'border-b-2 border-transparent'"
|
||||||
@if (!$hasVolumes) disabled @endif
|
@if (!$hasVolumes) disabled @endif
|
||||||
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasVolumes ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
|
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasVolumes ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
|
||||||
Volumes ({{ $this->volumeCount }})
|
Volumes ({{ $this->volumeCount }})
|
||||||
</button>
|
</button>
|
||||||
<button @click="activeTab = 'files'"
|
<button @click="activeTab = 'files'"
|
||||||
:class="activeTab === 'files' ? 'border-b-2 dark:border-white border-black' : 'border-b-2 border-transparent'"
|
:class="activeTab === 'files' ? 'border-b-2 dark:border-white border-black' :
|
||||||
|
'border-b-2 border-transparent'"
|
||||||
@if (!$hasFiles) disabled @endif
|
@if (!$hasFiles) disabled @endif
|
||||||
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasFiles ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
|
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasFiles ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
|
||||||
Files ({{ $this->fileCount }})
|
Files ({{ $this->fileCount }})
|
||||||
</button>
|
</button>
|
||||||
<button @click="activeTab = 'directories'"
|
<button @click="activeTab = 'directories'"
|
||||||
:class="activeTab === 'directories' ? 'border-b-2 dark:border-white border-black' : 'border-b-2 border-transparent'"
|
:class="activeTab === 'directories' ? 'border-b-2 dark:border-white border-black' :
|
||||||
|
'border-b-2 border-transparent'"
|
||||||
@if (!$hasDirectories) disabled @endif
|
@if (!$hasDirectories) disabled @endif
|
||||||
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
|
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
|
||||||
Directories ({{ $this->directoryCount }})
|
Directories ({{ $this->directoryCount }})
|
||||||
|
|
@ -333,19 +364,96 @@ class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@else
|
@else
|
||||||
@if ($resource->persistentStorages()->get()->count() > 0)
|
<div class="flex flex-col gap-4 py-2">
|
||||||
<h3>{{ Str::headline($resource->name) }} </h3>
|
<div>
|
||||||
@endif
|
<div class="flex items-center gap-2">
|
||||||
@if ($resource->persistentStorages()->get()->count() > 0)
|
<h2>{{ Str::headline($resource->name) }}</h2>
|
||||||
<livewire:project.shared.storages.all :resource="$resource" />
|
</div>
|
||||||
@endif
|
|
||||||
@if ($fileStorage->count() > 0)
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
@foreach ($fileStorage->sort() as $fileStorage)
|
|
||||||
<livewire:project.service.file-storage :fileStorage="$fileStorage"
|
|
||||||
wire:key="resource-{{ $fileStorage->uuid }}" />
|
|
||||||
@endforeach
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
|
||||||
|
@if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
|
||||||
|
<div>No storage found.</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@php
|
||||||
|
$hasVolumes = $this->volumeCount > 0;
|
||||||
|
$hasFiles = $this->fileCount > 0;
|
||||||
|
$hasDirectories = $this->directoryCount > 0;
|
||||||
|
$defaultTab = $hasVolumes ? 'volumes' : ($hasFiles ? 'files' : 'directories');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($hasVolumes || $hasFiles || $hasDirectories)
|
||||||
|
<div x-data="{
|
||||||
|
activeTab: '{{ $defaultTab }}'
|
||||||
|
}">
|
||||||
|
{{-- Tabs Navigation --}}
|
||||||
|
<div class="flex gap-2 border-b dark:border-coolgray-300 border-neutral-200">
|
||||||
|
<button @click="activeTab = 'volumes'"
|
||||||
|
:class="activeTab === 'volumes' ? 'border-b-2 dark:border-white border-black' :
|
||||||
|
'border-b-2 border-transparent'"
|
||||||
|
@if (!$hasVolumes) disabled @endif
|
||||||
|
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasVolumes ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
|
||||||
|
Volumes ({{ $this->volumeCount }})
|
||||||
|
</button>
|
||||||
|
<button @click="activeTab = 'files'"
|
||||||
|
:class="activeTab === 'files' ? 'border-b-2 dark:border-white border-black' :
|
||||||
|
'border-b-2 border-transparent'"
|
||||||
|
@if (!$hasFiles) disabled @endif
|
||||||
|
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasFiles ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
|
||||||
|
Files ({{ $this->fileCount }})
|
||||||
|
</button>
|
||||||
|
<button @click="activeTab = 'directories'"
|
||||||
|
:class="activeTab === 'directories' ? 'border-b-2 dark:border-white border-black' :
|
||||||
|
'border-b-2 border-transparent'"
|
||||||
|
@if (!$hasDirectories) disabled @endif
|
||||||
|
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
|
||||||
|
Directories ({{ $this->directoryCount }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Tab Content --}}
|
||||||
|
<div class="pt-4">
|
||||||
|
{{-- Volumes Tab --}}
|
||||||
|
<div x-show="activeTab === 'volumes'" class="flex flex-col gap-4">
|
||||||
|
@if ($hasVolumes)
|
||||||
|
<livewire:project.shared.storages.all :resource="$resource" />
|
||||||
|
@else
|
||||||
|
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
|
||||||
|
No volumes configured.
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Files Tab --}}
|
||||||
|
<div x-show="activeTab === 'files'" class="flex flex-col gap-4">
|
||||||
|
@if ($hasFiles)
|
||||||
|
@foreach ($this->files as $fs)
|
||||||
|
<livewire:project.service.file-storage :fileStorage="$fs"
|
||||||
|
wire:key="file-{{ $fs->id }}" />
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
|
||||||
|
No file mounts configured.
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Directories Tab --}}
|
||||||
|
<div x-show="activeTab === 'directories'" class="flex flex-col gap-4">
|
||||||
|
@if ($hasDirectories)
|
||||||
|
@foreach ($this->directories as $fs)
|
||||||
|
<livewire:project.service.file-storage :fileStorage="$fs"
|
||||||
|
wire:key="directory-{{ $fs->id }}" />
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
|
||||||
|
No directory mounts configured.
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
<div>
|
<div>
|
||||||
<form wire:submit='submit' class="flex flex-col items-center gap-4 p-4 bg-white border lg:items-start dark:bg-base dark:border-coolgray-300 border-neutral-200">
|
<form wire:submit='submit' class="flex flex-col items-center gap-4 p-4 bg-white border lg:items-start dark:bg-base dark:border-coolgray-300 border-neutral-200">
|
||||||
@if ($isReadOnly)
|
@if ($isReadOnly)
|
||||||
|
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
|
||||||
|
This volume is mounted as read-only and cannot be modified from the UI.
|
||||||
|
</div>
|
||||||
@if ($isFirst)
|
@if ($isFirst)
|
||||||
<div class="flex gap-2 items-end w-full md:flex-row flex-col">
|
<div class="flex gap-2 items-end w-full md:flex-row flex-col">
|
||||||
@if (
|
@if (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue