{@code alerting_webhook_delivery_duration_seconds} — webhook POST latency
*
- * Gauges (read from PostgreSQL on each scrape; low scrape frequency = low DB load):
+ * Gauges (read from PostgreSQL, cached for {@link #DEFAULT_GAUGE_TTL} to amortise
+ * Prometheus scrapes that may fire every few seconds):
*
*
{@code alerting_rules_total{state=enabled|disabled}} — rule counts from {@code alert_rules}
- *
{@code alerting_instances_total{state,severity}} — instance counts grouped from {@code alert_instances}
+ *
{@code alerting_instances_total{state}} — instance counts grouped from {@code alert_instances}
*
*/
@Component
@@ -41,11 +50,13 @@ public class AlertingMetrics {
private static final Logger log = LoggerFactory.getLogger(AlertingMetrics.class);
+ /** Default time-to-live for the gauge-supplier caches. */
+ static final Duration DEFAULT_GAUGE_TTL = Duration.ofSeconds(30);
+
private final MeterRegistry registry;
- private final JdbcTemplate jdbc;
// Cached counters per kind (lazy-initialized)
- private final ConcurrentMap evalErrorCounters = new ConcurrentHashMap<>();
+ private final ConcurrentMap evalErrorCounters = new ConcurrentHashMap<>();
private final ConcurrentMap circuitOpenCounters = new ConcurrentHashMap<>();
private final ConcurrentMap evalDurationTimers = new ConcurrentHashMap<>();
@@ -55,33 +66,81 @@ public class AlertingMetrics {
// Shared delivery timer
private final Timer webhookDeliveryTimer;
+ // TTL-cached gauge suppliers registered so tests can force a read cycle.
+ private final TtlCache enabledRulesCache;
+ private final TtlCache disabledRulesCache;
+ private final Map instancesByStateCaches;
+
+ /**
+ * Production constructor: wraps the Postgres-backed gauge suppliers in a
+ * 30-second TTL cache so Prometheus scrapes don't cause per-scrape DB queries.
+ */
+ @Autowired
public AlertingMetrics(MeterRegistry registry, JdbcTemplate jdbc) {
+ this(registry,
+ () -> countRules(jdbc, true),
+ () -> countRules(jdbc, false),
+ state -> countInstances(jdbc, state),
+ DEFAULT_GAUGE_TTL,
+ Instant::now);
+ }
+
+ /**
+ * Test-friendly constructor accepting the three gauge suppliers that are
+ * exercised in the {@link AlertingMetricsCachingTest} plan sketch. The
+ * {@code instancesSupplier} is used for every {@link AlertState}.
+ */
+ AlertingMetrics(MeterRegistry registry,
+ Supplier enabledRulesSupplier,
+ Supplier disabledRulesSupplier,
+ Supplier instancesSupplier,
+ Duration gaugeTtl,
+ Supplier clock) {
+ this(registry,
+ enabledRulesSupplier,
+ disabledRulesSupplier,
+ state -> instancesSupplier.get(),
+ gaugeTtl,
+ clock);
+ }
+
+ /**
+ * Core constructor: accepts per-state instance supplier so production can
+ * query PostgreSQL with a different value per {@link AlertState}.
+ */
+ private AlertingMetrics(MeterRegistry registry,
+ Supplier enabledRulesSupplier,
+ Supplier disabledRulesSupplier,
+ java.util.function.Function instancesSupplier,
+ Duration gaugeTtl,
+ Supplier clock) {
this.registry = registry;
- this.jdbc = jdbc;
// ── Static timers ───────────────────────────────────────────────
this.webhookDeliveryTimer = Timer.builder("alerting_webhook_delivery_duration_seconds")
.description("Latency of outbound webhook POST requests")
.register(registry);
- // ── Gauge: rules by enabled/disabled ────────────────────────────
- Gauge.builder("alerting_rules_total", this, m -> m.countRules(true))
+ // ── Gauge: rules by enabled/disabled (cached) ───────────────────
+ this.enabledRulesCache = new TtlCache(enabledRulesSupplier, gaugeTtl, clock);
+ this.disabledRulesCache = new TtlCache(disabledRulesSupplier, gaugeTtl, clock);
+
+ Gauge.builder("alerting_rules_total", enabledRulesCache, TtlCache::getAsDouble)
.tag("state", "enabled")
.description("Number of enabled alert rules")
.register(registry);
- Gauge.builder("alerting_rules_total", this, m -> m.countRules(false))
+ Gauge.builder("alerting_rules_total", disabledRulesCache, TtlCache::getAsDouble)
.tag("state", "disabled")
.description("Number of disabled alert rules")
.register(registry);
- // ── Gauges: alert instances by state × severity ─────────────────
+ // ── Gauges: alert instances by state (cached) ───────────────────
+ this.instancesByStateCaches = new EnumMap<>(AlertState.class);
for (AlertState state : AlertState.values()) {
- // Capture state as effectively-final for lambda
- AlertState capturedState = state;
- // We register one gauge per state (summed across severities) for simplicity;
- // per-severity breakdown would require a dynamic MultiGauge.
- Gauge.builder("alerting_instances_total", this,
- m -> m.countInstances(capturedState))
+ AlertState captured = state;
+ TtlCache cache = new TtlCache(() -> instancesSupplier.apply(captured), gaugeTtl, clock);
+ this.instancesByStateCaches.put(state, cache);
+ Gauge.builder("alerting_instances_total", cache, TtlCache::getAsDouble)
.tag("state", state.name().toLowerCase())
.description("Number of alert instances by state")
.register(registry);
@@ -148,28 +207,73 @@ public class AlertingMetrics {
.increment();
}
- // ── Gauge suppliers (called on each Prometheus scrape) ──────────────
-
- private double countRules(boolean enabled) {
- try {
- Long count = jdbc.queryForObject(
- "SELECT COUNT(*) FROM alert_rules WHERE enabled = ?", Long.class, enabled);
- return count == null ? 0.0 : count.doubleValue();
- } catch (Exception e) {
- log.debug("alerting_rules gauge query failed: {}", e.getMessage());
- return 0.0;
+ /**
+ * Force a read of every TTL-cached gauge supplier. Used by tests to simulate
+ * a Prometheus scrape without needing a real registry scrape pipeline.
+ */
+ void snapshotAllGauges() {
+ List all = new ArrayList<>();
+ all.add(enabledRulesCache);
+ all.add(disabledRulesCache);
+ all.addAll(instancesByStateCaches.values());
+ for (TtlCache c : all) {
+ c.getAsDouble();
}
}
- private double countInstances(AlertState state) {
+ // ── Gauge suppliers (queried at most once per TTL) ──────────────────
+
+ private static long countRules(JdbcTemplate jdbc, boolean enabled) {
+ try {
+ Long count = jdbc.queryForObject(
+ "SELECT COUNT(*) FROM alert_rules WHERE enabled = ?", Long.class, enabled);
+ return count == null ? 0L : count;
+ } catch (Exception e) {
+ log.debug("alerting_rules gauge query failed: {}", e.getMessage());
+ return 0L;
+ }
+ }
+
+ private static long countInstances(JdbcTemplate jdbc, AlertState state) {
try {
Long count = jdbc.queryForObject(
"SELECT COUNT(*) FROM alert_instances WHERE state = ?::alert_state_enum",
Long.class, state.name());
- return count == null ? 0.0 : count.doubleValue();
+ return count == null ? 0L : count;
} catch (Exception e) {
log.debug("alerting_instances gauge query failed: {}", e.getMessage());
- return 0.0;
+ return 0L;
+ }
+ }
+
+ /**
+ * Lightweight TTL cache around a {@code Supplier}. Every call to
+ * {@link #getAsDouble()} either returns the cached value (if {@code clock.get()
+ * - lastRead < ttl}) or invokes the delegate and refreshes the cache.
+ *
+ *
Used to amortise Postgres queries behind Prometheus gauges over a
+ * 30-second TTL (see {@link AlertingMetrics#DEFAULT_GAUGE_TTL}).
+ */
+ static final class TtlCache {
+ private final Supplier delegate;
+ private final Duration ttl;
+ private final Supplier clock;
+ private volatile Instant lastRead = Instant.MIN;
+ private volatile long cached = 0L;
+
+ TtlCache(Supplier delegate, Duration ttl, Supplier clock) {
+ this.delegate = delegate;
+ this.ttl = ttl;
+ this.clock = clock;
+ }
+
+ synchronized double getAsDouble() {
+ Instant now = clock.get();
+ if (lastRead == Instant.MIN || Duration.between(lastRead, now).compareTo(ttl) >= 0) {
+ cached = delegate.get();
+ lastRead = now;
+ }
+ return cached;
}
}
}
diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java
index 328a68e6..81d6719f 100644
--- a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java
@@ -7,6 +7,8 @@ import com.cameleer.server.core.outbound.OutboundConnectionService;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
+import java.net.URI;
+import java.net.URISyntaxException;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@@ -15,20 +17,24 @@ public class OutboundConnectionServiceImpl implements OutboundConnectionService
private final OutboundConnectionRepository repo;
private final AlertRuleRepository ruleRepo;
+ private final SsrfGuard ssrfGuard;
private final String tenantId;
public OutboundConnectionServiceImpl(
OutboundConnectionRepository repo,
AlertRuleRepository ruleRepo,
+ SsrfGuard ssrfGuard,
String tenantId) {
this.repo = repo;
this.ruleRepo = ruleRepo;
+ this.ssrfGuard = ssrfGuard;
this.tenantId = tenantId;
}
@Override
public OutboundConnection create(OutboundConnection draft, String actingUserId) {
assertNameUnique(draft.name(), null);
+ validateUrl(draft.url());
OutboundConnection c = new OutboundConnection(
UUID.randomUUID(), tenantId, draft.name(), draft.description(),
draft.url(), draft.method(), draft.defaultHeaders(), draft.defaultBodyTmpl(),
@@ -46,6 +52,7 @@ public class OutboundConnectionServiceImpl implements OutboundConnectionService
if (!existing.name().equals(draft.name())) {
assertNameUnique(draft.name(), id);
}
+ validateUrl(draft.url());
// Narrowing allowed-envs guard: if the new draft restricts to a non-empty set of envs,
// find any envs that existed before but are absent in the draft.
@@ -107,4 +114,23 @@ public class OutboundConnectionServiceImpl implements OutboundConnectionService
}
});
}
+
+ /**
+ * Validate the webhook URL against SSRF pitfalls. Translates the guard's
+ * {@link IllegalArgumentException} into a 400 Bad Request with the guard's
+ * message preserved, so the client sees e.g. "private or loopback".
+ */
+ private void validateUrl(String url) {
+ URI uri;
+ try {
+ uri = new URI(url);
+ } catch (URISyntaxException e) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid URL: " + url);
+ }
+ try {
+ ssrfGuard.validate(uri);
+ } catch (IllegalArgumentException e) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e);
+ }
+ }
}
diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java
new file mode 100644
index 00000000..557f6f19
--- /dev/null
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java
@@ -0,0 +1,69 @@
+package com.cameleer.server.app.outbound;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.URI;
+import java.net.UnknownHostException;
+
+/**
+ * Validates outbound webhook URLs against SSRF pitfalls: rejects hosts that resolve to
+ * loopback, link-local, or RFC-1918 private ranges (and IPv6 equivalents).
+ *
+ * Per spec §17. The `cameleer.server.outbound-http.allow-private-targets` flag bypasses
+ * the check for dev environments where webhooks legitimately point at local services.
+ */
+@Component
+public class SsrfGuard {
+
+ private final boolean allowPrivate;
+
+ public SsrfGuard(
+ @Value("${cameleer.server.outbound-http.allow-private-targets:false}") boolean allowPrivate
+ ) {
+ this.allowPrivate = allowPrivate;
+ }
+
+ public void validate(URI uri) {
+ if (allowPrivate) return;
+ String host = uri.getHost();
+ if (host == null || host.isBlank()) {
+ throw new IllegalArgumentException("URL must include a host: " + uri);
+ }
+ if ("localhost".equalsIgnoreCase(host)) {
+ throw new IllegalArgumentException("URL host resolves to private or loopback range: " + host);
+ }
+ InetAddress[] addrs;
+ try {
+ addrs = InetAddress.getAllByName(host);
+ } catch (UnknownHostException e) {
+ throw new IllegalArgumentException("URL host does not resolve: " + host, e);
+ }
+ for (InetAddress addr : addrs) {
+ if (isPrivate(addr)) {
+ throw new IllegalArgumentException("URL host resolves to private or loopback range: " + host + " -> " + addr.getHostAddress());
+ }
+ }
+ }
+
+ private static boolean isPrivate(InetAddress addr) {
+ if (addr.isLoopbackAddress()) return true;
+ if (addr.isLinkLocalAddress()) return true;
+ if (addr.isSiteLocalAddress()) return true; // 10/8, 172.16/12, 192.168/16
+ if (addr.isAnyLocalAddress()) return true; // 0.0.0.0, ::
+ if (addr instanceof Inet6Address ip6) {
+ byte[] raw = ip6.getAddress();
+ // fc00::/7 unique-local
+ if ((raw[0] & 0xfe) == 0xfc) return true;
+ }
+ if (addr instanceof Inet4Address ip4) {
+ byte[] raw = ip4.getAddress();
+ // 169.254.0.0/16 link-local (also matches isLinkLocalAddress but doubled-up for safety)
+ if ((raw[0] & 0xff) == 169 && (raw[1] & 0xff) == 254) return true;
+ }
+ return false;
+ }
+}
diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java
index bea1fab5..6c8a2182 100644
--- a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java
@@ -1,6 +1,7 @@
package com.cameleer.server.app.outbound.config;
import com.cameleer.server.app.outbound.OutboundConnectionServiceImpl;
+import com.cameleer.server.app.outbound.SsrfGuard;
import com.cameleer.server.app.outbound.crypto.SecretCipher;
import com.cameleer.server.app.outbound.storage.PostgresOutboundConnectionRepository;
import com.cameleer.server.core.alerting.AlertRuleRepository;
@@ -31,7 +32,8 @@ public class OutboundBeanConfig {
public OutboundConnectionService outboundConnectionService(
OutboundConnectionRepository repo,
AlertRuleRepository ruleRepo,
+ SsrfGuard ssrfGuard,
@Value("${cameleer.server.tenant.id:default}") String tenantId) {
- return new OutboundConnectionServiceImpl(repo, ruleRepo, tenantId);
+ return new OutboundConnectionServiceImpl(repo, ruleRepo, ssrfGuard, tenantId);
}
}
diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java
new file mode 100644
index 00000000..194bc982
--- /dev/null
+++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java
@@ -0,0 +1,111 @@
+package com.cameleer.server.app.alerting.metrics;
+
+import com.cameleer.server.core.alerting.AlertState;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies that {@link AlertingMetrics} caches gauge values for a configurable TTL,
+ * so that Prometheus scrapes do not cause one Postgres query per scrape.
+ */
+class AlertingMetricsCachingTest {
+
+ @Test
+ void gaugeSupplierIsCalledAtMostOncePerTtl() {
+ // The instances supplier is shared across every AlertState gauge, so each
+ // full gauge snapshot invokes it once per AlertState (one cache per state).
+ final int stateCount = AlertState.values().length;
+
+ AtomicInteger enabledRulesCalls = new AtomicInteger();
+ AtomicInteger disabledRulesCalls = new AtomicInteger();
+ AtomicInteger instancesCalls = new AtomicInteger();
+ AtomicReference now = new AtomicReference<>(Instant.parse("2026-04-20T00:00:00Z"));
+ Supplier clock = now::get;
+
+ MeterRegistry registry = new SimpleMeterRegistry();
+
+ Supplier enabledRulesSupplier = () -> { enabledRulesCalls.incrementAndGet(); return 7L; };
+ Supplier disabledRulesSupplier = () -> { disabledRulesCalls.incrementAndGet(); return 3L; };
+ Supplier instancesSupplier = () -> { instancesCalls.incrementAndGet(); return 5L; };
+
+ AlertingMetrics metrics = new AlertingMetrics(
+ registry,
+ enabledRulesSupplier,
+ disabledRulesSupplier,
+ instancesSupplier,
+ Duration.ofSeconds(30),
+ clock
+ );
+
+ // First scrape — each supplier invoked exactly once per gauge.
+ metrics.snapshotAllGauges();
+ assertThat(enabledRulesCalls.get()).isEqualTo(1);
+ assertThat(disabledRulesCalls.get()).isEqualTo(1);
+ assertThat(instancesCalls.get()).isEqualTo(stateCount);
+
+ // Second scrape within TTL — served from cache.
+ metrics.snapshotAllGauges();
+ assertThat(enabledRulesCalls.get()).isEqualTo(1);
+ assertThat(disabledRulesCalls.get()).isEqualTo(1);
+ assertThat(instancesCalls.get()).isEqualTo(stateCount);
+
+ // Third scrape still within TTL (29 s later) — still cached.
+ now.set(now.get().plusSeconds(29));
+ metrics.snapshotAllGauges();
+ assertThat(enabledRulesCalls.get()).isEqualTo(1);
+ assertThat(disabledRulesCalls.get()).isEqualTo(1);
+ assertThat(instancesCalls.get()).isEqualTo(stateCount);
+
+ // Advance past TTL — next scrape re-queries the delegate.
+ now.set(Instant.parse("2026-04-20T00:00:31Z"));
+ metrics.snapshotAllGauges();
+ assertThat(enabledRulesCalls.get()).isEqualTo(2);
+ assertThat(disabledRulesCalls.get()).isEqualTo(2);
+ assertThat(instancesCalls.get()).isEqualTo(stateCount * 2);
+
+ // Immediate follow-up — back in cache.
+ metrics.snapshotAllGauges();
+ assertThat(enabledRulesCalls.get()).isEqualTo(2);
+ assertThat(disabledRulesCalls.get()).isEqualTo(2);
+ assertThat(instancesCalls.get()).isEqualTo(stateCount * 2);
+ }
+
+ @Test
+ void gaugeValueReflectsCachedResult() {
+ AtomicReference enabledValue = new AtomicReference<>(10L);
+ AtomicReference now = new AtomicReference<>(Instant.parse("2026-04-20T00:00:00Z"));
+
+ MeterRegistry registry = new SimpleMeterRegistry();
+ AlertingMetrics metrics = new AlertingMetrics(
+ registry,
+ enabledValue::get,
+ () -> 0L,
+ () -> 0L,
+ Duration.ofSeconds(30),
+ now::get
+ );
+
+ // Read once — value cached at 10.
+ metrics.snapshotAllGauges();
+
+ // Mutate the underlying supplier output; cache should shield it.
+ enabledValue.set(99L);
+ double cached = registry.find("alerting_rules_total").tag("state", "enabled").gauge().value();
+ assertThat(cached).isEqualTo(10.0);
+
+ // After TTL, new value surfaces.
+ now.set(now.get().plusSeconds(31));
+ metrics.snapshotAllGauges();
+ double refreshed = registry.find("alerting_rules_total").tag("state", "enabled").gauge().value();
+ assertThat(refreshed).isEqualTo(99.0);
+ }
+}
diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java
new file mode 100644
index 00000000..9614d0c6
--- /dev/null
+++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java
@@ -0,0 +1,73 @@
+package com.cameleer.server.app.outbound;
+
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class SsrfGuardTest {
+
+ private final SsrfGuard guard = new SsrfGuard(false); // allow-private disabled by default
+
+ @Test
+ void rejectsLoopbackIpv4() {
+ assertThatThrownBy(() -> guard.validate(URI.create("https://127.0.0.1/webhook")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("private or loopback");
+ }
+
+ @Test
+ void rejectsLocalhostHostname() {
+ assertThatThrownBy(() -> guard.validate(URI.create("https://localhost:8080/x")))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void rejectsRfc1918Ranges() {
+ for (String url : Set.of(
+ "https://10.0.0.1/x",
+ "https://172.16.5.6/x",
+ "https://192.168.1.1/x"
+ )) {
+ assertThatThrownBy(() -> guard.validate(URI.create(url)))
+ .as(url)
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+ }
+
+ @Test
+ void rejectsLinkLocal() {
+ assertThatThrownBy(() -> guard.validate(URI.create("https://169.254.169.254/latest/meta-data/")))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void rejectsIpv6Loopback() {
+ assertThatThrownBy(() -> guard.validate(URI.create("https://[::1]/x")))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void rejectsIpv6UniqueLocal() {
+ assertThatThrownBy(() -> guard.validate(URI.create("https://[fc00::1]/x")))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void acceptsPublicHttps() {
+ // DNS resolution happens inside validate(); this test relies on a public hostname.
+ // Use a literal public IP to avoid network flakiness.
+ // 8.8.8.8 is a public Google DNS IP — not in any private range.
+ assertThat(new SsrfGuard(false)).isNotNull();
+ guard.validate(URI.create("https://8.8.8.8/")); // does not throw
+ }
+
+ @Test
+ void allowPrivateFlagBypassesCheck() {
+ SsrfGuard permissive = new SsrfGuard(true);
+ permissive.validate(URI.create("https://127.0.0.1/")); // must not throw
+ }
+}
diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionSsrfIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionSsrfIT.java
new file mode 100644
index 00000000..6f791b8d
--- /dev/null
+++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionSsrfIT.java
@@ -0,0 +1,67 @@
+package com.cameleer.server.app.outbound.controller;
+
+import com.cameleer.server.app.AbstractPostgresIT;
+import com.cameleer.server.app.TestSecurityHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.TestPropertySource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Dedicated IT that overrides the test-profile default `allow-private-targets=true`
+ * back to `false` so the SSRF guard's production behavior (reject loopback) is
+ * exercised end-to-end through the admin controller.
+ *
+ * Uses {@link DirtiesContext} to avoid polluting the shared context used by the
+ * other ITs which rely on the flag being `true` to hit WireMock on localhost.
+ */
+@TestPropertySource(properties = "cameleer.server.outbound-http.allow-private-targets=false")
+@DirtiesContext
+class OutboundConnectionSsrfIT extends AbstractPostgresIT {
+
+ @Autowired private TestRestTemplate restTemplate;
+ @Autowired private TestSecurityHelper securityHelper;
+
+ private String adminJwt;
+
+ @BeforeEach
+ void setUp() {
+ adminJwt = securityHelper.adminToken();
+ // Seed admin user row since users(user_id) is an FK target.
+ jdbcTemplate.update(
+ "INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING",
+ "test-admin", "test-admin@example.com", "test-admin");
+ jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'");
+ }
+
+ @AfterEach
+ void cleanup() {
+ jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'");
+ jdbcTemplate.update("DELETE FROM users WHERE user_id = 'test-admin'");
+ }
+
+ @Test
+ void rejectsLoopbackUrlOnCreate() {
+ String body = """
+ {"name":"evil","url":"https://127.0.0.1/abuse","method":"POST",
+ "tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}""";
+
+ ResponseEntity resp = restTemplate.exchange(
+ "/api/v1/admin/outbound-connections", HttpMethod.POST,
+ new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
+ String.class);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(resp.getBody()).isNotNull();
+ assertThat(resp.getBody()).contains("private or loopback");
+ }
+}
diff --git a/cameleer-server-app/src/test/resources/application-test.yml b/cameleer-server-app/src/test/resources/application-test.yml
index ce02814a..ec845496 100644
--- a/cameleer-server-app/src/test/resources/application-test.yml
+++ b/cameleer-server-app/src/test/resources/application-test.yml
@@ -17,3 +17,5 @@ cameleer:
bootstraptokenprevious: old-bootstrap-token
infrastructureendpoints: true
jwtsecret: test-jwt-secret-for-integration-tests-only
+ outbound-http:
+ allow-private-targets: true
diff --git a/deploy/docker/postgres-init.sql b/deploy/docker/postgres-init.sql
new file mode 100644
index 00000000..6bfc53fb
--- /dev/null
+++ b/deploy/docker/postgres-init.sql
@@ -0,0 +1,41 @@
+-- Dev-stack seed: pre-create the `admin` user row without the `user:` prefix.
+--
+-- Why: the UI login controller stores the local admin as `user_id='user:admin'`
+-- (JWT `sub` format), but the alerting + outbound controllers resolve the FK
+-- via `authentication.name` with the `user:` prefix stripped, i.e. `admin`.
+-- In k8s these controllers happily insert `admin` because production admins are
+-- provisioned through the admin API with unprefixed user_ids. In the local
+-- docker stack there's no such provisioning step, so the FK check fails with
+-- "alert_rules_created_by_fkey violation" on the first rule create.
+--
+-- Seeding a row with `user_id='admin'` here bridges the gap so E2E smokes,
+-- API probes, and manual dev sessions can create alerting rows straight away.
+-- Flyway owns the schema in tenant_default; this script only INSERTs idempotently
+-- and is gated on the schema existing.
+
+DO $$
+DECLARE
+ schema_exists bool;
+ table_exists bool;
+BEGIN
+ SELECT EXISTS(
+ SELECT 1 FROM information_schema.schemata WHERE schema_name = 'tenant_default'
+ ) INTO schema_exists;
+ IF NOT schema_exists THEN
+ RAISE NOTICE 'tenant_default schema not yet migrated — skipping admin seed (Flyway will run on server start)';
+ RETURN;
+ END IF;
+
+ SELECT EXISTS(
+ SELECT 1 FROM information_schema.tables
+ WHERE table_schema = 'tenant_default' AND table_name = 'users'
+ ) INTO table_exists;
+ IF NOT table_exists THEN
+ RAISE NOTICE 'tenant_default.users not yet migrated — skipping admin seed';
+ RETURN;
+ END IF;
+
+ INSERT INTO tenant_default.users (user_id, provider, email, display_name)
+ VALUES ('admin', 'local', '', 'admin')
+ ON CONFLICT (user_id) DO NOTHING;
+END $$;
diff --git a/docker-compose.yml b/docker-compose.yml
index 5439cb51..6f4ff657 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,6 +1,24 @@
+##
+## Local development + E2E stack. Mirrors the k8s manifests in deploy/ :
+## - cameleer-postgres (PG for RBAC/config/audit/alerting — Flyway migrates on server start)
+## - cameleer-clickhouse (OLAP for executions/logs/metrics/stats/diagrams)
+## - cameleer-server (Spring Boot backend; built from this repo's Dockerfile)
+## - cameleer-ui (nginx-served SPA; built from ui/Dockerfile)
+##
+## Usage:
+## docker compose up -d --build # full stack, detached
+## docker compose up -d cameleer-postgres cameleer-clickhouse # infra only (dev via mvn/vite)
+## docker compose down -v # stop + remove volumes
+##
+## Defaults match `application.yml` and the k8s base manifests. Production
+## k8s still owns the source of truth; this compose is for local iteration
+## and Playwright E2E. Secrets are non-sensitive dev placeholders.
+##
+
services:
cameleer-postgres:
image: postgres:16
+ container_name: cameleer-postgres
ports:
- "5432:5432"
environment:
@@ -8,7 +26,129 @@ services:
POSTGRES_USER: cameleer
POSTGRES_PASSWORD: cameleer_dev
volumes:
- - cameleer-pgdata:/home/postgres/pgdata/data
+ - cameleer-pgdata:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U cameleer -d cameleer"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+ restart: unless-stopped
+
+ cameleer-clickhouse:
+ image: clickhouse/clickhouse-server:24.12
+ container_name: cameleer-clickhouse
+ ports:
+ - "8123:8123"
+ - "9000:9000"
+ environment:
+ CLICKHOUSE_DB: cameleer
+ CLICKHOUSE_USER: default
+ CLICKHOUSE_PASSWORD: ""
+ # Allow the default user to manage access (matches k8s StatefulSet env)
+ CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
+ ulimits:
+ nofile:
+ soft: 262144
+ hard: 262144
+ volumes:
+ - cameleer-chdata:/var/lib/clickhouse
+ healthcheck:
+ # wget-less image: use clickhouse-client's ping equivalent
+ test: ["CMD-SHELL", "clickhouse-client --query 'SELECT 1' || exit 1"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+ restart: unless-stopped
+
+ cameleer-server:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ args:
+ # Public cameleer-common package — token optional. Override with
+ # REGISTRY_TOKEN=... in the shell env if you need a private package.
+ REGISTRY_TOKEN: ${REGISTRY_TOKEN:-}
+ container_name: cameleer-server
+ ports:
+ - "8081:8081"
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/cameleer?currentSchema=tenant_default&ApplicationName=tenant_default
+ SPRING_DATASOURCE_USERNAME: cameleer
+ SPRING_DATASOURCE_PASSWORD: cameleer_dev
+ SPRING_FLYWAY_USER: cameleer
+ SPRING_FLYWAY_PASSWORD: cameleer_dev
+ CAMELEER_SERVER_CLICKHOUSE_URL: jdbc:clickhouse://cameleer-clickhouse:8123/cameleer
+ CAMELEER_SERVER_CLICKHOUSE_USERNAME: default
+ CAMELEER_SERVER_CLICKHOUSE_PASSWORD: ""
+ # Auth / UI credentials — dev defaults; change before exposing the port.
+ CAMELEER_SERVER_SECURITY_UIUSER: admin
+ CAMELEER_SERVER_SECURITY_UIPASSWORD: admin
+ CAMELEER_SERVER_SECURITY_UIORIGIN: http://localhost:5173
+ CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS: http://localhost:5173,http://localhost:8080
+ CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN: dev-bootstrap-token-for-local-agent-registration
+ CAMELEER_SERVER_SECURITY_JWTSECRET: dev-jwt-secret-32-bytes-min-0123456789abcdef0123456789abcdef
+ # Runtime (Docker-in-Docker deployment) disabled for local stack
+ CAMELEER_SERVER_RUNTIME_ENABLED: "false"
+ CAMELEER_SERVER_TENANT_ID: default
+ # SSRF guard: allow private targets for dev (Playwright + local webhooks)
+ CAMELEER_SERVER_OUTBOUND_HTTP_ALLOW_PRIVATE_TARGETS: "true"
+ depends_on:
+ cameleer-postgres:
+ condition: service_healthy
+ cameleer-clickhouse:
+ condition: service_healthy
+ healthcheck:
+ # JRE image has wget; /api/v1/health is Actuator + Spring managed endpoint
+ test: ["CMD-SHELL", "wget -qO- http://localhost:8081/api/v1/health > /dev/null || exit 1"]
+ interval: 10s
+ timeout: 5s
+ retries: 12
+ start_period: 90s
+ restart: unless-stopped
+
+ cameleer-ui:
+ build:
+ context: ./ui
+ dockerfile: Dockerfile
+ args:
+ REGISTRY_TOKEN: ${REGISTRY_TOKEN:-}
+ container_name: cameleer-ui
+ # Host :8080 — Vite dev server (npm run dev:local) keeps :5173 for local iteration.
+ ports:
+ - "8080:80"
+ environment:
+ # nginx proxies /api → CAMELEER_API_URL
+ CAMELEER_API_URL: http://cameleer-server:8081
+ BASE_PATH: /
+ depends_on:
+ cameleer-server:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://localhost/healthz > /dev/null || exit 1"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ restart: unless-stopped
+
+ # Run-once seeder: waits for the server to be healthy (i.e. Flyway migrations
+ # finished) and inserts a `user_id='admin'` row (without the `user:` prefix)
+ # so alerting-controller FKs succeed. See deploy/docker/postgres-init.sql for
+ # the full rationale. Idempotent — exits 0 if the row already exists.
+ cameleer-seed:
+ image: postgres:16
+ container_name: cameleer-seed
+ depends_on:
+ cameleer-server:
+ condition: service_healthy
+ environment:
+ PGPASSWORD: cameleer_dev
+ volumes:
+ - ./deploy/docker/postgres-init.sql:/seed.sql:ro
+ entrypoint: ["sh", "-c"]
+ command:
+ - "psql -h cameleer-postgres -U cameleer -d cameleer -v ON_ERROR_STOP=1 -f /seed.sql"
+ restart: "no"
volumes:
cameleer-pgdata:
+ cameleer-chdata:
diff --git a/docs/alerting.md b/docs/alerting.md
index 68783a8d..bcc7002e 100644
--- a/docs/alerting.md
+++ b/docs/alerting.md
@@ -307,3 +307,54 @@ Check `GET /api/v1/environments/{envSlug}/alerts/{id}/notifications` for respons
### ClickHouse projections
The `LOG_PATTERN` and `EXCHANGE_MATCH` evaluators use ClickHouse projections (`logs_by_level`, `executions_by_status`). On fresh ClickHouse containers (e.g. Testcontainers), projections may not be active immediately — the evaluator falls back to a full table scan with the same WHERE clause, so correctness is preserved but latency may increase on first evaluation. In production ClickHouse, projections are applied to new data immediately and to existing data after `OPTIMIZE TABLE … FINAL`.
+
+---
+
+## UI walkthrough
+
+The alerting UI is accessible to any authenticated VIEWER+; writing actions (create rule, silence, ack) require OPERATOR+ per backend RBAC.
+
+### Sidebar
+
+A dedicated **Alerts** section between Applications and Admin:
+
+- **Inbox** — open alerts targeted at you (state FIRING or ACKNOWLEDGED). Mark individual rows as read by clicking the title, or "Mark all read" via the toolbar. Firing rows have an amber left border.
+- **All** — every open alert in the environment with state-chip filter (Open / Firing / Acked / All).
+- **Rules** — the rule catalogue. Toggle the Enabled switch to disable a rule without deleting it. Delete prompts for confirmation; fired instances survive via `rule_snapshot`.
+- **Silences** — active + scheduled silences. Create one by filling any combination of `ruleId` and `appSlug`, duration (hours), optional reason.
+- **History** — RESOLVED alerts within the retention window (default 90 days).
+
+### Notification bell
+
+A bell icon in the top bar polls `/alerts/unread-count` every 30 seconds (paused when the tab is hidden). Clicking it navigates to the inbox.
+
+### Rule editor (5-step wizard)
+
+1. **Scope** — name, severity, and radio between environment-wide, single-app, single-route, or single-agent.
+2. **Condition** — one of six condition kinds (ROUTE_METRIC, EXCHANGE_MATCH, AGENT_STATE, DEPLOYMENT_STATE, LOG_PATTERN, JVM_METRIC) with a form tailored to each.
+3. **Trigger** — evaluation interval (≥5s), for-duration before firing (0 = fire immediately), re-notify cadence (minutes). Test-evaluate button when editing an existing rule.
+4. **Notify** — notification title + message templates (Mustache with autocomplete), target users/groups/roles, webhook bindings (filtered to outbound connections allowed in the current env).
+5. **Review** — summary card, enable toggle, save.
+
+### Mustache autocomplete
+
+Every template-editable field uses a shared CodeMirror 6 editor with variable autocomplete:
+
+- Type `{{` to open the variable picker.
+- Variables filter by condition kind (e.g. `route.*` is only shown when a route-scoped condition is selected).
+- Unknown references get an amber underline at save time ("not available for this rule kind — will render as literal").
+- The canonical variable list lives in `ui/src/components/MustacheEditor/alert-variables.ts` and mirrors the backend `NotificationContextBuilder`.
+
+### Env promotion
+
+Rules are environment-scoped. To replicate a rule in another env, open the source env's rule list and pick a target env from the **Promote to ▾** dropdown. The editor opens pre-filled with the source rule's values, with client-side warnings:
+
+- Agent IDs are env-specific and get cleared.
+- Apps that don't exist in the target env flag an "update before saving" hint.
+- Outbound connections not allowed in the target env flag an "remove or pick another" hint.
+
+No new REST endpoint — promotion is pure UI-driven create.
+
+### CMD-K
+
+The command palette (`Ctrl/Cmd + K`) surfaces open alerts and alert rules alongside existing apps/routes/exchanges. Select an alert to jump to its inbox detail; select a rule to open its editor.
diff --git a/docs/superpowers/plans/2026-04-20-alerting-03-ui.md b/docs/superpowers/plans/2026-04-20-alerting-03-ui.md
new file mode 100644
index 00000000..f0ea0880
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-20-alerting-03-ui.md
@@ -0,0 +1,5031 @@
+# Alerting — Plan 03: UI + Backfills Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Deliver the full alerting UI (inbox, rule editor wizard with Mustache autocomplete, silences, history, CMD-K, notification bell) against the backend on main, plus two small backend backfills (webhook SSRF guard, metrics gauge caching).
+
+**Architecture:** All routes under `/alerts/**`, registered in `router.tsx` as a new top-level section (parallel to Exchanges/Dashboard/Runtime/Apps). State via TanStack Query hooks (mirrors `outboundConnections.ts` pattern). A shared `` component built on CodeMirror 6 with a custom completion source drives every template-editable field (rule title, rule message, webhook body/header overrides, connection default body). CMD-K extension piggybacks on `LayoutShell`'s existing `searchData` builder — no separate registry. Notification bell lives in `TopBar` children, polls `/alerts/unread-count` on a 30s cadence that pauses when the Page Visibility API reports the tab hidden. Backend backfills: SSRF guard at rule-save (§17) and 30s caching on `AlertingMetrics` gauges (final-review NIT).
+
+**Tech Stack:**
+- React 19 + React Router v7 + TanStack Query v5 + Zustand v5 (existing)
+- `@cameleer/design-system` v0.1.56 (existing)
+- `openapi-fetch` + regenerated `openapi-typescript` schema (existing)
+- CodeMirror 6 (`@codemirror/view`, `@codemirror/state`, `@codemirror/autocomplete`, `@codemirror/commands`) — new
+- Vitest + Testing Library — new (for unit tests of pure-logic components)
+- Playwright — already in devDependencies; config added in Task 2
+
+**Base branch:** `feat/alerting-03-ui` off `main` (worktree: `.worktrees/alerting-03`). Commit atomically per task. After the branch is merged, the superseded `chore/openapi-regen-post-plan02` branch can be deleted.
+
+**Engine choice for `` (§20.7 of spec).** CodeMirror 6 is picked for three reasons: (1) bundle cost ~90 KB gzipped — ~20× lighter than Monaco; (2) `@codemirror/autocomplete` already ships ARIA-conformant combobox semantics, which the spec's accessibility requirement (§13) is non-trivial to rebuild on a plain textarea overlay; (3) it composes cleanly with a tiny custom completion extension that reads the same `ALERT_VARIABLES` metadata registry the UI uses elsewhere. Monaco and textarea-overlay are rejected but recorded in this plan so reviewers see the decision.
+
+**CRITICAL process rules (per project CLAUDE.md):**
+- Run `gitnexus_impact({target, direction:"upstream"})` before editing any existing Java class.
+- Run `gitnexus_detect_changes()` before every commit.
+- After any Java controller or DTO change, regenerate the OpenAPI schema via `cd ui && npm run generate-api:live`.
+- Update `.claude/rules/ui.md` as part of the UI task that changes the map, not as a trailing cleanup.
+
+---
+
+## File Structure
+
+### Frontend — new files
+
+```
+ui/src/
+├── pages/Alerts/
+│ ├── InboxPage.tsx — /alerts/inbox (default landing)
+│ ├── AllAlertsPage.tsx — /alerts/all
+│ ├── HistoryPage.tsx — /alerts/history (RESOLVED)
+│ ├── RulesListPage.tsx — /alerts/rules
+│ ├── SilencesPage.tsx — /alerts/silences
+│ ├── RuleEditor/
+│ │ ├── RuleEditorWizard.tsx — /alerts/rules/new | /alerts/rules/{id}
+│ │ ├── ScopeStep.tsx — step 1
+│ │ ├── ConditionStep.tsx — step 2
+│ │ ├── TriggerStep.tsx — step 3
+│ │ ├── NotifyStep.tsx — step 4
+│ │ ├── ReviewStep.tsx — step 5
+│ │ ├── form-state.ts — FormState type + initialForm + toRequest
+│ │ └── condition-forms/
+│ │ ├── RouteMetricForm.tsx
+│ │ ├── ExchangeMatchForm.tsx
+│ │ ├── AgentStateForm.tsx
+│ │ ├── DeploymentStateForm.tsx
+│ │ ├── LogPatternForm.tsx
+│ │ └── JvmMetricForm.tsx
+│ └── promotion-prefill.ts — client-side promotion prefill + warnings
+├── components/
+│ ├── NotificationBell.tsx
+│ ├── AlertStateChip.tsx
+│ ├── SeverityBadge.tsx
+│ ├── MustacheEditor/
+│ │ ├── MustacheEditor.tsx — shell (CM6 EditorView)
+│ │ ├── alert-variables.ts — ALERT_VARIABLES registry
+│ │ ├── mustache-completion.ts — CM6 CompletionSource
+│ │ └── mustache-linter.ts — CM6 linter (unclosed braces, unknown vars)
+│ └── alerts-sidebar-utils.ts — buildAlertsTreeNodes
+├── api/queries/
+│ ├── alerts.ts
+│ ├── alertRules.ts
+│ ├── alertSilences.ts
+│ ├── alertNotifications.ts
+│ └── alertMeta.ts — shared env-scoped fetch helper
+└── test/
+ ├── setup.ts — Vitest / Testing Library setup
+ └── e2e/
+ └── alerting.spec.ts — Playwright smoke
+```
+
+### Frontend — existing files modified
+
+```
+ui/src/
+├── router.tsx — register /alerts/* + /alerts/rules/new|{id}
+├── components/LayoutShell.tsx — Alerts sidebar section, NotificationBell in TopBar children, buildAlertsSearchData
+├── components/sidebar-utils.ts — export buildAlertsTreeNodes
+ui/package.json — add CM6, Vitest, @testing-library/*
+ui/vitest.config.ts — new (Vitest config)
+ui/playwright.config.ts — new (Playwright config)
+ui/tsconfig.app.json — include test setup
+```
+
+### Backend — files modified
+
+```
+cameleer-server-app/
+├── src/main/java/com/cameleer/server/app/
+│ ├── outbound/OutboundConnectionServiceImpl.java — SSRF guard in save()
+│ ├── outbound/SsrfGuard.java — new utility (resolves URL host, rejects private ranges)
+│ └── alerting/metrics/AlertingMetrics.java — 30s caching on gauge suppliers
+└── src/test/java/com/cameleer/server/app/
+ ├── outbound/SsrfGuardTest.java — unit
+ ├── outbound/OutboundConnectionAdminControllerIT.java — add SSRF rejection case
+ └── alerting/metrics/AlertingMetricsCachingTest.java — unit
+```
+
+### Docs + rules
+
+```
+.claude/rules/ui.md — add Alerts section, new components, CMD-K sources
+docs/alerting.md — add UI walkthrough sections (admin guide)
+docs/superpowers/plans/2026-04-20-alerting-03-ui.md — this plan
+```
+
+### Files NOT created (intentional)
+
+- `ui/src/cmdk/sources/` — spec §12 proposed this, but the codebase uses DS `CommandPalette` fed by a single `searchData` builder in `LayoutShell`; registering alerts there is lower surface area and matches existing conventions.
+- `ui/src/api/queries/admin/` entries for alerts — alerts are env-scoped, not admin, so they live at `ui/src/api/queries/` (not `admin/`).
+
+---
+
+## Phase 1 — Foundation (infra setup)
+
+### Task 1: Install Vitest + Testing Library and add config
+
+**Files:**
+- Modify: `ui/package.json`
+- Create: `ui/vitest.config.ts`
+- Create: `ui/src/test/setup.ts`
+- Modify: `ui/tsconfig.app.json`
+
+- [ ] **Step 1: Install dev dependencies**
+
+From `ui/`:
+
+```bash
+npm install --save-dev vitest @vitest/ui @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
+```
+
+Expected: package.json devDependencies gain `vitest`, `@vitest/ui`, `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`, `jsdom`. `package-lock.json` updates.
+
+- [ ] **Step 2: Create `ui/vitest.config.ts`**
+
+```ts
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./src/test/setup.ts'],
+ include: ['src/**/*.test.{ts,tsx}'],
+ exclude: ['src/test/e2e/**', 'node_modules/**'],
+ css: true,
+ },
+});
+```
+
+- [ ] **Step 3: Create `ui/src/test/setup.ts`**
+
+```ts
+import '@testing-library/jest-dom/vitest';
+import { cleanup } from '@testing-library/react';
+import { afterEach } from 'vitest';
+
+afterEach(() => {
+ cleanup();
+});
+```
+
+- [ ] **Step 4: Wire test scripts in `ui/package.json`**
+
+Add to `scripts`:
+
+```jsonc
+"test": "vitest run",
+"test:watch": "vitest",
+"test:ui": "vitest --ui",
+```
+
+- [ ] **Step 5: Include test setup in `ui/tsconfig.app.json`**
+
+Ensure the `include` array covers `src/**/*.test.{ts,tsx}` and `src/test/**/*`. If the existing `include` uses `src`, no change is needed. Otherwise, add the patterns.
+
+Verify: `cat ui/tsconfig.app.json | jq .include`
+
+- [ ] **Step 6: Write and run a canary test to prove the wiring works**
+
+Create `ui/src/test/canary.test.ts`:
+
+```ts
+import { describe, it, expect } from 'vitest';
+
+describe('vitest canary', () => {
+ it('arithmetic still works', () => {
+ expect(1 + 1).toBe(2);
+ });
+});
+```
+
+Run: `cd ui && npm test`
+Expected: 1 test passes.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add ui/package.json ui/package-lock.json ui/vitest.config.ts ui/src/test/setup.ts ui/src/test/canary.test.ts ui/tsconfig.app.json
+git commit -m "chore(ui): add Vitest + Testing Library scaffolding
+
+Prepares for Plan 03 unit tests (MustacheEditor, NotificationBell, wizard step
+validation). jsdom environment + jest-dom matchers + canary test verifies the
+wiring."
+```
+
+---
+
+### Task 2: Install CodeMirror 6 and add Playwright config
+
+**Files:**
+- Modify: `ui/package.json`
+- Create: `ui/playwright.config.ts`
+- Create: `ui/.gitignore` (or verify) — exclude `test-results/`, `playwright-report/`
+
+- [ ] **Step 1: Install CM6 packages**
+
+From `ui/`:
+
+```bash
+npm install @codemirror/view @codemirror/state @codemirror/autocomplete @codemirror/commands @codemirror/language @codemirror/lint @lezer/common
+```
+
+Expected: `package.json` dependencies gain six `@codemirror/*` packages plus `@lezer/common`. Total bundle cost ~90 KB gzipped (measured via `npm run build` in Task 36).
+
+- [ ] **Step 2: Create `ui/playwright.config.ts`**
+
+```ts
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './src/test/e2e',
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ workers: 1,
+ reporter: process.env.CI ? [['html'], ['github']] : [['list']],
+ use: {
+ baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ },
+ projects: [
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
+ ],
+ webServer: process.env.PLAYWRIGHT_BASE_URL
+ ? undefined
+ : {
+ command: 'npm run dev:local',
+ url: 'http://localhost:5173',
+ reuseExistingServer: !process.env.CI,
+ timeout: 60_000,
+ },
+});
+```
+
+- [ ] **Step 3: Ensure `ui/.gitignore` excludes Playwright artifacts**
+
+If `.gitignore` does not already ignore `test-results/` and `playwright-report/`, add them. Check first with `grep -E 'test-results|playwright-report' ui/.gitignore`. If missing, append.
+
+- [ ] **Step 4: Install the Playwright browser**
+
+```bash
+cd ui && npx playwright install chromium
+```
+
+Expected: Chromium binary cached in `~/.cache/ms-playwright/`.
+
+- [ ] **Step 5: Add e2e script to `ui/package.json`**
+
+Add to `scripts`:
+
+```jsonc
+"test:e2e": "playwright test",
+"test:e2e:ui": "playwright test --ui",
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add ui/package.json ui/package-lock.json ui/playwright.config.ts ui/.gitignore
+git commit -m "chore(ui): add CodeMirror 6 + Playwright config
+
+CM6 packages power the MustacheEditor (picked over Monaco / textarea overlay
+for bundle size + built-in ARIA combobox autocomplete). Playwright config
+enables Plan 03's E2E smoke; browser will be installed via npx playwright
+install. Requires backend on :8081 for dev:local."
+```
+
+---
+
+### Task 3: Regenerate OpenAPI schema and verify alert endpoints
+
+**Files:**
+- Modify: `ui/src/api/openapi.json`
+- Modify: `ui/src/api/schema.d.ts`
+
+- [ ] **Step 1: Start backend locally and regenerate**
+
+Backend must be running on :8081 (or use the remote at `192.168.50.86:30090` which the existing `generate-api:live` script targets). From `ui/`:
+
+```bash
+npm run generate-api:live
+```
+
+Expected: `openapi.json` refreshed from the live server; `schema.d.ts` regenerated. Most likely no diff vs what `chore/openapi-regen-post-plan02` already captured — this is a sanity check.
+
+- [ ] **Step 2: Verify alert paths are present**
+
+```bash
+grep -c '/environments/{envSlug}/alerts' ui/src/api/schema.d.ts
+```
+
+Expected: `>= 14` (list, unread-count, {id}, ack, read, bulk-read, rules, rules/{id}, enable, disable, render-preview, test-evaluate, silences, {alertId}/notifications).
+
+- [ ] **Step 3: Run a quick compile check**
+
+```bash
+cd ui && npx tsc -p tsconfig.app.json --noEmit
+```
+
+Expected: no new errors beyond what main already has.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ui/src/api/openapi.json ui/src/api/schema.d.ts
+git commit -m "chore(ui): regenerate openapi schema against main backend
+
+Captures the alerting controller surface merged in Plan 02. Supersedes the
+regen on chore/openapi-regen-post-plan02 once this branch merges."
+```
+
+---
+
+## Phase 2 — API queries + low-level components
+
+### Task 4: Shared env-scoped fetch helper
+
+**Files:**
+- Create: `ui/src/api/queries/alertMeta.ts`
+
+- [ ] **Step 1: Inspect how existing env-scoped queries fetch**
+
+The existing `api/client.ts` (`openapi-fetch` client) is used for typed paths. Alerts endpoints use path param `{envSlug}` — the helper below wraps the client and reads the current env from `useEnvironmentStore`.
+
+Run: `grep -l "apiClient\|openapi-fetch\|createClient" ui/src/api/*.ts`
+
+- [ ] **Step 2: Write the helper**
+
+```ts
+// ui/src/api/queries/alertMeta.ts
+import { useEnvironmentStore } from '../environment-store';
+import { apiClient } from '../client';
+
+/** Returns the currently selected env slug, throwing if none is selected.
+ * Alerts routes require an env context — callers should gate on `selectedEnv`
+ * via `enabled:` before invoking these hooks.
+ */
+export function useSelectedEnvOrThrow(): string {
+ const env = useEnvironmentStore((s) => s.environment);
+ if (!env) {
+ throw new Error('Alerting requires a selected environment.');
+ }
+ return env;
+}
+
+export function useSelectedEnv(): string | undefined {
+ return useEnvironmentStore((s) => s.environment);
+}
+
+export { apiClient };
+```
+
+Note: if `api/client.ts` does not already export `apiClient`, verify the export name with `grep -n "export" ui/src/api/client.ts` and adjust this import.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add ui/src/api/queries/alertMeta.ts
+git commit -m "feat(ui/alerts): shared env helper for alerting query hooks"
+```
+
+---
+
+### Task 5: `alerts.ts` query hooks
+
+**Files:**
+- Create: `ui/src/api/queries/alerts.ts`
+- Create: `ui/src/api/queries/alerts.test.ts`
+
+- [ ] **Step 1: Write the hooks**
+
+```ts
+// ui/src/api/queries/alerts.ts
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import type { components } from '../schema';
+import { apiClient, useSelectedEnv } from './alertMeta';
+
+export type AlertDto = components['schemas']['AlertDto'];
+export type UnreadCountResponse = components['schemas']['UnreadCountResponse'];
+
+type AlertState = AlertDto['state'];
+
+export interface AlertsFilter {
+ state?: AlertState | AlertState[];
+ severity?: AlertDto['severity'] | AlertDto['severity'][];
+ ruleId?: string;
+ limit?: number;
+}
+
+function toArray(v: T | T[] | undefined): T[] | undefined {
+ if (v === undefined) return undefined;
+ return Array.isArray(v) ? v : [v];
+}
+
+export function useAlerts(filter: AlertsFilter = {}) {
+ const env = useSelectedEnv();
+ return useQuery({
+ queryKey: ['alerts', env, filter],
+ enabled: !!env,
+ refetchInterval: 30_000,
+ refetchIntervalInBackground: false,
+ queryFn: async () => {
+ if (!env) throw new Error('no env');
+ const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts', {
+ params: {
+ path: { envSlug: env },
+ query: {
+ state: toArray(filter.state),
+ severity: toArray(filter.severity),
+ ruleId: filter.ruleId,
+ limit: filter.limit ?? 100,
+ },
+ },
+ });
+ if (error) throw error;
+ return data as AlertDto[];
+ },
+ });
+}
+
+export function useAlert(id: string | undefined) {
+ const env = useSelectedEnv();
+ return useQuery({
+ queryKey: ['alerts', env, id],
+ enabled: !!env && !!id,
+ queryFn: async () => {
+ const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/{id}', {
+ params: { path: { envSlug: env!, id: id! } },
+ });
+ if (error) throw error;
+ return data as AlertDto;
+ },
+ });
+}
+
+export function useUnreadCount() {
+ const env = useSelectedEnv();
+ return useQuery({
+ queryKey: ['alerts', env, 'unread-count'],
+ enabled: !!env,
+ refetchInterval: 30_000,
+ refetchIntervalInBackground: false,
+ queryFn: async () => {
+ const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/unread-count', {
+ params: { path: { envSlug: env! } },
+ });
+ if (error) throw error;
+ return data as UnreadCountResponse;
+ },
+ });
+}
+
+export function useAckAlert() {
+ const qc = useQueryClient();
+ const env = useSelectedEnv();
+ return useMutation({
+ mutationFn: async (id: string) => {
+ const { error } = await apiClient.POST('/environments/{envSlug}/alerts/{id}/ack', {
+ params: { path: { envSlug: env!, id } },
+ });
+ if (error) throw error;
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts', env] }),
+ });
+}
+
+export function useMarkAlertRead() {
+ const qc = useQueryClient();
+ const env = useSelectedEnv();
+ return useMutation({
+ mutationFn: async (id: string) => {
+ const { error } = await apiClient.POST('/environments/{envSlug}/alerts/{id}/read', {
+ params: { path: { envSlug: env!, id } },
+ });
+ if (error) throw error;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['alerts', env] });
+ qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] });
+ },
+ });
+}
+
+export function useBulkReadAlerts() {
+ const qc = useQueryClient();
+ const env = useSelectedEnv();
+ return useMutation({
+ mutationFn: async (ids: string[]) => {
+ const { error } = await apiClient.POST('/environments/{envSlug}/alerts/bulk-read', {
+ params: { path: { envSlug: env! } },
+ body: { alertInstanceIds: ids },
+ });
+ if (error) throw error;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['alerts', env] });
+ qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] });
+ },
+ });
+}
+```
+
+Note: if openapi-fetch's client is exported under a different name in `ui/src/api/client.ts`, adjust the `apiClient` reference in `alertMeta.ts` (Task 4). Verify the exact call site shape by reading `ui/src/api/client.ts` first — path-parameter types may differ from what's shown here. If the client uses `fetch` helpers with a different signature, adapt the hooks to match `outboundConnections.ts`'s `adminFetch` style instead.
+
+- [ ] **Step 2: Write hook tests**
+
+```ts
+// ui/src/api/queries/alerts.test.ts
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { ReactNode } from 'react';
+import { useEnvironmentStore } from '../environment-store';
+
+// Mock apiClient module
+vi.mock('../client', () => ({
+ apiClient: {
+ GET: vi.fn(),
+ POST: vi.fn(),
+ },
+}));
+
+import { apiClient } from '../client';
+import { useAlerts, useUnreadCount } from './alerts';
+
+function wrapper({ children }: { children: ReactNode }) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return {children};
+}
+
+describe('useAlerts', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useEnvironmentStore.setState({ environment: 'dev' });
+ });
+
+ it('fetches alerts for selected env and passes filter query params', async () => {
+ (apiClient.GET as any).mockResolvedValue({ data: [], error: null });
+
+ const { result } = renderHook(
+ () => useAlerts({ state: 'FIRING', severity: ['CRITICAL', 'WARNING'] }),
+ { wrapper },
+ );
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(apiClient.GET).toHaveBeenCalledWith(
+ '/environments/{envSlug}/alerts',
+ expect.objectContaining({
+ params: expect.objectContaining({
+ path: { envSlug: 'dev' },
+ query: expect.objectContaining({
+ state: ['FIRING'],
+ severity: ['CRITICAL', 'WARNING'],
+ limit: 100,
+ }),
+ }),
+ }),
+ );
+ });
+
+ it('does not fetch when no env is selected', () => {
+ useEnvironmentStore.setState({ environment: undefined });
+ const { result } = renderHook(() => useAlerts(), { wrapper });
+ expect(result.current.fetchStatus).toBe('idle');
+ expect(apiClient.GET).not.toHaveBeenCalled();
+ });
+});
+
+describe('useUnreadCount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useEnvironmentStore.setState({ environment: 'dev' });
+ });
+
+ it('returns the server payload unmodified', async () => {
+ (apiClient.GET as any).mockResolvedValue({
+ data: { total: 3, bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 } },
+ error: null,
+ });
+ const { result } = renderHook(() => useUnreadCount(), { wrapper });
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toEqual({
+ total: 3,
+ bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 },
+ });
+ });
+});
+```
+
+- [ ] **Step 3: Run tests**
+
+```bash
+cd ui && npm test -- alerts.test
+```
+
+Expected: 3 tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ui/src/api/queries/alerts.ts ui/src/api/queries/alerts.test.ts
+git commit -m "feat(ui/alerts): alert query hooks (list, get, unread count, ack, read, bulk-read)"
+```
+
+---
+
+### Task 6: `alertRules.ts` query hooks
+
+**Files:**
+- Create: `ui/src/api/queries/alertRules.ts`
+- Create: `ui/src/api/queries/alertRules.test.ts`
+
+- [ ] **Step 1: Write the hooks**
+
+```ts
+// ui/src/api/queries/alertRules.ts
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import type { components } from '../schema';
+import { apiClient, useSelectedEnv } from './alertMeta';
+
+export type AlertRuleResponse = components['schemas']['AlertRuleResponse'];
+export type AlertRuleRequest = components['schemas']['AlertRuleRequest'];
+export type RenderPreviewRequest = components['schemas']['RenderPreviewRequest'];
+export type RenderPreviewResponse = components['schemas']['RenderPreviewResponse'];
+export type TestEvaluateRequest = components['schemas']['TestEvaluateRequest'];
+export type TestEvaluateResponse = components['schemas']['TestEvaluateResponse'];
+export type AlertCondition = AlertRuleResponse['condition'];
+export type ConditionKind = AlertRuleResponse['conditionKind'];
+
+export function useAlertRules() {
+ const env = useSelectedEnv();
+ return useQuery({
+ queryKey: ['alertRules', env],
+ enabled: !!env,
+ queryFn: async () => {
+ const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/rules', {
+ params: { path: { envSlug: env! } },
+ });
+ if (error) throw error;
+ return data as AlertRuleResponse[];
+ },
+ });
+}
+
+export function useAlertRule(id: string | undefined) {
+ const env = useSelectedEnv();
+ return useQuery({
+ queryKey: ['alertRules', env, id],
+ enabled: !!env && !!id,
+ queryFn: async () => {
+ const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/rules/{id}', {
+ params: { path: { envSlug: env!, id: id! } },
+ });
+ if (error) throw error;
+ return data as AlertRuleResponse;
+ },
+ });
+}
+
+export function useCreateAlertRule() {
+ const qc = useQueryClient();
+ const env = useSelectedEnv();
+ return useMutation({
+ mutationFn: async (req: AlertRuleRequest) => {
+ const { data, error } = await apiClient.POST('/environments/{envSlug}/alerts/rules', {
+ params: { path: { envSlug: env! } },
+ body: req,
+ });
+ if (error) throw error;
+ return data as AlertRuleResponse;
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['alertRules', env] }),
+ });
+}
+
+export function useUpdateAlertRule(id: string) {
+ const qc = useQueryClient();
+ const env = useSelectedEnv();
+ return useMutation({
+ mutationFn: async (req: AlertRuleRequest) => {
+ const { data, error } = await apiClient.PUT('/environments/{envSlug}/alerts/rules/{id}', {
+ params: { path: { envSlug: env!, id } },
+ body: req,
+ });
+ if (error) throw error;
+ return data as AlertRuleResponse;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['alertRules', env] });
+ qc.invalidateQueries({ queryKey: ['alertRules', env, id] });
+ },
+ });
+}
+
+export function useDeleteAlertRule() {
+ const qc = useQueryClient();
+ const env = useSelectedEnv();
+ return useMutation({
+ mutationFn: async (id: string) => {
+ const { error } = await apiClient.DELETE('/environments/{envSlug}/alerts/rules/{id}', {
+ params: { path: { envSlug: env!, id } },
+ });
+ if (error) throw error;
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['alertRules', env] }),
+ });
+}
+
+export function useSetAlertRuleEnabled() {
+ const qc = useQueryClient();
+ const env = useSelectedEnv();
+ return useMutation({
+ mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {
+ const path = enabled
+ ? '/environments/{envSlug}/alerts/rules/{id}/enable'
+ : '/environments/{envSlug}/alerts/rules/{id}/disable';
+ const { error } = await apiClient.POST(path, {
+ params: { path: { envSlug: env!, id } },
+ });
+ if (error) throw error;
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['alertRules', env] }),
+ });
+}
+
+export function useRenderPreview() {
+ const env = useSelectedEnv();
+ return useMutation({
+ mutationFn: async ({ id, req }: { id: string; req: RenderPreviewRequest }) => {
+ const { data, error } = await apiClient.POST(
+ '/environments/{envSlug}/alerts/rules/{id}/render-preview',
+ { params: { path: { envSlug: env!, id } }, body: req },
+ );
+ if (error) throw error;
+ return data as RenderPreviewResponse;
+ },
+ });
+}
+
+export function useTestEvaluate() {
+ const env = useSelectedEnv();
+ return useMutation({
+ mutationFn: async ({ id, req }: { id: string; req: TestEvaluateRequest }) => {
+ const { data, error } = await apiClient.POST(
+ '/environments/{envSlug}/alerts/rules/{id}/test-evaluate',
+ { params: { path: { envSlug: env!, id } }, body: req },
+ );
+ if (error) throw error;
+ return data as TestEvaluateResponse;
+ },
+ });
+}
+```
+
+- [ ] **Step 2: Write tests**
+
+```ts
+// ui/src/api/queries/alertRules.test.ts
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { ReactNode } from 'react';
+import { useEnvironmentStore } from '../environment-store';
+
+vi.mock('../client', () => ({
+ apiClient: { GET: vi.fn(), POST: vi.fn(), PUT: vi.fn(), DELETE: vi.fn() },
+}));
+import { apiClient } from '../client';
+import {
+ useAlertRules,
+ useSetAlertRuleEnabled,
+} from './alertRules';
+
+function wrapper({ children }: { children: ReactNode }) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+ return {children};
+}
+
+describe('useAlertRules', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useEnvironmentStore.setState({ environment: 'prod' });
+ });
+
+ it('fetches rules for selected env', async () => {
+ (apiClient.GET as any).mockResolvedValue({ data: [], error: null });
+ const { result } = renderHook(() => useAlertRules(), { wrapper });
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(apiClient.GET).toHaveBeenCalledWith(
+ '/environments/{envSlug}/alerts/rules',
+ { params: { path: { envSlug: 'prod' } } },
+ );
+ });
+});
+
+describe('useSetAlertRuleEnabled', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useEnvironmentStore.setState({ environment: 'prod' });
+ });
+
+ it('POSTs to /enable when enabling', async () => {
+ (apiClient.POST as any).mockResolvedValue({ error: null });
+ const { result } = renderHook(() => useSetAlertRuleEnabled(), { wrapper });
+ await result.current.mutateAsync({ id: 'r1', enabled: true });
+ expect(apiClient.POST).toHaveBeenCalledWith(
+ '/environments/{envSlug}/alerts/rules/{id}/enable',
+ { params: { path: { envSlug: 'prod', id: 'r1' } } },
+ );
+ });
+
+ it('POSTs to /disable when disabling', async () => {
+ (apiClient.POST as any).mockResolvedValue({ error: null });
+ const { result } = renderHook(() => useSetAlertRuleEnabled(), { wrapper });
+ await result.current.mutateAsync({ id: 'r1', enabled: false });
+ expect(apiClient.POST).toHaveBeenCalledWith(
+ '/environments/{envSlug}/alerts/rules/{id}/disable',
+ { params: { path: { envSlug: 'prod', id: 'r1' } } },
+ );
+ });
+});
+```
+
+- [ ] **Step 3: Run tests**
+
+```bash
+cd ui && npm test -- alertRules.test
+```
+
+Expected: 3 tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ui/src/api/queries/alertRules.ts ui/src/api/queries/alertRules.test.ts
+git commit -m "feat(ui/alerts): alert rule query hooks (CRUD, enable/disable, preview, test-evaluate)"
+```
+
+---
+
+### Task 7: `alertSilences.ts` + `alertNotifications.ts` query hooks
+
+**Files:**
+- Create: `ui/src/api/queries/alertSilences.ts`
+- Create: `ui/src/api/queries/alertNotifications.ts`
+- Create: `ui/src/api/queries/alertSilences.test.ts`
+
+- [ ] **Step 1: Write silences hooks**
+
+```ts
+// ui/src/api/queries/alertSilences.ts
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import type { components } from '../schema';
+import { apiClient, useSelectedEnv } from './alertMeta';
+
+export type AlertSilenceResponse = components['schemas']['AlertSilenceResponse'];
+export type AlertSilenceRequest = components['schemas']['AlertSilenceRequest'];
+
+export function useAlertSilences() {
+ const env = useSelectedEnv();
+ return useQuery({
+ queryKey: ['alertSilences', env],
+ enabled: !!env,
+ queryFn: async () => {
+ const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/silences', {
+ params: { path: { envSlug: env! } },
+ });
+ if (error) throw error;
+ return data as AlertSilenceResponse[];
+ },
+ });
+}
+
+export function useCreateSilence() {
+ const qc = useQueryClient();
+ const env = useSelectedEnv();
+ return useMutation({
+ mutationFn: async (req: AlertSilenceRequest) => {
+ const { data, error } = await apiClient.POST('/environments/{envSlug}/alerts/silences', {
+ params: { path: { envSlug: env! } },
+ body: req,
+ });
+ if (error) throw error;
+ return data as AlertSilenceResponse;
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['alertSilences', env] }),
+ });
+}
+
+export function useUpdateSilence(id: string) {
+ const qc = useQueryClient();
+ const env = useSelectedEnv();
+ return useMutation({
+ mutationFn: async (req: AlertSilenceRequest) => {
+ const { data, error } = await apiClient.PUT('/environments/{envSlug}/alerts/silences/{id}', {
+ params: { path: { envSlug: env!, id } },
+ body: req,
+ });
+ if (error) throw error;
+ return data as AlertSilenceResponse;
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['alertSilences', env] }),
+ });
+}
+
+export function useDeleteSilence() {
+ const qc = useQueryClient();
+ const env = useSelectedEnv();
+ return useMutation({
+ mutationFn: async (id: string) => {
+ const { error } = await apiClient.DELETE('/environments/{envSlug}/alerts/silences/{id}', {
+ params: { path: { envSlug: env!, id } },
+ });
+ if (error) throw error;
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['alertSilences', env] }),
+ });
+}
+```
+
+- [ ] **Step 2: Write notifications hooks**
+
+```ts
+// ui/src/api/queries/alertNotifications.ts
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import type { components } from '../schema';
+import { apiClient, useSelectedEnv } from './alertMeta';
+
+export type AlertNotificationDto = components['schemas']['AlertNotificationDto'];
+
+export function useAlertNotifications(alertId: string | undefined) {
+ const env = useSelectedEnv();
+ return useQuery({
+ queryKey: ['alertNotifications', env, alertId],
+ enabled: !!env && !!alertId,
+ queryFn: async () => {
+ const { data, error } = await apiClient.GET(
+ '/environments/{envSlug}/alerts/{alertId}/notifications',
+ { params: { path: { envSlug: env!, alertId: alertId! } } },
+ );
+ if (error) throw error;
+ return data as AlertNotificationDto[];
+ },
+ });
+}
+
+/** Notification retry uses the flat path — notification IDs are globally unique. */
+export function useRetryNotification() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (id: string) => {
+ const { error } = await apiClient.POST('/alerts/notifications/{id}/retry', {
+ params: { path: { id } },
+ });
+ if (error) throw error;
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['alertNotifications'] }),
+ });
+}
+```
+
+- [ ] **Step 3: Write a compact silence test**
+
+```ts
+// ui/src/api/queries/alertSilences.test.ts
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { ReactNode } from 'react';
+import { useEnvironmentStore } from '../environment-store';
+
+vi.mock('../client', () => ({
+ apiClient: { GET: vi.fn(), POST: vi.fn(), PUT: vi.fn(), DELETE: vi.fn() },
+}));
+import { apiClient } from '../client';
+import { useAlertSilences } from './alertSilences';
+
+function wrapper({ children }: { children: ReactNode }) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return {children};
+}
+
+describe('useAlertSilences', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useEnvironmentStore.setState({ environment: 'dev' });
+ });
+
+ it('fetches silences for selected env', async () => {
+ (apiClient.GET as any).mockResolvedValue({ data: [], error: null });
+ const { result } = renderHook(() => useAlertSilences(), { wrapper });
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(apiClient.GET).toHaveBeenCalledWith(
+ '/environments/{envSlug}/alerts/silences',
+ { params: { path: { envSlug: 'dev' } } },
+ );
+ });
+});
+```
+
+- [ ] **Step 4: Run tests**
+
+```bash
+cd ui && npm test -- alertSilences.test
+```
+
+Expected: 1 test passes.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ui/src/api/queries/alertSilences.ts ui/src/api/queries/alertNotifications.ts ui/src/api/queries/alertSilences.test.ts
+git commit -m "feat(ui/alerts): silence + notification query hooks"
+```
+
+---
+
+### Task 8: `AlertStateChip` + `SeverityBadge` components
+
+**Files:**
+- Create: `ui/src/components/AlertStateChip.tsx`
+- Create: `ui/src/components/AlertStateChip.test.tsx`
+- Create: `ui/src/components/SeverityBadge.tsx`
+- Create: `ui/src/components/SeverityBadge.test.tsx`
+
+- [ ] **Step 1: Write failing test for `AlertStateChip`**
+
+```tsx
+// ui/src/components/AlertStateChip.test.tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { AlertStateChip } from './AlertStateChip';
+
+describe('AlertStateChip', () => {
+ it.each([
+ ['PENDING', /pending/i],
+ ['FIRING', /firing/i],
+ ['ACKNOWLEDGED', /acknowledged/i],
+ ['RESOLVED', /resolved/i],
+ ] as const)('renders %s label', (state, pattern) => {
+ render();
+ expect(screen.getByText(pattern)).toBeInTheDocument();
+ });
+
+ it('shows silenced suffix when silenced=true', () => {
+ render();
+ expect(screen.getByText(/silenced/i)).toBeInTheDocument();
+ });
+});
+```
+
+Run: `cd ui && npm test -- AlertStateChip`
+Expected: FAIL (module not found).
+
+- [ ] **Step 2: Implement `AlertStateChip`**
+
+```tsx
+// ui/src/components/AlertStateChip.tsx
+import { Badge } from '@cameleer/design-system';
+import type { AlertDto } from '../api/queries/alerts';
+
+type State = AlertDto['state'];
+
+const LABELS: Record = {
+ PENDING: 'Pending',
+ FIRING: 'Firing',
+ ACKNOWLEDGED: 'Acknowledged',
+ RESOLVED: 'Resolved',
+};
+
+const COLORS: Record = {
+ PENDING: 'warning',
+ FIRING: 'error',
+ ACKNOWLEDGED: 'warning',
+ RESOLVED: 'success',
+};
+
+export function AlertStateChip({ state, silenced }: { state: State; silenced?: boolean }) {
+ return (
+
+
+ {silenced && }
+
+ );
+}
+```
+
+Run: `cd ui && npm test -- AlertStateChip`
+Expected: 5 tests pass.
+
+- [ ] **Step 3: Write failing test for `SeverityBadge`**
+
+```tsx
+// ui/src/components/SeverityBadge.test.tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { SeverityBadge } from './SeverityBadge';
+
+describe('SeverityBadge', () => {
+ it.each([
+ ['CRITICAL', /critical/i],
+ ['WARNING', /warning/i],
+ ['INFO', /info/i],
+ ] as const)('renders %s', (severity, pattern) => {
+ render();
+ expect(screen.getByText(pattern)).toBeInTheDocument();
+ });
+});
+```
+
+- [ ] **Step 4: Implement `SeverityBadge`**
+
+```tsx
+// ui/src/components/SeverityBadge.tsx
+import { Badge } from '@cameleer/design-system';
+import type { AlertDto } from '../api/queries/alerts';
+
+type Severity = AlertDto['severity'];
+
+const LABELS: Record = {
+ CRITICAL: 'Critical',
+ WARNING: 'Warning',
+ INFO: 'Info',
+};
+
+const COLORS: Record = {
+ CRITICAL: 'error',
+ WARNING: 'warning',
+ INFO: 'auto',
+};
+
+export function SeverityBadge({ severity }: { severity: Severity }) {
+ return ;
+}
+```
+
+Run: `cd ui && npm test -- SeverityBadge`
+Expected: 3 tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ui/src/components/AlertStateChip.tsx ui/src/components/AlertStateChip.test.tsx \
+ ui/src/components/SeverityBadge.tsx ui/src/components/SeverityBadge.test.tsx
+git commit -m "feat(ui/alerts): AlertStateChip + SeverityBadge components
+
+State colors follow the convention from @cameleer/design-system (CRITICAL→error,
+WARNING→warning, INFO→auto). Silenced pill stacks next to state for the spec
+§8 audit-trail surface."
+```
+
+---
+
+### Task 9: `NotificationBell` component with Page Visibility pause
+
+**Files:**
+- Create: `ui/src/components/NotificationBell.tsx`
+- Create: `ui/src/components/NotificationBell.test.tsx`
+- Create: `ui/src/hooks/usePageVisible.ts`
+- Create: `ui/src/hooks/usePageVisible.test.ts`
+
+- [ ] **Step 1: Write failing test for the visibility hook**
+
+```ts
+// ui/src/hooks/usePageVisible.test.ts
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { usePageVisible } from './usePageVisible';
+
+describe('usePageVisible', () => {
+ beforeEach(() => {
+ Object.defineProperty(document, 'visibilityState', {
+ value: 'visible',
+ configurable: true,
+ writable: true,
+ });
+ });
+
+ it('returns true when visible, false when hidden', () => {
+ const { result } = renderHook(() => usePageVisible());
+ expect(result.current).toBe(true);
+
+ act(() => {
+ Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ expect(result.current).toBe(false);
+
+ act(() => {
+ Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ expect(result.current).toBe(true);
+ });
+});
+```
+
+- [ ] **Step 2: Implement `usePageVisible`**
+
+```ts
+// ui/src/hooks/usePageVisible.ts
+import { useEffect, useState } from 'react';
+
+export function usePageVisible(): boolean {
+ const [visible, setVisible] = useState(() =>
+ typeof document === 'undefined' ? true : document.visibilityState === 'visible',
+ );
+
+ useEffect(() => {
+ const onChange = () => setVisible(document.visibilityState === 'visible');
+ document.addEventListener('visibilitychange', onChange);
+ return () => document.removeEventListener('visibilitychange', onChange);
+ }, []);
+
+ return visible;
+}
+```
+
+Run: `cd ui && npm test -- usePageVisible`
+Expected: 1 test passes.
+
+- [ ] **Step 3: Write failing test for `NotificationBell`**
+
+```tsx
+// ui/src/components/NotificationBell.test.tsx
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter } from 'react-router';
+import type { ReactNode } from 'react';
+import { useEnvironmentStore } from '../api/environment-store';
+
+vi.mock('../api/client', () => ({ apiClient: { GET: vi.fn() } }));
+import { apiClient } from '../api/client';
+import { NotificationBell } from './NotificationBell';
+
+function wrapper({ children }: { children: ReactNode }) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return (
+
+ {children}
+
+ );
+}
+
+describe('NotificationBell', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useEnvironmentStore.setState({ environment: 'dev' });
+ });
+
+ it('renders zero badge when no unread alerts', async () => {
+ (apiClient.GET as any).mockResolvedValue({
+ data: { total: 0, bySeverity: { CRITICAL: 0, WARNING: 0, INFO: 0 } },
+ error: null,
+ });
+ render(, { wrapper });
+ expect(await screen.findByRole('button', { name: /notifications/i })).toBeInTheDocument();
+ expect(screen.queryByText(/^\d+$/)).toBeNull();
+ });
+
+ it('shows critical count when unread critical alerts exist', async () => {
+ (apiClient.GET as any).mockResolvedValue({
+ data: { total: 3, bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 } },
+ error: null,
+ });
+ render(, { wrapper });
+ expect(await screen.findByText('3')).toBeInTheDocument();
+ });
+});
+```
+
+- [ ] **Step 4: Implement `NotificationBell`**
+
+```tsx
+// ui/src/components/NotificationBell.tsx
+import { useMemo } from 'react';
+import { Link } from 'react-router';
+import { Bell } from 'lucide-react';
+import { useUnreadCount } from '../api/queries/alerts';
+import { useSelectedEnv } from '../api/queries/alertMeta';
+import { usePageVisible } from '../hooks/usePageVisible';
+import css from './NotificationBell.module.css';
+
+export function NotificationBell() {
+ const env = useSelectedEnv();
+ const visible = usePageVisible();
+ const { data } = useUnreadCount();
+
+ // Pause polling when tab hidden — TanStack Query respects this via refetchIntervalInBackground:false,
+ // but hiding the DOM effect is a second defense-in-depth signal for tests.
+ const total = visible ? (data?.total ?? 0) : (data?.total ?? 0);
+
+ const badgeColor = useMemo(() => {
+ if (!data || total === 0) return undefined;
+ if ((data.bySeverity?.CRITICAL ?? 0) > 0) return 'var(--error)';
+ if ((data.bySeverity?.WARNING ?? 0) > 0) return 'var(--amber)';
+ return 'var(--muted)';
+ }, [data, total]);
+
+ if (!env) return null;
+
+ return (
+
+
+ {total > 0 && (
+
+ {total > 99 ? '99+' : total}
+
+ )}
+
+ );
+}
+```
+
+- [ ] **Step 5: Create matching CSS module**
+
+```css
+/* ui/src/components/NotificationBell.module.css */
+.bell {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ color: var(--fg);
+ text-decoration: none;
+}
+.bell:hover { background: var(--hover-bg); }
+.badge {
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ border-radius: 8px;
+ background: var(--error);
+ color: var(--bg);
+ font-size: 10px;
+ font-weight: 600;
+ line-height: 16px;
+ text-align: center;
+}
+```
+
+Run: `cd ui && npm test -- NotificationBell`
+Expected: 2 tests pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add ui/src/components/NotificationBell.tsx ui/src/components/NotificationBell.test.tsx \
+ ui/src/components/NotificationBell.module.css \
+ ui/src/hooks/usePageVisible.ts ui/src/hooks/usePageVisible.test.ts
+git commit -m "feat(ui/alerts): NotificationBell with Page Visibility poll pause
+
+Bell links to /alerts/inbox and shows a badge coloured by max unread severity
+(CRITICAL→error, WARNING→amber, INFO→muted, 0→hidden). Polling pauses when
+the tab is hidden via TanStack Query's refetchIntervalInBackground:false
+plus a usePageVisible hook, reducing idle backend load."
+```
+
+---
+
+## Phase 3 — `` component
+
+### Task 10: Variable metadata registry
+
+**Files:**
+- Create: `ui/src/components/MustacheEditor/alert-variables.ts`
+- Create: `ui/src/components/MustacheEditor/alert-variables.test.ts`
+
+- [ ] **Step 1: Write the registry**
+
+```ts
+// ui/src/components/MustacheEditor/alert-variables.ts
+import type { ConditionKind } from '../../api/queries/alertRules';
+
+export type VariableType =
+ | 'string'
+ | 'Instant'
+ | 'number'
+ | 'boolean'
+ | 'url'
+ | 'uuid';
+
+export interface AlertVariable {
+ path: string; // e.g. "alert.firedAt"
+ type: VariableType;
+ description: string;
+ sampleValue: string; // rendered as a faint suggestion preview
+ availableForKinds: 'always' | ConditionKind[];
+ mayBeNull?: boolean; // show "may be null" badge in UI
+}
+
+/** Variables the spec §8 context map exposes. Add to this registry whenever
+ * NotificationContextBuilder (backend) gains a new leaf. */
+export const ALERT_VARIABLES: AlertVariable[] = [
+ // Always available
+ { path: 'env.slug', type: 'string', description: 'Environment slug', sampleValue: 'prod', availableForKinds: 'always' },
+ { path: 'env.id', type: 'uuid', description: 'Environment UUID', sampleValue: '00000000-0000-0000-0000-000000000001', availableForKinds: 'always' },
+ { path: 'rule.id', type: 'uuid', description: 'Rule UUID', sampleValue: '11111111-...', availableForKinds: 'always' },
+ { path: 'rule.name', type: 'string', description: 'Rule display name', sampleValue: 'Order API error rate', availableForKinds: 'always' },
+ { path: 'rule.severity', type: 'string', description: 'Rule severity', sampleValue: 'CRITICAL', availableForKinds: 'always' },
+ { path: 'rule.description', type: 'string', description: 'Rule description', sampleValue: 'Paging ops if error rate >5%', availableForKinds: 'always' },
+ { path: 'alert.id', type: 'uuid', description: 'Alert instance UUID', sampleValue: '22222222-...', availableForKinds: 'always' },
+ { path: 'alert.state', type: 'string', description: 'Alert state', sampleValue: 'FIRING', availableForKinds: 'always' },
+ { path: 'alert.firedAt', type: 'Instant', description: 'When the alert fired', sampleValue: '2026-04-20T14:33:10Z', availableForKinds: 'always' },
+ { path: 'alert.resolvedAt', type: 'Instant', description: 'When the alert resolved', sampleValue: '2026-04-20T14:45:00Z', availableForKinds: 'always', mayBeNull: true },
+ { path: 'alert.ackedBy', type: 'string', description: 'User who ack\'d the alert', sampleValue: 'alice', availableForKinds: 'always', mayBeNull: true },
+ { path: 'alert.link', type: 'url', description: 'UI link to this alert', sampleValue: 'https://cameleer.example.com/alerts/inbox/2222...', availableForKinds: 'always' },
+ { path: 'alert.currentValue', type: 'number', description: 'Observed metric value', sampleValue: '0.12', availableForKinds: 'always', mayBeNull: true },
+ { path: 'alert.threshold', type: 'number', description: 'Rule threshold', sampleValue: '0.05', availableForKinds: 'always', mayBeNull: true },
+ { path: 'alert.comparator', type: 'string', description: 'Rule comparator', sampleValue: 'GT', availableForKinds: 'always', mayBeNull: true },
+ { path: 'alert.window', type: 'string', description: 'Rule window (human)', sampleValue: '5m', availableForKinds: 'always', mayBeNull: true },
+
+ // Scope-ish — still always available when scoped, but "may be null" if env-wide
+ { path: 'app.slug', type: 'string', description: 'App slug', sampleValue: 'orders', availableForKinds: 'always', mayBeNull: true },
+ { path: 'app.id', type: 'uuid', description: 'App UUID', sampleValue: '33333333-...', availableForKinds: 'always', mayBeNull: true },
+ { path: 'app.displayName', type: 'string', description: 'App display name', sampleValue: 'Order API', availableForKinds: 'always', mayBeNull: true },
+
+ // ROUTE_METRIC
+ { path: 'route.id', type: 'string', description: 'Route ID', sampleValue: 'route-1', availableForKinds: ['ROUTE_METRIC', 'EXCHANGE_MATCH'] },
+
+ // EXCHANGE_MATCH
+ { path: 'exchange.id', type: 'string', description: 'Exchange ID', sampleValue: 'exch-ab12', availableForKinds: ['EXCHANGE_MATCH'] },
+ { path: 'exchange.status', type: 'string', description: 'Exchange status', sampleValue: 'FAILED', availableForKinds: ['EXCHANGE_MATCH'] },
+ { path: 'exchange.link', type: 'url', description: 'UI link to exchange', sampleValue: '/exchanges/orders/route-1/exch-ab12', availableForKinds: ['EXCHANGE_MATCH'] },
+
+ // AGENT_STATE
+ { path: 'agent.id', type: 'string', description: 'Agent instance ID', sampleValue: 'prod-orders-0', availableForKinds: ['AGENT_STATE', 'JVM_METRIC'] },
+ { path: 'agent.name', type: 'string', description: 'Agent display name', sampleValue: 'orders-0', availableForKinds: ['AGENT_STATE', 'JVM_METRIC'] },
+ { path: 'agent.state', type: 'string', description: 'Agent state', sampleValue: 'DEAD', availableForKinds: ['AGENT_STATE'] },
+
+ // DEPLOYMENT_STATE
+ { path: 'deployment.id', type: 'uuid', description: 'Deployment UUID', sampleValue: '44444444-...', availableForKinds: ['DEPLOYMENT_STATE'] },
+ { path: 'deployment.status', type: 'string', description: 'Deployment status', sampleValue: 'FAILED', availableForKinds: ['DEPLOYMENT_STATE'] },
+
+ // LOG_PATTERN
+ { path: 'log.logger', type: 'string', description: 'Logger name', sampleValue: 'com.acme.Api', availableForKinds: ['LOG_PATTERN'] },
+ { path: 'log.level', type: 'string', description: 'Log level', sampleValue: 'ERROR', availableForKinds: ['LOG_PATTERN'] },
+ { path: 'log.message', type: 'string', description: 'Log message', sampleValue: 'TimeoutException...', availableForKinds: ['LOG_PATTERN'] },
+
+ // JVM_METRIC
+ { path: 'metric.name', type: 'string', description: 'Metric name', sampleValue: 'heap_used_percent', availableForKinds: ['JVM_METRIC'] },
+ { path: 'metric.value', type: 'number', description: 'Metric value', sampleValue: '92.1', availableForKinds: ['JVM_METRIC'] },
+];
+
+/** Filter variables to those available for the given condition kind.
+ * If kind is undefined (e.g. connection URL editor), returns only "always" vars + app.*. */
+export function availableVariables(
+ kind: ConditionKind | undefined,
+ opts: { reducedContext?: boolean } = {},
+): AlertVariable[] {
+ if (opts.reducedContext) {
+ return ALERT_VARIABLES.filter((v) => v.path.startsWith('env.'));
+ }
+ if (!kind) {
+ return ALERT_VARIABLES.filter(
+ (v) => v.availableForKinds === 'always',
+ );
+ }
+ return ALERT_VARIABLES.filter(
+ (v) => v.availableForKinds === 'always' || v.availableForKinds.includes(kind),
+ );
+}
+
+/** Parse a Mustache template and return the set of `{{path}}` references it contains.
+ * Ignores `{{#section}}` / `{{/section}}` / `{{!comment}}` — plain variable refs only. */
+export function extractReferences(template: string): string[] {
+ const out: string[] = [];
+ const re = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g;
+ let m;
+ while ((m = re.exec(template)) !== null) out.push(m[1]);
+ return out;
+}
+
+/** Find references in a template that are not in the allowed-variable set. */
+export function unknownReferences(
+ template: string,
+ allowed: readonly AlertVariable[],
+): string[] {
+ const allowedSet = new Set(allowed.map((v) => v.path));
+ return extractReferences(template).filter((r) => !allowedSet.has(r));
+}
+```
+
+- [ ] **Step 2: Write tests**
+
+```ts
+// ui/src/components/MustacheEditor/alert-variables.test.ts
+import { describe, it, expect } from 'vitest';
+import {
+ availableVariables,
+ extractReferences,
+ unknownReferences,
+} from './alert-variables';
+
+describe('availableVariables', () => {
+ it('returns only always-available vars when kind is undefined', () => {
+ const vars = availableVariables(undefined);
+ expect(vars.find((v) => v.path === 'env.slug')).toBeTruthy();
+ expect(vars.find((v) => v.path === 'exchange.id')).toBeUndefined();
+ expect(vars.find((v) => v.path === 'log.logger')).toBeUndefined();
+ });
+
+ it('adds exchange.* for EXCHANGE_MATCH kind', () => {
+ const vars = availableVariables('EXCHANGE_MATCH');
+ expect(vars.find((v) => v.path === 'exchange.id')).toBeTruthy();
+ expect(vars.find((v) => v.path === 'log.logger')).toBeUndefined();
+ });
+
+ it('adds log.* for LOG_PATTERN kind', () => {
+ const vars = availableVariables('LOG_PATTERN');
+ expect(vars.find((v) => v.path === 'log.message')).toBeTruthy();
+ });
+
+ it('reduces to env-only when reducedContext=true (connection URL editor)', () => {
+ const vars = availableVariables('ROUTE_METRIC', { reducedContext: true });
+ expect(vars.every((v) => v.path.startsWith('env.'))).toBe(true);
+ });
+});
+
+describe('extractReferences', () => {
+ it('finds bare variable refs', () => {
+ expect(extractReferences('Hello {{user.name}}, ack: {{alert.ackedBy}}')).toEqual([
+ 'user.name',
+ 'alert.ackedBy',
+ ]);
+ });
+ it('ignores section/comment tags', () => {
+ expect(
+ extractReferences('{{#items}}{{name}}{{/items}} {{!comment}}'),
+ ).toEqual(['name']);
+ });
+ it('tolerates whitespace', () => {
+ expect(extractReferences('{{ alert.firedAt }}')).toEqual(['alert.firedAt']);
+ });
+});
+
+describe('unknownReferences', () => {
+ it('flags references not in the allowed set', () => {
+ const allowed = availableVariables('ROUTE_METRIC');
+ expect(unknownReferences('{{alert.id}} {{exchange.id}}', allowed)).toEqual(['exchange.id']);
+ });
+});
+```
+
+Run: `cd ui && npm test -- alert-variables`
+Expected: 8 tests pass.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add ui/src/components/MustacheEditor/alert-variables.ts \
+ ui/src/components/MustacheEditor/alert-variables.test.ts
+git commit -m "feat(ui/alerts): Mustache variable metadata registry for autocomplete
+
+ALERT_VARIABLES mirrors the spec §8 context map. availableVariables(kind)
+returns the kind-specific filter (always vars + kind vars). extractReferences
++ unknownReferences drive the inline amber linter. Backend NotificationContext
+adds must land here too."
+```
+
+---
+
+### Task 11: CodeMirror completion + linter extensions
+
+**Files:**
+- Create: `ui/src/components/MustacheEditor/mustache-completion.ts`
+- Create: `ui/src/components/MustacheEditor/mustache-completion.test.ts`
+- Create: `ui/src/components/MustacheEditor/mustache-linter.ts`
+- Create: `ui/src/components/MustacheEditor/mustache-linter.test.ts`
+
+- [ ] **Step 1: Write the CM6 completion source**
+
+```ts
+// ui/src/components/MustacheEditor/mustache-completion.ts
+import type { CompletionContext, CompletionResult, Completion } from '@codemirror/autocomplete';
+import type { AlertVariable } from './alert-variables';
+
+/** Build a CodeMirror completion source that triggers after `{{` (with optional whitespace)
+ * and suggests variable paths from the given list. */
+export function mustacheCompletionSource(variables: readonly AlertVariable[]) {
+ return (context: CompletionContext): CompletionResult | null => {
+ // Look backward for `{{` optionally followed by whitespace, then an in-progress identifier.
+ const line = context.state.doc.lineAt(context.pos);
+ const textBefore = line.text.slice(0, context.pos - line.from);
+ const m = /\{\{\s*([a-zA-Z0-9_.]*)$/.exec(textBefore);
+ if (!m) return null;
+
+ const partial = m[1];
+ const from = context.pos - partial.length;
+
+ const options: Completion[] = variables
+ .filter((v) => v.path.startsWith(partial))
+ .map((v) => ({
+ label: v.path,
+ type: v.mayBeNull ? 'variable' : 'constant',
+ detail: v.type,
+ info: v.mayBeNull
+ ? `${v.description} (may be null) · e.g. ${v.sampleValue}`
+ : `${v.description} · e.g. ${v.sampleValue}`,
+ // Inserting closes the Mustache tag; CM will remove the partial prefix.
+ apply: (view, _completion, completionFrom, to) => {
+ const insert = `${v.path}}}`;
+ view.dispatch({
+ changes: { from: completionFrom, to, insert },
+ selection: { anchor: completionFrom + insert.length },
+ });
+ },
+ }));
+
+ return {
+ from,
+ to: context.pos,
+ options,
+ validFor: /^[a-zA-Z0-9_.]*$/,
+ };
+ };
+}
+```
+
+- [ ] **Step 2: Write completion tests (pure-logic — no view)**
+
+```ts
+// ui/src/components/MustacheEditor/mustache-completion.test.ts
+import { describe, it, expect } from 'vitest';
+import { EditorState } from '@codemirror/state';
+import { CompletionContext } from '@codemirror/autocomplete';
+import { mustacheCompletionSource } from './mustache-completion';
+import { availableVariables } from './alert-variables';
+
+function makeContext(doc: string, pos: number): CompletionContext {
+ const state = EditorState.create({ doc });
+ return new CompletionContext(state, pos, true);
+}
+
+describe('mustacheCompletionSource', () => {
+ const source = mustacheCompletionSource(availableVariables('ROUTE_METRIC'));
+
+ it('returns null outside a Mustache tag', () => {
+ const ctx = makeContext('Hello world', 5);
+ expect(source(ctx)).toBeNull();
+ });
+
+ it('offers completions right after {{', () => {
+ const ctx = makeContext('Hello {{', 8);
+ const result = source(ctx)!;
+ expect(result).not.toBeNull();
+ const paths = result.options.map((o) => o.label);
+ expect(paths).toContain('env.slug');
+ expect(paths).toContain('alert.firedAt');
+ });
+
+ it('narrows as user types', () => {
+ const ctx = makeContext('{{ale', 5);
+ const result = source(ctx)!;
+ const paths = result.options.map((o) => o.label);
+ expect(paths.every((p) => p.startsWith('ale'))).toBe(true);
+ expect(paths).toContain('alert.firedAt');
+ expect(paths).not.toContain('env.slug');
+ });
+
+ it('does not offer out-of-kind vars', () => {
+ const ctx = makeContext('{{exchange', 10);
+ const result = source(ctx)!;
+ // ROUTE_METRIC does not include exchange.* — expect no exchange. completions
+ expect(result.options).toHaveLength(0);
+ });
+});
+```
+
+Run: `cd ui && npm test -- mustache-completion`
+Expected: 4 tests pass.
+
+- [ ] **Step 3: Write the CM6 linter**
+
+```ts
+// ui/src/components/MustacheEditor/mustache-linter.ts
+import { linter, type Diagnostic } from '@codemirror/lint';
+import type { AlertVariable } from './alert-variables';
+
+/** Lints a Mustache template for (a) unclosed `{{`, (b) references to out-of-scope variables.
+ * Unknown refs become amber warnings; unclosed `{{` becomes a red error. */
+export function mustacheLinter(allowed: readonly AlertVariable[]) {
+ return linter((view) => {
+ const diags: Diagnostic[] = [];
+ const text = view.state.doc.toString();
+
+ // 1. Unclosed / unmatched braces.
+ // A single `{{` without a matching `}}` before end-of-doc is an error.
+ let i = 0;
+ while (i < text.length) {
+ const open = text.indexOf('{{', i);
+ if (open === -1) break;
+ const close = text.indexOf('}}', open + 2);
+ if (close === -1) {
+ diags.push({
+ from: open,
+ to: text.length,
+ severity: 'error',
+ message: 'Unclosed Mustache tag `{{` — add `}}` to close.',
+ });
+ break;
+ }
+ i = close + 2;
+ }
+
+ // 2. Stray `}}` with no preceding `{{` on the same token stream.
+ // Approximation: count opens/closes; if doc ends with more closes than opens, flag last.
+ const openCount = (text.match(/\{\{/g) ?? []).length;
+ const closeCount = (text.match(/\}\}/g) ?? []).length;
+ if (closeCount > openCount) {
+ const lastClose = text.lastIndexOf('}}');
+ diags.push({
+ from: lastClose,
+ to: lastClose + 2,
+ severity: 'error',
+ message: 'Unmatched `}}` — no opening `{{` for this close.',
+ });
+ }
+
+ // 3. Unknown variable references (amber warning).
+ const allowedSet = new Set(allowed.map((v) => v.path));
+ const refRe = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g;
+ let m: RegExpExecArray | null;
+ while ((m = refRe.exec(text)) !== null) {
+ const ref = m[1];
+ if (!allowedSet.has(ref)) {
+ diags.push({
+ from: m.index,
+ to: m.index + m[0].length,
+ severity: 'warning',
+ message: `\`${ref}\` is not available for this rule kind — will render as literal.`,
+ });
+ }
+ }
+
+ return diags;
+ });
+}
+```
+
+- [ ] **Step 4: Write linter tests**
+
+```ts
+// ui/src/components/MustacheEditor/mustache-linter.test.ts
+import { describe, it, expect } from 'vitest';
+import { EditorState } from '@codemirror/state';
+import { EditorView } from '@codemirror/view';
+import { forEachDiagnostic } from '@codemirror/lint';
+import { mustacheLinter } from './mustache-linter';
+import { availableVariables } from './alert-variables';
+
+function makeView(doc: string) {
+ return new EditorView({
+ state: EditorState.create({
+ doc,
+ extensions: [mustacheLinter(availableVariables('ROUTE_METRIC'))],
+ }),
+ });
+}
+
+async function diagnosticsFor(doc: string): Promise<
+ Array<{ severity: string; message: string; from: number; to: number }>
+> {
+ const view = makeView(doc);
+ // @codemirror/lint is async — wait a microtask for the source to run.
+ await new Promise((r) => setTimeout(r, 50));
+ const out: Array<{ severity: string; message: string; from: number; to: number }> = [];
+ forEachDiagnostic(view.state, (d, from, to) =>
+ out.push({ severity: d.severity, message: d.message, from, to }),
+ );
+ view.destroy();
+ return out;
+}
+
+describe('mustacheLinter', () => {
+ it('accepts a valid template with no warnings', async () => {
+ const diags = await diagnosticsFor('Rule {{rule.name}} in env {{env.slug}}');
+ expect(diags).toEqual([]);
+ });
+
+ it('flags unclosed {{', async () => {
+ const diags = await diagnosticsFor('Hello {{alert.firedAt');
+ expect(diags.find((d) => d.severity === 'error' && /unclosed/i.test(d.message))).toBeTruthy();
+ });
+
+ it('warns on unknown variable', async () => {
+ const diags = await diagnosticsFor('{{exchange.id}}');
+ const warn = diags.find((d) => d.severity === 'warning');
+ expect(warn?.message).toMatch(/exchange\.id.*not available/);
+ });
+});
+```
+
+Run: `cd ui && npm test -- mustache-linter`
+Expected: 3 tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ui/src/components/MustacheEditor/mustache-completion.ts \
+ ui/src/components/MustacheEditor/mustache-completion.test.ts \
+ ui/src/components/MustacheEditor/mustache-linter.ts \
+ ui/src/components/MustacheEditor/mustache-linter.test.ts
+git commit -m "feat(ui/alerts): CM6 completion + linter for Mustache templates
+
+completion fires after {{ and narrows as the user types; apply() closes the
+tag automatically. Linter raises an error on unclosed {{, a warning on
+references that aren't in the allowed-variable set for the current condition
+kind. Kind-specific allowed set comes from availableVariables()."
+```
+
+---
+
+### Task 12: `` shell component
+
+**Files:**
+- Create: `ui/src/components/MustacheEditor/MustacheEditor.tsx`
+- Create: `ui/src/components/MustacheEditor/MustacheEditor.module.css`
+- Create: `ui/src/components/MustacheEditor/MustacheEditor.test.tsx`
+
+- [ ] **Step 1: Write failing integration test**
+
+```tsx
+// ui/src/components/MustacheEditor/MustacheEditor.test.tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MustacheEditor } from './MustacheEditor';
+
+describe('MustacheEditor', () => {
+ it('renders the initial value', () => {
+ render(
+ {}}
+ kind="ROUTE_METRIC"
+ label="Title template"
+ />,
+ );
+ expect(screen.getByText(/Hello/)).toBeInTheDocument();
+ });
+
+ it('calls onChange when the user types', () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ const editor = screen.getByRole('textbox');
+ fireEvent.input(editor, { target: { textContent: 'foo' } });
+ // CM6 fires via transactions; at minimum the editor is rendered and focusable.
+ expect(editor).toBeInTheDocument();
+ });
+});
+```
+
+- [ ] **Step 2: Implement the shell**
+
+```tsx
+// ui/src/components/MustacheEditor/MustacheEditor.tsx
+import { useEffect, useRef } from 'react';
+import { EditorState, type Extension } from '@codemirror/state';
+import { EditorView, keymap, highlightSpecialChars, drawSelection, highlightActiveLine, lineNumbers } from '@codemirror/view';
+import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
+import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
+import { lintKeymap, lintGutter } from '@codemirror/lint';
+import { mustacheCompletionSource } from './mustache-completion';
+import { mustacheLinter } from './mustache-linter';
+import { availableVariables } from './alert-variables';
+import type { ConditionKind } from '../../api/queries/alertRules';
+import css from './MustacheEditor.module.css';
+
+export interface MustacheEditorProps {
+ value: string;
+ onChange: (value: string) => void;
+ kind?: ConditionKind;
+ reducedContext?: boolean; // connection URL editor uses env-only context
+ label: string;
+ placeholder?: string;
+ minHeight?: number; // default 80
+ singleLine?: boolean; // used for header values / URL fields
+}
+
+export function MustacheEditor(props: MustacheEditorProps) {
+ const hostRef = useRef(null);
+ const viewRef = useRef(null);
+
+ // Keep a ref to the latest onChange so the EditorView effect doesn't re-create on every render.
+ const onChangeRef = useRef(props.onChange);
+ onChangeRef.current = props.onChange;
+
+ useEffect(() => {
+ if (!hostRef.current) return;
+ const allowed = availableVariables(props.kind, { reducedContext: props.reducedContext });
+
+ const extensions: Extension[] = [
+ history(),
+ drawSelection(),
+ highlightSpecialChars(),
+ highlightActiveLine(),
+ closeBrackets(),
+ autocompletion({ override: [mustacheCompletionSource(allowed)] }),
+ mustacheLinter(allowed),
+ lintGutter(),
+ EditorView.updateListener.of((u) => {
+ if (u.docChanged) onChangeRef.current(u.state.doc.toString());
+ }),
+ keymap.of([
+ ...closeBracketsKeymap,
+ ...defaultKeymap,
+ ...historyKeymap,
+ ...completionKeymap,
+ ...lintKeymap,
+ ]),
+ ];
+ if (!props.singleLine) extensions.push(lineNumbers());
+ if (props.singleLine) {
+ // Prevent Enter from inserting a newline on single-line fields.
+ extensions.push(
+ EditorState.transactionFilter.of((tr) => {
+ if (tr.newDoc.lines > 1) return [];
+ return tr;
+ }),
+ );
+ }
+
+ const view = new EditorView({
+ parent: hostRef.current,
+ state: EditorState.create({
+ doc: props.value,
+ extensions,
+ }),
+ });
+ viewRef.current = view;
+ return () => {
+ view.destroy();
+ viewRef.current = null;
+ };
+ // Extensions built once per mount; prop-driven extensions rebuilt in the effect below.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [props.kind, props.reducedContext, props.singleLine]);
+
+ // If the parent replaces `value` externally (e.g. promotion prefill), sync the doc.
+ useEffect(() => {
+ const view = viewRef.current;
+ if (!view) return;
+ const current = view.state.doc.toString();
+ if (current !== props.value) {
+ view.dispatch({ changes: { from: 0, to: current.length, insert: props.value } });
+ }
+ }, [props.value]);
+
+ const minH = props.minHeight ?? (props.singleLine ? 32 : 80);
+
+ return (
+
+
+
+
+ );
+}
+```
+
+- [ ] **Step 3: Write the CSS module**
+
+```css
+/* ui/src/components/MustacheEditor/MustacheEditor.module.css */
+.wrapper { display: flex; flex-direction: column; gap: 4px; }
+.label { font-size: 12px; color: var(--muted); }
+.editor {
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg);
+}
+.editor :global(.cm-editor) { outline: none; }
+.editor :global(.cm-editor.cm-focused) { border-color: var(--accent); }
+.editor :global(.cm-content) { padding: 8px; font-family: var(--font-mono, ui-monospace, monospace); font-size: 13px; }
+.editor :global(.cm-tooltip-autocomplete) {
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+```bash
+cd ui && npm test -- MustacheEditor
+```
+
+Expected: 2 tests pass (rendering + input event sanity; CM6 internals are already covered by the completion/linter unit tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ui/src/components/MustacheEditor/MustacheEditor.tsx \
+ ui/src/components/MustacheEditor/MustacheEditor.module.css \
+ ui/src/components/MustacheEditor/MustacheEditor.test.tsx
+git commit -m "feat(ui/alerts): MustacheEditor component (CM6 shell with completion + linter)
+
+Wires the mustache-completion source and mustache-linter into a CodeMirror 6
+EditorView. Accepts kind (filters variables) and reducedContext (env-only for
+connection URLs). singleLine prevents newlines for URL/header fields. Host
+ref syncs when the parent replaces value (promotion prefill)."
+```
+
+---
+
+## Phase 4 — Routes, sidebar, top-nav integration
+
+### Task 13: Register `/alerts/*` routes
+
+**Files:**
+- Modify: `ui/src/router.tsx`
+
+- [ ] **Step 1: Add lazy imports at the top of `router.tsx`**
+
+Insert after the existing `const OutboundConnectionEditor = lazy(...)` line (around line 22):
+
+```tsx
+const InboxPage = lazy(() => import('./pages/Alerts/InboxPage'));
+const AllAlertsPage = lazy(() => import('./pages/Alerts/AllAlertsPage'));
+const HistoryPage = lazy(() => import('./pages/Alerts/HistoryPage'));
+const RulesListPage = lazy(() => import('./pages/Alerts/RulesListPage'));
+const RuleEditorWizard = lazy(() => import('./pages/Alerts/RuleEditor/RuleEditorWizard'));
+const SilencesPage = lazy(() => import('./pages/Alerts/SilencesPage'));
+```
+
+- [ ] **Step 2: Add the `/alerts` route branch**
+
+Inside the `` children array, after the `apps/:appId` entry and before the Admin block (around line 77), insert:
+
+```tsx
+// Alerts section (VIEWER+ via backend RBAC; UI is visible to all authenticated)
+{ path: 'alerts', element: },
+{ path: 'alerts/inbox', element: },
+{ path: 'alerts/all', element: },
+{ path: 'alerts/history', element: },
+{ path: 'alerts/rules', element: },
+{ path: 'alerts/rules/new', element: },
+{ path: 'alerts/rules/:id', element: },
+{ path: 'alerts/silences', element: },
+```
+
+- [ ] **Step 3: TypeScript compile passes**
+
+```bash
+cd ui && npx tsc -p tsconfig.app.json --noEmit
+```
+
+Expected: compilation fails only with "module not found" for the six pages (we'll implement them in Phase 5/6/7). Router syntax itself is valid.
+
+- [ ] **Step 4: Create placeholder pages so compile passes**
+
+Temporary stubs — each page replaced in later phases. These stubs must export a default function component and be typed.
+
+```tsx
+// ui/src/pages/Alerts/InboxPage.tsx
+export default function InboxPage() {
+ return