You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

1118 lines
56 KiB

package app
import (
"fmt"
"sort"
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
)
// sortedKeys returns the sorted keys for a string map
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
templ TaskDetail(data *maintenance.TaskDetailData) {
<div class="container-fluid">
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item"><a href="/maintenance">Maintenance</a></li>
<li class="breadcrumb-item active" aria-current="page">Task Detail</li>
</ol>
</nav>
<h2 class="mb-0">
<i class="fas fa-tasks me-2"></i>
Task Detail: {data.Task.ID}
</h2>
</div>
<div class="btn-group">
<button type="button" class="btn btn-secondary" onclick="history.back()">
<i class="fas fa-arrow-left me-1"></i>
Back
</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>
<!-- Task Overview Card -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-info-circle me-2"></i>
Task Overview
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<dl class="row">
<dt class="col-sm-4">Task ID:</dt>
<dd class="col-sm-8"><code>{data.Task.ID}</code></dd>
<dt class="col-sm-4">Type:</dt>
<dd class="col-sm-8">
<span class="badge bg-info">{string(data.Task.Type)}</span>
</dd>
<dt class="col-sm-4">Status:</dt>
<dd class="col-sm-8">
if data.Task.Status == maintenance.TaskStatusPending {
<span class="badge bg-secondary">Pending</span>
} else if data.Task.Status == maintenance.TaskStatusAssigned {
<span class="badge bg-info">Assigned</span>
} else if data.Task.Status == maintenance.TaskStatusInProgress {
<span class="badge bg-warning">In Progress</span>
} else if data.Task.Status == maintenance.TaskStatusCompleted {
<span class="badge bg-success">Completed</span>
} else if data.Task.Status == maintenance.TaskStatusFailed {
<span class="badge bg-danger">Failed</span>
} else if data.Task.Status == maintenance.TaskStatusCancelled {
<span class="badge bg-dark">Cancelled</span>
}
</dd>
<dt class="col-sm-4">Priority:</dt>
<dd class="col-sm-8">
if data.Task.Priority == maintenance.PriorityHigh {
<span class="badge bg-danger">High</span>
} else if data.Task.Priority == maintenance.PriorityCritical {
<span class="badge bg-danger">Critical</span>
} else if data.Task.Priority == maintenance.PriorityNormal {
<span class="badge bg-warning">Normal</span>
} else {
<span class="badge bg-secondary">Low</span>
}
</dd>
if data.Task.Reason != "" {
<dt class="col-sm-4">Reason:</dt>
<dd class="col-sm-8">
<span class="text-muted">{data.Task.Reason}</span>
</dd>
}
</dl>
</div>
<div class="col-md-6">
<!-- Task Timeline -->
<div class="mb-3">
<h6 class="text-primary mb-3">
<i class="fas fa-clock me-1"></i>Task Timeline
</h6>
<div class="timeline-container">
<div class="timeline-progress">
<div class="timeline-step" data-step="created">
<div class="timeline-circle completed">
<i class="fas fa-plus"></i>
</div>
<div class="timeline-connector completed"></div>
<div class="timeline-label">
<strong>Created</strong>
<small class="d-block text-muted">{data.Task.CreatedAt.Format("01-02 15:04:05")}</small>
</div>
</div>
<div class="timeline-step" data-step="scheduled">
<div class="timeline-circle completed">
<i class="fas fa-calendar"></i>
</div>
if data.Task.StartedAt != nil {
<div class="timeline-connector completed"></div>
} else {
<div class="timeline-connector"></div>
}
<div class="timeline-label">
<strong>Scheduled</strong>
<small class="d-block text-muted">{data.Task.ScheduledAt.Format("01-02 15:04:05")}</small>
</div>
</div>
<div class="timeline-step" data-step="started">
if data.Task.StartedAt != nil {
<div class="timeline-circle completed">
<i class="fas fa-play"></i>
</div>
} else {
<div class="timeline-circle pending">
<i class="fas fa-clock"></i>
</div>
}
if data.Task.CompletedAt != nil {
<div class="timeline-connector completed"></div>
} else {
<div class="timeline-connector"></div>
}
<div class="timeline-label">
<strong>Started</strong>
<small class="d-block text-muted">
if data.Task.StartedAt != nil {
{data.Task.StartedAt.Format("01-02 15:04:05")}
} else {
}
</small>
</div>
</div>
<div class="timeline-step" data-step="completed">
if data.Task.CompletedAt != nil {
<div class="timeline-circle completed">
if data.Task.Status == maintenance.TaskStatusCompleted {
<i class="fas fa-check"></i>
} else if data.Task.Status == maintenance.TaskStatusFailed {
<i class="fas fa-times"></i>
} else {
<i class="fas fa-stop"></i>
}
</div>
} else {
<div class="timeline-circle pending">
<i class="fas fa-hourglass-half"></i>
</div>
}
<div class="timeline-label">
<strong>
if data.Task.Status == maintenance.TaskStatusCompleted {
Completed
} else if data.Task.Status == maintenance.TaskStatusFailed {
Failed
} else if data.Task.Status == maintenance.TaskStatusCancelled {
Cancelled
} else {
Pending
}
</strong>
<small class="d-block text-muted">
if data.Task.CompletedAt != nil {
{data.Task.CompletedAt.Format("01-02 15:04:05")}
} else {
}
</small>
</div>
</div>
</div>
</div>
</div>
<!-- Additional Info -->
if data.Task.WorkerID != "" {
<dl class="row">
<dt class="col-sm-4">Worker:</dt>
<dd class="col-sm-8"><code>{data.Task.WorkerID}</code></dd>
</dl>
}
<dl class="row">
if data.Task.TypedParams != nil && data.Task.TypedParams.VolumeSize > 0 {
<dt class="col-sm-4">Volume Size:</dt>
<dd class="col-sm-8">
<span class="badge bg-primary">{formatBytes(int64(data.Task.TypedParams.VolumeSize))}</span>
</dd>
}
if data.Task.TypedParams != nil && data.Task.TypedParams.Collection != "" {
<dt class="col-sm-4">Collection:</dt>
<dd class="col-sm-8">
<span class="badge bg-info"><i class="fas fa-folder me-1"></i>{data.Task.TypedParams.Collection}</span>
</dd>
}
if data.Task.TypedParams != nil && data.Task.TypedParams.DataCenter != "" {
<dt class="col-sm-4">Data Center:</dt>
<dd class="col-sm-8">
<span class="badge bg-secondary"><i class="fas fa-building me-1"></i>{data.Task.TypedParams.DataCenter}</span>
</dd>
}
if data.Task.Progress > 0 {
<dt class="col-sm-4">Progress:</dt>
<dd class="col-sm-8">
<div class="progress" style="height: 20px;">
<div class="progress-bar" role="progressbar"
style={fmt.Sprintf("width: %.1f%%", data.Task.Progress)}
aria-valuenow={fmt.Sprintf("%.1f", data.Task.Progress)}
aria-valuemin="0" aria-valuemax="100">
{fmt.Sprintf("%.1f%%", data.Task.Progress)}
</div>
</div>
</dd>
}
</dl>
</div>
</div>
if data.Task.DetailedReason != "" {
<div class="row mt-3">
<div class="col-12">
<h6>Detailed Reason:</h6>
<p class="text-muted">{data.Task.DetailedReason}</p>
</div>
</div>
}
if data.Task.Error != "" {
<div class="row mt-3">
<div class="col-12">
<h6>Error:</h6>
<div class="alert alert-danger">
<code>{data.Task.Error}</code>
</div>
</div>
</div>
}
</div>
</div>
</div>
</div>
<!-- Task Configuration Card -->
if data.Task.TypedParams != nil {
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-cog me-2"></i>
Task Configuration
</h5>
</div>
<div class="card-body">
<!-- Source Servers (Unified) -->
if len(data.Task.TypedParams.Sources) > 0 {
<div class="mb-4">
<h6 class="text-info d-flex align-items-center">
<i class="fas fa-server me-2"></i>
Source Servers
<span class="badge bg-info ms-2">{fmt.Sprintf("%d", len(data.Task.TypedParams.Sources))}</span>
</h6>
<div class="bg-light p-3 rounded">
<div class="d-flex flex-column gap-2">
for i, source := range data.Task.TypedParams.Sources {
<div class="d-grid" style="grid-template-columns: auto 1fr auto auto auto auto; gap: 0.5rem; align-items: center;">
<span class="badge bg-primary">{fmt.Sprintf("#%d", i+1)}</span>
<code>{source.Node}</code>
<div>
if source.DataCenter != "" {
<small class="text-muted">
<i class="fas fa-building me-1"></i>{source.DataCenter}
</small>
}
</div>
<div>
if source.Rack != "" {
<small class="text-muted">
<i class="fas fa-server me-1"></i>{source.Rack}
</small>
}
</div>
<div>
if source.VolumeId > 0 {
<small class="text-muted">
<i class="fas fa-hdd me-1"></i>Vol:{fmt.Sprintf("%d", source.VolumeId)}
</small>
}
</div>
<div>
if len(source.ShardIds) > 0 {
<small class="text-muted">
<i class="fas fa-puzzle-piece me-1"></i>Shards:
for j, shardId := range source.ShardIds {
if j > 0 {
<span>, </span>
}
if shardId < erasure_coding.DataShardsCount {
<span class="badge badge-sm bg-primary ms-1" style="font-size: 0.65rem;" title={fmt.Sprintf("Data shard %d", shardId)}>{fmt.Sprintf("%d", shardId)}</span>
} else {
<span class="badge badge-sm bg-warning text-dark ms-1" style="font-size: 0.65rem;" title={fmt.Sprintf("Parity shard %d", shardId)}>{fmt.Sprintf("P%d", shardId-erasure_coding.DataShardsCount)}</span>
}
}
</small>
}
</div>
</div>
}
</div>
</div>
</div>
}
<!-- Task Flow Indicator -->
if len(data.Task.TypedParams.Sources) > 0 || len(data.Task.TypedParams.Targets) > 0 {
<div class="text-center mb-3">
<i class="fas fa-arrow-down text-primary" style="font-size: 1.5rem;"></i>
<br/>
<small class="text-muted">Task: {string(data.Task.Type)}</small>
</div>
}
<!-- Target/Destination (Generic) -->
if len(data.Task.TypedParams.Targets) > 0 {
<div class="mb-4">
<h6 class="text-success d-flex align-items-center">
<i class="fas fa-bullseye me-2"></i>
Target Servers
<span class="badge bg-success ms-2">{fmt.Sprintf("%d", len(data.Task.TypedParams.Targets))}</span>
</h6>
<div class="bg-light p-3 rounded">
<div class="d-flex flex-column gap-2">
for i, target := range data.Task.TypedParams.Targets {
<div class="d-grid" style="grid-template-columns: auto 1fr auto auto auto auto; gap: 0.5rem; align-items: center;">
<span class="badge bg-success">{fmt.Sprintf("#%d", i+1)}</span>
<code>{target.Node}</code>
<div>
if target.DataCenter != "" {
<small class="text-muted">
<i class="fas fa-building me-1"></i>{target.DataCenter}
</small>
}
</div>
<div>
if target.Rack != "" {
<small class="text-muted">
<i class="fas fa-server me-1"></i>{target.Rack}
</small>
}
</div>
<div>
if target.VolumeId > 0 {
<small class="text-muted">
<i class="fas fa-hdd me-1"></i>Vol:{fmt.Sprintf("%d", target.VolumeId)}
</small>
}
</div>
<div>
if len(target.ShardIds) > 0 {
<small class="text-muted">
<i class="fas fa-puzzle-piece me-1"></i>Shards:
for j, shardId := range target.ShardIds {
if j > 0 {
<span>, </span>
}
if shardId < erasure_coding.DataShardsCount {
<span class="badge badge-sm bg-primary ms-1" style="font-size: 0.65rem;" title={fmt.Sprintf("Data shard %d", shardId)}>{fmt.Sprintf("%d", shardId)}</span>
} else {
<span class="badge badge-sm bg-warning text-dark ms-1" style="font-size: 0.65rem;" title={fmt.Sprintf("Parity shard %d", shardId)}>{fmt.Sprintf("P%d", shardId-erasure_coding.DataShardsCount)}</span>
}
}
</small>
}
</div>
</div>
}
</div>
</div>
</div>
}
</div>
</div>
</div>
</div>
}
<!-- Worker Information Card -->
if data.WorkerInfo != nil {
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-server me-2"></i>
Worker Information
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<dl class="row">
<dt class="col-sm-4">Worker ID:</dt>
<dd class="col-sm-8"><code>{data.WorkerInfo.ID}</code></dd>
<dt class="col-sm-4">Address:</dt>
<dd class="col-sm-8"><code>{data.WorkerInfo.Address}</code></dd>
<dt class="col-sm-4">Status:</dt>
<dd class="col-sm-8">
if data.WorkerInfo.Status == "active" {
<span class="badge bg-success">Active</span>
} else if data.WorkerInfo.Status == "busy" {
<span class="badge bg-warning">Busy</span>
} else {
<span class="badge bg-secondary">Inactive</span>
}
</dd>
</dl>
</div>
<div class="col-md-6">
<dl class="row">
<dt class="col-sm-4">Last Heartbeat:</dt>
<dd class="col-sm-8">{data.WorkerInfo.LastHeartbeat.Format("2006-01-02 15:04:05")}</dd>
<dt class="col-sm-4">Current Load:</dt>
<dd class="col-sm-8">{fmt.Sprintf("%d/%d", data.WorkerInfo.CurrentLoad, data.WorkerInfo.MaxConcurrent)}</dd>
<dt class="col-sm-4">Capabilities:</dt>
<dd class="col-sm-8">
for _, capability := range data.WorkerInfo.Capabilities {
<span class="badge bg-info me-1">{string(capability)}</span>
}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
}
<!-- Assignment History Card -->
if len(data.AssignmentHistory) > 0 {
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-history me-2"></i>
Assignment History
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Worker ID</th>
<th>Worker Address</th>
<th>Assigned At</th>
<th>Unassigned At</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
for _, assignment := range data.AssignmentHistory {
<tr>
<td><code>{assignment.WorkerID}</code></td>
<td><code>{assignment.WorkerAddress}</code></td>
<td>{assignment.AssignedAt.Format("2006-01-02 15:04:05")}</td>
<td>
if assignment.UnassignedAt != nil {
{assignment.UnassignedAt.Format("2006-01-02 15:04:05")}
} else {
<span class="text-muted">—</span>
}
</td>
<td>{assignment.Reason}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
}
<!-- Execution Logs Card -->
if len(data.ExecutionLogs) > 0 {
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-file-alt me-2"></i>
Execution Logs
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th width="150">Timestamp</th>
<th width="80">Level</th>
<th>Message</th>
<th>Details</th>
</tr>
</thead>
<tbody>
for _, log := range data.ExecutionLogs {
<tr>
<td><small>{log.Timestamp.Format("15:04:05")}</small></td>
<td>
if log.Level == "error" {
<span class="badge bg-danger">{log.Level}</span>
} else if log.Level == "warn" {
<span class="badge bg-warning">{log.Level}</span>
} else if log.Level == "info" {
<span class="badge bg-info">{log.Level}</span>
} else {
<span class="badge bg-secondary">{log.Level}</span>
}
</td>
<td><code>{log.Message}</code></td>
<td>
if log.Fields != nil && len(log.Fields) > 0 {
<small>
for _, k := range sortedKeys(log.Fields) {
<span class="badge bg-light text-dark me-1">{k}=<i>{log.Fields[k]}</i></span>
}
</small>
} else if log.Progress != nil || log.Status != "" {
<small>
if log.Progress != nil {
<span class="badge bg-secondary me-1">progress=<i>{fmt.Sprintf("%.0f%%", *log.Progress)}</i></span>
}
if log.Status != "" {
<span class="badge bg-secondary">status=<i>{log.Status}</i></span>
}
</small>
} else {
<span class="text-muted">-</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
}
<!-- Related Tasks Card -->
if len(data.RelatedTasks) > 0 {
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-link me-2"></i>
Related Tasks
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Task ID</th>
<th>Type</th>
<th>Status</th>
<th>Volume ID</th>
<th>Server</th>
<th>Created</th>
</tr>
</thead>
<tbody>
for _, relatedTask := range data.RelatedTasks {
<tr>
<td>
<a href={fmt.Sprintf("/maintenance/tasks/%s", relatedTask.ID)}>
<code>{relatedTask.ID}</code>
</a>
</td>
<td><span class="badge bg-info">{string(relatedTask.Type)}</span></td>
<td>
if relatedTask.Status == maintenance.TaskStatusCompleted {
<span class="badge bg-success">Completed</span>
} else if relatedTask.Status == maintenance.TaskStatusFailed {
<span class="badge bg-danger">Failed</span>
} else if relatedTask.Status == maintenance.TaskStatusInProgress {
<span class="badge bg-warning">In Progress</span>
} else {
<span class="badge bg-secondary">{string(relatedTask.Status)}</span>
}
</td>
<td>
if relatedTask.VolumeID != 0 {
{fmt.Sprintf("%d", relatedTask.VolumeID)}
} else {
<span class="text-muted">-</span>
}
</td>
<td>
if relatedTask.Server != "" {
<code>{relatedTask.Server}</code>
} else {
<span class="text-muted">-</span>
}
</td>
<td><small>{relatedTask.CreatedAt.Format("2006-01-02 15:04:05")}</small></td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
}
<!-- Actions Card -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-cogs me-2"></i>
Actions
</h5>
</div>
<div class="card-body">
if data.Task.Status == maintenance.TaskStatusPending || data.Task.Status == maintenance.TaskStatusAssigned {
<button type="button" class="btn btn-danger me-2" data-task-id={data.Task.ID} onclick="cancelTask(this.getAttribute('data-task-id'))">
<i class="fas fa-times me-1"></i>
Cancel Task
</button>
}
if data.Task.WorkerID != "" {
<button type="button" class="btn btn-primary me-2" data-task-id={data.Task.ID} data-worker-id={data.Task.WorkerID} onclick="showTaskLogs(this.getAttribute('data-task-id'), this.getAttribute('data-worker-id'))">
<i class="fas fa-file-text me-1"></i>
Show Task Logs
</button>
}
<button type="button" class="btn btn-info" data-task-id={data.Task.ID} onclick="exportTaskDetail(this.getAttribute('data-task-id'))">
<i class="fas fa-download me-1"></i>
Export Details
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Task Logs Modal -->
<div class="modal fade" id="taskLogsModal" tabindex="-1" aria-labelledby="taskLogsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="taskLogsModalLabel">
<i class="fas fa-file-text me-2"></i>Task Logs
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="logsLoadingSpinner" class="text-center py-4" style="display: none;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading logs...</span>
</div>
<p class="mt-2">Fetching logs from worker...</p>
</div>
<div id="logsError" class="alert alert-danger" style="display: none;">
<i class="fas fa-exclamation-triangle me-2"></i>
<span id="logsErrorMessage"></span>
</div>
<div id="logsContent" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<strong>Task:</strong> <span id="logsTaskId"></span> |
<strong>Worker:</strong> <span id="logsWorkerId"></span> |
<strong>Entries:</strong> <span id="logsCount"></span>
</div>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="refreshModalLogs()">
<i class="fas fa-sync-alt me-1"></i>Refresh
</button>
<button type="button" class="btn btn-sm btn-outline-success" onclick="downloadTaskLogs()">
<i class="fas fa-download me-1"></i>Download
</button>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<span>Log Entries (Last 100)</span>
<small class="text-muted">Newest entries first</small>
</div>
</div>
<div class="card-body p-0">
<pre id="logsDisplay" class="bg-dark text-light p-3 mb-0" style="max-height: 400px; overflow-y: auto; font-size: 0.85rem; line-height: 1.4;"></pre>
</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>
<style>
.timeline-container {
position: relative;
padding: 20px 0;
}
.timeline-progress {
display: flex;
justify-content: space-between;
align-items: flex-start;
position: relative;
max-width: 100%;
}
.timeline-step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
}
.timeline-circle {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
z-index: 2;
position: relative;
}
.timeline-circle.completed {
background-color: #28a745;
border: 3px solid #1e7e34;
}
.timeline-circle.pending {
background-color: #6c757d;
border: 3px solid #495057;
}
.timeline-connector {
position: absolute;
top: 20px;
left: 50%;
right: -50%;
height: 4px;
z-index: 1;
margin-left: 20px;
margin-right: 20px;
}
.timeline-connector.completed {
background-color: #28a745;
}
.timeline-connector:not(.completed) {
background-color: #dee2e6;
}
.timeline-step:last-child .timeline-connector {
display: none;
}
.timeline-label {
margin-top: 15px;
text-align: center;
min-height: 60px;
}
.timeline-label strong {
display: block;
font-size: 0.9rem;
margin-bottom: 4px;
}
.timeline-label small {
font-size: 0.75rem;
line-height: 1.2;
}
@media (max-width: 768px) {
.timeline-progress {
flex-direction: column;
align-items: stretch;
}
.timeline-step {
flex-direction: row;
align-items: center;
margin-bottom: 20px;
}
.timeline-circle {
margin-right: 15px;
flex-shrink: 0;
}
.timeline-connector {
display: none;
}
.timeline-label {
text-align: left;
margin-top: 0;
min-height: auto;
}
}
</style>
<script>
// Global variables for current logs modal
let currentTaskId = '';
let currentWorkerId = '';
function refreshPage() {
location.reload();
}
function showTaskLogs(taskId, workerId) {
currentTaskId = taskId;
currentWorkerId = workerId;
// Show the modal
const modal = new bootstrap.Modal(document.getElementById('taskLogsModal'));
modal.show();
// Load logs
loadTaskLogs(taskId, workerId);
}
function loadTaskLogs(taskId, workerId) {
// Show loading spinner
document.getElementById('logsLoadingSpinner').style.display = 'block';
document.getElementById('logsError').style.display = 'none';
document.getElementById('logsContent').style.display = 'none';
// Update modal info
document.getElementById('logsTaskId').textContent = taskId;
document.getElementById('logsWorkerId').textContent = workerId;
// Fetch logs from the API
fetch(`/api/maintenance/workers/${workerId}/logs?taskId=${taskId}&maxEntries=100`)
.then(response => response.json())
.then(data => {
document.getElementById('logsLoadingSpinner').style.display = 'none';
if (data.error) {
showLogsError(data.error);
return;
}
// Display logs
displayLogs(data.logs, data.count || 0);
})
.catch(error => {
document.getElementById('logsLoadingSpinner').style.display = 'none';
showLogsError('Failed to fetch logs: ' + error.message);
});
}
function displayLogs(logs, count) {
document.getElementById('logsError').style.display = 'none';
document.getElementById('logsContent').style.display = 'block';
document.getElementById('logsCount').textContent = count;
const logsDisplay = document.getElementById('logsDisplay');
if (!logs || logs.length === 0) {
logsDisplay.textContent = 'No logs found for this task.';
return;
}
// Format and display logs with structured fields
let logText = '';
logs.forEach(entry => {
const timestamp = entry.timestamp ? new Date(entry.timestamp * 1000).toISOString() : 'N/A';
const level = entry.level || 'INFO';
const message = entry.message || '';
logText += `[${timestamp}] ${level}: ${message}`;
// Add structured fields if they exist
if (entry.fields && Object.keys(entry.fields).length > 0) {
const fieldsStr = Object.entries(entry.fields)
.map(([key, value]) => `${key}=${value}`)
.join(', ');
logText += ` | ${fieldsStr}`;
}
// Add progress if available
if (entry.progress !== undefined && entry.progress !== null) {
logText += ` | progress=${entry.progress}%`;
}
// Add status if available
if (entry.status) {
logText += ` | status=${entry.status}`;
}
logText += '\n';
});
logsDisplay.textContent = logText;
// Scroll to top
logsDisplay.scrollTop = 0;
}
function showLogsError(errorMessage) {
document.getElementById('logsError').style.display = 'block';
document.getElementById('logsContent').style.display = 'none';
document.getElementById('logsErrorMessage').textContent = errorMessage;
}
function refreshModalLogs() {
if (currentTaskId && currentWorkerId) {
loadTaskLogs(currentTaskId, currentWorkerId);
}
}
function downloadTaskLogs() {
if (!currentTaskId || !currentWorkerId) {
alert('No task logs to download');
return;
}
// Download all logs (without maxEntries limit)
const downloadUrl = `/api/maintenance/workers/${currentWorkerId}/logs?taskId=${currentTaskId}&maxEntries=0`;
fetch(downloadUrl)
.then(response => response.json())
.then(data => {
if (data.error) {
alert('Error downloading logs: ' + data.error);
return;
}
// Convert logs to text format with structured fields
let logContent = '';
if (data.logs && data.logs.length > 0) {
data.logs.forEach(entry => {
const timestamp = entry.timestamp ? new Date(entry.timestamp * 1000).toISOString() : 'N/A';
const level = entry.level || 'INFO';
const message = entry.message || '';
logContent += `[${timestamp}] ${level}: ${message}`;
// Add structured fields if they exist
if (entry.fields && Object.keys(entry.fields).length > 0) {
const fieldsStr = Object.entries(entry.fields)
.map(([key, value]) => `${key}=${value}`)
.join(', ');
logContent += ` | ${fieldsStr}`;
}
// Add progress if available
if (entry.progress !== undefined && entry.progress !== null) {
logContent += ` | progress=${entry.progress}%`;
}
// Add status if available
if (entry.status) {
logContent += ` | status=${entry.status}`;
}
logContent += '\n';
});
} else {
logContent = 'No logs found for this task.';
}
// Create and download file
const blob = new Blob([logContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `task-${currentTaskId}-logs.txt`;
link.click();
URL.revokeObjectURL(url);
})
.catch(error => {
alert('Error downloading logs: ' + error.message);
});
}
function cancelTask(taskId) {
if (confirm('Are you sure you want to cancel this task?')) {
fetch(`/api/maintenance/tasks/${taskId}/cancel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Task cancelled successfully');
location.reload();
} else {
alert('Error cancelling task: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error cancelling task');
});
}
}
function refreshTaskLogs(taskId) {
fetch(`/api/maintenance/tasks/${taskId}/detail`)
.then(response => response.json())
.then(data => {
location.reload();
})
.catch(error => {
console.error('Error:', error);
alert('Error refreshing logs');
});
}
function exportTaskDetail(taskId) {
fetch(`/api/maintenance/tasks/${taskId}/detail`)
.then(response => response.json())
.then(data => {
const dataStr = JSON.stringify(data, null, 2);
const dataBlob = new Blob([dataStr], {type: 'application/json'});
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `task-${taskId}-detail.json`;
link.click();
URL.revokeObjectURL(url);
})
.catch(error => {
console.error('Error:', error);
alert('Error exporting task detail');
});
}
// Auto-refresh every 30 seconds for active tasks
if ('{string(data.Task.Status)}' === 'in_progress') {
setInterval(refreshPage, 30000);
}
</script>
}