Files
seaweedfs/weed/server/filer_ui/filer.html
T
Chris LuandClaude Opus 5.5 2ec0623073 filer UI, admin: upload a folder (#12371)
* admin: place uploaded files by their path inside an uploaded folder

The multipart file name keeps only the base name, so the upload API takes
an optional relativePaths field listing each file's path under the target
directory. Paths that are empty, absolute or contain . or .. components
are rejected per file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: upload a folder from the file browser

The upload modal gets a folder picker, and a folder dropped on the listing
is walked into, so its files upload with their subfolder paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* filer UI: upload a folder

An Upload Folder button picks a whole folder, and a folder dropped on the
listing is walked into. Each file is posted to its subfolder under the
current directory, which the filer creates on the first upload into it.
Progress rows are keyed by that path, so equal names in different
subfolders no longer share a row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: reject an empty relativePaths field instead of ignoring it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: upload a large selection in batches below the multipart part limit

Go's multipart parser rejects a form of more than 1000 parts, so a folder
of about a thousand files failed as a whole. The upload modal now sends
500 files per request and sums the results; progress covers all batches,
and the button stays disabled until the last one finishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: escape file names in upload error messages

A failed upload reports the client-supplied file or folder path, and the
toast renders its message as HTML.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: list failed uploads in the modal and reload the listing when it closes

A partial upload logged its failures only to the console before the page
reloaded, and a batch that failed after earlier ones succeeded left the
listing stale. The failures now stay in the open modal, and closing it
reloads the listing once any file was uploaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: cancel the upload when its modal closes

Closing the modal, including with its Cancel button, left the batch loop
running, so the remaining batches were still written. The batch in flight
is now aborted, which also stops the loop, and the listing reloads since
the aborted request may already have stored files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: count upload progress by files when the selection has no bytes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: keep the newest upload selection when a folder drop finishes late

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: cap the upload preview at a fixed number of rows

A folder of many thousands of files rendered one preview row per file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* filer UI: upload a few files at a time

Every selected file started its own request at once, so a large folder
opened thousands of requests together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: regenerate the gzipped admin.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: reload the listing after a batch fails without a result

A batch that fails mid-request may still have stored some of its files,
so closing the modal reloads the listing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: stop reading a folder drop once a newer selection replaces it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: skip the read error of a folder drop a newer selection replaced

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* filer UI: share the four-upload limit across selections

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: regenerate the gzipped admin.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: set up the file browser handlers once

admin.js already runs setupFileManagerEventHandlers on DOMContentLoaded,
so the file browser page registered every listener twice and each drop
was read twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:35:32 -08:00

446 lines
15 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<title>SeaweedFS Filer {{ .Version }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/seaweedfsstatic/bootstrap/3.3.1/css/bootstrap.min.css">
<style>
body {
padding-bottom: 128px;
}
#drop-area {
border: 1px transparent;
margin-top: 5px;
}
#drop-area.highlight {
border-color: purple;
border: 2px dashed #ccc;
}
.button {
display: inline-block;
padding: 2px;
background: #ccc;
cursor: pointer;
border-radius: 2px;
border: 1px solid #ccc;
float: right;
margin-left: 2px;
margin-bottom: 0;
}
label {
font-weight: normal;
}
.button:hover {
background: #ddd;
}
#fileElem, #dirElem {
display: none;
}
td, th {
vertical-align: bottom;
}
.table-hover > tbody > tr:hover > * > div.operations {
display: block;
}
.table > tbody > tr {
height: 39px;
}
div.operations {
display: none;
}
.footer {
position: absolute;
bottom: 0px;
right: 5%;
min-width: 25%;
border-left: 1px solid #ccc;
border-right: 1px solid #ccc;
}
.add-files {
font-size: 46px;
text-align: center;
border: 1px dashed #999;
padding-bottom: 9px;
margin: 0 2px;
}
</style>
</head>
<body>
<div class="container">
<div class="page-header">
<h1>
<a href="https://github.com/seaweedfs/seaweedfs"><img src="/seaweedfsstatic/seaweed50x50.png"></img></a>
SeaweedFS Filer <small>{{ .Version }}</small>
</h1>
</div>
<div class="row">
<div>
<div class="btn-group btn-group-sm pull-right" role="group" style="margin-top:3px;">
<label class="btn btn-default" onclick="handleCreateDir()">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> New Folder
</label>
<label class="btn btn-default" for="fileElem">
<span class="glyphicon glyphicon-cloud-upload" aria-hidden="true"></span> Upload
</label>
<label class="btn btn-default" for="dirElem">
<span class="glyphicon glyphicon-folder-close" aria-hidden="true"></span> Upload Folder
</label>
</div>
<ol class="breadcrumb">
{{ range $entry := .Breadcrumbs }}
<li><a href="{{ printpath $entry.Link }}">
{{ $entry.Name }}
</a></li>
{{ end }}
</ol>
</div>
</div>
<div class="row" id="drop-area">
<form class="upload-form">
<input type="file" id="fileElem" multiple onchange="handleFiles(this.files)">
<input type="file" id="dirElem" webkitdirectory multiple onchange="handleFiles(this.files)">
{{ if .EmptyFolder }}
<div class="row add-files">
+
</div>
{{ else }}
<table width="100%" class="table table-hover">
{{ $path := .Path }}
{{ $showDirDel := .ShowDirectoryDelete }}
{{ range $entry_index, $entry := .Entries }}
<tr>
<td>
{{ if $entry.IsDirectory }}
<span class="glyphicon glyphicon-folder-open" aria-hidden="true"></span>&nbsp;
<a href="{{ printpath $path "/" $entry.Name "/"}}" >
{{ $entry.Name }}
</a>
{{ else }}
<a href="{{ printpath $path "/" $entry.Name }}" >
{{ $entry.Name }}
</a>
{{ end }}
</td>
<td align="right" nowrap>
{{ if not $entry.IsDirectory }}
{{ $entry.Mime }}&nbsp;
{{ end }}
</td>
<td align="right" nowrap>
{{ if not $entry.IsDirectory }}
{{ $entry.Size | humanizeBytes }}&nbsp;
{{ end }}
</td>
<td align="right" nowrap>
{{ $entry.Timestamp.Format "2006-01-02 15:04" }}
</td>
<td style="width:75px">
<div class="btn-group btn-group-xs pull-right operations" role="group">
<label class="btn" onclick="handleRename('{{ $entry.Name }}', '{{ printpath $path "/" }}')">
<span class="glyphicon glyphicon-edit" aria-hidden="true"></span>
</label>
{{ if and $entry.IsDirectory $showDirDel }}
<label class="btn" onclick="handleDelete('{{ printpath $path "/" $entry.Name "/" }}')">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
</label>
{{ end }}
{{ if not $entry.IsDirectory }}
<label class="btn" onclick="handleDelete('{{ printpath $path "/" $entry.Name }}')">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
</label>
{{ end }}
</div>
</td>
</tr>
{{ end }}
</table>
{{ end }}
</form>
</div>
{{ if .ShouldDisplayLoadMore }}
<div class="row">
<a href="{{ printpath .Path }}?limit={{ .Limit }}&lastFileName={{ queryEscape .LastFileName }}" >
Load more
</a>
</div>
{{ end }}
<br/>
<br/>
<div id="progress-area" class="footer" style="display: none;">
</div>
</div>
</body>
<script type="text/javascript">
// ************************ Drag and drop ***************** //
let dropArea = document.getElementById("drop-area");
let progressArea = document.getElementById("progress-area");
// Prevent default drag behaviors
;['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, preventDefaults, false);
document.body.addEventListener(eventName, preventDefaults, false);
});
// Highlight drop area when item is dragged over it
;['dragenter', 'dragover'].forEach(eventName => {
dropArea.addEventListener(eventName, highlight, false);
});
;['dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, unhighlight, false);
});
// Handle dropped files
dropArea.addEventListener('drop', handleDrop, false);
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
function highlight(e) {
dropArea.classList.add('highlight');
}
function unhighlight(e) {
dropArea.classList.remove('highlight');
}
function handleDrop(e) {
collectDroppedFiles(e.dataTransfer).then(uploadItems, function (err) {
alert('Failed to read the dropped files: ' + err);
});
}
// Lists the dropped files, walking into dropped folders. The entries must be
// taken before the first await, while the drop event still exposes them.
async function collectDroppedFiles(dataTransfer) {
var entries = [...(dataTransfer.items || [])]
.map(item => item.webkitGetAsEntry && item.webkitGetAsEntry())
.filter(Boolean);
if (entries.length === 0) {
return [...dataTransfer.files].map(file => ({'file': file, 'path': file.name}));
}
var collected = [];
async function walk(entry) {
if (entry.isFile) {
var file = await new Promise((resolve, reject) => entry.file(resolve, reject));
collected.push({'file': file, 'path': entry.fullPath.replace(/^\/+/, '')});
return;
}
var reader = entry.createReader();
for (;;) {
var batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
if (batch.length === 0) {
return;
}
for (let child of batch) {
await walk(child);
}
}
}
for (let entry of entries) {
await walk(entry);
}
return collected;
}
function reloadPage() {
window.location.reload(true);
}
var uploadList = {};
function handleFiles(files) {
uploadItems([...files].map(file => ({'file': file, 'path': file.webkitRelativePath || file.name})));
}
// A large folder uploads a few files at a time instead of opening a request per file,
// and a selection made meanwhile joins the same queue.
var uploadConcurrency = 4;
var uploadQueue = [];
var activeUploads = 0;
function uploadItems(items) {
if (items.length === 0) {
return;
}
items.forEach(startUpload);
renderProgress();
uploadQueue.push(...items);
uploadNext();
}
function uploadNext() {
while (activeUploads < uploadConcurrency && uploadQueue.length > 0) {
activeUploads++;
uploadFile(uploadQueue.shift(), function () {
activeUploads--;
uploadNext();
});
}
}
function startUpload(item, i) {
uploadList[item.path] = {'name': item.path, 'percent': 0, 'finish': false};
}
function renderProgress() {
var values = Object.values(uploadList);
var html = '<table class="table">\n<tr><th>Uploading</th><\/tr>\n';
for (let i of values) {
var progressBarClass = 'progress-bar-striped active';
if (i.percent >= 100) {
progressBarClass = 'progress-bar-success';
}
html += '<tr>\n<td>\n';
html += '<div class="progress" style="margin-bottom: 2px;">\n';
html += '<div class="progress-bar ' + progressBarClass + '" role="progressbar" aria-valuenow="' + '100" aria-valuemin="0" aria-valuemax="100" style="width:' + i.percent + '%;">';
html += '<span style="margin-right: 10px;">' + escapeHtml(i.name) + '</span>' + i.percent + '%<\/div>';
html += '<\/div>\n<\/td>\n<\/tr>\n';
}
html += '<\/table>\n';
progressArea.innerHTML = html;
if (values.length > 0) {
progressArea.attributes.style.value = '';
}
}
function reportProgress(file, percent) {
var item = uploadList[file]
item.percent = percent;
renderProgress();
}
function finishUpload(file) {
uploadList[file]['finish'] = true;
renderProgress();
var allFinish = true;
for (let i of Object.values(uploadList)) {
if (!i.finish) {
allFinish = false;
break;
}
}
if (allFinish) {
console.log('All Finish');
reloadPage();
}
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// A file from a folder goes to its subfolder under the current directory,
// which the filer creates on the first upload into it.
function uploadUrl(path) {
var dirs = path.split('/').slice(0, -1);
if (dirs.length === 0) {
return window.location.href;
}
var baseUrl = window.location.origin + window.location.pathname;
if (!baseUrl.endsWith('/')) {
baseUrl += '/';
}
return baseUrl + dirs.map(encodeURIComponent).join('/') + '/' + window.location.search;
}
function uploadFile(item, done) {
var file = item.file;
var url = uploadUrl(item.path);
var xhr = new XMLHttpRequest();
var fileName = item.path;
xhr.onreadystatechange = function() {
if (xhr.readyState == XMLHttpRequest.DONE) {
finishUpload(fileName);
done();
}
}
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
var percent = Math.ceil((e.loaded / e.total) * 100);
reportProgress(fileName, percent)
}
});
xhr.upload.addEventListener('loadend', function(e) {
finishUpload(fileName);
});
var formData = new FormData();
xhr.open('POST', url, true);
formData.append('file', file);
xhr.send(formData);
}
function handleCreateDir() {
var dirName = prompt('Folder Name:', '');
dirName = dirName.trim();
if (dirName == null || dirName == '') {
return;
}
var baseUrl = window.location.origin + window.location.pathname;
if (!baseUrl.endsWith('/')) {
baseUrl += '/';
}
var url = baseUrl + encodeURIComponent(dirName);
if (!url.endsWith('/')) {
url += '/';
}
url += window.location.search;
var xhr = new XMLHttpRequest();
xhr.open('POST', url, false);
xhr.setRequestHeader('Content-Type', '');
xhr.send();
reloadPage();
}
function handleRename(originName, basePath) {
var newName = prompt('New Name:', originName);
if (newName == null || newName == '') {
return;
}
var url = basePath + encodeURIComponent(newName);
var originPath = basePath + originName;
url += '?mv.from=' + encodeURIComponent(originPath);
var xhr = new XMLHttpRequest();
xhr.open('POST', url, false);
xhr.setRequestHeader('Content-Type', '');
xhr.send();
reloadPage();
}
function handleDelete(path) {
if (!confirm('Are you sure to delete ' + path + '?')) {
return;
}
var url = path;
if (url.endsWith('/')) {
url += '?recursive=true';
}
var xhr = new XMLHttpRequest();
xhr.open('DELETE', url, false);
xhr.send();
reloadPage();
}
</script>
</html>