Compare commits
50 Commits
037a27d405
...
181a479037
| Author | SHA1 | Date | |
|---|---|---|---|
| 181a479037 | |||
|
|
849265a1c6 | ||
|
|
8a6744d3e9 | ||
|
|
88804aca2c | ||
|
|
0cd0a27452 | ||
|
|
9f28c69709 | ||
|
|
b20f08b3d0 | ||
|
|
35fea645b6 | ||
|
|
2bc214e324 | ||
|
|
837fcbf926 | ||
|
|
e3b656f159 | ||
|
|
be703eb71d | ||
|
|
207ae246af | ||
|
|
69fe80353c | ||
|
|
99b739d946 | ||
|
|
c70fa130ab | ||
|
|
efd8396045 | ||
|
|
dd2a5536ab | ||
|
|
e1321a4002 | ||
|
|
da2819332c | ||
|
|
55b2a00458 | ||
|
|
6e8d890442 | ||
|
|
5b1b3f215a | ||
|
|
82e82350f9 | ||
|
|
e95c21d0cb | ||
|
|
70bf59daca | ||
|
|
c0b8c9a1ad | ||
|
|
414f7204bf | ||
|
|
23d02ba6a0 | ||
|
|
e8de8d88ad | ||
|
|
f037d8c922 | ||
|
|
468132d1dd | ||
|
|
c443fc606a | ||
|
|
05f420d162 | ||
|
|
10e132cd50 | ||
|
|
35f17a7eeb | ||
|
|
e861e0199c | ||
|
|
1b6e6ce40c | ||
|
|
0037309e4f | ||
|
|
3e81572477 | ||
|
|
23f3c3990c | ||
|
|
436a0e4d4c | ||
|
|
a74785f64d | ||
|
|
588e0b723a | ||
|
|
c87c77c1cf | ||
|
|
b16ea8b185 | ||
|
|
4a63149338 | ||
|
|
a2b2ccbab7 | ||
|
|
52a08a8769 | ||
|
|
3d0a4d289b |
@@ -65,8 +65,8 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
|
||||
- `AgentEventsController` — GET `/api/v1/environments/{envSlug}/agents/events` (lifecycle events; cursor-paginated, returns `{ data, nextCursor, hasMore }`; order `(timestamp DESC, insert_id DESC)`; cursor is base64url of `"{timestampIso}|{insert_id_uuid}"` — `insert_id` is a stable UUID column used as a same-millisecond tiebreak).
|
||||
- `AgentMetricsController` — GET `/api/v1/environments/{envSlug}/agents/{agentId}/metrics` (JVM/Camel metrics). Rejects cross-env agents (404) as defence-in-depth.
|
||||
- `DiagramRenderController` — GET `/api/v1/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram` (env-scoped lookup). Also GET `/api/v1/diagrams/{contentHash}/render` (flat — content hashes are globally unique).
|
||||
- `AlertRuleController` — `/api/v1/environments/{envSlug}/alerts/rules`. GET list / POST create / GET `{id}` / PUT `{id}` / DELETE `{id}` / POST `{id}/enable` / POST `{id}/disable` / POST `{id}/render-preview` / POST `{id}/test-evaluate`. OPERATOR+ for mutations, VIEWER+ for reads. CRITICAL: attribute keys in `ExchangeMatchCondition.filter.attributes` are validated at rule-save time against `^[a-zA-Z0-9._-]+$` — they are later inlined into ClickHouse SQL. Webhook validation: verifies `outboundConnectionId` exists and `isAllowedInEnvironment`. Null notification templates default to `""` (NOT NULL constraint). Audit: `ALERT_RULE_CHANGE`.
|
||||
- `AlertController` — `/api/v1/environments/{envSlug}/alerts`. GET list (inbox filtered by userId/groupIds/roleNames via `InAppInboxQuery`) / GET `/unread-count` / GET `{id}` / POST `{id}/ack` / POST `{id}/read` / POST `/bulk-read`. VIEWER+ for all. Inbox SQL: `? = ANY(target_user_ids) OR target_group_ids && ? OR target_role_names && ?` — requires at least one matching target (no broadcast concept).
|
||||
- `AlertRuleController` — `/api/v1/environments/{envSlug}/alerts/rules`. GET list / POST create / GET `{id}` / PUT `{id}` / DELETE `{id}` / POST `{id}/enable` / POST `{id}/disable` / POST `{id}/render-preview` / POST `{id}/test-evaluate`. OPERATOR+ for mutations, VIEWER+ for reads. CRITICAL: attribute keys in `ExchangeMatchCondition.filter.attributes` are validated at rule-save time against `^[a-zA-Z0-9._-]+$` — they are later inlined into ClickHouse SQL. `AgentLifecycleCondition` is allowlist-only — the `AgentLifecycleEventType` enum (REGISTERED / RE_REGISTERED / DEREGISTERED / WENT_STALE / WENT_DEAD / RECOVERED) plus the record compact ctor (non-empty `eventTypes`, `withinSeconds ≥ 1`) do the validation; custom agent-emitted event types are tracked in backlog issue #145. Webhook validation: verifies `outboundConnectionId` exists and `isAllowedInEnvironment`. Null notification templates default to `""` (NOT NULL constraint). Audit: `ALERT_RULE_CHANGE`.
|
||||
- `AlertController` — `/api/v1/environments/{envSlug}/alerts`. GET list (inbox filtered by userId/groupIds/roleNames via `InAppInboxQuery`; optional multi-value `state`, `severity`, tri-state `acked`, tri-state `read` query params; soft-deleted rows always excluded) / GET `/unread-count` / GET `{id}` / POST `{id}/ack` / POST `{id}/read` / POST `/bulk-read` / POST `/bulk-ack` (VIEWER+) / DELETE `{id}` (OPERATOR+, soft-delete) / POST `/bulk-delete` (OPERATOR+) / POST `{id}/restore` (OPERATOR+, clears `deleted_at`). `requireLiveInstance` helper returns 404 on soft-deleted rows; `restore` explicitly fetches regardless of `deleted_at`. `BulkIdsRequest` is the shared body for bulk-read/ack/delete (`{ instanceIds }`). `AlertDto` includes `readAt`; `deletedAt` is intentionally NOT on the wire. Inbox SQL: `? = ANY(target_user_ids) OR target_group_ids && ? OR target_role_names && ?` — requires at least one matching target (no broadcast concept).
|
||||
- `AlertSilenceController` — `/api/v1/environments/{envSlug}/alerts/silences`. GET list / POST create / DELETE `{id}`. 422 if `endsAt <= startsAt`. OPERATOR+ for mutations, VIEWER+ for list. Audit: `ALERT_SILENCE_CHANGE`.
|
||||
- `AlertNotificationController` — Dual-path (no class-level prefix). GET `/api/v1/environments/{envSlug}/alerts/{alertId}/notifications` (VIEWER+); POST `/api/v1/alerts/notifications/{id}/retry` (OPERATOR+, flat — notification IDs globally unique). Retry resets attempts to 0 and sets `nextAttemptAt = now`.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ paths:
|
||||
- `CommandType` — enum for command types (config-update, deep-trace, replay, route-control, etc.)
|
||||
- `CommandStatus` — enum for command acknowledgement states
|
||||
- `CommandReply` — record: command execution result from agent
|
||||
- `AgentEventRecord`, `AgentEventRepository` — event persistence. `AgentEventRepository.queryPage(...)` is cursor-paginated (`AgentEventPage{data, nextCursor, hasMore}`); the legacy non-paginated `query(...)` path is gone.
|
||||
- `AgentEventRecord`, `AgentEventRepository` — event persistence. `AgentEventRepository.queryPage(...)` is cursor-paginated (`AgentEventPage{data, nextCursor, hasMore}`); the legacy non-paginated `query(...)` path is gone. `AgentEventRepository.findInWindow(env, appSlug, agentId, eventTypes, from, to, limit)` returns matching events ordered by `(timestamp ASC, insert_id ASC)` — consumed by `AgentLifecycleEvaluator`.
|
||||
- `AgentEventPage` — record: `(List<AgentEventRecord> data, String nextCursor, boolean hasMore)` returned by `AgentEventRepository.queryPage`
|
||||
- `AgentEventListener` — callback interface for agent events
|
||||
- `RouteStateRegistry` — tracks per-agent route states
|
||||
|
||||
@@ -36,15 +36,14 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
|
||||
|
||||
## 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`.
|
||||
- **Sidebar section** (`buildAlertsTreeNodes` in `ui/src/components/sidebar-utils.ts`) — Inbox, Rules, Silences.
|
||||
- **Routes** in `ui/src/router.tsx`: `/alerts` (redirect to inbox), `/alerts/inbox`, `/alerts/rules`, `/alerts/rules/new`, `/alerts/rules/:id`, `/alerts/silences`. No redirects for the retired `/alerts/all` and `/alerts/history` — stale URLs 404 per the clean-break policy.
|
||||
- **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.
|
||||
- `InboxPage.tsx` — single filterable inbox. Filters: severity (multi), state (PENDING/FIRING/RESOLVED, default FIRING), Hide acked toggle (default on), Hide read toggle (default on). Row actions: Acknowledge, Mark read, Silence rule… (duration quick menu), Delete (OPERATOR+, soft-delete with undo toast wired to `useRestoreAlert`). Bulk toolbar (selection-driven): Acknowledge N · Mark N read · Silence rules · Delete N (ConfirmDialog; OPERATOR+).
|
||||
- `SilenceRuleMenu.tsx` — DS `Dropdown`-based duration picker (1h / 8h / 24h / Custom…). Used by the row-level and bulk silence actions. "Custom…" navigates to `/alerts/silences?ruleId=<id>`.
|
||||
- `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.
|
||||
- `RuleEditor/RuleEditorWizard.tsx` — 5-step wizard (Scope / Condition / Trigger / Notify / Review). `form-state.ts` is the single source of truth (`initialForm` / `toRequest` / `validateStep`). Seven condition-form subcomponents under `RuleEditor/condition-forms/` — including `AgentLifecycleForm.tsx` (multi-select event-type chips for the six-entry `AgentLifecycleEventType` allowlist + lookback-window input).
|
||||
- `SilencesPage.tsx` — matcher-based create + end-early. Reads `?ruleId=` search param to prefill the Rule ID field (driven by InboxPage's "Silence rule… → Custom…" flow).
|
||||
- `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`).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **cameleer-server** (8527 symbols, 22174 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **cameleer-server** (8780 symbols, 22753 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -71,6 +71,9 @@ PostgreSQL (Flyway): `cameleer-server-app/src/main/resources/db/migration/`
|
||||
- V12 — Alerting tables (alert_rules, alert_rule_targets, alert_instances, alert_notifications, alert_reads, alert_silences)
|
||||
- V13 — alert_instances open-rule unique index (alert_instances_open_rule_uq partial index on rule_id WHERE state IN PENDING/FIRING/ACKNOWLEDGED)
|
||||
- V14 — Repair EXCHANGE_MATCH alert_rules persisted with fireMode=null (sets fireMode=PER_EXCHANGE + perExchangeLingerSeconds=300); paired with stricter `ExchangeMatchCondition` ctor that now rejects null fireMode.
|
||||
- V15 — Discriminate open-instance uniqueness by `context->'exchange'->>'id'` so EXCHANGE_MATCH/PER_EXCHANGE emits one alert_instance per matching exchange; scalar kinds resolve to `''` and keep one-open-per-rule.
|
||||
- V16 — Generalise the V15 discriminator to prefer `context->>'_subjectFingerprint'` (falls back to the V15 `exchange.id` expression for legacy rows). Enables AGENT_LIFECYCLE to emit one alert_instance per `(agent, eventType, timestamp)` via a canonical fingerprint in the evaluator firing's context.
|
||||
- V17 — Alerts inbox redesign: drop `ACKNOWLEDGED` from `alert_state_enum` (ack is now orthogonal via `acked_at`), add `read_at` + `deleted_at` timestamp columns (global, no per-user tracking), drop `alert_reads` table entirely, rework the V13/V15/V16 open-rule unique index predicate to `state IN ('PENDING','FIRING') AND deleted_at IS NULL` so ack doesn't close the slot and soft-delete frees it.
|
||||
|
||||
ClickHouse: `cameleer-server-app/src/main/resources/clickhouse/init.sql` (run idempotently on startup)
|
||||
|
||||
@@ -98,7 +101,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **cameleer-server** (8527 symbols, 22174 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **cameleer-server** (8780 symbols, 22753 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
93
HOWTO.md
93
HOWTO.md
@@ -19,38 +19,99 @@ mvn clean compile # compile only
|
||||
mvn clean verify # compile + run all tests (needs Docker for integration tests)
|
||||
```
|
||||
|
||||
## Infrastructure Setup
|
||||
## Start a brand-new local environment (Docker)
|
||||
|
||||
Start PostgreSQL:
|
||||
The repo ships a `docker-compose.yml` with the full stack: PostgreSQL, ClickHouse, the Spring Boot server, and the nginx-served SPA. All dev defaults are baked into the compose file — no `.env` file or extra config needed for a first run.
|
||||
|
||||
```bash
|
||||
# 1. Clean slate (safe if this is already a first run — noop when no volumes exist)
|
||||
docker compose down -v
|
||||
|
||||
# 2. Build + start everything. First run rebuilds both images (~2–4 min).
|
||||
docker compose up -d --build
|
||||
|
||||
# 3. Watch the server come up (health check goes green in ~60–90s after Flyway + ClickHouse init)
|
||||
docker compose logs -f cameleer-server
|
||||
# ready when you see "Started CameleerServerApplication in ...".
|
||||
# Ctrl+C when ready — containers keep running.
|
||||
|
||||
# 4. Smoke test
|
||||
curl -s http://localhost:8081/api/v1/health # → {"status":"UP"}
|
||||
```
|
||||
|
||||
Open the UI at **http://localhost:8080** (nginx) and log in with **admin / admin**.
|
||||
|
||||
| Service | Host port | URL / notes |
|
||||
|------------|-----------|-------------|
|
||||
| Web UI (nginx) | 8080 | http://localhost:8080 — proxies `/api` to the server |
|
||||
| Server API | 8081 | http://localhost:8081/api/v1/health, http://localhost:8081/api/v1/swagger-ui.html |
|
||||
| PostgreSQL | 5432 | user `cameleer`, password `cameleer_dev`, db `cameleer` |
|
||||
| ClickHouse | 8123 (HTTP), 9000 (native) | user `default`, no password, db `cameleer` |
|
||||
|
||||
**Dev credentials baked into compose (do not use in production):**
|
||||
|
||||
| Purpose | Value |
|
||||
|---|---|
|
||||
| UI login | `admin` / `admin` |
|
||||
| Bootstrap token (agent registration) | `dev-bootstrap-token-for-local-agent-registration` |
|
||||
| JWT secret | `dev-jwt-secret-32-bytes-min-0123456789abcdef0123456789abcdef` |
|
||||
| `CAMELEER_SERVER_RUNTIME_ENABLED` | `false` (Docker-in-Docker app orchestration off for the local stack) |
|
||||
|
||||
Override any of these by editing `docker-compose.yml` or passing `-e KEY=value` to `docker compose run`.
|
||||
|
||||
### Common lifecycle commands
|
||||
|
||||
```bash
|
||||
# Stop everything but keep volumes (quick restart later)
|
||||
docker compose stop
|
||||
|
||||
# Start again after a stop
|
||||
docker compose start
|
||||
|
||||
# Apply changes to the server code / UI — rebuild just what changed
|
||||
docker compose up -d --build cameleer-server
|
||||
docker compose up -d --build cameleer-ui
|
||||
|
||||
# Wipe the environment completely (drops PG + ClickHouse volumes — all data gone)
|
||||
docker compose down -v
|
||||
|
||||
# Fresh Flyway run by dropping just the PG volume (keeps ClickHouse data)
|
||||
docker compose down
|
||||
docker volume rm cameleer-server_cameleer-pgdata
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts PostgreSQL 16. The database schema is applied automatically via Flyway migrations on server startup. ClickHouse tables are created by the schema initializer on startup.
|
||||
### Infra-only mode (backend via `mvn` / UI via Vite)
|
||||
|
||||
| Service | Port | Purpose |
|
||||
|------------|------|----------------------|
|
||||
| PostgreSQL | 5432 | JDBC (Spring JDBC) |
|
||||
|
||||
PostgreSQL credentials: `cameleer` / `cameleer_dev`, database `cameleer`.
|
||||
|
||||
## Run the Server
|
||||
If you want to iterate on backend/UI code without rebuilding the server image on every change, start just the databases and run the server + UI locally:
|
||||
|
||||
```bash
|
||||
# 1. Only infra containers
|
||||
docker compose up -d cameleer-postgres cameleer-clickhouse
|
||||
|
||||
# 2. Build and run the server jar against those containers
|
||||
mvn clean package -DskipTests
|
||||
SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/cameleer \
|
||||
SPRING_DATASOURCE_URL="jdbc:postgresql://localhost:5432/cameleer?currentSchema=tenant_default&ApplicationName=tenant_default" \
|
||||
SPRING_DATASOURCE_USERNAME=cameleer \
|
||||
SPRING_DATASOURCE_PASSWORD=cameleer_dev \
|
||||
CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN=my-secret-token \
|
||||
SPRING_FLYWAY_USER=cameleer \
|
||||
SPRING_FLYWAY_PASSWORD=cameleer_dev \
|
||||
CAMELEER_SERVER_CLICKHOUSE_URL="jdbc:clickhouse://localhost:8123/cameleer" \
|
||||
CAMELEER_SERVER_CLICKHOUSE_USERNAME=default \
|
||||
CAMELEER_SERVER_CLICKHOUSE_PASSWORD= \
|
||||
CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN=dev-bootstrap-token-for-local-agent-registration \
|
||||
CAMELEER_SERVER_SECURITY_JWTSECRET=dev-jwt-secret-32-bytes-min-0123456789abcdef0123456789abcdef \
|
||||
CAMELEER_SERVER_RUNTIME_ENABLED=false \
|
||||
CAMELEER_SERVER_TENANT_ID=default \
|
||||
java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar
|
||||
|
||||
# 3. In another terminal — Vite dev server on :5173 (proxies /api → :8081)
|
||||
cd ui && npm install && npm run dev
|
||||
```
|
||||
|
||||
> **Note:** The Docker image no longer includes default database credentials. When running via `docker run`, pass `-e SPRING_DATASOURCE_URL=...` etc. The docker-compose setup provides these automatically.
|
||||
Database schema is applied automatically: PostgreSQL via Flyway migrations on server startup, ClickHouse tables via `ClickHouseSchemaInitializer`. No manual DDL needed.
|
||||
|
||||
The server starts on **port 8081**. The `CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN` environment variable is **required** — the server fails fast on startup if it is not set.
|
||||
|
||||
For token rotation without downtime, set `CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKENPREVIOUS` to the old token while rolling out the new one. The server accepts both during the overlap window.
|
||||
`CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN` is **required** for agent registration — the server fails fast on startup if it's not set. For token rotation without downtime, set `CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKENPREVIOUS` to the old token while rolling out the new one — the server accepts both during the overlap window.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ package com.cameleer.server.app.alerting.config;
|
||||
import com.cameleer.server.app.alerting.eval.PerKindCircuitBreaker;
|
||||
import com.cameleer.server.app.alerting.metrics.AlertingMetrics;
|
||||
import com.cameleer.server.app.alerting.storage.*;
|
||||
import com.cameleer.server.core.alerting.*;
|
||||
import com.cameleer.server.core.alerting.AlertInstanceRepository;
|
||||
import com.cameleer.server.core.alerting.AlertNotificationRepository;
|
||||
import com.cameleer.server.core.alerting.AlertRuleRepository;
|
||||
import com.cameleer.server.core.alerting.AlertSilenceRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -41,11 +44,6 @@ public class AlertingBeanConfig {
|
||||
return new PostgresAlertNotificationRepository(jdbc, om);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AlertReadRepository alertReadRepository(JdbcTemplate jdbc) {
|
||||
return new PostgresAlertReadRepository(jdbc);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Clock alertingClock() {
|
||||
return Clock.systemDefaultZone();
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
package com.cameleer.server.app.alerting.controller;
|
||||
|
||||
import com.cameleer.server.app.alerting.dto.AlertDto;
|
||||
import com.cameleer.server.app.alerting.dto.BulkReadRequest;
|
||||
import com.cameleer.server.app.alerting.dto.BulkIdsRequest;
|
||||
import com.cameleer.server.app.alerting.dto.UnreadCountResponse;
|
||||
import com.cameleer.server.app.alerting.notify.InAppInboxQuery;
|
||||
import com.cameleer.server.app.web.EnvPath;
|
||||
import com.cameleer.server.core.alerting.AlertInstance;
|
||||
import com.cameleer.server.core.alerting.AlertInstanceRepository;
|
||||
import com.cameleer.server.core.alerting.AlertReadRepository;
|
||||
import com.cameleer.server.core.alerting.AlertSeverity;
|
||||
import com.cameleer.server.core.alerting.AlertState;
|
||||
import com.cameleer.server.core.runtime.Environment;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -29,7 +32,7 @@ import java.util.UUID;
|
||||
|
||||
/**
|
||||
* REST controller for the in-app alert inbox (env-scoped).
|
||||
* VIEWER+ can read their own inbox; OPERATOR+ can ack any alert.
|
||||
* VIEWER+ can read their own inbox; OPERATOR+ can soft-delete and restore alerts.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/environments/{envSlug}/alerts")
|
||||
@@ -37,27 +40,26 @@ import java.util.UUID;
|
||||
@PreAuthorize("hasAnyRole('VIEWER','OPERATOR','ADMIN')")
|
||||
public class AlertController {
|
||||
|
||||
private static final int DEFAULT_LIMIT = 50;
|
||||
|
||||
private final InAppInboxQuery inboxQuery;
|
||||
private final AlertInstanceRepository instanceRepo;
|
||||
private final AlertReadRepository readRepo;
|
||||
|
||||
public AlertController(InAppInboxQuery inboxQuery,
|
||||
AlertInstanceRepository instanceRepo,
|
||||
AlertReadRepository readRepo) {
|
||||
AlertInstanceRepository instanceRepo) {
|
||||
this.inboxQuery = inboxQuery;
|
||||
this.instanceRepo = instanceRepo;
|
||||
this.readRepo = readRepo;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<AlertDto> list(
|
||||
@EnvPath Environment env,
|
||||
@RequestParam(defaultValue = "50") int limit) {
|
||||
@RequestParam(defaultValue = "50") int limit,
|
||||
@RequestParam(required = false) List<AlertState> state,
|
||||
@RequestParam(required = false) List<AlertSeverity> severity,
|
||||
@RequestParam(required = false) Boolean acked,
|
||||
@RequestParam(required = false) Boolean read) {
|
||||
String userId = currentUserId();
|
||||
int effectiveLimit = Math.min(limit, 200);
|
||||
return inboxQuery.listInbox(env.id(), userId, effectiveLimit)
|
||||
return inboxQuery.listInbox(env.id(), userId, state, severity, acked, read, effectiveLimit)
|
||||
.stream().map(AlertDto::from).toList();
|
||||
}
|
||||
|
||||
@@ -68,13 +70,13 @@ public class AlertController {
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public AlertDto get(@EnvPath Environment env, @PathVariable UUID id) {
|
||||
AlertInstance instance = requireInstance(id, env.id());
|
||||
AlertInstance instance = requireLiveInstance(id, env.id());
|
||||
return AlertDto.from(instance);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/ack")
|
||||
public AlertDto ack(@EnvPath Environment env, @PathVariable UUID id) {
|
||||
AlertInstance instance = requireInstance(id, env.id());
|
||||
AlertInstance instance = requireLiveInstance(id, env.id());
|
||||
String userId = currentUserId();
|
||||
instanceRepo.ack(id, userId, Instant.now());
|
||||
// Re-fetch to return fresh state
|
||||
@@ -84,39 +86,72 @@ public class AlertController {
|
||||
|
||||
@PostMapping("/{id}/read")
|
||||
public void read(@EnvPath Environment env, @PathVariable UUID id) {
|
||||
requireInstance(id, env.id());
|
||||
String userId = currentUserId();
|
||||
readRepo.markRead(userId, id);
|
||||
requireLiveInstance(id, env.id());
|
||||
instanceRepo.markRead(id, Instant.now());
|
||||
}
|
||||
|
||||
@PostMapping("/bulk-read")
|
||||
public void bulkRead(@EnvPath Environment env,
|
||||
@Valid @RequestBody BulkReadRequest req) {
|
||||
String userId = currentUserId();
|
||||
// filter to only instances in this env
|
||||
List<UUID> filtered = req.instanceIds().stream()
|
||||
.filter(instanceId -> instanceRepo.findById(instanceId)
|
||||
.map(i -> i.environmentId().equals(env.id()))
|
||||
.orElse(false))
|
||||
.toList();
|
||||
@Valid @RequestBody BulkIdsRequest req) {
|
||||
List<UUID> filtered = inEnvLiveIds(req.instanceIds(), env.id());
|
||||
if (!filtered.isEmpty()) {
|
||||
readRepo.bulkMarkRead(userId, filtered);
|
||||
instanceRepo.bulkMarkRead(filtered, Instant.now());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/bulk-ack")
|
||||
public void bulkAck(@EnvPath Environment env,
|
||||
@Valid @RequestBody BulkIdsRequest req) {
|
||||
List<UUID> filtered = inEnvLiveIds(req.instanceIds(), env.id());
|
||||
if (!filtered.isEmpty()) {
|
||||
instanceRepo.bulkAck(filtered, currentUserId(), Instant.now());
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
|
||||
public ResponseEntity<Void> delete(@EnvPath Environment env, @PathVariable UUID id) {
|
||||
requireLiveInstance(id, env.id());
|
||||
instanceRepo.softDelete(id, Instant.now());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/bulk-delete")
|
||||
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
|
||||
public void bulkDelete(@EnvPath Environment env,
|
||||
@Valid @RequestBody BulkIdsRequest req) {
|
||||
List<UUID> filtered = inEnvLiveIds(req.instanceIds(), env.id());
|
||||
if (!filtered.isEmpty()) {
|
||||
instanceRepo.bulkSoftDelete(filtered, Instant.now());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
|
||||
public ResponseEntity<Void> restore(@EnvPath Environment env, @PathVariable UUID id) {
|
||||
// Unlike requireLiveInstance, restore explicitly targets soft-deleted rows
|
||||
AlertInstance inst = instanceRepo.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Alert not found"));
|
||||
if (!inst.environmentId().equals(env.id()))
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Alert not found in env");
|
||||
instanceRepo.restore(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private AlertInstance requireInstance(UUID id, UUID envId) {
|
||||
AlertInstance instance = instanceRepo.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"Alert not found: " + id));
|
||||
if (!instance.environmentId().equals(envId)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"Alert not found in this environment: " + id);
|
||||
}
|
||||
return instance;
|
||||
private AlertInstance requireLiveInstance(UUID id, UUID envId) {
|
||||
AlertInstance i = instanceRepo.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Alert not found"));
|
||||
if (!i.environmentId().equals(envId) || i.deletedAt() != null)
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Alert not found in env");
|
||||
return i;
|
||||
}
|
||||
|
||||
private List<UUID> inEnvLiveIds(List<UUID> ids, UUID envId) {
|
||||
return instanceRepo.filterInEnvLive(ids, envId);
|
||||
}
|
||||
|
||||
private String currentUserId() {
|
||||
|
||||
@@ -20,6 +20,7 @@ public record AlertDto(
|
||||
Instant ackedAt,
|
||||
String ackedBy,
|
||||
Instant resolvedAt,
|
||||
Instant readAt, // global "has anyone read this"
|
||||
boolean silenced,
|
||||
Double currentValue,
|
||||
Double threshold,
|
||||
@@ -29,6 +30,7 @@ public record AlertDto(
|
||||
return new AlertDto(
|
||||
i.id(), i.ruleId(), i.environmentId(), i.state(), i.severity(),
|
||||
i.title(), i.message(), i.firedAt(), i.ackedAt(), i.ackedBy(),
|
||||
i.resolvedAt(), i.silenced(), i.currentValue(), i.threshold(), i.context());
|
||||
i.resolvedAt(), i.readAt(), i.silenced(),
|
||||
i.currentValue(), i.threshold(), i.context());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cameleer.server.app.alerting.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Shared body for bulk-read / bulk-ack / bulk-delete requests. */
|
||||
public record BulkIdsRequest(@NotNull @Size(min = 1, max = 500) List<UUID> instanceIds) {}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.cameleer.server.app.alerting.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record BulkReadRequest(@NotNull List<UUID> instanceIds) {
|
||||
public BulkReadRequest {
|
||||
instanceIds = instanceIds == null ? List.of() : List.copyOf(instanceIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.cameleer.server.app.alerting.eval;
|
||||
|
||||
import com.cameleer.server.core.agent.AgentEventRecord;
|
||||
import com.cameleer.server.core.agent.AgentEventRepository;
|
||||
import com.cameleer.server.core.alerting.AgentLifecycleCondition;
|
||||
import com.cameleer.server.core.alerting.AgentLifecycleEventType;
|
||||
import com.cameleer.server.core.alerting.AlertRule;
|
||||
import com.cameleer.server.core.alerting.AlertScope;
|
||||
import com.cameleer.server.core.alerting.ConditionKind;
|
||||
import com.cameleer.server.core.runtime.EnvironmentRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Evaluator for {@link AgentLifecycleCondition}.
|
||||
* <p>
|
||||
* Each matching row in {@code agent_events} produces its own {@link EvalResult.Firing}
|
||||
* in an {@link EvalResult.Batch}, so every {@code (agent, eventType, timestamp)}
|
||||
* tuple gets its own {@code AlertInstance} — operationally distinct outages /
|
||||
* restarts / shutdowns are independently ackable. Deduplication across ticks
|
||||
* is enforced by {@code alert_instances_open_rule_uq} via the canonical
|
||||
* {@code _subjectFingerprint} key in the instance context (see V16 migration).
|
||||
*/
|
||||
@Component
|
||||
public class AgentLifecycleEvaluator implements ConditionEvaluator<AgentLifecycleCondition> {
|
||||
|
||||
/** Hard cap on rows returned per tick — prevents a flood of stale events from overwhelming the job. */
|
||||
private static final int MAX_EVENTS_PER_TICK = 500;
|
||||
|
||||
private final AgentEventRepository eventRepo;
|
||||
private final EnvironmentRepository envRepo;
|
||||
|
||||
public AgentLifecycleEvaluator(AgentEventRepository eventRepo, EnvironmentRepository envRepo) {
|
||||
this.eventRepo = eventRepo;
|
||||
this.envRepo = envRepo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConditionKind kind() { return ConditionKind.AGENT_LIFECYCLE; }
|
||||
|
||||
@Override
|
||||
public EvalResult evaluate(AgentLifecycleCondition c, AlertRule rule, EvalContext ctx) {
|
||||
String envSlug = envRepo.findById(rule.environmentId())
|
||||
.map(e -> e.slug())
|
||||
.orElse(null);
|
||||
if (envSlug == null) return EvalResult.Clear.INSTANCE;
|
||||
|
||||
AlertScope scope = c.scope();
|
||||
String appSlug = scope != null ? scope.appSlug() : null;
|
||||
String agentId = scope != null ? scope.agentId() : null;
|
||||
|
||||
List<String> typeNames = c.eventTypes().stream()
|
||||
.map(AgentLifecycleEventType::name)
|
||||
.toList();
|
||||
|
||||
Instant from = ctx.now().minusSeconds(c.withinSeconds());
|
||||
Instant to = ctx.now();
|
||||
|
||||
List<AgentEventRecord> matches = eventRepo.findInWindow(
|
||||
envSlug, appSlug, agentId, typeNames, from, to, MAX_EVENTS_PER_TICK);
|
||||
|
||||
if (matches.isEmpty()) return new EvalResult.Batch(List.of());
|
||||
|
||||
List<EvalResult.Firing> firings = new ArrayList<>(matches.size());
|
||||
for (AgentEventRecord ev : matches) {
|
||||
firings.add(toFiring(ev));
|
||||
}
|
||||
return new EvalResult.Batch(firings);
|
||||
}
|
||||
|
||||
private static EvalResult.Firing toFiring(AgentEventRecord ev) {
|
||||
String fingerprint = (ev.instanceId() == null ? "" : ev.instanceId())
|
||||
+ ":" + (ev.eventType() == null ? "" : ev.eventType())
|
||||
+ ":" + (ev.timestamp() == null ? "0" : Long.toString(ev.timestamp().toEpochMilli()));
|
||||
|
||||
Map<String, Object> context = new LinkedHashMap<>();
|
||||
context.put("agent", Map.of(
|
||||
"id", ev.instanceId() == null ? "" : ev.instanceId(),
|
||||
"app", ev.applicationId() == null ? "" : ev.applicationId()
|
||||
));
|
||||
context.put("event", Map.of(
|
||||
"type", ev.eventType() == null ? "" : ev.eventType(),
|
||||
"timestamp", ev.timestamp() == null ? "" : ev.timestamp().toString(),
|
||||
"detail", ev.detail() == null ? "" : ev.detail()
|
||||
));
|
||||
context.put("_subjectFingerprint", fingerprint);
|
||||
|
||||
return new EvalResult.Firing(1.0, null, context);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ public final class AlertStateTransitions {
|
||||
/**
|
||||
* Apply an EvalResult to the current open AlertInstance.
|
||||
*
|
||||
* @param current the open instance for this rule (PENDING / FIRING / ACKNOWLEDGED), or null if none
|
||||
* @param current the open instance for this rule (PENDING / FIRING), or null if none
|
||||
* @param result the evaluator outcome
|
||||
* @param rule the rule being evaluated
|
||||
* @param now wall-clock instant for the current tick
|
||||
@@ -50,7 +50,7 @@ public final class AlertStateTransitions {
|
||||
private static Optional<AlertInstance> onClear(AlertInstance current, Instant now) {
|
||||
if (current == null) return Optional.empty(); // no open instance — no-op
|
||||
if (current.state() == AlertState.RESOLVED) return Optional.empty(); // already resolved
|
||||
// Any open state (PENDING / FIRING / ACKNOWLEDGED) → RESOLVED
|
||||
// Any open state (PENDING / FIRING) → RESOLVED
|
||||
return Optional.of(current
|
||||
.withState(AlertState.RESOLVED)
|
||||
.withResolvedAt(now));
|
||||
@@ -84,8 +84,8 @@ public final class AlertStateTransitions {
|
||||
// Still within forDuration — stay PENDING, nothing to persist
|
||||
yield Optional.empty();
|
||||
}
|
||||
// FIRING / ACKNOWLEDGED — re-notification cadence handled by the dispatcher
|
||||
case FIRING, ACKNOWLEDGED -> Optional.empty();
|
||||
// FIRING — re-notification cadence handled by the dispatcher
|
||||
case FIRING -> Optional.empty();
|
||||
// RESOLVED should never appear as the "current open" instance, but guard anyway
|
||||
case RESOLVED -> Optional.empty();
|
||||
};
|
||||
@@ -126,6 +126,8 @@ public final class AlertStateTransitions {
|
||||
null, // ackedBy
|
||||
null, // resolvedAt
|
||||
null, // lastNotifiedAt
|
||||
null, // readAt
|
||||
null, // deletedAt
|
||||
false, // silenced
|
||||
f.currentValue(),
|
||||
f.threshold(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.cameleer.server.app.alerting.dto.UnreadCountResponse;
|
||||
import com.cameleer.server.core.alerting.AlertInstance;
|
||||
import com.cameleer.server.core.alerting.AlertInstanceRepository;
|
||||
import com.cameleer.server.core.alerting.AlertSeverity;
|
||||
import com.cameleer.server.core.alerting.AlertState;
|
||||
import com.cameleer.server.core.rbac.RbacService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -48,15 +49,22 @@ public class InAppInboxQuery {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the most recent {@code limit} alert instances visible to the given user.
|
||||
* <p>
|
||||
* Visibility: the instance must target this user directly, or target a group the user belongs to,
|
||||
* or target a role the user holds. Empty target lists mean "broadcast to all".
|
||||
* Full filtered variant: optional {@code states}, {@code severities}, {@code acked},
|
||||
* and {@code read} narrow the result set. {@code null} or empty lists mean
|
||||
* "no filter on that dimension". {@code acked}/{@code read} are tri-state:
|
||||
* {@code null} = no filter, {@code TRUE} = only acked/read, {@code FALSE} = only unacked/unread.
|
||||
*/
|
||||
public List<AlertInstance> listInbox(UUID envId, String userId, int limit) {
|
||||
public List<AlertInstance> listInbox(UUID envId,
|
||||
String userId,
|
||||
List<AlertState> states,
|
||||
List<AlertSeverity> severities,
|
||||
Boolean acked,
|
||||
Boolean read,
|
||||
int limit) {
|
||||
List<String> groupIds = resolveGroupIds(userId);
|
||||
List<String> roleNames = resolveRoleNames(userId);
|
||||
return instanceRepo.listForInbox(envId, groupIds, userId, roleNames, limit);
|
||||
return instanceRepo.listForInbox(envId, groupIds, userId, roleNames,
|
||||
states, severities, acked, read, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,7 +79,9 @@ public class InAppInboxQuery {
|
||||
if (cached != null && now.isBefore(cached.expiresAt())) {
|
||||
return cached.response();
|
||||
}
|
||||
Map<AlertSeverity, Long> bySeverity = instanceRepo.countUnreadBySeverityForUser(envId, userId);
|
||||
List<String> groupIds = resolveGroupIds(userId);
|
||||
List<String> roleNames = resolveRoleNames(userId);
|
||||
Map<AlertSeverity, Long> bySeverity = instanceRepo.countUnreadBySeverity(envId, userId, groupIds, roleNames);
|
||||
UnreadCountResponse response = UnreadCountResponse.from(bySeverity);
|
||||
memo.put(key, new Entry(response, now.plusMillis(MEMO_TTL_MS)));
|
||||
return response;
|
||||
|
||||
@@ -64,6 +64,10 @@ public class NotificationContextBuilder {
|
||||
ctx.put("agent", subtree(instance, "agent.id", "agent.name", "agent.state"));
|
||||
ctx.put("app", subtree(instance, "app.slug", "app.id"));
|
||||
}
|
||||
case AGENT_LIFECYCLE -> {
|
||||
ctx.put("agent", subtree(instance, "agent.id", "agent.app"));
|
||||
ctx.put("event", subtree(instance, "event.type", "event.timestamp", "event.detail"));
|
||||
}
|
||||
case DEPLOYMENT_STATE -> {
|
||||
ctx.put("deployment", subtree(instance, "deployment.id", "deployment.status"));
|
||||
ctx.put("app", subtree(instance, "app.slug", "app.id"));
|
||||
|
||||
@@ -34,10 +34,12 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
|
||||
INSERT INTO alert_instances (
|
||||
id, rule_id, rule_snapshot, environment_id, state, severity,
|
||||
fired_at, acked_at, acked_by, resolved_at, last_notified_at,
|
||||
read_at, deleted_at,
|
||||
silenced, current_value, threshold, context, title, message,
|
||||
target_user_ids, target_group_ids, target_role_names)
|
||||
VALUES (?, ?, ?::jsonb, ?, ?::alert_state_enum, ?::severity_enum,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?,
|
||||
?, ?, ?, ?::jsonb, ?, ?,
|
||||
?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
@@ -46,6 +48,8 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
|
||||
acked_by = EXCLUDED.acked_by,
|
||||
resolved_at = EXCLUDED.resolved_at,
|
||||
last_notified_at = EXCLUDED.last_notified_at,
|
||||
read_at = EXCLUDED.read_at,
|
||||
deleted_at = EXCLUDED.deleted_at,
|
||||
silenced = EXCLUDED.silenced,
|
||||
current_value = EXCLUDED.current_value,
|
||||
threshold = EXCLUDED.threshold,
|
||||
@@ -66,6 +70,7 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
|
||||
i.environmentId(), i.state().name(), i.severity().name(),
|
||||
ts(i.firedAt()), ts(i.ackedAt()), i.ackedBy(),
|
||||
ts(i.resolvedAt()), ts(i.lastNotifiedAt()),
|
||||
ts(i.readAt()), ts(i.deletedAt()),
|
||||
i.silenced(), i.currentValue(), i.threshold(),
|
||||
writeJson(i.context()), i.title(), i.message(),
|
||||
userIds, groupIds, roleNames);
|
||||
@@ -87,7 +92,8 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
|
||||
var list = jdbc.query("""
|
||||
SELECT * FROM alert_instances
|
||||
WHERE rule_id = ?
|
||||
AND state IN ('PENDING','FIRING','ACKNOWLEDGED')
|
||||
AND state IN ('PENDING','FIRING')
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
""", rowMapper(), ruleId);
|
||||
return list.isEmpty() ? Optional.empty() : Optional.of(list.get(0));
|
||||
@@ -98,12 +104,15 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
|
||||
List<String> userGroupIdFilter,
|
||||
String userId,
|
||||
List<String> userRoleNames,
|
||||
List<AlertState> states,
|
||||
List<AlertSeverity> severities,
|
||||
Boolean acked,
|
||||
Boolean read,
|
||||
int limit) {
|
||||
// Build arrays for group UUIDs and role names
|
||||
Array groupArray = toUuidArrayFromStrings(userGroupIdFilter);
|
||||
Array roleArray = toTextArray(userRoleNames);
|
||||
|
||||
String sql = """
|
||||
StringBuilder sql = new StringBuilder("""
|
||||
SELECT * FROM alert_instances
|
||||
WHERE environment_id = ?
|
||||
AND (
|
||||
@@ -111,30 +120,57 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
|
||||
OR target_group_ids && ?
|
||||
OR target_role_names && ?
|
||||
)
|
||||
ORDER BY fired_at DESC
|
||||
LIMIT ?
|
||||
""";
|
||||
return jdbc.query(sql, rowMapper(), environmentId, userId, groupArray, roleArray, limit);
|
||||
""");
|
||||
List<Object> args = new ArrayList<>(List.of(environmentId, userId, groupArray, roleArray));
|
||||
|
||||
if (states != null && !states.isEmpty()) {
|
||||
Array stateArray = toTextArray(states.stream().map(Enum::name).toList());
|
||||
sql.append(" AND state::text = ANY(?)");
|
||||
args.add(stateArray);
|
||||
}
|
||||
if (severities != null && !severities.isEmpty()) {
|
||||
Array severityArray = toTextArray(severities.stream().map(Enum::name).toList());
|
||||
sql.append(" AND severity::text = ANY(?)");
|
||||
args.add(severityArray);
|
||||
}
|
||||
if (acked != null) {
|
||||
sql.append(acked ? " AND acked_at IS NOT NULL" : " AND acked_at IS NULL");
|
||||
}
|
||||
if (read != null) {
|
||||
sql.append(read ? " AND read_at IS NOT NULL" : " AND read_at IS NULL");
|
||||
}
|
||||
sql.append(" AND deleted_at IS NULL");
|
||||
sql.append(" ORDER BY fired_at DESC LIMIT ?");
|
||||
args.add(limit);
|
||||
|
||||
return jdbc.query(sql.toString(), rowMapper(), args.toArray());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<AlertSeverity, Long> countUnreadBySeverityForUser(UUID environmentId, String userId) {
|
||||
public Map<AlertSeverity, Long> countUnreadBySeverity(UUID environmentId,
|
||||
String userId,
|
||||
List<String> groupIds,
|
||||
List<String> roleNames) {
|
||||
Array groupArray = toUuidArrayFromStrings(groupIds);
|
||||
Array roleArray = toTextArray(roleNames);
|
||||
String sql = """
|
||||
SELECT ai.severity::text AS severity, COUNT(*) AS cnt
|
||||
FROM alert_instances ai
|
||||
WHERE ai.environment_id = ?
|
||||
AND ? = ANY(ai.target_user_ids)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM alert_reads ar
|
||||
WHERE ar.user_id = ? AND ar.alert_instance_id = ai.id
|
||||
SELECT severity::text AS severity, COUNT(*) AS cnt
|
||||
FROM alert_instances
|
||||
WHERE environment_id = ?
|
||||
AND read_at IS NULL
|
||||
AND deleted_at IS NULL
|
||||
AND (
|
||||
? = ANY(target_user_ids)
|
||||
OR target_group_ids && ?
|
||||
OR target_role_names && ?
|
||||
)
|
||||
GROUP BY ai.severity
|
||||
GROUP BY severity
|
||||
""";
|
||||
EnumMap<AlertSeverity, Long> counts = new EnumMap<>(AlertSeverity.class);
|
||||
for (AlertSeverity s : AlertSeverity.values()) counts.put(s, 0L);
|
||||
jdbc.query(sql, rs -> {
|
||||
counts.put(AlertSeverity.valueOf(rs.getString("severity")), rs.getLong("cnt"));
|
||||
}, environmentId, userId, userId);
|
||||
jdbc.query(sql, (org.springframework.jdbc.core.RowCallbackHandler) rs -> counts.put(
|
||||
AlertSeverity.valueOf(rs.getString("severity")), rs.getLong("cnt")
|
||||
), environmentId, userId, groupArray, roleArray);
|
||||
return counts;
|
||||
}
|
||||
|
||||
@@ -142,12 +178,61 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
|
||||
public void ack(UUID id, String userId, Instant when) {
|
||||
jdbc.update("""
|
||||
UPDATE alert_instances
|
||||
SET state = 'ACKNOWLEDGED'::alert_state_enum,
|
||||
acked_at = ?, acked_by = ?
|
||||
WHERE id = ?
|
||||
SET acked_at = ?, acked_by = ?
|
||||
WHERE id = ? AND acked_at IS NULL AND deleted_at IS NULL
|
||||
""", Timestamp.from(when), userId, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markRead(UUID id, Instant when) {
|
||||
jdbc.update("UPDATE alert_instances SET read_at = ? WHERE id = ? AND read_at IS NULL",
|
||||
Timestamp.from(when), id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bulkMarkRead(List<UUID> ids, Instant when) {
|
||||
if (ids == null || ids.isEmpty()) return;
|
||||
Array idArray = jdbc.execute((ConnectionCallback<Array>) c ->
|
||||
c.createArrayOf("uuid", ids.toArray()));
|
||||
jdbc.update("""
|
||||
UPDATE alert_instances SET read_at = ?
|
||||
WHERE id = ANY(?) AND read_at IS NULL AND deleted_at IS NULL
|
||||
""", Timestamp.from(when), idArray);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void softDelete(UUID id, Instant when) {
|
||||
jdbc.update("UPDATE alert_instances SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL",
|
||||
Timestamp.from(when), id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bulkSoftDelete(List<UUID> ids, Instant when) {
|
||||
if (ids == null || ids.isEmpty()) return;
|
||||
Array idArray = jdbc.execute((ConnectionCallback<Array>) c ->
|
||||
c.createArrayOf("uuid", ids.toArray()));
|
||||
jdbc.update("""
|
||||
UPDATE alert_instances SET deleted_at = ?
|
||||
WHERE id = ANY(?) AND deleted_at IS NULL
|
||||
""", Timestamp.from(when), idArray);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void restore(UUID id) {
|
||||
jdbc.update("UPDATE alert_instances SET deleted_at = NULL WHERE id = ?", id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bulkAck(List<UUID> ids, String userId, Instant when) {
|
||||
if (ids == null || ids.isEmpty()) return;
|
||||
Array idArray = jdbc.execute((ConnectionCallback<Array>) c ->
|
||||
c.createArrayOf("uuid", ids.toArray()));
|
||||
jdbc.update("""
|
||||
UPDATE alert_instances SET acked_at = ?, acked_by = ?
|
||||
WHERE id = ANY(?) AND acked_at IS NULL AND deleted_at IS NULL
|
||||
""", Timestamp.from(when), userId, idArray);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resolve(UUID id, Instant when) {
|
||||
jdbc.update("""
|
||||
@@ -177,6 +262,17 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
|
||||
""", rowMapper(), Timestamp.from(now));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UUID> filterInEnvLive(List<UUID> ids, UUID environmentId) {
|
||||
if (ids == null || ids.isEmpty()) return List.of();
|
||||
Array idArray = jdbc.execute((ConnectionCallback<Array>) c ->
|
||||
c.createArrayOf("uuid", ids.toArray()));
|
||||
return jdbc.query("""
|
||||
SELECT id FROM alert_instances
|
||||
WHERE id = ANY(?) AND environment_id = ? AND deleted_at IS NULL
|
||||
""", (rs, i) -> (UUID) rs.getObject("id"), idArray, environmentId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteResolvedBefore(Instant cutoff) {
|
||||
jdbc.update("""
|
||||
@@ -199,6 +295,8 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
|
||||
Timestamp ackedAt = rs.getTimestamp("acked_at");
|
||||
Timestamp resolvedAt = rs.getTimestamp("resolved_at");
|
||||
Timestamp lastNotifiedAt = rs.getTimestamp("last_notified_at");
|
||||
Timestamp readAt = rs.getTimestamp("read_at");
|
||||
Timestamp deletedAt = rs.getTimestamp("deleted_at");
|
||||
|
||||
Object cvObj = rs.getObject("current_value");
|
||||
Double currentValue = cvObj == null ? null : ((Number) cvObj).doubleValue();
|
||||
@@ -219,6 +317,8 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
|
||||
rs.getString("acked_by"),
|
||||
resolvedAt == null ? null : resolvedAt.toInstant(),
|
||||
lastNotifiedAt == null ? null : lastNotifiedAt.toInstant(),
|
||||
readAt == null ? null : readAt.toInstant(),
|
||||
deletedAt == null ? null : deletedAt.toInstant(),
|
||||
rs.getBoolean("silenced"),
|
||||
currentValue,
|
||||
threshold,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.cameleer.server.app.alerting.storage;
|
||||
|
||||
import com.cameleer.server.core.alerting.AlertReadRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public class PostgresAlertReadRepository implements AlertReadRepository {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public PostgresAlertReadRepository(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markRead(String userId, UUID alertInstanceId) {
|
||||
jdbc.update("""
|
||||
INSERT INTO alert_reads (user_id, alert_instance_id)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT (user_id, alert_instance_id) DO NOTHING
|
||||
""", userId, alertInstanceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bulkMarkRead(String userId, List<UUID> alertInstanceIds) {
|
||||
if (alertInstanceIds == null || alertInstanceIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (UUID id : alertInstanceIds) {
|
||||
markRead(userId, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,10 +171,15 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/silences/**").hasAnyRole("OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.PUT, "/api/v1/environments/*/alerts/silences/**").hasAnyRole("OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/v1/environments/*/alerts/silences/**").hasAnyRole("OPERATOR", "ADMIN")
|
||||
// Alerting — ack/read (VIEWER+ self-service)
|
||||
// Alerting — ack/read/bulk-ack (VIEWER+ self-service)
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/*/ack").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/*/read").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/bulk-read").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/bulk-ack").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
// Alerting — soft-delete / restore (OPERATOR+)
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/v1/environments/*/alerts/*").hasAnyRole("OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/bulk-delete").hasAnyRole("OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/*/restore").hasAnyRole("OPERATOR", "ADMIN")
|
||||
// Alerting — notification retry (flat path; notification IDs globally unique)
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/alerts/notifications/*/retry").hasAnyRole("OPERATOR", "ADMIN")
|
||||
|
||||
|
||||
@@ -106,4 +106,57 @@ public class ClickHouseAgentEventRepository implements AgentEventRepository {
|
||||
|
||||
return new AgentEventPage(results, nextCursor, hasMore);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AgentEventRecord> findInWindow(String environment,
|
||||
String applicationId,
|
||||
String instanceId,
|
||||
List<String> eventTypes,
|
||||
Instant fromInclusive,
|
||||
Instant toExclusive,
|
||||
int limit) {
|
||||
if (eventTypes == null || eventTypes.isEmpty()) {
|
||||
throw new IllegalArgumentException("eventTypes must not be empty");
|
||||
}
|
||||
if (fromInclusive == null || toExclusive == null) {
|
||||
throw new IllegalArgumentException("from/to must not be null");
|
||||
}
|
||||
|
||||
// `event_type IN (?, ?, …)` — one placeholder per type.
|
||||
String placeholders = String.join(",", java.util.Collections.nCopies(eventTypes.size(), "?"));
|
||||
var sql = new StringBuilder(SELECT_BASE);
|
||||
var params = new ArrayList<Object>();
|
||||
params.add(tenantId);
|
||||
|
||||
if (environment != null) {
|
||||
sql.append(" AND environment = ?");
|
||||
params.add(environment);
|
||||
}
|
||||
if (applicationId != null) {
|
||||
sql.append(" AND application_id = ?");
|
||||
params.add(applicationId);
|
||||
}
|
||||
if (instanceId != null) {
|
||||
sql.append(" AND instance_id = ?");
|
||||
params.add(instanceId);
|
||||
}
|
||||
sql.append(" AND event_type IN (").append(placeholders).append(")");
|
||||
params.addAll(eventTypes);
|
||||
sql.append(" AND timestamp >= ? AND timestamp < ?");
|
||||
params.add(Timestamp.from(fromInclusive));
|
||||
params.add(Timestamp.from(toExclusive));
|
||||
sql.append(" ORDER BY timestamp ASC, insert_id ASC LIMIT ?");
|
||||
params.add(limit);
|
||||
|
||||
return jdbc.query(sql.toString(),
|
||||
(rs, rowNum) -> new AgentEventRecord(
|
||||
rs.getLong("id"),
|
||||
rs.getString("instance_id"),
|
||||
rs.getString("application_id"),
|
||||
rs.getString("event_type"),
|
||||
rs.getString("detail"),
|
||||
rs.getTimestamp("timestamp").toInstant()
|
||||
),
|
||||
params.toArray());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- V16 — Generalise open-alert_instance uniqueness via `_subjectFingerprint`.
|
||||
--
|
||||
-- V15 discriminated open instances by `context->'exchange'->>'id'` so that
|
||||
-- EXCHANGE_MATCH / PER_EXCHANGE could emit one instance per exchange. The new
|
||||
-- AGENT_LIFECYCLE / PER_AGENT condition has the same shape but a different
|
||||
-- subject key (agentId + eventType + eventTs). Rather than bolt condition-kind
|
||||
-- knowledge into the index, we introduce a canonical `_subjectFingerprint`
|
||||
-- field in `context` that every "per-subject" evaluator writes. The index
|
||||
-- prefers it over the legacy exchange.id discriminator.
|
||||
--
|
||||
-- Precedence in the COALESCE:
|
||||
-- 1. context->>'_subjectFingerprint' — explicit per-subject key (new)
|
||||
-- 2. context->'exchange'->>'id' — legacy EXCHANGE_MATCH instances (pre-V16)
|
||||
-- 3. '' — scalar condition kinds (one open per rule)
|
||||
--
|
||||
-- Existing open PER_EXCHANGE instances keep working because they never set
|
||||
-- `_subjectFingerprint` but do carry `context.exchange.id`, so the index
|
||||
-- still discriminates them correctly.
|
||||
DROP INDEX IF EXISTS alert_instances_open_rule_uq;
|
||||
|
||||
CREATE UNIQUE INDEX alert_instances_open_rule_uq
|
||||
ON alert_instances (rule_id, (COALESCE(
|
||||
context->>'_subjectFingerprint',
|
||||
context->'exchange'->>'id',
|
||||
'')))
|
||||
WHERE rule_id IS NOT NULL
|
||||
AND state IN ('PENDING','FIRING','ACKNOWLEDGED');
|
||||
@@ -0,0 +1,53 @@
|
||||
-- V17 — Alerts: drop ACKNOWLEDGED state, add read_at/deleted_at, drop alert_reads,
|
||||
-- rework open-rule unique index predicate to survive ack (acked no longer "closed").
|
||||
|
||||
-- 1. Coerce ACKNOWLEDGED rows → FIRING (acked_at already set on these rows)
|
||||
UPDATE alert_instances SET state = 'FIRING' WHERE state = 'ACKNOWLEDGED';
|
||||
|
||||
-- 2. Swap alert_state_enum to remove ACKNOWLEDGED (Postgres can't drop enum values in place)
|
||||
-- First drop all indexes that reference alert_state_enum so ALTER COLUMN can proceed.
|
||||
DROP INDEX IF EXISTS alert_instances_open_rule_uq;
|
||||
DROP INDEX IF EXISTS alert_instances_inbox_idx;
|
||||
DROP INDEX IF EXISTS alert_instances_open_rule_idx;
|
||||
DROP INDEX IF EXISTS alert_instances_resolved_idx;
|
||||
|
||||
CREATE TYPE alert_state_enum_v2 AS ENUM ('PENDING','FIRING','RESOLVED');
|
||||
ALTER TABLE alert_instances
|
||||
ALTER COLUMN state TYPE alert_state_enum_v2
|
||||
USING state::text::alert_state_enum_v2;
|
||||
DROP TYPE alert_state_enum;
|
||||
ALTER TYPE alert_state_enum_v2 RENAME TO alert_state_enum;
|
||||
|
||||
-- Recreate the non-unique indexes that were dropped above
|
||||
CREATE INDEX alert_instances_inbox_idx ON alert_instances (environment_id, state, fired_at DESC);
|
||||
CREATE INDEX alert_instances_open_rule_idx ON alert_instances (rule_id, state) WHERE rule_id IS NOT NULL;
|
||||
CREATE INDEX alert_instances_resolved_idx ON alert_instances (resolved_at) WHERE state = 'RESOLVED';
|
||||
|
||||
-- 3. New orthogonal flag columns
|
||||
ALTER TABLE alert_instances
|
||||
ADD COLUMN read_at timestamptz NULL,
|
||||
ADD COLUMN deleted_at timestamptz NULL;
|
||||
|
||||
CREATE INDEX alert_instances_unread_idx
|
||||
ON alert_instances (environment_id, read_at)
|
||||
WHERE read_at IS NULL AND deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX alert_instances_deleted_idx
|
||||
ON alert_instances (deleted_at)
|
||||
WHERE deleted_at IS NOT NULL;
|
||||
|
||||
-- 4. Rework the V13/V15/V16 open-rule uniqueness index:
|
||||
-- - drop ACKNOWLEDGED from the predicate (ack no longer "closes")
|
||||
-- - add "AND deleted_at IS NULL" so a soft-deleted row frees the slot
|
||||
DROP INDEX IF EXISTS alert_instances_open_rule_uq;
|
||||
CREATE UNIQUE INDEX alert_instances_open_rule_uq
|
||||
ON alert_instances (rule_id, (COALESCE(
|
||||
context->>'_subjectFingerprint',
|
||||
context->'exchange'->>'id',
|
||||
'')))
|
||||
WHERE rule_id IS NOT NULL
|
||||
AND state IN ('PENDING','FIRING')
|
||||
AND deleted_at IS NULL;
|
||||
|
||||
-- 5. Drop the per-user reads table — read is now global on alert_instances.read_at
|
||||
DROP TABLE alert_reads;
|
||||
@@ -243,11 +243,11 @@ class AlertingFullLifecycleIT extends AbstractPostgresIT {
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(resp.getBody());
|
||||
assertThat(body.path("state").asText()).isEqualTo("ACKNOWLEDGED");
|
||||
assertThat(body.path("state").asText()).isEqualTo("FIRING");
|
||||
|
||||
// DB state
|
||||
AlertInstance updated = instanceRepo.findById(instanceId).orElseThrow();
|
||||
assertThat(updated.state()).isEqualTo(AlertState.ACKNOWLEDGED);
|
||||
assertThat(updated.state()).isEqualTo(AlertState.FIRING);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -5,7 +5,6 @@ import com.cameleer.server.app.TestSecurityHelper;
|
||||
import com.cameleer.server.app.search.ClickHouseLogStore;
|
||||
import com.cameleer.server.core.alerting.AlertInstance;
|
||||
import com.cameleer.server.core.alerting.AlertInstanceRepository;
|
||||
import com.cameleer.server.core.alerting.AlertReadRepository;
|
||||
import com.cameleer.server.core.alerting.AlertSeverity;
|
||||
import com.cameleer.server.core.alerting.AlertState;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
@@ -35,7 +34,6 @@ class AlertControllerIT extends AbstractPostgresIT {
|
||||
@Autowired private ObjectMapper objectMapper;
|
||||
@Autowired private TestSecurityHelper securityHelper;
|
||||
@Autowired private AlertInstanceRepository instanceRepo;
|
||||
@Autowired private AlertReadRepository readRepo;
|
||||
|
||||
private String operatorJwt;
|
||||
private String viewerJwt;
|
||||
@@ -71,6 +69,10 @@ class AlertControllerIT extends AbstractPostgresIT {
|
||||
jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-operator','test-viewer')");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Existing tests (baseline)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void listReturnsAlertsForEnv() throws Exception {
|
||||
AlertInstance instance = seedInstance(envIdA);
|
||||
@@ -84,7 +86,6 @@ class AlertControllerIT extends AbstractPostgresIT {
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(resp.getBody());
|
||||
assertThat(body.isArray()).isTrue();
|
||||
// The alert we seeded should be present
|
||||
boolean found = false;
|
||||
for (JsonNode node : body) {
|
||||
if (node.path("id").asText().equals(instance.id().toString())) {
|
||||
@@ -97,10 +98,8 @@ class AlertControllerIT extends AbstractPostgresIT {
|
||||
|
||||
@Test
|
||||
void envIsolation() throws Exception {
|
||||
// Seed an alert in env-A
|
||||
AlertInstance instanceA = seedInstance(envIdA);
|
||||
|
||||
// env-B inbox should NOT see env-A's alert
|
||||
ResponseEntity<String> resp = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugB + "/alerts",
|
||||
HttpMethod.GET,
|
||||
@@ -138,7 +137,7 @@ class AlertControllerIT extends AbstractPostgresIT {
|
||||
|
||||
assertThat(ack.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(ack.getBody());
|
||||
assertThat(body.path("state").asText()).isEqualTo("ACKNOWLEDGED");
|
||||
assertThat(body.path("state").asText()).isEqualTo("FIRING");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -152,6 +151,10 @@ class AlertControllerIT extends AbstractPostgresIT {
|
||||
String.class);
|
||||
|
||||
assertThat(read.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
// Verify persistence — readAt must now be set
|
||||
AlertInstance updated = instanceRepo.findById(instance.id()).orElseThrow();
|
||||
assertThat(updated.readAt()).as("readAt must be set after /read").isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -170,6 +173,12 @@ class AlertControllerIT extends AbstractPostgresIT {
|
||||
String.class);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
// Verify persistence — both must have readAt set
|
||||
assertThat(instanceRepo.findById(i1.id()).orElseThrow().readAt())
|
||||
.as("i1 readAt must be set after bulk-read").isNotNull();
|
||||
assertThat(instanceRepo.findById(i2.id()).orElseThrow().readAt())
|
||||
.as("i2 readAt must be set after bulk-read").isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -182,6 +191,313 @@ class AlertControllerIT extends AbstractPostgresIT {
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// New endpoint tests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void delete_softDeletes_and_subsequent_get_returns_404() {
|
||||
AlertInstance instance = seedInstance(envIdA);
|
||||
|
||||
// OPERATOR deletes
|
||||
ResponseEntity<String> del = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts/" + instance.id(),
|
||||
HttpMethod.DELETE,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(del.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
|
||||
// Subsequent GET returns 404
|
||||
ResponseEntity<String> get = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts/" + instance.id(),
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(get.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_non_operator_returns_403() {
|
||||
AlertInstance instance = seedInstance(envIdA);
|
||||
|
||||
ResponseEntity<String> del = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts/" + instance.id(),
|
||||
HttpMethod.DELETE,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
|
||||
String.class);
|
||||
assertThat(del.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkDelete_only_affects_matching_env() {
|
||||
AlertInstance inEnvA = seedInstance(envIdA);
|
||||
AlertInstance inEnvA2 = seedInstance(envIdA);
|
||||
AlertInstance inEnvB = seedInstance(envIdB);
|
||||
|
||||
String body = """
|
||||
{"instanceIds":["%s","%s","%s"]}
|
||||
""".formatted(inEnvA.id(), inEnvA2.id(), inEnvB.id());
|
||||
|
||||
ResponseEntity<String> resp = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts/bulk-delete",
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
// env-A alerts should be soft-deleted
|
||||
assertThat(instanceRepo.findById(inEnvA.id()).orElseThrow().deletedAt())
|
||||
.as("inEnvA should be soft-deleted").isNotNull();
|
||||
assertThat(instanceRepo.findById(inEnvA2.id()).orElseThrow().deletedAt())
|
||||
.as("inEnvA2 should be soft-deleted").isNotNull();
|
||||
|
||||
// env-B alert must NOT be soft-deleted
|
||||
assertThat(instanceRepo.findById(inEnvB.id()).orElseThrow().deletedAt())
|
||||
.as("inEnvB must not be soft-deleted via env-A bulk-delete").isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkAck_only_touches_unacked_rows() {
|
||||
AlertInstance i1 = seedInstance(envIdA);
|
||||
AlertInstance i2 = seedInstance(envIdA);
|
||||
|
||||
// Pre-ack i1 with an existing user (must be in users table due to FK)
|
||||
instanceRepo.ack(i1.id(), "test-viewer", Instant.now().minusSeconds(60));
|
||||
|
||||
String body = """
|
||||
{"instanceIds":["%s","%s"]}
|
||||
""".formatted(i1.id(), i2.id());
|
||||
|
||||
ResponseEntity<String> resp = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts/bulk-ack",
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
// i1's ackedBy must remain "test-viewer" (bulk-ack skips already-acked rows)
|
||||
AlertInstance refreshed1 = instanceRepo.findById(i1.id()).orElseThrow();
|
||||
assertThat(refreshed1.ackedBy()).as("previously-acked row must keep original ackedBy").isEqualTo("test-viewer");
|
||||
|
||||
// i2 must now be acked
|
||||
AlertInstance refreshed2 = instanceRepo.findById(i2.id()).orElseThrow();
|
||||
assertThat(refreshed2.ackedAt()).as("i2 must be acked after bulk-ack").isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void restore_clears_deleted_at_and_reappears_in_inbox() throws Exception {
|
||||
AlertInstance instance = seedInstance(envIdA);
|
||||
|
||||
// Soft-delete first
|
||||
restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts/" + instance.id(),
|
||||
HttpMethod.DELETE,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(instanceRepo.findById(instance.id()).orElseThrow().deletedAt()).isNotNull();
|
||||
|
||||
// Restore
|
||||
ResponseEntity<String> restoreResp = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts/" + instance.id() + "/restore",
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(securityHelper.authHeaders(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(restoreResp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
|
||||
// deletedAt must be cleared
|
||||
assertThat(instanceRepo.findById(instance.id()).orElseThrow().deletedAt())
|
||||
.as("deletedAt must be null after restore").isNull();
|
||||
|
||||
// Alert reappears in inbox list
|
||||
ResponseEntity<String> listResp = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(listResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(listResp.getBody());
|
||||
boolean found = false;
|
||||
for (JsonNode node : body) {
|
||||
if (node.path("id").asText().equals(instance.id().toString())) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertThat(found).as("restored alert must reappear in inbox").isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_respects_acked_filter_tristate() throws Exception {
|
||||
AlertInstance unacked = seedInstance(envIdA);
|
||||
AlertInstance acked = seedInstance(envIdA);
|
||||
instanceRepo.ack(acked.id(), "test-operator", Instant.now());
|
||||
|
||||
// ?acked=false — only unacked
|
||||
ResponseEntity<String> falseResp = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts?acked=false",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(falseResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode falseBody = objectMapper.readTree(falseResp.getBody());
|
||||
boolean unackedFound = false, ackedFoundInFalse = false;
|
||||
for (JsonNode node : falseBody) {
|
||||
String id = node.path("id").asText();
|
||||
if (id.equals(unacked.id().toString())) unackedFound = true;
|
||||
if (id.equals(acked.id().toString())) ackedFoundInFalse = true;
|
||||
}
|
||||
assertThat(unackedFound).as("unacked alert must appear with ?acked=false").isTrue();
|
||||
assertThat(ackedFoundInFalse).as("acked alert must NOT appear with ?acked=false").isFalse();
|
||||
|
||||
// ?acked=true — only acked
|
||||
ResponseEntity<String> trueResp = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts?acked=true",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(trueResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode trueBody = objectMapper.readTree(trueResp.getBody());
|
||||
boolean ackedFound = false, unackedFoundInTrue = false;
|
||||
for (JsonNode node : trueBody) {
|
||||
String id = node.path("id").asText();
|
||||
if (id.equals(acked.id().toString())) ackedFound = true;
|
||||
if (id.equals(unacked.id().toString())) unackedFoundInTrue = true;
|
||||
}
|
||||
assertThat(ackedFound).as("acked alert must appear with ?acked=true").isTrue();
|
||||
assertThat(unackedFoundInTrue).as("unacked alert must NOT appear with ?acked=true").isFalse();
|
||||
|
||||
// no param — both visible
|
||||
ResponseEntity<String> allResp = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(allResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode allBody = objectMapper.readTree(allResp.getBody());
|
||||
boolean bothUnacked = false, bothAcked = false;
|
||||
for (JsonNode node : allBody) {
|
||||
String id = node.path("id").asText();
|
||||
if (id.equals(unacked.id().toString())) bothUnacked = true;
|
||||
if (id.equals(acked.id().toString())) bothAcked = true;
|
||||
}
|
||||
assertThat(bothUnacked).as("unacked must appear with no acked filter").isTrue();
|
||||
assertThat(bothAcked).as("acked must appear with no acked filter").isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_respects_read_filter_tristate() throws Exception {
|
||||
AlertInstance unread = seedInstance(envIdA);
|
||||
AlertInstance read = seedInstance(envIdA);
|
||||
instanceRepo.markRead(read.id(), Instant.now());
|
||||
|
||||
// ?read=false — only unread
|
||||
ResponseEntity<String> falseResp = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts?read=false",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(falseResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode falseBody = objectMapper.readTree(falseResp.getBody());
|
||||
boolean unreadFound = false, readFoundInFalse = false;
|
||||
for (JsonNode node : falseBody) {
|
||||
String id = node.path("id").asText();
|
||||
if (id.equals(unread.id().toString())) unreadFound = true;
|
||||
if (id.equals(read.id().toString())) readFoundInFalse = true;
|
||||
}
|
||||
assertThat(unreadFound).as("unread alert must appear with ?read=false").isTrue();
|
||||
assertThat(readFoundInFalse).as("read alert must NOT appear with ?read=false").isFalse();
|
||||
|
||||
// ?read=true — only read
|
||||
ResponseEntity<String> trueResp = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts?read=true",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(trueResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode trueBody = objectMapper.readTree(trueResp.getBody());
|
||||
boolean readFound = false, unreadFoundInTrue = false;
|
||||
for (JsonNode node : trueBody) {
|
||||
String id = node.path("id").asText();
|
||||
if (id.equals(read.id().toString())) readFound = true;
|
||||
if (id.equals(unread.id().toString())) unreadFoundInTrue = true;
|
||||
}
|
||||
assertThat(readFound).as("read alert must appear with ?read=true").isTrue();
|
||||
assertThat(unreadFoundInTrue).as("unread alert must NOT appear with ?read=true").isFalse();
|
||||
|
||||
// no param — both visible
|
||||
ResponseEntity<String> allResp = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(allResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode allBody = objectMapper.readTree(allResp.getBody());
|
||||
boolean bothUnread = false, bothRead = false;
|
||||
for (JsonNode node : allBody) {
|
||||
String id = node.path("id").asText();
|
||||
if (id.equals(unread.id().toString())) bothUnread = true;
|
||||
if (id.equals(read.id().toString())) bothRead = true;
|
||||
}
|
||||
assertThat(bothUnread).as("unread must appear with no read filter").isTrue();
|
||||
assertThat(bothRead).as("read must appear with no read filter").isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void read_is_global_other_users_see_readAt_set() throws Exception {
|
||||
// Seed an alert targeting BOTH users so the viewer's GET /{id} is visible
|
||||
AlertInstance instance = new AlertInstance(
|
||||
UUID.randomUUID(), null, null, envIdA,
|
||||
AlertState.FIRING, AlertSeverity.WARNING,
|
||||
Instant.now(), null, null, null, null, null, null, false,
|
||||
42.0, 1000.0, null, "Global read test", "Operator reads, viewer sees it",
|
||||
List.of("test-operator", "test-viewer"), List.of(), List.of());
|
||||
instance = instanceRepo.save(instance);
|
||||
|
||||
// Operator (user A) marks the alert as read
|
||||
ResponseEntity<String> readResp = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts/" + instance.id() + "/read",
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(securityHelper.authHeaders(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(readResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
// Viewer (user B) fetches the same alert and must see readAt != null
|
||||
ResponseEntity<String> getResp = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts/" + instance.id(),
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
|
||||
String.class);
|
||||
assertThat(getResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode node = objectMapper.readTree(getResp.getBody());
|
||||
assertThat(node.path("readAt").isNull())
|
||||
.as("viewer must see readAt as non-null after operator marked read")
|
||||
.isFalse();
|
||||
assertThat(node.path("readAt").isMissingNode())
|
||||
.as("readAt field must be present in response")
|
||||
.isFalse();
|
||||
|
||||
// Viewer's list endpoint must also show the alert with readAt set
|
||||
ResponseEntity<String> listResp = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlugA + "/alerts",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
|
||||
String.class);
|
||||
assertThat(listResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode listBody = objectMapper.readTree(listResp.getBody());
|
||||
UUID instanceId = instance.id();
|
||||
boolean foundWithReadAt = false;
|
||||
for (JsonNode item : listBody) {
|
||||
if (item.path("id").asText().equals(instanceId.toString())) {
|
||||
assertThat(item.path("readAt").isNull())
|
||||
.as("list entry readAt must be non-null for viewer after global read")
|
||||
.isFalse();
|
||||
foundWithReadAt = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertThat(foundWithReadAt).as("alert must appear in viewer's list with readAt set").isTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -192,7 +508,7 @@ class AlertControllerIT extends AbstractPostgresIT {
|
||||
AlertInstance instance = new AlertInstance(
|
||||
UUID.randomUUID(), null, null, envId,
|
||||
AlertState.FIRING, AlertSeverity.WARNING,
|
||||
Instant.now(), null, null, null, null, false,
|
||||
Instant.now(), null, null, null, null, null, null, false,
|
||||
42.0, 1000.0, null, "Test alert", "Something happened",
|
||||
List.of("test-operator"), List.of(), List.of());
|
||||
return instanceRepo.save(instance);
|
||||
|
||||
@@ -175,7 +175,7 @@ class AlertNotificationControllerIT extends AbstractPostgresIT {
|
||||
AlertInstance instance = new AlertInstance(
|
||||
UUID.randomUUID(), null, null, envId,
|
||||
AlertState.FIRING, AlertSeverity.WARNING,
|
||||
Instant.now(), null, null, null, null, false,
|
||||
Instant.now(), null, null, null, null, null, null, false,
|
||||
42.0, 1000.0, null, "Test alert", "Something happened",
|
||||
List.of(), List.of(), List.of("OPERATOR"));
|
||||
return instanceRepo.save(instance);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.cameleer.server.app.alerting.eval;
|
||||
|
||||
import com.cameleer.server.core.agent.AgentEventRecord;
|
||||
import com.cameleer.server.core.agent.AgentEventRepository;
|
||||
import com.cameleer.server.core.alerting.*;
|
||||
import com.cameleer.server.core.runtime.Environment;
|
||||
import com.cameleer.server.core.runtime.EnvironmentRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AgentLifecycleEvaluatorTest {
|
||||
|
||||
private AgentEventRepository events;
|
||||
private EnvironmentRepository envRepo;
|
||||
private AgentLifecycleEvaluator eval;
|
||||
|
||||
private static final UUID ENV_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
private static final UUID RULE_ID = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
private static final String ENV_SLUG = "prod";
|
||||
private static final Instant NOW = Instant.parse("2026-04-19T10:00:00Z");
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
events = mock(AgentEventRepository.class);
|
||||
envRepo = mock(EnvironmentRepository.class);
|
||||
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(
|
||||
new Environment(ENV_ID, ENV_SLUG, "Prod", true, true, Map.of(), 5, Instant.EPOCH)));
|
||||
eval = new AgentLifecycleEvaluator(events, envRepo);
|
||||
}
|
||||
|
||||
private AlertRule ruleWith(AlertCondition condition) {
|
||||
return new AlertRule(RULE_ID, ENV_ID, "lifecycle test", null,
|
||||
AlertSeverity.CRITICAL, true, condition.kind(), condition,
|
||||
60, 0, 0, null, null, List.of(), List.of(),
|
||||
null, null, null, Map.of(), null, null, null, null);
|
||||
}
|
||||
|
||||
private EvalContext ctx() { return new EvalContext("default", NOW, new TickCache()); }
|
||||
|
||||
@Test
|
||||
void kindIsAgentLifecycle() {
|
||||
assertThat(eval.kind()).isEqualTo(ConditionKind.AGENT_LIFECYCLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyWindowYieldsEmptyBatch() {
|
||||
var condition = new AgentLifecycleCondition(
|
||||
new AlertScope(null, null, null),
|
||||
List.of(AgentLifecycleEventType.WENT_DEAD),
|
||||
300);
|
||||
when(events.findInWindow(eq(ENV_SLUG), any(), any(), any(), any(), any(), anyInt()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
EvalResult r = eval.evaluate(condition, ruleWith(condition), ctx());
|
||||
assertThat(r).isInstanceOf(EvalResult.Batch.class);
|
||||
assertThat(((EvalResult.Batch) r).firings()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void emitsOneFiringPerEventWithFingerprint() {
|
||||
Instant ts1 = NOW.minusSeconds(30);
|
||||
Instant ts2 = NOW.minusSeconds(10);
|
||||
when(events.findInWindow(eq(ENV_SLUG), any(), any(), any(), any(), any(), anyInt()))
|
||||
.thenReturn(List.of(
|
||||
new AgentEventRecord(0, "agent-A", "orders", "WENT_DEAD", "A went dead", ts1),
|
||||
new AgentEventRecord(0, "agent-B", "orders", "WENT_DEAD", "B went dead", ts2)
|
||||
));
|
||||
|
||||
var condition = new AgentLifecycleCondition(
|
||||
new AlertScope(null, null, null),
|
||||
List.of(AgentLifecycleEventType.WENT_DEAD), 60);
|
||||
|
||||
EvalResult r = eval.evaluate(condition, ruleWith(condition), ctx());
|
||||
var batch = (EvalResult.Batch) r;
|
||||
assertThat(batch.firings()).hasSize(2);
|
||||
|
||||
var f0 = batch.firings().get(0);
|
||||
assertThat(f0.context()).containsKey("_subjectFingerprint");
|
||||
assertThat((String) f0.context().get("_subjectFingerprint"))
|
||||
.isEqualTo("agent-A:WENT_DEAD:" + ts1.toEpochMilli());
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> agent0 = (Map<String, Object>) f0.context().get("agent");
|
||||
assertThat(agent0).containsEntry("id", "agent-A").containsEntry("app", "orders");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> event0 = (Map<String, Object>) f0.context().get("event");
|
||||
assertThat(event0).containsEntry("type", "WENT_DEAD");
|
||||
|
||||
var f1 = batch.firings().get(1);
|
||||
assertThat((String) f1.context().get("_subjectFingerprint"))
|
||||
.isEqualTo("agent-B:WENT_DEAD:" + ts2.toEpochMilli());
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardsScopeFiltersToRepo() {
|
||||
when(events.findInWindow(eq(ENV_SLUG), eq("orders"), eq("agent-A"), any(), any(), any(), anyInt()))
|
||||
.thenReturn(List.of());
|
||||
var condition = new AgentLifecycleCondition(
|
||||
new AlertScope("orders", null, "agent-A"),
|
||||
List.of(AgentLifecycleEventType.REGISTERED), 120);
|
||||
eval.evaluate(condition, ruleWith(condition), ctx());
|
||||
// Mockito `when` matches — verifying no mismatch is enough; stub returns []
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearsWhenEnvIsMissing() {
|
||||
// envRepo returns empty → should Clear, not throw.
|
||||
EnvironmentRepository emptyEnvRepo = mock(EnvironmentRepository.class);
|
||||
when(emptyEnvRepo.findById(ENV_ID)).thenReturn(Optional.empty());
|
||||
AgentLifecycleEvaluator localEval = new AgentLifecycleEvaluator(events, emptyEnvRepo);
|
||||
|
||||
var condition = new AgentLifecycleCondition(
|
||||
new AlertScope(null, null, null),
|
||||
List.of(AgentLifecycleEventType.WENT_DEAD), 60);
|
||||
EvalResult r = localEval.evaluate(condition, ruleWith(condition), ctx());
|
||||
assertThat(r).isEqualTo(EvalResult.Clear.INSTANCE);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class AlertStateTransitionsTest {
|
||||
return new AlertInstance(
|
||||
UUID.randomUUID(), UUID.randomUUID(), Map.of(), UUID.randomUUID(),
|
||||
state, AlertSeverity.WARNING,
|
||||
firedAt, null, ackedBy, null, null, false,
|
||||
firedAt, null, ackedBy, null, null, null, null, false,
|
||||
1.0, null, Map.of(), "title", "msg",
|
||||
List.of(), List.of(), List.of());
|
||||
}
|
||||
@@ -71,7 +71,8 @@ class AlertStateTransitionsTest {
|
||||
|
||||
@Test
|
||||
void ackedInstanceClearsToResolved() {
|
||||
var acked = openInstance(AlertState.ACKNOWLEDGED, NOW.minusSeconds(30), "alice");
|
||||
var acked = openInstance(AlertState.FIRING, NOW.minusSeconds(30), null)
|
||||
.withAck("alice", Instant.parse("2026-04-19T11:55:00Z"));
|
||||
var next = AlertStateTransitions.apply(acked, EvalResult.Clear.INSTANCE, ruleWith(0), NOW);
|
||||
assertThat(next).hasValueSatisfying(i -> {
|
||||
assertThat(i.state()).isEqualTo(AlertState.RESOLVED);
|
||||
@@ -131,7 +132,7 @@ class AlertStateTransitionsTest {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Firing branch — already open FIRING / ACKNOWLEDGED
|
||||
// Firing branch — already open FIRING (with or without ack)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@@ -142,9 +143,11 @@ class AlertStateTransitionsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void firingWhenAcknowledgedIsNoOp() {
|
||||
var acked = openInstance(AlertState.ACKNOWLEDGED, NOW.minusSeconds(30), "alice");
|
||||
var next = AlertStateTransitions.apply(acked, FIRING_RESULT, ruleWith(0), NOW);
|
||||
void firing_with_ack_stays_firing_on_next_firing_tick() {
|
||||
var current = openInstance(AlertState.FIRING, NOW.minusSeconds(30), null)
|
||||
.withAck("alice", Instant.parse("2026-04-21T10:00:00Z"));
|
||||
var next = AlertStateTransitions.apply(
|
||||
current, new EvalResult.Firing(1.0, null, Map.of()), ruleWith(0), NOW);
|
||||
assertThat(next).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.mockito.ArgumentMatchers;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
@@ -75,13 +77,31 @@ class InAppInboxQueryTest {
|
||||
.thenReturn(List.of(new RoleSummary(roleId, "OPERATOR", true, "direct")));
|
||||
|
||||
when(instanceRepo.listForInbox(eq(ENV_ID), eq(List.of(groupId.toString())),
|
||||
eq(USER_ID), eq(List.of("OPERATOR")), eq(20)))
|
||||
eq(USER_ID), eq(List.of("OPERATOR")), isNull(), isNull(), isNull(), isNull(), eq(20)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
List<AlertInstance> result = query.listInbox(ENV_ID, USER_ID, 20);
|
||||
List<AlertInstance> result = query.listInbox(ENV_ID, USER_ID, null, null, null, null, 20);
|
||||
assertThat(result).isEmpty();
|
||||
verify(instanceRepo).listForInbox(ENV_ID, List.of(groupId.toString()),
|
||||
USER_ID, List.of("OPERATOR"), 20);
|
||||
USER_ID, List.of("OPERATOR"), null, null, null, null, 20);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listInbox_forwardsStateAndSeverityFilters() {
|
||||
when(rbacService.getEffectiveGroupsForUser(USER_ID)).thenReturn(List.of());
|
||||
when(rbacService.getEffectiveRolesForUser(USER_ID)).thenReturn(List.of());
|
||||
|
||||
List<com.cameleer.server.core.alerting.AlertState> states =
|
||||
List.of(com.cameleer.server.core.alerting.AlertState.FIRING);
|
||||
List<AlertSeverity> severities = List.of(AlertSeverity.CRITICAL, AlertSeverity.WARNING);
|
||||
|
||||
when(instanceRepo.listForInbox(eq(ENV_ID), eq(List.of()), eq(USER_ID), eq(List.of()),
|
||||
eq(states), eq(severities), isNull(), isNull(), eq(25)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
query.listInbox(ENV_ID, USER_ID, states, severities, null, null, 25);
|
||||
verify(instanceRepo).listForInbox(ENV_ID, List.of(), USER_ID, List.of(),
|
||||
states, severities, null, null, 25);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -90,7 +110,8 @@ class InAppInboxQueryTest {
|
||||
|
||||
@Test
|
||||
void countUnread_totalIsSumOfBySeverityValues() {
|
||||
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID))
|
||||
when(instanceRepo.countUnreadBySeverity(eq(ENV_ID), eq(USER_ID),
|
||||
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any()))
|
||||
.thenReturn(severities(4L, 2L, 1L));
|
||||
|
||||
UnreadCountResponse response = query.countUnread(ENV_ID, USER_ID);
|
||||
@@ -105,7 +126,8 @@ class InAppInboxQueryTest {
|
||||
@Test
|
||||
void countUnread_fillsMissingSeveritiesWithZero() {
|
||||
// Repository returns only CRITICAL — WARNING/INFO must default to 0.
|
||||
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID))
|
||||
when(instanceRepo.countUnreadBySeverity(eq(ENV_ID), eq(USER_ID),
|
||||
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any()))
|
||||
.thenReturn(Map.of(AlertSeverity.CRITICAL, 3L));
|
||||
|
||||
UnreadCountResponse response = query.countUnread(ENV_ID, USER_ID);
|
||||
@@ -123,7 +145,8 @@ class InAppInboxQueryTest {
|
||||
|
||||
@Test
|
||||
void countUnread_secondCallWithin5sUsesCache() {
|
||||
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID))
|
||||
when(instanceRepo.countUnreadBySeverity(eq(ENV_ID), eq(USER_ID),
|
||||
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any()))
|
||||
.thenReturn(severities(1L, 2L, 2L));
|
||||
|
||||
UnreadCountResponse first = query.countUnread(ENV_ID, USER_ID);
|
||||
@@ -132,12 +155,14 @@ class InAppInboxQueryTest {
|
||||
|
||||
assertThat(first.total()).isEqualTo(5L);
|
||||
assertThat(second.total()).isEqualTo(5L);
|
||||
verify(instanceRepo, times(1)).countUnreadBySeverityForUser(ENV_ID, USER_ID);
|
||||
verify(instanceRepo, times(1)).countUnreadBySeverity(eq(ENV_ID), eq(USER_ID),
|
||||
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void countUnread_callAfter5sRefreshesCache() {
|
||||
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID))
|
||||
when(instanceRepo.countUnreadBySeverity(eq(ENV_ID), eq(USER_ID),
|
||||
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any()))
|
||||
.thenReturn(severities(1L, 1L, 1L)) // first call — total 3
|
||||
.thenReturn(severities(4L, 3L, 2L)); // after TTL — total 9
|
||||
|
||||
@@ -147,29 +172,36 @@ class InAppInboxQueryTest {
|
||||
|
||||
assertThat(first.total()).isEqualTo(3L);
|
||||
assertThat(third.total()).isEqualTo(9L);
|
||||
verify(instanceRepo, times(2)).countUnreadBySeverityForUser(ENV_ID, USER_ID);
|
||||
verify(instanceRepo, times(2)).countUnreadBySeverity(eq(ENV_ID), eq(USER_ID),
|
||||
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void countUnread_differentUsersDontShareCache() {
|
||||
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, "alice"))
|
||||
when(instanceRepo.countUnreadBySeverity(eq(ENV_ID), eq("alice"),
|
||||
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any()))
|
||||
.thenReturn(severities(0L, 1L, 1L));
|
||||
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, "bob"))
|
||||
when(instanceRepo.countUnreadBySeverity(eq(ENV_ID), eq("bob"),
|
||||
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any()))
|
||||
.thenReturn(severities(2L, 2L, 4L));
|
||||
|
||||
assertThat(query.countUnread(ENV_ID, "alice").total()).isEqualTo(2L);
|
||||
assertThat(query.countUnread(ENV_ID, "bob").total()).isEqualTo(8L);
|
||||
verify(instanceRepo).countUnreadBySeverityForUser(ENV_ID, "alice");
|
||||
verify(instanceRepo).countUnreadBySeverityForUser(ENV_ID, "bob");
|
||||
verify(instanceRepo).countUnreadBySeverity(eq(ENV_ID), eq("alice"),
|
||||
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any());
|
||||
verify(instanceRepo).countUnreadBySeverity(eq(ENV_ID), eq("bob"),
|
||||
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void countUnread_differentEnvsDontShareCache() {
|
||||
UUID envA = UUID.randomUUID();
|
||||
UUID envB = UUID.randomUUID();
|
||||
when(instanceRepo.countUnreadBySeverityForUser(envA, USER_ID))
|
||||
when(instanceRepo.countUnreadBySeverity(eq(envA), eq(USER_ID),
|
||||
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any()))
|
||||
.thenReturn(severities(0L, 0L, 1L));
|
||||
when(instanceRepo.countUnreadBySeverityForUser(envB, USER_ID))
|
||||
when(instanceRepo.countUnreadBySeverity(eq(envB), eq(USER_ID),
|
||||
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any()))
|
||||
.thenReturn(severities(1L, 1L, 2L));
|
||||
|
||||
assertThat(query.countUnread(envA, USER_ID).total()).isEqualTo(1L);
|
||||
|
||||
@@ -43,6 +43,10 @@ class NotificationContextBuilderTest {
|
||||
case AGENT_STATE -> new AgentStateCondition(
|
||||
new AlertScope(null, null, null),
|
||||
"DEAD", 0);
|
||||
case AGENT_LIFECYCLE -> new AgentLifecycleCondition(
|
||||
new AlertScope(null, null, null),
|
||||
List.of(AgentLifecycleEventType.WENT_DEAD),
|
||||
60);
|
||||
case DEPLOYMENT_STATE -> new DeploymentStateCondition(
|
||||
new AlertScope("my-app", null, null),
|
||||
List.of("FAILED"));
|
||||
@@ -71,7 +75,7 @@ class NotificationContextBuilderTest {
|
||||
INST_ID, RULE_ID, Map.of(), ENV_ID,
|
||||
AlertState.FIRING, AlertSeverity.CRITICAL,
|
||||
Instant.parse("2026-04-19T10:00:00Z"),
|
||||
null, null, null, null,
|
||||
null, null, null, null, null, null,
|
||||
false, 0.95, 0.1,
|
||||
ctx, "Alert fired", "Some message",
|
||||
List.of(), List.of(), List.of()
|
||||
|
||||
@@ -89,7 +89,7 @@ class NotificationDispatchJobIT extends AbstractPostgresIT {
|
||||
instanceRepo.save(new AlertInstance(
|
||||
instanceId, ruleId, Map.of(), envId,
|
||||
AlertState.FIRING, AlertSeverity.WARNING,
|
||||
Instant.now(), null, null, null, null, false,
|
||||
Instant.now(), null, null, null, null, null, null, false,
|
||||
null, null, Map.of(), "title", "msg",
|
||||
List.of(), List.of(), List.of()));
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class SilenceMatcherServiceTest {
|
||||
return new AlertInstance(
|
||||
INST_ID, RULE_ID, Map.of(), ENV_ID,
|
||||
AlertState.FIRING, AlertSeverity.WARNING,
|
||||
Instant.now(), null, null, null, null,
|
||||
Instant.now(), null, null, null, null, null, null,
|
||||
false, 1.5, 1.0,
|
||||
Map.of(), "title", "msg",
|
||||
List.of(), List.of(), List.of()
|
||||
@@ -85,7 +85,7 @@ class SilenceMatcherServiceTest {
|
||||
var inst = new AlertInstance(
|
||||
INST_ID, null, Map.of(), ENV_ID,
|
||||
AlertState.FIRING, AlertSeverity.WARNING,
|
||||
Instant.now(), null, null, null, null,
|
||||
Instant.now(), null, null, null, null, null, null,
|
||||
false, null, null,
|
||||
Map.of(), "t", "m",
|
||||
List.of(), List.of(), List.of()
|
||||
@@ -99,7 +99,7 @@ class SilenceMatcherServiceTest {
|
||||
var inst = new AlertInstance(
|
||||
INST_ID, null, Map.of(), ENV_ID,
|
||||
AlertState.FIRING, AlertSeverity.WARNING,
|
||||
Instant.now(), null, null, null, null,
|
||||
Instant.now(), null, null, null, null, null, null,
|
||||
false, null, null,
|
||||
Map.of(), "t", "m",
|
||||
List.of(), List.of(), List.of()
|
||||
|
||||
@@ -188,7 +188,7 @@ class WebhookDispatcherIT {
|
||||
return new AlertInstance(
|
||||
UUID.randomUUID(), UUID.randomUUID(), Map.of(),
|
||||
UUID.randomUUID(), AlertState.FIRING, AlertSeverity.WARNING,
|
||||
Instant.now(), null, null, null, null, false,
|
||||
Instant.now(), null, null, null, null, null, null, false,
|
||||
null, null, Map.of(), "Alert", "Message",
|
||||
List.of(), List.of(), List.of());
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
|
||||
private PostgresAlertInstanceRepository repo;
|
||||
private UUID envId;
|
||||
private UUID otherEnvId;
|
||||
private UUID ruleId;
|
||||
private final String userId = "inbox-user-" + UUID.randomUUID();
|
||||
private final String groupId = UUID.randomUUID().toString();
|
||||
@@ -31,11 +32,15 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
void setup() {
|
||||
repo = new PostgresAlertInstanceRepository(jdbcTemplate, new ObjectMapper());
|
||||
envId = UUID.randomUUID();
|
||||
otherEnvId = UUID.randomUUID();
|
||||
ruleId = UUID.randomUUID();
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
|
||||
envId, "test-env-" + UUID.randomUUID(), "Test Env");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
|
||||
otherEnvId, "other-env-" + UUID.randomUUID(), "Other Env");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, email) VALUES (?, 'local', ?) ON CONFLICT (user_id) DO NOTHING",
|
||||
userId, userId + "@example.com");
|
||||
@@ -50,12 +55,16 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
jdbcTemplate.update("DELETE FROM alert_reads WHERE user_id = ?", userId);
|
||||
jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id IN " +
|
||||
"(SELECT id FROM alert_instances WHERE environment_id = ?)", envId);
|
||||
jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id IN " +
|
||||
"(SELECT id FROM alert_instances WHERE environment_id = ?)", otherEnvId);
|
||||
jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id = ?", envId);
|
||||
jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id = ?", otherEnvId);
|
||||
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id = ?", envId);
|
||||
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id = ?", otherEnvId);
|
||||
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
|
||||
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", otherEnvId);
|
||||
jdbcTemplate.update("DELETE FROM users WHERE user_id = ?", userId);
|
||||
}
|
||||
|
||||
@@ -92,7 +101,7 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
repo.save(byRole);
|
||||
|
||||
// User is member of the group AND has the role
|
||||
var inbox = repo.listForInbox(envId, List.of(groupId), userId, List.of(roleName), 50);
|
||||
var inbox = repo.listForInbox(envId, List.of(groupId), userId, List.of(roleName), null, null, null, null, 50);
|
||||
assertThat(inbox).extracting(AlertInstance::id)
|
||||
.containsExactlyInAnyOrder(byUser.id(), byGroup.id(), byRole.id());
|
||||
}
|
||||
@@ -102,33 +111,30 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
var byUser = newInstance(ruleId, List.of(userId), List.of(), List.of());
|
||||
repo.save(byUser);
|
||||
|
||||
var inbox = repo.listForInbox(envId, List.of(), userId, List.of(), 50);
|
||||
var inbox = repo.listForInbox(envId, List.of(), userId, List.of(), null, null, null, null, 50);
|
||||
assertThat(inbox).hasSize(1);
|
||||
assertThat(inbox.get(0).id()).isEqualTo(byUser.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void countUnreadBySeverityForUser_decreasesAfterMarkRead() {
|
||||
void countUnreadBySeverity_decreasesAfterMarkRead() {
|
||||
var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
|
||||
repo.save(inst);
|
||||
|
||||
var before = repo.countUnreadBySeverityForUser(envId, userId);
|
||||
var before = repo.countUnreadBySeverity(envId, userId, List.of(), List.of());
|
||||
assertThat(before)
|
||||
.containsEntry(AlertSeverity.WARNING, 1L)
|
||||
.containsEntry(AlertSeverity.CRITICAL, 0L)
|
||||
.containsEntry(AlertSeverity.INFO, 0L);
|
||||
|
||||
// Insert read record directly (AlertReadRepository not yet wired in this test)
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO alert_reads (user_id, alert_instance_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
|
||||
userId, inst.id());
|
||||
repo.markRead(inst.id(), Instant.now());
|
||||
|
||||
var after = repo.countUnreadBySeverityForUser(envId, userId);
|
||||
var after = repo.countUnreadBySeverity(envId, userId, List.of(), List.of());
|
||||
assertThat(after.values()).allMatch(v -> v == 0L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countUnreadBySeverityForUser_groupsBySeverity() {
|
||||
void countUnreadBySeverity_groupsBySeverity() {
|
||||
// Each open instance needs its own rule to satisfy V13's unique partial index.
|
||||
UUID critRule = seedRuleWithSeverity("crit", AlertSeverity.CRITICAL);
|
||||
UUID warnRule = seedRuleWithSeverity("warn", AlertSeverity.WARNING);
|
||||
@@ -138,7 +144,7 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
repo.save(newInstance(warnRule, AlertSeverity.WARNING, List.of(userId), List.of(), List.of()));
|
||||
repo.save(newInstance(infoRule, AlertSeverity.INFO, List.of(userId), List.of(), List.of()));
|
||||
|
||||
var counts = repo.countUnreadBySeverityForUser(envId, userId);
|
||||
var counts = repo.countUnreadBySeverity(envId, userId, List.of(), List.of());
|
||||
|
||||
assertThat(counts)
|
||||
.containsEntry(AlertSeverity.CRITICAL, 1L)
|
||||
@@ -147,10 +153,10 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
}
|
||||
|
||||
@Test
|
||||
void countUnreadBySeverityForUser_emptyMapStillHasAllKeys() {
|
||||
void countUnreadBySeverity_emptyMapStillHasAllKeys() {
|
||||
// No instances saved — every severity must still be present with value 0
|
||||
// so callers never deal with null/missing keys.
|
||||
var counts = repo.countUnreadBySeverityForUser(envId, userId);
|
||||
var counts = repo.countUnreadBySeverity(envId, userId, List.of(), List.of());
|
||||
assertThat(counts).hasSize(3);
|
||||
assertThat(counts.values()).allMatch(v -> v == 0L);
|
||||
}
|
||||
@@ -168,7 +174,7 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
}
|
||||
|
||||
@Test
|
||||
void ack_setsAckedAtAndState() {
|
||||
void ack_setsAckedAtAndLeavesStateFiring() {
|
||||
var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
|
||||
repo.save(inst);
|
||||
|
||||
@@ -176,7 +182,7 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
repo.ack(inst.id(), userId, when);
|
||||
|
||||
var found = repo.findById(inst.id()).orElseThrow();
|
||||
assertThat(found.state()).isEqualTo(AlertState.ACKNOWLEDGED);
|
||||
assertThat(found.state()).isEqualTo(AlertState.FIRING);
|
||||
assertThat(found.ackedBy()).isEqualTo(userId);
|
||||
assertThat(found.ackedAt()).isNotNull();
|
||||
}
|
||||
@@ -269,7 +275,7 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
|
||||
Long count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM alert_instances " +
|
||||
" WHERE rule_id = ? AND state IN ('PENDING','FIRING','ACKNOWLEDGED')",
|
||||
" WHERE rule_id = ? AND state IN ('PENDING','FIRING')",
|
||||
Long.class, ruleId);
|
||||
assertThat(count).isEqualTo(3L);
|
||||
}
|
||||
@@ -293,7 +299,7 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
|
||||
Long count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM alert_instances " +
|
||||
" WHERE rule_id = ? AND state IN ('PENDING','FIRING','ACKNOWLEDGED')",
|
||||
" WHERE rule_id = ? AND state IN ('PENDING','FIRING')",
|
||||
Long.class, ruleId);
|
||||
assertThat(count).isEqualTo(1L);
|
||||
}
|
||||
@@ -308,8 +314,121 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
assertThat(repo.findById(inst.id()).orElseThrow().silenced()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void markRead_is_idempotent_and_sets_read_at() {
|
||||
var inst = insertFreshFiring();
|
||||
repo.markRead(inst.id(), Instant.parse("2026-04-21T10:00:00Z"));
|
||||
repo.markRead(inst.id(), Instant.parse("2026-04-21T11:00:00Z")); // idempotent — no-op
|
||||
var loaded = repo.findById(inst.id()).orElseThrow();
|
||||
assertThat(loaded.readAt()).isEqualTo(Instant.parse("2026-04-21T10:00:00Z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void softDelete_excludes_from_listForInbox() {
|
||||
var inst = insertFreshFiring();
|
||||
repo.softDelete(inst.id(), Instant.parse("2026-04-21T10:00:00Z"));
|
||||
var rows = repo.listForInbox(envId, List.of(), userId, List.of(),
|
||||
null, null, null, null, 100);
|
||||
assertThat(rows).extracting(AlertInstance::id).doesNotContain(inst.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOpenForRule_returns_acked_firing() {
|
||||
var inst = insertFreshFiring();
|
||||
repo.ack(inst.id(), userId, Instant.parse("2026-04-21T10:00:00Z"));
|
||||
var open = repo.findOpenForRule(inst.ruleId());
|
||||
assertThat(open).isPresent(); // ack no longer closes the open slot — state stays FIRING
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOpenForRule_skips_soft_deleted() {
|
||||
var inst = insertFreshFiring();
|
||||
repo.softDelete(inst.id(), Instant.now());
|
||||
assertThat(repo.findOpenForRule(inst.ruleId())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulk_ack_only_touches_unacked_rows() {
|
||||
var a = insertFreshFiring();
|
||||
var b = insertFreshFiring();
|
||||
// ack 'a' first with userId; bulkAck should leave 'a' untouched (already acked)
|
||||
repo.ack(a.id(), userId, Instant.parse("2026-04-21T09:00:00Z"));
|
||||
repo.bulkAck(List.of(a.id(), b.id()), userId, Instant.parse("2026-04-21T10:00:00Z"));
|
||||
// a was already acked — acked_at stays at the first timestamp, not updated again
|
||||
assertThat(repo.findById(a.id()).orElseThrow().ackedBy()).isEqualTo(userId);
|
||||
assertThat(repo.findById(b.id()).orElseThrow().ackedBy()).isEqualTo(userId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listForInbox_acked_false_hides_acked_rows() {
|
||||
var a = insertFreshFiring();
|
||||
var b = insertFreshFiring();
|
||||
repo.ack(a.id(), userId, Instant.now());
|
||||
var rows = repo.listForInbox(envId, List.of(), userId, List.of(),
|
||||
null, null, /*acked*/ false, null, 100);
|
||||
assertThat(rows).extracting(AlertInstance::id).doesNotContain(a.id());
|
||||
assertThat(rows).extracting(AlertInstance::id).contains(b.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restore_clears_deleted_at() {
|
||||
var inst = insertFreshFiring();
|
||||
repo.softDelete(inst.id(), Instant.now());
|
||||
repo.restore(inst.id());
|
||||
var loaded = repo.findById(inst.id()).orElseThrow();
|
||||
assertThat(loaded.deletedAt()).isNull();
|
||||
var rows = repo.listForInbox(envId, List.of(), userId, List.of(),
|
||||
null, null, null, null, 100);
|
||||
assertThat(rows).extracting(AlertInstance::id).contains(inst.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void filterInEnvLive_excludes_other_env_and_soft_deleted() {
|
||||
var a = insertFreshFiring(); // env envId, live
|
||||
var b = insertFreshFiring(); // env envId, will be soft-deleted
|
||||
repo.softDelete(b.id(), Instant.now());
|
||||
|
||||
UUID unknownId = UUID.randomUUID(); // not in DB at all
|
||||
|
||||
// Insert a rule + instance in the second environment (otherEnvId) to prove
|
||||
// that the SQL env-filter actually excludes rows from a different environment.
|
||||
UUID otherRuleId = seedRuleInEnv("other-rule", otherEnvId);
|
||||
var otherEnvInst = newInstanceInEnv(otherRuleId, otherEnvId, List.of(userId), List.of(), List.of());
|
||||
repo.save(otherEnvInst);
|
||||
|
||||
var kept = repo.filterInEnvLive(List.of(a.id(), b.id(), unknownId, otherEnvInst.id()), envId);
|
||||
assertThat(kept).containsExactly(a.id());
|
||||
assertThat(kept).doesNotContain(otherEnvInst.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkMarkRead_respects_deleted_at() {
|
||||
var live = insertFreshFiring();
|
||||
// second instance — need a fresh ruleId due to the open-rule unique index
|
||||
UUID ruleId2 = seedRule("rule-deleted");
|
||||
var deleted = newInstance(ruleId2, List.of(userId), List.of(), List.of());
|
||||
repo.save(deleted);
|
||||
|
||||
repo.softDelete(deleted.id(), Instant.parse("2026-04-21T10:00:00Z"));
|
||||
|
||||
repo.bulkMarkRead(List.of(live.id(), deleted.id()), Instant.parse("2026-04-21T10:05:00Z"));
|
||||
|
||||
// live row is marked read
|
||||
assertThat(repo.findById(live.id()).orElseThrow().readAt())
|
||||
.isEqualTo(Instant.parse("2026-04-21T10:05:00Z"));
|
||||
// soft-deleted row is NOT touched by bulkMarkRead
|
||||
assertThat(repo.findById(deleted.id()).orElseThrow().readAt()).isNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Creates and saves a fresh FIRING instance targeted at the test userId with its own rule. */
|
||||
private AlertInstance insertFreshFiring() {
|
||||
UUID freshRuleId = seedRule("fresh-rule");
|
||||
var inst = newInstance(freshRuleId, List.of(userId), List.of(), List.of());
|
||||
return repo.save(inst);
|
||||
}
|
||||
|
||||
private AlertInstance newInstance(UUID ruleId,
|
||||
List<String> userIds,
|
||||
List<UUID> groupIds,
|
||||
@@ -325,7 +444,7 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
return new AlertInstance(
|
||||
UUID.randomUUID(), ruleId, Map.of(), envId,
|
||||
AlertState.FIRING, severity,
|
||||
Instant.now(), null, null, null, null,
|
||||
Instant.now(), null, null, null, null, null, null,
|
||||
false, null, null,
|
||||
Map.of(), "title", "message",
|
||||
userIds, groupIds, roleNames);
|
||||
@@ -341,7 +460,7 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
return new AlertInstance(
|
||||
UUID.randomUUID(), ruleId, Map.of(), envId,
|
||||
AlertState.FIRING, AlertSeverity.WARNING,
|
||||
Instant.now(), null, null, null, null,
|
||||
Instant.now(), null, null, null, null, null, null,
|
||||
false, null, null,
|
||||
Map.of("exchange", Map.of("id", exchangeId)),
|
||||
"title", "message",
|
||||
@@ -370,6 +489,32 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Inserts a minimal alert_rule in a specific environment and returns its id. */
|
||||
private UUID seedRuleInEnv(String name, UUID targetEnvId) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO alert_rules (id, environment_id, name, severity, condition_kind, condition, " +
|
||||
"notification_title_tmpl, notification_message_tmpl, created_by, updated_by) " +
|
||||
"VALUES (?, ?, ?, 'WARNING', 'AGENT_STATE', '{}'::jsonb, 't', 'm', 'sys-user', 'sys-user')",
|
||||
id, targetEnvId, name + "-" + id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Creates an AlertInstance bound to a specific environment (not the default envId). */
|
||||
private AlertInstance newInstanceInEnv(UUID ruleId,
|
||||
UUID targetEnvId,
|
||||
List<String> userIds,
|
||||
List<UUID> groupIds,
|
||||
List<String> roleNames) {
|
||||
return new AlertInstance(
|
||||
UUID.randomUUID(), ruleId, Map.of(), targetEnvId,
|
||||
AlertState.FIRING, AlertSeverity.WARNING,
|
||||
Instant.now(), null, null, null, null, null, null,
|
||||
false, null, null,
|
||||
Map.of(), "title", "message",
|
||||
userIds, groupIds, roleNames);
|
||||
}
|
||||
|
||||
/** Inserts a minimal alert_rule with re_notify_minutes=1 and returns its id. */
|
||||
private UUID seedReNotifyRule(String name) {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
package com.cameleer.server.app.alerting.storage;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.app.search.ClickHouseLogStore;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
class PostgresAlertReadRepositoryIT extends AbstractPostgresIT {
|
||||
|
||||
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
|
||||
|
||||
private PostgresAlertReadRepository repo;
|
||||
private UUID envId;
|
||||
private UUID instanceId1;
|
||||
private UUID instanceId2;
|
||||
private UUID instanceId3;
|
||||
private final String userId = "read-user-" + UUID.randomUUID();
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
repo = new PostgresAlertReadRepository(jdbcTemplate);
|
||||
envId = UUID.randomUUID();
|
||||
instanceId1 = UUID.randomUUID();
|
||||
instanceId2 = UUID.randomUUID();
|
||||
instanceId3 = UUID.randomUUID();
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
|
||||
envId, "test-env-" + UUID.randomUUID(), "Test Env");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, email) VALUES ('sys-user', 'local', 'sys@example.com') ON CONFLICT (user_id) DO NOTHING");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, email) VALUES (?, 'local', ?) ON CONFLICT (user_id) DO NOTHING",
|
||||
userId, userId + "@example.com");
|
||||
|
||||
// Each open alert_instance needs its own rule_id — the alert_instances_open_rule_uq
|
||||
// partial unique forbids multiple open instances sharing the same rule_id + exchange
|
||||
// discriminator (V13/V15). Three separate rules let all three instances coexist
|
||||
// in FIRING state so alert_reads tests can target each one independently.
|
||||
for (UUID instanceId : List.of(instanceId1, instanceId2, instanceId3)) {
|
||||
UUID ruleId = UUID.randomUUID();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO alert_rules (id, environment_id, name, severity, condition_kind, condition, " +
|
||||
"notification_title_tmpl, notification_message_tmpl, created_by, updated_by) " +
|
||||
"VALUES (?, ?, ?, 'WARNING', 'AGENT_STATE', '{}'::jsonb, 't', 'm', 'sys-user', 'sys-user')",
|
||||
ruleId, envId, "rule-" + instanceId);
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO alert_instances (id, rule_id, rule_snapshot, environment_id, state, severity, " +
|
||||
"fired_at, context, title, message) VALUES (?, ?, '{}'::jsonb, ?, 'FIRING', 'WARNING', " +
|
||||
"now(), '{}'::jsonb, 'title', 'msg')",
|
||||
instanceId, ruleId, envId);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
jdbcTemplate.update("DELETE FROM alert_reads WHERE user_id = ?", userId);
|
||||
jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id = ?", envId);
|
||||
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id = ?", envId);
|
||||
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
|
||||
jdbcTemplate.update("DELETE FROM users WHERE user_id = ?", userId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void markRead_insertsReadRecord() {
|
||||
repo.markRead(userId, instanceId1);
|
||||
|
||||
int count = jdbcTemplate.queryForObject(
|
||||
"SELECT count(*) FROM alert_reads WHERE user_id = ? AND alert_instance_id = ?",
|
||||
Integer.class, userId, instanceId1);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void markRead_isIdempotent() {
|
||||
repo.markRead(userId, instanceId1);
|
||||
// second call should not throw
|
||||
assertThatCode(() -> repo.markRead(userId, instanceId1)).doesNotThrowAnyException();
|
||||
|
||||
int count = jdbcTemplate.queryForObject(
|
||||
"SELECT count(*) FROM alert_reads WHERE user_id = ? AND alert_instance_id = ?",
|
||||
Integer.class, userId, instanceId1);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkMarkRead_marksMultiple() {
|
||||
repo.bulkMarkRead(userId, List.of(instanceId1, instanceId2, instanceId3));
|
||||
|
||||
int count = jdbcTemplate.queryForObject(
|
||||
"SELECT count(*) FROM alert_reads WHERE user_id = ?",
|
||||
Integer.class, userId);
|
||||
assertThat(count).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkMarkRead_emptyListDoesNotThrow() {
|
||||
assertThatCode(() -> repo.bulkMarkRead(userId, List.of())).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkMarkRead_isIdempotent() {
|
||||
repo.bulkMarkRead(userId, List.of(instanceId1, instanceId2));
|
||||
assertThatCode(() -> repo.bulkMarkRead(userId, List.of(instanceId1, instanceId2)))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
int count = jdbcTemplate.queryForObject(
|
||||
"SELECT count(*) FROM alert_reads WHERE user_id = ?",
|
||||
Integer.class, userId);
|
||||
assertThat(count).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
@@ -22,14 +22,15 @@ class V12MigrationIT extends AbstractPostgresIT {
|
||||
|
||||
@Test
|
||||
void allAlertingTablesAndEnumsExist() {
|
||||
// Note: alert_reads was created in V12 but dropped by V17 (superseded by read_at column).
|
||||
var tables = jdbcTemplate.queryForList(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_schema='public' " +
|
||||
"AND table_name IN ('alert_rules','alert_rule_targets','alert_instances'," +
|
||||
"'alert_silences','alert_notifications','alert_reads')",
|
||||
"'alert_silences','alert_notifications')",
|
||||
String.class);
|
||||
assertThat(tables).containsExactlyInAnyOrder(
|
||||
"alert_rules","alert_rule_targets","alert_instances",
|
||||
"alert_silences","alert_notifications","alert_reads");
|
||||
"alert_silences","alert_notifications");
|
||||
|
||||
var enums = jdbcTemplate.queryForList(
|
||||
"SELECT typname FROM pg_type WHERE typname IN " +
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.cameleer.server.app.alerting.storage;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class V17MigrationIT extends AbstractPostgresIT {
|
||||
|
||||
@Test
|
||||
void alert_state_enum_drops_acknowledged() {
|
||||
var values = jdbcTemplate.queryForList("""
|
||||
SELECT unnest(enum_range(NULL::alert_state_enum))::text AS v
|
||||
""", String.class);
|
||||
assertThat(values).containsExactlyInAnyOrder("PENDING", "FIRING", "RESOLVED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void read_at_and_deleted_at_columns_exist() {
|
||||
var cols = jdbcTemplate.queryForList("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'alert_instances'
|
||||
AND column_name IN ('read_at','deleted_at')
|
||||
""", String.class);
|
||||
assertThat(cols).containsExactlyInAnyOrder("read_at", "deleted_at");
|
||||
}
|
||||
|
||||
@Test
|
||||
void alert_reads_table_is_gone() {
|
||||
Integer count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)::int FROM information_schema.tables
|
||||
WHERE table_name = 'alert_reads'
|
||||
""", Integer.class);
|
||||
assertThat(count).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void open_rule_index_exists_and_is_unique() {
|
||||
// Structural check only — the pg_get_indexdef pretty-printer varies across
|
||||
// Postgres versions. Predicate semantics (ack doesn't close; soft-delete
|
||||
// frees the slot; RESOLVED excluded) are covered behaviorally by
|
||||
// PostgresAlertInstanceRepositoryIT#findOpenForRule_* and
|
||||
// #save_rejectsSecondOpenInstanceForSameRuleAndExchange.
|
||||
Integer count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)::int FROM pg_indexes
|
||||
WHERE indexname = 'alert_instances_open_rule_uq'
|
||||
AND tablename = 'alert_instances'
|
||||
""", Integer.class);
|
||||
assertThat(count).isEqualTo(1);
|
||||
|
||||
Boolean isUnique = jdbcTemplate.queryForObject("""
|
||||
SELECT indisunique FROM pg_index
|
||||
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
|
||||
WHERE pg_class.relname = 'alert_instances_open_rule_uq'
|
||||
""", Boolean.class);
|
||||
assertThat(isUnique).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cameleer.server.core.agent;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public interface AgentEventRepository {
|
||||
|
||||
@@ -13,4 +14,19 @@ public interface AgentEventRepository {
|
||||
*/
|
||||
AgentEventPage queryPage(String applicationId, String instanceId, String environment,
|
||||
Instant from, Instant to, String cursor, int limit);
|
||||
|
||||
/**
|
||||
* Inclusive-exclusive window query ordered by (timestamp ASC, instance_id ASC)
|
||||
* used by the AGENT_LIFECYCLE alert evaluator. {@code eventTypes} is required
|
||||
* and must be non-empty; the implementation filters via {@code event_type IN (...)}.
|
||||
* Scope filters ({@code applicationId}, {@code instanceId}) are optional. The
|
||||
* returned list is capped at {@code limit} rows.
|
||||
*/
|
||||
List<AgentEventRecord> findInWindow(String environment,
|
||||
String applicationId,
|
||||
String instanceId,
|
||||
List<String> eventTypes,
|
||||
Instant fromInclusive,
|
||||
Instant toExclusive,
|
||||
int limit);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.cameleer.server.core.alerting;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Fires one {@code AlertInstance} per matching {@code agent_events} row in the
|
||||
* lookback window. Per-subject fire mode (see
|
||||
* {@link AgentLifecycleEventType}) — each {@code (agent, eventType, timestamp)}
|
||||
* tuple is independently ackable, driven by a canonical
|
||||
* {@code _subjectFingerprint} in the instance context and the partial unique
|
||||
* index on {@code alert_instances}.
|
||||
*/
|
||||
public record AgentLifecycleCondition(
|
||||
AlertScope scope,
|
||||
List<AgentLifecycleEventType> eventTypes,
|
||||
int withinSeconds
|
||||
) implements AlertCondition {
|
||||
|
||||
public AgentLifecycleCondition {
|
||||
if (eventTypes == null || eventTypes.isEmpty()) {
|
||||
throw new IllegalArgumentException("eventTypes must not be empty");
|
||||
}
|
||||
if (withinSeconds < 1) {
|
||||
throw new IllegalArgumentException("withinSeconds must be >= 1");
|
||||
}
|
||||
eventTypes = List.copyOf(eventTypes);
|
||||
}
|
||||
|
||||
@Override
|
||||
@JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY)
|
||||
public ConditionKind kind() { return ConditionKind.AGENT_LIFECYCLE; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cameleer.server.core.alerting;
|
||||
|
||||
/**
|
||||
* Allowlist of agent-lifecycle event types that may appear in an
|
||||
* {@link AgentLifecycleCondition}. The set matches exactly the events the
|
||||
* server writes to {@code agent_events} — registration-controller emits
|
||||
* REGISTERED / RE_REGISTERED / DEREGISTERED, the lifecycle monitor emits
|
||||
* WENT_STALE / WENT_DEAD / RECOVERED.
|
||||
* <p>
|
||||
* Custom agent-emitted event types (via {@code POST /api/v1/data/events})
|
||||
* are intentionally excluded — see backlog issue #145.
|
||||
*/
|
||||
public enum AgentLifecycleEventType {
|
||||
REGISTERED,
|
||||
RE_REGISTERED,
|
||||
DEREGISTERED,
|
||||
WENT_STALE,
|
||||
WENT_DEAD,
|
||||
RECOVERED
|
||||
}
|
||||
@@ -9,13 +9,15 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
@JsonSubTypes.Type(value = RouteMetricCondition.class, name = "ROUTE_METRIC"),
|
||||
@JsonSubTypes.Type(value = ExchangeMatchCondition.class, name = "EXCHANGE_MATCH"),
|
||||
@JsonSubTypes.Type(value = AgentStateCondition.class, name = "AGENT_STATE"),
|
||||
@JsonSubTypes.Type(value = AgentLifecycleCondition.class, name = "AGENT_LIFECYCLE"),
|
||||
@JsonSubTypes.Type(value = DeploymentStateCondition.class, name = "DEPLOYMENT_STATE"),
|
||||
@JsonSubTypes.Type(value = LogPatternCondition.class, name = "LOG_PATTERN"),
|
||||
@JsonSubTypes.Type(value = JvmMetricCondition.class, name = "JVM_METRIC")
|
||||
})
|
||||
public sealed interface AlertCondition permits
|
||||
RouteMetricCondition, ExchangeMatchCondition, AgentStateCondition,
|
||||
DeploymentStateCondition, LogPatternCondition, JvmMetricCondition {
|
||||
AgentLifecycleCondition, DeploymentStateCondition, LogPatternCondition,
|
||||
JvmMetricCondition {
|
||||
|
||||
@JsonProperty("kind")
|
||||
ConditionKind kind();
|
||||
|
||||
@@ -17,6 +17,8 @@ public record AlertInstance(
|
||||
String ackedBy,
|
||||
Instant resolvedAt,
|
||||
Instant lastNotifiedAt,
|
||||
Instant readAt, // NEW — global "someone has seen this"
|
||||
Instant deletedAt, // NEW — soft delete
|
||||
boolean silenced,
|
||||
Double currentValue,
|
||||
Double threshold,
|
||||
@@ -39,63 +41,77 @@ public record AlertInstance(
|
||||
|
||||
public AlertInstance withState(AlertState s) {
|
||||
return new AlertInstance(id, ruleId, ruleSnapshot, environmentId,
|
||||
s, severity, firedAt, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, silenced,
|
||||
s, severity, firedAt, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, readAt, deletedAt, silenced,
|
||||
currentValue, threshold, context, title, message,
|
||||
targetUserIds, targetGroupIds, targetRoleNames);
|
||||
}
|
||||
|
||||
public AlertInstance withFiredAt(Instant i) {
|
||||
return new AlertInstance(id, ruleId, ruleSnapshot, environmentId,
|
||||
state, severity, i, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, silenced,
|
||||
state, severity, i, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, readAt, deletedAt, silenced,
|
||||
currentValue, threshold, context, title, message,
|
||||
targetUserIds, targetGroupIds, targetRoleNames);
|
||||
}
|
||||
|
||||
public AlertInstance withResolvedAt(Instant i) {
|
||||
return new AlertInstance(id, ruleId, ruleSnapshot, environmentId,
|
||||
state, severity, firedAt, ackedAt, ackedBy, i, lastNotifiedAt, silenced,
|
||||
state, severity, firedAt, ackedAt, ackedBy, i, lastNotifiedAt, readAt, deletedAt, silenced,
|
||||
currentValue, threshold, context, title, message,
|
||||
targetUserIds, targetGroupIds, targetRoleNames);
|
||||
}
|
||||
|
||||
public AlertInstance withAck(String ackedBy, Instant ackedAt) {
|
||||
return new AlertInstance(id, ruleId, ruleSnapshot, environmentId,
|
||||
state, severity, firedAt, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, silenced,
|
||||
state, severity, firedAt, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, readAt, deletedAt, silenced,
|
||||
currentValue, threshold, context, title, message,
|
||||
targetUserIds, targetGroupIds, targetRoleNames);
|
||||
}
|
||||
|
||||
public AlertInstance withSilenced(boolean silenced) {
|
||||
return new AlertInstance(id, ruleId, ruleSnapshot, environmentId,
|
||||
state, severity, firedAt, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, silenced,
|
||||
state, severity, firedAt, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, readAt, deletedAt, silenced,
|
||||
currentValue, threshold, context, title, message,
|
||||
targetUserIds, targetGroupIds, targetRoleNames);
|
||||
}
|
||||
|
||||
public AlertInstance withTitleMessage(String title, String message) {
|
||||
return new AlertInstance(id, ruleId, ruleSnapshot, environmentId,
|
||||
state, severity, firedAt, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, silenced,
|
||||
state, severity, firedAt, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, readAt, deletedAt, silenced,
|
||||
currentValue, threshold, context, title, message,
|
||||
targetUserIds, targetGroupIds, targetRoleNames);
|
||||
}
|
||||
|
||||
public AlertInstance withLastNotifiedAt(Instant instant) {
|
||||
return new AlertInstance(id, ruleId, ruleSnapshot, environmentId,
|
||||
state, severity, firedAt, ackedAt, ackedBy, resolvedAt, instant, silenced,
|
||||
state, severity, firedAt, ackedAt, ackedBy, resolvedAt, instant, readAt, deletedAt, silenced,
|
||||
currentValue, threshold, context, title, message,
|
||||
targetUserIds, targetGroupIds, targetRoleNames);
|
||||
}
|
||||
|
||||
public AlertInstance withContext(Map<String, Object> context) {
|
||||
return new AlertInstance(id, ruleId, ruleSnapshot, environmentId,
|
||||
state, severity, firedAt, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, silenced,
|
||||
state, severity, firedAt, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, readAt, deletedAt, silenced,
|
||||
currentValue, threshold, context, title, message,
|
||||
targetUserIds, targetGroupIds, targetRoleNames);
|
||||
}
|
||||
|
||||
public AlertInstance withRuleSnapshot(Map<String, Object> snapshot) {
|
||||
return new AlertInstance(id, ruleId, snapshot, environmentId,
|
||||
state, severity, firedAt, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, silenced,
|
||||
state, severity, firedAt, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, readAt, deletedAt, silenced,
|
||||
currentValue, threshold, context, title, message,
|
||||
targetUserIds, targetGroupIds, targetRoleNames);
|
||||
}
|
||||
|
||||
public AlertInstance withReadAt(Instant i) {
|
||||
return new AlertInstance(id, ruleId, ruleSnapshot, environmentId,
|
||||
state, severity, firedAt, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, i, deletedAt, silenced,
|
||||
currentValue, threshold, context, title, message,
|
||||
targetUserIds, targetGroupIds, targetRoleNames);
|
||||
}
|
||||
|
||||
public AlertInstance withDeletedAt(Instant i) {
|
||||
return new AlertInstance(id, ruleId, ruleSnapshot, environmentId,
|
||||
state, severity, firedAt, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, readAt, i, silenced,
|
||||
currentValue, threshold, context, title, message,
|
||||
targetUserIds, targetGroupIds, targetRoleNames);
|
||||
}
|
||||
|
||||
@@ -7,27 +7,76 @@ import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface AlertInstanceRepository {
|
||||
AlertInstance save(AlertInstance instance); // upsert by id
|
||||
AlertInstance save(AlertInstance instance);
|
||||
Optional<AlertInstance> findById(UUID id);
|
||||
Optional<AlertInstance> findOpenForRule(UUID ruleId); // state IN ('PENDING','FIRING','ACKNOWLEDGED')
|
||||
|
||||
/** Open instance for a rule: state IN ('PENDING','FIRING') AND deleted_at IS NULL. */
|
||||
Optional<AlertInstance> findOpenForRule(UUID ruleId);
|
||||
|
||||
/** Unfiltered inbox listing — convenience overload. */
|
||||
default List<AlertInstance> listForInbox(UUID environmentId,
|
||||
List<String> userGroupIdFilter,
|
||||
String userId,
|
||||
List<String> userRoleNames,
|
||||
int limit) {
|
||||
return listForInbox(environmentId, userGroupIdFilter, userId, userRoleNames,
|
||||
null, null, null, null, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbox listing with optional filters. {@code null} or empty lists mean no filter.
|
||||
* {@code acked} and {@code read} are tri-state: {@code null} = no filter,
|
||||
* {@code TRUE} = only acked/read, {@code FALSE} = only unacked/unread.
|
||||
* Always excludes soft-deleted rows ({@code deleted_at IS NOT NULL}).
|
||||
*/
|
||||
List<AlertInstance> listForInbox(UUID environmentId,
|
||||
List<String> userGroupIdFilter,
|
||||
String userId,
|
||||
List<String> userRoleNames,
|
||||
List<AlertState> states,
|
||||
List<AlertSeverity> severities,
|
||||
Boolean acked,
|
||||
Boolean read,
|
||||
int limit);
|
||||
|
||||
/**
|
||||
* Count unread alert instances for the user, grouped by severity.
|
||||
* <p>
|
||||
* Count unread alert instances visible to the user, grouped by severity.
|
||||
* Visibility: targets user directly, or via one of the given groups/roles.
|
||||
* "Unread" = {@code read_at IS NULL AND deleted_at IS NULL}.
|
||||
* Always returns a map with an entry for every {@link AlertSeverity} (value 0 if no rows),
|
||||
* so callers never need null-checks. Total unread count is the sum of the values.
|
||||
* so callers never need null-checks.
|
||||
*/
|
||||
Map<AlertSeverity, Long> countUnreadBySeverityForUser(UUID environmentId, String userId);
|
||||
Map<AlertSeverity, Long> countUnreadBySeverity(UUID environmentId,
|
||||
String userId,
|
||||
List<String> groupIds,
|
||||
List<String> roleNames);
|
||||
|
||||
void ack(UUID id, String userId, Instant when);
|
||||
void resolve(UUID id, Instant when);
|
||||
void markSilenced(UUID id, boolean silenced);
|
||||
void deleteResolvedBefore(Instant cutoff);
|
||||
|
||||
/** FIRING instances whose reNotify cadence has elapsed since last notification. */
|
||||
/** Set {@code read_at = when} if currently null. Idempotent. */
|
||||
void markRead(UUID id, Instant when);
|
||||
/** Bulk variant — single UPDATE. */
|
||||
void bulkMarkRead(List<UUID> ids, Instant when);
|
||||
|
||||
/** Set {@code deleted_at = when} if currently null. Idempotent. */
|
||||
void softDelete(UUID id, Instant when);
|
||||
/** Bulk variant — single UPDATE. */
|
||||
void bulkSoftDelete(List<UUID> ids, Instant when);
|
||||
|
||||
/** Clear {@code deleted_at}. Undo for soft-delete. Idempotent. */
|
||||
void restore(UUID id);
|
||||
|
||||
/** Bulk ack — single UPDATE. Each row gets {@code acked_at=when, acked_by=userId} if unacked. */
|
||||
void bulkAck(List<UUID> ids, String userId, Instant when);
|
||||
|
||||
List<AlertInstance> listFiringDueForReNotify(Instant now);
|
||||
|
||||
/**
|
||||
* Filter the given IDs to those that exist in the given environment and are not
|
||||
* soft-deleted. Single SQL round-trip — avoids N+1 in bulk operations.
|
||||
*/
|
||||
List<UUID> filterInEnvLive(List<UUID> ids, UUID environmentId);
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.cameleer.server.core.alerting;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface AlertReadRepository {
|
||||
void markRead(String userId, UUID alertInstanceId);
|
||||
void bulkMarkRead(String userId, List<UUID> alertInstanceIds);
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
package com.cameleer.server.core.alerting;
|
||||
|
||||
public enum AlertState { PENDING, FIRING, ACKNOWLEDGED, RESOLVED }
|
||||
public enum AlertState { PENDING, FIRING, RESOLVED }
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
package com.cameleer.server.core.alerting;
|
||||
|
||||
public enum ConditionKind { ROUTE_METRIC, EXCHANGE_MATCH, AGENT_STATE, DEPLOYMENT_STATE, LOG_PATTERN, JVM_METRIC }
|
||||
public enum ConditionKind {
|
||||
ROUTE_METRIC,
|
||||
EXCHANGE_MATCH,
|
||||
AGENT_STATE,
|
||||
AGENT_LIFECYCLE,
|
||||
DEPLOYMENT_STATE,
|
||||
LOG_PATTERN,
|
||||
JVM_METRIC
|
||||
}
|
||||
|
||||
@@ -101,4 +101,50 @@ class AlertConditionJsonTest {
|
||||
AlertCondition parsed = om.readValue(om.writeValueAsString((AlertCondition) c), AlertCondition.class);
|
||||
assertThat(parsed).isInstanceOf(JvmMetricCondition.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundtripAgentLifecycle() throws Exception {
|
||||
var c = new AgentLifecycleCondition(
|
||||
new AlertScope("orders", null, null),
|
||||
List.of(AgentLifecycleEventType.WENT_DEAD, AgentLifecycleEventType.DEREGISTERED),
|
||||
300);
|
||||
AlertCondition parsed = om.readValue(om.writeValueAsString((AlertCondition) c), AlertCondition.class);
|
||||
assertThat(parsed).isInstanceOf(AgentLifecycleCondition.class);
|
||||
var alc = (AgentLifecycleCondition) parsed;
|
||||
assertThat(alc.eventTypes()).containsExactly(
|
||||
AgentLifecycleEventType.WENT_DEAD, AgentLifecycleEventType.DEREGISTERED);
|
||||
assertThat(alc.withinSeconds()).isEqualTo(300);
|
||||
assertThat(alc.kind()).isEqualTo(ConditionKind.AGENT_LIFECYCLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void agentLifecycleRejectsEmptyEventTypes() {
|
||||
assertThatThrownBy(() -> new AgentLifecycleCondition(
|
||||
new AlertScope(null, null, null), List.of(), 60))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("eventTypes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void agentLifecycleRejectsZeroWindow() {
|
||||
assertThatThrownBy(() -> new AgentLifecycleCondition(
|
||||
new AlertScope(null, null, null),
|
||||
List.of(AgentLifecycleEventType.WENT_DEAD), 0))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("withinSeconds");
|
||||
}
|
||||
|
||||
@Test
|
||||
void agentLifecycleRejectsUnknownEventTypeOnDeserialization() {
|
||||
String json = """
|
||||
{
|
||||
"kind": "AGENT_LIFECYCLE",
|
||||
"scope": {},
|
||||
"eventTypes": ["REGISTERED", "BOGUS_EVENT"],
|
||||
"withinSeconds": 60
|
||||
}
|
||||
""";
|
||||
assertThatThrownBy(() -> om.readValue(json, AlertCondition.class))
|
||||
.hasMessageContaining("BOGUS_EVENT");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ class AlertScopeTest {
|
||||
assertThat(AlertSeverity.values()).containsExactly(
|
||||
AlertSeverity.CRITICAL, AlertSeverity.WARNING, AlertSeverity.INFO);
|
||||
assertThat(AlertState.values()).containsExactly(
|
||||
AlertState.PENDING, AlertState.FIRING, AlertState.ACKNOWLEDGED, AlertState.RESOLVED);
|
||||
assertThat(ConditionKind.values()).hasSize(6);
|
||||
AlertState.PENDING, AlertState.FIRING, AlertState.RESOLVED);
|
||||
assertThat(ConditionKind.values()).hasSize(7);
|
||||
assertThat(TargetKind.values()).containsExactly(
|
||||
TargetKind.USER, TargetKind.GROUP, TargetKind.ROLE);
|
||||
assertThat(NotificationStatus.values()).containsExactly(
|
||||
|
||||
1906
docs/superpowers/plans/2026-04-21-alerts-design-system-alignment.md
Normal file
1906
docs/superpowers/plans/2026-04-21-alerts-design-system-alignment.md
Normal file
File diff suppressed because it is too large
Load Diff
1615
docs/superpowers/plans/2026-04-21-alerts-inbox-redesign.md
Normal file
1615
docs/superpowers/plans/2026-04-21-alerts-inbox-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
# Alerts pages — design-system alignment
|
||||
|
||||
**Status:** Approved (2026-04-21)
|
||||
**Scope:** All pages and helper components under `/alerts` in `ui/src/pages/Alerts/` plus `ui/src/components/NotificationBell.tsx` (audit only — already on DS).
|
||||
**Non-goals:** Backend changes, DS package changes, alert semantics, `MustacheEditor` restyling.
|
||||
|
||||
## Problem
|
||||
|
||||
Pages under `/alerts` don't fully adhere to the `@cameleer/design-system` styling and component conventions used by the rest of the SPA (Admin, Audit, Apps, Runtime). Concretely:
|
||||
|
||||
1. **Undefined CSS variables.** `alerts-page.module.css` and `wizard.module.css` use tokens (`--bg`, `--fg`, `--muted`, `--accent`) that are **not** defined by the DS (verified against `@cameleer/design-system/dist/style.css`). These fall back to browser defaults and do not theme correctly in dark mode. (Note: `--border` and `--amber-bg` **are** valid DS tokens, but `--border-subtle` is the convention used by the rest of the app for card chrome.)
|
||||
2. **Raw HTML where DS components exist.** Raw `<table>` (RulesList, Silences), raw `<select>` (RulesList promote), custom centered-div empty states, custom "promote banner" div.
|
||||
3. **Inconsistent page layout.** Toolbars built ad-hoc with inline styles. Admin / Audit pages use a consistent `SectionHeader + sectionStyles.section / tableStyles.tableSection` shell.
|
||||
4. **Native `confirm()`** instead of DS `ConfirmDialog`.
|
||||
|
||||
## Design principles
|
||||
|
||||
1. **Consistency over novelty** — all three list pages (Inbox / All / History) share one `DataTable` shell; they differ only in toolbar controls.
|
||||
2. **Double-encode severity** — DS `SeverityBadge` column **and** `rowAccent` tint — accessible to colorblind users.
|
||||
3. **Expandable rows** give the inbox-style preview affordance without needing a separate feed layout.
|
||||
4. **Relative time** (`2m ago`) with tooltip for absolute ISO — industry-standard for alert consoles.
|
||||
5. **Use DS tokens only** — `--bg-surface`, `--border-subtle`, `--radius-lg`, `--shadow-card`, `--text-primary/secondary/muted`, `--space-sm/md/lg`.
|
||||
|
||||
## Per-page design
|
||||
|
||||
### Inbox (`/alerts/inbox`)
|
||||
|
||||
Personal triage queue — user-targeted FIRING/ACKNOWLEDGED alerts.
|
||||
|
||||
- Shell: `<SectionHeader>Inbox</SectionHeader>` → bulk-action toolbar (`Mark selected read`, `Mark all read`) → `tableStyles.tableSection` wrapping `DataTable`.
|
||||
- Columns: **☐ checkbox | Severity | State | Title | App/Rule | Age | Ack**.
|
||||
- `rowAccent`: map severity → `error | warning | info`. Unread (FIRING) rows render with DataTable's inherent accent tint; additional bold weight on title via `render`.
|
||||
- `expandedContent`: message body, targeted users, fireMode, absolute firedAt/updatedAt.
|
||||
- Empty state: DS `<EmptyState icon={<Inbox />} title="All clear" description="No open alerts for you in this environment." />`.
|
||||
|
||||
### All alerts (`/alerts/all`)
|
||||
|
||||
Env-wide operational awareness.
|
||||
|
||||
- Same shell as Inbox, minus the checkbox column.
|
||||
- Filter bar: DS `ButtonGroup` with items `Open` / `Firing` / `Acked` / `All`. Replaces the current four-`Button` row.
|
||||
- Columns: **Severity | State | Title | App/Rule | Fired at | Silenced**.
|
||||
- `expandedContent`: same as Inbox.
|
||||
- Empty state: `EmptyState` with filter-specific message.
|
||||
|
||||
### History (`/alerts/history`)
|
||||
|
||||
Retrospective lookup — RESOLVED alerts only.
|
||||
|
||||
- Same shell as All.
|
||||
- Filter bar: DS `DateRangePicker` (default: last 7 days). Replaces the static "retention window" label.
|
||||
- Columns: **Severity | Title | App/Rule | Fired at | Resolved at | Duration**.
|
||||
- `expandedContent`: message body, rule snapshot pointer, full timestamps.
|
||||
- Empty state: `EmptyState` with "No resolved alerts in selected range."
|
||||
|
||||
### Rules list (`/alerts/rules`)
|
||||
|
||||
- Shell: `<SectionHeader action={<Button>New rule</Button>}>` with DS `action` slot — replaces the inline flex container that currently wraps them.
|
||||
- Raw `<table>` → `DataTable` inside `tableStyles.tableSection`.
|
||||
- Columns: **Name (Link) | Kind (Badge) | Severity (SeverityBadge) | Enabled (Toggle) | Targets (count) | Actions**.
|
||||
- Actions cell: DS `Dropdown` for **Promote to env** (replaces raw `<select>`), DS `Button variant="ghost"` **Delete** opening a `ConfirmDialog`.
|
||||
- Empty state: `EmptyState` with CTA linking to `/alerts/rules/new`.
|
||||
|
||||
### Silences (`/alerts/silences`)
|
||||
|
||||
- Shell: `<SectionHeader>Alert silences</SectionHeader>`.
|
||||
- Create form: kept in `sectionStyles.section`, but grid laid out via `FormField`s with proper `Label` and `hint` props — no inline-style grid.
|
||||
- List: raw `<table>` → `DataTable` below the form.
|
||||
- Columns: **Matcher (MonoText) | Reason | Starts | Ends | End action**.
|
||||
- `End` action → `ConfirmDialog`.
|
||||
- Empty state: `EmptyState` "No active or scheduled silences."
|
||||
|
||||
### Rule editor wizard (`/alerts/rules/new`, `/alerts/rules/:id`)
|
||||
|
||||
Keep the current custom tab stepper — DS has no `Stepper`, and the existing layout is appropriate.
|
||||
|
||||
Changes:
|
||||
- `wizard.module.css` — replace undefined tokens with DS tokens. `.wizard` uses `--space-md` gap; `.steps` underline uses `--border-subtle`; `.stepActive` border uses `--amber` (the DS accent color); `.step` idle color uses `--text-muted`, active/done uses `--text-primary`.
|
||||
- Promote banner → DS `<Alert variant="info">`.
|
||||
- Warnings block → DS `<Alert variant="warning">` with the list as children.
|
||||
- Step body wraps in `sectionStyles.section` for card affordance matching other forms.
|
||||
|
||||
## Shared changes
|
||||
|
||||
### `alerts-page.module.css`
|
||||
|
||||
Reduced to layout-only:
|
||||
|
||||
```css
|
||||
.page {
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.filterBar {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
}
|
||||
```
|
||||
|
||||
Delete: `.row`, `.rowUnread`, `.body`, `.meta`, `.time`, `.message`, `.actions`, `.empty` — all replaced by DS components.
|
||||
|
||||
### `AlertRow.tsx`
|
||||
|
||||
**Delete.** Logic migrates into:
|
||||
- A column renderer for the Title cell (handles the `Link` + `markRead` side-effect on click).
|
||||
- An `expandedContent` renderer shared across the three list pages (extracted into `ui/src/pages/Alerts/alert-expanded.tsx`).
|
||||
- An Ack action button rendered via DataTable `Actions` column.
|
||||
|
||||
### `ConfirmDialog` migration
|
||||
|
||||
Replaces native `confirm()` in `RulesListPage` (delete), `SilencesPage` (end), and the wizard if it grows a delete path (not currently present).
|
||||
|
||||
### Helpers
|
||||
|
||||
Two small pure-logic helpers, co-located in `ui/src/pages/Alerts/`:
|
||||
|
||||
- `time-utils.ts` — `formatRelativeTime(iso: string, now?: Date): string` returning `2m ago` / `1h ago` / `3d ago`. With a Vitest.
|
||||
- `severity-utils.ts` — `severityToAccent(severity: AlertSeverity): DataTableRowAccent` mapping `CRITICAL→error`, `MAJOR→warning`, `MINOR→warning`, `INFO→info`. With a Vitest.
|
||||
|
||||
## Components touched
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `ui/src/pages/Alerts/InboxPage.tsx` | Rewrite: DataTable + bulk toolbar + EmptyState |
|
||||
| `ui/src/pages/Alerts/AllAlertsPage.tsx` | Rewrite: DataTable + ButtonGroup filter + EmptyState |
|
||||
| `ui/src/pages/Alerts/HistoryPage.tsx` | Rewrite: DataTable + DateRangePicker + EmptyState |
|
||||
| `ui/src/pages/Alerts/RulesListPage.tsx` | Table → DataTable; select → Dropdown; confirm → ConfirmDialog |
|
||||
| `ui/src/pages/Alerts/SilencesPage.tsx` | Table → DataTable; FormField grid; confirm → ConfirmDialog |
|
||||
| `ui/src/pages/Alerts/AlertRow.tsx` | **Delete** |
|
||||
| `ui/src/pages/Alerts/alert-expanded.tsx` | **New** — shared expandedContent renderer |
|
||||
| `ui/src/pages/Alerts/time-utils.ts` | **New** |
|
||||
| `ui/src/pages/Alerts/time-utils.test.ts` | **New** |
|
||||
| `ui/src/pages/Alerts/severity-utils.ts` | **New** |
|
||||
| `ui/src/pages/Alerts/severity-utils.test.ts` | **New** |
|
||||
| `ui/src/pages/Alerts/alerts-page.module.css` | Slim down to layout-only, DS tokens |
|
||||
| `ui/src/pages/Alerts/RuleEditor/wizard.module.css` | Replace legacy tokens → DS tokens |
|
||||
| `ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx` | Promote banner / warnings → DS `Alert`; step body → section-card wrap |
|
||||
|
||||
## Testing
|
||||
|
||||
1. **Unit (Vitest):**
|
||||
- `time-utils.test.ts` — relative time formatting at 0s / 30s / 5m / 2h / 3d / 10d boundaries.
|
||||
- `severity-utils.test.ts` — all four severities map correctly.
|
||||
2. **Component tests:**
|
||||
- Existing `AlertStateChip.test.tsx`, `SeverityBadge.test.tsx` keep passing (no change).
|
||||
- `NotificationBell.test.tsx` — unchanged (this component already uses DS correctly per audit).
|
||||
3. **E2E (Playwright):**
|
||||
- Inbox empty state renders.
|
||||
- AllAlerts filter ButtonGroup switches active state and requery fires.
|
||||
- Rules list delete opens ConfirmDialog, confirms, row disappears.
|
||||
- Wizard promote banner renders as `Alert`.
|
||||
4. **Manual smoke:**
|
||||
- Light + dark theme on all five pages — verify no raw `<table>` borders bleeding through; all surfaces use DS card shadows.
|
||||
- Screenshot comparison pre/post via already-present Playwright config.
|
||||
|
||||
## Open questions
|
||||
|
||||
None — DS v0.1.56 ships every primitive we need (`DataTable`, `EmptyState`, `Alert`, `ButtonGroup`, `Dropdown`, `ConfirmDialog`, `DateRangePicker`, `FormField`, `MonoText`). If a gap surfaces during implementation, flag it as a separate DS-change discussion per user's standing rule.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Keyboard shortcuts (j/k nav, e to ack) — future enhancement.
|
||||
- Grouping alerts by rule (collapse duplicates) — future enhancement.
|
||||
- `MustacheEditor` visual restyling — separate concern.
|
||||
- Replacing native `confirm()` outside `/alerts` — project-wide pattern; changing it all requires separate decision.
|
||||
|
||||
## Migration risk
|
||||
|
||||
- **Low.** Changes are localized to `ui/src/pages/Alerts/` plus one CSS module file for the wizard. No backend, no DS, no router changes.
|
||||
- Openapi schema regeneration **not required** (no controller changes).
|
||||
- Existing tests don't exercise feed-row DOM directly (component tests cover badges/chips only), so row-to-table conversion won't break assertions.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Alerts Inbox Redesign — Design
|
||||
|
||||
**Status:** Approved for planning
|
||||
**Date:** 2026-04-21
|
||||
**Author:** Hendrik + Claude
|
||||
|
||||
## Goal
|
||||
|
||||
Collapse the three alert list pages (`/alerts/inbox`, `/alerts/all`, `/alerts/history`) into a single filterable inbox. Retire per-user read tracking: `ack`, `read`, and `delete` become global timestamp flags on the alert instance. Add row/bulk actions for **Silence rule…** and **Delete** directly from the list.
|
||||
|
||||
## Motivation
|
||||
|
||||
Today the three pages all hit the same user-scoped `InAppInboxQuery.listInbox`, so "All" is misleading (it's not env-wide) and "History" is just "Inbox with status=RESOLVED". The user asked for a single clean inbox with richer filters and list-level actions, and explicitly granted simplification of the read/ack/delete tracking model: one action is visible to everyone, no per-user state.
|
||||
|
||||
## Scope
|
||||
|
||||
**In:**
|
||||
- Data model: drop `ACKNOWLEDGED` from `AlertState`, add `read_at` + `deleted_at` columns, drop `alert_reads` table, rework V13 open-rule index predicate.
|
||||
- Backend: `AlertController` gains `acked`/`read` filter params, new `DELETE /alerts/{id}`, new `POST /alerts/bulk-delete`, new `POST /alerts/bulk-ack`. `/read` + `/bulk-read` rewire to update `alert_instances.read_at`.
|
||||
- Data migration (V17): existing `ACKNOWLEDGED` rows → `state='FIRING'` (ack_time preserved).
|
||||
- UI: rebuild `InboxPage` filter bar, add Silence/Delete row + bulk actions. Delete `AllAlertsPage.tsx` + `HistoryPage.tsx`. Sidebar trims to Inbox · Rules · Silences.
|
||||
- Tests + rules-file updates.
|
||||
|
||||
**Out:**
|
||||
- No redirects from `/alerts/all` or `/alerts/history` — clean break per project's no-backwards-compat policy. Stale URLs 404.
|
||||
- No per-instance silence (different from rule-silence). Silence row action always silences the rule that produced the alert.
|
||||
- No "mark unread". Read is a one-way flag.
|
||||
- No per-user actor tracking for `read`/`deleted`. `acked_by` stays (already exists, useful in UI), but only because it's already wired.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data model (`alert_instances`)
|
||||
|
||||
```
|
||||
state enum: PENDING · FIRING · RESOLVED (was: + ACKNOWLEDGED)
|
||||
acked_at TIMESTAMPTZ NULL (existing, semantics unchanged)
|
||||
acked_by TEXT NULL → users(user_id) (existing, retained for UI)
|
||||
read_at TIMESTAMPTZ NULL (NEW, global)
|
||||
deleted_at TIMESTAMPTZ NULL (NEW, soft delete)
|
||||
```
|
||||
|
||||
**Orthogonality:** `state` describes the alert's lifecycle (is the underlying condition still met?). `acked_at` / `read_at` / `deleted_at` describe what humans have done to the notification. A FIRING alert can be acked (= "someone's on it") while remaining FIRING until the condition clears.
|
||||
|
||||
**V13 open-rule unique index predicate** (preserved as the evaluator's dedup key) changes from:
|
||||
```sql
|
||||
WHERE state IN ('PENDING','FIRING','ACKNOWLEDGED')
|
||||
```
|
||||
to:
|
||||
```sql
|
||||
WHERE state IN ('PENDING','FIRING') AND deleted_at IS NULL
|
||||
```
|
||||
Ack no longer "closes" the open window — a rule that's still matching stays de-duped against the open instance whether acked or not. Deleting soft-deletes the row and opens a new slot so the rule can fire again fresh if the condition re-triggers.
|
||||
|
||||
**`alert_reads` table:** dropped entirely. No FK references elsewhere.
|
||||
|
||||
### Postgres enum removal
|
||||
|
||||
Postgres doesn't support removing a value from an enum type. Migration path:
|
||||
|
||||
```sql
|
||||
-- V17
|
||||
-- 1. Coerce existing ACKNOWLEDGED rows → FIRING (ack_time already set)
|
||||
UPDATE alert_instances SET state = 'FIRING' WHERE state = 'ACKNOWLEDGED';
|
||||
|
||||
-- 2. Swap to a new enum type without ACKNOWLEDGED
|
||||
CREATE TYPE alert_state_enum_v2 AS ENUM ('PENDING','FIRING','RESOLVED');
|
||||
ALTER TABLE alert_instances
|
||||
ALTER COLUMN state TYPE alert_state_enum_v2
|
||||
USING state::text::alert_state_enum_v2;
|
||||
DROP TYPE alert_state_enum;
|
||||
ALTER TYPE alert_state_enum_v2 RENAME TO alert_state_enum;
|
||||
|
||||
-- 3. New columns
|
||||
ALTER TABLE alert_instances
|
||||
ADD COLUMN read_at timestamptz NULL,
|
||||
ADD COLUMN deleted_at timestamptz NULL;
|
||||
CREATE INDEX alert_instances_read_idx ON alert_instances (environment_id, read_at) WHERE read_at IS NULL AND deleted_at IS NULL;
|
||||
CREATE INDEX alert_instances_deleted_idx ON alert_instances (deleted_at) WHERE deleted_at IS NOT NULL;
|
||||
|
||||
-- 4. Rework V13/V15/V16 open-rule unique index with the new predicate
|
||||
DROP INDEX IF EXISTS alert_instances_open_rule_uq;
|
||||
CREATE UNIQUE INDEX alert_instances_open_rule_uq
|
||||
ON alert_instances (rule_id, (COALESCE(
|
||||
context->>'_subjectFingerprint',
|
||||
context->'exchange'->>'id',
|
||||
'')))
|
||||
WHERE rule_id IS NOT NULL
|
||||
AND state IN ('PENDING','FIRING')
|
||||
AND deleted_at IS NULL;
|
||||
|
||||
-- 5. Drop alert_reads
|
||||
DROP TABLE alert_reads;
|
||||
```
|
||||
|
||||
### Backend — `AlertController`
|
||||
|
||||
| Method | Path | Body/Query | RBAC | Effect |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/alerts` | `state, severity, acked, read, limit` | VIEWER+ | Inbox list, always `deleted_at IS NULL`. `state` no longer accepts `ACKNOWLEDGED`. `acked` / `read` are tri-state: **omitted** = no filter; `=true` = only acked/read; `=false` = only unacked/unread. UI defaults to `acked=false&read=false` via the "Hide acked" + "Hide read" toggles. |
|
||||
| GET | `/alerts/unread-count` | — | VIEWER+ | Counts `read_at IS NULL AND deleted_at IS NULL` + user-visibility predicate. |
|
||||
| GET | `/alerts/{id}` | — | VIEWER+ | Detail. Returns 404 if `deleted_at IS NOT NULL`. |
|
||||
| POST | `/alerts/{id}/ack` | — | VIEWER+ | Sets `acked_at=now, acked_by=user`. No state change. |
|
||||
| POST | `/alerts/{id}/read` | — | VIEWER+ | Sets `read_at=now` if null. Idempotent. |
|
||||
| POST | `/alerts/bulk-read` | `{ instanceIds: [...] }` | VIEWER+ | UPDATE `alert_instances SET read_at=now()` for all ids in env. |
|
||||
| POST | `/alerts/bulk-ack` | `{ instanceIds: [...] }` | **NEW** VIEWER+ | Parallel to bulk-read. |
|
||||
| DELETE | `/alerts/{id}` | — | **NEW** OPERATOR+ | Sets `deleted_at=now`. Returns 204. |
|
||||
| POST | `/alerts/bulk-delete` | `{ instanceIds: [...] }` | **NEW** OPERATOR+ | Bulk soft-delete in env. |
|
||||
|
||||
Removed:
|
||||
- `AlertReadRepository` bean + `alert_reads` usage — `read_at`/`bulk-read` now update `alert_instances` directly.
|
||||
- `ACKNOWLEDGED` handling in all backend code paths.
|
||||
|
||||
`InAppInboxQuery.countUnread` rewires to a single SQL count on `alert_instances` with `read_at IS NULL AND deleted_at IS NULL` + target-visibility predicate.
|
||||
|
||||
### Backend — evaluator + notifier
|
||||
|
||||
- `AlertInstanceRepository.findOpenByRule(ruleId, subjectFingerprint)` already exists; its predicate now matches the new index (`state IN ('PENDING','FIRING') AND deleted_at IS NULL`).
|
||||
- All test fixtures that assert `state=ACKNOWLEDGED` → assert `acked_at IS NOT NULL`.
|
||||
- Notification pipeline (`AlertNotifier`) already fires on state transitions; no change — ack no longer being a state means one fewer state-change branch to handle.
|
||||
|
||||
### Silence from list — no new endpoint
|
||||
|
||||
UI row-action calls existing `POST /alerts/silences` with `{ matcher: { ruleId: <id> }, startsAt: now, endsAt: now + duration, reason: "Silenced from inbox" }`. The duration picker is a small menu: `1h / 8h / 24h / Custom…`. "Custom" routes to `/alerts/silences` (the existing SilencesPage form) with the `ruleId` pre-filled via URL search param.
|
||||
|
||||
### UI — `InboxPage.tsx`
|
||||
|
||||
**Filter bar (topnavbar-style, left-to-right):**
|
||||
|
||||
| Filter | Values | Default |
|
||||
|---|---|---|
|
||||
| Severity (ButtonGroup multi) | CRITICAL · WARNING · INFO | none (= no filter) |
|
||||
| Status (ButtonGroup multi) | PENDING · FIRING · RESOLVED | FIRING selected |
|
||||
| Hide acked (Toggle) | on/off | **on** |
|
||||
| Hide read (Toggle) | on/off | **on** |
|
||||
|
||||
Default state: "Show me firing things nobody's touched." Matches the "what needs attention" mental model.
|
||||
|
||||
**Row actions column** (right-aligned, shown on hover or always for the touched row):
|
||||
- `Acknowledge` (when `acked_at IS NULL`)
|
||||
- `Mark read` (when `read_at IS NULL`)
|
||||
- `Silence rule…` (opens quick menu: `1h / 8h / 24h / Custom…`)
|
||||
- `Delete` (trash icon, OPERATOR+ only). Soft-delete. Undo toast for 5s invalidates the mutation.
|
||||
|
||||
**Bulk toolbar** (shown when selection > 0, above table):
|
||||
- `Acknowledge N` (filters to unacked)
|
||||
- `Mark N read` (filters to unread)
|
||||
- `Silence rules` (silences every unique ruleId in selection — duration menu)
|
||||
- `Delete N` (OPERATOR+) — opens confirmation modal: "Delete N alerts? This affects all users."
|
||||
|
||||
**Deleted/dropped files:**
|
||||
- `ui/src/pages/Alerts/AllAlertsPage.tsx` — removed
|
||||
- `ui/src/pages/Alerts/HistoryPage.tsx` — removed
|
||||
- `/alerts/all` and `/alerts/history` route definitions in `router.tsx` — removed
|
||||
|
||||
**Sidebar:**
|
||||
`buildAlertsTreeNodes` in `ui/src/components/sidebar-utils.ts` trims to: Inbox · Rules · Silences. "Inbox" stays as the first child; selecting `/alerts` (bare) goes to inbox.
|
||||
|
||||
**CMD-K:** `buildAlertSearchData` still registers `alert` and `alertRule` categories, but the alert-category deep links all point to `/alerts/inbox/{id}` (single detail route).
|
||||
|
||||
### Tests
|
||||
|
||||
Backend:
|
||||
- `AlertControllerTest` — new `acked` + `read` filter cases, new DELETE + bulk-delete + bulk-ack tests, 404 on soft-deleted instance.
|
||||
- `PostgresAlertInstanceRepositoryTest` — `markRead`/`bulkMarkRead`/`softDelete`/`bulkSoftDelete` SQL, index predicate correctness.
|
||||
- V17 migration test: seed an `ACKNOWLEDGED` row pre-migration, verify post-migration state and index.
|
||||
- Every test using `AlertState.ACKNOWLEDGED` — removed or switched to `acked_at IS NOT NULL` assertion.
|
||||
- `AlertNotifierTest` — confirm no regression on notification emission paths.
|
||||
|
||||
UI:
|
||||
- `InboxPage.test.tsx` — filter toggles, select-all, row actions, bulk actions, optimistic delete + undo.
|
||||
- `enums.test.ts` snapshot — `AlertState` drops ACKNOWLEDGED, new filter option arrays added.
|
||||
- Silence duration menu component test.
|
||||
|
||||
### Docs / rules updates
|
||||
|
||||
- `.claude/rules/app-classes.md`:
|
||||
- `AlertController` endpoint list updated (new DELETE, bulk-delete, bulk-ack; `acked`/`read` filter params; `ACKNOWLEDGED` removed from allowed state).
|
||||
- Drop `AlertReadRepository` from `security/` or repository listings.
|
||||
- `.claude/rules/ui.md`:
|
||||
- Alerts section: remove "All" and "History" pages, drop their routes. Rewrite Inbox description to new filter bar + actions.
|
||||
- Note: unread-count bell now global.
|
||||
- `.claude/rules/core-classes.md`:
|
||||
- `AlertState` enum values reduced to three.
|
||||
- Note `alert_reads` table is retired.
|
||||
- `CLAUDE.md`:
|
||||
- New migration entry: `V17 — Alerts: drop ACKNOWLEDGED state, add read_at/deleted_at, drop alert_reads, rework open-rule index.`
|
||||
|
||||
## Risk / open concerns
|
||||
|
||||
1. **Enum-type swap on a populated table.** `ALTER COLUMN TYPE … USING cast::text::enum_v2` rewrites every row. `alert_instances` is expected to remain bounded (RESOLVED rows age out via retention), but on large installs this should run during a low-traffic window. Migration is idempotent.
|
||||
2. **Concurrent ack/delete races.** Both are simple column updates with `WHERE id=? AND deleted_at IS NULL`; last-write wins is acceptable per the "no individual tracking" decision.
|
||||
3. **Notification context mustache variables.** No change — `alert.state` shape is unchanged; templates referencing `state=ACKNOWLEDGED` are user-authored and will start producing no matches after the migration, which is intentional. Add a release note.
|
||||
4. **CMD-K deep links** to deleted alert ids return 404 now (they did before for missing, now also for soft-deleted). Acceptable.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Single inbox at `/alerts/inbox` with four filter dimensions wired end-to-end.
|
||||
- Silence-rule menu works from row + bulk.
|
||||
- Soft-delete works from row + bulk, with OPERATOR+ guard and undo toast for single.
|
||||
- Unread count bell reflects global `read_at IS NULL`.
|
||||
- All existing backend/UI tests green; new test coverage as listed above.
|
||||
- V17 up-migrates ACKNOWLEDGED rows cleanly; reviewer can verify with a seeded pre-migration snapshot.
|
||||
File diff suppressed because one or more lines are too long
@@ -22,7 +22,7 @@ describe('useAlerts', () => {
|
||||
useEnvironmentStore.setState({ environment: 'dev' });
|
||||
});
|
||||
|
||||
it('fetches alerts for selected env and passes filter query params', async () => {
|
||||
it('forwards state + severity filters to the server as query params', async () => {
|
||||
(apiClient.GET as any).mockResolvedValue({ data: [], error: null });
|
||||
const { result } = renderHook(
|
||||
() => useAlerts({ state: 'FIRING', severity: ['CRITICAL', 'WARNING'] }),
|
||||
@@ -34,16 +34,46 @@ describe('useAlerts', () => {
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
path: { envSlug: 'dev' },
|
||||
query: expect.objectContaining({
|
||||
query: {
|
||||
limit: 200,
|
||||
state: ['FIRING'],
|
||||
severity: ['CRITICAL', 'WARNING'],
|
||||
limit: 100,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits state + severity when no filter is set', async () => {
|
||||
(apiClient.GET as any).mockResolvedValue({ data: [], error: null });
|
||||
const { result } = renderHook(() => useAlerts(), { 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: { limit: 200 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('still applies ruleId client-side via select', async () => {
|
||||
const dataset = [
|
||||
{ id: '1', ruleId: 'R1', state: 'FIRING', severity: 'WARNING', title: 'a' },
|
||||
{ id: '2', ruleId: 'R2', state: 'FIRING', severity: 'WARNING', title: 'b' },
|
||||
];
|
||||
(apiClient.GET as any).mockResolvedValue({ data: dataset, error: null });
|
||||
const { result } = renderHook(
|
||||
() => useAlerts({ ruleId: 'R2' }),
|
||||
{ wrapper },
|
||||
);
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
const ids = (result.current.data ?? []).map((a: any) => a.id);
|
||||
expect(ids).toEqual(['2']);
|
||||
});
|
||||
|
||||
it('does not fetch when no env is selected', () => {
|
||||
useEnvironmentStore.setState({ environment: undefined });
|
||||
const { result } = renderHook(() => useAlerts(), { wrapper });
|
||||
|
||||
@@ -11,6 +11,8 @@ type AlertSeverity = NonNullable<AlertDto['severity']>;
|
||||
export interface AlertsFilter {
|
||||
state?: AlertState | AlertState[];
|
||||
severity?: AlertSeverity | AlertSeverity[];
|
||||
acked?: boolean;
|
||||
read?: boolean;
|
||||
ruleId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
@@ -28,33 +30,49 @@ function toArray<T>(v: T | T[] | undefined): T[] | undefined {
|
||||
// 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). */
|
||||
/** List alert instances in the current env. Polls every 30s (pauses in background).
|
||||
*
|
||||
* State + severity filters are server-side (`state=FIRING&state=ACKNOWLEDGED&severity=CRITICAL`).
|
||||
* `ruleId` is not a backend param and is still applied via react-query `select`.
|
||||
* Each unique (state, severity) combo gets its own cache entry so the server
|
||||
* honors the filter and stays as the source of truth.
|
||||
*/
|
||||
export function useAlerts(filter: AlertsFilter = {}) {
|
||||
const env = useSelectedEnv();
|
||||
const fetchLimit = Math.min(filter.limit ?? 200, 200);
|
||||
const stateArr = toArray(filter.state);
|
||||
const severityArr = toArray(filter.severity);
|
||||
const ruleIdFilter = filter.ruleId;
|
||||
|
||||
// Stable, serialisable key — arrays must be sorted so order doesn't create cache misses.
|
||||
const stateKey = stateArr ? [...stateArr].sort() : null;
|
||||
const severityKey = severityArr ? [...severityArr].sort() : null;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['alerts', env, filter],
|
||||
queryKey: ['alerts', env, 'list', fetchLimit, stateKey, severityKey, filter.acked ?? null, filter.read ?? null],
|
||||
enabled: !!env,
|
||||
refetchInterval: 30_000,
|
||||
refetchIntervalInBackground: false,
|
||||
queryFn: async () => {
|
||||
if (!env) throw new Error('no env');
|
||||
const query: Record<string, unknown> = { limit: fetchLimit };
|
||||
if (stateArr && stateArr.length > 0) query.state = stateArr;
|
||||
if (severityArr && severityArr.length > 0) query.severity = severityArr;
|
||||
if (filter.acked !== undefined) query.acked = filter.acked;
|
||||
if (filter.read !== undefined) query.read = filter.read;
|
||||
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,
|
||||
},
|
||||
query,
|
||||
},
|
||||
} as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as AlertDto[];
|
||||
},
|
||||
select: (all) => ruleIdFilter ? all.filter((a) => a.ruleId === ruleIdFilter) : all,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -166,3 +184,80 @@ export function useBulkReadAlerts() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Acknowledge a batch of alert instances. */
|
||||
export function useBulkAckAlerts() {
|
||||
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-ack',
|
||||
{ params: { path: { envSlug: env } }, body: { instanceIds: ids } } as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts', env] }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete (soft) a single alert instance. */
|
||||
export function useDeleteAlert() {
|
||||
const env = useSelectedEnv();
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
if (!env) throw new Error('no env');
|
||||
const { error } = await apiClient.DELETE(
|
||||
'/environments/{envSlug}/alerts/{id}',
|
||||
{ params: { path: { envSlug: env, id } } } as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['alerts', env] });
|
||||
qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete (soft) a batch of alert instances. */
|
||||
export function useBulkDeleteAlerts() {
|
||||
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-delete',
|
||||
{ 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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Restore a soft-deleted alert instance. */
|
||||
export function useRestoreAlert() {
|
||||
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}/restore',
|
||||
{ params: { path: { envSlug: env, id } } } as any,
|
||||
);
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['alerts', env] });
|
||||
qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
186
ui/src/api/schema.d.ts
vendored
186
ui/src/api/schema.d.ts
vendored
@@ -433,6 +433,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/environments/{envSlug}/alerts/{id}/restore": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["restore"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/environments/{envSlug}/alerts/{id}/read": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -577,6 +593,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/environments/{envSlug}/alerts/bulk-delete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["bulkDelete"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/environments/{envSlug}/alerts/bulk-ack": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["bulkAck"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/data/metrics": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1616,7 +1664,7 @@ export interface paths {
|
||||
get: operations["get_3"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
delete: operations["delete_5"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
@@ -2221,6 +2269,16 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
};
|
||||
AgentLifecycleCondition: {
|
||||
kind: "AgentLifecycleCondition";
|
||||
} & (Omit<components["schemas"]["AlertCondition"], "kind"> & {
|
||||
scope?: components["schemas"]["AlertScope"];
|
||||
eventTypes?: ("REGISTERED" | "RE_REGISTERED" | "DEREGISTERED" | "WENT_STALE" | "WENT_DEAD" | "RECOVERED")[];
|
||||
/** Format: int32 */
|
||||
withinSeconds?: number;
|
||||
/** @enum {string} */
|
||||
readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "AGENT_LIFECYCLE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
});
|
||||
AgentStateCondition: {
|
||||
kind: "AgentStateCondition";
|
||||
} & (Omit<components["schemas"]["AlertCondition"], "kind"> & {
|
||||
@@ -2229,11 +2287,11 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
forSeconds?: number;
|
||||
/** @enum {string} */
|
||||
readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "AGENT_LIFECYCLE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
});
|
||||
AlertCondition: {
|
||||
/** @enum {string} */
|
||||
kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "AGENT_LIFECYCLE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
};
|
||||
AlertRuleRequest: {
|
||||
name?: string;
|
||||
@@ -2241,8 +2299,8 @@ export interface components {
|
||||
/** @enum {string} */
|
||||
severity: "CRITICAL" | "WARNING" | "INFO";
|
||||
/** @enum {string} */
|
||||
conditionKind: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
condition: components["schemas"]["AgentStateCondition"] | components["schemas"]["DeploymentStateCondition"] | components["schemas"]["ExchangeMatchCondition"] | components["schemas"]["JvmMetricCondition"] | components["schemas"]["LogPatternCondition"] | components["schemas"]["RouteMetricCondition"];
|
||||
conditionKind: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "AGENT_LIFECYCLE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
condition: components["schemas"]["AgentLifecycleCondition"] | components["schemas"]["AgentStateCondition"] | components["schemas"]["DeploymentStateCondition"] | components["schemas"]["ExchangeMatchCondition"] | components["schemas"]["JvmMetricCondition"] | components["schemas"]["LogPatternCondition"] | components["schemas"]["RouteMetricCondition"];
|
||||
/** Format: int32 */
|
||||
evaluationIntervalSeconds?: number;
|
||||
/** Format: int32 */
|
||||
@@ -2274,7 +2332,7 @@ export interface components {
|
||||
scope?: components["schemas"]["AlertScope"];
|
||||
states?: string[];
|
||||
/** @enum {string} */
|
||||
readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "AGENT_LIFECYCLE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
});
|
||||
ExchangeFilter: {
|
||||
status?: string;
|
||||
@@ -2296,7 +2354,7 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
perExchangeLingerSeconds?: number;
|
||||
/** @enum {string} */
|
||||
readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "AGENT_LIFECYCLE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
});
|
||||
JvmMetricCondition: {
|
||||
kind: "JvmMetricCondition";
|
||||
@@ -2312,7 +2370,7 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
windowSeconds?: number;
|
||||
/** @enum {string} */
|
||||
readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "AGENT_LIFECYCLE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
});
|
||||
LogPatternCondition: {
|
||||
kind: "LogPatternCondition";
|
||||
@@ -2325,7 +2383,7 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
windowSeconds?: number;
|
||||
/** @enum {string} */
|
||||
readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "AGENT_LIFECYCLE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
});
|
||||
RouteMetricCondition: {
|
||||
kind: "RouteMetricCondition";
|
||||
@@ -2340,7 +2398,7 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
windowSeconds?: number;
|
||||
/** @enum {string} */
|
||||
readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "AGENT_LIFECYCLE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
});
|
||||
WebhookBindingRequest: {
|
||||
/** Format: uuid */
|
||||
@@ -2361,8 +2419,8 @@ export interface components {
|
||||
severity?: "CRITICAL" | "WARNING" | "INFO";
|
||||
enabled?: boolean;
|
||||
/** @enum {string} */
|
||||
conditionKind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
condition?: components["schemas"]["AgentStateCondition"] | components["schemas"]["DeploymentStateCondition"] | components["schemas"]["ExchangeMatchCondition"] | components["schemas"]["JvmMetricCondition"] | components["schemas"]["LogPatternCondition"] | components["schemas"]["RouteMetricCondition"];
|
||||
conditionKind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "AGENT_LIFECYCLE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC";
|
||||
condition?: components["schemas"]["AgentLifecycleCondition"] | components["schemas"]["AgentStateCondition"] | components["schemas"]["DeploymentStateCondition"] | components["schemas"]["ExchangeMatchCondition"] | components["schemas"]["JvmMetricCondition"] | components["schemas"]["LogPatternCondition"] | components["schemas"]["RouteMetricCondition"];
|
||||
/** Format: int32 */
|
||||
evaluationIntervalSeconds?: number;
|
||||
/** Format: int32 */
|
||||
@@ -2704,7 +2762,7 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
environmentId?: string;
|
||||
/** @enum {string} */
|
||||
state?: "PENDING" | "FIRING" | "ACKNOWLEDGED" | "RESOLVED";
|
||||
state?: "PENDING" | "FIRING" | "RESOLVED";
|
||||
/** @enum {string} */
|
||||
severity?: "CRITICAL" | "WARNING" | "INFO";
|
||||
title?: string;
|
||||
@@ -2716,6 +2774,8 @@ export interface components {
|
||||
ackedBy?: string;
|
||||
/** Format: date-time */
|
||||
resolvedAt?: string;
|
||||
/** Format: date-time */
|
||||
readAt?: string;
|
||||
silenced?: boolean;
|
||||
/** Format: double */
|
||||
currentValue?: number;
|
||||
@@ -2739,7 +2799,7 @@ export interface components {
|
||||
title?: string;
|
||||
message?: string;
|
||||
};
|
||||
BulkReadRequest: {
|
||||
BulkIdsRequest: {
|
||||
instanceIds: string[];
|
||||
};
|
||||
LogEntry: {
|
||||
@@ -5042,6 +5102,28 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
restore: {
|
||||
parameters: {
|
||||
query: {
|
||||
env: components["schemas"]["Environment"];
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
read: {
|
||||
parameters: {
|
||||
query: {
|
||||
@@ -5299,7 +5381,55 @@ export interface operations {
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["BulkReadRequest"];
|
||||
"application/json": components["schemas"]["BulkIdsRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
bulkDelete: {
|
||||
parameters: {
|
||||
query: {
|
||||
env: components["schemas"]["Environment"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["BulkIdsRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
bulkAck: {
|
||||
parameters: {
|
||||
query: {
|
||||
env: components["schemas"]["Environment"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["BulkIdsRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
@@ -7213,6 +7343,10 @@ export interface operations {
|
||||
query: {
|
||||
env: components["schemas"]["Environment"];
|
||||
limit?: number;
|
||||
state?: ("PENDING" | "FIRING" | "RESOLVED")[];
|
||||
severity?: ("CRITICAL" | "WARNING" | "INFO")[];
|
||||
acked?: boolean;
|
||||
read?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -7255,6 +7389,28 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_5: {
|
||||
parameters: {
|
||||
query: {
|
||||
env: components["schemas"]["Environment"];
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
listForInstance: {
|
||||
parameters: {
|
||||
query: {
|
||||
|
||||
@@ -11,7 +11,6 @@ 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} />);
|
||||
|
||||
@@ -6,14 +6,12 @@ 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',
|
||||
};
|
||||
|
||||
|
||||
@@ -368,7 +368,7 @@ function LayoutContent() {
|
||||
const { data: envRecords = [] } = useEnvironments();
|
||||
|
||||
// Open alerts + rules for CMD-K (env-scoped).
|
||||
const { data: cmdkAlerts } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 });
|
||||
const { data: cmdkAlerts } = useAlerts({ state: ['FIRING'], acked: false, limit: 100 });
|
||||
const { data: cmdkRules } = useAlertRules();
|
||||
|
||||
// Merge environments from both the environments table and agent heartbeats
|
||||
@@ -957,18 +957,20 @@ function LayoutContent() {
|
||||
<TopBar
|
||||
breadcrumb={breadcrumb}
|
||||
environment={
|
||||
<EnvironmentSelector
|
||||
environments={environments}
|
||||
value={selectedEnv}
|
||||
onChange={setSelectedEnv}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<EnvironmentSelector
|
||||
environments={environments}
|
||||
value={selectedEnv}
|
||||
onChange={setSelectedEnv}
|
||||
/>
|
||||
<NotificationBell />
|
||||
</div>
|
||||
}
|
||||
user={username ? { name: username } : undefined}
|
||||
userMenuItems={userMenuItems}
|
||||
onLogout={handleLogout}
|
||||
onNavigate={navigate}
|
||||
>
|
||||
<NotificationBell />
|
||||
<SearchTrigger onClick={() => setPaletteOpen(true)} />
|
||||
<ButtonGroup
|
||||
items={STATUS_ITEMS}
|
||||
@@ -1006,7 +1008,7 @@ function LayoutContent() {
|
||||
data={searchData}
|
||||
/>
|
||||
|
||||
{!isAdminPage && (
|
||||
{!isAdminPage && !isAlertsPage && (
|
||||
<ContentTabs active={scope.tab} onChange={setTab} scope={scope} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -42,6 +42,16 @@ export const ALERT_VARIABLES: AlertVariable[] = [
|
||||
{ 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 },
|
||||
|
||||
// AGENT_LIFECYCLE — agent + event subtree (distinct from AGENT_STATE's agent.* leaves)
|
||||
{ path: 'agent.app', type: 'string', description: 'Agent app slug', sampleValue: 'orders',
|
||||
availableForKinds: ['AGENT_LIFECYCLE'] },
|
||||
{ path: 'event.type', type: 'string', description: 'Lifecycle event type', sampleValue: 'WENT_DEAD',
|
||||
availableForKinds: ['AGENT_LIFECYCLE'] },
|
||||
{ path: 'event.timestamp', type: 'Instant', description: 'When the event happened', sampleValue: '2026-04-20T14:33:10Z',
|
||||
availableForKinds: ['AGENT_LIFECYCLE'] },
|
||||
{ path: 'event.detail', type: 'string', description: 'Free-text event detail', sampleValue: 'orders-0 STALE -> DEAD',
|
||||
availableForKinds: ['AGENT_LIFECYCLE'], mayBeNull: true },
|
||||
|
||||
// ROUTE_METRIC + EXCHANGE_MATCH share route.*
|
||||
{ path: 'route.id', type: 'string', description: 'Route ID', sampleValue: 'route-1',
|
||||
availableForKinds: ['ROUTE_METRIC', 'EXCHANGE_MATCH'] },
|
||||
@@ -56,7 +66,7 @@ export const ALERT_VARIABLES: AlertVariable[] = [
|
||||
|
||||
// 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'] },
|
||||
availableForKinds: ['AGENT_STATE', 'AGENT_LIFECYCLE', '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',
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
color: var(--fg);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.bell:hover { background: var(--hover-bg); }
|
||||
.bell:hover { background: var(--bg-hover); }
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
@@ -18,7 +18,7 @@
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
color: var(--bg);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
@@ -26,4 +26,4 @@
|
||||
}
|
||||
.badgeCritical { background: var(--error); }
|
||||
.badgeWarning { background: var(--amber); }
|
||||
.badgeInfo { background: var(--muted); }
|
||||
.badgeInfo { background: var(--text-muted); }
|
||||
|
||||
@@ -2,16 +2,14 @@ import { describe, it, expect } from 'vitest';
|
||||
import { buildAlertsTreeNodes } from './sidebar-utils';
|
||||
|
||||
describe('buildAlertsTreeNodes', () => {
|
||||
it('returns 5 entries with inbox/all/rules/silences/history paths', () => {
|
||||
it('returns 3 entries with inbox/rules/silences paths', () => {
|
||||
const nodes = buildAlertsTreeNodes();
|
||||
expect(nodes).toHaveLength(5);
|
||||
expect(nodes).toHaveLength(3);
|
||||
const paths = nodes.map((n) => n.path);
|
||||
expect(paths).toEqual([
|
||||
'/alerts/inbox',
|
||||
'/alerts/all',
|
||||
'/alerts/rules',
|
||||
'/alerts/silences',
|
||||
'/alerts/history',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import type { SidebarTreeNode } from '@cameleer/design-system';
|
||||
import { AlertTriangle, Inbox, List, ScrollText, BellOff } from 'lucide-react';
|
||||
import { AlertTriangle, Inbox, BellOff } from 'lucide-react';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Domain types (moved out of DS — no longer exported there) */
|
||||
@@ -117,15 +117,13 @@ export function buildAdminTreeNodes(opts?: { infrastructureEndpoints?: boolean }
|
||||
|
||||
/**
|
||||
* Alerts tree — static nodes for the alerting section.
|
||||
* Paths: /alerts/{inbox|all|rules|silences|history}
|
||||
* Paths: /alerts/{inbox|rules|silences}
|
||||
*/
|
||||
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 })) },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -64,6 +64,14 @@
|
||||
--text-muted: #766A5E;
|
||||
/* White text on colored badge backgrounds (not in DS yet) */
|
||||
--text-inverse: #fff;
|
||||
|
||||
/* Spacing scale — DS doesn't ship these, but many app modules reference them.
|
||||
Keep local here until the DS grows a real spacing system. */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
209
ui/src/pages/Alerts/InboxPage.test.tsx
Normal file
209
ui/src/pages/Alerts/InboxPage.test.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { ThemeProvider, ToastProvider } from '@cameleer/design-system';
|
||||
import InboxPage from './InboxPage';
|
||||
import type { AlertDto } from '../../api/queries/alerts';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import { useEnvironmentStore } from '../../api/environment-store';
|
||||
|
||||
// ── hook mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Capture the args passed to useAlerts so we can assert on filter params.
|
||||
const alertsCallArgs = vi.fn();
|
||||
const deleteMock = vi.fn().mockResolvedValue(undefined);
|
||||
const bulkDeleteMock = vi.fn().mockResolvedValue(undefined);
|
||||
const ackMock = vi.fn().mockResolvedValue(undefined);
|
||||
const markReadMutateAsync = vi.fn().mockResolvedValue(undefined);
|
||||
const markReadMutate = vi.fn();
|
||||
const bulkAckMock = vi.fn().mockResolvedValue(undefined);
|
||||
const bulkReadMock = vi.fn().mockResolvedValue(undefined);
|
||||
const restoreMock = vi.fn().mockResolvedValue(undefined);
|
||||
const createSilenceMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// alertsMock is a factory — each call records its args and returns the current
|
||||
// rows so we can change the fixture per test.
|
||||
let currentRows: AlertDto[] = [];
|
||||
|
||||
vi.mock('../../api/queries/alerts', () => ({
|
||||
useAlerts: (filter: unknown) => {
|
||||
alertsCallArgs(filter);
|
||||
return { data: currentRows, isLoading: false, error: null };
|
||||
},
|
||||
useAckAlert: () => ({ mutateAsync: ackMock, isPending: false }),
|
||||
useMarkAlertRead: () => ({ mutateAsync: markReadMutateAsync, mutate: markReadMutate, isPending: false }),
|
||||
useBulkReadAlerts: () => ({ mutateAsync: bulkReadMock, isPending: false }),
|
||||
useBulkAckAlerts: () => ({ mutateAsync: bulkAckMock, isPending: false }),
|
||||
useDeleteAlert: () => ({ mutateAsync: deleteMock, isPending: false }),
|
||||
useBulkDeleteAlerts: () => ({ mutateAsync: bulkDeleteMock, isPending: false }),
|
||||
useRestoreAlert: () => ({ mutateAsync: restoreMock }),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/queries/alertSilences', () => ({
|
||||
useCreateSilence: () => ({ mutateAsync: createSilenceMock, isPending: false }),
|
||||
}));
|
||||
|
||||
// ── fixture rows ────────────────────────────────────────────────────────────
|
||||
|
||||
const ROW_FIRING: AlertDto = {
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
ruleId: 'rrrrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr',
|
||||
state: 'FIRING',
|
||||
severity: 'CRITICAL',
|
||||
title: 'Order pipeline down',
|
||||
message: 'msg',
|
||||
firedAt: '2026-04-21T10:00:00Z',
|
||||
ackedAt: undefined,
|
||||
ackedBy: undefined,
|
||||
resolvedAt: undefined,
|
||||
readAt: undefined,
|
||||
silenced: false,
|
||||
currentValue: undefined,
|
||||
threshold: undefined,
|
||||
environmentId: 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee',
|
||||
context: {},
|
||||
};
|
||||
|
||||
const ROW_ACKED: AlertDto = {
|
||||
...ROW_FIRING,
|
||||
id: '22222222-2222-2222-2222-222222222222',
|
||||
ackedAt: '2026-04-21T10:05:00Z',
|
||||
ackedBy: 'alice',
|
||||
};
|
||||
|
||||
// ── mount helper ────────────────────────────────────────────────────────────
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={qc}>
|
||||
<ToastProvider>
|
||||
<MemoryRouter initialEntries={['/alerts/inbox']}>
|
||||
<InboxPage />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// ── setup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset mocks to their default resolved values after clearAllMocks
|
||||
deleteMock.mockResolvedValue(undefined);
|
||||
bulkDeleteMock.mockResolvedValue(undefined);
|
||||
ackMock.mockResolvedValue(undefined);
|
||||
markReadMutateAsync.mockResolvedValue(undefined);
|
||||
bulkAckMock.mockResolvedValue(undefined);
|
||||
bulkReadMock.mockResolvedValue(undefined);
|
||||
restoreMock.mockResolvedValue(undefined);
|
||||
createSilenceMock.mockResolvedValue(undefined);
|
||||
|
||||
currentRows = [ROW_FIRING];
|
||||
|
||||
// Set OPERATOR role and an env so hooks are enabled
|
||||
useAuthStore.setState({ roles: ['OPERATOR'] });
|
||||
useEnvironmentStore.setState({ environment: 'dev' });
|
||||
});
|
||||
|
||||
// ── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('InboxPage', () => {
|
||||
it('calls useAlerts with default filters: state=[FIRING], acked=false, read=false', () => {
|
||||
mount();
|
||||
|
||||
// useAlerts is called during render; check the first call's filter arg
|
||||
expect(alertsCallArgs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
state: ['FIRING'],
|
||||
acked: false,
|
||||
read: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('unchecking "Hide acked" removes the acked filter', () => {
|
||||
mount();
|
||||
|
||||
// Initial call should include acked: false
|
||||
expect(alertsCallArgs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ acked: false }),
|
||||
);
|
||||
|
||||
// Find and uncheck the "Hide acked" toggle
|
||||
const hideAckedToggle = screen.getByRole('checkbox', { name: /hide acked/i });
|
||||
fireEvent.click(hideAckedToggle);
|
||||
|
||||
// After unchecking, useAlerts should be called without acked: false
|
||||
// (the component passes `undefined` when the toggle is off)
|
||||
const lastCall = alertsCallArgs.mock.calls[alertsCallArgs.mock.calls.length - 1][0];
|
||||
expect(lastCall.acked).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows Acknowledge button only on rows where ackedAt is null', () => {
|
||||
currentRows = [ROW_FIRING, ROW_ACKED];
|
||||
mount();
|
||||
|
||||
// ROW_FIRING has ackedAt=undefined → Ack button should appear
|
||||
// ROW_ACKED has ackedAt set → Ack button should NOT appear
|
||||
// The row-level Ack button label is "Ack"
|
||||
const ackButtons = screen.getAllByRole('button', { name: /^ack$/i });
|
||||
expect(ackButtons).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('opens bulk-delete confirmation with the correct count', async () => {
|
||||
currentRows = [ROW_FIRING, ROW_ACKED];
|
||||
mount();
|
||||
|
||||
// Use the "Select all" checkbox in the filter bar to select all 2 rows.
|
||||
// It is labelled "Select all (2)" when nothing is selected.
|
||||
const selectAllCb = screen.getByRole('checkbox', { name: /select all/i });
|
||||
// fireEvent.click toggles checkboxes and triggers React's onChange
|
||||
fireEvent.click(selectAllCb);
|
||||
|
||||
// After selection the bulk toolbar should show a "Delete N" button
|
||||
const deleteButton = await screen.findByRole('button', { name: /^delete 2$/i });
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
// The ConfirmDialog should now be open with the count in the message
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/delete 2 alerts/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides Delete buttons when user lacks OPERATOR role', () => {
|
||||
useAuthStore.setState({ roles: ['VIEWER'] });
|
||||
mount();
|
||||
|
||||
// Neither the row-level "Delete alert" button nor the bulk "Delete N" button should appear
|
||||
expect(screen.queryByRole('button', { name: /delete alert/i })).toBeNull();
|
||||
// No selection is active so "Delete N" wouldn't appear anyway, but confirm
|
||||
// there's also no element with "Delete" that would open the confirm dialog
|
||||
const deleteButtons = screen
|
||||
.queryAllByRole('button')
|
||||
.filter((btn) => /^delete\b/i.test(btn.textContent ?? ''));
|
||||
expect(deleteButtons).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clicking row Delete invokes useDeleteAlert and shows an Undo toast', async () => {
|
||||
mount();
|
||||
|
||||
const deleteAlertButton = screen.getByRole('button', { name: /delete alert/i });
|
||||
fireEvent.click(deleteAlertButton);
|
||||
|
||||
// Verify deleteMock was called with the row's id
|
||||
await waitFor(() => {
|
||||
expect(deleteMock).toHaveBeenCalledWith(ROW_FIRING.id);
|
||||
});
|
||||
|
||||
// After deletion a toast appears with "Deleted" title and an "Undo" button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/deleted/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /undo/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,48 +1,497 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, SectionHeader, useToast } from '@cameleer/design-system';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { Inbox, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Button, ButtonGroup, ConfirmDialog, DataTable, EmptyState, Toggle, useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import type { ButtonGroupItem, Column } from '@cameleer/design-system';
|
||||
import { PageLoader } from '../../components/PageLoader';
|
||||
import { useAlerts, useBulkReadAlerts } from '../../api/queries/alerts';
|
||||
import { AlertRow } from './AlertRow';
|
||||
import { SeverityBadge } from '../../components/SeverityBadge';
|
||||
import { AlertStateChip } from '../../components/AlertStateChip';
|
||||
import {
|
||||
useAlerts, useAckAlert, useBulkAckAlerts, useBulkReadAlerts, useMarkAlertRead,
|
||||
useDeleteAlert, useBulkDeleteAlerts, useRestoreAlert,
|
||||
type AlertDto,
|
||||
} from '../../api/queries/alerts';
|
||||
import { useCreateSilence } from '../../api/queries/alertSilences';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import { SilenceRuleMenu } from './SilenceRuleMenu';
|
||||
import { severityToAccent } from './severity-utils';
|
||||
import { formatRelativeTime } from './time-utils';
|
||||
import { renderAlertExpanded } from './alert-expanded';
|
||||
import css from './alerts-page.module.css';
|
||||
import tableStyles from '../../styles/table-section.module.css';
|
||||
|
||||
type AlertSeverity = NonNullable<AlertDto['severity']>;
|
||||
type AlertState = NonNullable<AlertDto['state']>;
|
||||
|
||||
// ── Filter bar items ────────────────────────────────────────────────────────
|
||||
|
||||
const SEVERITY_ITEMS: ButtonGroupItem[] = [
|
||||
{ value: 'CRITICAL', label: 'Critical', color: 'var(--error)' },
|
||||
{ value: 'WARNING', label: 'Warning', color: 'var(--warning)' },
|
||||
{ value: 'INFO', label: 'Info', color: 'var(--text-muted)' },
|
||||
];
|
||||
|
||||
const STATE_ITEMS: ButtonGroupItem[] = [
|
||||
{ value: 'PENDING', label: 'Pending', color: 'var(--text-muted)' },
|
||||
{ value: 'FIRING', label: 'Firing', color: 'var(--error)' },
|
||||
{ value: 'RESOLVED', label: 'Resolved', color: 'var(--success)' },
|
||||
];
|
||||
|
||||
// ── Bulk silence helper ─────────────────────────────────────────────────────
|
||||
|
||||
const SILENCE_PRESETS: Array<{ label: string; hours: number }> = [
|
||||
{ label: '1 hour', hours: 1 },
|
||||
{ label: '8 hours', hours: 8 },
|
||||
{ label: '24 hours', hours: 24 },
|
||||
];
|
||||
|
||||
interface SilenceRulesForSelectionProps {
|
||||
selected: Set<string>;
|
||||
rows: AlertDto[];
|
||||
}
|
||||
|
||||
function SilenceRulesForSelection({ selected, rows }: SilenceRulesForSelectionProps) {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const createSilence = useCreateSilence();
|
||||
|
||||
const ruleIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const id of selected) {
|
||||
const row = rows.find((r) => r.id === id);
|
||||
if (row?.ruleId) ids.add(row.ruleId);
|
||||
}
|
||||
return [...ids];
|
||||
}, [selected, rows]);
|
||||
|
||||
if (ruleIds.length === 0) return null;
|
||||
|
||||
const handlePreset = (hours: number) => async () => {
|
||||
const now = new Date();
|
||||
const results = await Promise.allSettled(
|
||||
ruleIds.map((ruleId) =>
|
||||
createSilence.mutateAsync({
|
||||
matcher: { ruleId },
|
||||
reason: 'Silenced from inbox (bulk)',
|
||||
startsAt: now.toISOString(),
|
||||
endsAt: new Date(now.getTime() + hours * 3_600_000).toISOString(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
if (failed === 0) {
|
||||
toast({ title: `Silenced ${ruleIds.length} rule${ruleIds.length === 1 ? '' : 's'} for ${hours}h`, variant: 'success' });
|
||||
} else {
|
||||
toast({ title: `Silenced ${ruleIds.length - failed}/${ruleIds.length} rules`, description: `${failed} failed`, variant: 'warning' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustom = () => navigate('/alerts/silences');
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 'var(--space-xs)' }}>
|
||||
{SILENCE_PRESETS.map(({ label, hours }) => (
|
||||
<Button
|
||||
key={hours}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={createSilence.isPending}
|
||||
onClick={handlePreset(hours)}
|
||||
>
|
||||
Silence {hours}h
|
||||
</Button>
|
||||
))}
|
||||
<Button variant="ghost" size="sm" onClick={handleCustom}>
|
||||
Custom…
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── InboxPage ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function InboxPage() {
|
||||
const { data, isLoading, error } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 });
|
||||
const bulkRead = useBulkReadAlerts();
|
||||
const { toast } = useToast();
|
||||
// Filter state — defaults: FIRING selected, hide-acked on, hide-read on
|
||||
const [severitySel, setSeveritySel] = useState<Set<string>>(new Set());
|
||||
const [stateSel, setStateSel] = useState<Set<string>>(new Set(['FIRING']));
|
||||
const [hideAcked, setHideAcked] = useState(true);
|
||||
const [hideRead, setHideRead] = useState(true);
|
||||
|
||||
const unreadIds = useMemo(
|
||||
() => (data ?? []).filter((a) => a.state === 'FIRING').map((a) => a.id),
|
||||
[data],
|
||||
);
|
||||
const { data, isLoading, error } = useAlerts({
|
||||
severity: severitySel.size ? ([...severitySel] as AlertSeverity[]) : undefined,
|
||||
state: stateSel.size ? ([...stateSel] as AlertState[]) : undefined,
|
||||
acked: hideAcked ? false : undefined,
|
||||
read: hideRead ? false : undefined,
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
if (isLoading) return <PageLoader />;
|
||||
if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;
|
||||
// Mutations
|
||||
const ack = useAckAlert();
|
||||
const bulkAck = useBulkAckAlerts();
|
||||
const markRead = useMarkAlertRead();
|
||||
const bulkRead = useBulkReadAlerts();
|
||||
const del = useDeleteAlert();
|
||||
const bulkDelete = useBulkDeleteAlerts();
|
||||
const restore = useRestoreAlert();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Selection
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [deletePending, setDeletePending] = useState<string[] | null>(null);
|
||||
|
||||
// RBAC
|
||||
const roles = useAuthStore((s) => s.roles);
|
||||
const canDelete = roles.includes('OPERATOR') || roles.includes('ADMIN');
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
const onMarkAllRead = async () => {
|
||||
if (unreadIds.length === 0) return;
|
||||
const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.id));
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
|
||||
const toggleSelected = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const toggleSelectAll = () =>
|
||||
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
|
||||
|
||||
// Derived counts for bulk-toolbar labels
|
||||
const selectedRows = rows.filter((r) => selected.has(r.id));
|
||||
const unackedSel = selectedRows.filter((r) => r.ackedAt == null).map((r) => r.id);
|
||||
const unreadSel = selectedRows.filter((r) => r.readAt == null).map((r) => r.id);
|
||||
|
||||
// "Acknowledge all firing" target (no-selection state)
|
||||
const firingUnackedIds = rows
|
||||
.filter((r) => r.state === 'FIRING' && r.ackedAt == null)
|
||||
.map((r) => r.id);
|
||||
const allUnreadIds = rows.filter((r) => r.readAt == null).map((r) => r.id);
|
||||
|
||||
// ── handlers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const onAck = async (id: string, title?: string) => {
|
||||
try {
|
||||
await bulkRead.mutateAsync(unreadIds);
|
||||
toast({ title: `Marked ${unreadIds.length} as read`, variant: 'success' });
|
||||
await ack.mutateAsync(id);
|
||||
toast({ title: 'Acknowledged', description: title, variant: 'success' });
|
||||
} catch (e) {
|
||||
toast({ title: 'Ack failed', description: String(e), variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const onMarkRead = async (id: string) => {
|
||||
try {
|
||||
await markRead.mutateAsync(id);
|
||||
toast({ title: 'Marked as read', variant: 'success' });
|
||||
} catch (e) {
|
||||
toast({ title: 'Mark read failed', description: String(e), variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteOne = async (id: string) => {
|
||||
try {
|
||||
await del.mutateAsync(id);
|
||||
setSelected((prev) => {
|
||||
if (!prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
// No built-in action slot in DS toast — render Undo as a Button node
|
||||
const undoNode = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
restore
|
||||
.mutateAsync(id)
|
||||
.then(
|
||||
() => toast({ title: 'Restored', variant: 'success' }),
|
||||
(e: unknown) => toast({ title: 'Undo failed', description: String(e), variant: 'error' }),
|
||||
)
|
||||
}
|
||||
>
|
||||
Undo
|
||||
</Button>
|
||||
) as unknown as string; // DS description accepts ReactNode at runtime
|
||||
toast({ title: 'Deleted', description: undoNode, variant: 'success', duration: 5000 });
|
||||
} catch (e) {
|
||||
toast({ title: 'Delete failed', description: String(e), variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const onBulkAck = async (ids: string[]) => {
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await bulkAck.mutateAsync(ids);
|
||||
setSelected(new Set());
|
||||
toast({ title: `Acknowledged ${ids.length} alert${ids.length === 1 ? '' : 's'}`, variant: 'success' });
|
||||
} catch (e) {
|
||||
toast({ title: 'Bulk ack failed', description: String(e), variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const onBulkRead = async (ids: string[]) => {
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await bulkRead.mutateAsync(ids);
|
||||
setSelected(new Set());
|
||||
toast({ title: `Marked ${ids.length} as read`, variant: 'success' });
|
||||
} catch (e) {
|
||||
toast({ title: 'Bulk read failed', description: String(e), variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
// ── columns ────────────────────────────────────────────────────────────────
|
||||
|
||||
const columns: Column<AlertDto>[] = [
|
||||
{
|
||||
key: 'select', header: '', width: '40px',
|
||||
render: (_, row) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(row.id)}
|
||||
onChange={() => toggleSelected(row.id)}
|
||||
aria-label={`Select ${row.title ?? row.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'severity', header: 'Severity', width: '110px',
|
||||
render: (_, row) =>
|
||||
row.severity ? <SeverityBadge severity={row.severity} /> : null,
|
||||
},
|
||||
{
|
||||
key: 'state', header: 'Status', width: '140px',
|
||||
render: (_, row) =>
|
||||
row.state ? <AlertStateChip state={row.state} silenced={row.silenced} /> : null,
|
||||
},
|
||||
{
|
||||
key: 'title', header: 'Title',
|
||||
render: (_, row) => {
|
||||
const unread = row.readAt == null;
|
||||
return (
|
||||
<div className={`${css.titleCell} ${unread ? css.titleCellUnread : ''}`}>
|
||||
<Link to={`/alerts/inbox/${row.id}`} onClick={() => markRead.mutate(row.id)}>
|
||||
{row.title ?? '(untitled)'}
|
||||
</Link>
|
||||
{row.message && <span className={css.titlePreview}>{row.message}</span>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'age', header: 'Age', width: '100px', sortable: true,
|
||||
render: (_, row) =>
|
||||
row.firedAt ? (
|
||||
<span title={row.firedAt} style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{formatRelativeTime(row.firedAt)}
|
||||
</span>
|
||||
) : '—',
|
||||
},
|
||||
{
|
||||
key: 'rowActions', header: '', width: '220px',
|
||||
render: (_, row) => (
|
||||
<div style={{ display: 'flex', gap: 'var(--space-xs)', justifyContent: 'flex-end' }}>
|
||||
{row.ackedAt == null && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => onAck(row.id, row.title ?? undefined)}
|
||||
disabled={ack.isPending}
|
||||
>
|
||||
Ack
|
||||
</Button>
|
||||
)}
|
||||
{row.readAt == null && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onMarkRead(row.id)}
|
||||
disabled={markRead.isPending}
|
||||
>
|
||||
Mark read
|
||||
</Button>
|
||||
)}
|
||||
{row.ruleId && (
|
||||
<SilenceRuleMenu
|
||||
ruleId={row.ruleId}
|
||||
ruleTitle={row.title ?? undefined}
|
||||
variant="row"
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onDeleteOne(row.id)}
|
||||
disabled={del.isPending}
|
||||
aria-label="Delete alert"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// ── render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
if (isLoading) return <PageLoader />;
|
||||
if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;
|
||||
|
||||
const selectedIds = Array.from(selected);
|
||||
|
||||
const needsAttention = rows.filter((r) => r.readAt == null || r.ackedAt == null).length;
|
||||
const subtitle =
|
||||
selectedIds.length > 0
|
||||
? `${selectedIds.length} selected`
|
||||
: `${needsAttention} need attention · ${rows.length} total`;
|
||||
|
||||
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>
|
||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||
<header className={css.pageHeader}>
|
||||
<div className={css.pageTitleGroup}>
|
||||
<h2 className={css.pageTitle}>Inbox</h2>
|
||||
<span className={css.pageSubtitle}>{subtitle}</span>
|
||||
</div>
|
||||
<div className={css.pageActions}>
|
||||
<ButtonGroup
|
||||
items={SEVERITY_ITEMS}
|
||||
value={severitySel}
|
||||
onChange={setSeveritySel}
|
||||
/>
|
||||
<ButtonGroup
|
||||
items={STATE_ITEMS}
|
||||
value={stateSel}
|
||||
onChange={setStateSel}
|
||||
/>
|
||||
<Toggle
|
||||
label="Hide acked"
|
||||
checked={hideAcked}
|
||||
onChange={(e) => setHideAcked(e.currentTarget.checked)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Hide read"
|
||||
checked={hideRead}
|
||||
onChange={(e) => setHideRead(e.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Filter / bulk toolbar ───────────────────────────────────────── */}
|
||||
<div className={css.filterBar}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => { if (el) el.indeterminate = someSelected; }}
|
||||
onChange={toggleSelectAll}
|
||||
aria-label={allSelected ? 'Deselect all' : 'Select all'}
|
||||
/>
|
||||
{allSelected ? 'Deselect all' : `Select all${rows.length ? ` (${rows.length})` : ''}`}
|
||||
</label>
|
||||
<span style={{ flex: 1 }} />
|
||||
|
||||
{selectedIds.length > 0 ? (
|
||||
/* ── Bulk actions ─────────────────────────────────────────────── */
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onBulkAck(unackedSel)}
|
||||
disabled={unackedSel.length === 0 || bulkAck.isPending}
|
||||
>
|
||||
Acknowledge {unackedSel.length}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onBulkRead(unreadSel)}
|
||||
disabled={unreadSel.length === 0 || bulkRead.isPending}
|
||||
>
|
||||
Mark {unreadSel.length} read
|
||||
</Button>
|
||||
<SilenceRulesForSelection selected={selected} rows={rows} />
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setDeletePending(selectedIds)}
|
||||
disabled={bulkDelete.isPending}
|
||||
>
|
||||
Delete {selectedIds.length}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* ── Global actions (no selection) ───────────────────────────── */
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onBulkAck(firingUnackedIds)}
|
||||
disabled={firingUnackedIds.length === 0 || bulkAck.isPending}
|
||||
>
|
||||
Acknowledge all firing
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onBulkRead(allUnreadIds)}
|
||||
disabled={allUnreadIds.length === 0 || bulkRead.isPending}
|
||||
>
|
||||
Mark all read
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Table / empty ───────────────────────────────────────────────── */}
|
||||
{rows.length === 0 ? (
|
||||
<div className={css.empty}>No open alerts for you in this environment.</div>
|
||||
<EmptyState
|
||||
icon={<Inbox size={32} />}
|
||||
title="All clear"
|
||||
description="No alerts match the current filters."
|
||||
/>
|
||||
) : (
|
||||
rows.map((a) => <AlertRow key={a.id} alert={a} unread={a.state === 'FIRING'} />)
|
||||
<div className={`${tableStyles.tableSection} ${css.tableWrap}`}>
|
||||
<DataTable<AlertDto & { id: string }>
|
||||
columns={columns as Column<AlertDto & { id: string }>[]}
|
||||
data={rows as Array<AlertDto & { id: string }>}
|
||||
sortable
|
||||
flush
|
||||
fillHeight
|
||||
pageSize={200}
|
||||
rowAccent={(row) => row.severity ? severityToAccent(row.severity) : undefined}
|
||||
expandedContent={renderAlertExpanded}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Bulk delete confirmation ─────────────────────────────────────── */}
|
||||
<ConfirmDialog
|
||||
open={deletePending != null}
|
||||
onClose={() => setDeletePending(null)}
|
||||
onConfirm={async () => {
|
||||
if (!deletePending) return;
|
||||
await bulkDelete.mutateAsync(deletePending);
|
||||
toast({ title: `Deleted ${deletePending.length}`, variant: 'success' });
|
||||
setDeletePending(null);
|
||||
setSelected(new Set());
|
||||
}}
|
||||
title="Delete alerts?"
|
||||
message={`Delete ${deletePending?.length ?? 0} alerts? This affects all users.`}
|
||||
confirmText="Delete"
|
||||
variant="danger"
|
||||
loading={bulkDelete.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { AgentLifecycleForm } from './condition-forms/AgentLifecycleForm';
|
||||
import { DeploymentStateForm } from './condition-forms/DeploymentStateForm';
|
||||
import { LogPatternForm } from './condition-forms/LogPatternForm';
|
||||
import { JvmMetricForm } from './condition-forms/JvmMetricForm';
|
||||
@@ -23,6 +24,13 @@ export function ConditionStep({ form, setForm }: { form: FormState; setForm: (f:
|
||||
base.perExchangeLingerSeconds = 300;
|
||||
base.filter = {};
|
||||
}
|
||||
if (kind === 'AGENT_LIFECYCLE') {
|
||||
// Sensible defaults so a rule can be saved without touching every sub-field.
|
||||
// WENT_DEAD is the most "alert-worthy" event out of the six; a 5-minute
|
||||
// window matches the registry's STALE→DEAD cadence + slack for tick jitter.
|
||||
base.eventTypes = ['WENT_DEAD'];
|
||||
base.withinSeconds = 300;
|
||||
}
|
||||
setForm({
|
||||
...form,
|
||||
conditionKind: kind,
|
||||
@@ -42,6 +50,7 @@ export function ConditionStep({ form, setForm }: { form: FormState; setForm: (f:
|
||||
{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 === 'AGENT_LIFECYCLE' && <AgentLifecycleForm 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} />}
|
||||
|
||||
@@ -91,7 +91,7 @@ export function NotifyStep({
|
||||
Preview rendered output
|
||||
</Button>
|
||||
{!ruleId && (
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--muted)' }}>
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Save the rule first to preview rendered output.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { Button, SectionHeader, useToast } from '@cameleer/design-system';
|
||||
import { Alert, Button, useToast } from '@cameleer/design-system';
|
||||
import { PageLoader } from '../../../components/PageLoader';
|
||||
import {
|
||||
useAlertRule,
|
||||
@@ -24,6 +24,7 @@ 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 sectionStyles from '../../../styles/section-card.module.css';
|
||||
import css from './wizard.module.css';
|
||||
|
||||
const STEP_LABELS: Record<WizardStep, string> = {
|
||||
@@ -147,16 +148,17 @@ export default function RuleEditorWizard() {
|
||||
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>
|
||||
)}
|
||||
<h2 className={css.pageTitle}>{isEdit ? `Edit rule: ${form.name}` : 'New alert rule'}</h2>
|
||||
</div>
|
||||
|
||||
{promoteFrom && (
|
||||
<Alert variant="info" title="Promoting a rule">
|
||||
Promoting from <code>{promoteFrom}</code> — review and adjust, then save.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div className={css.promoteBanner}>
|
||||
<strong>Review before saving:</strong>
|
||||
<Alert variant="warning" title="Review before saving">
|
||||
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
|
||||
{warnings.map((w) => (
|
||||
<li key={w.field}>
|
||||
@@ -164,12 +166,14 @@ export default function RuleEditorWizard() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<nav className={css.steps}>
|
||||
{WIZARD_STEPS.map((s, i) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={`${css.step} ${step === s ? css.stepActive : ''} ${i < idx ? css.stepDone : ''}`}
|
||||
onClick={() => setStep(s)}
|
||||
>
|
||||
@@ -177,7 +181,9 @@ export default function RuleEditorWizard() {
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className={css.stepBody}>{body}</div>
|
||||
|
||||
<section className={`${sectionStyles.section} ${css.stepBody}`}>{body}</section>
|
||||
|
||||
<div className={css.footer}>
|
||||
<Button variant="secondary" onClick={onBack} disabled={idx === 0}>
|
||||
Back
|
||||
|
||||
@@ -60,7 +60,7 @@ export function TriggerStep({
|
||||
Test evaluate (uses saved rule)
|
||||
</Button>
|
||||
{!ruleId && (
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--muted)' }}>
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Save the rule first to enable test-evaluate.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { FormField, Input } from '@cameleer/design-system';
|
||||
import type { FormState } from '../form-state';
|
||||
import {
|
||||
AGENT_LIFECYCLE_EVENT_TYPE_OPTIONS,
|
||||
type AgentLifecycleEventType,
|
||||
} from '../../enums';
|
||||
|
||||
/**
|
||||
* Form for `AGENT_LIFECYCLE` conditions. Users pick one or more event types
|
||||
* (allowlist only) and a lookback window in seconds. The evaluator queries
|
||||
* `agent_events` with those filters; each matching row produces its own
|
||||
* {@code AlertInstance}.
|
||||
*/
|
||||
export function AgentLifecycleForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
|
||||
const c = form.condition as Record<string, unknown>;
|
||||
const selected = new Set<AgentLifecycleEventType>(
|
||||
Array.isArray(c.eventTypes) ? (c.eventTypes as AgentLifecycleEventType[]) : [],
|
||||
);
|
||||
|
||||
const patch = (p: Record<string, unknown>) =>
|
||||
setForm({
|
||||
...form,
|
||||
condition: { ...(form.condition as Record<string, unknown>), ...p } as FormState['condition'],
|
||||
});
|
||||
|
||||
const toggle = (t: AgentLifecycleEventType) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(t)) next.delete(t); else next.add(t);
|
||||
patch({ eventTypes: [...next] });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
label="Event types"
|
||||
hint="Fires one alert per matching event. Pick at least one."
|
||||
>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{AGENT_LIFECYCLE_EVENT_TYPE_OPTIONS.map((opt) => {
|
||||
const active = selected.has(opt.value);
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => toggle(opt.value)}
|
||||
style={{
|
||||
border: `1px solid ${active ? 'var(--amber)' : 'var(--border-subtle)'}`,
|
||||
background: active ? 'var(--amber-bg)' : 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
borderRadius: 999,
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Lookback window (seconds)" hint="How far back to search for matching events each tick.">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={(c.withinSeconds as number | undefined) ?? 300}
|
||||
onChange={(e) => patch({ withinSeconds: Number(e.target.value) })}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -160,6 +160,13 @@ export function validateStep(step: WizardStep, f: FormState): string[] {
|
||||
if (c.windowSeconds == null) errs.push('Window (seconds) is required for COUNT_IN_WINDOW.');
|
||||
}
|
||||
}
|
||||
if (f.conditionKind === 'AGENT_LIFECYCLE') {
|
||||
const c = f.condition as Record<string, unknown>;
|
||||
const types = Array.isArray(c.eventTypes) ? (c.eventTypes as string[]) : [];
|
||||
if (types.length === 0) errs.push('Pick at least one event type.');
|
||||
const within = c.withinSeconds as number | undefined;
|
||||
if (within == null || within < 1) errs.push('Lookback window must be \u2265 1 second.');
|
||||
}
|
||||
}
|
||||
if (step === 'trigger') {
|
||||
if (f.evaluationIntervalSeconds < 5) errs.push('Evaluation interval must be \u2265 5 s.');
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
.wizard {
|
||||
padding: 16px;
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: var(--space-md);
|
||||
max-width: 840px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: var(--space-sm);
|
||||
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;
|
||||
gap: var(--space-sm);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.step {
|
||||
@@ -34,17 +37,22 @@
|
||||
padding: 8px 12px;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.step:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stepActive {
|
||||
color: var(--fg);
|
||||
border-bottom-color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--amber);
|
||||
}
|
||||
|
||||
.stepDone {
|
||||
color: var(--fg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stepBody {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { Button, SectionHeader, Toggle, useToast, Badge } from '@cameleer/design-system';
|
||||
import { FilePlus } from 'lucide-react';
|
||||
import {
|
||||
Button, Toggle, useToast, Badge, DataTable,
|
||||
EmptyState, Dropdown, ConfirmDialog,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { PageLoader } from '../../components/PageLoader';
|
||||
import { SeverityBadge } from '../../components/SeverityBadge';
|
||||
import {
|
||||
@@ -10,7 +16,8 @@ import {
|
||||
} 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';
|
||||
import tableStyles from '../../styles/table-section.module.css';
|
||||
import css from './alerts-page.module.css';
|
||||
|
||||
export default function RulesListPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -21,28 +28,32 @@ export default function RulesListPage() {
|
||||
const deleteRule = useDeleteAlertRule();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [pendingDelete, setPendingDelete] = useState<AlertRuleResponse | null>(null);
|
||||
|
||||
if (isLoading) return <PageLoader />;
|
||||
if (error) return <div>Failed to load rules: {String(error)}</div>;
|
||||
if (error) return <div className={css.page}>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 });
|
||||
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;
|
||||
const confirmDelete = async () => {
|
||||
if (!pendingDelete) return;
|
||||
try {
|
||||
await deleteRule.mutateAsync(r.id);
|
||||
toast({ title: 'Deleted', description: r.name, variant: 'success' });
|
||||
await deleteRule.mutateAsync(pendingDelete.id!);
|
||||
toast({ title: 'Deleted', description: pendingDelete.name, variant: 'success' });
|
||||
} catch (e) {
|
||||
toast({ title: 'Delete failed', description: String(e), variant: 'error' });
|
||||
} finally {
|
||||
setPendingDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,66 +61,106 @@ export default function RulesListPage() {
|
||||
navigate(`/alerts/rules/new?promoteFrom=${env}&ruleId=${r.id}&targetEnv=${targetEnvSlug}`);
|
||||
};
|
||||
|
||||
const columns: Column<AlertRuleResponse & { id: string }>[] = [
|
||||
{
|
||||
key: 'name', header: 'Name',
|
||||
render: (_, r) => <Link to={`/alerts/rules/${r.id}`}>{r.name}</Link>,
|
||||
},
|
||||
{
|
||||
key: 'conditionKind', header: 'Type', width: '160px',
|
||||
render: (_, r) => <Badge label={r.conditionKind ?? ''} color="auto" variant="outlined" />,
|
||||
},
|
||||
{
|
||||
key: 'severity', header: 'Severity', width: '110px',
|
||||
render: (_, r) => <SeverityBadge severity={r.severity!} />,
|
||||
},
|
||||
{
|
||||
key: 'enabled', header: 'Enabled', width: '90px',
|
||||
render: (_, r) => (
|
||||
<Toggle
|
||||
checked={!!r.enabled}
|
||||
onChange={() => onToggle(r)}
|
||||
disabled={setEnabled.isPending}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'targets', header: 'Notifies', width: '90px',
|
||||
render: (_, r) => String(r.targets?.length ?? 0),
|
||||
},
|
||||
{
|
||||
key: 'actions', header: '', width: '220px',
|
||||
render: (_, r) => (
|
||||
<div style={{ display: 'flex', gap: 'var(--space-sm)', justifyContent: 'flex-end' }}>
|
||||
{otherEnvs.length > 0 && (
|
||||
<Dropdown
|
||||
trigger={<Button variant="ghost" size="sm">Promote to ▾</Button>}
|
||||
items={otherEnvs.map((e) => ({
|
||||
label: e.slug,
|
||||
onClick: () => onPromote(r, e.slug),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => setPendingDelete(r)} disabled={deleteRule.isPending}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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 className={css.page}>
|
||||
<header className={css.pageHeader}>
|
||||
<div className={css.pageTitleGroup}>
|
||||
<h2 className={css.pageTitle}>Alert rules</h2>
|
||||
<span className={css.pageSubtitle}>
|
||||
{rows.length === 0 ? 'No rules yet' : `${rows.length} rule${rows.length === 1 ? '' : 's'} configured`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.pageActions}>
|
||||
<Link to="/alerts/rules/new">
|
||||
<Button variant="primary">New rule</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FilePlus size={32} />}
|
||||
title="No alert rules"
|
||||
description="Create one to start evaluating alerts for this environment."
|
||||
action={
|
||||
<Link to="/alerts/rules/new">
|
||||
<Button variant="primary">Create rule</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className={`${tableStyles.tableSection} ${css.tableWrap}`}>
|
||||
<DataTable<AlertRuleResponse & { id: string }>
|
||||
columns={columns}
|
||||
data={rows as (AlertRuleResponse & { id: string })[]}
|
||||
flush
|
||||
fillHeight
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!pendingDelete}
|
||||
onClose={() => setPendingDelete(null)}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete alert rule?"
|
||||
message={
|
||||
pendingDelete
|
||||
? `Delete rule "${pendingDelete.name}"? Fired alerts are preserved via rule_snapshot.`
|
||||
: ''
|
||||
}
|
||||
confirmText="Delete"
|
||||
variant="danger"
|
||||
loading={deleteRule.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
71
ui/src/pages/Alerts/SilenceRuleMenu.tsx
Normal file
71
ui/src/pages/Alerts/SilenceRuleMenu.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { BellOff } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button, Dropdown, useToast } from '@cameleer/design-system';
|
||||
import type { DropdownItem } from '@cameleer/design-system';
|
||||
import { useCreateSilence } from '../../api/queries/alertSilences';
|
||||
|
||||
interface Props {
|
||||
ruleId: string;
|
||||
ruleTitle?: string;
|
||||
onDone?: () => void;
|
||||
variant?: 'row' | 'bulk';
|
||||
}
|
||||
|
||||
const PRESETS: Array<{ label: string; hours: number }> = [
|
||||
{ label: '1 hour', hours: 1 },
|
||||
{ label: '8 hours', hours: 8 },
|
||||
{ label: '24 hours', hours: 24 },
|
||||
];
|
||||
|
||||
export function SilenceRuleMenu({ ruleId, ruleTitle, onDone, variant = 'row' }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const createSilence = useCreateSilence();
|
||||
|
||||
const handlePreset = (hours: number) => async () => {
|
||||
const now = new Date();
|
||||
const reason = ruleTitle
|
||||
? `Silenced from inbox (${ruleTitle})`
|
||||
: 'Silenced from inbox';
|
||||
try {
|
||||
await createSilence.mutateAsync({
|
||||
matcher: { ruleId },
|
||||
reason,
|
||||
startsAt: now.toISOString(),
|
||||
endsAt: new Date(now.getTime() + hours * 3_600_000).toISOString(),
|
||||
});
|
||||
toast({ title: `Silenced for ${hours}h`, variant: 'success' });
|
||||
onDone?.();
|
||||
} catch (e) {
|
||||
toast({ title: 'Silence failed', description: String(e), variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustom = () => {
|
||||
navigate(`/alerts/silences?ruleId=${encodeURIComponent(ruleId)}`);
|
||||
};
|
||||
|
||||
const items: DropdownItem[] = [
|
||||
...PRESETS.map(({ label, hours }) => ({
|
||||
label,
|
||||
disabled: createSilence.isPending,
|
||||
onClick: handlePreset(hours),
|
||||
})),
|
||||
{ divider: true, label: '' },
|
||||
{ label: 'Custom…', onClick: handleCustom },
|
||||
];
|
||||
|
||||
const buttonLabel = variant === 'bulk' ? 'Silence rules' : 'Silence rule…';
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<Button variant="secondary" size="sm">
|
||||
<BellOff size={14} style={{ marginRight: 4 }} />
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, FormField, Input, SectionHeader, useToast } from '@cameleer/design-system';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { BellOff } from 'lucide-react';
|
||||
import {
|
||||
Button, FormField, Input, useToast, DataTable,
|
||||
EmptyState, ConfirmDialog, MonoText,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { PageLoader } from '../../components/PageLoader';
|
||||
import {
|
||||
useAlertSilences,
|
||||
@@ -8,6 +14,8 @@ import {
|
||||
type AlertSilenceResponse,
|
||||
} from '../../api/queries/alertSilences';
|
||||
import sectionStyles from '../../styles/section-card.module.css';
|
||||
import tableStyles from '../../styles/table-section.module.css';
|
||||
import css from './alerts-page.module.css';
|
||||
|
||||
export default function SilencesPage() {
|
||||
const { data, isLoading, error } = useAlertSilences();
|
||||
@@ -19,9 +27,18 @@ export default function SilencesPage() {
|
||||
const [matcherRuleId, setMatcherRuleId] = useState('');
|
||||
const [matcherAppSlug, setMatcherAppSlug] = useState('');
|
||||
const [hours, setHours] = useState(1);
|
||||
const [pendingEnd, setPendingEnd] = useState<AlertSilenceResponse | null>(null);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
useEffect(() => {
|
||||
const r = searchParams.get('ruleId');
|
||||
if (r) setMatcherRuleId(r);
|
||||
}, [searchParams]);
|
||||
|
||||
if (isLoading) return <PageLoader />;
|
||||
if (error) return <div>Failed to load silences: {String(error)}</div>;
|
||||
if (error) return <div className={css.page}>Failed to load silences: {String(error)}</div>;
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
const onCreate = async () => {
|
||||
const now = new Date();
|
||||
@@ -50,30 +67,65 @@ export default function SilencesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = async (s: AlertSilenceResponse) => {
|
||||
if (!confirm(`End silence early?`)) return;
|
||||
const confirmEnd = async () => {
|
||||
if (!pendingEnd) return;
|
||||
try {
|
||||
await remove.mutateAsync(s.id!);
|
||||
await remove.mutateAsync(pendingEnd.id!);
|
||||
toast({ title: 'Silence removed', variant: 'success' });
|
||||
} catch (e) {
|
||||
toast({ title: 'Remove failed', description: String(e), variant: 'error' });
|
||||
} finally {
|
||||
setPendingEnd(null);
|
||||
}
|
||||
};
|
||||
|
||||
const rows = data ?? [];
|
||||
const columns: Column<AlertSilenceResponse & { id: string }>[] = [
|
||||
{
|
||||
key: 'matcher', header: 'Matcher',
|
||||
render: (_, s) => <MonoText size="xs">{JSON.stringify(s.matcher)}</MonoText>,
|
||||
},
|
||||
{ key: 'reason', header: 'Reason', render: (_, s) => s.reason ?? '—' },
|
||||
{ key: 'startsAt', header: 'Starts', width: '200px' },
|
||||
{ key: 'endsAt', header: 'Ends', width: '200px' },
|
||||
{
|
||||
key: 'actions', header: '', width: '90px',
|
||||
render: (_, s) => (
|
||||
<Button variant="ghost" size="sm" onClick={() => setPendingEnd(s)}>
|
||||
End early
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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)">
|
||||
<div className={css.page}>
|
||||
<header className={css.pageHeader}>
|
||||
<div className={css.pageTitleGroup}>
|
||||
<h2 className={css.pageTitle}>Alert silences</h2>
|
||||
<span className={css.pageSubtitle}>
|
||||
{rows.length === 0
|
||||
? 'Nothing silenced right now'
|
||||
: `${rows.length} active silence${rows.length === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={sectionStyles.section}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, minmax(0, 1fr)) auto',
|
||||
gap: 'var(--space-sm)',
|
||||
alignItems: 'end',
|
||||
}}
|
||||
>
|
||||
<FormField label="Rule ID" hint="Exact rule id (optional)">
|
||||
<Input value={matcherRuleId} onChange={(e) => setMatcherRuleId(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="App slug (optional)">
|
||||
<FormField label="App slug" hint="App slug (optional)">
|
||||
<Input value={matcherAppSlug} onChange={(e) => setMatcherAppSlug(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Duration (hours)">
|
||||
<FormField label="Duration" hint="Hours">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -81,7 +133,7 @@ export default function SilencesPage() {
|
||||
onChange={(e) => setHours(Number(e.target.value))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Reason">
|
||||
<FormField label="Reason" hint="Context for operators">
|
||||
<Input
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
@@ -92,39 +144,35 @@ export default function SilencesPage() {
|
||||
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>
|
||||
</section>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<BellOff size={32} />}
|
||||
title="No silences"
|
||||
description="Nothing is currently silenced in this environment."
|
||||
/>
|
||||
) : (
|
||||
<div className={`${tableStyles.tableSection} ${css.tableWrap}`}>
|
||||
<DataTable<AlertSilenceResponse & { id: string }>
|
||||
columns={columns}
|
||||
data={rows.map((s) => ({ ...s, id: s.id ?? '' }))}
|
||||
flush
|
||||
fillHeight
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!pendingEnd}
|
||||
onClose={() => setPendingEnd(null)}
|
||||
onConfirm={confirmEnd}
|
||||
title="End silence?"
|
||||
message="End this silence early? Affected rules will resume firing."
|
||||
confirmText="End silence"
|
||||
variant="warning"
|
||||
loading={remove.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
41
ui/src/pages/Alerts/alert-expanded.tsx
Normal file
41
ui/src/pages/Alerts/alert-expanded.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { AlertDto } from '../../api/queries/alerts';
|
||||
import css from './alerts-page.module.css';
|
||||
|
||||
/**
|
||||
* Shared DataTable expandedContent renderer for alert rows.
|
||||
* Used by Inbox, All alerts, and History pages.
|
||||
*/
|
||||
export function renderAlertExpanded(alert: AlertDto) {
|
||||
return (
|
||||
<div className={css.expanded}>
|
||||
{alert.message && (
|
||||
<div className={css.expandedField}>
|
||||
<span className={css.expandedLabel}>Message</span>
|
||||
<p className={css.expandedValue}>{alert.message}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className={css.expandedGrid}>
|
||||
<div className={css.expandedField}>
|
||||
<span className={css.expandedLabel}>Fired at</span>
|
||||
<span className={css.expandedValue}>{alert.firedAt ?? '—'}</span>
|
||||
</div>
|
||||
{alert.resolvedAt && (
|
||||
<div className={css.expandedField}>
|
||||
<span className={css.expandedLabel}>Resolved at</span>
|
||||
<span className={css.expandedValue}>{alert.resolvedAt}</span>
|
||||
</div>
|
||||
)}
|
||||
{alert.ackedAt && (
|
||||
<div className={css.expandedField}>
|
||||
<span className={css.expandedLabel}>Acknowledged at</span>
|
||||
<span className={css.expandedValue}>{alert.ackedAt}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={css.expandedField}>
|
||||
<span className={css.expandedLabel}>Rule</span>
|
||||
<span className={css.expandedValue}>{alert.ruleId ?? '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,125 @@
|
||||
.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);
|
||||
.page {
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding-bottom: var(--space-sm);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pageTitleGroup {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.pageSubtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pageActions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterBar {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.titleCell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titleCell a {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.titleCell a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.titleCellUnread a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.titlePreview {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.expanded {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.expandedGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.expandedField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.expandedLabel {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.expandedValue {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.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); }
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
JVM_AGGREGATION_OPTIONS,
|
||||
EXCHANGE_FIRE_MODE_OPTIONS,
|
||||
TARGET_KIND_OPTIONS,
|
||||
AGENT_LIFECYCLE_EVENT_TYPE_OPTIONS,
|
||||
} from './enums';
|
||||
|
||||
/**
|
||||
@@ -25,12 +26,24 @@ describe('alerts/enums option arrays', () => {
|
||||
{ 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: 'AGENT_LIFECYCLE', label: 'Agent lifecycle (register / restart / stale / dead)' },
|
||||
{ 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)' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('AGENT_LIFECYCLE_EVENT_TYPE_OPTIONS', () => {
|
||||
expect(AGENT_LIFECYCLE_EVENT_TYPE_OPTIONS).toEqual([
|
||||
{ value: 'WENT_STALE', label: 'Went stale (heartbeat missed)' },
|
||||
{ value: 'WENT_DEAD', label: 'Went dead (extended silence)' },
|
||||
{ value: 'RECOVERED', label: 'Recovered (stale → live)' },
|
||||
{ value: 'REGISTERED', label: 'Registered (first check-in)' },
|
||||
{ value: 'RE_REGISTERED', label: 'Re-registered (app restart)' },
|
||||
{ value: 'DEREGISTERED', label: 'Deregistered (graceful shutdown)' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('SEVERITY_OPTIONS', () => {
|
||||
expect(SEVERITY_OPTIONS).toEqual([
|
||||
{ value: 'CRITICAL', label: 'Critical' },
|
||||
|
||||
@@ -44,6 +44,13 @@ export type RouteMetric = 'ERROR_RATE' | 'AVG_DURATION_MS' | 'P99_LATENCY_M
|
||||
export type Comparator = 'GT' | 'GTE' | 'LT' | 'LTE' | 'EQ';
|
||||
export type JvmAggregation = 'MAX' | 'MIN' | 'AVG' | 'LATEST';
|
||||
export type ExchangeFireMode = 'PER_EXCHANGE' | 'COUNT_IN_WINDOW';
|
||||
export type AgentLifecycleEventType =
|
||||
| 'REGISTERED'
|
||||
| 'RE_REGISTERED'
|
||||
| 'DEREGISTERED'
|
||||
| 'WENT_STALE'
|
||||
| 'WENT_DEAD'
|
||||
| 'RECOVERED';
|
||||
|
||||
export interface Option<T extends string> { value: T; label: string }
|
||||
|
||||
@@ -73,6 +80,7 @@ const CONDITION_KIND_LABELS: Record<ConditionKind, string> = {
|
||||
ROUTE_METRIC: 'Route metric (error rate, latency, throughput)',
|
||||
EXCHANGE_MATCH: 'Exchange match (specific failures)',
|
||||
AGENT_STATE: 'Agent state (DEAD / STALE)',
|
||||
AGENT_LIFECYCLE: 'Agent lifecycle (register / restart / stale / dead)',
|
||||
DEPLOYMENT_STATE: 'Deployment state (FAILED / DEGRADED)',
|
||||
LOG_PATTERN: 'Log pattern (count of matching logs)',
|
||||
JVM_METRIC: 'JVM metric (heap, GC, inflight)',
|
||||
@@ -114,6 +122,15 @@ const EXCHANGE_FIRE_MODE_LABELS: Record<ExchangeFireMode, string> = {
|
||||
COUNT_IN_WINDOW: 'Threshold: N matches in window',
|
||||
};
|
||||
|
||||
const AGENT_LIFECYCLE_EVENT_TYPE_LABELS: Record<AgentLifecycleEventType, string> = {
|
||||
WENT_STALE: 'Went stale (heartbeat missed)',
|
||||
WENT_DEAD: 'Went dead (extended silence)',
|
||||
RECOVERED: 'Recovered (stale → live)',
|
||||
REGISTERED: 'Registered (first check-in)',
|
||||
RE_REGISTERED: 'Re-registered (app restart)',
|
||||
DEREGISTERED: 'Deregistered (graceful shutdown)',
|
||||
};
|
||||
|
||||
const TARGET_KIND_LABELS: Record<TargetKind, string> = {
|
||||
USER: 'User',
|
||||
GROUP: 'Group',
|
||||
@@ -147,3 +164,5 @@ export const COMPARATOR_OPTIONS: Option<Comparator>[] = toOptions
|
||||
export const JVM_AGGREGATION_OPTIONS: Option<JvmAggregation>[] = toOptions(JVM_AGGREGATION_LABELS, JVM_AGGREGATION_HIDDEN);
|
||||
export const EXCHANGE_FIRE_MODE_OPTIONS: Option<ExchangeFireMode>[] = toOptions(EXCHANGE_FIRE_MODE_LABELS);
|
||||
export const TARGET_KIND_OPTIONS: Option<TargetKind>[] = toOptions(TARGET_KIND_LABELS);
|
||||
export const AGENT_LIFECYCLE_EVENT_TYPE_OPTIONS: Option<AgentLifecycleEventType>[] =
|
||||
toOptions(AGENT_LIFECYCLE_EVENT_TYPE_LABELS);
|
||||
|
||||
16
ui/src/pages/Alerts/severity-utils.test.ts
Normal file
16
ui/src/pages/Alerts/severity-utils.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { severityToAccent } from './severity-utils';
|
||||
|
||||
describe('severityToAccent', () => {
|
||||
it('maps CRITICAL → error', () => {
|
||||
expect(severityToAccent('CRITICAL')).toBe('error');
|
||||
});
|
||||
|
||||
it('maps WARNING → warning', () => {
|
||||
expect(severityToAccent('WARNING')).toBe('warning');
|
||||
});
|
||||
|
||||
it('maps INFO → undefined (no row tint)', () => {
|
||||
expect(severityToAccent('INFO')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
12
ui/src/pages/Alerts/severity-utils.ts
Normal file
12
ui/src/pages/Alerts/severity-utils.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { AlertDto } from '../../api/queries/alerts';
|
||||
|
||||
type Severity = NonNullable<AlertDto['severity']>;
|
||||
export type RowAccent = 'error' | 'warning' | undefined;
|
||||
|
||||
export function severityToAccent(severity: Severity): RowAccent {
|
||||
switch (severity) {
|
||||
case 'CRITICAL': return 'error';
|
||||
case 'WARNING': return 'warning';
|
||||
case 'INFO': return undefined;
|
||||
}
|
||||
}
|
||||
32
ui/src/pages/Alerts/time-utils.test.ts
Normal file
32
ui/src/pages/Alerts/time-utils.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatRelativeTime } from './time-utils';
|
||||
|
||||
const NOW = new Date('2026-04-21T12:00:00Z');
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
it('returns "just now" for < 30s', () => {
|
||||
expect(formatRelativeTime('2026-04-21T11:59:50Z', NOW)).toBe('just now');
|
||||
});
|
||||
|
||||
it('returns minutes for < 60m', () => {
|
||||
expect(formatRelativeTime('2026-04-21T11:57:00Z', NOW)).toBe('3m ago');
|
||||
});
|
||||
|
||||
it('returns hours for < 24h', () => {
|
||||
expect(formatRelativeTime('2026-04-21T10:00:00Z', NOW)).toBe('2h ago');
|
||||
});
|
||||
|
||||
it('returns days for < 30d', () => {
|
||||
expect(formatRelativeTime('2026-04-18T12:00:00Z', NOW)).toBe('3d ago');
|
||||
});
|
||||
|
||||
it('returns locale date string for older than 30d', () => {
|
||||
const out = formatRelativeTime('2025-01-01T00:00:00Z', NOW);
|
||||
expect(out).not.toMatch(/ago$/);
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('handles future timestamps by clamping to "just now"', () => {
|
||||
expect(formatRelativeTime('2026-04-21T12:00:30Z', NOW)).toBe('just now');
|
||||
});
|
||||
});
|
||||
12
ui/src/pages/Alerts/time-utils.ts
Normal file
12
ui/src/pages/Alerts/time-utils.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function formatRelativeTime(iso: string, now: Date = new Date()): string {
|
||||
const then = new Date(iso).getTime();
|
||||
const diffSec = Math.max(0, Math.floor((now.getTime() - then) / 1000));
|
||||
if (diffSec < 30) return 'just now';
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
|
||||
if (diffSec < 86_400) return `${Math.floor(diffSec / 3600)}h ago`;
|
||||
const diffDays = Math.floor(diffSec / 86_400);
|
||||
if (diffDays < 30) return `${diffDays}d ago`;
|
||||
return new Date(iso).toLocaleDateString('en-GB', {
|
||||
year: 'numeric', month: 'short', day: '2-digit',
|
||||
});
|
||||
}
|
||||
@@ -24,8 +24,6 @@ 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'));
|
||||
@@ -84,8 +82,6 @@ export const router = createBrowserRouter([
|
||||
// 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> },
|
||||
|
||||
@@ -62,12 +62,14 @@ test.describe('alerting UI smoke', () => {
|
||||
const main = page.locator('main');
|
||||
await expect(main.getByRole('link', { name: ruleName })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Cleanup: delete.
|
||||
page.once('dialog', (d) => d.accept());
|
||||
// Cleanup: open ConfirmDialog via row Delete button, confirm in dialog.
|
||||
await page
|
||||
.getByRole('row', { name: new RegExp(ruleName) })
|
||||
.getByRole('button', { name: /^delete$/i })
|
||||
.click();
|
||||
const confirmDelete = page.getByRole('dialog');
|
||||
await expect(confirmDelete.getByText(/delete alert rule/i)).toBeVisible();
|
||||
await confirmDelete.getByRole('button', { name: /^delete$/i }).click();
|
||||
await expect(main.getByRole('link', { name: ruleName })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -97,11 +99,13 @@ test.describe('alerting UI smoke', () => {
|
||||
|
||||
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 })
|
||||
.getByRole('button', { name: /^end early$/i })
|
||||
.click();
|
||||
const confirmEnd = page.getByRole('dialog');
|
||||
await expect(confirmEnd.getByText(/end silence/i)).toBeVisible();
|
||||
await confirmEnd.getByRole('button', { name: /end silence/i }).click();
|
||||
await expect(page.getByText(unique)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"root":["./src/config.ts","./src/main.tsx","./src/router.tsx","./src/swagger-ui-dist.d.ts","./src/vite-env.d.ts","./src/api/client.ts","./src/api/schema.d.ts","./src/api/types.ts","./src/api/queries/agents.ts","./src/api/queries/catalog.ts","./src/api/queries/diagrams.ts","./src/api/queries/executions.ts","./src/api/queries/admin/admin-api.ts","./src/api/queries/admin/audit.ts","./src/api/queries/admin/database.ts","./src/api/queries/admin/opensearch.ts","./src/api/queries/admin/rbac.ts","./src/api/queries/admin/thresholds.ts","./src/auth/loginpage.tsx","./src/auth/oidccallback.tsx","./src/auth/protectedroute.tsx","./src/auth/auth-store.ts","./src/auth/use-auth.ts","./src/components/layoutshell.tsx","./src/pages/admin/auditlogpage.tsx","./src/pages/admin/databaseadminpage.tsx","./src/pages/admin/oidcconfigpage.tsx","./src/pages/admin/opensearchadminpage.tsx","./src/pages/admin/rbacpage.tsx","./src/pages/agenthealth/agenthealth.tsx","./src/pages/agentinstance/agentinstance.tsx","./src/pages/dashboard/dashboard.tsx","./src/pages/exchangedetail/exchangedetail.tsx","./src/pages/routes/routesmetrics.tsx","./src/pages/swagger/swaggerpage.tsx"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user