Merge pull request 'feat(alerting): Plan 03 — UI + backfills (SSRF guard, metrics caching, docker stack)' (#144) from feat/alerting-03-ui into main
Reviewed-on: #144
This commit was merged in pull request #144.
This commit is contained in:
@@ -34,6 +34,28 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
|
||||
- `ui/src/hooks/useInfiniteStream.ts` — tanstack `useInfiniteQuery` wrapper with top-gated auto-refetch, flattened `items[]`, and `refresh()` invalidator
|
||||
- `ui/src/components/InfiniteScrollArea.tsx` — scrollable container with IntersectionObserver top/bottom sentinels. Streaming log/event views use this + `useInfiniteStream`. Bounded views (LogTab, StartupLogPanel) keep `useLogs`/`useStartupLogs`
|
||||
|
||||
## Alerts
|
||||
|
||||
- **Sidebar section** (`buildAlertsTreeNodes` in `ui/src/components/sidebar-utils.ts`) — Inbox, All, Rules, Silences, History.
|
||||
- **Routes** in `ui/src/router.tsx`: `/alerts`, `/alerts/inbox`, `/alerts/all`, `/alerts/history`, `/alerts/rules`, `/alerts/rules/new`, `/alerts/rules/:id`, `/alerts/silences`.
|
||||
- **Pages** under `ui/src/pages/Alerts/`:
|
||||
- `InboxPage.tsx` — user-targeted FIRING/ACK'd alerts with bulk-read.
|
||||
- `AllAlertsPage.tsx` — env-wide list with state-chip filter.
|
||||
- `HistoryPage.tsx` — RESOLVED alerts.
|
||||
- `RulesListPage.tsx` — CRUD + enable/disable toggle + env-promotion dropdown (pure UI prefill, no new endpoint).
|
||||
- `RuleEditor/RuleEditorWizard.tsx` — 5-step wizard (Scope / Condition / Trigger / Notify / Review). `form-state.ts` is the single source of truth (`initialForm` / `toRequest` / `validateStep`). Six condition-form subcomponents under `RuleEditor/condition-forms/`.
|
||||
- `SilencesPage.tsx` — matcher-based create + end-early.
|
||||
- `AlertRow.tsx` shared list row; `alerts-page.module.css` shared styling.
|
||||
- **Components**:
|
||||
- `NotificationBell.tsx` — polls `/alerts/unread-count` every 30 s (paused when tab hidden via TanStack Query `refetchIntervalInBackground: false`).
|
||||
- `AlertStateChip.tsx`, `SeverityBadge.tsx` — shared state/severity indicators.
|
||||
- `MustacheEditor/` — CodeMirror 6 editor with variable autocomplete + inline linter. Shared between rule title/message, webhook body/header overrides, and (future) Admin Outbound Connection editor (reduced-context mode for URL).
|
||||
- `MustacheEditor/alert-variables.ts` — variable registry aligned with `NotificationContextBuilder.java`. Add new leaves here whenever the backend context grows.
|
||||
- **API queries** under `ui/src/api/queries/`: `alerts.ts`, `alertRules.ts`, `alertSilences.ts`, `alertNotifications.ts`, `alertMeta.ts`. All env-scoped via `useSelectedEnv` from `alertMeta`.
|
||||
- **CMD-K**: `buildAlertSearchData` in `LayoutShell.tsx` registers `alert` and `alertRule` result categories. Badges convey severity + state. Palette navigates directly to the deep-link path — no sidebar-reveal state for alerts.
|
||||
- **Sidebar accordion**: entering `/alerts/*` collapses Applications + Admin + Starred (mirrors Admin accordion).
|
||||
- **Top-nav**: `<NotificationBell />` is the first child of `<TopBar>`, sitting alongside `SearchTrigger` + status `ButtonGroup` + `TimeRangeDropdown` + `AutoRefreshToggle`.
|
||||
|
||||
## UI Styling
|
||||
|
||||
- Always use `@cameleer/design-system` CSS variables for colors (`var(--amber)`, `var(--error)`, `var(--success)`, etc.) — never hardcode hex values. This applies to CSS modules, inline styles, and SVG `fill`/`stroke` attributes. SVG presentation attributes resolve `var()` correctly. All colors use CSS variables (no hardcoded hex).
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,10 +1,14 @@
|
||||
FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-17 AS build
|
||||
WORKDIR /build
|
||||
|
||||
# Configure Gitea Maven Registry for cameleer-common dependency
|
||||
ARG REGISTRY_TOKEN
|
||||
RUN mkdir -p ~/.m2 && \
|
||||
echo '<settings><servers><server><id>gitea</id><username>cameleer</username><password>'${REGISTRY_TOKEN}'</password></server></servers></settings>' > ~/.m2/settings.xml
|
||||
# Optional auth for Gitea Maven Registry. The `cameleer/cameleer-common` package
|
||||
# is published publicly, so empty token → anonymous pull (no settings.xml).
|
||||
# Private packages require a non-empty token.
|
||||
ARG REGISTRY_TOKEN=""
|
||||
RUN if [ -n "$REGISTRY_TOKEN" ]; then \
|
||||
mkdir -p ~/.m2 && \
|
||||
printf '<settings><servers><server><id>gitea</id><username>cameleer</username><password>%s</password></server></servers></settings>\n' "$REGISTRY_TOKEN" > ~/.m2/settings.xml; \
|
||||
fi
|
||||
|
||||
COPY pom.xml .
|
||||
COPY cameleer-server-core/pom.xml cameleer-server-core/
|
||||
|
||||
@@ -9,12 +9,20 @@ import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Micrometer-based metrics for the alerting subsystem.
|
||||
@@ -30,10 +38,11 @@ import java.util.concurrent.ConcurrentMap;
|
||||
* <li>{@code alerting_eval_duration_seconds{kind}} — per-kind evaluation latency</li>
|
||||
* <li>{@code alerting_webhook_delivery_duration_seconds} — webhook POST latency</li>
|
||||
* </ul>
|
||||
* 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):
|
||||
* <ul>
|
||||
* <li>{@code alerting_rules_total{state=enabled|disabled}} — rule counts from {@code alert_rules}</li>
|
||||
* <li>{@code alerting_instances_total{state,severity}} — instance counts grouped from {@code alert_instances}</li>
|
||||
* <li>{@code alerting_instances_total{state}} — instance counts grouped from {@code alert_instances}</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<String, Counter> evalErrorCounters = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, Counter> evalErrorCounters = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, Counter> circuitOpenCounters = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, Timer> 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<AlertState, TtlCache> 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<Long> enabledRulesSupplier,
|
||||
Supplier<Long> disabledRulesSupplier,
|
||||
Supplier<Long> instancesSupplier,
|
||||
Duration gaugeTtl,
|
||||
Supplier<Instant> 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<Long> enabledRulesSupplier,
|
||||
Supplier<Long> disabledRulesSupplier,
|
||||
java.util.function.Function<AlertState, Long> instancesSupplier,
|
||||
Duration gaugeTtl,
|
||||
Supplier<Instant> 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<TtlCache> 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<Long>}. Every call to
|
||||
* {@link #getAsDouble()} either returns the cached value (if {@code clock.get()
|
||||
* - lastRead < ttl}) or invokes the delegate and refreshes the cache.
|
||||
*
|
||||
* <p>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<Long> delegate;
|
||||
private final Duration ttl;
|
||||
private final Supplier<Instant> clock;
|
||||
private volatile Instant lastRead = Instant.MIN;
|
||||
private volatile long cached = 0L;
|
||||
|
||||
TtlCache(Supplier<Long> delegate, Duration ttl, Supplier<Instant> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Instant> now = new AtomicReference<>(Instant.parse("2026-04-20T00:00:00Z"));
|
||||
Supplier<Instant> clock = now::get;
|
||||
|
||||
MeterRegistry registry = new SimpleMeterRegistry();
|
||||
|
||||
Supplier<Long> enabledRulesSupplier = () -> { enabledRulesCalls.incrementAndGet(); return 7L; };
|
||||
Supplier<Long> disabledRulesSupplier = () -> { disabledRulesCalls.incrementAndGet(); return 3L; };
|
||||
Supplier<Long> 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<Long> enabledValue = new AtomicReference<>(10L);
|
||||
AtomicReference<Instant> 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
41
deploy/docker/postgres-init.sql
Normal file
41
deploy/docker/postgres-init.sql
Normal file
@@ -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 $$;
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
5031
docs/superpowers/plans/2026-04-20-alerting-03-ui.md
Normal file
5031
docs/superpowers/plans/2026-04-20-alerting-03-ui.md
Normal file
File diff suppressed because it is too large
Load Diff
2
ui/.gitignore
vendored
2
ui/.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
public/favicon.svg
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
ARG REGISTRY_TOKEN
|
||||
ARG REGISTRY_TOKEN=""
|
||||
COPY package.json package-lock.json .npmrc ./
|
||||
RUN echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc && \
|
||||
RUN if [ -n "$REGISTRY_TOKEN" ]; then \
|
||||
echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc; \
|
||||
fi && \
|
||||
npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# Upgrade design system to latest dev snapshot (after COPY to bust Docker cache)
|
||||
RUN echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc && \
|
||||
RUN if [ -n "$REGISTRY_TOKEN" ]; then \
|
||||
echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc; \
|
||||
fi && \
|
||||
npm install @cameleer/design-system@dev && \
|
||||
rm -f .npmrc
|
||||
|
||||
|
||||
1334
ui/package-lock.json
generated
1334
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,10 +12,22 @@
|
||||
"preview": "vite preview",
|
||||
"generate-api": "openapi-typescript src/api/openapi.json -o src/api/schema.d.ts",
|
||||
"generate-api:live": "curl -s http://192.168.50.86:30090/api/v1/api-docs -o src/api/openapi.json && openapi-typescript src/api/openapi.json -o src/api/schema.d.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"postinstall": "node -e \"const fs=require('fs');fs.mkdirSync('public',{recursive:true});fs.copyFileSync('node_modules/@cameleer/design-system/assets/cameleer-logo.svg','public/favicon.svg')\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@cameleer/design-system": "^0.1.56",
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/lint": "^6.9.5",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"@lezer/common": "^1.5.2",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
@@ -30,19 +42,25 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"@vitest/ui": "^4.1.4",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.0"
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
26
ui/playwright.config.ts
Normal file
26
ui/playwright.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
24
ui/src/api/queries/alertMeta.ts
Normal file
24
ui/src/api/queries/alertMeta.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEnvironmentStore } from '../environment-store';
|
||||
import { api } 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);
|
||||
}
|
||||
|
||||
/** Re-exported openapi-fetch client for alerting query hooks.
|
||||
* The underlying export in `../client` is named `api`; we re-export as
|
||||
* `apiClient` so alerting hooks can import it under the plan's canonical name.
|
||||
*/
|
||||
export { api as apiClient };
|
||||
54
ui/src/api/queries/alertNotifications.ts
Normal file
54
ui/src/api/queries/alertNotifications.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { components } from '../schema';
|
||||
import { apiClient, useSelectedEnv } from './alertMeta';
|
||||
|
||||
export type AlertNotificationDto = components['schemas']['AlertNotificationDto'];
|
||||
|
||||
// NOTE ON TYPES: the generated OpenAPI schema for env-scoped alert-notification
|
||||
// endpoints emits `path?: never` plus a `query.env: Environment` parameter
|
||||
// because the server resolves the env via the `@EnvPath` argument resolver,
|
||||
// which the OpenAPI scanner does not recognise as a path variable. At runtime
|
||||
// the URL template `{envSlug}` is substituted from `params.path.envSlug` by
|
||||
// openapi-fetch regardless of what the TS types say; we therefore cast the
|
||||
// call options to `any` on each call to bypass the generated type oddity.
|
||||
|
||||
/** List notifications for a given alert instance. */
|
||||
export function useAlertNotifications(alertId: string | undefined) {
|
||||
const env = useSelectedEnv();
|
||||
return useQuery({
|
||||
queryKey: ['alertNotifications', env, alertId],
|
||||
enabled: !!env && !!alertId,
|
||||
queryFn: async () => {
|
||||
if (!env || !alertId) throw new Error('no env/alertId');
|
||||
const { data, error } = await apiClient.GET(
|
||||
'/environments/{envSlug}/alerts/{alertId}/notifications',
|
||||
{
|
||||
params: { path: { envSlug: env, alertId } },
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as AlertNotificationDto[];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Retry a failed notification. Uses the flat path — notification IDs are
|
||||
* globally unique across environments, so the endpoint is not env-scoped.
|
||||
*/
|
||||
export function useRetryNotification() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { error } = await apiClient.POST(
|
||||
'/alerts/notifications/{id}/retry',
|
||||
{
|
||||
params: { path: { id } },
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['alertNotifications'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
66
ui/src/api/queries/alertRules.test.tsx
Normal file
66
ui/src/api/queries/alertRules.test.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
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', () => ({
|
||||
api: { GET: vi.fn(), POST: vi.fn(), PUT: vi.fn(), DELETE: vi.fn() },
|
||||
}));
|
||||
|
||||
import { api as 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 <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
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' } } },
|
||||
);
|
||||
});
|
||||
});
|
||||
187
ui/src/api/queries/alertRules.ts
Normal file
187
ui/src/api/queries/alertRules.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
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'];
|
||||
|
||||
// NOTE ON TYPES: the generated OpenAPI schema for env-scoped alert-rule endpoints
|
||||
// emits `path?: never` plus a `query.env: Environment` parameter because the
|
||||
// server resolves the env via the `@EnvPath` argument resolver, which the
|
||||
// OpenAPI scanner does not recognise as a path variable. At runtime the URL
|
||||
// template `{envSlug}` is substituted from `params.path.envSlug` by
|
||||
// openapi-fetch regardless of what the TS types say; we therefore cast the
|
||||
// call options to `any` on each call to bypass the generated type oddity.
|
||||
|
||||
/** List alert rules in the current env. */
|
||||
export function useAlertRules() {
|
||||
const env = useSelectedEnv();
|
||||
return useQuery({
|
||||
queryKey: ['alertRules', env],
|
||||
enabled: !!env,
|
||||
queryFn: async () => {
|
||||
if (!env) throw new Error('no env');
|
||||
const { data, error } = await apiClient.GET(
|
||||
'/environments/{envSlug}/alerts/rules',
|
||||
{
|
||||
params: { path: { envSlug: env } },
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as AlertRuleResponse[];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a single alert rule by id. */
|
||||
export function useAlertRule(id: string | undefined) {
|
||||
const env = useSelectedEnv();
|
||||
return useQuery({
|
||||
queryKey: ['alertRules', env, id],
|
||||
enabled: !!env && !!id,
|
||||
queryFn: async () => {
|
||||
if (!env || !id) throw new Error('no env/id');
|
||||
const { data, error } = await apiClient.GET(
|
||||
'/environments/{envSlug}/alerts/rules/{id}',
|
||||
{
|
||||
params: { path: { envSlug: env, id } },
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as AlertRuleResponse;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a new alert rule in the current env. */
|
||||
export function useCreateAlertRule() {
|
||||
const qc = useQueryClient();
|
||||
const env = useSelectedEnv();
|
||||
return useMutation({
|
||||
mutationFn: async (req: AlertRuleRequest) => {
|
||||
if (!env) throw new Error('no env');
|
||||
const { data, error } = await apiClient.POST(
|
||||
'/environments/{envSlug}/alerts/rules',
|
||||
{
|
||||
params: { path: { envSlug: env } },
|
||||
body: req,
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as AlertRuleResponse;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['alertRules', env] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Update an existing alert rule. */
|
||||
export function useUpdateAlertRule(id: string) {
|
||||
const qc = useQueryClient();
|
||||
const env = useSelectedEnv();
|
||||
return useMutation({
|
||||
mutationFn: async (req: AlertRuleRequest) => {
|
||||
if (!env) throw new Error('no env');
|
||||
const { data, error } = await apiClient.PUT(
|
||||
'/environments/{envSlug}/alerts/rules/{id}',
|
||||
{
|
||||
params: { path: { envSlug: env, id } },
|
||||
body: req,
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as AlertRuleResponse;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['alertRules', env] });
|
||||
qc.invalidateQueries({ queryKey: ['alertRules', env, id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete an alert rule. */
|
||||
export function useDeleteAlertRule() {
|
||||
const qc = useQueryClient();
|
||||
const env = useSelectedEnv();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
if (!env) throw new Error('no env');
|
||||
const { error } = await apiClient.DELETE(
|
||||
'/environments/{envSlug}/alerts/rules/{id}',
|
||||
{
|
||||
params: { path: { envSlug: env, id } },
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['alertRules', env] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Enable or disable an alert rule. Routes to /enable or /disable based on the flag. */
|
||||
export function useSetAlertRuleEnabled() {
|
||||
const qc = useQueryClient();
|
||||
const env = useSelectedEnv();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {
|
||||
if (!env) throw new Error('no env');
|
||||
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 } },
|
||||
} as any);
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['alertRules', env] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Render a preview of the notification title + message for a rule using the provided context. */
|
||||
export function useRenderPreview() {
|
||||
const env = useSelectedEnv();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, req }: { id: string; req: RenderPreviewRequest }) => {
|
||||
if (!env) throw new Error('no env');
|
||||
const { data, error } = await apiClient.POST(
|
||||
'/environments/{envSlug}/alerts/rules/{id}/render-preview',
|
||||
{
|
||||
params: { path: { envSlug: env, id } },
|
||||
body: req,
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as RenderPreviewResponse;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Test-evaluate a rule (dry-run) without persisting an alert instance. */
|
||||
export function useTestEvaluate() {
|
||||
const env = useSelectedEnv();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, req }: { id: string; req: TestEvaluateRequest }) => {
|
||||
if (!env) throw new Error('no env');
|
||||
const { data, error } = await apiClient.POST(
|
||||
'/environments/{envSlug}/alerts/rules/{id}/test-evaluate',
|
||||
{
|
||||
params: { path: { envSlug: env, id } },
|
||||
body: req,
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as TestEvaluateResponse;
|
||||
},
|
||||
});
|
||||
}
|
||||
39
ui/src/api/queries/alertSilences.test.tsx
Normal file
39
ui/src/api/queries/alertSilences.test.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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', () => ({
|
||||
api: { GET: vi.fn(), POST: vi.fn(), PUT: vi.fn(), DELETE: vi.fn() },
|
||||
}));
|
||||
|
||||
import { api as apiClient } from '../client';
|
||||
import { useAlertSilences } from './alertSilences';
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
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' } } },
|
||||
);
|
||||
});
|
||||
});
|
||||
101
ui/src/api/queries/alertSilences.ts
Normal file
101
ui/src/api/queries/alertSilences.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
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'];
|
||||
|
||||
// NOTE ON TYPES: the generated OpenAPI schema for env-scoped alert-silence
|
||||
// endpoints emits `path?: never` plus a `query.env: Environment` parameter
|
||||
// because the server resolves the env via the `@EnvPath` argument resolver,
|
||||
// which the OpenAPI scanner does not recognise as a path variable. At runtime
|
||||
// the URL template `{envSlug}` is substituted from `params.path.envSlug` by
|
||||
// openapi-fetch regardless of what the TS types say; we therefore cast the
|
||||
// call options to `any` on each call to bypass the generated type oddity.
|
||||
|
||||
/** List alert silences in the current env. */
|
||||
export function useAlertSilences() {
|
||||
const env = useSelectedEnv();
|
||||
return useQuery({
|
||||
queryKey: ['alertSilences', env],
|
||||
enabled: !!env,
|
||||
queryFn: async () => {
|
||||
if (!env) throw new Error('no env');
|
||||
const { data, error } = await apiClient.GET(
|
||||
'/environments/{envSlug}/alerts/silences',
|
||||
{
|
||||
params: { path: { envSlug: env } },
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as AlertSilenceResponse[];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a new alert silence in the current env. */
|
||||
export function useCreateSilence() {
|
||||
const qc = useQueryClient();
|
||||
const env = useSelectedEnv();
|
||||
return useMutation({
|
||||
mutationFn: async (req: AlertSilenceRequest) => {
|
||||
if (!env) throw new Error('no env');
|
||||
const { data, error } = await apiClient.POST(
|
||||
'/environments/{envSlug}/alerts/silences',
|
||||
{
|
||||
params: { path: { envSlug: env } },
|
||||
body: req,
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as AlertSilenceResponse;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['alertSilences', env] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Update an existing alert silence. */
|
||||
export function useUpdateSilence(id: string) {
|
||||
const qc = useQueryClient();
|
||||
const env = useSelectedEnv();
|
||||
return useMutation({
|
||||
mutationFn: async (req: AlertSilenceRequest) => {
|
||||
if (!env) throw new Error('no env');
|
||||
const { data, error } = await apiClient.PUT(
|
||||
'/environments/{envSlug}/alerts/silences/{id}',
|
||||
{
|
||||
params: { path: { envSlug: env, id } },
|
||||
body: req,
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as AlertSilenceResponse;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['alertSilences', env] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete an alert silence. */
|
||||
export function useDeleteSilence() {
|
||||
const qc = useQueryClient();
|
||||
const env = useSelectedEnv();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
if (!env) throw new Error('no env');
|
||||
const { error } = await apiClient.DELETE(
|
||||
'/environments/{envSlug}/alerts/silences/{id}',
|
||||
{
|
||||
params: { path: { envSlug: env, id } },
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['alertSilences', env] });
|
||||
},
|
||||
});
|
||||
}
|
||||
70
ui/src/api/queries/alerts.test.tsx
Normal file
70
ui/src/api/queries/alerts.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
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', () => ({
|
||||
api: { GET: vi.fn(), POST: vi.fn() },
|
||||
}));
|
||||
|
||||
import { api as apiClient } from '../client';
|
||||
import { useAlerts, useUnreadCount } from './alerts';
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
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: { count: 3 },
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useUnreadCount(), { wrapper });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toEqual({ count: 3 });
|
||||
});
|
||||
});
|
||||
168
ui/src/api/queries/alerts.ts
Normal file
168
ui/src/api/queries/alerts.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
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 = NonNullable<AlertDto['state']>;
|
||||
type AlertSeverity = NonNullable<AlertDto['severity']>;
|
||||
|
||||
export interface AlertsFilter {
|
||||
state?: AlertState | AlertState[];
|
||||
severity?: AlertSeverity | AlertSeverity[];
|
||||
ruleId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
function toArray<T>(v: T | T[] | undefined): T[] | undefined {
|
||||
if (v === undefined) return undefined;
|
||||
return Array.isArray(v) ? v : [v];
|
||||
}
|
||||
|
||||
// NOTE ON TYPES: the generated OpenAPI schema for env-scoped alert endpoints
|
||||
// emits `path?: never` plus a `query.env: Environment` parameter because the
|
||||
// server resolves the env via the `@EnvPath` argument resolver, which the
|
||||
// OpenAPI scanner does not recognise as a path variable. At runtime the URL
|
||||
// template `{envSlug}` is substituted from `params.path.envSlug` by
|
||||
// openapi-fetch regardless of what the TS types say; we therefore cast the
|
||||
// call options to `any` to bypass the generated type oddity.
|
||||
|
||||
/** List alert instances in the current env. Polls every 30s (pauses in background). */
|
||||
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,
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as AlertDto[];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a single alert instance by id. */
|
||||
export function useAlert(id: string | undefined) {
|
||||
const env = useSelectedEnv();
|
||||
return useQuery({
|
||||
queryKey: ['alerts', env, 'detail', id],
|
||||
enabled: !!env && !!id,
|
||||
queryFn: async () => {
|
||||
if (!env || !id) throw new Error('no env/id');
|
||||
const { data, error } = await apiClient.GET(
|
||||
'/environments/{envSlug}/alerts/{id}',
|
||||
{
|
||||
params: { path: { envSlug: env, id } },
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as AlertDto;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Unread alert count for the current env. Polls every 30s (pauses in background). */
|
||||
export function useUnreadCount() {
|
||||
const env = useSelectedEnv();
|
||||
return useQuery({
|
||||
queryKey: ['alerts', env, 'unread-count'],
|
||||
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/unread-count',
|
||||
{
|
||||
params: { path: { envSlug: env } },
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as UnreadCountResponse;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Acknowledge a single alert instance. */
|
||||
export function useAckAlert() {
|
||||
const env = useSelectedEnv();
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
if (!env) throw new Error('no env');
|
||||
const { data, error } = await apiClient.POST(
|
||||
'/environments/{envSlug}/alerts/{id}/ack',
|
||||
{
|
||||
params: { path: { envSlug: env, id } },
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as AlertDto;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['alerts', env] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark a single alert instance as read (inbox semantics). */
|
||||
export function useMarkAlertRead() {
|
||||
const env = useSelectedEnv();
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
if (!env) throw new Error('no env');
|
||||
const { error } = await apiClient.POST(
|
||||
'/environments/{envSlug}/alerts/{id}/read',
|
||||
{
|
||||
params: { path: { envSlug: env, id } },
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['alerts', env] });
|
||||
qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark a batch of alert instances as read. */
|
||||
export function useBulkReadAlerts() {
|
||||
const env = useSelectedEnv();
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (ids: string[]) => {
|
||||
if (!env) throw new Error('no env');
|
||||
const { error } = await apiClient.POST(
|
||||
'/environments/{envSlug}/alerts/bulk-read',
|
||||
{
|
||||
params: { path: { envSlug: env } },
|
||||
body: { instanceIds: ids },
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['alerts', env] });
|
||||
qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
25
ui/src/components/AlertStateChip.test.tsx
Normal file
25
ui/src/components/AlertStateChip.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@cameleer/design-system';
|
||||
import { AlertStateChip } from './AlertStateChip';
|
||||
|
||||
function renderWithTheme(ui: React.ReactElement) {
|
||||
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
}
|
||||
|
||||
describe('AlertStateChip', () => {
|
||||
it.each([
|
||||
['PENDING', /pending/i],
|
||||
['FIRING', /firing/i],
|
||||
['ACKNOWLEDGED', /acknowledged/i],
|
||||
['RESOLVED', /resolved/i],
|
||||
] as const)('renders %s label', (state, pattern) => {
|
||||
renderWithTheme(<AlertStateChip state={state} />);
|
||||
expect(screen.getByText(pattern)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows silenced suffix when silenced=true', () => {
|
||||
renderWithTheme(<AlertStateChip state="FIRING" silenced />);
|
||||
expect(screen.getByText(/silenced/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
27
ui/src/components/AlertStateChip.tsx
Normal file
27
ui/src/components/AlertStateChip.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Badge } from '@cameleer/design-system';
|
||||
import type { AlertDto } from '../api/queries/alerts';
|
||||
|
||||
type State = NonNullable<AlertDto['state']>;
|
||||
|
||||
const LABELS: Record<State, string> = {
|
||||
PENDING: 'Pending',
|
||||
FIRING: 'Firing',
|
||||
ACKNOWLEDGED: 'Acknowledged',
|
||||
RESOLVED: 'Resolved',
|
||||
};
|
||||
|
||||
const COLORS: Record<State, 'auto' | 'success' | 'warning' | 'error'> = {
|
||||
PENDING: 'warning',
|
||||
FIRING: 'error',
|
||||
ACKNOWLEDGED: 'warning',
|
||||
RESOLVED: 'success',
|
||||
};
|
||||
|
||||
export function AlertStateChip({ state, silenced }: { state: State; silenced?: boolean }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
|
||||
<Badge label={LABELS[state]} color={COLORS[state]} variant="filled" />
|
||||
{silenced && <Badge label="Silenced" color="auto" variant="outlined" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -21,8 +21,9 @@ import {
|
||||
} from '@cameleer/design-system';
|
||||
import type { SearchResult, SidebarTreeNode, DropdownItem, ButtonGroupItem, ExchangeStatus } from '@cameleer/design-system';
|
||||
import sidebarLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||
import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X, User, Plus, EyeOff } from 'lucide-react';
|
||||
import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X, User, Plus, EyeOff, Bell } from 'lucide-react';
|
||||
import { AboutMeDialog } from './AboutMeDialog';
|
||||
import { NotificationBell } from './NotificationBell';
|
||||
import css from './LayoutShell.module.css';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useCatalog } from '../api/queries/catalog';
|
||||
@@ -30,6 +31,8 @@ import { useAgents } from '../api/queries/agents';
|
||||
import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions';
|
||||
import { useUsers, useGroups, useRoles } from '../api/queries/admin/rbac';
|
||||
import { useEnvironments } from '../api/queries/admin/environments';
|
||||
import { useAlerts } from '../api/queries/alerts';
|
||||
import { useAlertRules } from '../api/queries/alertRules';
|
||||
import type { UserDetail, GroupDetail, RoleDetail } from '../api/queries/admin/rbac';
|
||||
import { useAuthStore, useIsAdmin, useCanControl } from '../auth/auth-store';
|
||||
import { useEnvironmentStore } from '../api/environment-store';
|
||||
@@ -42,6 +45,7 @@ import { formatDuration } from '../utils/format-utils';
|
||||
import {
|
||||
buildAppTreeNodes,
|
||||
buildAdminTreeNodes,
|
||||
buildAlertsTreeNodes,
|
||||
formatCount,
|
||||
readCollapsed,
|
||||
writeCollapsed,
|
||||
@@ -159,6 +163,58 @@ function buildAdminSearchData(
|
||||
return results;
|
||||
}
|
||||
|
||||
function buildAlertSearchData(
|
||||
alerts: any[] | undefined,
|
||||
rules: any[] | undefined,
|
||||
): SearchResult[] {
|
||||
const results: SearchResult[] = [];
|
||||
if (alerts) {
|
||||
for (const a of alerts) {
|
||||
results.push({
|
||||
id: `alert:${a.id}`,
|
||||
category: 'alert',
|
||||
title: a.title ?? '(untitled)',
|
||||
badges: [
|
||||
{ label: a.severity, color: severityToSearchColor(a.severity) },
|
||||
{ label: a.state, color: stateToSearchColor(a.state) },
|
||||
],
|
||||
meta: `${a.firedAt ?? ''}${a.silenced ? ' · silenced' : ''}`,
|
||||
path: `/alerts/inbox/${a.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rules) {
|
||||
for (const r of rules) {
|
||||
results.push({
|
||||
id: `rule:${r.id}`,
|
||||
category: 'alertRule',
|
||||
title: r.name,
|
||||
badges: [
|
||||
{ label: r.severity, color: severityToSearchColor(r.severity) },
|
||||
{ label: r.conditionKind, color: 'auto' },
|
||||
...(r.enabled ? [] : [{ label: 'DISABLED', color: 'warning' as const }]),
|
||||
],
|
||||
meta: `${r.evaluationIntervalSeconds}s · ${r.targets?.length ?? 0} targets`,
|
||||
path: `/alerts/rules/${r.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function severityToSearchColor(s: string): string {
|
||||
if (s === 'CRITICAL') return 'error';
|
||||
if (s === 'WARNING') return 'warning';
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
function stateToSearchColor(s: string): string {
|
||||
if (s === 'FIRING') return 'error';
|
||||
if (s === 'ACKNOWLEDGED') return 'warning';
|
||||
if (s === 'RESOLVED') return 'success';
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
function healthToSearchColor(health: string): string {
|
||||
switch (health) {
|
||||
case 'live': return 'success';
|
||||
@@ -278,6 +334,7 @@ const STATUS_ITEMS: ButtonGroupItem[] = [
|
||||
|
||||
const SK_APPS = 'sidebar:section:apps';
|
||||
const SK_ADMIN = 'sidebar:section:admin';
|
||||
const SK_ALERTS = 'sidebar:section:alerts';
|
||||
const SK_COLLAPSED = 'sidebar:collapsed';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -310,6 +367,10 @@ function LayoutContent() {
|
||||
const { data: attributeKeys } = useAttributeKeys();
|
||||
const { data: envRecords = [] } = useEnvironments();
|
||||
|
||||
// Open alerts + rules for CMD-K (env-scoped).
|
||||
const { data: cmdkAlerts } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 });
|
||||
const { data: cmdkRules } = useAlertRules();
|
||||
|
||||
// Merge environments from both the environments table and agent heartbeats
|
||||
const environments: string[] = useMemo(() => {
|
||||
const envSet = new Set<string>();
|
||||
@@ -325,6 +386,7 @@ function LayoutContent() {
|
||||
|
||||
// --- Admin search data (only fetched on admin pages) ----------------
|
||||
const isAdminPage = location.pathname.startsWith('/admin');
|
||||
const isAlertsPage = location.pathname.startsWith('/alerts');
|
||||
const { data: adminUsers } = useUsers(isAdminPage);
|
||||
const { data: adminGroups } = useGroups(isAdminPage);
|
||||
const { data: adminRoles } = useRoles(isAdminPage);
|
||||
@@ -367,8 +429,9 @@ function LayoutContent() {
|
||||
}, [setSelectedEnvRaw, navigate, location.pathname, location.search, queryClient]);
|
||||
|
||||
// --- Section open states ------------------------------------------
|
||||
const [appsOpen, setAppsOpen] = useState(() => isAdminPage ? false : readCollapsed(SK_APPS, true));
|
||||
const [appsOpen, setAppsOpen] = useState(() => (isAdminPage || isAlertsPage) ? false : readCollapsed(SK_APPS, true));
|
||||
const [adminOpen, setAdminOpen] = useState(() => isAdminPage ? true : readCollapsed(SK_ADMIN, false));
|
||||
const [alertsOpen, setAlertsOpen] = useState(() => isAlertsPage ? true : readCollapsed(SK_ALERTS, false));
|
||||
const [starredOpen, setStarredOpen] = useState(true);
|
||||
|
||||
// Accordion: entering admin collapses apps + starred; leaving restores
|
||||
@@ -388,6 +451,36 @@ function LayoutContent() {
|
||||
prevAdminRef.current = isAdminPage;
|
||||
}, [isAdminPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Accordion: entering alerts collapses apps + admin + starred; leaving restores
|
||||
const opsAlertsStateRef = useRef({ apps: appsOpen, admin: adminOpen, starred: starredOpen });
|
||||
const prevAlertsRef = useRef(isAlertsPage);
|
||||
useEffect(() => {
|
||||
if (isAlertsPage && !prevAlertsRef.current) {
|
||||
opsAlertsStateRef.current = { apps: appsOpen, admin: adminOpen, starred: starredOpen };
|
||||
setAppsOpen(false);
|
||||
setAdminOpen(false);
|
||||
setStarredOpen(false);
|
||||
setAlertsOpen(true);
|
||||
} else if (!isAlertsPage && prevAlertsRef.current) {
|
||||
setAppsOpen(opsAlertsStateRef.current.apps);
|
||||
setAdminOpen(opsAlertsStateRef.current.admin);
|
||||
setStarredOpen(opsAlertsStateRef.current.starred);
|
||||
setAlertsOpen(false);
|
||||
}
|
||||
prevAlertsRef.current = isAlertsPage;
|
||||
}, [isAlertsPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const toggleAlerts = useCallback(() => {
|
||||
if (!isAlertsPage) {
|
||||
navigate('/alerts/inbox');
|
||||
return;
|
||||
}
|
||||
setAlertsOpen((prev) => {
|
||||
writeCollapsed(SK_ALERTS, !prev);
|
||||
return !prev;
|
||||
});
|
||||
}, [isAlertsPage, navigate]);
|
||||
|
||||
const toggleApps = useCallback(() => {
|
||||
if (isAdminPage) {
|
||||
navigate('/exchanges');
|
||||
@@ -469,6 +562,11 @@ function LayoutContent() {
|
||||
[capabilities?.infrastructureEndpoints],
|
||||
);
|
||||
|
||||
const alertsTreeNodes: SidebarTreeNode[] = useMemo(
|
||||
() => buildAlertsTreeNodes(),
|
||||
[],
|
||||
);
|
||||
|
||||
// --- Starred items ------------------------------------------------
|
||||
const starredItems = useMemo(
|
||||
() => collectStarredItems(sidebarApps, starredIds),
|
||||
@@ -482,6 +580,7 @@ function LayoutContent() {
|
||||
if (!sidebarRevealPath) return;
|
||||
if (sidebarRevealPath.startsWith('/apps') && !appsOpen) setAppsOpen(true);
|
||||
if (sidebarRevealPath.startsWith('/admin') && !adminOpen) setAdminOpen(true);
|
||||
if (sidebarRevealPath.startsWith('/alerts') && !alertsOpen) setAlertsOpen(true);
|
||||
}, [sidebarRevealPath]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Normalize path so sidebar highlights the app regardless of which tab is active.
|
||||
@@ -528,6 +627,11 @@ function LayoutContent() {
|
||||
[adminUsers, adminGroups, adminRoles],
|
||||
);
|
||||
|
||||
const alertingSearchData: SearchResult[] = useMemo(
|
||||
() => buildAlertSearchData(cmdkAlerts, cmdkRules),
|
||||
[cmdkAlerts, cmdkRules],
|
||||
);
|
||||
|
||||
const operationalSearchData: SearchResult[] = useMemo(() => {
|
||||
if (isAdminPage) return [];
|
||||
|
||||
@@ -563,8 +667,8 @@ function LayoutContent() {
|
||||
}
|
||||
}
|
||||
|
||||
return [...catalogRef.current, ...exchangeItems, ...attributeItems];
|
||||
}, [isAdminPage, catalogRef.current, exchangeResults, debouncedQuery]);
|
||||
return [...catalogRef.current, ...exchangeItems, ...attributeItems, ...alertingSearchData];
|
||||
}, [isAdminPage, catalogRef.current, exchangeResults, debouncedQuery, alertingSearchData]);
|
||||
|
||||
const searchData = isAdminPage ? adminSearchData : operationalSearchData;
|
||||
|
||||
@@ -612,6 +716,11 @@ function LayoutContent() {
|
||||
const ADMIN_TAB_MAP: Record<string, string> = { user: 'users', group: 'groups', role: 'roles' };
|
||||
|
||||
const handlePaletteSelect = useCallback((result: any) => {
|
||||
if (result.category === 'alert' || result.category === 'alertRule') {
|
||||
if (result.path) navigate(result.path);
|
||||
setPaletteOpen(false);
|
||||
return;
|
||||
}
|
||||
if (result.path) {
|
||||
if (ADMIN_CATEGORIES.has(result.category)) {
|
||||
const itemId = result.id.split(':').slice(1).join(':');
|
||||
@@ -771,6 +880,26 @@ function LayoutContent() {
|
||||
</Sidebar.Section>
|
||||
</div>
|
||||
|
||||
{/* Alerts section */}
|
||||
<Sidebar.Section
|
||||
icon={createElement(Bell, { size: 16 })}
|
||||
label="Alerts"
|
||||
open={alertsOpen}
|
||||
onToggle={toggleAlerts}
|
||||
active={isAlertsPage}
|
||||
>
|
||||
<SidebarTree
|
||||
nodes={alertsTreeNodes}
|
||||
selectedPath={location.pathname}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={filterQuery}
|
||||
persistKey="alerts"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
onNavigate={handleSidebarNavigate}
|
||||
/>
|
||||
</Sidebar.Section>
|
||||
|
||||
{/* Starred section — only when there are starred items */}
|
||||
{starredItems.length > 0 && (
|
||||
<Sidebar.Section
|
||||
@@ -839,6 +968,7 @@ function LayoutContent() {
|
||||
onLogout={handleLogout}
|
||||
onNavigate={navigate}
|
||||
>
|
||||
<NotificationBell />
|
||||
<SearchTrigger onClick={() => setPaletteOpen(true)} />
|
||||
<ButtonGroup
|
||||
items={STATUS_ITEMS}
|
||||
|
||||
15
ui/src/components/MustacheEditor/MustacheEditor.module.css
Normal file
15
ui/src/components/MustacheEditor/MustacheEditor.module.css
Normal file
@@ -0,0 +1,15 @@
|
||||
.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);
|
||||
}
|
||||
34
ui/src/components/MustacheEditor/MustacheEditor.test.tsx
Normal file
34
ui/src/components/MustacheEditor/MustacheEditor.test.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MustacheEditor } from './MustacheEditor';
|
||||
|
||||
describe('MustacheEditor', () => {
|
||||
it('renders the initial value', () => {
|
||||
render(
|
||||
<MustacheEditor
|
||||
value="Hello {{rule.name}}"
|
||||
onChange={() => {}}
|
||||
kind="ROUTE_METRIC"
|
||||
label="Title template"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Hello/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a textbox and does not call onChange before user interaction', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<MustacheEditor
|
||||
value=""
|
||||
onChange={onChange}
|
||||
kind="ROUTE_METRIC"
|
||||
label="Title template"
|
||||
/>,
|
||||
);
|
||||
const editor = screen.getByRole('textbox', { name: 'Title template' });
|
||||
expect(editor).toBeInTheDocument();
|
||||
// CM6 fires onChange via transactions, not DOM input events; without a real
|
||||
// user interaction the callback must remain untouched.
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
108
ui/src/components/MustacheEditor/MustacheEditor.tsx
Normal file
108
ui/src/components/MustacheEditor/MustacheEditor.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
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<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(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 (
|
||||
<div className={css.wrapper}>
|
||||
<label className={css.label}>{props.label}</label>
|
||||
<div
|
||||
ref={hostRef}
|
||||
className={css.editor}
|
||||
role="textbox"
|
||||
aria-label={props.label}
|
||||
style={{ minHeight: minH }}
|
||||
data-placeholder={props.placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
ui/src/components/MustacheEditor/alert-variables.test.ts
Normal file
61
ui/src/components/MustacheEditor/alert-variables.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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.pattern')).toBeUndefined();
|
||||
expect(vars.find((v) => v.path === 'app.slug')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('adds exchange.* + route.* + app.* for EXCHANGE_MATCH kind', () => {
|
||||
const vars = availableVariables('EXCHANGE_MATCH');
|
||||
expect(vars.find((v) => v.path === 'exchange.id')).toBeTruthy();
|
||||
expect(vars.find((v) => v.path === 'route.uri')).toBeTruthy();
|
||||
expect(vars.find((v) => v.path === 'app.slug')).toBeTruthy();
|
||||
expect(vars.find((v) => v.path === 'log.pattern')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('adds log.* + app.* for LOG_PATTERN kind', () => {
|
||||
const vars = availableVariables('LOG_PATTERN');
|
||||
expect(vars.find((v) => v.path === 'log.pattern')).toBeTruthy();
|
||||
expect(vars.find((v) => v.path === 'log.matchCount')).toBeTruthy();
|
||||
expect(vars.find((v) => v.path === 'app.slug')).toBeTruthy();
|
||||
expect(vars.find((v) => v.path === 'exchange.id')).toBeUndefined();
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
120
ui/src/components/MustacheEditor/alert-variables.ts
Normal file
120
ui/src/components/MustacheEditor/alert-variables.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
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 },
|
||||
|
||||
// App subtree — populated on every kind except env-wide rules
|
||||
{ path: 'app.slug', type: 'string', description: 'App slug', sampleValue: 'orders',
|
||||
availableForKinds: ['ROUTE_METRIC', 'EXCHANGE_MATCH', 'AGENT_STATE', 'DEPLOYMENT_STATE', 'LOG_PATTERN', 'JVM_METRIC'], mayBeNull: true },
|
||||
{ path: 'app.id', type: 'uuid', description: 'App UUID', sampleValue: '33333333-...',
|
||||
availableForKinds: ['ROUTE_METRIC', 'EXCHANGE_MATCH', 'AGENT_STATE', 'DEPLOYMENT_STATE', 'LOG_PATTERN', 'JVM_METRIC'], mayBeNull: true },
|
||||
|
||||
// ROUTE_METRIC + EXCHANGE_MATCH share route.*
|
||||
{ path: 'route.id', type: 'string', description: 'Route ID', sampleValue: 'route-1',
|
||||
availableForKinds: ['ROUTE_METRIC', 'EXCHANGE_MATCH'] },
|
||||
{ path: 'route.uri', type: 'string', description: 'Route URI', sampleValue: 'direct:orders',
|
||||
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'] },
|
||||
|
||||
// AGENT_STATE + JVM_METRIC share agent.id/name; AGENT_STATE adds 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 — leaf names match NotificationContextBuilder (log.pattern + log.matchCount)
|
||||
{ path: 'log.pattern', type: 'string', description: 'Matched log pattern', sampleValue: 'TimeoutException',
|
||||
availableForKinds: ['LOG_PATTERN'] },
|
||||
{ path: 'log.matchCount', type: 'number', description: 'Matches in window', sampleValue: '7',
|
||||
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));
|
||||
}
|
||||
44
ui/src/components/MustacheEditor/mustache-completion.test.ts
Normal file
44
ui/src/components/MustacheEditor/mustache-completion.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
43
ui/src/components/MustacheEditor/mustache-completion.ts
Normal file
43
ui/src/components/MustacheEditor/mustache-completion.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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_.]*$/,
|
||||
};
|
||||
};
|
||||
}
|
||||
47
ui/src/components/MustacheEditor/mustache-linter.test.ts
Normal file
47
ui/src/components/MustacheEditor/mustache-linter.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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 debounces with a default 750ms delay — wait past that for the source to run.
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
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/);
|
||||
});
|
||||
});
|
||||
62
ui/src/components/MustacheEditor/mustache-linter.ts
Normal file
62
ui/src/components/MustacheEditor/mustache-linter.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
27
ui/src/components/NotificationBell.module.css
Normal file
27
ui/src/components/NotificationBell.module.css
Normal file
@@ -0,0 +1,27 @@
|
||||
.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;
|
||||
}
|
||||
47
ui/src/components/NotificationBell.test.tsx
Normal file
47
ui/src/components/NotificationBell.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
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', () => ({ api: { GET: vi.fn() } }));
|
||||
|
||||
import { api as apiClient } from '../api/client';
|
||||
import { NotificationBell } from './NotificationBell';
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('NotificationBell', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useEnvironmentStore.setState({ environment: 'dev' });
|
||||
});
|
||||
|
||||
it('renders bell with no badge when zero unread', async () => {
|
||||
(apiClient.GET as any).mockResolvedValue({
|
||||
data: { count: 0 },
|
||||
error: null,
|
||||
});
|
||||
render(<NotificationBell />, { wrapper });
|
||||
expect(await screen.findByRole('button', { name: /notifications/i })).toBeInTheDocument();
|
||||
// Badge is only rendered when count > 0; no numeric text should appear.
|
||||
expect(screen.queryByText(/^\d+$/)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows unread count badge when unread alerts exist', async () => {
|
||||
(apiClient.GET as any).mockResolvedValue({
|
||||
data: { count: 3 },
|
||||
error: null,
|
||||
});
|
||||
render(<NotificationBell />, { wrapper });
|
||||
expect(await screen.findByText('3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
40
ui/src/components/NotificationBell.tsx
Normal file
40
ui/src/components/NotificationBell.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Link } from 'react-router';
|
||||
import { Bell } from 'lucide-react';
|
||||
import { useUnreadCount } from '../api/queries/alerts';
|
||||
import { useSelectedEnv } from '../api/queries/alertMeta';
|
||||
import css from './NotificationBell.module.css';
|
||||
|
||||
/**
|
||||
* Global notification bell shown in the layout header. Links to the alerts
|
||||
* inbox and renders a badge with the unread-alert count for the currently
|
||||
* selected environment.
|
||||
*
|
||||
* Polling pause when the tab is hidden is handled by `useUnreadCount`'s
|
||||
* `refetchIntervalInBackground: false`; no separate visibility subscription
|
||||
* is needed. If per-severity coloring (spec §13) is re-introduced, the
|
||||
* backend `UnreadCountResponse` must grow a `bySeverity` map.
|
||||
*/
|
||||
export function NotificationBell() {
|
||||
const env = useSelectedEnv();
|
||||
const { data } = useUnreadCount();
|
||||
|
||||
const count = data?.count ?? 0;
|
||||
|
||||
if (!env) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/alerts/inbox"
|
||||
role="button"
|
||||
aria-label={`Notifications (${count} unread)`}
|
||||
className={css.bell}
|
||||
>
|
||||
<Bell size={16} />
|
||||
{count > 0 && (
|
||||
<span className={css.badge} aria-hidden>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
19
ui/src/components/SeverityBadge.test.tsx
Normal file
19
ui/src/components/SeverityBadge.test.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@cameleer/design-system';
|
||||
import { SeverityBadge } from './SeverityBadge';
|
||||
|
||||
function renderWithTheme(ui: React.ReactElement) {
|
||||
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
}
|
||||
|
||||
describe('SeverityBadge', () => {
|
||||
it.each([
|
||||
['CRITICAL', /critical/i],
|
||||
['WARNING', /warning/i],
|
||||
['INFO', /info/i],
|
||||
] as const)('renders %s', (severity, pattern) => {
|
||||
renderWithTheme(<SeverityBadge severity={severity} />);
|
||||
expect(screen.getByText(pattern)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
20
ui/src/components/SeverityBadge.tsx
Normal file
20
ui/src/components/SeverityBadge.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Badge } from '@cameleer/design-system';
|
||||
import type { AlertDto } from '../api/queries/alerts';
|
||||
|
||||
type Severity = NonNullable<AlertDto['severity']>;
|
||||
|
||||
const LABELS: Record<Severity, string> = {
|
||||
CRITICAL: 'Critical',
|
||||
WARNING: 'Warning',
|
||||
INFO: 'Info',
|
||||
};
|
||||
|
||||
const COLORS: Record<Severity, 'auto' | 'warning' | 'error'> = {
|
||||
CRITICAL: 'error',
|
||||
WARNING: 'warning',
|
||||
INFO: 'auto',
|
||||
};
|
||||
|
||||
export function SeverityBadge({ severity }: { severity: Severity }) {
|
||||
return <Badge label={LABELS[severity]} color={COLORS[severity]} variant="filled" />;
|
||||
}
|
||||
17
ui/src/components/sidebar-utils.test.ts
Normal file
17
ui/src/components/sidebar-utils.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildAlertsTreeNodes } from './sidebar-utils';
|
||||
|
||||
describe('buildAlertsTreeNodes', () => {
|
||||
it('returns 5 entries with inbox/all/rules/silences/history paths', () => {
|
||||
const nodes = buildAlertsTreeNodes();
|
||||
expect(nodes).toHaveLength(5);
|
||||
const paths = nodes.map((n) => n.path);
|
||||
expect(paths).toEqual([
|
||||
'/alerts/inbox',
|
||||
'/alerts/all',
|
||||
'/alerts/rules',
|
||||
'/alerts/silences',
|
||||
'/alerts/history',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import type { SidebarTreeNode } from '@cameleer/design-system';
|
||||
import { AlertTriangle, Inbox, List, ScrollText, BellOff } from 'lucide-react';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Domain types (moved out of DS — no longer exported there) */
|
||||
@@ -113,3 +114,18 @@ export function buildAdminTreeNodes(opts?: { infrastructureEndpoints?: boolean }
|
||||
];
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alerts tree — static nodes for the alerting section.
|
||||
* Paths: /alerts/{inbox|all|rules|silences|history}
|
||||
*/
|
||||
export function buildAlertsTreeNodes(): SidebarTreeNode[] {
|
||||
const icon = (el: ReactNode) => el;
|
||||
return [
|
||||
{ id: 'alerts-inbox', label: 'Inbox', path: '/alerts/inbox', icon: icon(createElement(Inbox, { size: 14 })) },
|
||||
{ id: 'alerts-all', label: 'All', path: '/alerts/all', icon: icon(createElement(List, { size: 14 })) },
|
||||
{ id: 'alerts-rules', label: 'Rules', path: '/alerts/rules', icon: icon(createElement(AlertTriangle, { size: 14 })) },
|
||||
{ id: 'alerts-silences', label: 'Silences', path: '/alerts/silences', icon: icon(createElement(BellOff, { size: 14 })) },
|
||||
{ id: 'alerts-history', label: 'History', path: '/alerts/history', icon: icon(createElement(ScrollText, { size: 14 })) },
|
||||
];
|
||||
}
|
||||
|
||||
30
ui/src/hooks/usePageVisible.test.ts
Normal file
30
ui/src/hooks/usePageVisible.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, beforeEach } 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);
|
||||
});
|
||||
});
|
||||
22
ui/src/hooks/usePageVisible.ts
Normal file
22
ui/src/hooks/usePageVisible.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Tracks Page Visibility API state for the current document.
|
||||
*
|
||||
* Returns `true` when the tab is visible, `false` when hidden. Useful for
|
||||
* pausing work (polling, animations, expensive DOM effects) while the tab
|
||||
* is backgrounded. SSR-safe: defaults to `true` when `document` is undefined.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
48
ui/src/pages/Alerts/AlertRow.tsx
Normal file
48
ui/src/pages/Alerts/AlertRow.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Link } from 'react-router';
|
||||
import { Button, useToast } from '@cameleer/design-system';
|
||||
import { AlertStateChip } from '../../components/AlertStateChip';
|
||||
import { SeverityBadge } from '../../components/SeverityBadge';
|
||||
import type { AlertDto } from '../../api/queries/alerts';
|
||||
import { useAckAlert, useMarkAlertRead } from '../../api/queries/alerts';
|
||||
import css from './alerts-page.module.css';
|
||||
|
||||
export function AlertRow({ alert, unread }: { alert: AlertDto; unread: boolean }) {
|
||||
const ack = useAckAlert();
|
||||
const markRead = useMarkAlertRead();
|
||||
const { toast } = useToast();
|
||||
|
||||
const onAck = async () => {
|
||||
try {
|
||||
await ack.mutateAsync(alert.id);
|
||||
toast({ title: 'Acknowledged', description: alert.title, variant: 'success' });
|
||||
} catch (e) {
|
||||
toast({ title: 'Ack failed', description: String(e), variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${css.row} ${unread ? css.rowUnread : ''}`}
|
||||
data-testid={`alert-row-${alert.id}`}
|
||||
>
|
||||
<SeverityBadge severity={alert.severity} />
|
||||
<div className={css.body}>
|
||||
<Link to={`/alerts/inbox/${alert.id}`} onClick={() => markRead.mutate(alert.id)}>
|
||||
<strong>{alert.title}</strong>
|
||||
</Link>
|
||||
<div className={css.meta}>
|
||||
<AlertStateChip state={alert.state} silenced={alert.silenced} />
|
||||
<span className={css.time}>{alert.firedAt}</span>
|
||||
</div>
|
||||
<p className={css.message}>{alert.message}</p>
|
||||
</div>
|
||||
<div className={css.actions}>
|
||||
{alert.state === 'FIRING' && (
|
||||
<Button size="sm" variant="secondary" onClick={onAck} disabled={ack.isPending}>
|
||||
Ack
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
ui/src/pages/Alerts/AllAlertsPage.tsx
Normal file
51
ui/src/pages/Alerts/AllAlertsPage.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
import { SectionHeader, Button } from '@cameleer/design-system';
|
||||
import { PageLoader } from '../../components/PageLoader';
|
||||
import { useAlerts, type AlertDto } from '../../api/queries/alerts';
|
||||
import { AlertRow } from './AlertRow';
|
||||
import css from './alerts-page.module.css';
|
||||
|
||||
type AlertState = NonNullable<AlertDto['state']>;
|
||||
|
||||
const STATE_FILTERS: Array<{ label: string; values: AlertState[] }> = [
|
||||
{ label: 'Open', values: ['PENDING', 'FIRING', 'ACKNOWLEDGED'] },
|
||||
{ label: 'Firing', values: ['FIRING'] },
|
||||
{ label: 'Acked', values: ['ACKNOWLEDGED'] },
|
||||
{ label: 'All', values: ['PENDING', 'FIRING', 'ACKNOWLEDGED', 'RESOLVED'] },
|
||||
];
|
||||
|
||||
export default function AllAlertsPage() {
|
||||
const [filterIdx, setFilterIdx] = useState(0);
|
||||
const filter = STATE_FILTERS[filterIdx];
|
||||
const { data, isLoading, error } = useAlerts({ state: filter.values, limit: 200 });
|
||||
|
||||
if (isLoading) return <PageLoader />;
|
||||
if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
return (
|
||||
<div className={css.page}>
|
||||
<div className={css.toolbar}>
|
||||
<SectionHeader>All alerts</SectionHeader>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{STATE_FILTERS.map((f, i) => (
|
||||
<Button
|
||||
key={f.label}
|
||||
size="sm"
|
||||
variant={i === filterIdx ? 'primary' : 'secondary'}
|
||||
onClick={() => setFilterIdx(i)}
|
||||
>
|
||||
{f.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div className={css.empty}>No alerts match this filter.</div>
|
||||
) : (
|
||||
rows.map((a) => <AlertRow key={a.id} alert={a} unread={false} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
ui/src/pages/Alerts/HistoryPage.tsx
Normal file
27
ui/src/pages/Alerts/HistoryPage.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { SectionHeader } from '@cameleer/design-system';
|
||||
import { PageLoader } from '../../components/PageLoader';
|
||||
import { useAlerts } from '../../api/queries/alerts';
|
||||
import { AlertRow } from './AlertRow';
|
||||
import css from './alerts-page.module.css';
|
||||
|
||||
export default function HistoryPage() {
|
||||
const { data, isLoading, error } = useAlerts({ state: 'RESOLVED', limit: 200 });
|
||||
|
||||
if (isLoading) return <PageLoader />;
|
||||
if (error) return <div className={css.page}>Failed to load history: {String(error)}</div>;
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
return (
|
||||
<div className={css.page}>
|
||||
<div className={css.toolbar}>
|
||||
<SectionHeader>History</SectionHeader>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div className={css.empty}>No resolved alerts in retention window.</div>
|
||||
) : (
|
||||
rows.map((a) => <AlertRow key={a.id} alert={a} unread={false} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
ui/src/pages/Alerts/InboxPage.tsx
Normal file
48
ui/src/pages/Alerts/InboxPage.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, SectionHeader, useToast } from '@cameleer/design-system';
|
||||
import { PageLoader } from '../../components/PageLoader';
|
||||
import { useAlerts, useBulkReadAlerts } from '../../api/queries/alerts';
|
||||
import { AlertRow } from './AlertRow';
|
||||
import css from './alerts-page.module.css';
|
||||
|
||||
export default function InboxPage() {
|
||||
const { data, isLoading, error } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 });
|
||||
const bulkRead = useBulkReadAlerts();
|
||||
const { toast } = useToast();
|
||||
|
||||
const unreadIds = useMemo(
|
||||
() => (data ?? []).filter((a) => a.state === 'FIRING').map((a) => a.id),
|
||||
[data],
|
||||
);
|
||||
|
||||
if (isLoading) return <PageLoader />;
|
||||
if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
const onMarkAllRead = async () => {
|
||||
if (unreadIds.length === 0) return;
|
||||
try {
|
||||
await bulkRead.mutateAsync(unreadIds);
|
||||
toast({ title: `Marked ${unreadIds.length} as read`, variant: 'success' });
|
||||
} catch (e) {
|
||||
toast({ title: 'Bulk read failed', description: String(e), variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={css.page}>
|
||||
<div className={css.toolbar}>
|
||||
<SectionHeader>Inbox</SectionHeader>
|
||||
<Button variant="secondary" onClick={onMarkAllRead} disabled={bulkRead.isPending || unreadIds.length === 0}>
|
||||
Mark all read
|
||||
</Button>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div className={css.empty}>No open alerts for you in this environment.</div>
|
||||
) : (
|
||||
rows.map((a) => <AlertRow key={a.id} alert={a} unread={a.state === 'FIRING'} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx
Normal file
49
ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { FormField, Select } from '@cameleer/design-system';
|
||||
import type { FormState } from './form-state';
|
||||
import { RouteMetricForm } from './condition-forms/RouteMetricForm';
|
||||
import { ExchangeMatchForm } from './condition-forms/ExchangeMatchForm';
|
||||
import { AgentStateForm } from './condition-forms/AgentStateForm';
|
||||
import { DeploymentStateForm } from './condition-forms/DeploymentStateForm';
|
||||
import { LogPatternForm } from './condition-forms/LogPatternForm';
|
||||
import { JvmMetricForm } from './condition-forms/JvmMetricForm';
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ value: 'ROUTE_METRIC', label: 'Route metric (error rate, latency, throughput)' },
|
||||
{ value: 'EXCHANGE_MATCH', label: 'Exchange match (specific failures)' },
|
||||
{ value: 'AGENT_STATE', label: 'Agent state (DEAD / STALE)' },
|
||||
{ value: 'DEPLOYMENT_STATE', label: 'Deployment state (FAILED / DEGRADED)' },
|
||||
{ value: 'LOG_PATTERN', label: 'Log pattern (count of matching logs)' },
|
||||
{ value: 'JVM_METRIC', label: 'JVM metric (heap, GC, inflight)' },
|
||||
];
|
||||
|
||||
export function ConditionStep({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
|
||||
const onKindChange = (v: string) => {
|
||||
const kind = v as FormState['conditionKind'];
|
||||
// Reset the condition payload so stale fields from a previous kind don't leak
|
||||
// into the save request. Preserve scope — it's managed on the scope step.
|
||||
const prev = form.condition as Record<string, unknown>;
|
||||
setForm({
|
||||
...form,
|
||||
conditionKind: kind,
|
||||
condition: { kind, scope: prev.scope } as FormState['condition'],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 16, maxWidth: 720 }}>
|
||||
<FormField label="Condition kind">
|
||||
<Select
|
||||
value={form.conditionKind}
|
||||
onChange={(e) => onKindChange(e.target.value)}
|
||||
options={KIND_OPTIONS}
|
||||
/>
|
||||
</FormField>
|
||||
{form.conditionKind === 'ROUTE_METRIC' && <RouteMetricForm form={form} setForm={setForm} />}
|
||||
{form.conditionKind === 'EXCHANGE_MATCH' && <ExchangeMatchForm form={form} setForm={setForm} />}
|
||||
{form.conditionKind === 'AGENT_STATE' && <AgentStateForm form={form} setForm={setForm} />}
|
||||
{form.conditionKind === 'DEPLOYMENT_STATE' && <DeploymentStateForm form={form} setForm={setForm} />}
|
||||
{form.conditionKind === 'LOG_PATTERN' && <LogPatternForm form={form} setForm={setForm} />}
|
||||
{form.conditionKind === 'JVM_METRIC' && <JvmMetricForm form={form} setForm={setForm} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx
Normal file
250
ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useState } from 'react';
|
||||
import { Badge, Button, FormField, Input, Select, useToast } from '@cameleer/design-system';
|
||||
import { MustacheEditor } from '../../../components/MustacheEditor/MustacheEditor';
|
||||
import { useUsers, useGroups, useRoles } from '../../../api/queries/admin/rbac';
|
||||
import { useOutboundConnections } from '../../../api/queries/admin/outboundConnections';
|
||||
import { useSelectedEnv } from '../../../api/queries/alertMeta';
|
||||
import { useRenderPreview } from '../../../api/queries/alertRules';
|
||||
import type { FormState } from './form-state';
|
||||
|
||||
type TargetKind = FormState['targets'][number]['kind'];
|
||||
|
||||
export function NotifyStep({
|
||||
form,
|
||||
setForm,
|
||||
ruleId,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm: (f: FormState) => void;
|
||||
ruleId?: string;
|
||||
}) {
|
||||
const env = useSelectedEnv();
|
||||
const { data: users } = useUsers(true);
|
||||
const { data: groups } = useGroups(true);
|
||||
const { data: roles } = useRoles(true);
|
||||
const { data: connections } = useOutboundConnections();
|
||||
const preview = useRenderPreview();
|
||||
const { toast } = useToast();
|
||||
const [lastPreview, setLastPreview] = useState<string | null>(null);
|
||||
|
||||
// Filter connections to those that allow the current env.
|
||||
const availableConnections = (connections ?? []).filter(
|
||||
(c) => c.allowedEnvironmentIds.length === 0 || (!!env && c.allowedEnvironmentIds.includes(env)),
|
||||
);
|
||||
|
||||
const onPreview = async () => {
|
||||
if (!ruleId) {
|
||||
toast({ title: 'Save rule first to preview', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await preview.mutateAsync({ id: ruleId, req: {} });
|
||||
setLastPreview(`TITLE:\n${res.title ?? ''}\n\nMESSAGE:\n${res.message ?? ''}`);
|
||||
} catch (e) {
|
||||
toast({ title: 'Preview failed', description: String(e), variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const addTarget = (kind: TargetKind, targetId: string) => {
|
||||
if (!targetId) return;
|
||||
if (form.targets.some((t) => t.kind === kind && t.targetId === targetId)) return;
|
||||
setForm({ ...form, targets: [...form.targets, { kind, targetId }] });
|
||||
};
|
||||
const removeTarget = (idx: number) => {
|
||||
setForm({ ...form, targets: form.targets.filter((_, i) => i !== idx) });
|
||||
};
|
||||
|
||||
const addWebhook = (outboundConnectionId: string) => {
|
||||
setForm({
|
||||
...form,
|
||||
webhooks: [...form.webhooks, { outboundConnectionId, bodyOverride: '', headerOverrides: [] }],
|
||||
});
|
||||
};
|
||||
const removeWebhook = (idx: number) => {
|
||||
setForm({ ...form, webhooks: form.webhooks.filter((_, i) => i !== idx) });
|
||||
};
|
||||
const updateWebhook = (idx: number, patch: Partial<FormState['webhooks'][number]>) => {
|
||||
setForm({
|
||||
...form,
|
||||
webhooks: form.webhooks.map((w, i) => (i === idx ? { ...w, ...patch } : w)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 16, maxWidth: 720 }}>
|
||||
<MustacheEditor
|
||||
label="Notification title"
|
||||
value={form.notificationTitleTmpl}
|
||||
onChange={(v) => setForm({ ...form, notificationTitleTmpl: v })}
|
||||
kind={form.conditionKind}
|
||||
singleLine
|
||||
/>
|
||||
<MustacheEditor
|
||||
label="Notification message"
|
||||
value={form.notificationMessageTmpl}
|
||||
onChange={(v) => setForm({ ...form, notificationMessageTmpl: v })}
|
||||
kind={form.conditionKind}
|
||||
minHeight={120}
|
||||
/>
|
||||
<div>
|
||||
<Button variant="secondary" onClick={onPreview} disabled={preview.isPending}>
|
||||
Preview rendered output
|
||||
</Button>
|
||||
{!ruleId && (
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--muted)' }}>
|
||||
Save the rule first to preview rendered output.
|
||||
</p>
|
||||
)}
|
||||
{lastPreview && (
|
||||
<pre
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: 8,
|
||||
background: 'var(--code-bg)',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{lastPreview}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField label="Notification targets">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
|
||||
{form.targets.map((t, i) => (
|
||||
<Badge
|
||||
key={`${t.kind}:${t.targetId}`}
|
||||
label={`${t.kind}: ${t.targetId}`}
|
||||
variant="outlined"
|
||||
onRemove={() => removeTarget(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
addTarget('USER', e.target.value);
|
||||
e.target.value = '';
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: '+ User' },
|
||||
...(users ?? []).map((u) => ({ value: u.userId, label: u.displayName ?? u.userId })),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
addTarget('GROUP', e.target.value);
|
||||
e.target.value = '';
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: '+ Group' },
|
||||
...(groups ?? []).map((g) => ({ value: g.id, label: g.name })),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
addTarget('ROLE', e.target.value);
|
||||
e.target.value = '';
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: '+ Role' },
|
||||
...(roles ?? []).map((r) => ({ value: r.name, label: r.name })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Webhook destinations (outbound connections)">
|
||||
<Select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value) addWebhook(e.target.value);
|
||||
e.target.value = '';
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: '+ Add webhook' },
|
||||
...availableConnections.map((c) => ({ value: c.id, label: c.name })),
|
||||
]}
|
||||
/>
|
||||
{form.webhooks.map((w, i) => {
|
||||
const conn = availableConnections.find((c) => c.id === w.outboundConnectionId);
|
||||
return (
|
||||
<div
|
||||
key={`${w.outboundConnectionId}-${i}`}
|
||||
style={{
|
||||
padding: 12,
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
marginTop: 8,
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<strong>{conn?.name ?? w.outboundConnectionId}</strong>
|
||||
<Button size="sm" variant="secondary" onClick={() => removeWebhook(i)}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
<MustacheEditor
|
||||
label="Body override (optional)"
|
||||
value={w.bodyOverride}
|
||||
onChange={(v) => updateWebhook(i, { bodyOverride: v })}
|
||||
kind={form.conditionKind}
|
||||
placeholder="Leave empty to use connection default"
|
||||
minHeight={80}
|
||||
/>
|
||||
<FormField label="Header overrides">
|
||||
{w.headerOverrides.map((h, hi) => (
|
||||
<div key={hi} style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
|
||||
<Input
|
||||
value={h.key}
|
||||
placeholder="Header name"
|
||||
onChange={(e) => {
|
||||
const heads = [...w.headerOverrides];
|
||||
heads[hi] = { ...heads[hi], key: e.target.value };
|
||||
updateWebhook(i, { headerOverrides: heads });
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={h.value}
|
||||
placeholder="Mustache value"
|
||||
onChange={(e) => {
|
||||
const heads = [...w.headerOverrides];
|
||||
heads[hi] = { ...heads[hi], value: e.target.value };
|
||||
updateWebhook(i, { headerOverrides: heads });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
updateWebhook(i, { headerOverrides: w.headerOverrides.filter((_, x) => x !== hi) })
|
||||
}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
updateWebhook(i, { headerOverrides: [...w.headerOverrides, { key: '', value: '' }] })
|
||||
}
|
||||
>
|
||||
+ Header override
|
||||
</Button>
|
||||
</FormField>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</FormField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx
Normal file
62
ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Toggle } from '@cameleer/design-system';
|
||||
import { toRequest, type FormState } from './form-state';
|
||||
|
||||
export function ReviewStep({
|
||||
form,
|
||||
setForm,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm?: (f: FormState) => void;
|
||||
}) {
|
||||
const req = toRequest(form);
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 12, maxWidth: 720 }}>
|
||||
<div>
|
||||
<strong>Name:</strong> {form.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Severity:</strong> {form.severity}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Scope:</strong> {form.scopeKind}
|
||||
{form.scopeKind !== 'env' &&
|
||||
` (app=${form.appSlug}${form.routeId ? `, route=${form.routeId}` : ''}${form.agentId ? `, agent=${form.agentId}` : ''})`}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Condition kind:</strong> {form.conditionKind}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Intervals:</strong> eval {form.evaluationIntervalSeconds}s · for {form.forDurationSeconds}s · re-notify {form.reNotifyMinutes}m
|
||||
</div>
|
||||
<div>
|
||||
<strong>Targets:</strong> {form.targets.length}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Webhooks:</strong> {form.webhooks.length}
|
||||
</div>
|
||||
{setForm && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Toggle
|
||||
checked={form.enabled}
|
||||
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
|
||||
label="Enabled on save"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<details>
|
||||
<summary>Raw request JSON</summary>
|
||||
<pre
|
||||
style={{
|
||||
fontSize: 11,
|
||||
background: 'var(--code-bg)',
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(req, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx
Normal file
197
ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { Button, SectionHeader, useToast } from '@cameleer/design-system';
|
||||
import { PageLoader } from '../../../components/PageLoader';
|
||||
import {
|
||||
useAlertRule,
|
||||
useCreateAlertRule,
|
||||
useUpdateAlertRule,
|
||||
} from '../../../api/queries/alertRules';
|
||||
import {
|
||||
initialForm,
|
||||
toRequest,
|
||||
validateStep,
|
||||
WIZARD_STEPS,
|
||||
type FormState,
|
||||
type WizardStep,
|
||||
} from './form-state';
|
||||
import { ScopeStep } from './ScopeStep';
|
||||
import { ConditionStep } from './ConditionStep';
|
||||
import { TriggerStep } from './TriggerStep';
|
||||
import { NotifyStep } from './NotifyStep';
|
||||
import { ReviewStep } from './ReviewStep';
|
||||
import { prefillFromPromotion, type PrefillWarning } from './promotion-prefill';
|
||||
import { useCatalog } from '../../../api/queries/catalog';
|
||||
import { useOutboundConnections } from '../../../api/queries/admin/outboundConnections';
|
||||
import { useSelectedEnv } from '../../../api/queries/alertMeta';
|
||||
import css from './wizard.module.css';
|
||||
|
||||
const STEP_LABELS: Record<WizardStep, string> = {
|
||||
scope: '1. Scope',
|
||||
condition: '2. Condition',
|
||||
trigger: '3. Trigger',
|
||||
notify: '4. Notify',
|
||||
review: '5. Review',
|
||||
};
|
||||
|
||||
export default function RuleEditorWizard() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const [search] = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
|
||||
const isEdit = !!id;
|
||||
const existingQuery = useAlertRule(isEdit ? id : undefined);
|
||||
|
||||
// Promotion prefill uses a separate query against the source env. In
|
||||
// Plan 03 we reuse `useAlertRule` (current-env scoped); cross-env
|
||||
// fetching is handled server-side in the promote query hook when wired.
|
||||
const promoteFrom = search.get('promoteFrom') ?? undefined;
|
||||
const promoteRuleId = search.get('ruleId') ?? undefined;
|
||||
const sourceRuleQuery = useAlertRule(promoteFrom ? promoteRuleId : undefined);
|
||||
|
||||
// Target-env data for promotion warnings.
|
||||
const env = useSelectedEnv();
|
||||
const targetEnv = search.get('targetEnv') ?? env;
|
||||
const { data: targetCatalog } = useCatalog(targetEnv ?? undefined);
|
||||
const { data: connections } = useOutboundConnections();
|
||||
|
||||
const targetAppSlugs = (targetCatalog ?? []).map((a) => a.slug);
|
||||
const targetAllowedConnIds = (connections ?? [])
|
||||
.filter((c) => c.allowedEnvironmentIds.length === 0 || (!!targetEnv && c.allowedEnvironmentIds.includes(targetEnv)))
|
||||
.map((c) => c.id);
|
||||
|
||||
const [step, setStep] = useState<WizardStep>('scope');
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [warnings, setWarnings] = useState<PrefillWarning[]>([]);
|
||||
|
||||
// Initialize form once the existing or source rule loads.
|
||||
useEffect(() => {
|
||||
if (form) return;
|
||||
if (isEdit && existingQuery.data) {
|
||||
setForm(initialForm(existingQuery.data));
|
||||
return;
|
||||
}
|
||||
if (promoteFrom && sourceRuleQuery.data) {
|
||||
const { form: prefilled, warnings: w } = prefillFromPromotion(sourceRuleQuery.data, {
|
||||
targetEnvAppSlugs: targetAppSlugs,
|
||||
targetEnvAllowedConnectionIds: targetAllowedConnIds,
|
||||
});
|
||||
setForm(prefilled);
|
||||
setWarnings(w);
|
||||
return;
|
||||
}
|
||||
if (!isEdit && !promoteFrom) {
|
||||
setForm(initialForm());
|
||||
}
|
||||
// Intentionally depend on join()'d slug/id strings so the effect
|
||||
// doesn't retrigger on new array identities when contents are equal.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
form,
|
||||
isEdit,
|
||||
existingQuery.data,
|
||||
promoteFrom,
|
||||
sourceRuleQuery.data,
|
||||
targetAppSlugs.join(','),
|
||||
targetAllowedConnIds.join(','),
|
||||
]);
|
||||
|
||||
const create = useCreateAlertRule();
|
||||
const update = useUpdateAlertRule(id ?? '');
|
||||
|
||||
if (!form) return <PageLoader />;
|
||||
|
||||
const idx = WIZARD_STEPS.indexOf(step);
|
||||
const errors = validateStep(step, form);
|
||||
|
||||
const onNext = () => {
|
||||
if (errors.length > 0) {
|
||||
toast({ title: 'Fix validation errors before continuing', description: errors.join(' \u00b7 '), variant: 'error' });
|
||||
return;
|
||||
}
|
||||
if (idx < WIZARD_STEPS.length - 1) setStep(WIZARD_STEPS[idx + 1]);
|
||||
};
|
||||
const onBack = () => {
|
||||
if (idx > 0) setStep(WIZARD_STEPS[idx - 1]);
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
try {
|
||||
if (isEdit) {
|
||||
await update.mutateAsync(toRequest(form));
|
||||
toast({ title: 'Rule updated', description: form.name, variant: 'success' });
|
||||
} else {
|
||||
await create.mutateAsync(toRequest(form));
|
||||
toast({ title: 'Rule created', description: form.name, variant: 'success' });
|
||||
}
|
||||
navigate('/alerts/rules');
|
||||
} catch (e) {
|
||||
toast({ title: 'Save failed', description: String(e), variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const body =
|
||||
step === 'scope' ? (
|
||||
<ScopeStep form={form} setForm={setForm} />
|
||||
) : step === 'condition' ? (
|
||||
<ConditionStep form={form} setForm={setForm} />
|
||||
) : step === 'trigger' ? (
|
||||
<TriggerStep form={form} setForm={setForm} ruleId={id} />
|
||||
) : step === 'notify' ? (
|
||||
<NotifyStep form={form} setForm={setForm} ruleId={id} />
|
||||
) : (
|
||||
<ReviewStep form={form} setForm={setForm} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={css.wizard}>
|
||||
<div className={css.header}>
|
||||
<SectionHeader>{isEdit ? `Edit rule: ${form.name}` : 'New alert rule'}</SectionHeader>
|
||||
{promoteFrom && (
|
||||
<div className={css.promoteBanner}>
|
||||
Promoting from <code>{promoteFrom}</code> — review and adjust, then save.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{warnings.length > 0 && (
|
||||
<div className={css.promoteBanner}>
|
||||
<strong>Review before saving:</strong>
|
||||
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
|
||||
{warnings.map((w) => (
|
||||
<li key={w.field}>
|
||||
<code>{w.field}</code>: {w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<nav className={css.steps}>
|
||||
{WIZARD_STEPS.map((s, i) => (
|
||||
<button
|
||||
key={s}
|
||||
className={`${css.step} ${step === s ? css.stepActive : ''} ${i < idx ? css.stepDone : ''}`}
|
||||
onClick={() => setStep(s)}
|
||||
>
|
||||
{STEP_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className={css.stepBody}>{body}</div>
|
||||
<div className={css.footer}>
|
||||
<Button variant="secondary" onClick={onBack} disabled={idx === 0}>
|
||||
Back
|
||||
</Button>
|
||||
{idx < WIZARD_STEPS.length - 1 ? (
|
||||
<Button variant="primary" onClick={onNext}>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" onClick={onSave} disabled={create.isPending || update.isPending}>
|
||||
{isEdit ? 'Save changes' : 'Create rule'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx
Normal file
103
ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { FormField, Input, Select } from '@cameleer/design-system';
|
||||
import { useCatalog } from '../../../api/queries/catalog';
|
||||
import { useAgents } from '../../../api/queries/agents';
|
||||
import { useSelectedEnv } from '../../../api/queries/alertMeta';
|
||||
import type { FormState } from './form-state';
|
||||
|
||||
const SEVERITY_OPTIONS = [
|
||||
{ value: 'CRITICAL', label: 'Critical' },
|
||||
{ value: 'WARNING', label: 'Warning' },
|
||||
{ value: 'INFO', label: 'Info' },
|
||||
];
|
||||
|
||||
const SCOPE_OPTIONS = [
|
||||
{ value: 'env', label: 'Environment-wide' },
|
||||
{ value: 'app', label: 'Single app' },
|
||||
{ value: 'route', label: 'Single route' },
|
||||
{ value: 'agent', label: 'Single agent' },
|
||||
];
|
||||
|
||||
type AgentSummary = {
|
||||
instanceId?: string;
|
||||
displayName?: string;
|
||||
applicationId?: string;
|
||||
};
|
||||
|
||||
export function ScopeStep({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
|
||||
const env = useSelectedEnv();
|
||||
const { data: catalog } = useCatalog(env);
|
||||
const { data: agents } = useAgents();
|
||||
|
||||
const apps = (catalog ?? []).map((a) => ({
|
||||
slug: a.slug,
|
||||
name: a.displayName ?? a.slug,
|
||||
routes: a.routes ?? [],
|
||||
}));
|
||||
const selectedApp = apps.find((a) => a.slug === form.appSlug);
|
||||
const routes = selectedApp?.routes ?? [];
|
||||
const appAgents: AgentSummary[] = Array.isArray(agents)
|
||||
? (agents as AgentSummary[]).filter((a) => a.applicationId === form.appSlug)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 12, maxWidth: 600 }}>
|
||||
<FormField label="Name">
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="Order API error rate"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Description">
|
||||
<Input
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Severity">
|
||||
<Select
|
||||
value={form.severity}
|
||||
onChange={(e) => setForm({ ...form, severity: e.target.value as FormState['severity'] })}
|
||||
options={SEVERITY_OPTIONS}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Scope">
|
||||
<Select
|
||||
value={form.scopeKind}
|
||||
onChange={(e) => setForm({ ...form, scopeKind: e.target.value as FormState['scopeKind'] })}
|
||||
options={SCOPE_OPTIONS}
|
||||
/>
|
||||
</FormField>
|
||||
{form.scopeKind !== 'env' && (
|
||||
<FormField label="App">
|
||||
<Select
|
||||
value={form.appSlug}
|
||||
onChange={(e) => setForm({ ...form, appSlug: e.target.value, routeId: '', agentId: '' })}
|
||||
options={[{ value: '', label: '-- select --' }, ...apps.map((a) => ({ value: a.slug, label: a.name }))]}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{form.scopeKind === 'route' && (
|
||||
<FormField label="Route">
|
||||
<Select
|
||||
value={form.routeId}
|
||||
onChange={(e) => setForm({ ...form, routeId: e.target.value })}
|
||||
options={[{ value: '', label: '-- select --' }, ...routes.map((r) => ({ value: r.routeId, label: r.routeId }))]}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{form.scopeKind === 'agent' && (
|
||||
<FormField label="Agent">
|
||||
<Select
|
||||
value={form.agentId}
|
||||
onChange={(e) => setForm({ ...form, agentId: e.target.value })}
|
||||
options={[
|
||||
{ value: '', label: '-- select --' },
|
||||
...appAgents.map((a) => ({ value: a.instanceId ?? '', label: a.displayName ?? a.instanceId ?? '' })),
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx
Normal file
85
ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, FormField, Input, useToast } from '@cameleer/design-system';
|
||||
import { useTestEvaluate } from '../../../api/queries/alertRules';
|
||||
import type { FormState } from './form-state';
|
||||
|
||||
export function TriggerStep({
|
||||
form,
|
||||
setForm,
|
||||
ruleId,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm: (f: FormState) => void;
|
||||
ruleId?: string;
|
||||
}) {
|
||||
const testEvaluate = useTestEvaluate();
|
||||
const { toast } = useToast();
|
||||
const [lastResult, setLastResult] = useState<string | null>(null);
|
||||
|
||||
const onTest = async () => {
|
||||
if (!ruleId) {
|
||||
toast({ title: 'Save rule first to run test evaluate', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await testEvaluate.mutateAsync({ id: ruleId, req: {} });
|
||||
setLastResult(JSON.stringify(result, null, 2));
|
||||
} catch (e) {
|
||||
toast({ title: 'Test-evaluate failed', description: String(e), variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 12, maxWidth: 600 }}>
|
||||
<FormField label="Evaluation interval (seconds, min 5)">
|
||||
<Input
|
||||
type="number"
|
||||
min={5}
|
||||
value={form.evaluationIntervalSeconds}
|
||||
onChange={(e) => setForm({ ...form, evaluationIntervalSeconds: Number(e.target.value) })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="For-duration before firing (seconds, 0 = fire immediately)">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.forDurationSeconds}
|
||||
onChange={(e) => setForm({ ...form, forDurationSeconds: Number(e.target.value) })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Re-notify cadence (minutes, 0 = notify once)">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.reNotifyMinutes}
|
||||
onChange={(e) => setForm({ ...form, reNotifyMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
</FormField>
|
||||
<div>
|
||||
<Button variant="secondary" onClick={onTest} disabled={testEvaluate.isPending}>
|
||||
Test evaluate (uses saved rule)
|
||||
</Button>
|
||||
{!ruleId && (
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--muted)' }}>
|
||||
Save the rule first to enable test-evaluate.
|
||||
</p>
|
||||
)}
|
||||
{lastResult && (
|
||||
<pre
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 8,
|
||||
background: 'var(--code-bg)',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
maxHeight: 240,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{lastResult}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { FormField, Input, Select } from '@cameleer/design-system';
|
||||
import type { FormState } from '../form-state';
|
||||
|
||||
export function AgentStateForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
|
||||
const c = form.condition as Record<string, unknown>;
|
||||
const patch = (p: Record<string, unknown>) =>
|
||||
setForm({ ...form, condition: { ...(form.condition as Record<string, unknown>), ...p } as FormState['condition'] });
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField label="Agent state">
|
||||
<Select
|
||||
value={(c.state as string) ?? 'DEAD'}
|
||||
onChange={(e) => patch({ state: e.target.value })}
|
||||
options={[
|
||||
{ value: 'DEAD', label: 'DEAD' },
|
||||
{ value: 'STALE', label: 'STALE' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="For duration (seconds)">
|
||||
<Input
|
||||
type="number"
|
||||
value={(c.forSeconds as number | undefined) ?? 60}
|
||||
onChange={(e) => patch({ forSeconds: Number(e.target.value) })}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { FormField } from '@cameleer/design-system';
|
||||
import type { FormState } from '../form-state';
|
||||
|
||||
const OPTIONS = ['FAILED', 'DEGRADED'] as const;
|
||||
|
||||
export function DeploymentStateForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
|
||||
const c = form.condition as Record<string, unknown>;
|
||||
const states: string[] = (c.states as string[] | undefined) ?? [];
|
||||
const toggle = (s: string) => {
|
||||
const next = states.includes(s) ? states.filter((x) => x !== s) : [...states, s];
|
||||
setForm({ ...form, condition: { ...(form.condition as Record<string, unknown>), states: next } as FormState['condition'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<FormField label="Fire when deployment is in states">
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
{OPTIONS.map((s) => (
|
||||
<label key={s} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<input type="checkbox" checked={states.includes(s)} onChange={() => toggle(s)} />
|
||||
{s}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { FormField, Input, Select } from '@cameleer/design-system';
|
||||
import type { FormState } from '../form-state';
|
||||
|
||||
const FIRE_MODES = [
|
||||
{ value: 'PER_EXCHANGE', label: 'One alert per matching exchange' },
|
||||
{ value: 'COUNT_IN_WINDOW', label: 'Threshold: N matches in window' },
|
||||
];
|
||||
|
||||
const STATUSES = [
|
||||
{ value: '', label: '(any)' },
|
||||
{ value: 'COMPLETED', label: 'COMPLETED' },
|
||||
{ value: 'FAILED', label: 'FAILED' },
|
||||
{ value: 'RUNNING', label: 'RUNNING' },
|
||||
];
|
||||
|
||||
export function ExchangeMatchForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
|
||||
const c = form.condition as Record<string, unknown>;
|
||||
const filter = (c.filter as Record<string, unknown> | undefined) ?? {};
|
||||
const patch = (p: Record<string, unknown>) =>
|
||||
setForm({ ...form, condition: { ...(form.condition as Record<string, unknown>), ...p } as FormState['condition'] });
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField label="Fire mode">
|
||||
<Select
|
||||
value={(c.fireMode as string) ?? 'PER_EXCHANGE'}
|
||||
onChange={(e) => patch({ fireMode: e.target.value })}
|
||||
options={FIRE_MODES}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Status filter">
|
||||
<Select
|
||||
value={(filter.status as string) ?? ''}
|
||||
onChange={(e) => patch({ filter: { ...filter, status: e.target.value || undefined } })}
|
||||
options={STATUSES}
|
||||
/>
|
||||
</FormField>
|
||||
{c.fireMode === 'PER_EXCHANGE' && (
|
||||
<FormField label="Linger seconds (default 300)">
|
||||
<Input
|
||||
type="number"
|
||||
value={(c.perExchangeLingerSeconds as number | undefined) ?? 300}
|
||||
onChange={(e) => patch({ perExchangeLingerSeconds: Number(e.target.value) })}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{c.fireMode === 'COUNT_IN_WINDOW' && (
|
||||
<>
|
||||
<FormField label="Threshold (matches)">
|
||||
<Input
|
||||
type="number"
|
||||
value={(c.threshold as number | undefined) ?? ''}
|
||||
onChange={(e) => patch({ threshold: Number(e.target.value) })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Window (seconds)">
|
||||
<Input
|
||||
type="number"
|
||||
value={(c.windowSeconds as number | undefined) ?? 900}
|
||||
onChange={(e) => patch({ windowSeconds: Number(e.target.value) })}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { FormField, Input, Select } from '@cameleer/design-system';
|
||||
import type { FormState } from '../form-state';
|
||||
|
||||
export function JvmMetricForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
|
||||
const c = form.condition as Record<string, unknown>;
|
||||
const patch = (p: Record<string, unknown>) =>
|
||||
setForm({ ...form, condition: { ...(form.condition as Record<string, unknown>), ...p } as FormState['condition'] });
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField label="Metric name">
|
||||
<Input
|
||||
value={(c.metric as string | undefined) ?? ''}
|
||||
onChange={(e) => patch({ metric: e.target.value })}
|
||||
placeholder="heap_used_percent"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Aggregation">
|
||||
<Select
|
||||
value={(c.aggregation as string) ?? 'MAX'}
|
||||
onChange={(e) => patch({ aggregation: e.target.value })}
|
||||
options={[
|
||||
{ value: 'MAX', label: 'MAX' },
|
||||
{ value: 'AVG', label: 'AVG' },
|
||||
{ value: 'MIN', label: 'MIN' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Comparator">
|
||||
<Select
|
||||
value={(c.comparator as string) ?? 'GT'}
|
||||
onChange={(e) => patch({ comparator: e.target.value })}
|
||||
options={[
|
||||
{ value: 'GT', label: '>' },
|
||||
{ value: 'GTE', label: '\u2265' },
|
||||
{ value: 'LT', label: '<' },
|
||||
{ value: 'LTE', label: '\u2264' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Threshold">
|
||||
<Input
|
||||
type="number"
|
||||
value={(c.threshold as number | undefined) ?? ''}
|
||||
onChange={(e) => patch({ threshold: Number(e.target.value) })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Window (seconds)">
|
||||
<Input
|
||||
type="number"
|
||||
value={(c.windowSeconds as number | undefined) ?? 300}
|
||||
onChange={(e) => patch({ windowSeconds: Number(e.target.value) })}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FormField, Input, Select } from '@cameleer/design-system';
|
||||
import type { FormState } from '../form-state';
|
||||
|
||||
export function LogPatternForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
|
||||
const c = form.condition as Record<string, unknown>;
|
||||
const patch = (p: Record<string, unknown>) =>
|
||||
setForm({ ...form, condition: { ...(form.condition as Record<string, unknown>), ...p } as FormState['condition'] });
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField label="Level">
|
||||
<Select
|
||||
value={(c.level as string) ?? 'ERROR'}
|
||||
onChange={(e) => patch({ level: e.target.value })}
|
||||
options={[
|
||||
{ value: 'ERROR', label: 'ERROR' },
|
||||
{ value: 'WARN', label: 'WARN' },
|
||||
{ value: 'INFO', label: 'INFO' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Logger (substring, optional)">
|
||||
<Input
|
||||
value={(c.logger as string | undefined) ?? ''}
|
||||
onChange={(e) => patch({ logger: e.target.value || undefined })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Pattern (regex)">
|
||||
<Input
|
||||
value={(c.pattern as string | undefined) ?? ''}
|
||||
onChange={(e) => patch({ pattern: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Threshold (matches)">
|
||||
<Input
|
||||
type="number"
|
||||
value={(c.threshold as number | undefined) ?? ''}
|
||||
onChange={(e) => patch({ threshold: Number(e.target.value) })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Window (seconds)">
|
||||
<Input
|
||||
type="number"
|
||||
value={(c.windowSeconds as number | undefined) ?? 900}
|
||||
onChange={(e) => patch({ windowSeconds: Number(e.target.value) })}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { FormField, Input, Select } from '@cameleer/design-system';
|
||||
import type { FormState } from '../form-state';
|
||||
|
||||
// Mirrors cameleer-server-core RouteMetric enum — keep in sync.
|
||||
const METRICS = [
|
||||
{ value: 'ERROR_RATE', label: 'Error rate' },
|
||||
{ value: 'P99_LATENCY_MS', label: 'P99 latency (ms)' },
|
||||
{ value: 'AVG_DURATION_MS', label: 'Avg duration (ms)' },
|
||||
{ value: 'THROUGHPUT', label: 'Throughput (msg/s)' },
|
||||
{ value: 'ERROR_COUNT', label: 'Error count' },
|
||||
];
|
||||
|
||||
const COMPARATORS = [
|
||||
{ value: 'GT', label: '>' },
|
||||
{ value: 'GTE', label: '\u2265' },
|
||||
{ value: 'LT', label: '<' },
|
||||
{ value: 'LTE', label: '\u2264' },
|
||||
];
|
||||
|
||||
export function RouteMetricForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
|
||||
const c = form.condition as Record<string, unknown>;
|
||||
const patch = (p: Record<string, unknown>) =>
|
||||
setForm({ ...form, condition: { ...(form.condition as Record<string, unknown>), ...p } as FormState['condition'] });
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField label="Metric">
|
||||
<Select
|
||||
value={(c.metric as string) ?? ''}
|
||||
onChange={(e) => patch({ metric: e.target.value })}
|
||||
options={METRICS}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Comparator">
|
||||
<Select
|
||||
value={(c.comparator as string) ?? 'GT'}
|
||||
onChange={(e) => patch({ comparator: e.target.value })}
|
||||
options={COMPARATORS}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Threshold">
|
||||
<Input
|
||||
type="number"
|
||||
value={(c.threshold as number | undefined) ?? ''}
|
||||
onChange={(e) => patch({ threshold: Number(e.target.value) })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Window (seconds)">
|
||||
<Input
|
||||
type="number"
|
||||
value={(c.windowSeconds as number | undefined) ?? 300}
|
||||
onChange={(e) => patch({ windowSeconds: Number(e.target.value) })}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
ui/src/pages/Alerts/RuleEditor/form-state.test.ts
Normal file
50
ui/src/pages/Alerts/RuleEditor/form-state.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { initialForm, toRequest, validateStep } from './form-state';
|
||||
|
||||
describe('initialForm', () => {
|
||||
it('defaults to env-wide ROUTE_METRIC with safe intervals', () => {
|
||||
const f = initialForm();
|
||||
expect(f.scopeKind).toBe('env');
|
||||
expect(f.conditionKind).toBe('ROUTE_METRIC');
|
||||
expect(f.evaluationIntervalSeconds).toBeGreaterThanOrEqual(5);
|
||||
expect(f.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toRequest', () => {
|
||||
it('strips empty scope fields for env-wide rules', () => {
|
||||
const f = initialForm();
|
||||
f.name = 'test';
|
||||
const req = toRequest(f);
|
||||
const scope = (req.condition as unknown as { scope: Record<string, string | undefined> }).scope;
|
||||
expect(scope.appSlug).toBeUndefined();
|
||||
expect(scope.routeId).toBeUndefined();
|
||||
expect(scope.agentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes appSlug for app/route/agent scopes', () => {
|
||||
const f = initialForm();
|
||||
f.scopeKind = 'app';
|
||||
f.appSlug = 'orders';
|
||||
const req = toRequest(f);
|
||||
const scope = (req.condition as unknown as { scope: Record<string, string | undefined> }).scope;
|
||||
expect(scope.appSlug).toBe('orders');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateStep', () => {
|
||||
it('flags blank name on scope step', () => {
|
||||
expect(validateStep('scope', initialForm())).toContain('Name is required.');
|
||||
});
|
||||
it('flags app requirement for app-scope', () => {
|
||||
const f = initialForm();
|
||||
f.name = 'x';
|
||||
f.scopeKind = 'app';
|
||||
expect(validateStep('scope', f).some((e) => /App is required/.test(e))).toBe(true);
|
||||
});
|
||||
it('flags intervals below floor on trigger step', () => {
|
||||
const f = initialForm();
|
||||
f.evaluationIntervalSeconds = 1;
|
||||
expect(validateStep('trigger', f).some((e) => /Evaluation interval/.test(e))).toBe(true);
|
||||
});
|
||||
});
|
||||
161
ui/src/pages/Alerts/RuleEditor/form-state.ts
Normal file
161
ui/src/pages/Alerts/RuleEditor/form-state.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type {
|
||||
AlertRuleRequest,
|
||||
AlertRuleResponse,
|
||||
ConditionKind,
|
||||
AlertCondition,
|
||||
} from '../../../api/queries/alertRules';
|
||||
|
||||
export type WizardStep = 'scope' | 'condition' | 'trigger' | 'notify' | 'review';
|
||||
export const WIZARD_STEPS: WizardStep[] = ['scope', 'condition', 'trigger', 'notify', 'review'];
|
||||
|
||||
export interface FormState {
|
||||
name: string;
|
||||
description: string;
|
||||
severity: 'CRITICAL' | 'WARNING' | 'INFO';
|
||||
enabled: boolean;
|
||||
|
||||
// Scope (radio: env-wide | app | route | agent)
|
||||
scopeKind: 'env' | 'app' | 'route' | 'agent';
|
||||
appSlug: string;
|
||||
routeId: string;
|
||||
agentId: string;
|
||||
|
||||
conditionKind: ConditionKind;
|
||||
condition: Partial<AlertCondition>;
|
||||
|
||||
evaluationIntervalSeconds: number;
|
||||
forDurationSeconds: number;
|
||||
reNotifyMinutes: number;
|
||||
|
||||
notificationTitleTmpl: string;
|
||||
notificationMessageTmpl: string;
|
||||
|
||||
webhooks: Array<{
|
||||
outboundConnectionId: string;
|
||||
bodyOverride: string;
|
||||
headerOverrides: Array<{ key: string; value: string }>;
|
||||
}>;
|
||||
|
||||
targets: Array<{ kind: 'USER' | 'GROUP' | 'ROLE'; targetId: string }>;
|
||||
}
|
||||
|
||||
export function initialForm(existing?: AlertRuleResponse): FormState {
|
||||
if (!existing) {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
severity: 'WARNING',
|
||||
enabled: true,
|
||||
scopeKind: 'env',
|
||||
appSlug: '',
|
||||
routeId: '',
|
||||
agentId: '',
|
||||
conditionKind: 'ROUTE_METRIC',
|
||||
// Pre-populate a valid ROUTE_METRIC default so a rule can be saved without
|
||||
// the user needing to fill in every condition field. Values chosen to be
|
||||
// sane for "error rate" alerts on almost any route.
|
||||
condition: {
|
||||
kind: 'ROUTE_METRIC',
|
||||
scope: {},
|
||||
metric: 'ERROR_RATE',
|
||||
comparator: 'GT',
|
||||
threshold: 0.05,
|
||||
windowSeconds: 300,
|
||||
} as unknown as Partial<AlertCondition>,
|
||||
evaluationIntervalSeconds: 60,
|
||||
forDurationSeconds: 0,
|
||||
reNotifyMinutes: 60,
|
||||
notificationTitleTmpl: '{{rule.name}} is firing',
|
||||
notificationMessageTmpl: 'Alert {{alert.id}} fired at {{alert.firedAt}}',
|
||||
webhooks: [],
|
||||
targets: [],
|
||||
};
|
||||
}
|
||||
const scope = ((existing.condition as { scope?: { appSlug?: string; routeId?: string; agentId?: string } } | undefined)?.scope) ?? {};
|
||||
const scopeKind: FormState['scopeKind'] = scope.agentId
|
||||
? 'agent'
|
||||
: scope.routeId
|
||||
? 'route'
|
||||
: scope.appSlug
|
||||
? 'app'
|
||||
: 'env';
|
||||
return {
|
||||
name: existing.name ?? '',
|
||||
description: existing.description ?? '',
|
||||
severity: (existing.severity ?? 'WARNING') as FormState['severity'],
|
||||
enabled: existing.enabled ?? true,
|
||||
scopeKind,
|
||||
appSlug: scope.appSlug ?? '',
|
||||
routeId: scope.routeId ?? '',
|
||||
agentId: scope.agentId ?? '',
|
||||
conditionKind: (existing.conditionKind ?? 'ROUTE_METRIC') as ConditionKind,
|
||||
condition: (existing.condition ?? { kind: existing.conditionKind }) as Partial<AlertCondition>,
|
||||
evaluationIntervalSeconds: existing.evaluationIntervalSeconds ?? 60,
|
||||
forDurationSeconds: existing.forDurationSeconds ?? 0,
|
||||
reNotifyMinutes: existing.reNotifyMinutes ?? 60,
|
||||
notificationTitleTmpl: existing.notificationTitleTmpl ?? '{{rule.name}} is firing',
|
||||
notificationMessageTmpl: existing.notificationMessageTmpl ?? 'Alert {{alert.id}} fired at {{alert.firedAt}}',
|
||||
webhooks: (existing.webhooks ?? []).map((w) => ({
|
||||
outboundConnectionId: (w.outboundConnectionId ?? '') as string,
|
||||
bodyOverride: w.bodyOverride ?? '',
|
||||
headerOverrides: Object.entries((w.headerOverrides ?? {}) as Record<string, string>)
|
||||
.map(([key, value]) => ({ key, value })),
|
||||
})),
|
||||
targets: (existing.targets ?? []).map((t) => ({
|
||||
kind: (t.kind ?? 'USER') as 'USER' | 'GROUP' | 'ROLE',
|
||||
targetId: t.targetId ?? '',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function toRequest(f: FormState): AlertRuleRequest {
|
||||
const scope: Record<string, string | undefined> = {};
|
||||
if (f.scopeKind === 'app' || f.scopeKind === 'route' || f.scopeKind === 'agent') scope.appSlug = f.appSlug || undefined;
|
||||
if (f.scopeKind === 'route') scope.routeId = f.routeId || undefined;
|
||||
if (f.scopeKind === 'agent') scope.agentId = f.agentId || undefined;
|
||||
|
||||
const condition = { ...(f.condition as Record<string, unknown>), kind: f.conditionKind, scope } as unknown as AlertCondition;
|
||||
|
||||
return {
|
||||
name: f.name,
|
||||
description: f.description || undefined,
|
||||
severity: f.severity,
|
||||
enabled: f.enabled,
|
||||
conditionKind: f.conditionKind,
|
||||
condition,
|
||||
evaluationIntervalSeconds: f.evaluationIntervalSeconds,
|
||||
forDurationSeconds: f.forDurationSeconds,
|
||||
reNotifyMinutes: f.reNotifyMinutes,
|
||||
notificationTitleTmpl: f.notificationTitleTmpl,
|
||||
notificationMessageTmpl: f.notificationMessageTmpl,
|
||||
webhooks: f.webhooks.map((w) => ({
|
||||
outboundConnectionId: w.outboundConnectionId,
|
||||
bodyOverride: w.bodyOverride || undefined,
|
||||
headerOverrides: Object.fromEntries(w.headerOverrides.filter((h) => h.key.trim()).map((h) => [h.key.trim(), h.value])),
|
||||
})),
|
||||
targets: f.targets.map((t) => ({ kind: t.kind, targetId: t.targetId })),
|
||||
} as AlertRuleRequest;
|
||||
}
|
||||
|
||||
export function validateStep(step: WizardStep, f: FormState): string[] {
|
||||
const errs: string[] = [];
|
||||
if (step === 'scope') {
|
||||
if (!f.name.trim()) errs.push('Name is required.');
|
||||
if (f.scopeKind !== 'env' && !f.appSlug.trim()) errs.push('App is required for app/route/agent scope.');
|
||||
if (f.scopeKind === 'route' && !f.routeId.trim()) errs.push('Route id is required for route scope.');
|
||||
if (f.scopeKind === 'agent' && !f.agentId.trim()) errs.push('Agent id is required for agent scope.');
|
||||
}
|
||||
if (step === 'condition') {
|
||||
if (!f.conditionKind) errs.push('Condition kind is required.');
|
||||
}
|
||||
if (step === 'trigger') {
|
||||
if (f.evaluationIntervalSeconds < 5) errs.push('Evaluation interval must be \u2265 5 s.');
|
||||
if (f.forDurationSeconds < 0) errs.push('For-duration must be \u2265 0.');
|
||||
if (f.reNotifyMinutes < 0) errs.push('Re-notify cadence must be \u2265 0.');
|
||||
}
|
||||
if (step === 'notify') {
|
||||
if (!f.notificationTitleTmpl.trim()) errs.push('Notification title template is required.');
|
||||
if (!f.notificationMessageTmpl.trim()) errs.push('Notification message template is required.');
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
74
ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts
Normal file
74
ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { prefillFromPromotion } from './promotion-prefill';
|
||||
import type { AlertRuleResponse } from '../../../api/queries/alertRules';
|
||||
|
||||
function fakeRule(overrides: Partial<AlertRuleResponse> = {}): AlertRuleResponse {
|
||||
return {
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
environmentId: '22222222-2222-2222-2222-222222222222',
|
||||
name: 'High error rate',
|
||||
description: undefined,
|
||||
severity: 'CRITICAL',
|
||||
enabled: true,
|
||||
conditionKind: 'ROUTE_METRIC',
|
||||
condition: {
|
||||
kind: 'RouteMetricCondition',
|
||||
scope: { appSlug: 'orders' },
|
||||
} as unknown as AlertRuleResponse['condition'],
|
||||
evaluationIntervalSeconds: 60,
|
||||
forDurationSeconds: 0,
|
||||
reNotifyMinutes: 60,
|
||||
notificationTitleTmpl: '{{rule.name}}',
|
||||
notificationMessageTmpl: 'msg',
|
||||
webhooks: [],
|
||||
targets: [],
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
createdBy: 'alice',
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
updatedBy: 'alice',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('prefillFromPromotion', () => {
|
||||
it('appends "(copy)" to name', () => {
|
||||
const { form } = prefillFromPromotion(fakeRule());
|
||||
expect(form.name).toBe('High error rate (copy)');
|
||||
});
|
||||
|
||||
it('warns + clears agentId when source rule is agent-scoped', () => {
|
||||
const { form, warnings } = prefillFromPromotion(
|
||||
fakeRule({
|
||||
conditionKind: 'AGENT_STATE',
|
||||
condition: {
|
||||
kind: 'AgentStateCondition',
|
||||
scope: { appSlug: 'orders', agentId: 'orders-0' },
|
||||
state: 'DEAD',
|
||||
forSeconds: 60,
|
||||
} as unknown as AlertRuleResponse['condition'],
|
||||
}),
|
||||
);
|
||||
expect(form.agentId).toBe('');
|
||||
expect(warnings.find((w) => w.field === 'scope.agentId')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('warns if app does not exist in target env', () => {
|
||||
const { warnings } = prefillFromPromotion(fakeRule(), { targetEnvAppSlugs: ['other-app'] });
|
||||
expect(warnings.find((w) => w.field === 'scope.appSlug')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('warns if webhook connection is not allowed in target env', () => {
|
||||
const rule = fakeRule({
|
||||
webhooks: [
|
||||
{
|
||||
id: 'w1',
|
||||
outboundConnectionId: 'conn-prod',
|
||||
bodyOverride: undefined,
|
||||
headerOverrides: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
const { warnings } = prefillFromPromotion(rule, { targetEnvAllowedConnectionIds: ['conn-dev'] });
|
||||
expect(warnings.find((w) => w.field.startsWith('webhooks['))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
59
ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts
Normal file
59
ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { initialForm, type FormState } from './form-state';
|
||||
import type { AlertRuleResponse } from '../../../api/queries/alertRules';
|
||||
|
||||
export interface PrefillWarning {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PrefillOptions {
|
||||
targetEnvAppSlugs?: string[];
|
||||
/** IDs of outbound connections allowed in the target env. */
|
||||
targetEnvAllowedConnectionIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side prefill when promoting a rule from another env. Emits warnings for
|
||||
* fields that cross env boundaries (agent IDs, apps missing in target env,
|
||||
* outbound connections not allowed in target env).
|
||||
*/
|
||||
export function prefillFromPromotion(
|
||||
source: AlertRuleResponse,
|
||||
opts: PrefillOptions = {},
|
||||
): { form: FormState; warnings: PrefillWarning[] } {
|
||||
const form = initialForm(source);
|
||||
form.name = `${source.name ?? 'rule'} (copy)`;
|
||||
const warnings: PrefillWarning[] = [];
|
||||
|
||||
// Agent IDs are per-env, can't transfer.
|
||||
if (form.agentId) {
|
||||
warnings.push({
|
||||
field: 'scope.agentId',
|
||||
message: `Agent \`${form.agentId}\` is specific to the source env \u2014 cleared for target env.`,
|
||||
});
|
||||
form.agentId = '';
|
||||
if (form.scopeKind === 'agent') form.scopeKind = 'app';
|
||||
}
|
||||
|
||||
// App slug: warn if not present in target env.
|
||||
if (form.appSlug && opts.targetEnvAppSlugs && !opts.targetEnvAppSlugs.includes(form.appSlug)) {
|
||||
warnings.push({
|
||||
field: 'scope.appSlug',
|
||||
message: `App \`${form.appSlug}\` does not exist in the target env. Update before saving.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Webhook connections: warn if connection is not allowed in target env.
|
||||
if (opts.targetEnvAllowedConnectionIds) {
|
||||
for (const w of form.webhooks) {
|
||||
if (!opts.targetEnvAllowedConnectionIds.includes(w.outboundConnectionId)) {
|
||||
warnings.push({
|
||||
field: `webhooks[${w.outboundConnectionId}]`,
|
||||
message: `Outbound connection is not allowed in the target env \u2014 remove or pick another before saving.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { form, warnings };
|
||||
}
|
||||
57
ui/src/pages/Alerts/RuleEditor/wizard.module.css
Normal file
57
ui/src/pages/Alerts/RuleEditor/wizard.module.css
Normal file
@@ -0,0 +1,57 @@
|
||||
.wizard {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.promoteBanner {
|
||||
padding: 8px 12px;
|
||||
background: var(--amber-bg, rgba(255, 180, 0, 0.12));
|
||||
border: 1px solid var(--amber);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.step {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stepActive {
|
||||
color: var(--fg);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.stepDone {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.stepBody {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
115
ui/src/pages/Alerts/RulesListPage.tsx
Normal file
115
ui/src/pages/Alerts/RulesListPage.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { Button, SectionHeader, Toggle, useToast, Badge } from '@cameleer/design-system';
|
||||
import { PageLoader } from '../../components/PageLoader';
|
||||
import { SeverityBadge } from '../../components/SeverityBadge';
|
||||
import {
|
||||
useAlertRules,
|
||||
useDeleteAlertRule,
|
||||
useSetAlertRuleEnabled,
|
||||
type AlertRuleResponse,
|
||||
} from '../../api/queries/alertRules';
|
||||
import { useEnvironments } from '../../api/queries/admin/environments';
|
||||
import { useSelectedEnv } from '../../api/queries/alertMeta';
|
||||
import sectionStyles from '../../styles/section-card.module.css';
|
||||
|
||||
export default function RulesListPage() {
|
||||
const navigate = useNavigate();
|
||||
const env = useSelectedEnv();
|
||||
const { data: rules, isLoading, error } = useAlertRules();
|
||||
const { data: envs } = useEnvironments();
|
||||
const setEnabled = useSetAlertRuleEnabled();
|
||||
const deleteRule = useDeleteAlertRule();
|
||||
const { toast } = useToast();
|
||||
|
||||
if (isLoading) return <PageLoader />;
|
||||
if (error) return <div>Failed to load rules: {String(error)}</div>;
|
||||
|
||||
const rows = rules ?? [];
|
||||
const otherEnvs = (envs ?? []).filter((e) => e.slug !== env);
|
||||
|
||||
const onToggle = async (r: AlertRuleResponse) => {
|
||||
try {
|
||||
await setEnabled.mutateAsync({ id: r.id, enabled: !r.enabled });
|
||||
toast({ title: r.enabled ? 'Disabled' : 'Enabled', description: r.name, variant: 'success' });
|
||||
} catch (e) {
|
||||
toast({ title: 'Toggle failed', description: String(e), variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (r: AlertRuleResponse) => {
|
||||
if (!confirm(`Delete rule "${r.name}"? Fired alerts are preserved via rule_snapshot.`)) return;
|
||||
try {
|
||||
await deleteRule.mutateAsync(r.id);
|
||||
toast({ title: 'Deleted', description: r.name, variant: 'success' });
|
||||
} catch (e) {
|
||||
toast({ title: 'Delete failed', description: String(e), variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const onPromote = (r: AlertRuleResponse, targetEnvSlug: string) => {
|
||||
navigate(`/alerts/rules/new?promoteFrom=${env}&ruleId=${r.id}&targetEnv=${targetEnvSlug}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<SectionHeader>Alert rules</SectionHeader>
|
||||
<Link to="/alerts/rules/new">
|
||||
<Button variant="primary">New rule</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={sectionStyles.section}>
|
||||
{rows.length === 0 ? (
|
||||
<p>No rules yet. Create one to start evaluating alerts for this environment.</p>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left' }}>Name</th>
|
||||
<th style={{ textAlign: 'left' }}>Kind</th>
|
||||
<th style={{ textAlign: 'left' }}>Severity</th>
|
||||
<th style={{ textAlign: 'left' }}>Enabled</th>
|
||||
<th style={{ textAlign: 'left' }}>Targets</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td><Link to={`/alerts/rules/${r.id}`}>{r.name}</Link></td>
|
||||
<td><Badge label={r.conditionKind} color="auto" variant="outlined" /></td>
|
||||
<td><SeverityBadge severity={r.severity} /></td>
|
||||
<td>
|
||||
<Toggle
|
||||
checked={r.enabled}
|
||||
onChange={() => onToggle(r)}
|
||||
disabled={setEnabled.isPending}
|
||||
/>
|
||||
</td>
|
||||
<td>{r.targets.length}</td>
|
||||
<td style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{otherEnvs.length > 0 && (
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => { if (e.target.value) onPromote(r, e.target.value); }}
|
||||
aria-label={`Promote ${r.name} to another env`}
|
||||
>
|
||||
<option value="">Promote to ▾</option>
|
||||
{otherEnvs.map((e) => (
|
||||
<option key={e.slug} value={e.slug}>{e.slug}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => onDelete(r)} disabled={deleteRule.isPending}>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
ui/src/pages/Alerts/SilencesPage.tsx
Normal file
130
ui/src/pages/Alerts/SilencesPage.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, FormField, Input, SectionHeader, useToast } from '@cameleer/design-system';
|
||||
import { PageLoader } from '../../components/PageLoader';
|
||||
import {
|
||||
useAlertSilences,
|
||||
useCreateSilence,
|
||||
useDeleteSilence,
|
||||
type AlertSilenceResponse,
|
||||
} from '../../api/queries/alertSilences';
|
||||
import sectionStyles from '../../styles/section-card.module.css';
|
||||
|
||||
export default function SilencesPage() {
|
||||
const { data, isLoading, error } = useAlertSilences();
|
||||
const create = useCreateSilence();
|
||||
const remove = useDeleteSilence();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [reason, setReason] = useState('');
|
||||
const [matcherRuleId, setMatcherRuleId] = useState('');
|
||||
const [matcherAppSlug, setMatcherAppSlug] = useState('');
|
||||
const [hours, setHours] = useState(1);
|
||||
|
||||
if (isLoading) return <PageLoader />;
|
||||
if (error) return <div>Failed to load silences: {String(error)}</div>;
|
||||
|
||||
const onCreate = async () => {
|
||||
const now = new Date();
|
||||
const endsAt = new Date(now.getTime() + hours * 3600_000);
|
||||
const matcher: Record<string, string> = {};
|
||||
if (matcherRuleId) matcher.ruleId = matcherRuleId;
|
||||
if (matcherAppSlug) matcher.appSlug = matcherAppSlug;
|
||||
if (Object.keys(matcher).length === 0) {
|
||||
toast({ title: 'Silence needs at least one matcher field', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await create.mutateAsync({
|
||||
matcher,
|
||||
reason: reason || undefined,
|
||||
startsAt: now.toISOString(),
|
||||
endsAt: endsAt.toISOString(),
|
||||
});
|
||||
setReason('');
|
||||
setMatcherRuleId('');
|
||||
setMatcherAppSlug('');
|
||||
setHours(1);
|
||||
toast({ title: 'Silence created', variant: 'success' });
|
||||
} catch (e) {
|
||||
toast({ title: 'Create failed', description: String(e), variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = async (s: AlertSilenceResponse) => {
|
||||
if (!confirm(`End silence early?`)) return;
|
||||
try {
|
||||
await remove.mutateAsync(s.id!);
|
||||
toast({ title: 'Silence removed', variant: 'success' });
|
||||
} catch (e) {
|
||||
toast({ title: 'Remove failed', description: String(e), variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<SectionHeader>Alert silences</SectionHeader>
|
||||
<div className={sectionStyles.section} style={{ marginTop: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr) auto', gap: 8, alignItems: 'end' }}>
|
||||
<FormField label="Rule ID (optional)">
|
||||
<Input value={matcherRuleId} onChange={(e) => setMatcherRuleId(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="App slug (optional)">
|
||||
<Input value={matcherAppSlug} onChange={(e) => setMatcherAppSlug(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Duration (hours)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={hours}
|
||||
onChange={(e) => setHours(Number(e.target.value))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Reason">
|
||||
<Input
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Maintenance window"
|
||||
/>
|
||||
</FormField>
|
||||
<Button variant="primary" size="sm" onClick={onCreate} disabled={create.isPending}>
|
||||
Create silence
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={sectionStyles.section} style={{ marginTop: 16 }}>
|
||||
{rows.length === 0 ? (
|
||||
<p>No active or scheduled silences.</p>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left' }}>Matcher</th>
|
||||
<th style={{ textAlign: 'left' }}>Reason</th>
|
||||
<th style={{ textAlign: 'left' }}>Starts</th>
|
||||
<th style={{ textAlign: 'left' }}>Ends</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td><code>{JSON.stringify(s.matcher)}</code></td>
|
||||
<td>{s.reason ?? '—'}</td>
|
||||
<td>{s.startsAt}</td>
|
||||
<td>{s.endsAt}</td>
|
||||
<td>
|
||||
<Button variant="secondary" size="sm" onClick={() => onRemove(s)}>
|
||||
End
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
ui/src/pages/Alerts/alerts-page.module.css
Normal file
18
ui/src/pages/Alerts/alerts-page.module.css
Normal file
@@ -0,0 +1,18 @@
|
||||
.page { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr auto;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.rowUnread { border-left: 3px solid var(--accent); }
|
||||
.body { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.meta { display: flex; gap: 8px; font-size: 12px; color: var(--muted); }
|
||||
.time { font-variant-numeric: tabular-nums; }
|
||||
.message { margin: 0; font-size: 13px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.actions { display: flex; align-items: center; }
|
||||
.empty { padding: 48px; text-align: center; color: var(--muted); }
|
||||
@@ -23,6 +23,12 @@ const OutboundConnectionEditor = lazy(() => import('./pages/Admin/OutboundConnec
|
||||
const SensitiveKeysPage = lazy(() => import('./pages/Admin/SensitiveKeysPage'));
|
||||
const AppsTab = lazy(() => import('./pages/AppsTab/AppsTab'));
|
||||
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
|
||||
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'));
|
||||
|
||||
function SuspenseWrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -75,6 +81,16 @@ export const router = createBrowserRouter([
|
||||
{ path: 'apps/new', element: <SuspenseWrapper><AppsTab /></SuspenseWrapper> },
|
||||
{ path: 'apps/:appId', element: <SuspenseWrapper><AppsTab /></SuspenseWrapper> },
|
||||
|
||||
// Alerts
|
||||
{ path: 'alerts', element: <Navigate to="/alerts/inbox" replace /> },
|
||||
{ path: 'alerts/inbox', element: <SuspenseWrapper><InboxPage /></SuspenseWrapper> },
|
||||
{ path: 'alerts/all', element: <SuspenseWrapper><AllAlertsPage /></SuspenseWrapper> },
|
||||
{ path: 'alerts/history', element: <SuspenseWrapper><HistoryPage /></SuspenseWrapper> },
|
||||
{ path: 'alerts/rules', element: <SuspenseWrapper><RulesListPage /></SuspenseWrapper> },
|
||||
{ path: 'alerts/rules/new', element: <SuspenseWrapper><RuleEditorWizard /></SuspenseWrapper> },
|
||||
{ path: 'alerts/rules/:id', element: <SuspenseWrapper><RuleEditorWizard /></SuspenseWrapper> },
|
||||
{ path: 'alerts/silences', element: <SuspenseWrapper><SilencesPage /></SuspenseWrapper> },
|
||||
|
||||
// Admin (ADMIN role required)
|
||||
{
|
||||
element: <RequireAdmin />,
|
||||
|
||||
7
ui/src/test/canary.test.ts
Normal file
7
ui/src/test/canary.test.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('vitest canary', () => {
|
||||
it('arithmetic still works', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
});
|
||||
107
ui/src/test/e2e/alerting.spec.ts
Normal file
107
ui/src/test/e2e/alerting.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
/**
|
||||
* Plan 03 alerting smoke suite.
|
||||
*
|
||||
* Covers the CRUD + navigation paths that don't require event injection:
|
||||
* - sidebar → inbox
|
||||
* - create + delete a rule via the 5-step wizard
|
||||
* - CMD-K opens, closes cleanly
|
||||
* - silence create + end-early
|
||||
*
|
||||
* End-to-end fire→ack→clear is covered server-side by `AlertingFullLifecycleIT`
|
||||
* (Plan 02). Exercising it from the UI would require injecting executions
|
||||
* into ClickHouse, which is out of scope for this smoke.
|
||||
*
|
||||
* Note: the design-system `SectionHeader` renders a generic element (not role=heading),
|
||||
* so page headings are asserted via `getByText`.
|
||||
*/
|
||||
|
||||
test.describe('alerting UI smoke', () => {
|
||||
test('sidebar Alerts section navigates to inbox', async ({ page }) => {
|
||||
// Click the Alerts sidebar section header. On navigation the accordion
|
||||
// will already be expanded; the "Alerts" label is on the toggle button.
|
||||
await page.getByRole('button', { name: /^(collapse|expand) alerts$/i }).first().click();
|
||||
await expect(page).toHaveURL(/\/alerts\/inbox/, { timeout: 10_000 });
|
||||
// Inbox page renders "Inbox" text + "Mark all read" button.
|
||||
await expect(page.getByText(/^Inbox$/)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /mark all read/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('create + delete a rule via the wizard', async ({ page }) => {
|
||||
// Unique name per run so leftover rules from crashed prior runs don't
|
||||
// trip the strict-mode "multiple matches" check.
|
||||
const ruleName = `e2e smoke rule ${Date.now()}`;
|
||||
|
||||
await page.goto('/alerts/rules');
|
||||
await expect(page.getByText(/^Alert rules$/)).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: /new rule/i }).click();
|
||||
await expect(page).toHaveURL(/\/alerts\/rules\/new/);
|
||||
|
||||
// Step 1 — Scope. DS FormField renders the label as a generic element
|
||||
// (not `htmlFor` wired), so the textbox's accessible name is its placeholder.
|
||||
await page.getByPlaceholder('Order API error rate').fill(ruleName);
|
||||
await page.getByRole('button', { name: /^next$/i }).click();
|
||||
|
||||
// Step 2 — Condition (leave at ROUTE_METRIC default)
|
||||
await page.getByRole('button', { name: /^next$/i }).click();
|
||||
|
||||
// Step 3 — Trigger (defaults)
|
||||
await page.getByRole('button', { name: /^next$/i }).click();
|
||||
|
||||
// Step 4 — Notify: default title/message templates are pre-populated;
|
||||
// targets/webhooks empty is OK for smoke.
|
||||
await page.getByRole('button', { name: /^next$/i }).click();
|
||||
|
||||
// Step 5 — Review + save
|
||||
await page.getByRole('button', { name: /^create rule$/i }).click();
|
||||
|
||||
// Land on rules list, rule appears in the table.
|
||||
await expect(page).toHaveURL(/\/alerts\/rules$/, { timeout: 10_000 });
|
||||
const main = page.locator('main');
|
||||
await expect(main.getByRole('link', { name: ruleName })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Cleanup: delete.
|
||||
page.once('dialog', (d) => d.accept());
|
||||
await page
|
||||
.getByRole('row', { name: new RegExp(ruleName) })
|
||||
.getByRole('button', { name: /^delete$/i })
|
||||
.click();
|
||||
await expect(main.getByRole('link', { name: ruleName })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('CMD-K palette opens + closes', async ({ page }) => {
|
||||
await page.goto('/alerts/inbox');
|
||||
// The DS CommandPalette is toggled by the SearchTrigger button in the top bar
|
||||
// (accessible name "Open search"). Ctrl/Cmd+K is wired inside the DS but
|
||||
// clicking the button is the deterministic path.
|
||||
await page.getByRole('button', { name: /open search/i }).click();
|
||||
const dialog = page.getByRole('dialog').first();
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toBeHidden();
|
||||
});
|
||||
|
||||
test('silence create + end-early', async ({ page }) => {
|
||||
await page.goto('/alerts/silences');
|
||||
await expect(page.getByText(/^Alert silences$/)).toBeVisible();
|
||||
|
||||
const unique = `smoke-app-${Date.now()}`;
|
||||
// DS FormField labels aren't `htmlFor`-wired, so target via parent-of-label → textbox.
|
||||
const form = page.locator('main');
|
||||
await form.getByText(/^App slug/).locator('..').getByRole('textbox').fill(unique);
|
||||
await form.getByRole('spinbutton').fill('1');
|
||||
await form.getByPlaceholder('Maintenance window').fill('e2e smoke');
|
||||
await page.getByRole('button', { name: /create silence/i }).click();
|
||||
|
||||
await expect(page.getByText(unique).first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
page.once('dialog', (d) => d.accept());
|
||||
await page
|
||||
.getByRole('row', { name: new RegExp(unique) })
|
||||
.getByRole('button', { name: /^end$/i })
|
||||
.click();
|
||||
await expect(page.getByText(unique)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
39
ui/src/test/e2e/fixtures.ts
Normal file
39
ui/src/test/e2e/fixtures.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E fixtures for the alerting UI smoke suite.
|
||||
*
|
||||
* Auth happens once per test via an auto-applied fixture. Override creds via:
|
||||
* E2E_ADMIN_USER=... E2E_ADMIN_PASS=... npm run test:e2e
|
||||
*
|
||||
* The fixture logs in to the local form (not OIDC). The backend in the
|
||||
* Docker-compose stack defaults to `admin` / `admin` for the local login.
|
||||
*/
|
||||
export const ADMIN_USER = process.env.E2E_ADMIN_USER ?? 'admin';
|
||||
export const ADMIN_PASS = process.env.E2E_ADMIN_PASS ?? 'admin';
|
||||
|
||||
type Fixtures = {
|
||||
loggedIn: void;
|
||||
};
|
||||
|
||||
export const test = base.extend<Fixtures>({
|
||||
loggedIn: [
|
||||
async ({ page }, use) => {
|
||||
// `?local` keeps the login page's auto-OIDC-redirect from firing so the
|
||||
// form-based login works even when an OIDC config happens to be present.
|
||||
await page.goto('/login?local');
|
||||
await page.getByLabel(/username/i).fill(ADMIN_USER);
|
||||
await page.getByLabel(/password/i).fill(ADMIN_PASS);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
// Default landing after login is /exchanges (via Navigate redirect).
|
||||
await expect(page).toHaveURL(/\/(exchanges|alerts|dashboard)/, { timeout: 15_000 });
|
||||
// Env selection is required for every alerts query (useSelectedEnv gate).
|
||||
// Pick the default env so hooks enable.
|
||||
await page.getByRole('combobox').selectOption({ label: 'default' });
|
||||
await use();
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
|
||||
export { expect };
|
||||
7
ui/src/test/setup.ts
Normal file
7
ui/src/test/setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
14
ui/vitest.config.ts
Normal file
14
ui/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user