Files
coolify/resources/views/livewire/project/shared/file-browser.blade.php
T
Andras Bacsai fa1895c99a feat(dashboard): add canvas view with active deployment overlay
Add a /dashboard/canvas page that renders every team server with its
resources and their running or queued deployments, backed by a new
ServerCanvasController::dashboard endpoint and a per-server usage
endpoint for zone headers. The dashboard remembers the list/canvas
choice and each section (projects, servers) its own table/grid view.

- Show Deploying/Queued pills on canvas cards and a foldable recent
  deployments panel; add shared CanvasControls component
- Allow backup downloads from the server host via the S3 download
  storage (PrepareFileDownloadJob accepts a null container)
- Extract backup toggle/run actions into ManagesDatabaseBackup and
  surface them in backup execution headers; merge S3 and retention
  sections into the general backup settings
- Move deployments indicator to the top bar and add a main nav
  resource menu with submenus; simplify breadcrumbs and settings
  layouts
- Add shared variables overview canvas and scheduled task modals
- Add feature tests for dashboard canvas, view modes, navbar
  submenus, backup S3 downloads and scheduled task modals
2026-10-14 23:55:28 +02:00

435 lines
29 KiB
PHP

<div wire:init="loadFiles" x-on:open-file-download.window="window.location.href = $event.detail.url">
@if (count($pendingDownloads) > 0)
<div wire:poll.3s="checkDownloads" class="hidden"></div>
@endif
<x-slot:title>
{{ data_get_str($resource, 'name')->limit(10) }} > Files | Coolify
</x-slot>
@if ($type === 'application')
<livewire:project.application.heading :application="$resource" wire:key="application-heading-files" />
@elseif ($type === 'service')
<livewire:project.service.heading :service="$resource" :parameters="$parameters" title="Files" />
@else
<livewire:project.database.heading :database="$resource" />
@endif
{{-- Standard resource settings workspace (matches Logs/Configuration) so the
heading spacing and sidebar behave identically to every other page. --}}
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0"
x-data="{
busy: null,
{{-- Show a label for the running action over the listing. The short
delay skips the overlay for fast requests. --}}
run(label, action) {
const timer = setTimeout(() => this.busy = label, 150);
return Promise.resolve().then(action).catch(() => {}).finally(() => {
clearTimeout(timer);
this.busy = null;
});
},
uploadFile(file) {
return this.run('Uploading file...', () => new Promise((resolve, reject) => {
this.$wire.upload('upload', file, () => this.$wire.uploadFile().then(resolve, reject), reject);
}));
},
}">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
@if ($type === 'application')
<x-application.configuration-sidebar :application="$resource"
current-route="project.application.files" />
@elseif ($type === 'service')
<x-service.configuration-sidebar :service="$resource"
current-route="project.service.files" />
@else
<x-database.configuration-sidebar :database="$resource"
current-route="project.database.files" />
@endif
<div class="min-w-0">
<x-application.settings-section title="Files"
helper="Browse and manage files inside the running container." :flush="true">
<x-slot:badges>
<x-beta-badge />
</x-slot:badges>
<x-slot:actions>
@if ($containerRunning && $filesLoaded)
<input type="file" class="hidden" x-ref="uploadInput"
@change="if ($event.target.files.length) { uploadFile($event.target.files[0]); $event.target.value = '' }" />
<x-forms.button type="button" title="Upload a file to this folder" canGate="canAccessTerminal" :canResource="$resource"
wire:target="uploadFile" @click="$refs.uploadInput.click()">
Upload
</x-forms.button>
<div x-data="{ creationMenuOpen: false }" @keydown.escape.window="creationMenuOpen = false">
<x-forms.button type="button" x-ref="newTrigger"
@click="creationMenuOpen = !creationMenuOpen" x-bind:aria-expanded="creationMenuOpen"
canGate="canAccessTerminal" :canResource="$resource">
New
<x-reicon name="chevron-down" class="size-3.5" />
</x-forms.button>
<template x-teleport="body">
<div x-show="creationMenuOpen" x-cloak x-transition.opacity.duration.120ms
x-anchor.bottom-end.fixed.offset.4="$refs.newTrigger"
@click.outside="creationMenuOpen = false"
class="listbox-panel min-w-36! w-36! z-50!">
<x-modal-input title="New file">
<x-slot:content>
<button type="button" class="listbox-option" @click="creationMenuOpen = false">
<x-reicon name="file" class="size-3.5 shrink-0" />
<span class="flex-1 text-left">New file</span>
</button>
</x-slot:content>
<form x-data="{ name: '' }"
@submit.prevent="run('Creating file...', () => $wire.createFile(name)); name=''; $dispatch('close-modal')"
class="flex flex-col gap-4">
<x-forms.input id="newFileName" label="File name" x-model="name"
placeholder="config.yml" required />
<div class="flex justify-end">
<x-forms.button type="submit" isHighlighted canGate="canAccessTerminal" :canResource="$resource">Create file</x-forms.button>
</div>
</form>
</x-modal-input>
<x-modal-input title="New folder">
<x-slot:content>
<button type="button" class="listbox-option" @click="creationMenuOpen = false">
<x-reicon name="folder" class="size-3.5 shrink-0" />
<span class="flex-1 text-left">New folder</span>
</button>
</x-slot:content>
<form x-data="{ name: '' }"
@submit.prevent="run('Creating folder...', () => $wire.createDirectory(name)); name=''; $dispatch('close-modal')"
class="flex flex-col gap-4">
<x-forms.input id="newFolderName" label="Folder name" x-model="name"
placeholder="plugins" required />
<div class="flex justify-end">
<x-forms.button type="submit" isHighlighted canGate="canAccessTerminal" :canResource="$resource">Create folder</x-forms.button>
</div>
</form>
</x-modal-input>
</div>
</template>
</div>
<x-forms.button type="button" @click="run('Loading files...', () => $wire.refresh())" title="Refresh this folder">
Refresh
</x-forms.button>
@endif
</x-slot:actions>
@if ($loadFailed)
<x-empty title="Could not load files" description="Check the server connection and try again." />
<div class="flex justify-center pb-4">
<x-forms.button wire:click="loadFiles">Retry</x-forms.button>
</div>
@elseif (! $filesLoaded)
<div class="flex justify-center py-12">
<x-loading text="Loading files..." />
</div>
@elseif (! $containerRunning)
<x-empty title="Container is not running"
description="Start the container to browse its files.">
<x-slot:icon>
<x-reicon name="folder" class="size-8" />
</x-slot:icon>
</x-empty>
@else
{{-- Keep the current listing visible while an action runs: blur it
and show the action label (from run()) on top of it. --}}
<div class="relative">
<div x-show="busy" x-cloak class="absolute inset-0 z-10">
<div class="flex h-full items-start justify-center">
<div class="sticky top-[40vh] mt-12 inline-flex items-center gap-2 rounded-md bg-white px-3 py-1.5 text-[13px] text-neutral-500 shadow-sm ring-1 ring-black/5 dark:bg-coolgray-100 dark:text-fg-dim dark:ring-white/10">
<x-loading />
<span x-text="busy"></span>
</div>
</div>
</div>
<div x-bind:class="busy && 'pointer-events-none select-none blur-[2px] opacity-60'"
class="transition-[filter,opacity] duration-150 motion-reduce:transition-none">
{{-- Container picker (services / multi-container) --}}
@if (count($availableContainers) > 1)
<div class="flex flex-wrap items-center gap-2 border-b border-coollabs-hairline px-4 py-2">
<span class="text-xs text-coollabs-subtle">Container</span>
@foreach ($availableContainers as $name)
<button type="button" x-on:click="run('Loading files...', () => $wire.selectContainer(@js($name)))"
@class([
'button',
'bg-coollabs/10 text-coollabs ring-coollabs/25 dark:bg-white/[0.08] dark:text-white dark:ring-white/15' => $name === $container,
])>{{ $name }}</button>
@endforeach
</div>
@endif
{{-- Breadcrumb. The root crumb renders as "/" and doubles as the
first separator, so we only draw a slash between named segments
(index >= 2) - avoids the "/ /" duplicate. --}}
<div class="flex flex-wrap items-center gap-1 px-4 py-2 text-sm">
@foreach ($this->breadcrumbs() as $crumb)
@if ($loop->index > 1)
<span class="text-coollabs-subtle">/</span>
@endif
<button type="button" x-on:click="run('Loading files...', () => $wire.goTo(@js($crumb['path'])))"
@class([
'rounded-md px-1.5 py-0.5 hover:bg-black/5 hover:text-current dark:hover:bg-white/[0.06]',
'text-current font-medium' => $loop->last,
'text-coollabs-subtle' => ! $loop->last,
])>
{{ $crumb['label'] }}
</button>
@endforeach
</div>
{{-- Drag-and-drop file list --}}
<div x-data="{ dragging: false }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; if ($event.dataTransfer.files.length) { uploadFile($event.dataTransfer.files[0]) }"
:class="dragging ? 'ring-2 ring-coollabs/40 dark:ring-white/30' : ''"
class="transition-[box-shadow] duration-150 ease-out motion-reduce:transition-none">
@if (count($entries) === 0)
<x-empty size="sm" title="This folder is empty"
description="Upload a file to get started.">
<x-slot:icon>
<x-reicon name="folder" class="size-8" />
</x-slot:icon>
</x-empty>
@else
<div class="data-table w-full">
<div class="data-table-header file-table-grid">
<span></span>
<span>Name</span>
<span>Perms</span>
<span>Owner</span>
<span>Group</span>
<span class="text-right">Size</span>
<span>Modified</span>
<span class="text-right">Actions</span>
</div>
@foreach ($entries as $entry)
<div wire:key="entry-{{ $entry['name'] }}" class="data-table-row file-table-grid">
<div class="flex items-center">
<x-reicon :name="$entry['type'] === 'dir' ? 'folder-filled' : 'file'"
class="size-4 {{ $entry['type'] === 'dir' ? 'text-coollabs dark:text-neutral-200' : 'text-coollabs-subtle opacity-70' }}" />
</div>
<div class="min-w-0">
@if ($entry['type'] === 'dir')
<button type="button" x-on:click="run('Loading files...', () => $wire.open(@js($entry['name'])))"
class="block w-full cursor-pointer truncate text-left font-medium hover:text-coollabs hover:underline dark:hover:text-white">{{ $entry['name'] }}</button>
@else
<span class="block truncate">{{ $entry['name'] }}</span>
@endif
</div>
{{-- Permissions (click to chmod) --}}
<div class="min-w-0">
<x-modal-input title="Change permissions">
<x-slot:content>
<button type="button" title="Edit permissions"
class="cursor-pointer rounded-md px-1.5 py-0.5 font-mono text-xs text-coollabs-subtle hover:bg-black/5 hover:text-current dark:hover:bg-white/[0.06]">
{{ $entry['perms'] ?: '-' }}
</button>
</x-slot:content>
<form x-data="{ mode: @js($entry['perms']) }"
@submit.prevent="run('Updating permissions...', () => $wire.chmodEntry(@js($entry['name']), mode)); $dispatch('close-modal')"
class="flex flex-col gap-4">
<p class="text-sm text-coollabs-subtle">
Set the octal permission mode for
<span class="font-medium">{{ $entry['name'] }}</span>.
</p>
<x-forms.input id="chmodMode-{{ $loop->index }}" label="Octal mode"
x-model="mode" placeholder="644" required />
<div class="flex justify-end">
<x-forms.button type="submit" isHighlighted canGate="canAccessTerminal" :canResource="$resource">Apply</x-forms.button>
</div>
</form>
</x-modal-input>
</div>
<div class="min-w-0 truncate text-coollabs-subtle">
{{ $entry['owner'] ?: '-' }}
</div>
<div class="min-w-0 truncate text-coollabs-subtle">
{{ $entry['group'] ?: '-' }}
</div>
<div class="text-right text-coollabs-subtle tabular-nums">
{{ $entry['type'] === 'dir' ? '-' : formatBytes($entry['size']) }}
</div>
<div class="text-coollabs-subtle tabular-nums">
{{ $entry['mtime'] ? \Illuminate\Support\Carbon::createFromTimestamp($entry['mtime'])->toDateTimeString() : '-' }}
</div>
{{-- Actions kebab. The entry name lives in this
row's x-data as a JS string so the menu can
call $wire.* with it directly - passing @js()
through a component attribute double-encodes
the quotes and breaks the handler. --}}
<div class="flex items-center justify-end"
x-data="{ open: false, name: @js($entry['name']), isDir: @js($entry['type'] === 'dir') }"
@keydown.escape.window="open = false">
<div class="relative">
<button type="button" title="Actions" aria-label="Actions" x-ref="trigger"
x-on:click="open = !open" x-bind:aria-expanded="open"
x-bind:class="open && 'bg-black/5 text-current dark:bg-white/[0.08]'"
class="inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-coollabs-subtle hover:bg-black/5 hover:text-current focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs/40 dark:hover:bg-white/[0.06] dark:focus-visible:ring-white/30">
<x-reicon name="dots-horizontal" class="size-4" />
</button>
<template x-teleport="body">
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
x-anchor.bottom-end.fixed.offset.4="$refs.trigger"
@click.outside="open = false"
class="listbox-panel min-w-52! w-52! z-50!">
@if ($entry['type'] !== 'dir')
<button type="button" class="listbox-option"
x-on:click="open = false; run('Opening file...', () => $wire.openEditor(name))">
<x-reicon name="file-content" class="size-3.5 shrink-0" />
<span class="min-w-0 flex-1 text-left">Edit</span>
</button>
@endif
<button type="button" class="listbox-option"
x-on:click="open = false; run(isDir ? 'Preparing folder download...' : 'Preparing file download...', () => $wire.download(name))">
<x-reicon name="download" class="size-3.5 shrink-0" />
<span class="min-w-0 flex-1 text-left">Download</span>
</button>
{{-- Rename --}}
<x-modal-input title="Rename">
<x-slot:content>
<button type="button" class="listbox-option" x-on:click="open = false">
<x-reicon name="edit" class="size-3.5 shrink-0" />
<span class="min-w-0 flex-1 text-left">Rename</span>
</button>
</x-slot:content>
<form x-data="{ newName: @js($entry['name']) }"
@submit.prevent="run('Renaming...', () => $wire.renameEntry(name, newName)); $dispatch('close-modal')"
class="flex flex-col gap-4">
<x-forms.input id="renameName-{{ $loop->index }}"
label="New name" x-model="newName" required />
<div class="flex justify-end">
<x-forms.button type="submit" isHighlighted canGate="canAccessTerminal" :canResource="$resource">Rename</x-forms.button>
</div>
</form>
</x-modal-input>
{{-- Delete --}}
<x-modal-input title="Delete this item?">
<x-slot:content>
<button type="button" class="listbox-option" x-on:click="open = false">
<x-reicon name="trash" class="size-3.5 shrink-0 text-red-600 dark:text-red-500" />
<span class="min-w-0 flex-1 text-left text-red-600 dark:text-red-500">Delete</span>
</button>
</x-slot:content>
<div class="flex flex-col gap-4">
<p class="text-sm text-coollabs-subtle">
This permanently deletes
<span class="font-medium">{{ $entry['name'] }}</span>
inside the container. This cannot be undone.
</p>
<div class="flex justify-end">
<x-forms.button isError type="button" canGate="canAccessTerminal" :canResource="$resource"
x-on:click="run(isDir ? 'Deleting folder...' : 'Deleting file...', () => $wire.deleteEntry(name)); $dispatch('close-modal')">Delete</x-forms.button>
</div>
</div>
</x-modal-input>
</div>
</template>
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
</div>
{{-- Editor modal (opens when a text file is loaded) --}}
<x-modal-input title="Edit file" :wireOpen="'editorOpen'" :content="true" wireIgnore="false" isLarge>
<x-slot:content>
<span class="hidden"></span>
</x-slot:content>
<form @submit.prevent="run('Saving file...', () => $wire.saveEditor())" class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-2">
<p class="truncate font-mono text-xs text-coollabs-subtle">{{ $editingPath }}</p>
@if ($editorLanguage)
<span class="shrink-0 rounded-md bg-coollabs/10 px-1.5 py-0.5 font-mono text-[11px] text-coollabs dark:bg-white/[0.08] dark:text-neutral-200">{{ $editorLanguage }}</span>
@endif
</div>
{{-- Monaco editor, kept mounted across opens (wire:ignore) and
preloaded on page init so the first Edit click is instant.
openEditor dispatches load-file-editor with the content and
Monaco language id; we setValue + setModelLanguage without
recreating, so highlighting applies and there is no flash.
Inline Alpine (like x-forms.monaco-editor) means this works
without a JS rebuild. --}}
<div wire:ignore class="min-w-0" x-init="loadMonaco()"
x-on:load-file-editor.window="load($event.detail)"
x-data="{
vs: '/js/monaco-editor-0.52.2/min/vs',
base: '/js/monaco-editor-0.52.2/min',
isDark() {
return document.documentElement.classList.contains('dark') || localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
},
loadMonaco() {
if (window.__fileEditorMonaco) return window.__fileEditorMonaco;
const vs = this.vs, base = this.base;
window.__fileEditorMonaco = new Promise((resolve, reject) => {
const boot = () => {
window.require.config({ paths: { vs } });
const proxy = URL.createObjectURL(new Blob([`self.MonacoEnvironment={baseUrl:'${window.location.origin}${base}'};importScripts('${window.location.origin}${vs}/base/worker/workerMain.js');`], { type: 'text/javascript' }));
window.MonacoEnvironment = { getWorkerUrl: () => proxy };
window.require(['vs/editor/editor.main'], () => resolve(window.monaco));
};
if (typeof window.require !== 'undefined' && window.require.config) { boot(); return; }
const s = document.createElement('script');
s.src = `${vs}/loader.js`; s.onload = boot; s.onerror = reject;
document.head.appendChild(s);
});
return window.__fileEditorMonaco;
},
ensureTheme(monaco) {
if (window.__fileEditorThemeDefined) return;
monaco.editor.defineTheme('coolify-dark', { base: 'vs-dark', inherit: true, rules: [], colors: { 'editor.background': '#0b0b0c', 'editorGutter.background': '#0b0b0c', 'minimap.background': '#0b0b0c' } });
window.__fileEditorThemeDefined = true;
},
async load(detail) {
const monaco = await this.loadMonaco();
this.ensureTheme(monaco);
const el = this.$refs.editor;
const wire = this.$wire;
const content = (detail && detail.content) || '';
const language = (detail && detail.language) || 'plaintext';
// Keep the Monaco instance on the DOM node, NOT in
// Alpine reactive state - proxying its huge object
// graph would hang and crash the tab.
if (!el._monacoEditor) {
const editor = monaco.editor.create(el, { value: content, language, theme: this.isDark() ? 'coolify-dark' : 'vs', automaticLayout: true, minimap: { enabled: false }, fontSize: 14, lineNumbersMinChars: 3, scrollBeyondLastLine: false, renderLineHighlight: 'all', padding: { top: 12, bottom: 12 }, scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8, useShadows: false } });
el._monacoEditor = editor;
editor.onDidChangeModelContent(() => { wire.set('editorContent', editor.getValue(), false); });
new MutationObserver(() => monaco.editor.setTheme(this.isDark() ? 'coolify-dark' : 'vs')).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
} else {
const model = el._monacoEditor.getModel();
model.setValue(content);
monaco.editor.setModelLanguage(model, language);
}
const editor = el._monacoEditor;
this.$nextTick(() => { editor.layout(); setTimeout(() => editor.focus(), 40); });
}
}">
<div x-ref="editor" class="file-editor-monaco"></div>
</div>
<div class="flex justify-end gap-2">
<x-forms.button type="button" wire:click="closeEditor"
@click="$dispatch('close-modal')">Cancel</x-forms.button>
<x-forms.button type="submit" isHighlighted
@click="$dispatch('close-modal')">Save changes</x-forms.button>
</div>
</form>
</x-modal-input>
@endif
</x-application.settings-section>
</div>
</div>
</section>
</div>