| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726 |
- package app
- import (
- "fmt"
- "strings"
- "github.com/seaweedfs/seaweedfs/weed/admin/dash"
- )
- templ ClusterVolumes(data dash.ClusterVolumesData) {
- <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
- <div>
- <h1 class="h2">
- <i class="fas fa-database me-2"></i>Cluster Volumes
- </h1>
- if data.FilterCollection != "" {
- <div class="d-flex align-items-center mt-2">
- <span class="badge bg-info me-2">
- <i class="fas fa-filter me-1"></i>Collection: {data.FilterCollection}
- </span>
- <a href="/cluster/volumes" class="btn btn-sm btn-outline-secondary">
- <i class="fas fa-times me-1"></i>Clear Filter
- </a>
- </div>
- }
- </div>
- <div class="btn-toolbar mb-2 mb-md-0">
- <div class="btn-group me-2">
- <select class="form-select form-select-sm me-2" id="pageSizeSelect" onchange="changePageSize()" style="width: auto;">
- <option value="50" if data.PageSize == 50 { selected="selected" }>50 per page</option>
- <option value="100" if data.PageSize == 100 { selected="selected" }>100 per page</option>
- <option value="200" if data.PageSize == 200 { selected="selected" }>200 per page</option>
- <option value="500" if data.PageSize == 500 { selected="selected" }>500 per page</option>
- </select>
- <button type="button" class="btn btn-sm btn-outline-primary" onclick="exportVolumes()">
- <i class="fas fa-download me-1"></i>Export
- </button>
- </div>
- </div>
- </div>
- <div id="volumes-content">
- <!-- Summary Cards -->
- <div class="row mb-4">
- <div class="col-xl-2 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 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-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">
- if data.CollectionCount == 1 {
- Collection
- } else {
- Collections
- }
- </div>
- <div class="h5 mb-0 font-weight-bold text-gray-800">
- if data.CollectionCount == 1 {
- {data.SingleCollection}
- } else {
- {fmt.Sprintf("%d", data.CollectionCount)}
- }
- </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-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">
- if data.DataCenterCount == 1 {
- Data Center
- } else {
- Data Centers
- }
- </div>
- <div class="h5 mb-0 font-weight-bold text-gray-800">
- if data.DataCenterCount == 1 {
- {data.SingleDataCenter}
- } else {
- {fmt.Sprintf("%d", data.DataCenterCount)}
- }
- </div>
- </div>
- <div class="col-auto">
- <i class="fas fa-building fa-2x text-gray-300"></i>
- </div>
- </div>
- </div>
- </div>
- </div>
- <div class="col-xl-2 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">
- if data.RackCount == 1 {
- Rack
- } else {
- Racks
- }
- </div>
- <div class="h5 mb-0 font-weight-bold text-gray-800">
- if data.RackCount == 1 {
- {data.SingleRack}
- } else {
- {fmt.Sprintf("%d", data.RackCount)}
- }
- </div>
- </div>
- <div class="col-auto">
- <i class="fas fa-server fa-2x text-gray-300"></i>
- </div>
- </div>
- </div>
- </div>
- </div>
- <div class="col-xl-2 col-md-4 col-sm-6 mb-4">
- <div class="card border-left-dark 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-dark text-uppercase mb-1">
- if data.DiskTypeCount == 1 {
- Disk Type
- } else {
- Disk Types
- }
- </div>
- <div class="h5 mb-0 font-weight-bold text-gray-800">
- if data.DiskTypeCount == 1 {
- {data.SingleDiskType}
- } else {
- {strings.Join(data.AllDiskTypes, ", ")}
- }
- </div>
- </div>
- <div class="col-auto">
- <i class="fas fa-hdd fa-2x text-gray-300"></i>
- </div>
- </div>
- </div>
- </div>
- </div>
- <div class="col-xl-2 col-md-4 col-sm-6 mb-4">
- <div class="card border-left-purple 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-purple text-uppercase mb-1">
- if data.VersionCount == 1 {
- Version
- } else {
- Versions
- }
- </div>
- <div class="h5 mb-0 font-weight-bold text-gray-800">
- if data.VersionCount == 1 {
- {data.SingleVersion}
- } else {
- {strings.Join(data.AllVersions, ", ")}
- }
- </div>
- </div>
- <div class="col-auto">
- <i class="fas fa-code-branch fa-2x text-gray-300"></i>
- </div>
- </div>
- </div>
- </div>
- </div>
- <div class="col-xl-2 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">
- Total Size
- </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-chart-area fa-2x text-gray-300"></i>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- Volumes 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-database me-2"></i>Volume Details
- </h6>
- </div>
- <div class="card-body">
- if len(data.Volumes) > 0 {
- <div class="table-responsive">
- <table class="table table-hover" id="volumesTable">
- <thead>
- <tr>
- <th>
- <a href="#" onclick="sortTable('id')" class="text-decoration-none text-dark">
- Volume ID
- @getSortIcon("id", data.SortBy, data.SortOrder)
- </a>
- </th>
- <th>
- <a href="#" onclick="sortTable('server')" class="text-decoration-none text-dark">
- Server
- @getSortIcon("server", data.SortBy, data.SortOrder)
- </a>
- </th>
- if data.ShowDataCenterColumn {
- <th>
- <a href="#" onclick="sortTable('datacenter')" class="text-decoration-none text-dark">
- Data Center
- @getSortIcon("datacenter", data.SortBy, data.SortOrder)
- </a>
- </th>
- }
- if data.ShowRackColumn {
- <th>
- <a href="#" onclick="sortTable('rack')" class="text-decoration-none text-dark">
- Rack
- @getSortIcon("rack", data.SortBy, data.SortOrder)
- </a>
- </th>
- }
- if data.ShowCollectionColumn {
- <th>
- <a href="#" onclick="sortTable('collection')" class="text-decoration-none text-dark">
- Collection
- @getSortIcon("collection", data.SortBy, data.SortOrder)
- </a>
- </th>
- }
- <th>
- <a href="#" onclick="sortTable('size')" class="text-decoration-none text-dark">
- Size
- @getSortIcon("size", data.SortBy, data.SortOrder)
- </a>
- </th>
- <th>Volume Utilization</th>
- <th>
- <a href="#" onclick="sortTable('filecount')" class="text-decoration-none text-dark">
- File Count
- @getSortIcon("filecount", data.SortBy, data.SortOrder)
- </a>
- </th>
- <th>
- <a href="#" onclick="sortTable('replication')" class="text-decoration-none text-dark">
- Replication
- @getSortIcon("replication", data.SortBy, data.SortOrder)
- </a>
- </th>
- if data.ShowDiskTypeColumn {
- <th>
- <a href="#" onclick="sortTable('disktype')" class="text-decoration-none text-dark">
- Disk Type
- @getSortIcon("disktype", data.SortBy, data.SortOrder)
- </a>
- </th>
- }
- if data.ShowVersionColumn {
- <th>
- <a href="#" onclick="sortTable('version')" class="text-decoration-none text-dark">
- Version
- @getSortIcon("version", data.SortBy, data.SortOrder)
- </a>
- </th>
- }
- <th>Actions</th>
- </tr>
- </thead>
- <tbody>
- for _, volume := range data.Volumes {
- <tr>
- <td>
- <code class="volume-id-link" style="cursor: pointer; text-decoration: underline; color: #0d6efd;"
- data-volume-id={fmt.Sprintf("%d", volume.Id)}
- title="Click to view volume details">
- {fmt.Sprintf("%d", volume.Id)}
- </code>
- </td>
- <td>
- <a href={templ.SafeURL(fmt.Sprintf("http://%s/ui/index.html", volume.Server))} target="_blank" class="text-decoration-none">
- {volume.Server}
- <i class="fas fa-external-link-alt ms-1 text-muted"></i>
- </a>
- </td>
- if data.ShowDataCenterColumn {
- <td>
- <span class="badge bg-light text-dark">{volume.DataCenter}</span>
- </td>
- }
- if data.ShowRackColumn {
- <td>
- <span class="badge bg-light text-dark">{volume.Rack}</span>
- </td>
- }
- if data.ShowCollectionColumn {
- <td>
- if volume.Collection == "" {
- <a href={templ.SafeURL("/cluster/volumes?collection=default")} class="text-decoration-none">
- <span class="badge bg-secondary">default</span>
- </a>
- } else {
- <a href={templ.SafeURL(fmt.Sprintf("/cluster/volumes?collection=%s", volume.Collection))} class="text-decoration-none">
- <span class="badge bg-secondary">{volume.Collection}</span>
- </a>
- }
- </td>
- }
- <td>{formatBytes(int64(volume.Size))}</td>
- <td>
- <div class="d-flex align-items-center">
- <div class="progress me-2" style="width: 80px; height: 16px; background-color: #e9ecef;">
- <!-- Active data (green) -->
- <div class="progress-bar bg-success" role="progressbar"
- style={fmt.Sprintf("width: %.1f%%",
- func() float64 {
- if volume.Size > 0 {
- activePct := float64(volume.Size - volume.DeletedByteCount) / float64(volume.Size) * 100
- if data.VolumeSizeLimit > 0 {
- return activePct * float64(volume.Size) / float64(data.VolumeSizeLimit) * 100
- }
- return activePct
- }
- return 0
- }())}
- title={fmt.Sprintf("Active: %s", formatBytes(int64(volume.Size - volume.DeletedByteCount)))}>
- </div>
- <!-- Garbage data (red) -->
- <div class="progress-bar bg-danger" role="progressbar"
- style={fmt.Sprintf("width: %.1f%%",
- func() float64 {
- if volume.Size > 0 && volume.DeletedByteCount > 0 {
- garbagePct := float64(volume.DeletedByteCount) / float64(volume.Size) * 100
- if data.VolumeSizeLimit > 0 {
- return garbagePct * float64(volume.Size) / float64(data.VolumeSizeLimit) * 100
- }
- return garbagePct
- }
- return 0
- }())}
- title={fmt.Sprintf("Garbage: %s", formatBytes(int64(volume.DeletedByteCount)))}>
- </div>
- </div>
- <small class="text-muted">
- {func() string {
- if data.VolumeSizeLimit > 0 {
- return fmt.Sprintf("%.0f%%", float64(volume.Size)/float64(data.VolumeSizeLimit)*100)
- }
- return "N/A"
- }()}
- </small>
- </div>
- </td>
- <td>{fmt.Sprintf("%d", volume.FileCount)}</td>
- <td>
- <span class="badge bg-info">{fmt.Sprintf("%03d", volume.ReplicaPlacement)}</span>
- </td>
- if data.ShowDiskTypeColumn {
- <td>
- <span class="badge bg-primary">{volume.DiskType}</span>
- </td>
- }
- if data.ShowVersionColumn {
- <td>
- <span class="badge bg-dark">{fmt.Sprintf("v%d", volume.Version)}</span>
- </td>
- }
- <td>
- <div class="btn-group btn-group-sm">
- <button type="button" class="btn btn-outline-primary btn-sm view-details-btn"
- title="View Details" data-volume-id={fmt.Sprintf("%d", volume.Id)}>
- <i class="fas fa-eye"></i>
- </button>
- <button type="button" class="btn btn-outline-secondary btn-sm vacuum-btn"
- title="Vacuum"
- data-volume-id={fmt.Sprintf("%d", volume.Id)}
- data-server={volume.Server}>
- <i class="fas fa-compress-alt"></i>
- </button>
- </div>
- </td>
- </tr>
- }
- </tbody>
- </table>
- </div>
-
- <!-- Volume Summary -->
- <div class="d-flex justify-content-between align-items-center mt-3">
- <div>
- <small class="text-muted">
- Showing {fmt.Sprintf("%d", (data.CurrentPage-1)*data.PageSize + 1)} to {fmt.Sprintf("%d", minInt(data.CurrentPage*data.PageSize, data.TotalVolumes))} of {fmt.Sprintf("%d", data.TotalVolumes)} volumes
- </small>
- </div>
- if data.TotalPages > 1 {
- <div>
- <small class="text-muted">
- Page {fmt.Sprintf("%d", data.CurrentPage)} of {fmt.Sprintf("%d", data.TotalPages)}
- </small>
- </div>
- }
- </div>
-
- <!-- Pagination Controls -->
- if data.TotalPages > 1 {
- <div class="d-flex justify-content-center mt-3">
- <nav aria-label="Volumes pagination">
- <ul class="pagination pagination-sm mb-0">
- <!-- Previous Button -->
- if data.CurrentPage > 1 {
- <li class="page-item">
- <a class="page-link pagination-link" href="#" data-page={fmt.Sprintf("%d", data.CurrentPage-1)}>
- <i class="fas fa-chevron-left"></i>
- </a>
- </li>
- } else {
- <li class="page-item disabled">
- <span class="page-link">
- <i class="fas fa-chevron-left"></i>
- </span>
- </li>
- }
-
- <!-- Page Numbers -->
- for i := maxInt(1, data.CurrentPage-2); i <= minInt(data.TotalPages, data.CurrentPage+2); i++ {
- if i == data.CurrentPage {
- <li class="page-item active">
- <span class="page-link">{fmt.Sprintf("%d", i)}</span>
- </li>
- } else {
- <li class="page-item">
- <a class="page-link pagination-link" href="#" data-page={fmt.Sprintf("%d", i)}>{fmt.Sprintf("%d", i)}</a>
- </li>
- }
- }
-
- <!-- Next Button -->
- if data.CurrentPage < data.TotalPages {
- <li class="page-item">
- <a class="page-link pagination-link" href="#" data-page={fmt.Sprintf("%d", data.CurrentPage+1)}>
- <i class="fas fa-chevron-right"></i>
- </a>
- </li>
- } else {
- <li class="page-item disabled">
- <span class="page-link">
- <i class="fas fa-chevron-right"></i>
- </span>
- </li>
- }
- </ul>
- </nav>
- </div>
- }
- } else {
- <div class="text-center py-5">
- <i class="fas fa-database fa-3x text-muted mb-3"></i>
- <h5 class="text-muted">No Volumes Found</h5>
- <p class="text-muted">No volumes are currently available 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 pagination and sorting -->
- <script>
- // Initialize pagination links when page loads
- document.addEventListener('DOMContentLoaded', function() {
- // Add click handlers to pagination links
- document.querySelectorAll('.pagination-link').forEach(link => {
- link.addEventListener('click', function(e) {
- e.preventDefault();
- const page = this.getAttribute('data-page');
- goToPage(page);
- });
- });
-
- // Add click handlers to view details buttons
- document.querySelectorAll('.view-details-btn').forEach(button => {
- button.addEventListener('click', function(e) {
- e.preventDefault();
- const volumeId = this.getAttribute('data-volume-id');
- viewVolumeDetails(volumeId);
- });
- });
- // Add click handlers to volume ID links
- document.querySelectorAll('.volume-id-link').forEach(link => {
- link.addEventListener('click', function(e) {
- e.preventDefault();
- const volumeId = this.getAttribute('data-volume-id');
- viewVolumeDetails(volumeId);
- });
- });
- // Add click handlers to vacuum buttons
- document.querySelectorAll('.vacuum-btn').forEach(button => {
- button.addEventListener('click', function(e) {
- e.preventDefault();
- const volumeId = this.getAttribute('data-volume-id');
- const server = this.getAttribute('data-server');
- performVacuum(volumeId, server, this);
- });
- });
- });
-
- function goToPage(page) {
- const url = new URL(window.location);
- url.searchParams.set('page', page);
- window.location.href = url.toString();
- }
-
- function changePageSize() {
- const pageSize = document.getElementById('pageSizeSelect').value;
- const url = new URL(window.location);
- url.searchParams.set('pageSize', pageSize);
- url.searchParams.set('page', '1'); // Reset to first page
- window.location.href = url.toString();
- }
-
- function sortTable(column) {
- const url = new URL(window.location);
- const currentSort = url.searchParams.get('sortBy');
- const currentOrder = url.searchParams.get('sortOrder') || 'asc';
-
- let newOrder = 'asc';
- if (currentSort === column && currentOrder === 'asc') {
- newOrder = 'desc';
- }
-
- url.searchParams.set('sortBy', column);
- url.searchParams.set('sortOrder', newOrder);
- url.searchParams.set('page', '1'); // Reset to first page
- window.location.href = url.toString();
- }
-
- function exportVolumes() {
- // TODO: Implement volume export functionality
- alert('Export functionality to be implemented');
- }
-
- function viewVolumeDetails(volumeId) {
- // Get the server from the current row - works for both buttons and volume ID links
- const clickedElement = event.target;
- const row = clickedElement.closest('tr');
- const serverCell = row.querySelector('td:nth-child(2) a');
- const server = serverCell ? serverCell.textContent.trim() : 'unknown';
-
- window.location.href = `/cluster/volumes/${volumeId}/${encodeURIComponent(server)}`;
- }
- function performVacuum(volumeId, server, button) {
- // Disable button and show loading state
- const originalHTML = button.innerHTML;
- button.disabled = true;
- button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
- // Send vacuum request
- fetch(`/api/volumes/${volumeId}/${encodeURIComponent(server)}/vacuum`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- }
- })
- .then(response => response.json())
- .then(data => {
- if (data.error) {
- showMessage(data.error, 'error');
- } else {
- showMessage(data.message || 'Volume vacuum started successfully', 'success');
- // Optionally refresh the page after a delay to show updated vacuum status
- setTimeout(() => {
- window.location.reload();
- }, 2000);
- }
- })
- .catch(error => {
- console.error('Error:', error);
- showMessage('Failed to start vacuum operation', 'error');
- })
- .finally(() => {
- // Re-enable button
- button.disabled = false;
- button.innerHTML = originalHTML;
- });
- }
- function showMessage(message, type) {
- // Create toast notification
- const toast = document.createElement('div');
- toast.className = `alert alert-${type === 'error' ? 'danger' : 'success'} alert-dismissible fade show position-fixed`;
- toast.style.top = '20px';
- toast.style.right = '20px';
- toast.style.zIndex = '9999';
- toast.style.minWidth = '300px';
-
- toast.innerHTML = `
- ${message}
- <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
- `;
-
- document.body.appendChild(toast);
-
- // Auto-remove after 5 seconds
- setTimeout(() => {
- if (toast.parentNode) {
- toast.parentNode.removeChild(toast);
- }
- }, 5000);
- }
- </script>
- }
- func countActiveVolumes(volumes []dash.VolumeWithTopology) int {
- // Since we removed status tracking, consider all volumes as active
- return len(volumes)
- }
- func countUniqueDataCenters(volumes []dash.VolumeWithTopology) int {
- dcMap := make(map[string]bool)
- for _, volume := range volumes {
- dcMap[volume.DataCenter] = true
- }
- return len(dcMap)
- }
- func countUniqueRacks(volumes []dash.VolumeWithTopology) int {
- rackMap := make(map[string]bool)
- for _, volume := range volumes {
- if volume.Rack != "" {
- rackMap[volume.Rack] = true
- }
- }
- return len(rackMap)
- }
- func countUniqueDiskTypes(volumes []dash.VolumeWithTopology) int {
- diskTypeMap := make(map[string]bool)
- for _, volume := range volumes {
- diskType := volume.DiskType
- if diskType == "" {
- diskType = "hdd"
- }
- diskTypeMap[diskType] = true
- }
- return len(diskTypeMap)
- }
- templ getSortIcon(column, currentSort, currentOrder string) {
- if column != currentSort {
- <i class="fas fa-sort text-muted ms-1"></i>
- } else if currentOrder == "asc" {
- <i class="fas fa-sort-up text-primary ms-1"></i>
- } else {
- <i class="fas fa-sort-down text-primary ms-1"></i>
- }
- }
- func minInt(a, b int) int {
- if a < b {
- return a
- }
- return b
- }
- func maxInt(a, b int) int {
- if a > b {
- return a
- }
- return b
- }
|