This commit is contained in:
Charles 2025-11-03 08:29:52 -05:00
parent 110cef8afe
commit 788a7add77
2 changed files with 198 additions and 88 deletions

View File

@ -45,6 +45,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Enums\PaginationMode; use Filament\Tables\Enums\PaginationMode;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route; use Illuminate\Routing\Route;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@ -65,6 +66,7 @@ class ListFiles extends ListRecords
private DaemonFileRepository $fileRepository; private DaemonFileRepository $fileRepository;
public function getTitle(): string public function getTitle(): string
{ {
return trans('server/file.title'); return trans('server/file.title');
@ -631,7 +633,7 @@ class ListFiles extends ListRecords
}; };
} }
public function getUploadUrl(): string public function getUploadUrl(NodeJWTService $jwtService): string
{ {
/** @var Server $server */ /** @var Server $server */
$server = Filament::getTenant(); $server = Filament::getTenant();
@ -640,14 +642,11 @@ class ListFiles extends ListRecords
abort(403, 'You do not have permission to upload files.'); abort(403, 'You do not have permission to upload files.');
} }
$jwtService = app(NodeJWTService::class);
$user = user();
$token = $jwtService $token = $jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15)) ->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setUser($user) ->setUser(user())
->setClaims(['server_uuid' => $server->uuid]) ->setClaims(['server_uuid' => $server->uuid])
->handle($server->node, $user->id . $server->uuid); ->handle($server->node, user()->id . $server->uuid);
return sprintf( return sprintf(
'%s/upload/file?token=%s', '%s/upload/file?token=%s',
@ -664,6 +663,32 @@ class ListFiles extends ListRecords
return $server->node->upload_size * 1024 * 1024; return $server->node->upload_size * 1024 * 1024;
} }
/**
* @throws ConnectionException
* @throws FileExistsException
* @throws \Throwable
*/
public function createFolder(string $folderPath): void
{
/** @var Server $server */
$server = Filament::getTenant();
if (!user()?->can(Permission::ACTION_FILE_CREATE, $server)) {
abort(403, 'You do not have permission to create folders.');
}
try {
$this->getDaemonFileRepository()->createDirectory($folderPath, $this->path);
Activity::event('server:file.create-directory')
->property(['directory' => $this->path, 'name' => $folderPath])
->log();
} catch (FileExistsException) {
// Ignore if the folder already exists.
}
}
private function getDaemonFileRepository(): DaemonFileRepository private function getDaemonFileRepository(): DaemonFileRepository
{ {
/** @var Server $server */ /** @var Server $server */

View File

@ -1,55 +1,136 @@
<x-filament-panels::page> <x-filament-panels::page>
<div <div
x-data="{ x-data="
{
isDragging: false, isDragging: false,
dragCounter: 0, dragCounter: 0,
isUploading: false, isUploading: false,
uploadQueue: [], uploadQueue: [],
currentFileIndex: 0, currentFileIndex: 0,
totalFiles: 0, totalFiles: 0,
autoCloseTimer: null, autoCloseTimer: 1000,
handleDragEnter(e) { handleDragEnter(e) {
if (document.querySelector('.fi-modal-content')) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.dragCounter++; this.dragCounter++;
this.isDragging = true; this.isDragging = true;
}, },
handleDragLeave(e) { handleDragLeave(e) {
if (document.querySelector('.fi-modal-content')) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.dragCounter--; this.dragCounter--;
if (this.dragCounter === 0) { if (this.dragCounter === 0) this.isDragging = false;
this.isDragging = false;
}
}, },
handleDragOver(e) { handleDragOver(e) {
if (document.querySelector('.fi-modal-content')) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
}, },
async handleDrop(e) { async handleDrop(e) {
if (document.querySelector('.fi-modal-content')) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.isDragging = false; this.isDragging = false;
this.dragCounter = 0; this.dragCounter = 0;
const items = e.dataTransfer.items;
const files = e.dataTransfer.files; const files = e.dataTransfer.files;
if (files.length === 0) return;
await this.uploadFiles(files); if ((!items || items.length === 0) && (!files || files.length === 0)) return;
let filesWithPaths = [];
if (items && items.length > 0 && items[0].webkitGetAsEntry) {
filesWithPaths = await this.extractFilesFromItems(items);
}
if (files && files.length > 0 && filesWithPaths.length === 0) {
filesWithPaths = Array.from(files).map(f => ({ file: f, path: '' }));
}
if (filesWithPaths.length > 0) {
await this.uploadFilesWithFolders(filesWithPaths);
}
}, },
async uploadFiles(files) {
async extractFilesFromItems(items) {
const filesWithPaths = [];
const traversePromises = [];
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry?.();
if (entry) {
traversePromises.push(this.traverseFileTree(entry, '', filesWithPaths));
} else if (items[i].kind === 'file') {
const file = items[i].getAsFile();
if (file) {
filesWithPaths.push({
file: file,
path: '',
});
}
}
}
await Promise.all(traversePromises);
return filesWithPaths;
},
async traverseFileTree(entry, path, filesWithPaths) {
return new Promise((resolve) => {
if (entry.isFile) {
entry.file((file) => {
filesWithPaths.push({
file: file,
path: path,
});
resolve();
});
} else if (entry.isDirectory) {
const reader = entry.createReader();
const readEntries = () => {
reader.readEntries(async (entries) => {
if (entries.length === 0) {
resolve();
return;
}
const subPromises = entries.map((e) =>
this.traverseFileTree(
e,
path ? `${path}/${entry.name}` : entry.name,
filesWithPaths
)
);
await Promise.all(subPromises);
readEntries();
});
};
readEntries();
} else {
resolve();
}
});
},
async uploadFilesWithFolders(filesWithPaths) {
this.isUploading = true; this.isUploading = true;
this.uploadQueue = []; this.uploadQueue = [];
this.totalFiles = files.length; this.totalFiles = filesWithPaths.length;
this.currentFileIndex = 0; this.currentFileIndex = 0;
try { try {
const uploadSizeLimit = await $wire.getUploadSizeLimit(); const uploadSizeLimit = await $wire.getUploadSizeLimit();
for (let i = 0; i < files.length; i++) { for (const { file } of filesWithPaths) {
if (files[i].size > uploadSizeLimit) { if (file.size > uploadSizeLimit) {
new window.FilamentNotification() new window.FilamentNotification()
.title(`File ${files[i].name} exceeds the upload size limit of ${this.formatBytes(uploadSizeLimit)}`) .title(`File ${file.name} exceeds the upload size limit of ${this.formatBytes(uploadSizeLimit)}`)
.danger() .danger()
.send(); .send();
this.isUploading = false; this.isUploading = false;
@ -57,11 +138,32 @@
} }
} }
for (let i = 0; i < files.length; i++) { const folderPaths = new Set();
for (const { path } of filesWithPaths) {
if (path) {
const parts = path.split('/').filter(Boolean);
let currentPath = '';
for (const part of parts) {
currentPath += part + '/';
folderPaths.add(currentPath);
}
}
}
for (const folderPath of folderPaths) {
try {
await $wire.createFolder(folderPath.slice(0, -1));
} catch (error) {
console.warn(`Folder ${folderPath} already exists or failed to create.`);
}
}
for (const f of filesWithPaths) {
this.uploadQueue.push({ this.uploadQueue.push({
file: files[i], file: f.file,
name: files[i].name, name: f.file.name,
size: files[i].size, path: f.path,
size: f.file.size,
progress: 0, progress: 0,
speed: 0, speed: 0,
uploadedBytes: 0, uploadedBytes: 0,
@ -74,67 +176,57 @@
let activeUploads = []; let activeUploads = [];
let completedCount = 0; let completedCount = 0;
for (let i = 0; i < files.length; i++) { for (let i = 0; i < this.uploadQueue.length; i++) {
const uploadPromise = this.uploadFile(i) const uploadPromise = this.uploadFile(i)
.then(() => { .then(() => { completedCount++; this.currentFileIndex = completedCount; })
completedCount++; .catch(() => { completedCount++; this.currentFileIndex = completedCount; });
this.currentFileIndex = completedCount;
})
.catch((error) => {
completedCount++;
this.currentFileIndex = completedCount;
});
activeUploads.push(uploadPromise); activeUploads.push(uploadPromise);
if (activeUploads.length >= maxConcurrent) { if (activeUploads.length >= maxConcurrent) {
await Promise.race(activeUploads); await Promise.race(activeUploads);
activeUploads = activeUploads.filter(p => { activeUploads = activeUploads.filter(p => p.status !== 'fulfilled' && p.status !== 'rejected');
let isPending = true;
p.then(() => { isPending = false; }).catch(() => { isPending = false; });
return isPending;
});
} }
} }
await Promise.allSettled(activeUploads); await Promise.allSettled(activeUploads);
const failedUploads = this.uploadQueue.filter(f => f.status === 'error');
const failed = this.uploadQueue.filter(f => f.status === 'error');
await $wire.$refresh(); await $wire.$refresh();
if (failedUploads.length === 0) { if (failed.length === 0) {
new window.FilamentNotification() new window.FilamentNotification().title('{{ trans('server/file.actions.upload.success') }}').success().send();
.title('{{ trans('server/file.actions.upload.success') }}') } else if (failed.length === this.totalFiles) {
.success() new window.FilamentNotification().title('{{ trans('server/file.actions.upload.error_all') }}').danger().send();
.send();
} else if (failedUploads.length < this.totalFiles) {
new window.FilamentNotification()
.title(`${this.totalFiles - failedUploads.length} of ${this.totalFiles} files uploaded successfully`)
.warning()
.send();
} else { } else {
new window.FilamentNotification() new window.FilamentNotification().title('{{ trans('server/file.actions.upload.error_partial') }}').warning().send();
.title('{{ trans('server/file.actions.upload.failed') }}')
.danger()
.send();
} }
if (this.autoCloseTimer) clearTimeout(this.autoCloseTimer);
this.autoCloseTimer = setTimeout(() => {
this.isUploading = false;
this.uploadQueue = [];
},1000);
} catch (error) { } catch (error) {
new window.FilamentNotification() console.error('Upload error:', error);
.title('{{ trans('server/file.actions.upload.failed') }}') new window.FilamentNotification().title('{{ trans('server/file.actions.upload.error') }}').danger().send();
.danger() this.isUploading = false;
.send();
} finally {
this.closeUploadDialog();
} }
}, },
async uploadFile(index) { async uploadFile(index) {
const fileData = this.uploadQueue[index]; const fileData = this.uploadQueue[index];
fileData.status = 'uploading'; fileData.status = 'uploading';
try { try {
const uploadUrl = await $wire.getUploadUrl(); const uploadUrl = await $wire.getUploadUrl();
const url = new URL(uploadUrl); const url = new URL(uploadUrl);
url.searchParams.append('directory', @js($this->path)); let basePath = @js($this->path);
if (fileData.path && fileData.path.trim() !== '') {
basePath = basePath.replace(/\/+$/, '') + '/' + fileData.path.replace(/^\/+/, '');
}
url.searchParams.append('directory', basePath);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
@ -149,19 +241,18 @@
fileData.uploadedBytes = e.loaded; fileData.uploadedBytes = e.loaded;
fileData.progress = Math.round((e.loaded / e.total) * 100); fileData.progress = Math.round((e.loaded / e.total) * 100);
// Calculate upload speed const now = Date.now();
const currentTime = Date.now(); const timeDiff = (now - lastTime) / 1000;
const timeDiff = (currentTime - lastTime) / 1000;
if (timeDiff > 0.1) { if (timeDiff > 0.1) {
const bytesDiff = e.loaded - lastLoaded; const bytesDiff = e.loaded - lastLoaded;
fileData.speed = bytesDiff / timeDiff; fileData.speed = bytesDiff / timeDiff;
lastTime = currentTime; lastTime = now;
lastLoaded = e.loaded; lastLoaded = e.loaded;
} }
} }
}); });
xhr.addEventListener('load', () => { xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
fileData.status = 'complete'; fileData.status = 'complete';
fileData.progress = 100; fileData.progress = 100;
@ -171,23 +262,24 @@
fileData.error = `Upload failed (${xhr.status})`; fileData.error = `Upload failed (${xhr.status})`;
reject(new Error(fileData.error)); reject(new Error(fileData.error));
} }
}); };
xhr.addEventListener('error', () => { xhr.onerror = () => {
fileData.status = 'error'; fileData.status = 'error';
fileData.error = 'Network error'; fileData.error = 'Network error';
reject(new Error('Upload failed')); reject(new Error('Network error'));
}); };
xhr.open('POST', url.toString()); xhr.open('POST', url.toString());
xhr.send(formData); xhr.send(formData);
}); });
} catch (error) { } catch (err) {
fileData.status = 'error'; fileData.status = 'error';
fileData.error = 'Failed to get upload token'; fileData.error = 'Failed to get upload token';
throw error; throw err;
} }
}, },
formatBytes(bytes) { formatBytes(bytes) {
if (bytes === 0) return '0.00 B'; if (bytes === 0) return '0.00 B';
const k = 1024; const k = 1024;
@ -198,28 +290,20 @@
formatSpeed(bytesPerSecond) { formatSpeed(bytesPerSecond) {
return this.formatBytes(bytesPerSecond) + '/s'; return this.formatBytes(bytesPerSecond) + '/s';
}, },
closeUploadDialog() {
if (this.autoCloseTimer) {
clearTimeout(this.autoCloseTimer);
this.autoCloseTimer = null;
}
this.isUploading = false;
this.uploadQueue = [];
},
handleEscapeKey(e) { handleEscapeKey(e) {
if (e.key === 'Escape' && this.isUploading) { if (e.key === 'Escape' && this.isUploading) {
this.closeUploadDialog(); this.isUploading = false;
this.uploadQueue = [];
} }
} },
}" }"
@dragenter.window="handleDragEnter($event)" @dragenter.window="handleDragEnter($event)"
@dragleave.window="handleDragLeave($event)" @dragleave.window="handleDragLeave($event)"
@dragover.window="handleDragOver($event)" s @dragover.window="handleDragOver($event)"
@drop.window="handleDrop($event)" @drop.window="handleDrop($event)"
@keydown.window="handleEscapeKey($event)" @keydown.window="handleEscapeKey($event)"
class="relative" class="relative"
> >
<!-- Drag & Drop Overlay -->
<div <div
x-show="isDragging" x-show="isDragging"
x-cloak x-cloak
@ -260,7 +344,7 @@
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100"> <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ trans('server/file.actions.upload.header') }} - {{ trans('server/file.actions.upload.header') }} -
<span class="text-lg text-gray-600 dark:text-gray-400"> <span class="text-lg text-gray-600 dark:text-gray-400">
<span x-text="currentFileIndex"></span> Of <span x-text="totalFiles"></span> <span x-text="currentFileIndex"></span> of <span x-text="totalFiles"></span>
</span> </span>
</h3> </h3>
</div> </div>
@ -275,7 +359,8 @@
<div class="flex flex-col gap-y-1"> <div class="flex flex-col gap-y-1">
<div <div
class="text-sm font-medium leading-6 text-gray-950 dark:text-white truncate max-w-xs" class="text-sm font-medium leading-6 text-gray-950 dark:text-white truncate max-w-xs"
x-text="fileData.name"></div> x-text="(fileData.path ? fileData.path + '/' : '') + fileData.name">
</div>
<div x-show="fileData.status === 'error'" <div x-show="fileData.status === 'error'"
class="text-xs text-danger-600 dark:text-danger-400" class="text-xs text-danger-600 dark:text-danger-400"
x-text="fileData.error"></div> x-text="fileData.error"></div>
@ -288,16 +373,16 @@
<td class="px-4 py-4 sm:px-6"> <td class="px-4 py-4 sm:px-6">
<div x-show="fileData.status === 'uploading' || fileData.status === 'complete'" <div x-show="fileData.status === 'uploading' || fileData.status === 'complete'"
class="flex justify-between items-center text-sm"> class="flex justify-between items-center text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300" <span class="font-medium text-gray-700 dark:text-gray-300"
x-text="`${fileData.progress}%`"></span> x-text="`${fileData.progress}%`"></span>
<span x-show="fileData.status === 'uploading' && fileData.speed > 0" <span x-show="fileData.status === 'uploading' && fileData.speed > 0"
class="text-gray-500 dark:text-gray-400" class="text-gray-500 dark:text-gray-400"
x-text="formatSpeed(fileData.speed)"></span> x-text="formatSpeed(fileData.speed)"></span>
</div> </div>
<span x-show="fileData.status === 'pending'" <span x-show="fileData.status === 'pending'"
class="text-sm text-gray-500 dark:text-gray-400"> class="text-sm text-gray-500 dark:text-gray-400">
</span> </span>
</td> </td>
</tr> </tr>
</template> </template>