diff --git a/pkg/controller/release_bearer_controller.go b/pkg/controller/release_bearer_controller.go
index 7b11f0a..5b1dc82 100644
--- a/pkg/controller/release_bearer_controller.go
+++ b/pkg/controller/release_bearer_controller.go
@@ -102,8 +102,12 @@ func (c *ReleaseBearerController) enqueueBearer(obj interface{}) {
key := bearer.Name
+ now := time.Now()
c.lastSeenMut.Lock()
- c.lastSeen[key] = time.Now()
+ if bearer.CreationTimestamp.Time.Before(now) {
+ now = bearer.CreationTimestamp.Time
+ }
+ c.lastSeen[key] = now
c.lastSeenMut.Unlock()
c.workqueue.Add(key)
}
@@ -148,7 +152,6 @@ func (c *ReleaseBearerController) chunkHandler(ctx context.Context, name string)
c.lastSeenMut.RLock()
lastSeenTime, ok := c.lastSeen[name]
c.lastSeenMut.RUnlock()
-
if !ok {
return 0, nil
}
@@ -198,41 +201,37 @@ func (c *ReleaseBearerController) chunkHandler(ctx context.Context, name string)
return 10 * time.Second, fmt.Errorf("failed to delete bearer %s: %v", name, err)
}
case v1alpha1.BearerPhaseSucceeded:
- ttl, ok := getTTLDuration(bearer.ObjectMeta, v1alpha1.ReleaseTTLAnnotation)
-
expiresIn := bearer.Status.TokenInfo.ExpiresIn
issuedAt := bearer.Status.TokenInfo.IssuedAt.Time
if expiresIn != 0 && !issuedAt.IsZero() {
expires := time.Duration(expiresIn) * time.Second
- since := time.Since(issuedAt)
- if ok {
- ttl = min(ttl, expires-since)
- } else {
- ttl = expires - since
+ expirationTime := issuedAt.Add(expires)
+ if !expirationTime.After(time.Now()) {
+ klog.Infof("Deleting succeeded bearer %s after token expiration", name)
+ err = c.client.TaskV1alpha1().Bearers().Delete(ctx, bearer.Name, metav1.DeleteOptions{})
+ if err != nil && !apierrors.IsNotFound(err) {
+ return 10 * time.Second, fmt.Errorf("failed to delete bearer %s: %v", name, err)
+ }
+ return 0, nil
}
- sub := time.Since(lastSeenTime)
- if sub < ttl {
- return ttl - sub, nil
- }
+ return time.Until(expirationTime) + 10*time.Second, nil
+ }
- klog.Infof("Deleting succeeded bearer %s after token expiration", name)
- err = c.client.TaskV1alpha1().Bearers().Delete(ctx, bearer.Name, metav1.DeleteOptions{})
- if err != nil && !apierrors.IsNotFound(err) {
- return 10 * time.Second, fmt.Errorf("failed to delete bearer %s: %v", name, err)
- }
- } else if ok {
- sub := time.Since(lastSeenTime)
- if sub < ttl {
- return ttl - sub, nil
- }
+ ttl, ok := getTTLDuration(bearer.ObjectMeta, v1alpha1.ReleaseTTLAnnotation)
+ if !ok {
+ return 0, nil
+ }
+ sub := time.Since(lastSeenTime)
+ if sub < ttl {
+ return ttl - sub, nil
+ }
- klog.Infof("Deleting succeeded bearer %s after %v", name, ttl)
- err = c.client.TaskV1alpha1().Bearers().Delete(ctx, name, metav1.DeleteOptions{})
- if err != nil && !apierrors.IsNotFound(err) {
- return 10 * time.Second, fmt.Errorf("failed to delete bearer %s: %v", name, err)
- }
+ klog.Infof("Deleting succeeded bearer %s after %v", name, ttl)
+ err = c.client.TaskV1alpha1().Bearers().Delete(ctx, name, metav1.DeleteOptions{})
+ if err != nil && !apierrors.IsNotFound(err) {
+ return 10 * time.Second, fmt.Errorf("failed to delete bearer %s: %v", name, err)
}
}
return 0, nil
diff --git a/pkg/controller/release_blob_controller.go b/pkg/controller/release_blob_controller.go
index f9f3fc3..25fc360 100644
--- a/pkg/controller/release_blob_controller.go
+++ b/pkg/controller/release_blob_controller.go
@@ -95,8 +95,12 @@ func (c *ReleaseBlobController) enqueueBlob(obj interface{}) {
key := blob.Name
+ now := time.Now()
c.lastSeenMut.Lock()
- c.lastSeen[key] = time.Now()
+ if blob.CreationTimestamp.Time.Before(now) {
+ now = blob.CreationTimestamp.Time
+ }
+ c.lastSeen[key] = now
c.lastSeenMut.Unlock()
c.workqueue.Add(key)
}
@@ -141,7 +145,6 @@ func (c *ReleaseBlobController) chunkHandler(ctx context.Context, name string) (
c.lastSeenMut.RLock()
lastSeenTime, ok := c.lastSeen[name]
c.lastSeenMut.RUnlock()
-
if !ok {
return 0, nil
}
diff --git a/pkg/controller/release_chunk_controller.go b/pkg/controller/release_chunk_controller.go
index a1fa0fd..5f231e1 100644
--- a/pkg/controller/release_chunk_controller.go
+++ b/pkg/controller/release_chunk_controller.go
@@ -103,8 +103,12 @@ func (c *ReleaseChunkController) enqueueChunk(obj interface{}) {
key := chunk.Name
+ now := time.Now()
c.lastSeenMut.Lock()
- c.lastSeen[key] = time.Now()
+ if chunk.CreationTimestamp.Time.Before(now) {
+ now = chunk.CreationTimestamp.Time
+ }
+ c.lastSeen[key] = now
c.lastSeenMut.Unlock()
c.workqueue.Add(key)
}
@@ -149,7 +153,6 @@ func (c *ReleaseChunkController) chunkHandler(ctx context.Context, name string)
c.lastSeenMut.RLock()
lastSeenTime, ok := c.lastSeen[name]
c.lastSeenMut.RUnlock()
-
if !ok {
return 0, nil
}
diff --git a/pkg/webui/html/index.html b/pkg/webui/html/index.html
index 1823090..80d17a9 100644
--- a/pkg/webui/html/index.html
+++ b/pkg/webui/html/index.html
@@ -534,6 +534,62 @@
Created: ${formatTimestamp(creationTime)}`;
+
+ if (completionTime && (phase === 'Succeeded' || phase === 'Failed')) {
+ const duration = formatDuration(creationTime, completionTime);
+ return `${created} | Completed: ${formatTimestamp(completionTime)} | Duration: ${duration}`;
+ }
+
+ return created;
+ };
+
const progressData = {};
const statusMap = {
'Pending': 'pending',
@@ -673,7 +729,10 @@
`;
+
+
+ ${highlightTime(data.creationTime, data.completionTime, data.phase)}
+
`;
}
case 'Failed':
return `${header}
@@ -681,6 +740,9 @@
${size ? highlightProgress(progress, size) : ``}
${hasChunks ? highlightChunksInfo(data) : ``}
+
+
+ ${highlightTime(data.creationTime, data.completionTime, data.phase)}
`;
case 'Pending':
case 'Unknown':
@@ -688,12 +750,18 @@
${size ? highlightProgress(progress, size) : ``}
${hasChunks ? highlightChunksInfo(data) : ``}
+
+
+ ${highlightTime(data.creationTime, data.completionTime, data.phase)}
`;
case 'Succeeded':
return `${header}
${size ? highlightBytes(size) : ``}
${hasChunks ? highlightChunksInfo(data) : ``}
+
+
+ ${highlightTime(data.creationTime, data.completionTime, data.phase)}
`;
}
};
diff --git a/pkg/webui/webui.go b/pkg/webui/webui.go
index be547fe..6e45368 100644
--- a/pkg/webui/webui.go
+++ b/pkg/webui/webui.go
@@ -446,6 +446,9 @@ type entry struct {
Errors []string `json:"errors,omitempty"`
+ CreationTime string `json:"creationTime,omitempty"`
+ CompletionTime string `json:"completionTime,omitempty"`
+
groupIgnoreSize bool `json:"-"`
}
@@ -519,6 +522,14 @@ func blobToEntry(blob *v1alpha1.Blob) *entry {
}
}
+ // Set timestamps
+ if !blob.CreationTimestamp.IsZero() {
+ e.CreationTime = blob.CreationTimestamp.Format(time.RFC3339)
+ }
+ if blob.Status.CompletionTime != nil && !blob.Status.CompletionTime.IsZero() {
+ e.CompletionTime = blob.Status.CompletionTime.Format(time.RFC3339)
+ }
+
return e
}
@@ -560,6 +571,15 @@ func chunkToEntry(chunk *v1alpha1.Chunk) *entry {
}
}
}
+
+ // Set timestamps
+ if !chunk.CreationTimestamp.IsZero() {
+ e.CreationTime = chunk.CreationTimestamp.Format(time.RFC3339)
+ }
+ if chunk.Status.CompletionTime != nil && !chunk.Status.CompletionTime.IsZero() {
+ e.CompletionTime = chunk.Status.CompletionTime.Format(time.RFC3339)
+ }
+
return e
}
@@ -582,6 +602,8 @@ func aggregateEntries(groupName string, entries map[string]*entry) *entry {
var hasFailed bool
var hasRunning bool
var hasPending bool
+ var earliestCreation time.Time
+ var latestCompletion time.Time
for uid, e := range entries {
// Add member info to the list
@@ -604,6 +626,24 @@ func aggregateEntries(groupName string, entries map[string]*entry) *entry {
maxPriority = e.Priority
}
+ // Track earliest creation time
+ if e.CreationTime != "" {
+ if t, err := time.Parse(time.RFC3339, e.CreationTime); err == nil {
+ if earliestCreation.IsZero() || t.Before(earliestCreation) {
+ earliestCreation = t
+ }
+ }
+ }
+
+ // Track latest completion time
+ if e.CompletionTime != "" {
+ if t, err := time.Parse(time.RFC3339, e.CompletionTime); err == nil {
+ if latestCompletion.IsZero() || t.After(latestCompletion) {
+ latestCompletion = t
+ }
+ }
+ }
+
if e.groupIgnoreSize {
continue
}
@@ -632,6 +672,14 @@ func aggregateEntries(groupName string, entries map[string]*entry) *entry {
aggregate.FailedChunks = failedChunks
aggregate.Priority = maxPriority
+ // Set timestamps
+ if !earliestCreation.IsZero() {
+ aggregate.CreationTime = earliestCreation.Format(time.RFC3339)
+ }
+ if !latestCompletion.IsZero() {
+ aggregate.CompletionTime = latestCompletion.Format(time.RFC3339)
+ }
+
completed := pendingChunks == 0 && runningChunks == 0 && idleChunks == 0 && !hasRunning && !hasPending
switch {
diff --git a/pkg/webui/webui_test.go b/pkg/webui/webui_test.go
new file mode 100644
index 0000000..0617f78
--- /dev/null
+++ b/pkg/webui/webui_test.go
@@ -0,0 +1,223 @@
+/*
+Copyright 2025 The OpenCIDN Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package webui
+
+import (
+ "testing"
+ "time"
+
+ "github.com/OpenCIDN/cidn/pkg/apis/task/v1alpha1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestBlobToEntry_WithTimestamps(t *testing.T) {
+ now := metav1.Now()
+ completionTime := metav1.NewTime(now.Add(5 * time.Minute))
+
+ blob := &v1alpha1.Blob{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-blob",
+ CreationTimestamp: now,
+ Annotations: map[string]string{
+ v1alpha1.WebuiDisplayNameAnnotation: "Test Blob",
+ },
+ },
+ Status: v1alpha1.BlobStatus{
+ Phase: v1alpha1.BlobPhaseSucceeded,
+ Total: 1000,
+ Progress: 1000,
+ CompletionTime: &completionTime,
+ },
+ }
+
+ entry := blobToEntry(blob)
+
+ if entry.Name != "Test Blob" {
+ t.Errorf("Expected name 'Test Blob', got '%s'", entry.Name)
+ }
+
+ if entry.CreationTime == "" {
+ t.Error("Expected CreationTime to be set")
+ }
+
+ if entry.CompletionTime == "" {
+ t.Error("Expected CompletionTime to be set")
+ }
+
+ // Verify the timestamps can be parsed
+ _, err := time.Parse(time.RFC3339, entry.CreationTime)
+ if err != nil {
+ t.Errorf("Failed to parse CreationTime: %v", err)
+ }
+
+ _, err = time.Parse(time.RFC3339, entry.CompletionTime)
+ if err != nil {
+ t.Errorf("Failed to parse CompletionTime: %v", err)
+ }
+}
+
+func TestChunkToEntry_WithTimestamps(t *testing.T) {
+ now := metav1.Now()
+ completionTime := metav1.NewTime(now.Add(2 * time.Minute))
+
+ chunk := &v1alpha1.Chunk{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-chunk",
+ CreationTimestamp: now,
+ Annotations: map[string]string{
+ v1alpha1.WebuiDisplayNameAnnotation: "Test Chunk",
+ },
+ },
+ Spec: v1alpha1.ChunkSpec{
+ Total: 100,
+ },
+ Status: v1alpha1.ChunkStatus{
+ Phase: v1alpha1.ChunkPhaseSucceeded,
+ Progress: 100,
+ CompletionTime: &completionTime,
+ },
+ }
+
+ entry := chunkToEntry(chunk)
+
+ if entry.Name != "Test Chunk" {
+ t.Errorf("Expected name 'Test Chunk', got '%s'", entry.Name)
+ }
+
+ if entry.CreationTime == "" {
+ t.Error("Expected CreationTime to be set")
+ }
+
+ if entry.CompletionTime == "" {
+ t.Error("Expected CompletionTime to be set")
+ }
+
+ // Verify the timestamps can be parsed
+ _, err := time.Parse(time.RFC3339, entry.CreationTime)
+ if err != nil {
+ t.Errorf("Failed to parse CreationTime: %v", err)
+ }
+
+ _, err = time.Parse(time.RFC3339, entry.CompletionTime)
+ if err != nil {
+ t.Errorf("Failed to parse CompletionTime: %v", err)
+ }
+}
+
+func TestAggregateEntries_WithTimestamps(t *testing.T) {
+ now := time.Now()
+ earliestTime := now.Add(-10 * time.Minute)
+ latestTime := now
+
+ entries := map[string]*entry{
+ "entry1": {
+ Name: "Entry 1",
+ Phase: "Succeeded",
+ CreationTime: earliestTime.Format(time.RFC3339),
+ CompletionTime: now.Add(-5 * time.Minute).Format(time.RFC3339),
+ Total: 100,
+ Progress: 100,
+ },
+ "entry2": {
+ Name: "Entry 2",
+ Phase: "Succeeded",
+ CreationTime: now.Add(-8 * time.Minute).Format(time.RFC3339),
+ CompletionTime: latestTime.Format(time.RFC3339),
+ Total: 200,
+ Progress: 200,
+ },
+ }
+
+ aggregate := aggregateEntries("test-group", entries)
+
+ if aggregate.Name != "test-group" {
+ t.Errorf("Expected name 'test-group', got '%s'", aggregate.Name)
+ }
+
+ if aggregate.CreationTime == "" {
+ t.Error("Expected CreationTime to be set for aggregate")
+ }
+
+ if aggregate.CompletionTime == "" {
+ t.Error("Expected CompletionTime to be set for aggregate")
+ }
+
+ // Parse and verify earliest creation time
+ creationTime, err := time.Parse(time.RFC3339, aggregate.CreationTime)
+ if err != nil {
+ t.Errorf("Failed to parse aggregate CreationTime: %v", err)
+ }
+
+ // Allow for 2 second difference due to time formatting precision
+ expectedEarliest := earliestTime
+ actualEarliest := creationTime
+ diff := actualEarliest.Sub(expectedEarliest)
+ if diff < -2*time.Second || diff > 2*time.Second {
+ t.Errorf("Expected earliest creation time around %v, got %v (diff: %v)", expectedEarliest, actualEarliest, diff)
+ }
+
+ // Parse and verify latest completion time
+ completionTime, err := time.Parse(time.RFC3339, aggregate.CompletionTime)
+ if err != nil {
+ t.Errorf("Failed to parse aggregate CompletionTime: %v", err)
+ }
+
+ // Allow for 2 second difference due to time formatting precision
+ expectedLatest := latestTime
+ actualLatest := completionTime
+ diff = actualLatest.Sub(expectedLatest)
+ if diff < -2*time.Second || diff > 2*time.Second {
+ t.Errorf("Expected latest completion time around %v, got %v (diff: %v)", expectedLatest, actualLatest, diff)
+ }
+
+ if aggregate.Phase != "Succeeded" {
+ t.Errorf("Expected phase 'Succeeded', got '%s'", aggregate.Phase)
+ }
+
+ if aggregate.Total != 300 {
+ t.Errorf("Expected total 300, got %d", aggregate.Total)
+ }
+}
+
+func TestBlobToEntry_WithoutCompletionTime(t *testing.T) {
+ now := metav1.Now()
+
+ blob := &v1alpha1.Blob{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-blob",
+ CreationTimestamp: now,
+ Annotations: map[string]string{
+ v1alpha1.WebuiDisplayNameAnnotation: "Test Blob",
+ },
+ },
+ Status: v1alpha1.BlobStatus{
+ Phase: v1alpha1.BlobPhaseRunning,
+ Total: 1000,
+ Progress: 500,
+ },
+ }
+
+ entry := blobToEntry(blob)
+
+ if entry.CreationTime == "" {
+ t.Error("Expected CreationTime to be set")
+ }
+
+ if entry.CompletionTime != "" {
+ t.Error("Expected CompletionTime to be empty for running blob")
+ }
+}