Admin UI: Add policies (#6968)
* add policies to UI, accessing filer directly * view, edit policies * add back buttons for "users" page * remove unused * fix ui dark mode when modal is closed * bucket view details button * fix browser buttons * filer action button works * clean up masters page * fix volume servers action buttons * fix collections page action button * fix properties page * more obvious * fix directory creation file mode * Update file_browser_handlers.go * directory permission
This commit is contained in:
@@ -164,22 +164,18 @@ templ ClusterCollections(data dash.ClusterCollectionsData) {
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
title="Delete"
|
||||
data-collection-name={collection.Name}
|
||||
onclick="confirmDeleteCollection(this)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
title="View Details"
|
||||
data-action="view-details"
|
||||
data-name={collection.Name}
|
||||
data-datacenter={collection.DataCenter}
|
||||
data-volume-count={fmt.Sprintf("%d", collection.VolumeCount)}
|
||||
data-file-count={fmt.Sprintf("%d", collection.FileCount)}
|
||||
data-total-size={fmt.Sprintf("%d", collection.TotalSize)}
|
||||
data-disk-types={formatDiskTypes(collection.DiskTypes)}>
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -209,30 +205,169 @@ templ ClusterCollections(data dash.ClusterCollectionsData) {
|
||||
|
||||
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteCollectionModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title text-danger">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>Delete Collection
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the collection <strong id="deleteCollectionName"></strong>?</p>
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-warning me-2"></i>
|
||||
This action cannot be undone. All volumes in this collection will be affected.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmDeleteCollection">Delete Collection</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- JavaScript for cluster collections functionality -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Handle collection action buttons
|
||||
document.addEventListener('click', function(e) {
|
||||
const button = e.target.closest('[data-action]');
|
||||
if (!button) return;
|
||||
|
||||
const action = button.getAttribute('data-action');
|
||||
|
||||
switch(action) {
|
||||
case 'view-details':
|
||||
const collectionData = {
|
||||
name: button.getAttribute('data-name'),
|
||||
datacenter: button.getAttribute('data-datacenter'),
|
||||
volumeCount: parseInt(button.getAttribute('data-volume-count')),
|
||||
fileCount: parseInt(button.getAttribute('data-file-count')),
|
||||
totalSize: parseInt(button.getAttribute('data-total-size')),
|
||||
diskTypes: button.getAttribute('data-disk-types')
|
||||
};
|
||||
showCollectionDetails(collectionData);
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function showCollectionDetails(collection) {
|
||||
const modalHtml = '<div class="modal fade" id="collectionDetailsModal" tabindex="-1">' +
|
||||
'<div class="modal-dialog modal-lg">' +
|
||||
'<div class="modal-content">' +
|
||||
'<div class="modal-header">' +
|
||||
'<h5 class="modal-title"><i class="fas fa-layer-group me-2"></i>Collection Details: ' + collection.name + '</h5>' +
|
||||
'<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' +
|
||||
'</div>' +
|
||||
'<div class="modal-body">' +
|
||||
'<div class="row">' +
|
||||
'<div class="col-md-6">' +
|
||||
'<h6 class="text-primary"><i class="fas fa-info-circle me-1"></i>Basic Information</h6>' +
|
||||
'<table class="table table-sm">' +
|
||||
'<tr><td><strong>Collection Name:</strong></td><td><code>' + collection.name + '</code></td></tr>' +
|
||||
'<tr><td><strong>Data Center:</strong></td><td>' +
|
||||
(collection.datacenter ? '<span class="badge bg-light text-dark">' + collection.datacenter + '</span>' : '<span class="text-muted">N/A</span>') +
|
||||
'</td></tr>' +
|
||||
'<tr><td><strong>Disk Types:</strong></td><td>' +
|
||||
(collection.diskTypes ? collection.diskTypes.split(', ').map(type =>
|
||||
'<span class="badge bg-' + getDiskTypeBadgeColor(type) + ' me-1">' + type + '</span>'
|
||||
).join('') : '<span class="text-muted">Unknown</span>') +
|
||||
'</td></tr>' +
|
||||
'</table>' +
|
||||
'</div>' +
|
||||
'<div class="col-md-6">' +
|
||||
'<h6 class="text-primary"><i class="fas fa-chart-bar me-1"></i>Storage Statistics</h6>' +
|
||||
'<table class="table table-sm">' +
|
||||
'<tr><td><strong>Total Volumes:</strong></td><td>' +
|
||||
'<div class="d-flex align-items-center">' +
|
||||
'<i class="fas fa-database me-2 text-muted"></i>' +
|
||||
'<span>' + collection.volumeCount.toLocaleString() + '</span>' +
|
||||
'</div>' +
|
||||
'</td></tr>' +
|
||||
'<tr><td><strong>Total Files:</strong></td><td>' +
|
||||
'<div class="d-flex align-items-center">' +
|
||||
'<i class="fas fa-file me-2 text-muted"></i>' +
|
||||
'<span>' + collection.fileCount.toLocaleString() + '</span>' +
|
||||
'</div>' +
|
||||
'</td></tr>' +
|
||||
'<tr><td><strong>Total Size:</strong></td><td>' +
|
||||
'<div class="d-flex align-items-center">' +
|
||||
'<i class="fas fa-hdd me-2 text-muted"></i>' +
|
||||
'<span>' + formatBytes(collection.totalSize) + '</span>' +
|
||||
'</div>' +
|
||||
'</td></tr>' +
|
||||
'</table>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="row mt-3">' +
|
||||
'<div class="col-12">' +
|
||||
'<h6 class="text-primary"><i class="fas fa-link me-1"></i>Quick Actions</h6>' +
|
||||
'<div class="d-grid gap-2 d-md-flex">' +
|
||||
'<a href="/cluster/volumes?collection=' + encodeURIComponent(collection.name) + '" class="btn btn-outline-primary">' +
|
||||
'<i class="fas fa-database me-1"></i>View Volumes' +
|
||||
'</a>' +
|
||||
'<a href="/files?collection=' + encodeURIComponent(collection.name) + '" class="btn btn-outline-info">' +
|
||||
'<i class="fas fa-folder me-1"></i>Browse Files' +
|
||||
'</a>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Remove existing modal if present
|
||||
const existingModal = document.getElementById('collectionDetailsModal');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
}
|
||||
|
||||
// Add modal to body and show
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
const modal = new bootstrap.Modal(document.getElementById('collectionDetailsModal'));
|
||||
modal.show();
|
||||
|
||||
// Remove modal when hidden
|
||||
document.getElementById('collectionDetailsModal').addEventListener('hidden.bs.modal', function() {
|
||||
this.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function getDiskTypeBadgeColor(diskType) {
|
||||
switch(diskType.toLowerCase()) {
|
||||
case 'ssd':
|
||||
return 'primary';
|
||||
case 'hdd':
|
||||
case '':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function exportCollections() {
|
||||
// Simple CSV export of collections list
|
||||
const rows = Array.from(document.querySelectorAll('#collectionsTable tbody tr')).map(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
if (cells.length > 1) {
|
||||
return {
|
||||
name: cells[0].textContent.trim(),
|
||||
volumes: cells[1].textContent.trim(),
|
||||
files: cells[2].textContent.trim(),
|
||||
size: cells[3].textContent.trim(),
|
||||
diskTypes: cells[4].textContent.trim()
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter(row => row !== null);
|
||||
|
||||
const csvContent = "data:text/csv;charset=utf-8," +
|
||||
"Collection Name,Volumes,Files,Size,Disk Types\n" +
|
||||
rows.map(r => '"' + r.name + '","' + r.volumes + '","' + r.files + '","' + r.size + '","' + r.diskTypes + '"').join("\n");
|
||||
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", encodedUri);
|
||||
link.setAttribute("download", "collections.csv");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
</script>
|
||||
}
|
||||
|
||||
func getDiskTypeColor(diskType string) string {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -121,6 +121,62 @@ templ ClusterFilers(data dash.ClusterFilersData) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript for cluster filers functionality -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Handle filer action buttons
|
||||
document.addEventListener('click', function(e) {
|
||||
const button = e.target.closest('[data-action]');
|
||||
if (!button) return;
|
||||
|
||||
const action = button.getAttribute('data-action');
|
||||
const address = button.getAttribute('data-address');
|
||||
|
||||
if (!address) return;
|
||||
|
||||
switch(action) {
|
||||
case 'open-filer':
|
||||
openFilerBrowser(address);
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function openFilerBrowser(address) {
|
||||
// Open file browser for specific filer
|
||||
window.open('/files?filer=' + encodeURIComponent(address), '_blank');
|
||||
}
|
||||
|
||||
function exportFilers() {
|
||||
// Simple CSV export of filers list
|
||||
const rows = Array.from(document.querySelectorAll('#filersTable tbody tr')).map(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
if (cells.length > 1) {
|
||||
return {
|
||||
address: cells[0].textContent.trim(),
|
||||
version: cells[1].textContent.trim(),
|
||||
datacenter: cells[2].textContent.trim(),
|
||||
rack: cells[3].textContent.trim(),
|
||||
created: cells[4].textContent.trim()
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter(row => row !== null);
|
||||
|
||||
const csvContent = "data:text/csv;charset=utf-8," +
|
||||
"Address,Version,Data Center,Rack,Created At\n" +
|
||||
rows.map(r => '"' + r.address + '","' + r.version + '","' + r.datacenter + '","' + r.rack + '","' + r.created + '"').join("\n");
|
||||
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", encodedUri);
|
||||
link.setAttribute("download", "filers.csv");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
</script>
|
||||
}
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ func ClusterFilers(data dash.ClusterFilersData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</small></div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</small></div></div></div><!-- JavaScript for cluster filers functionality --><script>\n\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t// Handle filer action buttons\n\t\tdocument.addEventListener('click', function(e) {\n\t\t\tconst button = e.target.closest('[data-action]');\n\t\t\tif (!button) return;\n\t\t\t\n\t\t\tconst action = button.getAttribute('data-action');\n\t\t\tconst address = button.getAttribute('data-address');\n\t\t\t\n\t\t\tif (!address) return;\n\t\t\t\n\t\t\tswitch(action) {\n\t\t\t\tcase 'open-filer':\n\t\t\t\t\topenFilerBrowser(address);\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t});\n\t});\n\t\n\tfunction openFilerBrowser(address) {\n\t\t// Open file browser for specific filer\n\t\twindow.open('/files?filer=' + encodeURIComponent(address), '_blank');\n\t}\n\t\n\tfunction exportFilers() {\n\t\t// Simple CSV export of filers list\n\t\tconst rows = Array.from(document.querySelectorAll('#filersTable tbody tr')).map(row => {\n\t\t\tconst cells = row.querySelectorAll('td');\n\t\t\tif (cells.length > 1) {\n\t\t\t\treturn {\n\t\t\t\t\taddress: cells[0].textContent.trim(),\n\t\t\t\t\tversion: cells[1].textContent.trim(),\n\t\t\t\t\tdatacenter: cells[2].textContent.trim(),\n\t\t\t\t\track: cells[3].textContent.trim(),\n\t\t\t\t\tcreated: cells[4].textContent.trim()\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn null;\n\t\t}).filter(row => row !== null);\n\t\t\n\t\tconst csvContent = \"data:text/csv;charset=utf-8,\" + \n\t\t\t\"Address,Version,Data Center,Rack,Created At\\n\" +\n\t\t\trows.map(r => '\"' + r.address + '\",\"' + r.version + '\",\"' + r.datacenter + '\",\"' + r.rack + '\",\"' + r.created + '\"').join(\"\\n\");\n\t\t\n\t\tconst encodedUri = encodeURI(csvContent);\n\t\tconst link = document.createElement(\"a\");\n\t\tlink.setAttribute(\"href\", encodedUri);\n\t\tlink.setAttribute(\"download\", \"filers.csv\");\n\t\tdocument.body.appendChild(link);\n\t\tlink.click();\n\t\tdocument.body.removeChild(link);\n\t}\n\t</script>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -136,14 +136,15 @@ templ ClusterMasters(data dash.ClusterMastersData) {
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" title="Manage">
|
||||
<i class="fas fa-cog"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
title="View Details"
|
||||
data-action="view-details"
|
||||
data-address={master.Address}
|
||||
data-leader={fmt.Sprintf("%t", master.IsLeader)}
|
||||
data-suffrage={master.Suffrage}>
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -170,6 +171,112 @@ templ ClusterMasters(data dash.ClusterMastersData) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript for cluster masters functionality -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Handle master action buttons
|
||||
document.addEventListener('click', function(e) {
|
||||
const button = e.target.closest('[data-action]');
|
||||
if (!button) return;
|
||||
|
||||
const action = button.getAttribute('data-action');
|
||||
const address = button.getAttribute('data-address');
|
||||
|
||||
if (!address) return;
|
||||
|
||||
switch(action) {
|
||||
case 'view-details':
|
||||
const isLeader = button.getAttribute('data-leader') === 'true';
|
||||
const suffrage = button.getAttribute('data-suffrage');
|
||||
showMasterDetails(address, isLeader, suffrage);
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function showMasterDetails(address, isLeader, suffrage) {
|
||||
const modalHtml = '<div class="modal fade" id="masterDetailsModal" tabindex="-1">' +
|
||||
'<div class="modal-dialog modal-lg">' +
|
||||
'<div class="modal-content">' +
|
||||
'<div class="modal-header">' +
|
||||
'<h5 class="modal-title"><i class="fas fa-crown me-2"></i>Master Details: ' + address + '</h5>' +
|
||||
'<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' +
|
||||
'</div>' +
|
||||
'<div class="modal-body">' +
|
||||
'<div class="row">' +
|
||||
'<div class="col-md-6">' +
|
||||
'<h6 class="text-primary"><i class="fas fa-info-circle me-1"></i>Basic Information</h6>' +
|
||||
'<table class="table table-sm">' +
|
||||
'<tr><td><strong>Address:</strong></td><td>' + address + '</td></tr>' +
|
||||
'<tr><td><strong>Role:</strong></td><td>' +
|
||||
(isLeader ? '<span class="badge bg-warning text-dark"><i class="fas fa-star me-1"></i>Leader</span>' :
|
||||
'<span class="badge bg-secondary">Follower</span>') + '</td></tr>' +
|
||||
'<tr><td><strong>Suffrage:</strong></td><td>' + (suffrage || 'N/A') + '</td></tr>' +
|
||||
'<tr><td><strong>Status:</strong></td><td><span class="badge bg-success">Active</span></td></tr>' +
|
||||
'</table>' +
|
||||
'</div>' +
|
||||
'<div class="col-md-6">' +
|
||||
'<h6 class="text-primary"><i class="fas fa-link me-1"></i>Quick Actions</h6>' +
|
||||
'<div class="d-grid gap-2">' +
|
||||
'<a href="http://' + address + '" target="_blank" class="btn btn-outline-primary">' +
|
||||
'<i class="fas fa-external-link-alt me-1"></i>Open Master UI' +
|
||||
'</a>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Remove existing modal if present
|
||||
const existingModal = document.getElementById('masterDetailsModal');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
}
|
||||
|
||||
// Add modal to body and show
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
const modal = new bootstrap.Modal(document.getElementById('masterDetailsModal'));
|
||||
modal.show();
|
||||
|
||||
// Remove modal when hidden
|
||||
document.getElementById('masterDetailsModal').addEventListener('hidden.bs.modal', function() {
|
||||
this.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function exportMasters() {
|
||||
// Simple CSV export of masters list
|
||||
const rows = Array.from(document.querySelectorAll('#mastersTable tbody tr')).map(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
if (cells.length > 1) {
|
||||
return {
|
||||
address: cells[0].textContent.trim(),
|
||||
role: cells[1].textContent.trim(),
|
||||
suffrage: cells[2].textContent.trim()
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter(row => row !== null);
|
||||
|
||||
const csvContent = "data:text/csv;charset=utf-8," +
|
||||
"Address,Role,Suffrage\n" +
|
||||
rows.map(r => '"' + r.address + '","' + r.role + '","' + r.suffrage + '"').join("\n");
|
||||
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", encodedUri);
|
||||
link.setAttribute("download", "masters.csv");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
</script>
|
||||
}
|
||||
|
||||
|
||||
@@ -154,35 +154,74 @@ func ClusterMasters(data dash.ClusterMastersData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td><td><div class=\"btn-group btn-group-sm\"><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Manage\"><i class=\"fas fa-cog\"></i></button></div></td></tr>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td><td><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\" data-action=\"view-details\" data-address=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(master.Address)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 143, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" data-leader=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", master.IsLeader))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 144, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-suffrage=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(master.Suffrage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 145, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"><i class=\"fas fa-eye\"></i></button></td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</tbody></table></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</tbody></table></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"text-center py-5\"><i class=\"fas fa-crown fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Masters Found</h5><p class=\"text-muted\">No master servers are currently available in the cluster.</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"text-center py-5\"><i class=\"fas fa-crown fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Masters Found</h5><p class=\"text-muted\">No master servers are currently available in the cluster.</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 168, Col: 67}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 169, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</small></div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</small></div></div></div><!-- JavaScript for cluster masters functionality --><script>\n\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t// Handle master action buttons\n\t\tdocument.addEventListener('click', function(e) {\n\t\t\tconst button = e.target.closest('[data-action]');\n\t\t\tif (!button) return;\n\t\t\t\n\t\t\tconst action = button.getAttribute('data-action');\n\t\t\tconst address = button.getAttribute('data-address');\n\t\t\t\n\t\t\tif (!address) return;\n\t\t\t\n\t\t\tswitch(action) {\n\t\t\t\tcase 'view-details':\n\t\t\t\t\tconst isLeader = button.getAttribute('data-leader') === 'true';\n\t\t\t\t\tconst suffrage = button.getAttribute('data-suffrage');\n\t\t\t\t\tshowMasterDetails(address, isLeader, suffrage);\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t});\n\t});\n\t\n\tfunction showMasterDetails(address, isLeader, suffrage) {\n\t\tconst modalHtml = '<div class=\"modal fade\" id=\"masterDetailsModal\" tabindex=\"-1\">' +\n\t\t\t'<div class=\"modal-dialog modal-lg\">' +\n\t\t\t'<div class=\"modal-content\">' +\n\t\t\t'<div class=\"modal-header\">' +\n\t\t\t'<h5 class=\"modal-title\"><i class=\"fas fa-crown me-2\"></i>Master Details: ' + address + '</h5>' +\n\t\t\t'<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"modal-body\">' +\n\t\t\t'<div class=\"row\">' +\n\t\t\t'<div class=\"col-md-6\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-info-circle me-1\"></i>Basic Information</h6>' +\n\t\t\t'<table class=\"table table-sm\">' +\n\t\t\t'<tr><td><strong>Address:</strong></td><td>' + address + '</td></tr>' +\n\t\t\t'<tr><td><strong>Role:</strong></td><td>' + \n\t\t\t(isLeader ? '<span class=\"badge bg-warning text-dark\"><i class=\"fas fa-star me-1\"></i>Leader</span>' : \n\t\t\t'<span class=\"badge bg-secondary\">Follower</span>') + '</td></tr>' +\n\t\t\t'<tr><td><strong>Suffrage:</strong></td><td>' + (suffrage || 'N/A') + '</td></tr>' +\n\t\t\t'<tr><td><strong>Status:</strong></td><td><span class=\"badge bg-success\">Active</span></td></tr>' +\n\t\t\t'</table>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"col-md-6\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-link me-1\"></i>Quick Actions</h6>' +\n\t\t\t'<div class=\"d-grid gap-2\">' +\n\t\t\t'<a href=\"http://' + address + '\" target=\"_blank\" class=\"btn btn-outline-primary\">' +\n\t\t\t'<i class=\"fas fa-external-link-alt me-1\"></i>Open Master UI' +\n\t\t\t'</a>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"modal-footer\">' +\n\t\t\t'<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>';\n\t\t\n\t\t// Remove existing modal if present\n\t\tconst existingModal = document.getElementById('masterDetailsModal');\n\t\tif (existingModal) {\n\t\t\texistingModal.remove();\n\t\t}\n\t\t\n\t\t// Add modal to body and show\n\t\tdocument.body.insertAdjacentHTML('beforeend', modalHtml);\n\t\tconst modal = new bootstrap.Modal(document.getElementById('masterDetailsModal'));\n\t\tmodal.show();\n\t\t\n\t\t// Remove modal when hidden\n\t\tdocument.getElementById('masterDetailsModal').addEventListener('hidden.bs.modal', function() {\n\t\t\tthis.remove();\n\t\t});\n\t}\n\t\n\tfunction exportMasters() {\n\t\t// Simple CSV export of masters list\n\t\tconst rows = Array.from(document.querySelectorAll('#mastersTable tbody tr')).map(row => {\n\t\t\tconst cells = row.querySelectorAll('td');\n\t\t\tif (cells.length > 1) {\n\t\t\t\treturn {\n\t\t\t\t\taddress: cells[0].textContent.trim(),\n\t\t\t\t\trole: cells[1].textContent.trim(),\n\t\t\t\t\tsuffrage: cells[2].textContent.trim()\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn null;\n\t\t}).filter(row => row !== null);\n\t\t\n\t\tconst csvContent = \"data:text/csv;charset=utf-8,\" + \n\t\t\t\"Address,Role,Suffrage\\n\" +\n\t\t\trows.map(r => '\"' + r.address + '\",\"' + r.role + '\",\"' + r.suffrage + '\"').join(\"\\n\");\n\t\t\n\t\tconst encodedUri = encodeURI(csvContent);\n\t\tconst link = document.createElement(\"a\");\n\t\tlink.setAttribute(\"href\", encodedUri);\n\t\tlink.setAttribute(\"download\", \"masters.csv\");\n\t\tdocument.body.appendChild(link);\n\t\tlink.click();\n\t\tdocument.body.removeChild(link);\n\t}\n\t</script>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -148,16 +148,22 @@ templ ClusterVolumeServers(data dash.ClusterVolumeServersData) {
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
title="Manage">
|
||||
<i class="fas fa-cog"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
title="View Details"
|
||||
data-action="view-details"
|
||||
data-id={host.ID}
|
||||
data-address={host.Address}
|
||||
data-public-url={host.PublicURL}
|
||||
data-datacenter={host.DataCenter}
|
||||
data-rack={host.Rack}
|
||||
data-volumes={fmt.Sprintf("%d", host.Volumes)}
|
||||
data-max-volumes={fmt.Sprintf("%d", host.MaxVolumes)}
|
||||
data-disk-usage={fmt.Sprintf("%d", host.DiskUsage)}
|
||||
data-disk-capacity={fmt.Sprintf("%d", host.DiskCapacity)}
|
||||
data-last-heartbeat={host.LastHeartbeat.Format("2006-01-02 15:04:05")}>
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -184,6 +190,161 @@ templ ClusterVolumeServers(data dash.ClusterVolumeServersData) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript for cluster volume servers functionality -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Handle volume server action buttons
|
||||
document.addEventListener('click', function(e) {
|
||||
const button = e.target.closest('[data-action]');
|
||||
if (!button) return;
|
||||
|
||||
const action = button.getAttribute('data-action');
|
||||
|
||||
switch(action) {
|
||||
case 'view-details':
|
||||
const serverData = {
|
||||
id: button.getAttribute('data-id'),
|
||||
address: button.getAttribute('data-address'),
|
||||
publicUrl: button.getAttribute('data-public-url'),
|
||||
datacenter: button.getAttribute('data-datacenter'),
|
||||
rack: button.getAttribute('data-rack'),
|
||||
volumes: parseInt(button.getAttribute('data-volumes')),
|
||||
maxVolumes: parseInt(button.getAttribute('data-max-volumes')),
|
||||
diskUsage: parseInt(button.getAttribute('data-disk-usage')),
|
||||
diskCapacity: parseInt(button.getAttribute('data-disk-capacity')),
|
||||
lastHeartbeat: button.getAttribute('data-last-heartbeat')
|
||||
};
|
||||
showVolumeServerDetails(serverData);
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function showVolumeServerDetails(server) {
|
||||
const volumePercent = server.maxVolumes > 0 ? Math.round((server.volumes / server.maxVolumes) * 100) : 0;
|
||||
const diskPercent = server.diskCapacity > 0 ? Math.round((server.diskUsage / server.diskCapacity) * 100) : 0;
|
||||
|
||||
const modalHtml = '<div class="modal fade" id="volumeServerDetailsModal" tabindex="-1">' +
|
||||
'<div class="modal-dialog modal-lg">' +
|
||||
'<div class="modal-content">' +
|
||||
'<div class="modal-header">' +
|
||||
'<h5 class="modal-title"><i class="fas fa-server me-2"></i>Volume Server Details: ' + server.address + '</h5>' +
|
||||
'<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' +
|
||||
'</div>' +
|
||||
'<div class="modal-body">' +
|
||||
'<div class="row">' +
|
||||
'<div class="col-md-6">' +
|
||||
'<h6 class="text-primary"><i class="fas fa-info-circle me-1"></i>Basic Information</h6>' +
|
||||
'<table class="table table-sm">' +
|
||||
'<tr><td><strong>Server ID:</strong></td><td><code>' + server.id + '</code></td></tr>' +
|
||||
'<tr><td><strong>Address:</strong></td><td>' + server.address + '</td></tr>' +
|
||||
'<tr><td><strong>Public URL:</strong></td><td>' + server.publicUrl + '</td></tr>' +
|
||||
'<tr><td><strong>Data Center:</strong></td><td><span class="badge bg-light text-dark">' + server.datacenter + '</span></td></tr>' +
|
||||
'<tr><td><strong>Rack:</strong></td><td><span class="badge bg-light text-dark">' + server.rack + '</span></td></tr>' +
|
||||
'<tr><td><strong>Last Heartbeat:</strong></td><td>' + server.lastHeartbeat + '</td></tr>' +
|
||||
'</table>' +
|
||||
'</div>' +
|
||||
'<div class="col-md-6">' +
|
||||
'<h6 class="text-primary"><i class="fas fa-chart-bar me-1"></i>Usage Statistics</h6>' +
|
||||
'<table class="table table-sm">' +
|
||||
'<tr><td><strong>Volumes:</strong></td><td>' +
|
||||
'<div class="d-flex align-items-center">' +
|
||||
'<div class="progress me-2" style="width: 100px; height: 20px;">' +
|
||||
'<div class="progress-bar" role="progressbar" style="width: ' + volumePercent + '%"></div>' +
|
||||
'</div>' +
|
||||
'<span>' + server.volumes + '/' + server.maxVolumes + ' (' + volumePercent + '%)</span>' +
|
||||
'</div>' +
|
||||
'</td></tr>' +
|
||||
'<tr><td><strong>Disk Usage:</strong></td><td>' +
|
||||
'<div class="d-flex align-items-center">' +
|
||||
'<div class="progress me-2" style="width: 100px; height: 20px;">' +
|
||||
'<div class="progress-bar" role="progressbar" style="width: ' + diskPercent + '%"></div>' +
|
||||
'</div>' +
|
||||
'<span>' + formatBytes(server.diskUsage) + '/' + formatBytes(server.diskCapacity) + ' (' + diskPercent + '%)</span>' +
|
||||
'</div>' +
|
||||
'</td></tr>' +
|
||||
'<tr><td><strong>Available Space:</strong></td><td>' + formatBytes(server.diskCapacity - server.diskUsage) + '</td></tr>' +
|
||||
'</table>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="row mt-3">' +
|
||||
'<div class="col-12">' +
|
||||
'<h6 class="text-primary"><i class="fas fa-link me-1"></i>Quick Actions</h6>' +
|
||||
'<div class="d-grid gap-2 d-md-flex">' +
|
||||
'<a href="http://' + server.publicUrl + '/ui/index.html" target="_blank" class="btn btn-outline-primary">' +
|
||||
'<i class="fas fa-external-link-alt me-1"></i>Open Volume Server UI' +
|
||||
'</a>' +
|
||||
'<a href="/cluster/volumes?server=' + encodeURIComponent(server.address) + '" class="btn btn-outline-info">' +
|
||||
'<i class="fas fa-database me-1"></i>View Volumes' +
|
||||
'</a>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Remove existing modal if present
|
||||
const existingModal = document.getElementById('volumeServerDetailsModal');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
}
|
||||
|
||||
// Add modal to body and show
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
const modal = new bootstrap.Modal(document.getElementById('volumeServerDetailsModal'));
|
||||
modal.show();
|
||||
|
||||
// Remove modal when hidden
|
||||
document.getElementById('volumeServerDetailsModal').addEventListener('hidden.bs.modal', function() {
|
||||
this.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function exportVolumeServers() {
|
||||
// Simple CSV export of volume servers list
|
||||
const rows = Array.from(document.querySelectorAll('#hostsTable tbody tr')).map(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
if (cells.length > 1) {
|
||||
return {
|
||||
id: cells[0].textContent.trim(),
|
||||
address: cells[1].textContent.trim(),
|
||||
datacenter: cells[2].textContent.trim(),
|
||||
rack: cells[3].textContent.trim(),
|
||||
volumes: cells[4].textContent.trim(),
|
||||
capacity: cells[5].textContent.trim(),
|
||||
usage: cells[6].textContent.trim()
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter(row => row !== null);
|
||||
|
||||
const csvContent = "data:text/csv;charset=utf-8," +
|
||||
"Server ID,Address,Data Center,Rack,Volumes,Capacity,Usage\n" +
|
||||
rows.map(r => '"' + r.id + '","' + r.address + '","' + r.datacenter + '","' + r.rack + '","' + r.volumes + '","' + r.capacity + '","' + r.usage + '"').join("\n");
|
||||
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", encodedUri);
|
||||
link.setAttribute("download", "volume_servers.csv");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
</script>
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -228,7 +228,7 @@ templ FileBrowser(data dash.FileBrowserData) {
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<code class="small">{ entry.Mode }</code>
|
||||
<code class="small permissions-display" data-mode={ entry.Mode } data-is-directory={ fmt.Sprintf("%t", entry.IsDirectory) }>{ entry.Mode }</code>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
@@ -356,6 +356,380 @@ templ FileBrowser(data dash.FileBrowserData) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript for file browser functionality -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Format permissions in the main table
|
||||
document.querySelectorAll('.permissions-display').forEach(element => {
|
||||
const mode = element.getAttribute('data-mode');
|
||||
const isDirectory = element.getAttribute('data-is-directory') === 'true';
|
||||
if (mode) {
|
||||
element.textContent = formatPermissions(mode, isDirectory);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle file browser action buttons (download, view, properties, delete)
|
||||
document.addEventListener('click', function(e) {
|
||||
const button = e.target.closest('[data-action]');
|
||||
if (!button) return;
|
||||
|
||||
const action = button.getAttribute('data-action');
|
||||
const path = button.getAttribute('data-path');
|
||||
|
||||
if (!path) return;
|
||||
|
||||
switch(action) {
|
||||
case 'download':
|
||||
downloadFile(path);
|
||||
break;
|
||||
case 'view':
|
||||
viewFile(path);
|
||||
break;
|
||||
case 'properties':
|
||||
showFileProperties(path);
|
||||
break;
|
||||
case 'delete':
|
||||
if (confirm('Are you sure you want to delete "' + path + '"?')) {
|
||||
deleteFile(path);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize file manager event handlers from admin.js
|
||||
if (typeof setupFileManagerEventHandlers === 'function') {
|
||||
setupFileManagerEventHandlers();
|
||||
}
|
||||
});
|
||||
|
||||
// File browser specific functions
|
||||
function downloadFile(path) {
|
||||
// Open download URL in new tab
|
||||
window.open('/api/files/download?path=' + encodeURIComponent(path), '_blank');
|
||||
}
|
||||
|
||||
function viewFile(path) {
|
||||
// Open file viewer in new tab
|
||||
window.open('/api/files/view?path=' + encodeURIComponent(path), '_blank');
|
||||
}
|
||||
|
||||
function showFileProperties(path) {
|
||||
// Fetch file properties and show in modal
|
||||
fetch('/api/files/properties?path=' + encodeURIComponent(path))
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
alert('Error loading file properties: ' + data.error);
|
||||
} else {
|
||||
displayFileProperties(data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching file properties:', error);
|
||||
alert('Error loading file properties: ' + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
function displayFileProperties(data) {
|
||||
// Create a comprehensive modal for file properties
|
||||
const modalHtml = '<div class="modal fade" id="filePropertiesModal" tabindex="-1">' +
|
||||
'<div class="modal-dialog modal-lg">' +
|
||||
'<div class="modal-content">' +
|
||||
'<div class="modal-header">' +
|
||||
'<h5 class="modal-title"><i class="fas fa-info-circle me-2"></i>Properties: ' + (data.name || 'Unknown') + '</h5>' +
|
||||
'<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' +
|
||||
'</div>' +
|
||||
'<div class="modal-body">' +
|
||||
createFilePropertiesContent(data) +
|
||||
'</div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Remove existing modal if present
|
||||
const existingModal = document.getElementById('filePropertiesModal');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
}
|
||||
|
||||
// Add modal to body and show
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
const modal = new bootstrap.Modal(document.getElementById('filePropertiesModal'));
|
||||
modal.show();
|
||||
|
||||
// Remove modal when hidden
|
||||
document.getElementById('filePropertiesModal').addEventListener('hidden.bs.modal', function() {
|
||||
this.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createFilePropertiesContent(data) {
|
||||
let html = '<div class="row">' +
|
||||
'<div class="col-12">' +
|
||||
'<h6 class="text-primary"><i class="fas fa-file me-1"></i>Basic Information</h6>' +
|
||||
'<table class="table table-sm">' +
|
||||
'<tr><td style="width: 120px;"><strong>Name:</strong></td><td>' + (data.name || 'N/A') + '</td></tr>' +
|
||||
'<tr><td><strong>Full Path:</strong></td><td><code class="text-break">' + (data.full_path || 'N/A') + '</code></td></tr>' +
|
||||
'<tr><td><strong>Type:</strong></td><td>' + (data.is_directory ? 'Directory' : 'File') + '</td></tr>';
|
||||
|
||||
if (!data.is_directory) {
|
||||
html += '<tr><td><strong>Size:</strong></td><td>' + (data.size_formatted || (data.size ? formatBytes(data.size) : 'N/A')) + '</td></tr>' +
|
||||
'<tr><td><strong>MIME Type:</strong></td><td>' + (data.mime_type || 'N/A') + '</td></tr>';
|
||||
}
|
||||
|
||||
html += '</table>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="row">' +
|
||||
'<div class="col-md-6">' +
|
||||
'<h6 class="text-primary"><i class="fas fa-clock me-1"></i>Timestamps</h6>' +
|
||||
'<table class="table table-sm">';
|
||||
|
||||
if (data.modified_time) {
|
||||
html += '<tr><td><strong>Modified:</strong></td><td>' + data.modified_time + '</td></tr>';
|
||||
}
|
||||
if (data.created_time) {
|
||||
html += '<tr><td><strong>Created:</strong></td><td>' + data.created_time + '</td></tr>';
|
||||
}
|
||||
|
||||
html += '</table>' +
|
||||
'</div>' +
|
||||
'<div class="col-md-6">' +
|
||||
'<h6 class="text-primary"><i class="fas fa-shield-alt me-1"></i>Permissions</h6>' +
|
||||
'<table class="table table-sm">';
|
||||
|
||||
if (data.file_mode) {
|
||||
const rwxPermissions = formatPermissions(data.file_mode, data.is_directory);
|
||||
html += '<tr><td><strong>Permissions:</strong></td><td><code>' + rwxPermissions + '</code></td></tr>';
|
||||
}
|
||||
if (data.uid !== undefined) {
|
||||
html += '<tr><td><strong>User ID:</strong></td><td>' + data.uid + '</td></tr>';
|
||||
}
|
||||
if (data.gid !== undefined) {
|
||||
html += '<tr><td><strong>Group ID:</strong></td><td>' + data.gid + '</td></tr>';
|
||||
}
|
||||
|
||||
html += '</table>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Add advanced info
|
||||
html += '<div class="row">' +
|
||||
'<div class="col-12">' +
|
||||
'<h6 class="text-primary"><i class="fas fa-cog me-1"></i>Advanced</h6>' +
|
||||
'<table class="table table-sm">';
|
||||
|
||||
if (data.chunk_count) {
|
||||
html += '<tr><td style="width: 120px;"><strong>Chunks:</strong></td><td>' + data.chunk_count + '</td></tr>';
|
||||
}
|
||||
if (data.ttl_formatted) {
|
||||
html += '<tr><td><strong>TTL:</strong></td><td>' + data.ttl_formatted + '</td></tr>';
|
||||
}
|
||||
|
||||
html += '</table>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Add chunk details if available (show top 5)
|
||||
if (data.chunks && data.chunks.length > 0) {
|
||||
const chunksToShow = data.chunks.slice(0, 5);
|
||||
html += '<div class="row mt-3">' +
|
||||
'<div class="col-12">' +
|
||||
'<h6 class="text-primary"><i class="fas fa-puzzle-piece me-1"></i>Chunk Details' +
|
||||
(data.chunk_count > 5 ? ' (Top 5 of ' + data.chunk_count + ')' : ' (' + data.chunk_count + ')') +
|
||||
'</h6>' +
|
||||
'<div class="table-responsive" style="max-height: 200px; overflow-y: auto;">' +
|
||||
'<table class="table table-sm table-striped">' +
|
||||
'<thead>' +
|
||||
'<tr>' +
|
||||
'<th>File ID</th>' +
|
||||
'<th>Offset</th>' +
|
||||
'<th>Size</th>' +
|
||||
'<th>ETag</th>' +
|
||||
'</tr>' +
|
||||
'</thead>' +
|
||||
'<tbody>';
|
||||
|
||||
chunksToShow.forEach(chunk => {
|
||||
html += '<tr>' +
|
||||
'<td><code class="small">' + (chunk.file_id || 'N/A') + '</code></td>' +
|
||||
'<td>' + formatBytes(chunk.offset || 0) + '</td>' +
|
||||
'<td>' + formatBytes(chunk.size || 0) + '</td>' +
|
||||
'<td><code class="small">' + (chunk.e_tag || 'N/A') + '</code></td>' +
|
||||
'</tr>';
|
||||
});
|
||||
|
||||
html += '</tbody>' +
|
||||
'</table>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Add extended attributes if present
|
||||
if (data.extended && Object.keys(data.extended).length > 0) {
|
||||
html += '<div class="row">' +
|
||||
'<div class="col-12">' +
|
||||
'<h6 class="text-primary"><i class="fas fa-tags me-1"></i>Extended Attributes</h6>' +
|
||||
'<table class="table table-sm">';
|
||||
|
||||
for (const [key, value] of Object.entries(data.extended)) {
|
||||
html += '<tr><td><strong>' + key + ':</strong></td><td>' + value + '</td></tr>';
|
||||
}
|
||||
|
||||
html += '</table>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function uploadFile() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('uploadFileModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const selectAllCheckbox = document.getElementById('selectAll');
|
||||
const checkboxes = document.querySelectorAll('.file-checkbox');
|
||||
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = selectAllCheckbox.checked;
|
||||
});
|
||||
|
||||
updateDeleteSelectedButton();
|
||||
}
|
||||
|
||||
function updateDeleteSelectedButton() {
|
||||
const checkboxes = document.querySelectorAll('.file-checkbox:checked');
|
||||
const deleteBtn = document.getElementById('deleteSelectedBtn');
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
deleteBtn.style.display = 'inline-block';
|
||||
} else {
|
||||
deleteBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to format bytes
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Helper function to format permissions in rwxrwxrwx format
|
||||
function formatPermissions(mode, isDirectory) {
|
||||
// Check if mode is already in rwxrwxrwx format (e.g., "drwxr-xr-x" or "-rw-r--r--")
|
||||
if (mode && (mode.startsWith('d') || mode.startsWith('-') || mode.startsWith('l')) && mode.length === 10) {
|
||||
return mode; // Already formatted
|
||||
}
|
||||
|
||||
// Convert to number - could be octal string or decimal
|
||||
let permissions;
|
||||
if (typeof mode === 'string') {
|
||||
// Try parsing as octal first, then decimal
|
||||
if (mode.startsWith('0') && mode.length <= 4) {
|
||||
permissions = parseInt(mode, 8);
|
||||
} else {
|
||||
permissions = parseInt(mode, 10);
|
||||
}
|
||||
} else {
|
||||
permissions = parseInt(mode, 10);
|
||||
}
|
||||
|
||||
if (isNaN(permissions)) {
|
||||
return isDirectory ? 'drwxr-xr-x' : '-rw-r--r--'; // Default fallback
|
||||
}
|
||||
|
||||
// Handle Go's os.ModeDir conversion
|
||||
// Go's os.ModeDir is 0x80000000 (2147483648), but Unix S_IFDIR is 0o40000 (16384)
|
||||
let fileType = '-';
|
||||
|
||||
// Check for Go's os.ModeDir flag
|
||||
if (permissions & 0x80000000) {
|
||||
fileType = 'd';
|
||||
}
|
||||
// Check for standard Unix file type bits
|
||||
else if ((permissions & 0xF000) === 0x4000) { // S_IFDIR (0o40000)
|
||||
fileType = 'd';
|
||||
} else if ((permissions & 0xF000) === 0x8000) { // S_IFREG (0o100000)
|
||||
fileType = '-';
|
||||
} else if ((permissions & 0xF000) === 0xA000) { // S_IFLNK (0o120000)
|
||||
fileType = 'l';
|
||||
} else if ((permissions & 0xF000) === 0x2000) { // S_IFCHR (0o020000)
|
||||
fileType = 'c';
|
||||
} else if ((permissions & 0xF000) === 0x6000) { // S_IFBLK (0o060000)
|
||||
fileType = 'b';
|
||||
} else if ((permissions & 0xF000) === 0x1000) { // S_IFIFO (0o010000)
|
||||
fileType = 'p';
|
||||
} else if ((permissions & 0xF000) === 0xC000) { // S_IFSOCK (0o140000)
|
||||
fileType = 's';
|
||||
}
|
||||
// Fallback to isDirectory parameter if file type detection fails
|
||||
else if (isDirectory) {
|
||||
fileType = 'd';
|
||||
}
|
||||
|
||||
// Permission bits (always use the lower 12 bits for permissions)
|
||||
const owner = (permissions >> 6) & 7;
|
||||
const group = (permissions >> 3) & 7;
|
||||
const others = permissions & 7;
|
||||
|
||||
// Convert number to rwx format
|
||||
function numToRwx(num) {
|
||||
const r = (num & 4) ? 'r' : '-';
|
||||
const w = (num & 2) ? 'w' : '-';
|
||||
const x = (num & 1) ? 'x' : '-';
|
||||
return r + w + x;
|
||||
}
|
||||
|
||||
return fileType + numToRwx(owner) + numToRwx(group) + numToRwx(others);
|
||||
}
|
||||
|
||||
function exportFileList() {
|
||||
// Simple CSV export of file list
|
||||
const rows = Array.from(document.querySelectorAll('#fileTable tbody tr')).map(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
if (cells.length > 1) {
|
||||
return {
|
||||
name: cells[1].textContent.trim(),
|
||||
size: cells[2].textContent.trim(),
|
||||
type: cells[3].textContent.trim(),
|
||||
modified: cells[4].textContent.trim(),
|
||||
permissions: cells[5].textContent.trim()
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter(row => row !== null);
|
||||
|
||||
const csvContent = "data:text/csv;charset=utf-8," +
|
||||
"Name,Size,Type,Modified,Permissions\n" +
|
||||
rows.map(r => '"' + r.name + '","' + r.size + '","' + r.type + '","' + r.modified + '","' + r.permissions + '"').join("\n");
|
||||
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", encodedUri);
|
||||
link.setAttribute("download", "files.csv");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
// Handle file checkbox changes
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.classList.contains('file-checkbox')) {
|
||||
updateDeleteSelectedButton();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
func countDirectories(entries []dash.FileEntry) int {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -317,7 +317,355 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
|
||||
<!-- JavaScript for user management -->
|
||||
<script>
|
||||
// User management functions will be included in admin.js
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Event delegation for user action buttons
|
||||
document.addEventListener('click', function(e) {
|
||||
const button = e.target.closest('[data-action]');
|
||||
if (!button) return;
|
||||
|
||||
const action = button.getAttribute('data-action');
|
||||
const username = button.getAttribute('data-username');
|
||||
|
||||
switch (action) {
|
||||
case 'show-user-details':
|
||||
showUserDetails(username);
|
||||
break;
|
||||
case 'edit-user':
|
||||
editUser(username);
|
||||
break;
|
||||
case 'manage-access-keys':
|
||||
manageAccessKeys(username);
|
||||
break;
|
||||
case 'delete-user':
|
||||
deleteUser(username);
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Show user details modal
|
||||
async function showUserDetails(username) {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${username}`);
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
document.getElementById('userDetailsContent').innerHTML = createUserDetailsContent(user);
|
||||
const modal = new bootstrap.Modal(document.getElementById('userDetailsModal'));
|
||||
modal.show();
|
||||
} else {
|
||||
showErrorMessage('Failed to load user details');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading user details:', error);
|
||||
showErrorMessage('Failed to load user details');
|
||||
}
|
||||
}
|
||||
|
||||
// Edit user function
|
||||
async function editUser(username) {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${username}`);
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
|
||||
// Populate edit form
|
||||
document.getElementById('editUsername').value = username;
|
||||
document.getElementById('editEmail').value = user.email || '';
|
||||
|
||||
// Set selected actions
|
||||
const actionsSelect = document.getElementById('editActions');
|
||||
Array.from(actionsSelect.options).forEach(option => {
|
||||
option.selected = user.actions && user.actions.includes(option.value);
|
||||
});
|
||||
|
||||
// Show modal
|
||||
const modal = new bootstrap.Modal(document.getElementById('editUserModal'));
|
||||
modal.show();
|
||||
} else {
|
||||
showErrorMessage('Failed to load user details');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading user:', error);
|
||||
showErrorMessage('Failed to load user details');
|
||||
}
|
||||
}
|
||||
|
||||
// Manage access keys function
|
||||
async function manageAccessKeys(username) {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${username}`);
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
document.getElementById('accessKeysUsername').textContent = username;
|
||||
document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);
|
||||
const modal = new bootstrap.Modal(document.getElementById('accessKeysModal'));
|
||||
modal.show();
|
||||
} else {
|
||||
showErrorMessage('Failed to load access keys');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading access keys:', error);
|
||||
showErrorMessage('Failed to load access keys');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user function
|
||||
async function deleteUser(username) {
|
||||
if (confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${username}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showSuccessMessage('User deleted successfully');
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showErrorMessage('Failed to delete user: ' + (error.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
showErrorMessage('Failed to delete user: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle create user form submission
|
||||
async function handleCreateUser() {
|
||||
const form = document.getElementById('createUserForm');
|
||||
const formData = new FormData(form);
|
||||
|
||||
const userData = {
|
||||
username: formData.get('username'),
|
||||
email: formData.get('email'),
|
||||
actions: Array.from(document.getElementById('actions').selectedOptions).map(option => option.value),
|
||||
generate_key: document.getElementById('generateKey').checked
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(userData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
showSuccessMessage('User created successfully');
|
||||
|
||||
// Show the created access key if generated
|
||||
if (result.user && result.user.access_key) {
|
||||
showNewAccessKeyModal(result.user);
|
||||
}
|
||||
|
||||
// Close modal and refresh page
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('createUserModal'));
|
||||
modal.hide();
|
||||
form.reset();
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showErrorMessage('Failed to create user: ' + (error.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
showErrorMessage('Failed to create user: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle update user form submission
|
||||
async function handleUpdateUser() {
|
||||
const username = document.getElementById('editUsername').value;
|
||||
const formData = new FormData(document.getElementById('editUserForm'));
|
||||
|
||||
const userData = {
|
||||
email: formData.get('email'),
|
||||
actions: Array.from(document.getElementById('editActions').selectedOptions).map(option => option.value)
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${username}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(userData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showSuccessMessage('User updated successfully');
|
||||
|
||||
// Close modal and refresh page
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('editUserModal'));
|
||||
modal.hide();
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showErrorMessage('Failed to update user: ' + (error.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
showErrorMessage('Failed to update user: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Create user details content
|
||||
function createUserDetailsContent(user) {
|
||||
var detailsHtml = '<div class="row">';
|
||||
detailsHtml += '<div class="col-md-6">';
|
||||
detailsHtml += '<h6 class="text-muted">Basic Information</h6>';
|
||||
detailsHtml += '<table class="table table-sm">';
|
||||
detailsHtml += '<tr><td><strong>Username:</strong></td><td>' + escapeHtml(user.username) + '</td></tr>';
|
||||
detailsHtml += '<tr><td><strong>Email:</strong></td><td>' + escapeHtml(user.email || 'Not set') + '</td></tr>';
|
||||
detailsHtml += '</table>';
|
||||
detailsHtml += '</div>';
|
||||
detailsHtml += '<div class="col-md-6">';
|
||||
detailsHtml += '<h6 class="text-muted">Permissions</h6>';
|
||||
detailsHtml += '<div class="mb-3">';
|
||||
if (user.actions && user.actions.length > 0) {
|
||||
detailsHtml += user.actions.map(function(action) {
|
||||
return '<span class="badge bg-info me-1">' + action + '</span>';
|
||||
}).join('');
|
||||
} else {
|
||||
detailsHtml += '<span class="text-muted">No permissions assigned</span>';
|
||||
}
|
||||
detailsHtml += '</div>';
|
||||
detailsHtml += '<h6 class="text-muted">Access Keys</h6>';
|
||||
if (user.access_keys && user.access_keys.length > 0) {
|
||||
detailsHtml += '<div class="mb-2">';
|
||||
user.access_keys.forEach(function(key) {
|
||||
detailsHtml += '<div><code class="text-muted">' + key.access_key + '</code></div>';
|
||||
});
|
||||
detailsHtml += '</div>';
|
||||
} else {
|
||||
detailsHtml += '<p class="text-muted">No access keys</p>';
|
||||
}
|
||||
detailsHtml += '</div>';
|
||||
detailsHtml += '</div>';
|
||||
return detailsHtml;
|
||||
}
|
||||
|
||||
// Create access keys content
|
||||
function createAccessKeysContent(user) {
|
||||
if (!user.access_keys || user.access_keys.length === 0) {
|
||||
return '<p class="text-muted">No access keys available</p>';
|
||||
}
|
||||
|
||||
var keysHtml = '<div class="table-responsive">';
|
||||
keysHtml += '<table class="table table-sm">';
|
||||
keysHtml += '<thead><tr><th>Access Key</th><th>Status</th><th>Actions</th></tr></thead>';
|
||||
keysHtml += '<tbody>';
|
||||
|
||||
user.access_keys.forEach(function(key) {
|
||||
keysHtml += '<tr>';
|
||||
keysHtml += '<td><code>' + key.access_key + '</code></td>';
|
||||
keysHtml += '<td><span class="badge bg-success">Active</span></td>';
|
||||
keysHtml += '<td>';
|
||||
keysHtml += '<button class="btn btn-outline-danger btn-sm" onclick="deleteAccessKey(\'' + user.username + '\', \'' + key.access_key + '\')">';
|
||||
keysHtml += '<i class="fas fa-trash"></i> Delete';
|
||||
keysHtml += '</button>';
|
||||
keysHtml += '</td>';
|
||||
keysHtml += '</tr>';
|
||||
});
|
||||
|
||||
keysHtml += '</tbody>';
|
||||
keysHtml += '</table>';
|
||||
keysHtml += '</div>';
|
||||
return keysHtml;
|
||||
}
|
||||
|
||||
// Create new access key
|
||||
async function createAccessKey() {
|
||||
const username = document.getElementById('accessKeysUsername').textContent;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${username}/access-keys`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
showSuccessMessage('Access key created successfully');
|
||||
|
||||
// Refresh access keys display
|
||||
const userResponse = await fetch(`/api/users/${username}`);
|
||||
if (userResponse.ok) {
|
||||
const user = await userResponse.json();
|
||||
document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showErrorMessage('Failed to create access key: ' + (error.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating access key:', error);
|
||||
showErrorMessage('Failed to create access key: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete access key
|
||||
async function deleteAccessKey(username, accessKey) {
|
||||
if (confirm('Are you sure you want to delete this access key?')) {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${username}/access-keys/${accessKey}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showSuccessMessage('Access key deleted successfully');
|
||||
|
||||
// Refresh access keys display
|
||||
const userResponse = await fetch(`/api/users/${username}`);
|
||||
if (userResponse.ok) {
|
||||
const user = await userResponse.json();
|
||||
document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showErrorMessage('Failed to delete access key: ' + (error.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting access key:', error);
|
||||
showErrorMessage('Failed to delete access key: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show new access key modal (when user is created with generated key)
|
||||
function showNewAccessKeyModal(user) {
|
||||
// Create a simple alert for now - could be enhanced with a dedicated modal
|
||||
var message = 'New user created!\n\n';
|
||||
message += 'Username: ' + user.username + '\n';
|
||||
message += 'Access Key: ' + user.access_key + '\n';
|
||||
message += 'Secret Key: ' + user.secret_key + '\n\n';
|
||||
message += 'Please save these credentials securely.';
|
||||
alert(message);
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function showSuccessMessage(message) {
|
||||
// Simple implementation - could be enhanced with toast notifications
|
||||
alert('Success: ' + message);
|
||||
}
|
||||
|
||||
function showErrorMessage(message) {
|
||||
// Simple implementation - could be enhanced with toast notifications
|
||||
alert('Error: ' + message);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
658
weed/admin/view/app/policies.templ
Normal file
658
weed/admin/view/app/policies.templ
Normal file
@@ -0,0 +1,658 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
|
||||
)
|
||||
|
||||
templ Policies(data dash.PoliciesData) {
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">
|
||||
<i class="fas fa-shield-alt me-2"></i>IAM Policies
|
||||
</h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#createPolicyModal">
|
||||
<i class="fas fa-plus me-1"></i>Create Policy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="policies-content">
|
||||
<!-- Summary Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-4 col-md-6 mb-4">
|
||||
<div class="card border-left-primary shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
||||
Total Policies
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{fmt.Sprintf("%d", data.TotalPolicies)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-shield-alt fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-4 col-md-6 mb-4">
|
||||
<div class="card border-left-success shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
||||
Active Policies
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{fmt.Sprintf("%d", data.TotalPolicies)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-check-circle fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-4 col-md-6 mb-4">
|
||||
<div class="card border-left-info shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
|
||||
Last Updated
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{data.LastUpdated.Format("15:04")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-clock fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Policies Table -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-shield-alt me-2"></i>IAM Policies
|
||||
</h6>
|
||||
<div class="dropdown no-arrow">
|
||||
<a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="fas fa-ellipsis-v fa-sm fa-fw text-gray-400"></i>
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-right shadow animated--fade-in">
|
||||
<div class="dropdown-header">Actions:</div>
|
||||
<a class="dropdown-item" href="#">
|
||||
<i class="fas fa-download me-2"></i>Export List
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover" width="100%" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Policy Name</th>
|
||||
<th>Version</th>
|
||||
<th>Statements</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, policy := range data.Policies {
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{policy.Name}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info">{policy.Document.Version}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary">{fmt.Sprintf("%d statements", len(policy.Document.Statement))}</span>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">{policy.CreatedAt.Format("2006-01-02 15:04")}</small>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">{policy.UpdatedAt.Format("2006-01-02 15:04")}</small>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-info view-policy-btn" title="View Policy" data-policy-name={policy.Name}>
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary edit-policy-btn" title="Edit Policy" data-policy-name={policy.Name}>
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger delete-policy-btn" title="Delete Policy" data-policy-name={policy.Name}>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
if len(data.Policies) == 0 {
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-4">
|
||||
<i class="fas fa-shield-alt fa-3x mb-3 text-muted"></i>
|
||||
<div>
|
||||
<h5>No IAM policies found</h5>
|
||||
<p>Create your first policy to manage access permissions.</p>
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createPolicyModal">
|
||||
<i class="fas fa-plus me-1"></i>Create Policy
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Policy Modal -->
|
||||
<div class="modal fade" id="createPolicyModal" tabindex="-1" aria-labelledby="createPolicyModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="createPolicyModalLabel">
|
||||
<i class="fas fa-shield-alt me-2"></i>Create IAM Policy
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="createPolicyForm">
|
||||
<div class="mb-3">
|
||||
<label for="policyName" class="form-label">Policy Name</label>
|
||||
<input type="text" class="form-control" id="policyName" name="name" required placeholder="e.g., S3ReadOnlyPolicy">
|
||||
<div class="form-text">Enter a unique name for this policy (alphanumeric and underscores only)</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="policyDocument" class="form-label">Policy Document</label>
|
||||
<textarea class="form-control" id="policyDocument" name="document" rows="15" required placeholder="Enter IAM policy JSON document..."></textarea>
|
||||
<div class="form-text">Enter the policy document in AWS IAM JSON format</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<button type="button" class="btn btn-outline-info btn-sm" onclick="insertSamplePolicy()">
|
||||
<i class="fas fa-file-alt me-1"></i>Use Sample Policy
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="validatePolicyDocument()">
|
||||
<i class="fas fa-check me-1"></i>Validate JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="createPolicy()">
|
||||
<i class="fas fa-plus me-1"></i>Create Policy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Policy Modal -->
|
||||
<div class="modal fade" id="viewPolicyModal" tabindex="-1" aria-labelledby="viewPolicyModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="viewPolicyModalLabel">
|
||||
<i class="fas fa-eye me-2"></i>View IAM Policy
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="viewPolicyContent">
|
||||
<div class="text-center">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-2">Loading policy...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" id="editFromViewBtn">
|
||||
<i class="fas fa-edit me-1"></i>Edit Policy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Policy Modal -->
|
||||
<div class="modal fade" id="editPolicyModal" tabindex="-1" aria-labelledby="editPolicyModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="editPolicyModalLabel">
|
||||
<i class="fas fa-edit me-2"></i>Edit IAM Policy
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="editPolicyForm">
|
||||
<div class="mb-3">
|
||||
<label for="editPolicyName" class="form-label">Policy Name</label>
|
||||
<input type="text" class="form-control" id="editPolicyName" name="name" readonly>
|
||||
<div class="form-text">Policy name cannot be changed</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="editPolicyDocument" class="form-label">Policy Document</label>
|
||||
<textarea class="form-control" id="editPolicyDocument" name="document" rows="15" required></textarea>
|
||||
<div class="form-text">Edit the policy document in AWS IAM JSON format</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<button type="button" class="btn btn-outline-info btn-sm" onclick="insertSamplePolicyEdit()">
|
||||
<i class="fas fa-file-alt me-1"></i>Reset to Sample
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="validateEditPolicyDocument()">
|
||||
<i class="fas fa-check me-1"></i>Validate JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="updatePolicy()">
|
||||
<i class="fas fa-save me-1"></i>Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript for Policy Management -->
|
||||
<script>
|
||||
// Current policy being viewed/edited
|
||||
let currentPolicy = null;
|
||||
|
||||
// Event listeners for policy actions
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// View policy buttons
|
||||
document.querySelectorAll('.view-policy-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const policyName = this.getAttribute('data-policy-name');
|
||||
viewPolicy(policyName);
|
||||
});
|
||||
});
|
||||
|
||||
// Edit policy buttons
|
||||
document.querySelectorAll('.edit-policy-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const policyName = this.getAttribute('data-policy-name');
|
||||
editPolicy(policyName);
|
||||
});
|
||||
});
|
||||
|
||||
// Delete policy buttons
|
||||
document.querySelectorAll('.delete-policy-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const policyName = this.getAttribute('data-policy-name');
|
||||
deletePolicy(policyName);
|
||||
});
|
||||
});
|
||||
|
||||
// Edit from view button
|
||||
document.getElementById('editFromViewBtn').addEventListener('click', function() {
|
||||
if (currentPolicy) {
|
||||
const viewModal = bootstrap.Modal.getInstance(document.getElementById('viewPolicyModal'));
|
||||
if (viewModal) viewModal.hide();
|
||||
editPolicy(currentPolicy.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function createPolicy() {
|
||||
const form = document.getElementById('createPolicyForm');
|
||||
const formData = new FormData(form);
|
||||
|
||||
const policyName = formData.get('name');
|
||||
const policyDocumentText = formData.get('document');
|
||||
|
||||
if (!policyName || !policyDocumentText) {
|
||||
alert('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
let policyDocument;
|
||||
try {
|
||||
policyDocument = JSON.parse(policyDocumentText);
|
||||
} catch (e) {
|
||||
alert('Invalid JSON in policy document: ' + e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestData = {
|
||||
name: policyName,
|
||||
document: policyDocument
|
||||
};
|
||||
|
||||
fetch('/api/object-store/policies', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('Policy created successfully!');
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('createPolicyModal'));
|
||||
if (modal) modal.hide();
|
||||
location.reload(); // Refresh the page to show the new policy
|
||||
} else {
|
||||
alert('Error creating policy: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error creating policy: ' + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
function viewPolicy(policyName) {
|
||||
// Show the modal first
|
||||
const modal = new bootstrap.Modal(document.getElementById('viewPolicyModal'));
|
||||
modal.show();
|
||||
|
||||
// Reset content to loading state
|
||||
document.getElementById('viewPolicyContent').innerHTML = `
|
||||
<div class="text-center">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-2">Loading policy...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Fetch policy data
|
||||
fetch('/api/object-store/policies/' + encodeURIComponent(policyName))
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Policy not found');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(policy => {
|
||||
currentPolicy = policy;
|
||||
displayPolicyDetails(policy);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
document.getElementById('viewPolicyContent').innerHTML = `
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
Error loading policy: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
function displayPolicyDetails(policy) {
|
||||
const content = document.getElementById('viewPolicyContent');
|
||||
|
||||
let statementsHtml = '';
|
||||
if (policy.document && policy.document.Statement) {
|
||||
statementsHtml = policy.document.Statement.map((stmt, index) => `
|
||||
<div class="card mb-2">
|
||||
<div class="card-header py-2">
|
||||
<h6 class="mb-0">Statement ${index + 1}</h6>
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<strong>Effect:</strong>
|
||||
<span class="badge ${stmt.Effect === 'Allow' ? 'bg-success' : 'bg-danger'}">${stmt.Effect}</span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Actions:</strong> ${Array.isArray(stmt.Action) ? stmt.Action.join(', ') : stmt.Action}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-12">
|
||||
<strong>Resources:</strong> ${Array.isArray(stmt.Resource) ? stmt.Resource.join(', ') : stmt.Resource}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<strong>Policy Name:</strong> ${policy.name || 'Unknown'}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Version:</strong> <span class="badge bg-info">${policy.document?.Version || 'Unknown'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<strong>Statements:</strong>
|
||||
<div class="mt-2">
|
||||
${statementsHtml || '<p class="text-muted">No statements found</p>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<strong>Raw Policy Document:</strong>
|
||||
<pre class="bg-light p-3 border rounded mt-2"><code>${JSON.stringify(policy.document, null, 2)}</code></pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function editPolicy(policyName) {
|
||||
// Show the modal first
|
||||
const modal = new bootstrap.Modal(document.getElementById('editPolicyModal'));
|
||||
modal.show();
|
||||
|
||||
// Set policy name
|
||||
document.getElementById('editPolicyName').value = policyName;
|
||||
document.getElementById('editPolicyDocument').value = 'Loading...';
|
||||
|
||||
// Fetch policy data
|
||||
fetch('/api/object-store/policies/' + encodeURIComponent(policyName))
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Policy not found');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(policy => {
|
||||
currentPolicy = policy;
|
||||
document.getElementById('editPolicyDocument').value = JSON.stringify(policy.document, null, 2);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error loading policy for editing: ' + error.message);
|
||||
const editModal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal'));
|
||||
if (editModal) editModal.hide();
|
||||
});
|
||||
}
|
||||
|
||||
function updatePolicy() {
|
||||
const policyName = document.getElementById('editPolicyName').value;
|
||||
const policyDocumentText = document.getElementById('editPolicyDocument').value;
|
||||
|
||||
if (!policyName || !policyDocumentText) {
|
||||
alert('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
let policyDocument;
|
||||
try {
|
||||
policyDocument = JSON.parse(policyDocumentText);
|
||||
} catch (e) {
|
||||
alert('Invalid JSON in policy document: ' + e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestData = {
|
||||
document: policyDocument
|
||||
};
|
||||
|
||||
fetch('/api/object-store/policies/' + encodeURIComponent(policyName), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('Policy updated successfully!');
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal'));
|
||||
if (modal) modal.hide();
|
||||
location.reload(); // Refresh the page to show the updated policy
|
||||
} else {
|
||||
alert('Error updating policy: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error updating policy: ' + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
function insertSamplePolicy() {
|
||||
const samplePolicy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::my-bucket/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
document.getElementById('policyDocument').value = JSON.stringify(samplePolicy, null, 2);
|
||||
}
|
||||
|
||||
function insertSamplePolicyEdit() {
|
||||
const samplePolicy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::my-bucket/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
document.getElementById('editPolicyDocument').value = JSON.stringify(samplePolicy, null, 2);
|
||||
}
|
||||
|
||||
function validatePolicyDocument() {
|
||||
const policyText = document.getElementById('policyDocument').value;
|
||||
validatePolicyJSON(policyText);
|
||||
}
|
||||
|
||||
function validateEditPolicyDocument() {
|
||||
const policyText = document.getElementById('editPolicyDocument').value;
|
||||
validatePolicyJSON(policyText);
|
||||
}
|
||||
|
||||
function validatePolicyJSON(policyText) {
|
||||
if (!policyText) {
|
||||
alert('Please enter a policy document first');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const policy = JSON.parse(policyText);
|
||||
|
||||
// Basic validation
|
||||
if (!policy.Version) {
|
||||
alert('Policy must have a Version field');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!policy.Statement || !Array.isArray(policy.Statement)) {
|
||||
alert('Policy must have a Statement array');
|
||||
return;
|
||||
}
|
||||
|
||||
alert('Policy document is valid JSON!');
|
||||
} catch (e) {
|
||||
alert('Invalid JSON: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function deletePolicy(policyName) {
|
||||
if (confirm('Are you sure you want to delete policy "' + policyName + '"?')) {
|
||||
fetch('/api/object-store/policies/' + encodeURIComponent(policyName), {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('Policy deleted successfully!');
|
||||
location.reload(); // Refresh the page
|
||||
} else {
|
||||
alert('Error deleting policy: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error deleting policy: ' + error.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
}
|
||||
204
weed/admin/view/app/policies_templ.go
Normal file
204
weed/admin/view/app/policies_templ.go
Normal file
File diff suppressed because one or more lines are too long
@@ -187,11 +187,12 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
title="Browse Files">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
</a>
|
||||
<a href={templ.SafeURL(fmt.Sprintf("/s3/buckets/%s", bucket.Name))}
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
title="View Details">
|
||||
<button type="button"
|
||||
class="btn btn-outline-primary btn-sm view-details-btn"
|
||||
data-bucket-name={bucket.Name}
|
||||
title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-outline-warning btn-sm quota-btn"
|
||||
data-bucket-name={bucket.Name}
|
||||
@@ -442,6 +443,33 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bucket Details Modal -->
|
||||
<div class="modal fade" id="bucketDetailsModal" tabindex="-1" aria-labelledby="bucketDetailsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="bucketDetailsModalLabel">
|
||||
<i class="fas fa-cube me-2"></i>Bucket Details
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="bucketDetailsContent">
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<div class="mt-2">Loading bucket details...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript for bucket management -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -504,7 +532,12 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
alert('Error creating bucket: ' + data.error);
|
||||
} else {
|
||||
alert('Bucket created successfully!');
|
||||
location.reload();
|
||||
// Properly close the modal before reloading
|
||||
const createModal = bootstrap.Modal.getInstance(document.getElementById('createBucketModal'));
|
||||
if (createModal) {
|
||||
createModal.hide();
|
||||
}
|
||||
setTimeout(() => location.reload(), 500);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -514,16 +547,41 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
});
|
||||
|
||||
// Handle delete bucket
|
||||
let deleteModalInstance = null;
|
||||
document.querySelectorAll('.delete-bucket-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const bucketName = this.dataset.bucketName;
|
||||
document.getElementById('deleteBucketName').textContent = bucketName;
|
||||
window.currentBucketToDelete = bucketName;
|
||||
new bootstrap.Modal(document.getElementById('deleteBucketModal')).show();
|
||||
|
||||
// Dispose of existing modal instance if it exists
|
||||
if (deleteModalInstance) {
|
||||
deleteModalInstance.dispose();
|
||||
}
|
||||
|
||||
// Create new modal instance
|
||||
deleteModalInstance = new bootstrap.Modal(document.getElementById('deleteBucketModal'));
|
||||
deleteModalInstance.show();
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listener to properly dispose of delete modal when hidden
|
||||
document.getElementById('deleteBucketModal').addEventListener('hidden.bs.modal', function() {
|
||||
if (deleteModalInstance) {
|
||||
deleteModalInstance.dispose();
|
||||
deleteModalInstance = null;
|
||||
}
|
||||
// Force remove any remaining backdrops
|
||||
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
|
||||
backdrop.remove();
|
||||
});
|
||||
// Ensure body classes are removed
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.removeProperty('padding-right');
|
||||
});
|
||||
|
||||
// Handle quota management
|
||||
let quotaModalInstance = null;
|
||||
document.querySelectorAll('.quota-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const bucketName = this.dataset.bucketName;
|
||||
@@ -538,10 +596,33 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none';
|
||||
|
||||
window.currentBucketToUpdate = bucketName;
|
||||
new bootstrap.Modal(document.getElementById('manageQuotaModal')).show();
|
||||
|
||||
// Dispose of existing modal instance if it exists
|
||||
if (quotaModalInstance) {
|
||||
quotaModalInstance.dispose();
|
||||
}
|
||||
|
||||
// Create new modal instance
|
||||
quotaModalInstance = new bootstrap.Modal(document.getElementById('manageQuotaModal'));
|
||||
quotaModalInstance.show();
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listener to properly dispose of quota modal when hidden
|
||||
document.getElementById('manageQuotaModal').addEventListener('hidden.bs.modal', function() {
|
||||
if (quotaModalInstance) {
|
||||
quotaModalInstance.dispose();
|
||||
quotaModalInstance = null;
|
||||
}
|
||||
// Force remove any remaining backdrops
|
||||
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
|
||||
backdrop.remove();
|
||||
});
|
||||
// Ensure body classes are removed
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.removeProperty('padding-right');
|
||||
});
|
||||
|
||||
// Handle quota form submission
|
||||
document.getElementById('quotaForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
@@ -567,7 +648,11 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
alert('Error updating quota: ' + data.error);
|
||||
} else {
|
||||
alert('Quota updated successfully!');
|
||||
location.reload();
|
||||
// Properly close the modal before reloading
|
||||
if (quotaModalInstance) {
|
||||
quotaModalInstance.hide();
|
||||
}
|
||||
setTimeout(() => location.reload(), 500);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -580,6 +665,74 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
document.getElementById('quotaEnabled').addEventListener('change', function() {
|
||||
document.getElementById('quotaSizeSettings').style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Handle view details button
|
||||
let detailsModalInstance = null;
|
||||
document.querySelectorAll('.view-details-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const bucketName = this.dataset.bucketName;
|
||||
|
||||
// Update modal title
|
||||
document.getElementById('bucketDetailsModalLabel').innerHTML =
|
||||
'<i class="fas fa-cube me-2"></i>Bucket Details - ' + bucketName;
|
||||
|
||||
// Show loading spinner
|
||||
document.getElementById('bucketDetailsContent').innerHTML =
|
||||
'<div class="text-center py-4">' +
|
||||
'<div class="spinner-border text-primary" role="status">' +
|
||||
'<span class="visually-hidden">Loading...</span>' +
|
||||
'</div>' +
|
||||
'<div class="mt-2">Loading bucket details...</div>' +
|
||||
'</div>';
|
||||
|
||||
// Dispose of existing modal instance if it exists
|
||||
if (detailsModalInstance) {
|
||||
detailsModalInstance.dispose();
|
||||
}
|
||||
|
||||
// Create new modal instance
|
||||
detailsModalInstance = new bootstrap.Modal(document.getElementById('bucketDetailsModal'));
|
||||
detailsModalInstance.show();
|
||||
|
||||
// Fetch bucket details
|
||||
fetch('/api/s3/buckets/' + bucketName)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
document.getElementById('bucketDetailsContent').innerHTML =
|
||||
'<div class="alert alert-danger">' +
|
||||
'<i class="fas fa-exclamation-triangle me-2"></i>' +
|
||||
'Error loading bucket details: ' + data.error +
|
||||
'</div>';
|
||||
} else {
|
||||
displayBucketDetails(data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching bucket details:', error);
|
||||
document.getElementById('bucketDetailsContent').innerHTML =
|
||||
'<div class="alert alert-danger">' +
|
||||
'<i class="fas fa-exclamation-triangle me-2"></i>' +
|
||||
'Error loading bucket details: ' + error.message +
|
||||
'</div>';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listener to properly dispose of details modal when hidden
|
||||
document.getElementById('bucketDetailsModal').addEventListener('hidden.bs.modal', function() {
|
||||
if (detailsModalInstance) {
|
||||
detailsModalInstance.dispose();
|
||||
detailsModalInstance = null;
|
||||
}
|
||||
// Force remove any remaining backdrops
|
||||
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
|
||||
backdrop.remove();
|
||||
});
|
||||
// Ensure body classes are removed
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.removeProperty('padding-right');
|
||||
});
|
||||
});
|
||||
|
||||
function deleteBucket() {
|
||||
@@ -595,7 +748,11 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
alert('Error deleting bucket: ' + data.error);
|
||||
} else {
|
||||
alert('Bucket deleted successfully!');
|
||||
location.reload();
|
||||
// Properly close the modal before reloading
|
||||
if (deleteModalInstance) {
|
||||
deleteModalInstance.hide();
|
||||
}
|
||||
setTimeout(() => location.reload(), 500);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -604,6 +761,128 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
});
|
||||
}
|
||||
|
||||
function displayBucketDetails(data) {
|
||||
const bucket = data.bucket;
|
||||
const objects = data.objects || [];
|
||||
|
||||
// Helper function to format bytes
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Helper function to format date
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
// Generate objects table
|
||||
let objectsTable = '';
|
||||
if (objects.length > 0) {
|
||||
objectsTable = '<div class="table-responsive">' +
|
||||
'<table class="table table-sm table-striped">' +
|
||||
'<thead>' +
|
||||
'<tr>' +
|
||||
'<th>Object Key</th>' +
|
||||
'<th>Size</th>' +
|
||||
'<th>Last Modified</th>' +
|
||||
'<th>Storage Class</th>' +
|
||||
'</tr>' +
|
||||
'</thead>' +
|
||||
'<tbody>' +
|
||||
objects.map(obj =>
|
||||
'<tr>' +
|
||||
'<td><i class="fas fa-file me-1"></i>' + obj.key + '</td>' +
|
||||
'<td>' + formatBytes(obj.size) + '</td>' +
|
||||
'<td>' + formatDate(obj.last_modified) + '</td>' +
|
||||
'<td><span class="badge bg-primary">' + obj.storage_class + '</span></td>' +
|
||||
'</tr>'
|
||||
).join('') +
|
||||
'</tbody>' +
|
||||
'</table>' +
|
||||
'</div>';
|
||||
} else {
|
||||
objectsTable = '<div class="text-center py-4 text-muted">' +
|
||||
'<i class="fas fa-file fa-3x mb-3"></i>' +
|
||||
'<div>No objects found in this bucket</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
const content = '<div class="row">' +
|
||||
'<div class="col-md-6">' +
|
||||
'<h6><i class="fas fa-info-circle me-2"></i>Bucket Information</h6>' +
|
||||
'<table class="table table-sm">' +
|
||||
'<tr>' +
|
||||
'<td><strong>Name:</strong></td>' +
|
||||
'<td>' + bucket.name + '</td>' +
|
||||
'</tr>' +
|
||||
'<tr>' +
|
||||
'<td><strong>Created:</strong></td>' +
|
||||
'<td>' + formatDate(bucket.created_at) + '</td>' +
|
||||
'</tr>' +
|
||||
'<tr>' +
|
||||
'<td><strong>Last Modified:</strong></td>' +
|
||||
'<td>' + formatDate(bucket.last_modified) + '</td>' +
|
||||
'</tr>' +
|
||||
'<tr>' +
|
||||
'<td><strong>Total Size:</strong></td>' +
|
||||
'<td>' + formatBytes(bucket.size) + '</td>' +
|
||||
'</tr>' +
|
||||
'<tr>' +
|
||||
'<td><strong>Object Count:</strong></td>' +
|
||||
'<td>' + bucket.object_count + '</td>' +
|
||||
'</tr>' +
|
||||
'</table>' +
|
||||
'</div>' +
|
||||
'<div class="col-md-6">' +
|
||||
'<h6><i class="fas fa-cogs me-2"></i>Configuration</h6>' +
|
||||
'<table class="table table-sm">' +
|
||||
'<tr>' +
|
||||
'<td><strong>Quota:</strong></td>' +
|
||||
'<td>' +
|
||||
(bucket.quota_enabled ?
|
||||
'<span class="badge bg-success">' + formatBytes(bucket.quota) + '</span>' :
|
||||
'<span class="badge bg-secondary">Disabled</span>'
|
||||
) +
|
||||
'</td>' +
|
||||
'</tr>' +
|
||||
'<tr>' +
|
||||
'<td><strong>Versioning:</strong></td>' +
|
||||
'<td>' +
|
||||
(bucket.versioning_enabled ?
|
||||
'<span class="badge bg-success"><i class="fas fa-check me-1"></i>Enabled</span>' :
|
||||
'<span class="badge bg-secondary"><i class="fas fa-times me-1"></i>Disabled</span>'
|
||||
) +
|
||||
'</td>' +
|
||||
'</tr>' +
|
||||
'<tr>' +
|
||||
'<td><strong>Object Lock:</strong></td>' +
|
||||
'<td>' +
|
||||
(bucket.object_lock_enabled ?
|
||||
'<span class="badge bg-warning"><i class="fas fa-lock me-1"></i>Enabled</span>' +
|
||||
'<br><small class="text-muted">' + bucket.object_lock_mode + ' • ' + bucket.object_lock_duration + ' days</small>' :
|
||||
'<span class="badge bg-secondary"><i class="fas fa-unlock me-1"></i>Disabled</span>'
|
||||
) +
|
||||
'</td>' +
|
||||
'</tr>' +
|
||||
'</table>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<hr>' +
|
||||
'<div class="row">' +
|
||||
'<div class="col-12">' +
|
||||
'<h6><i class="fas fa-list me-2"></i>Objects (' + objects.length + ')</h6>' +
|
||||
objectsTable +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
document.getElementById('bucketDetailsContent').innerHTML = content;
|
||||
}
|
||||
|
||||
function exportBucketList() {
|
||||
// Simple CSV export
|
||||
const buckets = Array.from(document.querySelectorAll('#bucketsTable tbody tr')).map(row => {
|
||||
@@ -624,7 +903,7 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
|
||||
const csvContent = "data:text/csv;charset=utf-8," +
|
||||
"Name,Created,Objects,Size,Quota,Versioning,Object Lock\n" +
|
||||
buckets.map(b => `"${b.name}","${b.created}","${b.objects}","${b.size}","${b.quota}","${b.versioning}","${b.objectLock}"`).join("\n");
|
||||
buckets.map(b => '"' + b.name + '","' + b.created + '","' + b.objects + '","' + b.size + '","' + b.quota + '","' + b.versioning + '","' + b.objectLock + '"').join("\n");
|
||||
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement("a");
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user