mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
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.
231 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|