Pulse/internal/ai/alert_provider_test.go
rcourtman fd1f94babf fix: AI Commands toggle now updates immediately in UI. Related to #952
Previously, toggling AI Commands in the Agents view would show a pending state
and wait for the agent to confirm the change (up to 2 minutes). If the agent
was slow to report or the WebSocket update was missed, the toggle would appear
stuck.

Now, UpdateHostAgentConfig also updates the Host model in state immediately,
providing instant UI feedback. The agent will still receive the config on its
next report, but users see the change right away.

Added SetHostCommandsEnabled function to models.State for this purpose.
2025-12-29 13:56:29 +00:00

231 lines
6.7 KiB
Go

package ai
import (
"strings"
"testing"
"time"
)
type stubAlertProvider struct {
active []AlertInfo
resolved []ResolvedAlertInfo
}
func (s *stubAlertProvider) GetActiveAlerts() []AlertInfo { return s.active }
func (s *stubAlertProvider) GetRecentlyResolved(minutes int) []ResolvedAlertInfo { return s.resolved }
func (s *stubAlertProvider) GetAlertsByResource(resourceID string) []AlertInfo {
out := make([]AlertInfo, 0)
for _, a := range s.active {
if a.ResourceID == resourceID {
out = append(out, a)
}
}
return out
}
func (s *stubAlertProvider) GetAlertHistory(resourceID string, limit int) []ResolvedAlertInfo {
out := make([]ResolvedAlertInfo, 0)
for _, a := range s.resolved {
if a.ResourceID == resourceID {
out = append(out, a)
if len(out) >= limit {
break
}
}
}
return out
}
func TestService_buildAlertContext(t *testing.T) {
now := time.Now()
s := &Service{}
s.SetAlertProvider(&stubAlertProvider{
active: []AlertInfo{
{
ID: "a1",
Type: "cpu",
Level: "critical",
ResourceID: "node:pve1",
ResourceName: "pve1",
ResourceType: "node",
Node: "pve1",
Message: "cpu high",
Value: 95,
Threshold: 80,
StartTime: now.Add(-5 * time.Minute),
Duration: "5 mins",
Acknowledged: true,
},
{
ID: "a2",
Type: "memory",
Level: "warning",
ResourceID: "guest:100",
ResourceName: "vm-100",
ResourceType: "guest",
Message: "mem high",
Value: 80,
Threshold: 75,
StartTime: now.Add(-2 * time.Minute),
Duration: "2 mins",
},
},
resolved: []ResolvedAlertInfo{
{
AlertInfo: AlertInfo{
ID: "r1",
Type: "disk",
Level: "warning",
ResourceID: "storage:local",
ResourceName: "local",
Message: "disk ok",
Duration: "10 mins",
},
ResolvedTime: now.Add(-2 * time.Minute),
Duration: "10 mins",
},
},
})
ctx := s.buildAlertContext()
if !strings.Contains(ctx, "## Alert Status") {
t.Fatalf("expected alert status header, got: %s", ctx)
}
if !strings.Contains(ctx, "### Active Alerts") || !strings.Contains(ctx, "**Critical:**") || !strings.Contains(ctx, "**Warning:**") {
t.Fatalf("expected active alert sections, got: %s", ctx)
}
if !strings.Contains(ctx, "[ACKNOWLEDGED]") || !strings.Contains(ctx, "on node pve1") {
t.Fatalf("expected acknowledged/node formatting, got: %s", ctx)
}
if !strings.Contains(ctx, "### Recently Resolved") {
t.Fatalf("expected recently resolved section, got: %s", ctx)
}
}
func TestService_buildAlertContext_Empty(t *testing.T) {
s := &Service{}
if got := s.buildAlertContext(); got != "" {
t.Fatalf("expected empty string, got: %q", got)
}
s.SetAlertProvider(&stubAlertProvider{})
if got := s.buildAlertContext(); got != "" {
t.Fatalf("expected empty string when no alerts, got: %q", got)
}
}
func TestService_buildAlertContext_NoActiveWithResolved(t *testing.T) {
now := time.Now()
resolved := make([]ResolvedAlertInfo, 0, 6)
for i := 0; i < 6; i++ {
resolved = append(resolved, ResolvedAlertInfo{
AlertInfo: AlertInfo{
ID: "r" + string(rune('a'+i)),
Type: "disk",
Level: "warning",
ResourceID: "storage:local",
ResourceName: "local",
Message: "disk ok",
Duration: "10 mins",
},
ResolvedTime: now.Add(-time.Duration(i) * time.Minute),
Duration: "10 mins",
})
}
s := &Service{}
s.SetAlertProvider(&stubAlertProvider{
active: nil,
resolved: resolved,
})
ctx := s.buildAlertContext()
if !strings.Contains(ctx, "No Active Alerts") {
t.Fatalf("expected no active alerts section, got: %s", ctx)
}
if !strings.Contains(ctx, "Recently Resolved") {
t.Fatalf("expected recently resolved section, got: %s", ctx)
}
if !strings.Contains(ctx, "... and 1 more") {
t.Fatalf("expected overflow line for resolved alerts, got: %s", ctx)
}
}
func TestService_buildTargetAlertContext(t *testing.T) {
s := &Service{}
s.SetAlertProvider(&stubAlertProvider{
active: []AlertInfo{
{ID: "a1", Level: "critical", Type: "cpu", ResourceID: "node:pve1", ResourceName: "pve1", Duration: "1 min", Value: 90, Threshold: 80},
{ID: "a2", Level: "warning", Type: "memory", ResourceID: "node:pve2", ResourceName: "pve2", Duration: "1 min", Value: 80, Threshold: 70},
},
})
got := s.buildTargetAlertContext("node:pve1")
if !strings.Contains(got, "Active Alerts for This Resource") || !strings.Contains(got, "pve1") {
t.Fatalf("unexpected context: %s", got)
}
if strings.Contains(got, "pve2") {
t.Fatalf("unexpected extra resource: %s", got)
}
}
func TestService_buildTargetAlertContext_Empty(t *testing.T) {
s := &Service{}
if got := s.buildTargetAlertContext("node:pve1"); got != "" {
t.Fatalf("expected empty string with no provider, got: %q", got)
}
s.SetAlertProvider(&stubAlertProvider{
active: []AlertInfo{
{ID: "a1", Level: "critical", Type: "cpu", ResourceID: "node:pve1", ResourceName: "pve1"},
},
})
if got := s.buildTargetAlertContext(""); got != "" {
t.Fatalf("expected empty string for empty resource ID, got: %q", got)
}
if got := s.buildTargetAlertContext("node:missing"); got != "" {
t.Fatalf("expected empty string for missing resource, got: %q", got)
}
}
func TestFormatTimeAgo(t *testing.T) {
now := time.Now()
if got := formatTimeAgo(now.Add(-10 * time.Second)); got != "just now" {
t.Fatalf("formatTimeAgo(<1m) = %q", got)
}
if got := formatTimeAgo(now.Add(-1 * time.Minute)); got != "1 minute" {
t.Fatalf("formatTimeAgo(1m) = %q", got)
}
if got := formatTimeAgo(now.Add(-2 * time.Minute)); got != "2 minutes" {
t.Fatalf("formatTimeAgo(2m) = %q", got)
}
if got := formatTimeAgo(now.Add(-1 * time.Hour)); got != "1 hour" {
t.Fatalf("formatTimeAgo(1h) = %q", got)
}
if got := formatTimeAgo(now.Add(-2 * time.Hour)); got != "2 hours" {
t.Fatalf("formatTimeAgo(2h) = %q", got)
}
if got := formatTimeAgo(now.Add(-24 * time.Hour)); got != "1 day" {
t.Fatalf("formatTimeAgo(1d) = %q", got)
}
if got := formatTimeAgo(now.Add(-48 * time.Hour)); got != "2 days" {
t.Fatalf("formatTimeAgo(2d) = %q", got)
}
}
func TestGenerateAlertInvestigationPrompt(t *testing.T) {
out := GenerateAlertInvestigationPrompt(AlertInvestigationRequest{
Level: "critical",
ResourceName: "pve1",
ResourceType: "node",
AlertType: "cpu",
Value: 95,
Threshold: 80,
Duration: "5 mins",
Node: "pve1",
})
if !strings.Contains(out, "Investigate this CRITICAL alert") ||
!strings.Contains(out, "**Resource:** pve1 (node)") ||
!strings.Contains(out, "**Node:** pve1") {
t.Fatalf("unexpected prompt: %s", out)
}
}