16 TDD tasks covering V17 migration (drop ACKNOWLEDGED + add read_at/deleted_at + drop alert_reads + rework open-rule index), backend repo/controller/endpoints including /restore for undo-toast backing, OpenAPI regen, UI rebuild (single filterable inbox, row/bulk actions, silence-rule quick menu, SilencesPage ?ruleId= prefill), concrete test bodies, and rules/CLAUDE.md updates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1616 lines
66 KiB
Markdown
1616 lines
66 KiB
Markdown
# Alerts Inbox Redesign Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Collapse the Inbox / All / History alert pages into a single filterable inbox; make `read`/`ack`/`delete` global timestamp flags; add Silence-rule and Delete row/bulk actions.
|
|
|
|
**Architecture:** Drop `ACKNOWLEDGED` from `AlertState` (orthogonal `acked_at` flag already exists); add `read_at` + `deleted_at` columns; drop the `alert_reads` table; rework V13 open-rule unique index predicate to `state IN ('PENDING','FIRING') AND deleted_at IS NULL`. Rewire `/read`, `/bulk-read`, `countUnread` to update `alert_instances` directly. Add `DELETE /alerts/{id}`, `POST /alerts/{id}/restore` (undo-toast backing), `POST /alerts/bulk-delete`, `POST /alerts/bulk-ack`. Rebuild `InboxPage.tsx` with a 4-filter bar; delete `AllAlertsPage` + `HistoryPage`; trim sidebar.
|
|
|
|
**Tech Stack:** Java 17 / Spring Boot 3.4.3 / Flyway / PostgreSQL 16 / TypeScript / React 18 / React Router v6 / TanStack Query / `@cameleer/design-system`.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-04-21-alerts-inbox-redesign-design.md`
|
|
|
|
---
|
|
|
|
## File structure
|
|
|
|
**Backend (core):**
|
|
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertState.java` — drop `ACKNOWLEDGED`
|
|
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertInstance.java` — add `readAt`, `deletedAt`
|
|
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertInstanceRepository.java` — add `acked`/`read` filter params, `markRead`/`bulkMarkRead`/`softDelete`/`bulkSoftDelete`, `countUnread(envId, userId, groupIds, roleNames)` (replaces old single-target countUnreadBySeverityForUser)
|
|
- Delete: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertReadRepository.java`
|
|
|
|
**Backend (app):**
|
|
- Create: `cameleer-server-app/src/main/resources/db/migration/V17__alerts_drop_acknowledged.sql`
|
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertInstanceRepository.java`
|
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AlertStateTransitions.java` — drop `ACKNOWLEDGED` case
|
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertController.java` — DELETE, bulk-delete, bulk-ack, acked/read filter params, rewire `/read` + `/bulk-read`
|
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/InAppInboxQuery.java` — add filter params, countUnread rewire
|
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertDto.java` — add `readAt`
|
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/config/AlertingBeanConfig.java` — drop `alertReadRepository` bean
|
|
- Delete: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepository.java`
|
|
|
|
**UI:**
|
|
- Regenerate: `ui/src/api/schema.d.ts`, `ui/src/api/openapi.json`
|
|
- Modify: `ui/src/api/queries/alerts.ts` — new mutations, new filter params
|
|
- Delete: `ui/src/pages/Alerts/AllAlertsPage.tsx`, `ui/src/pages/Alerts/HistoryPage.tsx`
|
|
- Modify: `ui/src/router.tsx` — drop `/alerts/all`, `/alerts/history`
|
|
- Modify: `ui/src/components/sidebar-utils.ts` — trim `buildAlertsTreeNodes`
|
|
- Modify: `ui/src/pages/Alerts/InboxPage.tsx` — new filter bar + actions
|
|
- Create: `ui/src/pages/Alerts/SilenceRuleMenu.tsx` — small duration-picker menu
|
|
- Modify: `ui/src/pages/Alerts/SilencesPage.tsx` — read `?ruleId=` URL param
|
|
|
|
**Tests:**
|
|
- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertInstanceRepositoryIT.java`
|
|
- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertControllerIT.java`
|
|
- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AlertStateTransitionsTest.java`
|
|
- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/AlertingFullLifecycleIT.java`
|
|
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V17MigrationIT.java`
|
|
- Create: `ui/src/pages/Alerts/InboxPage.test.tsx`
|
|
|
|
**Docs/rules:**
|
|
- Modify: `.claude/rules/app-classes.md`, `.claude/rules/ui.md`, `.claude/rules/core-classes.md`
|
|
- Modify: `CLAUDE.md`
|
|
|
|
---
|
|
|
|
## Working rules (every task)
|
|
|
|
1. **Run migration integration tests via Testcontainers** — `mvn -pl cameleer-server-app -Dtest=<ClassName> test`. The suite already bootstraps Postgres via `@SpringBootTest`.
|
|
2. **Commit per task.** Commit message format: `feat(alerts): <what>` / `refactor(alerts): <what>` / `test(alerts): <what>`. End with the Claude co-author trailer.
|
|
3. **After any backend REST change**, regenerate OpenAPI in one step: `cd ui && (curl -sS http://localhost:8081/api/v1/api-docs > src/api/openapi.json) && npx openapi-typescript src/api/openapi.json -o src/api/schema.d.ts`. This requires the backend running.
|
|
4. **Run `gitnexus_impact` before editing any symbol touched by ≥2 callers**, per project rule. Report blast radius. Run `gitnexus_detect_changes` pre-commit.
|
|
5. **Don't batch.** One task = one commit. Mark TODO items complete as you go.
|
|
|
|
---
|
|
|
|
## Task 1: V17 migration — drop ACKNOWLEDGED, add read_at + deleted_at, drop alert_reads, rework open-rule index
|
|
|
|
**Files:**
|
|
- Create: `cameleer-server-app/src/main/resources/db/migration/V17__alerts_drop_acknowledged.sql`
|
|
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V17MigrationIT.java`
|
|
|
|
- [ ] **Step 1: Write the migration SQL**
|
|
|
|
```sql
|
|
-- 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)
|
|
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 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;
|
|
```
|
|
|
|
- [ ] **Step 2: Write the failing migration IT**
|
|
|
|
```java
|
|
package com.cameleer.server.app.alerting.storage;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
import org.springframework.boot.test.context.SpringBootTest;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.springframework.test.context.ActiveProfiles;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
|
|
@SpringBootTest
|
|
@ActiveProfiles("test")
|
|
class V17MigrationIT {
|
|
|
|
@Autowired JdbcTemplate jdbc;
|
|
|
|
@Test
|
|
void alert_state_enum_drops_acknowledged() {
|
|
var values = jdbc.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 = jdbc.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 = jdbc.queryForObject("""
|
|
SELECT COUNT(*)::int FROM information_schema.tables
|
|
WHERE table_name = 'alert_reads'
|
|
""", Integer.class);
|
|
assertThat(count).isZero();
|
|
}
|
|
|
|
@Test
|
|
void open_rule_index_predicate_is_reworked() {
|
|
String def = jdbc.queryForObject("""
|
|
SELECT pg_get_indexdef(indexrelid)
|
|
FROM pg_index
|
|
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
|
|
WHERE pg_class.relname = 'alert_instances_open_rule_uq'
|
|
""", String.class);
|
|
assertThat(def).contains("state = ANY (ARRAY['PENDING'::alert_state_enum, 'FIRING'::alert_state_enum])");
|
|
assertThat(def).contains("deleted_at IS NULL");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run the tests — expect 4 failures** (migration not applied yet)
|
|
|
|
Run: `mvn -pl cameleer-server-app -Dtest=V17MigrationIT test`
|
|
Expected: 4 failures — enum still has ACKNOWLEDGED, columns don't exist, alert_reads still exists, index predicate wrong.
|
|
|
|
- [ ] **Step 4: Run the full suite to verify clean up-migration** — `mvn -pl cameleer-server-app verify -DskipITs=false`. Expected: V17 test class green; many existing tests RED because they reference `ACKNOWLEDGED`. That's fine — they get fixed in subsequent tasks.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add cameleer-server-app/src/main/resources/db/migration/V17__alerts_drop_acknowledged.sql \
|
|
cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V17MigrationIT.java
|
|
git commit -m "feat(alerts): V17 migration — drop ACKNOWLEDGED, add read_at + deleted_at"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Drop ACKNOWLEDGED from AlertState + add read_at/deleted_at to AlertInstance
|
|
|
|
**Files:**
|
|
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertState.java`
|
|
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertInstance.java`
|
|
|
|
- [ ] **Step 1: Update `AlertState.java`** to exactly:
|
|
|
|
```java
|
|
package com.cameleer.server.core.alerting;
|
|
|
|
public enum AlertState { PENDING, FIRING, RESOLVED }
|
|
```
|
|
|
|
- [ ] **Step 2: Add `readAt` + `deletedAt` to `AlertInstance`** — insert the fields after `lastNotifiedAt` (keep insertion order stable for DTOs), update compact ctor if needed, add witherr helpers `withReadAt`, `withDeletedAt`:
|
|
|
|
```java
|
|
public record AlertInstance(
|
|
UUID id,
|
|
UUID ruleId,
|
|
Map<String, Object> ruleSnapshot,
|
|
UUID environmentId,
|
|
AlertState state,
|
|
AlertSeverity severity,
|
|
Instant firedAt,
|
|
Instant ackedAt,
|
|
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,
|
|
Map<String, Object> context,
|
|
String title,
|
|
String message,
|
|
List<String> targetUserIds,
|
|
List<UUID> targetGroupIds,
|
|
List<String> targetRoleNames) {
|
|
// ... existing compact ctor unchanged (list/map copies) ...
|
|
|
|
public AlertInstance withReadAt(Instant i) { /* new record with readAt=i */ }
|
|
public AlertInstance withDeletedAt(Instant i) { /* new record with deletedAt=i */ }
|
|
// ... update every existing wither to include readAt/deletedAt in positional args ...
|
|
}
|
|
```
|
|
|
|
All existing `withXxx` helpers must be updated to pass `readAt, deletedAt` positionally to the new-record construction. This is mechanical. Any call sites in the codebase that construct `AlertInstance` directly need `null, null` inserted at those positions.
|
|
|
|
- [ ] **Step 3: Build the module** — `mvn -pl cameleer-server-core compile`. Fix any compile errors in callers of the constructor (seed data / tests). Expected caller: `AlertStateTransitions.newInstance` — add `null, null` for readAt/deletedAt.
|
|
|
|
- [ ] **Step 4: Run the core unit tests** — `mvn -pl cameleer-server-core test`. Expected: any direct `AlertState.ACKNOWLEDGED` references in `AlertScopeTest` → drop them or replace with `FIRING`.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertState.java \
|
|
cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertInstance.java \
|
|
cameleer-server-core/src/test/java/com/cameleer/server/core/alerting/AlertScopeTest.java \
|
|
cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AlertStateTransitions.java
|
|
git commit -m "refactor(alerts): drop ACKNOWLEDGED from AlertState, add readAt/deletedAt to AlertInstance"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: AlertStateTransitions — drop ACKNOWLEDGED branch
|
|
|
|
**Files:**
|
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AlertStateTransitions.java`
|
|
- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AlertStateTransitionsTest.java`
|
|
|
|
- [ ] **Step 1: Write the failing test case** — append to `AlertStateTransitionsTest.java`:
|
|
|
|
```java
|
|
@Test
|
|
void firing_with_ack_stays_firing_on_next_firing_tick() {
|
|
// Pre-redesign this was the "ACKNOWLEDGED stays ACK" case. Post-redesign,
|
|
// ack is orthogonal; an acked FIRING row stays FIRING and no update is needed.
|
|
AlertInstance current = newInstance(AlertState.FIRING)
|
|
.withAck("alice", Instant.parse("2026-04-21T10:00:00Z"));
|
|
Optional<AlertInstance> out = AlertStateTransitions.apply(
|
|
current, new EvalResult.Firing(1.0, null, Map.of()), rule, NOW);
|
|
assertThat(out).isEmpty();
|
|
}
|
|
```
|
|
|
|
(Use existing `newInstance` helper and `rule` field in the test class.)
|
|
|
|
- [ ] **Step 2: Run test — expect FAIL with `NoSuchFieldError: ACKNOWLEDGED`** (switch-case references removed enum value). Run: `mvn -pl cameleer-server-app -Dtest=AlertStateTransitionsTest test`.
|
|
|
|
- [ ] **Step 3: Update `AlertStateTransitions.onFiring`** — replace the switch:
|
|
|
|
```java
|
|
return switch (current.state()) {
|
|
case PENDING -> {
|
|
Instant promoteAt = current.firedAt().plusSeconds(rule.forDurationSeconds());
|
|
if (!promoteAt.isAfter(now)) {
|
|
yield Optional.of(current
|
|
.withState(AlertState.FIRING)
|
|
.withFiredAt(now));
|
|
}
|
|
yield Optional.empty();
|
|
}
|
|
case FIRING -> Optional.empty();
|
|
case RESOLVED -> Optional.empty();
|
|
};
|
|
```
|
|
|
|
Also update `onClear` comment to remove the `/ ACKNOWLEDGED` mention.
|
|
|
|
- [ ] **Step 4: Run test — expect PASS** + any existing test in this class that asserted the ACKNOWLEDGED branch needs to be dropped or converted. The old "ack stays ack" case becomes the new "ack-with-fire stays firing (no update)" case covered above.
|
|
|
|
- [ ] **Step 5: Run the full alerting test suite** — `mvn -pl cameleer-server-app -Dtest='*Alert*' test`. Many still RED — fixed in later tasks.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AlertStateTransitions.java \
|
|
cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AlertStateTransitionsTest.java
|
|
git commit -m "refactor(alerts): state machine — acked is orthogonal, no transition on ack"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: AlertInstanceRepository interface — filter params + new methods, drop AlertReadRepository
|
|
|
|
**Files:**
|
|
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertInstanceRepository.java`
|
|
- Delete: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertReadRepository.java`
|
|
|
|
- [ ] **Step 1: Rewrite `AlertInstanceRepository`** to add filter params + new methods:
|
|
|
|
```java
|
|
package com.cameleer.server.core.alerting;
|
|
|
|
import java.time.Instant;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Optional;
|
|
import java.util.UUID;
|
|
|
|
public interface AlertInstanceRepository {
|
|
AlertInstance save(AlertInstance instance);
|
|
Optional<AlertInstance> findById(UUID id);
|
|
|
|
/** 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 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}.
|
|
*/
|
|
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);
|
|
|
|
/** 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);
|
|
}
|
|
```
|
|
|
|
**Breaking changes vs prior signature:**
|
|
- `listForInbox` gains `acked` and `read` params (tri-state Boolean).
|
|
- `countUnreadBySeverityForUser(envId, userId)` → `countUnreadBySeverity(envId, userId, groupIds, roleNames)`. Renamed + extended scope.
|
|
- New: `markRead`, `bulkMarkRead`, `softDelete`, `bulkSoftDelete`, `bulkAck`.
|
|
|
|
- [ ] **Step 2: Delete `AlertReadRepository.java`** — `rm cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertReadRepository.java`. Read/ack/delete all live on `AlertInstanceRepository` now.
|
|
|
|
- [ ] **Step 3: Compile — expect caller breakage** — `mvn -pl cameleer-server-core compile`. Then `mvn -pl cameleer-server-app compile` will surface every broken call site. Known breakages (fixed in later tasks, do **not** patch them here):
|
|
- `PostgresAlertInstanceRepository` (Task 5)
|
|
- `AlertController` (Task 6)
|
|
- `InAppInboxQuery` (Task 6)
|
|
- `AlertingBeanConfig` (Task 5)
|
|
- `PostgresAlertReadRepository` (Task 5)
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertInstanceRepository.java
|
|
git rm cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertReadRepository.java
|
|
git commit -m "feat(alerts): core repo — filter params + markRead/softDelete/bulkAck; drop AlertReadRepository"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: PostgresAlertInstanceRepository — save/rowMapper, new methods, listForInbox filters, countUnread
|
|
|
|
**Files:**
|
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertInstanceRepository.java`
|
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/config/AlertingBeanConfig.java`
|
|
- Delete: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepository.java`
|
|
- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertInstanceRepositoryIT.java`
|
|
|
|
- [ ] **Step 1: Update `save` SQL** — add `read_at`, `deleted_at` to INSERT column list, values list, `ON CONFLICT DO UPDATE` list. Add `Timestamp.from(i.readAt())`, `Timestamp.from(i.deletedAt())` to the `jdbc.update(...)` args (via the existing `ts()` helper that null-safes). Follow the same pattern as `ackedAt`.
|
|
|
|
- [ ] **Step 2: Update `findOpenForRule` predicate**:
|
|
|
|
```java
|
|
var list = jdbc.query("""
|
|
SELECT * FROM alert_instances
|
|
WHERE rule_id = ?
|
|
AND state IN ('PENDING','FIRING')
|
|
AND deleted_at IS NULL
|
|
LIMIT 1
|
|
""", rowMapper(), ruleId);
|
|
```
|
|
|
|
- [ ] **Step 3: Update `ack` — no state change anymore**:
|
|
|
|
```java
|
|
@Override
|
|
public void ack(UUID id, String userId, Instant when) {
|
|
jdbc.update("""
|
|
UPDATE alert_instances
|
|
SET acked_at = ?, acked_by = ?
|
|
WHERE id = ? AND acked_at IS NULL AND deleted_at IS NULL
|
|
""", Timestamp.from(when), userId, id);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Implement `listForInbox` with tri-state filters** — add to existing dynamic SQL:
|
|
|
|
```java
|
|
// after severity filter block:
|
|
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 ?");
|
|
```
|
|
|
|
Signature matches the interface (`Boolean acked`, `Boolean read`).
|
|
|
|
- [ ] **Step 5: Rewrite `countUnreadBySeverity`** — removes the `alert_reads` join:
|
|
|
|
```java
|
|
@Override
|
|
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 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 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, groupArray, roleArray);
|
|
return counts;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Implement new mutation methods**:
|
|
|
|
```java
|
|
@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
|
|
""", 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);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Update `rowMapper`** — read `read_at` + `deleted_at`:
|
|
|
|
```java
|
|
Timestamp readAt = rs.getTimestamp("read_at");
|
|
Timestamp deletedAt = rs.getTimestamp("deleted_at");
|
|
// ... and pass:
|
|
readAt == null ? null : readAt.toInstant(),
|
|
deletedAt == null ? null : deletedAt.toInstant(),
|
|
```
|
|
|
|
Insert at the position matching the record's field order (after `lastNotifiedAt`, before `silenced`).
|
|
|
|
- [ ] **Step 8: Delete `PostgresAlertReadRepository.java`** + remove its bean from `AlertingBeanConfig`:
|
|
|
|
```bash
|
|
git rm cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepository.java
|
|
```
|
|
|
|
In `AlertingBeanConfig.java` remove:
|
|
```java
|
|
@Bean
|
|
public AlertReadRepository alertReadRepository(JdbcTemplate jdbc) { ... }
|
|
```
|
|
Remove the `AlertReadRepository` import too.
|
|
|
|
- [ ] **Step 9: Update `PostgresAlertInstanceRepositoryIT`** — add tests:
|
|
|
|
```java
|
|
@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(ENV_ID, List.of(), USER_ID, 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(), USER_ID, Instant.parse("2026-04-21T10:00:00Z"));
|
|
var open = repo.findOpenForRule(inst.ruleId());
|
|
assertThat(open).isPresent(); // ack no longer closes the open slot
|
|
}
|
|
|
|
@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();
|
|
repo.ack(a.id(), "alice", Instant.parse("2026-04-21T09:00:00Z"));
|
|
repo.bulkAck(List.of(a.id(), b.id()), "bob", Instant.parse("2026-04-21T10:00:00Z"));
|
|
assertThat(repo.findById(a.id()).orElseThrow().ackedBy()).isEqualTo("alice");
|
|
assertThat(repo.findById(b.id()).orElseThrow().ackedBy()).isEqualTo("bob");
|
|
}
|
|
|
|
@Test
|
|
void listForInbox_acked_false_hides_acked_rows() {
|
|
var a = insertFreshFiring();
|
|
var b = insertFreshFiring();
|
|
repo.ack(a.id(), "alice", Instant.now());
|
|
var rows = repo.listForInbox(ENV_ID, List.of(), USER_ID, List.of(),
|
|
null, null, /*acked*/ false, null, 100);
|
|
assertThat(rows).extracting(AlertInstance::id).containsExactly(b.id());
|
|
}
|
|
```
|
|
|
|
(Use the test class's existing `insertFreshFiring()` helper or add a small factory. `ENV_ID` / `USER_ID` are existing fixture constants.)
|
|
|
|
Also update any existing test asserting `state == ACKNOWLEDGED` after `ack()` — instead assert `ackedAt != null && state == FIRING`.
|
|
|
|
- [ ] **Step 10: Run the repo IT** — `mvn -pl cameleer-server-app -Dtest=PostgresAlertInstanceRepositoryIT test`. Expected: all green.
|
|
|
|
- [ ] **Step 11: Commit**
|
|
|
|
```bash
|
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertInstanceRepository.java \
|
|
cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/config/AlertingBeanConfig.java \
|
|
cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertInstanceRepositoryIT.java
|
|
git rm cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepository.java
|
|
git commit -m "feat(alerts): Postgres repo — markRead/softDelete/bulkAck, acked/read filters, countUnread via read_at"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: InAppInboxQuery + AlertController — new endpoints, filter params, rewire read
|
|
|
|
**Files:**
|
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/InAppInboxQuery.java`
|
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertController.java`
|
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertDto.java`
|
|
- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertControllerIT.java`
|
|
|
|
- [ ] **Step 1: Update `AlertDto`** — add `readAt`:
|
|
|
|
```java
|
|
public record AlertDto(
|
|
UUID id,
|
|
UUID ruleId,
|
|
UUID environmentId,
|
|
AlertState state,
|
|
AlertSeverity severity,
|
|
String title,
|
|
String message,
|
|
Instant firedAt,
|
|
Instant ackedAt,
|
|
String ackedBy,
|
|
Instant resolvedAt,
|
|
Instant readAt, // NEW
|
|
boolean silenced,
|
|
Double currentValue,
|
|
Double threshold,
|
|
Map<String, Object> context
|
|
) {
|
|
public static AlertDto from(AlertInstance i) {
|
|
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.readAt(), i.silenced(),
|
|
i.currentValue(), i.threshold(), i.context());
|
|
}
|
|
}
|
|
```
|
|
|
|
`deletedAt` is intentionally absent — soft-deleted rows never reach the wire.
|
|
|
|
- [ ] **Step 2: Update `InAppInboxQuery`** — new filter signature, rewired countUnread:
|
|
|
|
```java
|
|
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,
|
|
states, severities, acked, read, limit);
|
|
}
|
|
|
|
public UnreadCountResponse countUnread(UUID envId, String userId) {
|
|
Key key = new Key(envId, userId);
|
|
Instant now = Instant.now(clock);
|
|
Entry cached = memo.get(key);
|
|
if (cached != null && now.isBefore(cached.expiresAt())) return cached.response();
|
|
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;
|
|
}
|
|
```
|
|
|
|
Drop the deprecated 4-arg `listInbox` overload — the controller will use the full form.
|
|
|
|
- [ ] **Step 3: Update `AlertController`** — add DELETE, bulk-delete, bulk-ack, acked/read filter params, rewire read/bulk-read:
|
|
|
|
```java
|
|
@GetMapping
|
|
public List<AlertDto> list(
|
|
@EnvPath Environment env,
|
|
@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, state, severity, acked, read, effectiveLimit)
|
|
.stream().map(AlertDto::from).toList();
|
|
}
|
|
|
|
@PostMapping("/{id}/read")
|
|
public void read(@EnvPath Environment env, @PathVariable UUID id) {
|
|
requireLiveInstance(id, env.id());
|
|
instanceRepo.markRead(id, Instant.now());
|
|
}
|
|
|
|
@PostMapping("/bulk-read")
|
|
public void bulkRead(@EnvPath Environment env, @Valid @RequestBody BulkIdsRequest req) {
|
|
List<UUID> filtered = inEnvIds(req.instanceIds(), env.id());
|
|
if (!filtered.isEmpty()) instanceRepo.bulkMarkRead(filtered, Instant.now());
|
|
}
|
|
|
|
@PostMapping("/bulk-ack")
|
|
@PreAuthorize("hasAnyRole('VIEWER','OPERATOR','ADMIN')")
|
|
public void bulkAck(@EnvPath Environment env, @Valid @RequestBody BulkIdsRequest req) {
|
|
List<UUID> filtered = inEnvIds(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 = inEnvIds(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) {
|
|
// Must find the row regardless of deleted_at (requireLiveInstance would 404 on a deleted row).
|
|
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 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> inEnvIds(List<UUID> ids, UUID envId) {
|
|
return ids.stream()
|
|
.filter(id -> instanceRepo.findById(id)
|
|
.map(i -> i.environmentId().equals(envId) && i.deletedAt() == null)
|
|
.orElse(false))
|
|
.toList();
|
|
}
|
|
```
|
|
|
|
Rename `BulkReadRequest` → `BulkIdsRequest` (the body shape `{ instanceIds: [...] }` is identical for read/ack/delete; one DTO covers all three). Or keep `BulkReadRequest` and add `BulkAckRequest` + `BulkDeleteRequest` as sealed aliases — pick **rename to `BulkIdsRequest`** for DRY. Update references accordingly.
|
|
|
|
Drop the `AlertReadRepository` field + constructor param.
|
|
|
|
- [ ] **Step 4: Update `AlertControllerIT`** — add cases:
|
|
|
|
```java
|
|
@Test
|
|
void bulkDelete_softDeletes_matching_rows_in_env_only() { /* two rows in env, one out; bulk-delete both; only in-env row is deleted */ }
|
|
|
|
@Test
|
|
void list_respects_read_filter() { /* three rows, two unread; ?read=false returns two */ }
|
|
|
|
@Test
|
|
void list_respects_acked_filter() { /* similar */ }
|
|
|
|
@Test
|
|
void delete_non_operator_returns_403() { /* VIEWER role → 403 */ }
|
|
|
|
@Test
|
|
void get_returns_404_for_soft_deleted() { /* delete then GET → 404 */ }
|
|
```
|
|
|
|
Use existing test harness (`@WebMvcTest` or full `@SpringBootTest`, whichever the current IT uses).
|
|
|
|
Also fix every existing IT assertion that expects `state=ACKNOWLEDGED` — switch to `ackedAt != null`.
|
|
|
|
- [ ] **Step 5: Run** — `mvn -pl cameleer-server-app -Dtest=AlertControllerIT test`. Expected: green.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertController.java \
|
|
cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/InAppInboxQuery.java \
|
|
cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertDto.java \
|
|
cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/BulkIdsRequest.java \
|
|
cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertControllerIT.java
|
|
git rm cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/BulkReadRequest.java
|
|
git commit -m "feat(alerts): controller — DELETE + bulk-delete/bulk-ack, acked/read filters, read via instance"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Fix remaining backend tests + SecurityConfig matchers
|
|
|
|
**Files:**
|
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java` (RBAC matchers if needed)
|
|
- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/AlertingFullLifecycleIT.java`
|
|
- Modify: any other test referencing `ACKNOWLEDGED` / `AlertReadRepository`
|
|
- Modify: Grep-find all: `grep -rn "ACKNOWLEDGED\|AlertReadRepository\|alert_reads" cameleer-server-app/src cameleer-server-core/src`
|
|
|
|
- [ ] **Step 1: List remaining ACKNOWLEDGED references**
|
|
|
|
Run: `grep -rn "ACKNOWLEDGED\|AlertReadRepository\|alert_reads" cameleer-server-app/src cameleer-server-core/src`
|
|
Expected: only test + doc fixture references now. Patch each assertion from `state==ACKNOWLEDGED` → `ackedAt != null`.
|
|
|
|
- [ ] **Step 2: Update `SecurityConfig` matchers** — add matchers for the new endpoints:
|
|
|
|
```java
|
|
// Ack/read/bulk-read/bulk-ack — VIEWER+ (matches existing)
|
|
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/*/ack", "/api/v1/environments/*/alerts/*/read",
|
|
"/api/v1/environments/*/alerts/bulk-read", "/api/v1/environments/*/alerts/bulk-ack")
|
|
.hasAnyRole("VIEWER","OPERATOR","ADMIN")
|
|
// Delete/bulk-delete — OPERATOR+
|
|
.requestMatchers(HttpMethod.DELETE, "/api/v1/environments/*/alerts/*")
|
|
.hasAnyRole("OPERATOR","ADMIN")
|
|
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/bulk-delete")
|
|
.hasAnyRole("OPERATOR","ADMIN")
|
|
```
|
|
|
|
Verify the existing config — the `@PreAuthorize` annotations on the controller methods cover this, but add URL matchers if the existing config relies on them (inspect before copy-paste).
|
|
|
|
- [ ] **Step 3: Update `AlertingFullLifecycleIT`** — any flow that did `ack → state=ACKNOWLEDGED → resolve` now skips the state change on ack. Patch asserts.
|
|
|
|
- [ ] **Step 4: Run the whole alerting suite** — `mvn -pl cameleer-server-app -Dtest='*Alert*' test`. Expected: green.
|
|
|
|
- [ ] **Step 5: Full build** — `mvn clean verify`. Expected: green.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "test(alerts): migrate fixtures off ACKNOWLEDGED + alert_reads"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Regenerate OpenAPI schema
|
|
|
|
**Files:**
|
|
- Regenerate: `ui/src/api/schema.d.ts`, `ui/src/api/openapi.json`
|
|
|
|
- [ ] **Step 1: Start backend** — `java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar` in a separate terminal, or via Docker per your local workflow. Wait for `:8081` to accept.
|
|
|
|
- [ ] **Step 2: Regenerate schema**
|
|
|
|
```bash
|
|
cd ui
|
|
curl -sS http://localhost:8081/api/v1/api-docs > src/api/openapi.json
|
|
npx openapi-typescript src/api/openapi.json -o src/api/schema.d.ts
|
|
```
|
|
|
|
- [ ] **Step 3: Verify new endpoints appear** — `grep -E "bulk-delete|bulk-ack|\"delete\"" src/api/schema.d.ts | head -20`. Expected: DELETE on `/alerts/{id}`, POST on bulk-delete + bulk-ack. `AlertDto` includes `readAt`. `/alerts` GET has `acked`/`read` query params.
|
|
|
|
- [ ] **Step 4: Surface TS breakages** — `cd ui && npx tsc --noEmit`. Expected: errors in `alerts.ts`, `InboxPage.tsx`, `AllAlertsPage.tsx`, `HistoryPage.tsx` — all fixed in subsequent tasks. Do **not** patch here.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add ui/src/api/openapi.json ui/src/api/schema.d.ts
|
|
git commit -m "chore(ui): regenerate OpenAPI schema for alerts inbox redesign"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: UI alerts.ts — new mutations + useAlerts filter params
|
|
|
|
**Files:**
|
|
- Modify: `ui/src/api/queries/alerts.ts`
|
|
|
|
- [ ] **Step 1: Update `AlertsFilter` + `useAlerts`** — add `acked?: boolean`, `read?: boolean`:
|
|
|
|
```ts
|
|
export interface AlertsFilter {
|
|
state?: AlertState | AlertState[];
|
|
severity?: AlertSeverity | AlertSeverity[];
|
|
acked?: boolean;
|
|
read?: boolean;
|
|
ruleId?: string;
|
|
limit?: number;
|
|
}
|
|
|
|
// in useAlerts queryFn, after severity handling:
|
|
if (filter.acked !== undefined) query.acked = filter.acked;
|
|
if (filter.read !== undefined) query.read = filter.read;
|
|
|
|
// queryKey must include acked/read:
|
|
queryKey: ['alerts', env, 'list', fetchLimit, stateKey, severityKey, filter.acked ?? null, filter.read ?? null],
|
|
```
|
|
|
|
- [ ] **Step 2: Rewire `useBulkReadAlerts` body** — body shape is identical (`{ instanceIds }`), but fix the TS reference if the generated type renamed it to `BulkIdsRequest`.
|
|
|
|
- [ ] **Step 3: Add four new mutations**:
|
|
|
|
```ts
|
|
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] }),
|
|
});
|
|
}
|
|
|
|
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'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
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'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
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'] });
|
|
},
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: `npx tsc --noEmit`** — expected: only `AllAlertsPage.tsx`, `HistoryPage.tsx`, `InboxPage.tsx` errors remain. Those pages are rewritten/deleted in the next tasks.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add ui/src/api/queries/alerts.ts
|
|
git commit -m "feat(ui/alerts): hooks for bulk-ack, delete, bulk-delete; acked/read filter params"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Remove AllAlertsPage, HistoryPage, router routes, sidebar trim
|
|
|
|
**Files:**
|
|
- Delete: `ui/src/pages/Alerts/AllAlertsPage.tsx`
|
|
- Delete: `ui/src/pages/Alerts/HistoryPage.tsx`
|
|
- Modify: `ui/src/router.tsx`
|
|
- Modify: `ui/src/components/sidebar-utils.ts`
|
|
|
|
- [ ] **Step 1: Delete the two pages**
|
|
|
|
```bash
|
|
git rm ui/src/pages/Alerts/AllAlertsPage.tsx ui/src/pages/Alerts/HistoryPage.tsx
|
|
```
|
|
|
|
- [ ] **Step 2: Trim `router.tsx`** — drop lines 27, 28 (lazy imports) and the two route entries at lines 87, 88 (`/alerts/all`, `/alerts/history`). Keep the `/alerts` → `/alerts/inbox` redirect on line 85 (sidebar click lands here).
|
|
|
|
- [ ] **Step 3: Trim `buildAlertsTreeNodes`** in `ui/src/components/sidebar-utils.ts`:
|
|
|
|
```ts
|
|
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-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 })) },
|
|
];
|
|
}
|
|
```
|
|
|
|
Remove `List`, `ScrollText` from the `lucide-react` import (if no other user).
|
|
|
|
- [ ] **Step 4: Check CMD-K** — `grep -n "alerts/all\|alerts/history" ui/src`. Expected: no hits. `buildAlertSearchData` in `LayoutShell.tsx` deep-links to `/alerts/inbox/{id}` regardless.
|
|
|
|
- [ ] **Step 5: Run typecheck** — `cd ui && npx tsc --noEmit`. Expected: only `InboxPage.tsx` errors left (next task).
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add ui/src/router.tsx ui/src/components/sidebar-utils.ts
|
|
git rm ui/src/pages/Alerts/AllAlertsPage.tsx ui/src/pages/Alerts/HistoryPage.tsx
|
|
git commit -m "refactor(ui/alerts): single inbox — remove AllAlerts + History pages, trim sidebar"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Create Silence-rule quick menu component
|
|
|
|
**Files:**
|
|
- Create: `ui/src/pages/Alerts/SilenceRuleMenu.tsx`
|
|
|
|
- [ ] **Step 1: Write the component** — a small popover/menu with three preset buttons + a "Custom…" link:
|
|
|
|
```tsx
|
|
import { useState, useRef } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { Button, Popover } from '@cameleer/design-system';
|
|
import { BellOff } from 'lucide-react';
|
|
import { useCreateSilence } from '../../api/queries/alertSilences';
|
|
import { useToast } from '@cameleer/design-system';
|
|
|
|
const PRESETS: Array<{ label: string; hours: number }> = [
|
|
{ label: '1 hour', hours: 1 },
|
|
{ label: '8 hours', hours: 8 },
|
|
{ label: '24 hours', hours: 24 },
|
|
];
|
|
|
|
interface Props {
|
|
ruleId: string;
|
|
ruleTitle?: string;
|
|
onDone?: () => void;
|
|
variant?: 'row' | 'bulk';
|
|
}
|
|
|
|
export function SilenceRuleMenu({ ruleId, ruleTitle, onDone, variant = 'row' }: Props) {
|
|
const [open, setOpen] = useState(false);
|
|
const anchor = useRef<HTMLButtonElement>(null);
|
|
const create = useCreateSilence();
|
|
const navigate = useNavigate();
|
|
const { toast } = useToast();
|
|
|
|
const createWithDuration = async (hours: number) => {
|
|
const now = new Date();
|
|
const endsAt = new Date(now.getTime() + hours * 3600_000);
|
|
try {
|
|
await create.mutateAsync({
|
|
matcher: { ruleId },
|
|
reason: `Silenced from inbox${ruleTitle ? ` (${ruleTitle})` : ''}`,
|
|
startsAt: now.toISOString(),
|
|
endsAt: endsAt.toISOString(),
|
|
});
|
|
toast({ title: `Silenced for ${hours}h`, variant: 'success' });
|
|
setOpen(false);
|
|
onDone?.();
|
|
} catch (e) {
|
|
toast({ title: 'Silence failed', description: String(e), variant: 'error' });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Button ref={anchor} size="sm" variant="ghost" onClick={() => setOpen(true)}>
|
|
<BellOff size={14} /> {variant === 'bulk' ? 'Silence rules' : 'Silence rule…'}
|
|
</Button>
|
|
<Popover open={open} onClose={() => setOpen(false)} anchor={anchor}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', padding: 8, minWidth: 160 }}>
|
|
{PRESETS.map((p) => (
|
|
<Button
|
|
key={p.hours}
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => createWithDuration(p.hours)}
|
|
disabled={create.isPending}
|
|
>
|
|
{p.label}
|
|
</Button>
|
|
))}
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
navigate(`/alerts/silences?ruleId=${encodeURIComponent(ruleId)}`);
|
|
}}
|
|
>
|
|
Custom…
|
|
</Button>
|
|
</div>
|
|
</Popover>
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
If the DS doesn't export `Popover`, fall back to the existing dropdown/menu pattern used elsewhere — search `ui/src` for "Popover\|DropdownMenu\|PopoverContent" and match an established pattern.
|
|
|
|
- [ ] **Step 2: `npx tsc --noEmit`** — expected clean.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add ui/src/pages/Alerts/SilenceRuleMenu.tsx
|
|
git commit -m "feat(ui/alerts): silence-rule quick menu (1h/8h/24h/custom)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Rewrite InboxPage — filter bar, row actions, bulk toolbar
|
|
|
|
**Files:**
|
|
- Modify: `ui/src/pages/Alerts/InboxPage.tsx`
|
|
|
|
- [ ] **Step 1: Rewrite `InboxPage.tsx`** — full replacement. Key shape:
|
|
|
|
```tsx
|
|
// Filter state:
|
|
const [severitySel, setSeveritySel] = useState<Set<string>>(new Set());
|
|
const [stateSel, setStateSel] = useState<Set<string>>(new Set(['FIRING'])); // default FIRING only
|
|
const [hideAcked, setHideAcked] = useState(true);
|
|
const [hideRead, setHideRead] = useState(true);
|
|
|
|
const { data } = 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,
|
|
});
|
|
```
|
|
|
|
**Filter-bar JSX** (in the page header actions column):
|
|
|
|
```tsx
|
|
<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={setHideAcked} />
|
|
<Toggle label="Hide read" checked={hideRead} onChange={setHideRead} />
|
|
</div>
|
|
```
|
|
|
|
`STATE_ITEMS` omits `ACKNOWLEDGED`:
|
|
|
|
```tsx
|
|
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)' },
|
|
];
|
|
```
|
|
|
|
**Row actions column** (replace the existing `ack` column):
|
|
|
|
```tsx
|
|
{
|
|
key: 'actions', header: '', width: '240px',
|
|
render: (_, row) => (
|
|
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
|
|
{row.ackedAt == null && (
|
|
<Button size="sm" variant="secondary" onClick={() => onAck(row.id, row.title)}>
|
|
Acknowledge
|
|
</Button>
|
|
)}
|
|
{row.readAt == null && (
|
|
<Button size="sm" variant="ghost" onClick={() => markRead.mutate(row.id)}>
|
|
Mark read
|
|
</Button>
|
|
)}
|
|
{row.ruleId && (
|
|
<SilenceRuleMenu ruleId={row.ruleId} ruleTitle={row.title ?? undefined} />
|
|
)}
|
|
{canDelete && (
|
|
<Button size="sm" variant="ghost" onClick={() => onDeleteOne(row.id)}>
|
|
<Trash2 size={14} />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
```
|
|
|
|
`canDelete` gated via `useAuthStore()` role check: `roles.includes('OPERATOR') || roles.includes('ADMIN')`.
|
|
|
|
**Single delete with undo toast** (5s) — backed by `useRestoreAlert`:
|
|
|
|
```tsx
|
|
const del = useDeleteAlert();
|
|
const restore = useRestoreAlert();
|
|
|
|
const onDeleteOne = async (id: string) => {
|
|
try {
|
|
await del.mutateAsync(id);
|
|
toast({
|
|
title: 'Deleted',
|
|
variant: 'success',
|
|
duration: 5000,
|
|
action: {
|
|
label: 'Undo',
|
|
onClick: () => restore.mutateAsync(id).then(
|
|
() => toast({ title: 'Restored', variant: 'success' }),
|
|
(e) => toast({ title: 'Undo failed', description: String(e), variant: 'error' })
|
|
),
|
|
},
|
|
});
|
|
} catch (e) {
|
|
toast({ title: 'Delete failed', description: String(e), variant: 'error' });
|
|
}
|
|
};
|
|
```
|
|
|
|
If the DS `toast` API doesn't support an `action` slot, render a secondary `<Button>Undo</Button>` inside the toast `description` ReactNode — check `@cameleer/design-system` toast signature before choosing.
|
|
|
|
**Bulk toolbar** (the existing `filterBar` section):
|
|
|
|
```tsx
|
|
{selectedIds.length > 0 ? (
|
|
<>
|
|
<Button size="sm" variant="primary"
|
|
onClick={() => onBulkAck(selectedUnackedIds)}
|
|
disabled={selectedUnackedIds.length === 0}>
|
|
Acknowledge {selectedUnackedIds.length}
|
|
</Button>
|
|
<Button size="sm" variant="secondary"
|
|
onClick={() => onBulkRead(selectedUnreadIds)}
|
|
disabled={selectedUnreadIds.length === 0}>
|
|
Mark {selectedUnreadIds.length} read
|
|
</Button>
|
|
<SilenceRulesForSelection ids={selectedIds} rows={rows} />
|
|
{canDelete && (
|
|
<Button size="sm" variant="danger" onClick={() => setDeletePending(selectedIds)}>
|
|
Delete {selectedIds.length}
|
|
</Button>
|
|
)}
|
|
</>
|
|
) : ( /* existing "Acknowledge all firing" + "Mark all read" */ )}
|
|
```
|
|
|
|
**Bulk-delete confirmation modal:**
|
|
|
|
```tsx
|
|
<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"
|
|
/>
|
|
```
|
|
|
|
`SilenceRulesForSelection` is a helper that walks `rows` filtered to `selectedIds`, collects unique `ruleId`s, and renders a menu that creates one silence per ruleId with the chosen duration — composed from `SilenceRuleMenu` or a similar inline control.
|
|
|
|
- [ ] **Step 2: Manual TS check + dev run** — `cd ui && npx tsc --noEmit`, expected green. Run `npm run dev`; navigate to `/alerts/inbox`, verify:
|
|
- Filter toggles work (changing hides/reveals rows)
|
|
- Ack button disappears after ack; row stays visible (hide-acked OFF)
|
|
- Silence menu opens, "1 hour" creates a silence and shows success toast
|
|
- Delete button (as OPERATOR) soft-deletes; row disappears; bell count updates
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add ui/src/pages/Alerts/InboxPage.tsx
|
|
git commit -m "feat(ui/alerts): single inbox — filter bar, silence/delete row actions, bulk toolbar"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: SilencesPage — prefill `?ruleId=` from URL
|
|
|
|
**Files:**
|
|
- Modify: `ui/src/pages/Alerts/SilencesPage.tsx`
|
|
|
|
- [ ] **Step 1: Add `useSearchParams` import + effect**
|
|
|
|
```tsx
|
|
import { useSearchParams } from 'react-router';
|
|
// inside component:
|
|
const [searchParams] = useSearchParams();
|
|
useEffect(() => {
|
|
const r = searchParams.get('ruleId');
|
|
if (r) setMatcherRuleId(r);
|
|
}, [searchParams]);
|
|
```
|
|
|
|
That's the entire change. `matcherRuleId` already feeds the create-silence form.
|
|
|
|
- [ ] **Step 2: Manual test** — navigate to `/alerts/silences?ruleId=abc-123`; the Rule ID field is prefilled.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add ui/src/pages/Alerts/SilencesPage.tsx
|
|
git commit -m "feat(ui/alerts): SilencesPage — prefill Rule ID from ?ruleId= query param"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: UI InboxPage tests
|
|
|
|
**Files:**
|
|
- Create: `ui/src/pages/Alerts/InboxPage.test.tsx`
|
|
|
|
- [ ] **Step 1: Write Vitest + RTL tests** with concrete bodies. Mocks hoisted at top so hook calls return deterministic data.
|
|
|
|
```tsx
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, fireEvent, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { MemoryRouter } from 'react-router';
|
|
import InboxPage from './InboxPage';
|
|
import type { AlertDto } from '../../api/queries/alerts';
|
|
|
|
const alertsMock = vi.fn();
|
|
const deleteMock = vi.fn().mockResolvedValue(undefined);
|
|
const bulkDelete = vi.fn().mockResolvedValue(undefined);
|
|
const ackMock = vi.fn().mockResolvedValue(undefined);
|
|
const markReadMock = vi.fn();
|
|
const authRolesMock = vi.fn<[], string[]>();
|
|
|
|
vi.mock('../../api/queries/alerts', () => ({
|
|
useAlerts: (...args: unknown[]) => ({ data: alertsMock(...args), isLoading: false, error: null }),
|
|
useAckAlert: () => ({ mutateAsync: ackMock, isPending: false }),
|
|
useMarkAlertRead: () => ({ mutate: markReadMock }),
|
|
useBulkReadAlerts: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
|
useBulkAckAlerts: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
|
useDeleteAlert: () => ({ mutateAsync: deleteMock, isPending: false }),
|
|
useBulkDeleteAlerts:() => ({ mutateAsync: bulkDelete, isPending: false }),
|
|
useRestoreAlert: () => ({ mutateAsync: vi.fn() }),
|
|
}));
|
|
|
|
vi.mock('../../auth/auth-store', () => ({
|
|
useAuthStore: () => ({ roles: authRolesMock() }),
|
|
}));
|
|
|
|
function mount() {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
return render(
|
|
<QueryClientProvider client={qc}>
|
|
<MemoryRouter initialEntries={['/alerts/inbox']}>
|
|
<InboxPage />
|
|
</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
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: null, ackedBy: null, resolvedAt: null, readAt: null,
|
|
silenced: false, currentValue: null, threshold: null,
|
|
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' };
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
authRolesMock.mockReturnValue(['OPERATOR']);
|
|
alertsMock.mockReturnValue([ROW_FIRING]);
|
|
});
|
|
|
|
describe('InboxPage', () => {
|
|
it('calls useAlerts with default filters: state=[FIRING], acked=false, read=false', () => {
|
|
mount();
|
|
expect(alertsMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
state: ['FIRING'],
|
|
acked: false,
|
|
read: false,
|
|
}));
|
|
});
|
|
|
|
it('unchecking "Hide acked" removes the acked filter', async () => {
|
|
mount();
|
|
await userEvent.click(screen.getByRole('checkbox', { name: /hide acked/i }));
|
|
const lastCall = alertsMock.mock.calls.at(-1)?.[0];
|
|
expect(lastCall).not.toHaveProperty('acked');
|
|
});
|
|
|
|
it('shows Acknowledge button only on rows where ackedAt is null', () => {
|
|
alertsMock.mockReturnValue([ROW_FIRING, ROW_ACKED]);
|
|
mount();
|
|
expect(screen.getAllByRole('button', { name: /acknowledge/i })).toHaveLength(1);
|
|
});
|
|
|
|
it('opens bulk-delete confirmation with the correct count', async () => {
|
|
alertsMock.mockReturnValue([ROW_FIRING, ROW_ACKED]);
|
|
mount();
|
|
// Select both rows
|
|
for (const cb of screen.getAllByRole('checkbox', { name: /^select/i })) {
|
|
await userEvent.click(cb);
|
|
}
|
|
await userEvent.click(screen.getByRole('button', { name: /delete 2/i }));
|
|
expect(screen.getByRole('dialog')).toHaveTextContent(/delete 2 alerts/i);
|
|
});
|
|
|
|
it('hides Delete buttons when user lacks OPERATOR role', () => {
|
|
authRolesMock.mockReturnValue(['VIEWER']);
|
|
mount();
|
|
expect(screen.queryByRole('button', { name: /delete/i })).toBeNull();
|
|
});
|
|
|
|
it('clicking row Delete invokes useDeleteAlert and shows an Undo toast', async () => {
|
|
mount();
|
|
await userEvent.click(within(screen.getAllByRole('row')[1]).getByRole('button', { name: /delete/i }));
|
|
expect(deleteMock).toHaveBeenCalledWith(ROW_FIRING.id);
|
|
expect(await screen.findByText(/deleted/i)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /undo/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
```
|
|
|
|
Adjust selectors if design-system renders checkboxes/buttons with different accessible names — prefer role+name queries over test-ids.
|
|
|
|
- [ ] **Step 2: Run** — `cd ui && npx vitest run src/pages/Alerts/InboxPage.test.tsx`. Green.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add ui/src/pages/Alerts/InboxPage.test.tsx
|
|
git commit -m "test(ui/alerts): InboxPage filter + action coverage"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 15: Rules docs + CLAUDE.md
|
|
|
|
**Files:**
|
|
- Modify: `.claude/rules/app-classes.md` — AlertController endpoint table
|
|
- Modify: `.claude/rules/core-classes.md` — AlertState enum, drop AlertReadRepository mention
|
|
- Modify: `.claude/rules/ui.md` — Alerts section
|
|
- Modify: `CLAUDE.md` — V17 migration entry
|
|
|
|
- [ ] **Step 1: Update `.claude/rules/app-classes.md`** — find the `AlertController` bullet and replace:
|
|
|
|
```
|
|
- `AlertController` — `/api/v1/environments/{envSlug}/alerts`. GET list (inbox filtered by userId/groupIds/roleNames via `InAppInboxQuery`; query params: multi-value `state` (PENDING|FIRING|RESOLVED) + `severity`, tri-state `acked`/`read`; always `deleted_at IS NULL`) / GET `/unread-count` / GET `{id}` (404 if deleted) / POST `{id}/ack` (sets acked_at only, no state change) / POST `{id}/read` (sets read_at) / POST `/bulk-read` / POST `/bulk-ack` (VIEWER+) / DELETE `{id}` (OPERATOR+, soft-delete) / POST `/bulk-delete` (OPERATOR+). VIEWER+ for reads and ack/read. Inbox SQL: `? = ANY(target_user_ids) OR target_group_ids && ? OR target_role_names && ?` — requires at least one matching target.
|
|
```
|
|
|
|
Also drop any `AlertReadRepository` bean mention from the `config/` or `storage/` sub-bullet.
|
|
|
|
- [ ] **Step 2: Update `.claude/rules/core-classes.md`** — `AlertState` enum:
|
|
|
|
```
|
|
- `AlertState` — enum: PENDING, FIRING, RESOLVED. Ack is orthogonal (`acked_at` on `alert_instances`), not a state.
|
|
- `AlertInstanceRepository` — interface: save/findById/findOpenForRule (state IN ('PENDING','FIRING') AND deleted_at IS NULL); `listForInbox(env, groups, user, roles, states, severities, acked, read, limit)`; `countUnreadBySeverity(env, user, groups, roles)`; `ack`/`resolve`/`markSilenced`/`markRead`/`bulkMarkRead`/`softDelete`/`bulkSoftDelete`/`bulkAck`/`deleteResolvedBefore`/`listFiringDueForReNotify`.
|
|
```
|
|
|
|
Remove the `AlertReadRepository` bullet entirely.
|
|
|
|
- [ ] **Step 3: Update `.claude/rules/ui.md`** — Alerts section:
|
|
|
|
```
|
|
- **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`.
|
|
- **Pages** under `ui/src/pages/Alerts/`:
|
|
- `InboxPage.tsx` — single filterable inbox. Filters: severity (multi), state (PENDING/FIRING/RESOLVED, default FIRING), Hide acked (default on), Hide read (default on). Row actions: Acknowledge, Mark read, Silence rule… (quick menu: 1h/8h/24h/Custom), Delete (OPERATOR+, soft-delete). Bulk toolbar: Ack N · Mark N read · Silence rules · Delete N (confirmation modal).
|
|
- `RulesListPage.tsx`, `RuleEditor/RuleEditorWizard.tsx` (unchanged).
|
|
- `SilencesPage.tsx` — matcher-based create + end-early. Reads `?ruleId=` search param to prefill the Rule ID field (used by InboxPage "Silence rule… → Custom").
|
|
- `SilenceRuleMenu.tsx` (new) — popover duration picker used by row/bulk silence action.
|
|
```
|
|
|
|
- [ ] **Step 4: Update `CLAUDE.md`** — append to the V16 line:
|
|
|
|
```
|
|
- V17 — Alerts: drop ACKNOWLEDGED from AlertState (ack is now orthogonal via acked_at), add read_at + deleted_at (global, no per-user tracking), drop alert_reads table, rework V13/V15/V16 open-rule unique index predicate to `state IN ('PENDING','FIRING') AND deleted_at IS NULL`.
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add .claude/rules/app-classes.md .claude/rules/core-classes.md .claude/rules/ui.md CLAUDE.md
|
|
git commit -m "docs(alerts): rules + CLAUDE.md — inbox redesign, V17 migration"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 16: Final verification
|
|
|
|
- [ ] **Step 1: `mvn clean verify`** — full backend build + ITs. Expected: green.
|
|
- [ ] **Step 2: `cd ui && npm run build`** — TS strict + bundle. Expected: green.
|
|
- [ ] **Step 3: `cd ui && npx vitest run`** — all UI tests. Expected: green.
|
|
- [ ] **Step 4: Grep sanity** — `grep -rn "ACKNOWLEDGED\|AlertReadRepository\|alert_reads" cameleer-server-app cameleer-server-core ui/src`. Expected: only this plan file + spec.
|
|
- [ ] **Step 5: Manual smoke**:
|
|
- `/alerts/inbox` default view shows only unread-firing alerts
|
|
- Toggle "Hide acked" OFF — acked rows appear
|
|
- Ack a row — it stays visible while "Hide acked" is OFF; disappears when re-toggled ON
|
|
- Click "Silence rule… → 1 hour" on a row — success toast, new silence visible on `/alerts/silences`
|
|
- Bell badge decreases after marking rows as read
|
|
- As VIEWER, Delete button is absent
|
|
- As OPERATOR, Delete button works, soft-deletes, confirmation modal on bulk
|
|
- [ ] **Step 6: Index refresh** — `npx gitnexus analyze --embeddings` to keep the code graph fresh for future sessions.
|
|
|
|
---
|
|
|
|
## Self-review notes
|
|
|
|
Covered spec sections:
|
|
- ✅ Data model changes → Task 1, 2
|
|
- ✅ V17 migration (enum swap + columns + drop alert_reads + rework index) → Task 1
|
|
- ✅ `AlertStateTransitions` ACKNOWLEDGED removal → Task 3
|
|
- ✅ `AlertInstanceRepository` interface changes → Task 4
|
|
- ✅ `PostgresAlertInstanceRepository` save/rowMapper/findOpenForRule/ack/listForInbox/countUnread/new mutations → Task 5
|
|
- ✅ Drop `AlertReadRepository` + impl + bean → Task 4, 5
|
|
- ✅ `AlertController` DELETE/bulk-delete/bulk-ack + acked/read filters + rewire read → Task 6
|
|
- ✅ `InAppInboxQuery` new signature + countUnread → Task 6
|
|
- ✅ `AlertDto.readAt` → Task 6
|
|
- ✅ Test-fixture migration off ACKNOWLEDGED → Task 7
|
|
- ✅ `SecurityConfig` matchers for new endpoints → Task 7
|
|
- ✅ OpenAPI regen → Task 8
|
|
- ✅ UI hooks: `useDeleteAlert`, `useBulkDeleteAlerts`, `useBulkAckAlerts`, `useAlerts` filter params → Task 9
|
|
- ✅ Delete `AllAlertsPage` + `HistoryPage` + router + sidebar → Task 10
|
|
- ✅ `SilenceRuleMenu` component → Task 11
|
|
- ✅ `InboxPage` rewrite → Task 12
|
|
- ✅ `SilencesPage` `?ruleId=` prefill → Task 13
|
|
- ✅ UI tests → Task 14
|
|
- ✅ Rules + `CLAUDE.md` → Task 15
|
|
- ✅ Final verification → Task 16
|