Files
seaweedfs/weed/admin/view/app/cluster_collections.templ
T
Chris Lu 0cf62a921a admin: dashboard counts chunks, not files (#10598)
* admin: count each chunk once in the dashboard total

The dashboard summed file_count from every node's volume list, so a chunk
was counted once per replica and deleted chunks were never subtracted.
Reuse the collection aggregation, which dedupes replicas and EC shard
holders and nets out tombstones.

* admin: the dashboard card counts chunks, so name it that

Volumes store chunks, and a file is split into one or more of them, so
the 'Total Files' card always read far higher than the number of files in
the filer. Rename it to 'Total Chunks' and say so in the tooltip.

* admin: collections pages count chunks once and say so

The collections list and detail pages summed file_count straight off the
topology, so replicas multiplied the count, tombstones stayed in it, and
the detail page ignored EC volumes entirely. Take the numbers from the
shared collection aggregation and label them chunks.

* admin: dedupe replica chunk counts per volume instead of dividing

Dividing each replica's live count by the copy count truncated a chunk
per odd-sized volume, and reported half the count while a volume's
second replica had not checked in yet. Replicas mirror each other's
needles and deletes, so keep the fullest report per volume id.

* admin: fix the collections CSV export column mapping

The exporter read chunks from the EC-volume cell and shifted size and
disk types with it. Read every column the table actually has.
2026-08-06 11:22:06 -07:00

450 lines
22 KiB
Templ

package app
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
templ ClusterCollections(data dash.ClusterCollectionsData) {
<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-layer-group me-2"></i>Cluster Collections
</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="exportCollections()">
<i class="fas fa-download me-1"></i>Export
</button>
</div>
</div>
</div>
<div id="collections-content">
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-xl-2 col-lg-3 col-md-4 col-sm-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 Collections
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{fmt.Sprintf("%d", data.TotalCollections)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-layer-group fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-2 col-lg-3 col-md-4 col-sm-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">
Regular Volumes
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{fmt.Sprintf("%d", data.TotalVolumes)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-database fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-2 col-lg-3 col-md-4 col-sm-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">
EC Volumes
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{fmt.Sprintf("%d", data.TotalEcVolumes)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-th-large fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-2 col-lg-3 col-md-4 col-sm-6 mb-4">
<div class="card border-left-warning 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-warning text-uppercase mb-1" title="Chunks stored in volumes. A file is split into one or more chunks, so this is not the number of files.">
Total Chunks
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{fmt.Sprintf("%d", data.TotalChunks)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-cubes fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-2 col-lg-3 col-md-4 col-sm-6 mb-4">
<div class="card border-left-secondary 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-secondary text-uppercase mb-1">
Total Storage Size (Logical)
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{formatBytes(data.TotalSize)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-hdd fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Collections Table -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-layer-group me-2"></i>Collection Details
</h6>
</div>
<div class="card-body">
if len(data.Collections) > 0 {
<div class="table-responsive">
<table class="table table-hover" id="collectionsTable">
<thead>
<tr>
<th>Collection Name</th>
<th>Regular Volumes</th>
<th>EC Volumes</th>
<th>Chunks</th>
<th>Size (Logical)</th>
<th>Disk Types</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
for _, collection := range data.Collections {
<tr>
<td>
<a href={ dash.PUrl(ctx, fmt.Sprintf("/storage/collections/%s", collection.Name)) } class="text-decoration-none">
<strong>{collection.Name}</strong>
</a>
</td>
<td>
<a href={ dash.PUrl(ctx, fmt.Sprintf("/storage/volumes?collection=%s", collection.Name)) } class="text-decoration-none">
<div class="d-flex align-items-center">
<i class="fas fa-database me-2 text-muted"></i>
if collection.VolumeCount > 0 {
{fmt.Sprintf("%d", collection.VolumeCount)}
} else {
<span class="text-muted">0</span>
}
</div>
</a>
</td>
<td>
<a href={ dash.PUrl(ctx, fmt.Sprintf("/storage/ec-shards?collection=%s", collection.Name)) } class="text-decoration-none">
<div class="d-flex align-items-center">
<i class="fas fa-th-large me-2 text-muted"></i>
if collection.EcVolumeCount > 0 {
{fmt.Sprintf("%d", collection.EcVolumeCount)}
} else {
<span class="text-muted">0</span>
}
</div>
</a>
</td>
<td>
<div class="d-flex align-items-center">
<i class="fas fa-cubes me-2 text-muted"></i>
{fmt.Sprintf("%d", collection.ChunkCount)}
</div>
</td>
<td>
<div class="d-flex align-items-center">
<i class="fas fa-hdd me-2 text-muted"></i>
{formatBytes(collection.TotalSize)}
</div>
</td>
<td>
for i, diskType := range collection.DiskTypes {
if i > 0 {
<span class="me-1"></span>
}
<span class={fmt.Sprintf("badge bg-%s me-1", getDiskTypeColor(diskType))}>{diskType}</span>
}
if len(collection.DiskTypes) == 0 {
<span class="text-muted">Unknown</span>
}
</td>
<td>
<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-ec-volume-count={fmt.Sprintf("%d", collection.EcVolumeCount)}
data-chunk-count={fmt.Sprintf("%d", collection.ChunkCount)}
data-total-size={fmt.Sprintf("%d", collection.TotalSize)}
data-disk-types={formatDiskTypes(collection.DiskTypes)}>
<i class="fas fa-eye"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
} else {
<div class="text-center py-5">
<i class="fas fa-layer-group fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No Collections Found</h5>
<p class="text-muted">No collections are currently configured in the cluster.</p>
</div>
}
</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: {data.LastUpdated.Format("2006-01-02 15:04:05")}
</small>
</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')),
ecVolumeCount: parseInt(button.getAttribute('data-ec-volume-count')),
chunkCount: parseInt(button.getAttribute('data-chunk-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>Regular 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>EC Volumes:</strong></td><td>' +
'<div class="d-flex align-items-center">' +
'<i class="fas fa-th-large me-2 text-muted"></i>' +
'<span>' + collection.ecVolumeCount.toLocaleString() + '</span>' +
'</div>' +
'</td></tr>' +
'<tr><td><strong>Total Chunks:</strong></td><td>' +
'<div class="d-flex align-items-center">' +
'<i class="fas fa-cubes me-2 text-muted"></i>' +
'<span>' + collection.chunkCount.toLocaleString() + '</span>' +
'</div>' +
'</td></tr>' +
'<tr><td><strong>Total Size (Logical):</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="' + (window.__BASE_PATH__ || '') + '/storage/volumes?collection=' + encodeURIComponent(collection.name) + '" class="btn btn-outline-primary">' +
'<i class="fas fa-database me-1"></i>View Volumes' +
'</a>' +
'<a href="' + (window.__BASE_PATH__ || '') + '/storage/ec-shards?collection=' + encodeURIComponent(collection.name) + '" class="btn btn-outline-secondary">' +
'<i class="fas fa-th-large me-1"></i>View EC Volumes' +
'</a>' +
'<a href="' + (window.__BASE_PATH__ || '') + '/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>' +
'<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(),
ecVolumes: cells[2].textContent.trim(),
chunks: cells[3].textContent.trim(),
size: cells[4].textContent.trim(),
diskTypes: cells[5].textContent.trim()
};
}
return null;
}).filter(row => row !== null);
const csvContent = "data:text/csv;charset=utf-8," +
"Collection Name,Volumes,EC Volumes,Chunks,Size,Disk Types\n" +
rows.map(r => '"' + r.name + '","' + r.volumes + '","' + r.ecVolumes + '","' + r.chunks + '","' + 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 {
switch diskType {
case "ssd":
return "primary"
case "hdd", "":
return "secondary"
default:
return "info"
}
}
func formatDiskTypes(diskTypes []string) string {
if len(diskTypes) == 0 {
return "Unknown"
}
if len(diskTypes) == 1 {
return diskTypes[0]
}
// For multiple disk types, join with comma
result := ""
for i, diskType := range diskTypes {
if i > 0 {
result += ", "
}
result += diskType
}
return result
}