diff --git a/internal/ui/views_advanced.go b/internal/ui/views_advanced.go index a2369de..53f30a8 100644 --- a/internal/ui/views_advanced.go +++ b/internal/ui/views_advanced.go @@ -196,163 +196,6 @@ func (a *App) renderThreadPoolView() string { return b.String() } -// renderTasksView renders the currently running tasks view -func (a *App) renderTasksView() string { - var b strings.Builder - - b.WriteString(headerStyle.Render(fmt.Sprintf("Running Tasks (%d)", len(a.tasks)))) - b.WriteString("\n\n") - - if len(a.tasks) == 0 { - b.WriteString(statusGreen.Render("✓ No running tasks")) - return b.String() - } - - // Sort by running time (longest first) - type taskWithTime struct { - task TaskInfo - seconds float64 - } - - var tasksWithTime []taskWithTime - for _, task := range a.tasks { - seconds := parseRunningTime(task.RunningTime) - tasksWithTime = append(tasksWithTime, taskWithTime{task: task, seconds: seconds}) - } - - // Simple bubble sort by seconds (descending) - for i := 0; i < len(tasksWithTime); i++ { - for j := i + 1; j < len(tasksWithTime); j++ { - if tasksWithTime[j].seconds > tasksWithTime[i].seconds { - tasksWithTime[i], tasksWithTime[j] = tasksWithTime[j], tasksWithTime[i] - } - } - } - - // Limit to 50 tasks for performance - displayCount := len(tasksWithTime) - if displayCount > 50 { - displayCount = 50 - } - - // Check for long-running tasks - criticalCount := 0 - warningCount := 0 - for i := 0; i < displayCount; i++ { - if tasksWithTime[i].seconds >= 60 { - criticalCount++ - } else if tasksWithTime[i].seconds >= 30 { - warningCount++ - } - } - - if criticalCount > 0 { - b.WriteString(statusRed.Render(fmt.Sprintf("⚠ %d task(s) running ≥60s", criticalCount))) - b.WriteString("\n\n") - } else if warningCount > 0 { - b.WriteString(statusYellow.Render(fmt.Sprintf("⚠ %d task(s) running ≥30s", warningCount))) - b.WriteString("\n\n") - } - - // Display tasks - for i := 0; i < displayCount; i++ { - twt := tasksWithTime[i] - task := twt.task - seconds := twt.seconds - - var timeStyle lipgloss.Style - if seconds >= 60 { - timeStyle = statusRed - } else if seconds >= 30 { - timeStyle = statusYellow - } else { - timeStyle = statusGreen - } - - actionType := simplifyAction(task.Action) - - b.WriteString(fmt.Sprintf("%s %s %s\n", - timeStyle.Render(fmt.Sprintf("[%s]", task.RunningTime)), - valueStyle.Render(actionType), - labelStyle.Render(task.Node))) - - if task.Description != "" && task.Description != "null" { - desc := task.Description - if len(desc) > 80 { - desc = desc[:77] + "..." - } - b.WriteString(fmt.Sprintf(" %s\n", labelStyle.Render(desc))) - } - b.WriteString("\n") - } - - if len(tasksWithTime) > displayCount { - b.WriteString(labelStyle.Render(fmt.Sprintf("... and %d more tasks", len(tasksWithTime)-displayCount))) - b.WriteString("\n") - } - - return b.String() -} - -// renderPendingTasksView renders the pending cluster tasks view -func (a *App) renderPendingTasksView() string { - var b strings.Builder - - b.WriteString(headerStyle.Render(fmt.Sprintf("Pending Cluster Tasks (%d)", len(a.pendingTasks)))) - b.WriteString("\n\n") - - if len(a.pendingTasks) == 0 { - b.WriteString(statusGreen.Render("✓ No pending tasks")) - return b.String() - } - - // Check for tasks queued too long - criticalCount := 0 - warningCount := 0 - for _, task := range a.pendingTasks { - ms := parseTimeInQueue(task.TimeInQueue) - if ms >= 5000 { - criticalCount++ - } else if ms >= 1000 { - warningCount++ - } - } - - if criticalCount > 0 { - b.WriteString(statusRed.Render(fmt.Sprintf("⚠ CRITICAL: %d task(s) queued ≥5s", criticalCount))) - b.WriteString("\n") - b.WriteString(labelStyle.Render("Long queue times indicate cluster state update delays")) - b.WriteString("\n\n") - } else if warningCount > 0 { - b.WriteString(statusYellow.Render(fmt.Sprintf("⚠ %d task(s) queued ≥1s", warningCount))) - b.WriteString("\n\n") - } - - // Display tasks - for _, task := range a.pendingTasks { - ms := parseTimeInQueue(task.TimeInQueue) - - var timeStyle lipgloss.Style - if ms >= 5000 { - timeStyle = statusRed - } else if ms >= 1000 { - timeStyle = statusYellow - } else { - timeStyle = statusGreen - } - - b.WriteString(fmt.Sprintf("%s %s\n", - timeStyle.Render(fmt.Sprintf("[%s]", task.TimeInQueue)), - valueStyle.Render(task.Source))) - b.WriteString(fmt.Sprintf(" %s %s %s %s\n", - labelStyle.Render("Priority:"), task.Priority, - labelStyle.Render("Insert Order:"), task.InsertOrder)) - b.WriteString("\n") - } - - return b.String() -} - // renderRecoveryView renders the shard recovery view func (a *App) renderRecoveryView() string { var b strings.Builder diff --git a/internal/ui/views_advanced_test.go b/internal/ui/views_advanced_test.go index 6f51b14..fa417ee 100644 --- a/internal/ui/views_advanced_test.go +++ b/internal/ui/views_advanced_test.go @@ -196,81 +196,6 @@ func TestApp_RenderThreadPoolView_QueueDepthColorCoding(t *testing.T) { } } -// ==================== Tasks View Tests ==================== - -func TestApp_RenderTasksView_Empty(t *testing.T) { - app := &App{ - tasks: []TaskInfo{}, - } - - result := app.renderTasksView() - - if !strings.Contains(result, "No running tasks") { - t.Error("renderTasksView() should show 'No running tasks' for empty list") - } -} - -func TestApp_RenderTasksView_WithTasks(t *testing.T) { - app := &App{ - tasks: []TaskInfo{ - {Action: "indices:data/read/search", Type: "transport", Node: "node-1", RunningTime: "1.5s"}, - {Action: "indices:data/write/bulk", Type: "transport", Node: "node-2", RunningTime: "2.3s"}, - }, - } - - result := app.renderTasksView() - - if !strings.Contains(result, "Running Tasks (2)") { - t.Error("renderTasksView() should show task count") - } - - // Check for simplified action names - if !strings.Contains(result, "Search") || !strings.Contains(result, "Bulk") { - t.Error("renderTasksView() should show simplified action names") - } - - if !strings.Contains(result, "node-1") { - t.Error("renderTasksView() should show node names") - } -} - -// ==================== Pending Tasks View Tests ==================== - -func TestApp_RenderPendingTasksView_Empty(t *testing.T) { - app := &App{ - pendingTasks: []PendingTaskInfo{}, - } - - result := app.renderPendingTasksView() - - if !strings.Contains(result, "No pending tasks") { - t.Error("renderPendingTasksView() should show 'No pending tasks' for empty list") - } -} - -func TestApp_RenderPendingTasksView_WithTasks(t *testing.T) { - app := &App{ - pendingTasks: []PendingTaskInfo{ - {Priority: "URGENT", Source: "create-index", TimeInQueue: "500ms"}, - {Priority: "NORMAL", Source: "update-mapping", TimeInQueue: "100ms"}, - }, - } - - result := app.renderPendingTasksView() - - if !strings.Contains(result, "Pending Cluster Tasks (2)") { - t.Error("renderPendingTasksView() should show task count") - } - - if !strings.Contains(result, "URGENT") { - t.Error("renderPendingTasksView() should show task priorities") - } - - if !strings.Contains(result, "create-index") { - t.Error("renderPendingTasksView() should show task sources") - } -} - // ==================== Recovery View Tests ==================== func TestApp_RenderRecoveryView_Empty(t *testing.T) { diff --git a/internal/ui/views_tasks.go b/internal/ui/views_tasks.go new file mode 100644 index 0000000..98a5940 --- /dev/null +++ b/internal/ui/views_tasks.go @@ -0,0 +1,182 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// renderTasksView renders the currently running tasks view +func (a *App) renderTasksView() string { + var b strings.Builder + + b.WriteString(headerStyle.Render(fmt.Sprintf("Running Tasks (%d)", len(a.tasks)))) + b.WriteString("\n\n") + + if len(a.tasks) == 0 { + b.WriteString(statusGreen.Render("✓ No running tasks")) + return b.String() + } + + // Sort by running time (longest first) + type taskWithTime struct { + task TaskInfo + seconds float64 + } + + var tasksWithTime []taskWithTime + for _, task := range a.tasks { + seconds := parseRunningTime(task.RunningTime) + tasksWithTime = append(tasksWithTime, taskWithTime{task: task, seconds: seconds}) + } + + // Simple bubble sort by seconds (descending) + for i := 0; i < len(tasksWithTime); i++ { + for j := i + 1; j < len(tasksWithTime); j++ { + if tasksWithTime[j].seconds > tasksWithTime[i].seconds { + tasksWithTime[i], tasksWithTime[j] = tasksWithTime[j], tasksWithTime[i] + } + } + } + + // Limit to 50 tasks for performance + displayCount := len(tasksWithTime) + if displayCount > 50 { + displayCount = 50 + } + + // Check for long-running tasks + criticalCount := 0 + warningCount := 0 + for i := 0; i < displayCount; i++ { + if tasksWithTime[i].seconds >= 60 { + criticalCount++ + } else if tasksWithTime[i].seconds >= 30 { + warningCount++ + } + } + + if criticalCount > 0 { + b.WriteString(statusRed.Render(fmt.Sprintf("⚠ %d task(s) running ≥60s", criticalCount))) + b.WriteString("\n\n") + } else if warningCount > 0 { + b.WriteString(statusYellow.Render(fmt.Sprintf("⚠ %d task(s) running ≥30s", warningCount))) + b.WriteString("\n\n") + } + + // Display tasks + for i := 0; i < displayCount; i++ { + twt := tasksWithTime[i] + task := twt.task + seconds := twt.seconds + + var timeStyle lipgloss.Style + if seconds >= 60 { + timeStyle = statusRed + } else if seconds >= 30 { + timeStyle = statusYellow + } else { + timeStyle = statusGreen + } + + // Show full action instead of simplified version + b.WriteString(fmt.Sprintf("%s %s\n", + timeStyle.Render(fmt.Sprintf("[%s]", task.RunningTime)), + valueStyle.Render(task.Action))) + + // Show Task ID and IP/Node information + taskIDDisplay := task.TaskID + if len(taskIDDisplay) > 40 { + taskIDDisplay = taskIDDisplay[:37] + "..." + } + + nodeInfo := fmt.Sprintf("Node: %s", task.Node) + if task.IP != "" && task.IP != "null" { + nodeInfo = fmt.Sprintf("IP: %s | Node: %s", task.IP, task.Node) + } + + b.WriteString(fmt.Sprintf(" %s %s | %s\n", + labelStyle.Render("Task ID:"), + labelStyle.Render(taskIDDisplay), + labelStyle.Render(nodeInfo))) + + // Show description with more space + if task.Description != "" && task.Description != "null" { + desc := task.Description + if len(desc) > 120 { + desc = desc[:117] + "..." + } + b.WriteString(fmt.Sprintf(" %s %s\n", + labelStyle.Render("Details:"), + labelStyle.Render(desc))) + } + b.WriteString("\n") + } + + if len(tasksWithTime) > displayCount { + b.WriteString(labelStyle.Render(fmt.Sprintf("... and %d more tasks", len(tasksWithTime)-displayCount))) + b.WriteString("\n") + } + + return b.String() +} + +// renderPendingTasksView renders the pending cluster tasks view +func (a *App) renderPendingTasksView() string { + var b strings.Builder + + b.WriteString(headerStyle.Render(fmt.Sprintf("Pending Cluster Tasks (%d)", len(a.pendingTasks)))) + b.WriteString("\n\n") + + if len(a.pendingTasks) == 0 { + b.WriteString(statusGreen.Render("✓ No pending tasks")) + return b.String() + } + + // Check for tasks queued too long + criticalCount := 0 + warningCount := 0 + for _, task := range a.pendingTasks { + ms := parseTimeInQueue(task.TimeInQueue) + if ms >= 5000 { + criticalCount++ + } else if ms >= 1000 { + warningCount++ + } + } + + if criticalCount > 0 { + b.WriteString(statusRed.Render(fmt.Sprintf("⚠ CRITICAL: %d task(s) queued ≥5s", criticalCount))) + b.WriteString("\n") + b.WriteString(labelStyle.Render("Long queue times indicate cluster state update delays")) + b.WriteString("\n\n") + } else if warningCount > 0 { + b.WriteString(statusYellow.Render(fmt.Sprintf("⚠ %d task(s) queued ≥1s", warningCount))) + b.WriteString("\n\n") + } + + // Display tasks + for _, task := range a.pendingTasks { + ms := parseTimeInQueue(task.TimeInQueue) + + var timeStyle lipgloss.Style + if ms >= 5000 { + timeStyle = statusRed + } else if ms >= 1000 { + timeStyle = statusYellow + } else { + timeStyle = statusGreen + } + + b.WriteString(fmt.Sprintf("%s %s\n", + timeStyle.Render(fmt.Sprintf("[%s]", task.TimeInQueue)), + valueStyle.Render(task.Source))) + b.WriteString(fmt.Sprintf(" %s %s %s %s\n", + labelStyle.Render("Priority:"), task.Priority, + labelStyle.Render("Insert Order:"), task.InsertOrder)) + b.WriteString("\n") + } + + return b.String() +} diff --git a/internal/ui/views_tasks_test.go b/internal/ui/views_tasks_test.go new file mode 100644 index 0000000..9d2f6e6 --- /dev/null +++ b/internal/ui/views_tasks_test.go @@ -0,0 +1,430 @@ +package ui + +import ( + "strings" + "testing" +) + +func TestRenderTasksView_Empty(t *testing.T) { + app := &App{ + tasks: []TaskInfo{}, + } + + result := app.renderTasksView() + + if !strings.Contains(result, "Running Tasks (0)") { + t.Errorf("Expected empty task count, got: %s", result) + } + if !strings.Contains(result, "No running tasks") { + t.Errorf("Expected 'No running tasks' message, got: %s", result) + } +} + +func TestRenderTasksView_SingleTask(t *testing.T) { + app := &App{ + tasks: []TaskInfo{ + { + Action: "indices:data/write/bulk[s]", + TaskID: "node1:12345", + RunningTime: "5s", + Node: "i-0abc123", + IP: "10.0.1.5", + Description: "update-by-query [my_index]", + }, + }, + } + + result := app.renderTasksView() + + if !strings.Contains(result, "Running Tasks (1)") { + t.Errorf("Expected task count of 1, got: %s", result) + } + if !strings.Contains(result, "indices:data/write/bulk[s]") { + t.Errorf("Expected full action string, got: %s", result) + } + if !strings.Contains(result, "node1:12345") { + t.Errorf("Expected task ID, got: %s", result) + } + if !strings.Contains(result, "10.0.1.5") { + t.Errorf("Expected IP address, got: %s", result) + } + if !strings.Contains(result, "i-0abc123") { + t.Errorf("Expected node name, got: %s", result) + } + if !strings.Contains(result, "update-by-query [my_index]") { + t.Errorf("Expected description, got: %s", result) + } +} + +func TestRenderTasksView_Sorting(t *testing.T) { + app := &App{ + tasks: []TaskInfo{ + { + Action: "task1", + TaskID: "task1", + RunningTime: "5s", + Node: "node1", + }, + { + Action: "task2", + TaskID: "task2", + RunningTime: "65s", + Node: "node2", + }, + { + Action: "task3", + TaskID: "task3", + RunningTime: "35s", + Node: "node3", + }, + }, + } + + result := app.renderTasksView() + + // Find positions of tasks in output + pos1 := strings.Index(result, "task1") + pos2 := strings.Index(result, "task2") + pos3 := strings.Index(result, "task3") + + // task2 (65s) should come first, then task3 (35s), then task1 (5s) + if pos2 >= pos3 || pos3 >= pos1 { + t.Errorf("Tasks not sorted by running time (longest first). Order: task2=%d, task3=%d, task1=%d", pos2, pos3, pos1) + } +} + +func TestRenderTasksView_CriticalWarning(t *testing.T) { + tests := []struct { + name string + runningTime string + expectedMsg string + expectedInMsg string + }{ + { + name: "Critical task >= 60s", + runningTime: "65s", + expectedMsg: "⚠", + expectedInMsg: "running ≥60s", + }, + { + name: "Warning task >= 30s", + runningTime: "35s", + expectedMsg: "⚠", + expectedInMsg: "running ≥30s", + }, + { + name: "Normal task < 30s", + runningTime: "10s", + expectedMsg: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := &App{ + tasks: []TaskInfo{ + { + Action: "test:action", + TaskID: "test:1", + RunningTime: tt.runningTime, + Node: "testnode", + }, + }, + } + + result := app.renderTasksView() + + if tt.expectedMsg != "" { + if !strings.Contains(result, tt.expectedMsg) { + t.Errorf("Expected warning message, got: %s", result) + } + if tt.expectedInMsg != "" && !strings.Contains(result, tt.expectedInMsg) { + t.Errorf("Expected '%s' in message, got: %s", tt.expectedInMsg, result) + } + } + }) + } +} + +func TestRenderTasksView_TaskIDTruncation(t *testing.T) { + longTaskID := "node1234567890123456789012345678901234567890extra" + app := &App{ + tasks: []TaskInfo{ + { + Action: "test:action", + TaskID: longTaskID, + RunningTime: "5s", + Node: "node1", + }, + }, + } + + result := app.renderTasksView() + + if strings.Contains(result, longTaskID) { + t.Errorf("Task ID should be truncated, but found full ID: %s", result) + } + if !strings.Contains(result, "...") { + t.Errorf("Expected truncation indicator '...', got: %s", result) + } +} + +func TestRenderTasksView_DescriptionTruncation(t *testing.T) { + longDesc := strings.Repeat("a", 130) // Longer than 120 chars + app := &App{ + tasks: []TaskInfo{ + { + Action: "test:action", + TaskID: "test:1", + RunningTime: "5s", + Node: "node1", + Description: longDesc, + }, + }, + } + + result := app.renderTasksView() + + if strings.Contains(result, longDesc) { + t.Errorf("Description should be truncated, but found full description") + } + if !strings.Contains(result, "...") { + t.Errorf("Expected truncation indicator '...', got: %s", result) + } +} + +func TestRenderTasksView_IPDisplay(t *testing.T) { + tests := []struct { + name string + ip string + expectedIP bool + expectedFmt string + }{ + { + name: "With IP", + ip: "10.0.1.5", + expectedIP: true, + expectedFmt: "IP: 10.0.1.5", + }, + { + name: "Without IP", + ip: "", + expectedIP: false, + expectedFmt: "Node:", + }, + { + name: "Null IP", + ip: "null", + expectedIP: false, + expectedFmt: "Node:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := &App{ + tasks: []TaskInfo{ + { + Action: "test:action", + TaskID: "test:1", + RunningTime: "5s", + Node: "node1", + IP: tt.ip, + }, + }, + } + + result := app.renderTasksView() + + if tt.expectedIP { + if !strings.Contains(result, tt.expectedFmt) { + t.Errorf("Expected IP format '%s', got: %s", tt.expectedFmt, result) + } + } else { + if strings.Contains(result, "IP:") { + t.Errorf("Should not contain 'IP:' when IP is empty/null, got: %s", result) + } + } + }) + } +} + +func TestRenderTasksView_NullDescription(t *testing.T) { + app := &App{ + tasks: []TaskInfo{ + { + Action: "test:action", + TaskID: "test:1", + RunningTime: "5s", + Node: "node1", + Description: "null", + }, + }, + } + + result := app.renderTasksView() + + // Should not display "Details:" line when description is "null" + if strings.Contains(result, "Details:") { + t.Errorf("Should not display Details when description is 'null', got: %s", result) + } +} + +func TestRenderTasksView_Limit50(t *testing.T) { + // Create 60 tasks + tasks := make([]TaskInfo, 60) + for i := 0; i < 60; i++ { + tasks[i] = TaskInfo{ + Action: "test:action", + TaskID: "test:1", + RunningTime: "5s", + Node: "node1", + } + } + + app := &App{ + tasks: tasks, + } + + result := app.renderTasksView() + + if !strings.Contains(result, "Running Tasks (60)") { + t.Errorf("Expected total task count of 60, got: %s", result) + } + if !strings.Contains(result, "... and 10 more tasks") { + t.Errorf("Expected message about 10 more tasks, got: %s", result) + } +} + +func TestRenderPendingTasksView_Empty(t *testing.T) { + app := &App{ + pendingTasks: []PendingTaskInfo{}, + } + + result := app.renderPendingTasksView() + + if !strings.Contains(result, "Pending Cluster Tasks (0)") { + t.Errorf("Expected empty pending task count, got: %s", result) + } + if !strings.Contains(result, "No pending tasks") { + t.Errorf("Expected 'No pending tasks' message, got: %s", result) + } +} + +func TestRenderPendingTasksView_SingleTask(t *testing.T) { + app := &App{ + pendingTasks: []PendingTaskInfo{ + { + InsertOrder: "1", + TimeInQueue: "100ms", + Priority: "URGENT", + Source: "create-index [test_index]", + }, + }, + } + + result := app.renderPendingTasksView() + + if !strings.Contains(result, "Pending Cluster Tasks (1)") { + t.Errorf("Expected pending task count of 1, got: %s", result) + } + if !strings.Contains(result, "create-index [test_index]") { + t.Errorf("Expected source, got: %s", result) + } + if !strings.Contains(result, "URGENT") { + t.Errorf("Expected priority, got: %s", result) + } + if !strings.Contains(result, "Insert Order:") { + t.Errorf("Expected insert order label, got: %s", result) + } +} + +func TestRenderPendingTasksView_CriticalWarning(t *testing.T) { + tests := []struct { + name string + timeInQueue string + expectedMsg string + expectedInMsg string + }{ + { + name: "Critical task >= 5s", + timeInQueue: "6s", + expectedMsg: "⚠ CRITICAL:", + expectedInMsg: "queued ≥5s", + }, + { + name: "Warning task >= 1s", + timeInQueue: "1500ms", + expectedMsg: "⚠", + expectedInMsg: "queued ≥1s", + }, + { + name: "Normal task < 1s", + timeInQueue: "500ms", + expectedMsg: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := &App{ + pendingTasks: []PendingTaskInfo{ + { + InsertOrder: "1", + TimeInQueue: tt.timeInQueue, + Priority: "NORMAL", + Source: "test-task", + }, + }, + } + + result := app.renderPendingTasksView() + + if tt.expectedMsg != "" { + if !strings.Contains(result, tt.expectedMsg) { + t.Errorf("Expected warning message '%s', got: %s", tt.expectedMsg, result) + } + if tt.expectedInMsg != "" && !strings.Contains(result, tt.expectedInMsg) { + t.Errorf("Expected '%s' in message, got: %s", tt.expectedInMsg, result) + } + } + }) + } +} + +func TestRenderPendingTasksView_MultipleTasks(t *testing.T) { + app := &App{ + pendingTasks: []PendingTaskInfo{ + { + InsertOrder: "1", + TimeInQueue: "100ms", + Priority: "URGENT", + Source: "create-index [index1]", + }, + { + InsertOrder: "2", + TimeInQueue: "50ms", + Priority: "HIGH", + Source: "update-mapping [index2]", + }, + }, + } + + result := app.renderPendingTasksView() + + if !strings.Contains(result, "Pending Cluster Tasks (2)") { + t.Errorf("Expected pending task count of 2, got: %s", result) + } + if !strings.Contains(result, "create-index [index1]") { + t.Errorf("Expected first task source, got: %s", result) + } + if !strings.Contains(result, "update-mapping [index2]") { + t.Errorf("Expected second task source, got: %s", result) + } + if !strings.Contains(result, "URGENT") { + t.Errorf("Expected URGENT priority, got: %s", result) + } + if !strings.Contains(result, "HIGH") { + t.Errorf("Expected HIGH priority, got: %s", result) + } +}