diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AgentStateEvaluator.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AgentStateEvaluator.java new file mode 100644 index 00000000..6e15bd14 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AgentStateEvaluator.java @@ -0,0 +1,61 @@ +package com.cameleer.server.app.alerting.eval; + +import com.cameleer.server.core.agent.AgentInfo; +import com.cameleer.server.core.agent.AgentRegistryService; +import com.cameleer.server.core.agent.AgentState; +import com.cameleer.server.core.alerting.AgentStateCondition; +import com.cameleer.server.core.alerting.AlertRule; +import com.cameleer.server.core.alerting.AlertScope; +import com.cameleer.server.core.alerting.ConditionKind; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +@Component +public class AgentStateEvaluator implements ConditionEvaluator { + + private final AgentRegistryService registry; + + public AgentStateEvaluator(AgentRegistryService registry) { + this.registry = registry; + } + + @Override + public ConditionKind kind() { return ConditionKind.AGENT_STATE; } + + @Override + public EvalResult evaluate(AgentStateCondition c, AlertRule rule, EvalContext ctx) { + AgentState target = AgentState.valueOf(c.state()); + Instant cutoff = ctx.now().minusSeconds(c.forSeconds()); + + List hits = registry.findAll().stream() + .filter(a -> matchesScope(a, c.scope())) + .filter(a -> a.state() == target) + .filter(a -> a.lastHeartbeat() != null && a.lastHeartbeat().isBefore(cutoff)) + .toList(); + + if (hits.isEmpty()) return EvalResult.Clear.INSTANCE; + + AgentInfo first = hits.get(0); + return new EvalResult.Firing( + (double) hits.size(), null, + Map.of( + "agent", Map.of( + "id", first.instanceId(), + "name", first.displayName(), + "state", first.state().name() + ), + "app", Map.of("slug", first.applicationId()) + ) + ); + } + + private static boolean matchesScope(AgentInfo a, AlertScope s) { + if (s == null) return true; + if (s.appSlug() != null && !s.appSlug().equals(a.applicationId())) return false; + if (s.agentId() != null && !s.agentId().equals(a.instanceId())) return false; + return true; + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AgentStateEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AgentStateEvaluatorTest.java new file mode 100644 index 00000000..814f6228 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AgentStateEvaluatorTest.java @@ -0,0 +1,104 @@ +package com.cameleer.server.app.alerting.eval; + +import com.cameleer.server.core.agent.AgentInfo; +import com.cameleer.server.core.agent.AgentRegistryService; +import com.cameleer.server.core.agent.AgentState; +import com.cameleer.server.core.alerting.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentStateEvaluatorTest { + + private AgentRegistryService registry; + private AgentStateEvaluator eval; + + private static final UUID ENV_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + private static final UUID RULE_ID = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static final Instant NOW = Instant.parse("2026-04-19T10:00:00Z"); + + @BeforeEach + void setUp() { + registry = mock(AgentRegistryService.class); + eval = new AgentStateEvaluator(registry); + } + + private AlertRule ruleWith(AlertCondition condition) { + return new AlertRule(RULE_ID, ENV_ID, "test", null, + AlertSeverity.WARNING, true, condition.kind(), condition, + 60, 0, 0, null, null, List.of(), List.of(), + null, null, null, Map.of(), null, null, null, null); + } + + @Test + void firesWhenAgentInTargetStateForScope() { + when(registry.findAll()).thenReturn(List.of( + new AgentInfo("a1", "Agent1", "orders", ENV_ID.toString(), "1.0", + List.of(), Map.of(), AgentState.DEAD, + NOW.minusSeconds(200), NOW.minusSeconds(120), null) + )); + var condition = new AgentStateCondition(new AlertScope("orders", null, null), "DEAD", 60); + var rule = ruleWith(condition); + EvalResult r = eval.evaluate(condition, rule, new EvalContext("default", NOW, new TickCache())); + assertThat(r).isInstanceOf(EvalResult.Firing.class); + var firing = (EvalResult.Firing) r; + assertThat(firing.currentValue()).isEqualTo(1.0); + } + + @Test + void clearWhenNoMatchingAgents() { + when(registry.findAll()).thenReturn(List.of( + new AgentInfo("a1", "Agent1", "orders", ENV_ID.toString(), "1.0", + List.of(), Map.of(), AgentState.LIVE, + NOW.minusSeconds(200), NOW.minusSeconds(10), null) + )); + var condition = new AgentStateCondition(new AlertScope("orders", null, null), "DEAD", 60); + var rule = ruleWith(condition); + EvalResult r = eval.evaluate(condition, rule, new EvalContext("default", NOW, new TickCache())); + assertThat(r).isEqualTo(EvalResult.Clear.INSTANCE); + } + + @Test + void clearWhenAgentInStateBelowForSecondsCutoff() { + // Agent is DEAD but only 30s ago — forSeconds=60 → not yet long enough + when(registry.findAll()).thenReturn(List.of( + new AgentInfo("a1", "Agent1", "orders", ENV_ID.toString(), "1.0", + List.of(), Map.of(), AgentState.DEAD, + NOW.minusSeconds(200), NOW.minusSeconds(30), null) + )); + var condition = new AgentStateCondition(new AlertScope("orders", null, null), "DEAD", 60); + var rule = ruleWith(condition); + EvalResult r = eval.evaluate(condition, rule, new EvalContext("default", NOW, new TickCache())); + assertThat(r).isEqualTo(EvalResult.Clear.INSTANCE); + } + + @Test + void kindIsAgentState() { + assertThat(eval.kind()).isEqualTo(ConditionKind.AGENT_STATE); + } + + @Test + void scopeFilterByAgentId() { + when(registry.findAll()).thenReturn(List.of( + new AgentInfo("a1", "Agent1", "orders", ENV_ID.toString(), "1.0", + List.of(), Map.of(), AgentState.DEAD, + NOW.minusSeconds(200), NOW.minusSeconds(120), null), + new AgentInfo("a2", "Agent2", "orders", ENV_ID.toString(), "1.0", + List.of(), Map.of(), AgentState.DEAD, + NOW.minusSeconds(200), NOW.minusSeconds(120), null) + )); + // filter to only a1 + var condition = new AgentStateCondition(new AlertScope("orders", null, "a1"), "DEAD", 60); + EvalResult r = eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache())); + assertThat(r).isInstanceOf(EvalResult.Firing.class); + assertThat(((EvalResult.Firing) r).currentValue()).isEqualTo(1.0); + } +}