* fix multipart etag * address comments * clean up * clean up * optimization * address comments * unquoted etag * dedup * upgrade * clean * etag * return quoted tag * quoted etag * debug * s3api: unify ETag retrieval and quoting across handlers Refactor newListEntry to take *S3ApiServer and use getObjectETag, and update setResponseHeaders to use the same logic. This ensures consistent ETags are returned for both listing and direct access. * s3api: implement ListObjects deduplication for versioned buckets Handle duplicate entries between the main path and the .versions directory by prioritizing the latest version when bucket versioning is enabled. * s3api: cleanup stale main file entries during versioned uploads Add explicit deletion of pre-existing "main" files when creating new versions in versioned buckets. This prevents stale entries from appearing in bucket listings and ensures consistency. * s3api: fix cleanup code placement in versioned uploads Correct the placement of rm calls in completeMultipartUpload and putVersionedObject to ensure stale main files are properly deleted during versioned uploads. * s3api: improve getObjectETag fallback for empty ExtETagKey Ensure that when ExtETagKey exists but contains an empty value, the function falls through to MD5/chunk-based calculation instead of returning an empty string. * s3api: fix test files for new newListEntry signature Update test files to use the new newListEntry signature where the first parameter is *S3ApiServer. Created mockS3ApiServer to properly test owner display name lookup functionality. * s3api: use filer.ETag for consistent Md5 handling in getEtagFromEntry Change getEtagFromEntry fallback to use filer.ETag(entry) instead of filer.ETagChunks to ensure legacy entries with Attributes.Md5 are handled consistently with the rest of the codebase. * s3api: optimize list logic and fix conditional header logging - Hoist bucket versioning check out of per-entry callback to avoid repeated getVersioningState calls - Extract appendOrDedup helper function to eliminate duplicate dedup/append logic across multiple code paths - Change If-Match mismatch logging from glog.Errorf to glog.V(3).Infof and remove DEBUG prefix for consistency * s3api: fix test mock to properly initialize IAM accounts Fixed nil pointer dereference in TestNewListEntryOwnerDisplayName by directly initializing the IdentityAccessManagement.accounts map in the test setup. This ensures newListEntry can properly look up account display names without panicking. * cleanup * s3api: remove premature main file cleanup in versioned uploads Removed incorrect cleanup logic that was deleting main files during versioned uploads. This was causing test failures because it deleted objects that should have been preserved as null versions when versioning was first enabled. The deduplication logic in listing is sufficient to handle duplicate entries without deleting files during upload. * s3api: add empty-value guard to getEtagFromEntry Added the same empty-value guard used in getObjectETag to prevent returning quoted empty strings. When ExtETagKey exists but is empty, the function now falls through to filer.ETag calculation instead of returning "". * s3api: fix listing of directory key objects with matching prefix Revert prefix handling logic to use strings.TrimPrefix instead of checking HasPrefix with empty string result. This ensures that when a directory key object exactly matches the prefix (e.g. prefix="dir/", object="dir/"), it is correctly handled as a regular entry instead of being skipped or incorrectly processed as a common prefix. Also fixed missing variable definition. * s3api: refactor list inline dedup to use appendOrDedup helper Refactored the inline deduplication logic in listFilerEntries to use the shared appendOrDedup helper function. This ensures consistent behavior and reduces code duplication. * test: fix port allocation race in s3tables integration test Updated startMiniCluster to find all required ports simultaneously using findAvailablePorts instead of sequentially. This prevents race conditions where the OS reallocates a port that was just released, causing multiple services (e.g. Filer and Volume) to be assigned the same port and fail to start.
405 lines
20 KiB
Plaintext
405 lines
20 KiB
Plaintext
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
|
|
)
|
|
|
|
templ MaintenanceQueue(data *maintenance.MaintenanceQueueData) {
|
|
<div class="container-fluid">
|
|
<!-- Header -->
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<h2 class="mb-0">
|
|
<i class="fas fa-tasks me-2"></i>
|
|
Maintenance Queue
|
|
</h2>
|
|
<div class="btn-group">
|
|
<button type="button" class="btn btn-primary" onclick="triggerScan()">
|
|
<i class="fas fa-search me-1"></i>
|
|
Trigger Scan
|
|
</button>
|
|
<button type="button" class="btn btn-secondary" onclick="refreshPage()">
|
|
<i class="fas fa-sync-alt me-1"></i>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Statistics Cards -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-3">
|
|
<div class="card border-primary">
|
|
<div class="card-body text-center">
|
|
<i class="fas fa-clock fa-2x text-primary mb-2"></i>
|
|
<h4 class="mb-1">{fmt.Sprintf("%d", data.Stats.PendingTasks)}</h4>
|
|
<p class="text-muted mb-0">Pending Tasks</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card border-warning">
|
|
<div class="card-body text-center">
|
|
<i class="fas fa-running fa-2x text-warning mb-2"></i>
|
|
<h4 class="mb-1">{fmt.Sprintf("%d", data.Stats.RunningTasks)}</h4>
|
|
<p class="text-muted mb-0">Running Tasks</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card border-success">
|
|
<div class="card-body text-center">
|
|
<i class="fas fa-check-circle fa-2x text-success mb-2"></i>
|
|
<h4 class="mb-1">{fmt.Sprintf("%d", data.Stats.CompletedToday)}</h4>
|
|
<p class="text-muted mb-0">Completed Today</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card border-danger">
|
|
<div class="card-body text-center">
|
|
<i class="fas fa-exclamation-triangle fa-2x text-danger mb-2"></i>
|
|
<h4 class="mb-1">{fmt.Sprintf("%d", data.Stats.FailedToday)}</h4>
|
|
<p class="text-muted mb-0">Failed Today</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Completed Tasks -->
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header bg-success text-white">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-check-circle me-2"></i>
|
|
Completed Tasks
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
if data.Stats.CompletedToday == 0 && data.Stats.FailedToday == 0 {
|
|
<div class="text-center text-muted py-4">
|
|
<i class="fas fa-check-circle fa-3x mb-3"></i>
|
|
<p>No completed maintenance tasks today</p>
|
|
<small>Completed tasks will appear here after workers finish processing them</small>
|
|
</div>
|
|
} else {
|
|
<div class="table-responsive">
|
|
<table class="table table-hover">
|
|
<thead>
|
|
<tr>
|
|
<th>Type</th>
|
|
<th>Status</th>
|
|
<th>Volume</th>
|
|
<th>Worker</th>
|
|
<th>Duration</th>
|
|
<th>Completed</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
for _, task := range data.Tasks {
|
|
if string(task.Status) == "completed" || string(task.Status) == "failed" || string(task.Status) == "cancelled" {
|
|
if string(task.Status) == "failed" {
|
|
<tr class="table-danger clickable-row" data-task-id={task.ID} onclick="navigateToTask(this)" style="cursor: pointer;">
|
|
<td>
|
|
@TaskTypeIcon(task.Type)
|
|
{string(task.Type)}
|
|
</td>
|
|
<td>@StatusBadge(task.Status)</td>
|
|
<td>{fmt.Sprintf("%d", task.VolumeID)}</td>
|
|
<td>
|
|
if task.WorkerID != "" {
|
|
<small>{task.WorkerID}</small>
|
|
} else {
|
|
<span class="text-muted">-</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
if task.StartedAt != nil && task.CompletedAt != nil {
|
|
{formatDuration(task.CompletedAt.Sub(*task.StartedAt))}
|
|
} else {
|
|
<span class="text-muted">-</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
if task.CompletedAt != nil {
|
|
{task.CompletedAt.Format("2006-01-02 15:04")}
|
|
} else {
|
|
<span class="text-muted">-</span>
|
|
}
|
|
</td>
|
|
</tr>
|
|
} else {
|
|
<tr class="clickable-row" data-task-id={task.ID} onclick="navigateToTask(this)" style="cursor: pointer;">
|
|
<td>
|
|
@TaskTypeIcon(task.Type)
|
|
{string(task.Type)}
|
|
</td>
|
|
<td>@StatusBadge(task.Status)</td>
|
|
<td>{fmt.Sprintf("%d", task.VolumeID)}</td>
|
|
<td>
|
|
if task.WorkerID != "" {
|
|
<small>{task.WorkerID}</small>
|
|
} else {
|
|
<span class="text-muted">-</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
if task.StartedAt != nil && task.CompletedAt != nil {
|
|
{formatDuration(task.CompletedAt.Sub(*task.StartedAt))}
|
|
} else {
|
|
<span class="text-muted">-</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
if task.CompletedAt != nil {
|
|
{task.CompletedAt.Format("2006-01-02 15:04")}
|
|
} else {
|
|
<span class="text-muted">-</span>
|
|
}
|
|
</td>
|
|
</tr>
|
|
}
|
|
}
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pending Tasks -->
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header bg-primary text-white">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-clock me-2"></i>
|
|
Pending Tasks
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
if data.Stats.PendingTasks == 0 {
|
|
<div class="text-center text-muted py-4">
|
|
<i class="fas fa-clipboard-list fa-3x mb-3"></i>
|
|
<p>No pending maintenance tasks</p>
|
|
<small>Pending tasks will appear here when the system detects maintenance needs</small>
|
|
</div>
|
|
} else {
|
|
<div class="table-responsive">
|
|
<table class="table table-hover">
|
|
<thead>
|
|
<tr>
|
|
<th>Type</th>
|
|
<th>Priority</th>
|
|
<th>Volume</th>
|
|
<th>Server</th>
|
|
<th>Reason</th>
|
|
<th>Created</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
for _, task := range data.Tasks {
|
|
if string(task.Status) == "pending" {
|
|
<tr class="clickable-row" data-task-id={task.ID} onclick="navigateToTask(this)" style="cursor: pointer;">
|
|
<td>
|
|
@TaskTypeIcon(task.Type)
|
|
{string(task.Type)}
|
|
</td>
|
|
<td>@PriorityBadge(task.Priority)</td>
|
|
<td>{fmt.Sprintf("%d", task.VolumeID)}</td>
|
|
<td><small>{task.Server}</small></td>
|
|
<td><small>{task.Reason}</small></td>
|
|
<td>{task.CreatedAt.Format("2006-01-02 15:04")}</td>
|
|
</tr>
|
|
}
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Active Tasks -->
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header bg-warning text-dark">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-running me-2"></i>
|
|
Active Tasks
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
if data.Stats.RunningTasks == 0 {
|
|
<div class="text-center text-muted py-4">
|
|
<i class="fas fa-tasks fa-3x mb-3"></i>
|
|
<p>No active maintenance tasks</p>
|
|
<small>Active tasks will appear here when workers start processing them</small>
|
|
</div>
|
|
} else {
|
|
<div class="table-responsive">
|
|
<table class="table table-hover">
|
|
<thead>
|
|
<tr>
|
|
<th>Type</th>
|
|
<th>Status</th>
|
|
<th>Progress</th>
|
|
<th>Volume</th>
|
|
<th>Worker</th>
|
|
<th>Started</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
for _, task := range data.Tasks {
|
|
if string(task.Status) == "assigned" || string(task.Status) == "in_progress" {
|
|
<tr class="clickable-row" data-task-id={task.ID} onclick="navigateToTask(this)" style="cursor: pointer;">
|
|
<td>
|
|
@TaskTypeIcon(task.Type)
|
|
{string(task.Type)}
|
|
</td>
|
|
<td>@StatusBadge(task.Status)</td>
|
|
<td>@ProgressBar(task.Progress, task.Status)</td>
|
|
<td>{fmt.Sprintf("%d", task.VolumeID)}</td>
|
|
<td>
|
|
if task.WorkerID != "" {
|
|
<small>{task.WorkerID}</small>
|
|
} else {
|
|
<span class="text-muted">-</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
if task.StartedAt != nil {
|
|
{task.StartedAt.Format("2006-01-02 15:04")}
|
|
} else {
|
|
<span class="text-muted">-</span>
|
|
}
|
|
</td>
|
|
</tr>
|
|
}
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
window.triggerScan = function() {
|
|
console.log("triggerScan called");
|
|
fetch('/api/maintenance/scan', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
}
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
showToast('Success', 'Maintenance scan triggered successfully', 'success');
|
|
setTimeout(() => window.location.reload(), 2000);
|
|
} else {
|
|
showToast('Error', 'Failed to trigger scan: ' + (data.error || 'Unknown error'), 'danger');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showToast('Error', 'Error: ' + error.message, 'danger');
|
|
});
|
|
};
|
|
|
|
window.refreshPage = function() {
|
|
console.log("refreshPage called");
|
|
window.location.reload();
|
|
};
|
|
|
|
window.navigateToTask = function(element) {
|
|
const taskId = element.getAttribute('data-task-id');
|
|
if (taskId) {
|
|
window.location.href = '/maintenance/tasks/' + taskId;
|
|
}
|
|
};
|
|
</script>
|
|
}
|
|
|
|
// Helper components
|
|
templ TaskTypeIcon(taskType maintenance.MaintenanceTaskType) {
|
|
<i class={maintenance.GetTaskIcon(taskType) + " me-1"}></i>
|
|
}
|
|
|
|
templ PriorityBadge(priority maintenance.MaintenanceTaskPriority) {
|
|
switch priority {
|
|
case maintenance.PriorityCritical:
|
|
<span class="badge bg-danger">Critical</span>
|
|
case maintenance.PriorityHigh:
|
|
<span class="badge bg-warning">High</span>
|
|
case maintenance.PriorityNormal:
|
|
<span class="badge bg-primary">Normal</span>
|
|
case maintenance.PriorityLow:
|
|
<span class="badge bg-secondary">Low</span>
|
|
default:
|
|
<span class="badge bg-light text-dark">Unknown</span>
|
|
}
|
|
}
|
|
|
|
templ StatusBadge(status maintenance.MaintenanceTaskStatus) {
|
|
switch status {
|
|
case maintenance.TaskStatusPending:
|
|
<span class="badge bg-secondary">Pending</span>
|
|
case maintenance.TaskStatusAssigned:
|
|
<span class="badge bg-info">Assigned</span>
|
|
case maintenance.TaskStatusInProgress:
|
|
<span class="badge bg-warning">Running</span>
|
|
case maintenance.TaskStatusCompleted:
|
|
<span class="badge bg-success">Completed</span>
|
|
case maintenance.TaskStatusFailed:
|
|
<span class="badge bg-danger">Failed</span>
|
|
case maintenance.TaskStatusCancelled:
|
|
<span class="badge bg-light text-dark">Cancelled</span>
|
|
default:
|
|
<span class="badge bg-light text-dark">Unknown</span>
|
|
}
|
|
}
|
|
|
|
templ ProgressBar(progress float64, status maintenance.MaintenanceTaskStatus) {
|
|
if status == maintenance.TaskStatusInProgress || status == maintenance.TaskStatusAssigned {
|
|
<div class="progress" style="height: 8px; min-width: 100px;">
|
|
<div class="progress-bar" role="progressbar" style={fmt.Sprintf("width: %.1f%%", progress)}>
|
|
</div>
|
|
</div>
|
|
<small class="text-muted">{fmt.Sprintf("%.1f%%", progress)}</small>
|
|
} else if status == maintenance.TaskStatusCompleted {
|
|
<div class="progress" style="height: 8px; min-width: 100px;">
|
|
<div class="progress-bar bg-success" role="progressbar" style="width: 100%">
|
|
</div>
|
|
</div>
|
|
<small class="text-success">100%</small>
|
|
} else {
|
|
<span class="text-muted">-</span>
|
|
}
|
|
}
|
|
|
|
func formatDuration(d time.Duration) string {
|
|
if d < time.Minute {
|
|
return fmt.Sprintf("%.0fs", d.Seconds())
|
|
} else if d < time.Hour {
|
|
return fmt.Sprintf("%.1fm", d.Minutes())
|
|
} else {
|
|
return fmt.Sprintf("%.1fh", d.Hours())
|
|
}
|
|
}
|
|
|
|
|