Compare commits

...

50 Commits

Author SHA1 Message Date
181a479037 Merge pull request 'feat(alerts): DS alignment + AGENT_LIFECYCLE + single-inbox redesign' (#146) from feat/alerts-ds-alignment into main
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m56s
CI / docker (push) Successful in 33s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s
Reviewed-on: #146
2026-04-21 19:53:11 +02:00
hsiegeln
849265a1c6 docs(howto): brand-new local environment via docker-compose
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m58s
CI / docker (push) Successful in 1m19s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 39s
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m2s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
Rewrite the "Infrastructure Setup" / "Run the Server" sections to
reflect what docker-compose.yml actually provides (full stack —
PostgreSQL + ClickHouse + server + UI — not just PostgreSQL). Adds:

- Step-by-step walkthrough for a first-run clean environment.
- Port map including the UI (8080), ClickHouse (8123/9000), PG (5432),
  server (8081).
- Dev credentials baked into compose surfaced in one place.
- Lifecycle commands (stop/start/rebuild-single-service/wipe).
- Infra-only mode for backend-via-mvn / UI-via-vite iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:41:30 +02:00
hsiegeln
8a6744d3e9 chore: refresh GitNexus stats + drop stale tsbuildinfo
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / build (push) Has been cancelled
GitNexus analyze --embeddings after the alerts-inbox-redesign branch
brought the graph to 8780 symbols / 22753 relationships (was 8527/22174
in AGENTS.md and 8603/22281 in CLAUDE.md). The stat-header drift between
AGENTS.md and CLAUDE.md is an artifact of separate reindexes — both now
in sync.

ui/tsconfig.app.tsbuildinfo was a stale tsc incremental-build cache
that shouldn't be tracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:39:36 +02:00
hsiegeln
88804aca2c fix(alerts): final sweep — drop ACKNOWLEDGED from AlertStateChip + CMD-K; harden V17 IT
UI: AlertStateChip.LABELS and .COLORS no longer include ACKNOWLEDGED
(dropped in V17). AlertStateChip.test.tsx test-cases trimmed to the
three remaining states. LayoutShell CMD-K now searches FIRING alerts
with acked=false (was state=[FIRING,ACKNOWLEDGED]).

Test: V17MigrationIT.open_rule_index_predicate_is_reworked replaced
with a structural-only assertion (index exists, indisunique). The
pg_get_indexdef pretty-printer varies across Postgres versions, so
predicate semantics are verified behaviorally in
PostgresAlertInstanceRepositoryIT (findOpenForRule_* +
save_rejectsSecondOpenInstanceForSameRuleAndExchange).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:29:58 +02:00
hsiegeln
0cd0a27452 docs(alerts): rules + CLAUDE.md — inbox redesign, V17 migration
- .claude/rules/ui.md: rewrite Alerts section — sidebar trims to
  Inbox/Rules/Silences, InboxPage description updated (4 filters, row
  actions, bulk toolbar, soft-delete undo), SilenceRuleMenu documented,
  SilencesPage ?ruleId= prefill noted.
- CLAUDE.md: V17 migration entry describing enum/column/table/index
  changes for the inbox redesign.
- .claude/rules/app-classes.md AlertController bullet already updated
  in the T6 drive-by.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:21:27 +02:00
hsiegeln
9f28c69709 test(ui/alerts): InboxPage — filter defaults, toggle behavior, role-gated delete, undo toast
Covers: default useAlerts call (FIRING + hide-acked + hide-read),
Hide-acked toggle removes the acked filter, Acknowledge button only
renders for unacked rows, bulk-delete confirmation dialog with count,
delete buttons hidden for non-OPERATOR users, row-delete wires to
useDeleteAlert + renders an Undo action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:19:51 +02:00
hsiegeln
b20f08b3d0 feat(ui/alerts): SilencesPage prefills Rule ID from ?ruleId= query param
Used by InboxPage's 'Silence rule… → Custom…' flow to carry the alert's
ruleId into the silence creation form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:15:52 +02:00
hsiegeln
35fea645b6 fix(ui/alerts): InboxPage polish — status colors, selected-scrub on delete, drop stale comment
- STATE_ITEMS gains color dots (text-muted/error/success) to match SEVERITY_ITEMS
- onDeleteOne removes the deleted id from the selection Set so a follow-up bulk
  action doesn't try to re-delete a tombstoned row
- drop stale comment block that described an alternative SilenceRulesForSelection
  implementation not matching the shipped code

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:14:55 +02:00
hsiegeln
2bc214e324 feat(ui/alerts): single inbox — filter bar, silence/delete row + bulk actions
Replaces the old FIRING+ACK hardcoded inbox with the single filterable
inbox:

- Filter bar: Severity · Status (PENDING/FIRING/RESOLVED, default FIRING) ·
  Hide acked (default on) · Hide read (default on).
- Row actions: Ack, Mark read, Silence rule… (quick menu), Delete
  (OPERATOR+, soft delete with undo toast wired to useRestoreAlert).
- Bulk toolbar: Ack N · Mark N read · Silence rules · Delete N
  (ConfirmDialog; OPERATOR+).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:09:22 +02:00
hsiegeln
837fcbf926 feat(ui/alerts): SilenceRuleMenu — 1h/8h/24h/custom duration menu
Used by InboxPage row + bulk actions to silence an alert's underlying
rule for a chosen preset window. 'Custom…' routes to
/alerts/silences?ruleId=<id> (T13 adds the prefill wire).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:05:30 +02:00
hsiegeln
e3b656f159 refactor(ui/alerts): single inbox — remove AllAlerts + History pages, trim sidebar
Sidebar Alerts section now just: Inbox · Rules · Silences. The /alerts
redirect still lands in /alerts/inbox; /alerts/all and /alerts/history
routes are gone (no redirect — stale URLs 404 per clean-break policy).

Also updates sidebar-utils.test.ts to match the new 3-entry shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:02:12 +02:00
hsiegeln
be703eb71d feat(ui/alerts): hooks for bulk-ack, delete, bulk-delete, restore + acked/read filter params
- useAlerts gains acked/read filter params threaded into query + queryKey
- new mutations: useBulkAckAlerts, useDeleteAlert, useBulkDeleteAlerts, useRestoreAlert
- all cache-invalidate the alerts list and unread-count on success

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:00:18 +02:00
hsiegeln
207ae246af chore(ui): regenerate OpenAPI schema for alerts inbox redesign
New endpoints visible to the SPA: DELETE /alerts/{id}, POST
/alerts/{id}/restore, POST /alerts/bulk-delete, POST /alerts/bulk-ack.
GET /alerts gains tri-state acked / read query params. AlertDto now
includes readAt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:58:26 +02:00
hsiegeln
69fe80353c test(alerts): close repo IT gaps — filterInEnvLive other-env + bulkMarkRead soft-delete
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:55:12 +02:00
hsiegeln
99b739d946 fix(alerts): backend hardening + complete ACKNOWLEDGED migration
- new AlertInstanceRepository.filterInEnvLive(ids, env): single-query bulk ID validation
- AlertController.inEnvLiveIds now one SQL round-trip instead of N
- bulkMarkRead SQL: defense-in-depth AND deleted_at IS NULL
- bulkAck SQL already had deleted_at IS NULL guard — no change needed
- PostgresAlertInstanceRepositoryIT: add filterInEnvLive_excludes_other_env_and_soft_deleted
- V12MigrationIT: remove alert_reads assertion (table dropped by V17)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:48:57 +02:00
hsiegeln
c70fa130ab test(alerts): cover global read — one user marks read, others see readAt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:20:21 +02:00
hsiegeln
efd8396045 feat(alerts): controller — DELETE/bulk-delete/bulk-ack/restore + acked/read filters + readAt on DTO
- GET /alerts gains tri-state acked + read query params
- new endpoints: DELETE /{id} (soft-delete), POST /bulk-delete, POST /bulk-ack, POST /{id}/restore
- requireLiveInstance 404s on soft-deleted rows; restore() reads the row regardless
- BulkReadRequest → BulkIdsRequest (shared body for bulk read/ack/delete)
- AlertDto gains readAt; deletedAt stays off the wire
- InAppInboxQuery.listInbox threads acked/read through to the repo (7-arg, no more null placeholders)
- SecurityConfig: new matchers for bulk-ack (VIEWER+), DELETE/bulk-delete/restore (OPERATOR+)
- AlertControllerIT: persistence assertions on /read + /bulk-read; full coverage for new endpoints
- InAppInboxQueryTest: updated to 7-arg listInbox signature

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:15:16 +02:00
hsiegeln
dd2a5536ab test(alerts): rename ack test to reflect state is unchanged
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:04:39 +02:00
hsiegeln
e1321a4002 chore(alerts): delete orphan PostgresAlertReadRepositoryIT
The class under test was removed in da281933; the IT became a @Disabled
placeholder. Deleting per no-backwards-compat policy. Read mutation
coverage lives in PostgresAlertInstanceRepositoryIT going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:00:00 +02:00
hsiegeln
da2819332c feat(alerts): Postgres repo — read_at/deleted_at columns, filter params, new mutations
- save/rowMapper read+write read_at and deleted_at
- listForInbox: tri-state acked/read filters; always excludes deleted
- countUnreadBySeverity: rewire without alert_reads join, preserve zero-fill
- new: markRead/bulkMarkRead/softDelete/bulkSoftDelete/bulkAck/restore
- delete PostgresAlertReadRepository + its bean
- restore zero-fill Javadoc on interface
- mechanical compile-fixes in AlertController, InAppInboxQuery,
  AlertControllerIT, InAppInboxQueryTest; Task 6 owns the rewrite
- PostgresAlertReadRepositoryIT stubbed @Disabled; Task 7 owns migration

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:56:06 +02:00
hsiegeln
55b2a00458 feat(alerts): core repo — filter params + markRead/softDelete/bulkAck/restore; drop AlertReadRepository
- listForInbox gains tri-state acked/read filter params
- countUnreadBySeverityForUser(envId, userId) → countUnreadBySeverity(envId, userId, groupIds, roleNames)
- new methods: markRead, bulkMarkRead, softDelete, bulkSoftDelete, bulkAck, restore
- delete AlertReadRepository — read is now global on alert_instances.read_at

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:38:10 +02:00
hsiegeln
6e8d890442 fix(alerts): remove dead ACKNOWLEDGED enum SQL + TODO comments
Remove SET state='ACKNOWLEDGED' from ack() and the ACKNOWLEDGED predicate
from findOpenForRule — both would error after V17. The final ack() + open-rule
semantics (idempotent guards, deleted_at) are owned by Task 5; this is just
the minimum to stop runtime SQL errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:36:02 +02:00
hsiegeln
5b1b3f215a test(alerts): state machine — ack is orthogonal, does not transition FIRING
- AlertStateTransitionsTest: add null,null for readAt/deletedAt in openInstance helper;
  replace firingWhenAcknowledgedIsNoOp with firing_with_ack_stays_firing_on_next_firing_tick;
  convert ackedInstanceClearsToResolved to use FIRING+withAck; update section comment.
- PostgresAlertInstanceRepository: stub null,null for readAt/deletedAt in rowMapper
  to unblock compilation (Task 4 will read the actual DB columns).
- All other alerting test files: add null,null for readAt/deletedAt to AlertInstance
  ctor calls so the test source tree compiles; stub ACKNOWLEDGED JSON/state assertions
  with FIRING + TODO Task 4 comments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:28:31 +02:00
hsiegeln
82e82350f9 refactor(alerts): drop ACKNOWLEDGED from AlertState, add readAt/deletedAt to AlertInstance
- AlertState: remove ACKNOWLEDGED case (V17 migration already dropped it from DB enum)
- AlertInstance: insert readAt + deletedAt Instant fields after lastNotifiedAt; add withReadAt/withDeletedAt withers; update all existing withers to pass both fields positionally
- AlertStateTransitions: add null,null for readAt/deletedAt in newInstance ctor call; collapse FIRING,ACKNOWLEDGED switch arm to just FIRING
- AlertScopeTest: update AlertState.values() assertion to 3 values; fix stale ConditionKind.hasSize(6) to 7 (JVM_METRIC was added earlier)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:12:37 +02:00
hsiegeln
e95c21d0cb feat(alerts): V17 migration — drop ACKNOWLEDGED, add read_at + deleted_at
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:04:09 +02:00
hsiegeln
70bf59daca docs(alerts): implementation plan — inbox redesign (16 tasks)
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>
2026-04-21 16:56:53 +02:00
hsiegeln
c0b8c9a1ad docs(alerts): spec — inbox redesign (single filterable inbox)
Collapse /alerts/inbox, /alerts/all, /alerts/history into a single
filterable inbox. Drop ACKNOWLEDGED from AlertState; add read_at and
deleted_at as orthogonal timestamp flags. Retire per-user alert_reads
tracking. Add Silence-rule and Delete row/bulk actions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:45:04 +02:00
hsiegeln
414f7204bf feat(alerting): AGENT_LIFECYCLE condition kind with per-subject fire mode
Allows alert rules to fire on agent-lifecycle events — REGISTERED,
RE_REGISTERED, DEREGISTERED, WENT_STALE, WENT_DEAD, RECOVERED — rather
than only on current state. Each matching `(agent, eventType, timestamp)`
becomes its own ackable AlertInstance, so outages on distinct agents are
independently routable.

Core:
- New `ConditionKind.AGENT_LIFECYCLE` + `AgentLifecycleCondition` record
  (scope, eventTypes, withinSeconds). Compact ctor rejects empty
  eventTypes and withinSeconds<1.
- Strict allowlist enum `AgentLifecycleEventType` (six entries matching
  the server-emitted types in `AgentRegistrationController` and
  `AgentLifecycleMonitor`). Custom agent-emitted event types tracked in
  backlog issue #145.
- `AgentEventRepository.findInWindow(env, appSlug, agentId, eventTypes,
  from, to, limit)` — new read path ordered `(timestamp ASC, insert_id
  ASC)` used by the evaluator. Implemented on
  `ClickHouseAgentEventRepository` with tenant + env filter mandatory.

App:
- `AgentLifecycleEvaluator` queries events in the last `withinSeconds`
  window and returns `EvalResult.Batch` with one `Firing` per row.
  Every Firing carries a canonical `_subjectFingerprint` of
  `"<agentId>:<eventType>:<tsMillis>"` in context plus `agent` / `event`
  subtrees for Mustache templating.
- `NotificationContextBuilder` gains an `AGENT_LIFECYCLE` branch that
  exposes `{{agent.id}}`, `{{agent.app}}`, `{{event.type}}`,
  `{{event.timestamp}}`, `{{event.detail}}`.
- Validation is delegated to the record compact ctor + enum at Jackson
  deserialization time — matches the existing policy of keeping
  controller validators focused on env-scoped / SQL-injection concerns.

Schema:
- V16 migration generalises the V15 per-exchange discriminator on
  `alert_instances_open_rule_uq` to prefer `_subjectFingerprint` with a
  fallback to the legacy `exchange.id` expression. Scalar kinds still
  resolve to `''` and keep one-open-per-rule. Duplicate-key path in
  `PostgresAlertInstanceRepository.save` is unchanged — the index is
  the deduper.

UI:
- New `AgentLifecycleForm.tsx` wizard form with multi-select chips for
  the six allowed event types + `withinSeconds` input. Wired into
  `ConditionStep`, `form-state` (validation + defaults: WENT_DEAD,
  300 s), and `enums.ts` options. Tests in `enums.test.ts` pin the
  new option array.
- `alert-variables.ts` registers `{{agent.app}}`, `{{event.type}}`,
  `{{event.timestamp}}`, `{{event.detail}}` leaves for the new kind,
  and extends `agent.id`'s availability list to include `AGENT_LIFECYCLE`.

Tests (all passing):
- 5 new JSON-roundtrip cases on `AlertConditionJsonTest` (positive +
  empty/zero/unknown-type rejection).
- 5 new evaluator unit tests on `AgentLifecycleEvaluatorTest` (empty
  window, multi-agent fingerprint shape, scope forwarding, missing env).
- `NotificationContextBuilderTest` switch now covers the new kind.
- 119 alerting unit tests + 71 UI tests green.

Docs: `.claude/rules/{core,app,ui}` and CLAUDE.md migration list updated.
2026-04-21 14:52:08 +02:00
hsiegeln
23d02ba6a0 refactor(ui/alerts): tighter inbox action bar, history uses global time range
Inbox: replace 4 parallel outlined buttons with 2 context-aware ones.
When nothing is selected → "Acknowledge all firing" (primary) + "Mark all
read" (secondary). When rows are selected → the same slots become
"Acknowledge N" + "Mark N read" with counts inlined. Primary variant
gives the foreground action proper visual weight; secondary is the
supporting action. No more visually-identical disabled buttons cluttering
the bar.

History: drop the local DateRangePicker. The page now reads
`timeRange` from `useGlobalFilters()` so the top-bar TimeRangeDropdown
(1h / 3h / 6h / Today / 24h / 7d / custom) is the single source of
truth, consistent with every other time-scoped page in the app.
2026-04-21 13:10:43 +02:00
hsiegeln
e8de8d88ad refactor(ui/alerts/all): state filter to ButtonGroup (topnavbar style)
Replace the SegmentedTabs with multi-select ButtonGroup, matching the
topnavbar Completed/Warning/Failed/Running pattern. State dots use the
same palette as AlertStateChip (FIRING=error, ACKNOWLEDGED=warning,
PENDING=muted, RESOLVED=success). Default selection is the three "open"
states — Resolved is off by default and a single click surfaces closed
alerts without navigating to /history.
2026-04-21 13:05:32 +02:00
hsiegeln
f037d8c922 feat(alerting): server-side state+severity filters, ButtonGroup filter UI
Backend: `GET /environments/{envSlug}/alerts` now accepts optional multi-value
`state=…` and `severity=…` query params. Filters are pushed down to
PostgresAlertInstanceRepository, which appends `AND state::text = ANY(?)` /
`AND severity::text = ANY(?)` to the inbox query (null/empty = no filter).

`AlertInstanceRepository.listForInbox` gained a 7-arg overload; the old 5-arg
form is preserved as a default delegate so existing callers (evaluator,
AlertingFullLifecycleIT, PostgresAlertInstanceRepositoryIT) compile unchanged.
`InAppInboxQuery.listInbox` also has a new filtered overload.

UI: InboxPage severity filter migrated from `SegmentedTabs` (single-select,
no color cues) to `ButtonGroup` (multi-select with severity-coloured dots),
matching the topnavbar status-filter pattern. `useAlerts` forwards the
filters as query params and cache-keys on the filter tuple so each combo
is independently cached.

Unit + hook tests updated to the new contract (5 UI tests + 8 Java unit
tests passing). OpenAPI types regenerated from the fresh local backend.
2026-04-21 12:47:31 +02:00
hsiegeln
468132d1dd fix(ui/alerts): bell spacing, rule editor width, inbox bulk controls
Round 4 smoke feedback on /alerts:
- Bell now has consistent 12px gap from env selector and user name
  (wrap env + bell in flex container inside TopBar's environment prop)
- RuleEditorWizard constrained to max-width 840px (centered) and
  upgraded the page title from SectionHeader to h2 pattern used by
  the list pages
- Inbox: added select-all checkbox, severity SegmentedTabs filter
  (All / Critical / Warning / Info), and bulk-ack actions
  (Acknowledge selected + Acknowledge all firing) alongside the
  existing mark-read actions
2026-04-21 12:10:20 +02:00
hsiegeln
c443fc606a fix(alerts/ui): bell position, content tabs hidden, filters, novice labels
Surfaced during second smoke:

1. Notification bell moved — was first child of TopBar (left of
   breadcrumb); now rendered inside the `environment` slot so it
   sits between the env selector and the user menu, matching user
   expectations.

2. Content tabs (Exchanges/Dashboard/Runtime/Deployments) hidden on
   `/alerts/*` — the operational tabs don't apply there.

3. Inbox / All alerts filters now actually filter. `AlertController.list`
   accepts only `limit` — `state`/`severity` query params are dropped
   server-side. Move `useAlerts` to fetch once per env (limit 200) and
   apply filters client-side via react-query `select`, with a stable
   queryKey so filter toggles are instant and don't re-request. True
   server-side filter needs a backend change (follow-up).

4. Novice-friendly labels:
   - Inbox subtitle: "99 firing · 100 total" → "99 need attention ·
     100 total in inbox"
   - All alerts filter: Open/Firing/Acked/All →
     "Currently open"/"Firing now"/"Acknowledged"/"All states"
   - All alerts subtitle: "N shown" → "N matching your filter"
   - History subtitle: "N resolved" → "N resolved alert(s) in range"
   - Rules subtitle: "N total" → "N rule(s) configured"
   - Silences subtitle: "N active" → "N active silence(s)" or
     "Nothing silenced right now"
   - Column headers: "State" → "Status", rules "Kind" → "Type",
     rules "Targets" → "Notifies"
   - Buttons: "Ack" → "Acknowledge", silence "End" → "End early"

Updated alerts.test.tsx and e2e selector to match new behavior/labels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:48:33 +02:00
hsiegeln
05f420d162 fix(alerts/ui): page header, scroll, title preview, bell badge polish
Visual regressions surfaced during browser smoke:

1. Page headers — `SectionHeader` renders as 12px uppercase gray (a
   section divider, not a page title). Replace with proper h2 title
   + inline subtitle (`N firing · N total` etc.) and right-aligned
   actions, styled from `alerts-page.module.css`.

2. Undefined `--space-*` tokens — the project (and `@cameleer/design-system`)
   has never shipped `--space-sm|md|lg|xl`, even though many modules
   (SensitiveKeysPage, alerts CSS, …) reference them. The fallback
   to `initial` silently collapsed gaps/paddings to 0. Define the
   scale in `ui/src/index.css` so every consumer picks it up.

3. List scrolling — DataTable was using default pagination, but with
   no flex sizing the whole page scrolled. Add `fillHeight` and raise
   `pageSize`/list `limit` to 200 so the table gets sticky header +
   internal scroll + pinned pagination footer (Gmail-style). True
   cursor-based infinite scroll needs a backend change (filed as
   follow-up — `/alerts` only accepts `limit` today).

4. Title column clipping — `.titlePreview` used `white-space: nowrap`
   + fixed `max-width`, truncating message mid-UUID. Switch to a
   2-line `-webkit-line-clamp` so full context is visible.

5. Notification bell badge invisible — `NotificationBell.module.css`
   referenced undefined tokens (`--fg`, `--hover-bg`, `--bg`,
   `--muted`). Map to real DS tokens (`--text-primary`, `--bg-hover`,
   `#fff`, `--text-muted`). The admin user currently sees no badge
   because the backend `/alerts/unread-count` returns 0 (read
   receipts) — that's data, not UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:40:28 +02:00
hsiegeln
10e132cd50 refactor(alerts/ui): fix leftover --muted refs in wizard steps
Two inline-style color refs in NotifyStep and TriggerStep were still
pointing at the undefined --muted token instead of the DS
--text-muted. Caught by the design-system-alignment verification
grep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:21:00 +02:00
hsiegeln
35f17a7eeb test(alerts/e2e): adapt smoke suite to DS ConfirmDialog
The Rules list Delete and Silences End-early flows now use DS
ConfirmDialog instead of native confirm(). Update selectors to
target the dialog's role=dialog + confirm button instead of
listening for the native `dialog` event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:19:14 +02:00
hsiegeln
e861e0199c refactor(alerts/ui): wizard banners → DS Alert, step body → section card
Promote banner and prefill warnings now render as DS Alert components
(info / warning variants). Step body wraps in sectionStyles.section
for card affordance matching other forms in the app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:17:54 +02:00
hsiegeln
1b6e6ce40c refactor(alerts/ui): replace undefined CSS vars in wizard.module.css
Replace undefined tokens (--muted, --fg, --accent, --border,
--amber-bg) with DS tokens (--text-muted, --text-primary, --amber,
--border-subtle, --space-sm|md). Drop .promoteBanner — replaced by
DS Alert in follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:16:47 +02:00
hsiegeln
0037309e4f chore(alerts/ui): remove obsolete AlertRow.tsx
The feed-row component is replaced by DataTable column renderers and
the shared renderAlertExpanded content renderer. No callers remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:15:24 +02:00
hsiegeln
3e81572477 refactor(alerts/ui): rewrite Silences with DataTable + FormField + ConfirmDialog
Replaces raw <table> with DataTable, inline-styled form with proper
FormField hints, and native confirm() end-early with ConfirmDialog
(warning variant). Adds DS EmptyState for no-silences case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:14:19 +02:00
hsiegeln
23f3c3990c refactor(alerts/ui): rewrite Rules list with DataTable + Dropdown + ConfirmDialog
Replaces raw <table> with DataTable, raw <select> promote control with
DS Dropdown, and native confirm() delete with ConfirmDialog. Adds DS
EmptyState with CTA for the no-rules case. Uses SectionHeader's
action slot instead of ad-hoc flex wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:12:25 +02:00
hsiegeln
436a0e4d4c refactor(alerts/ui): rewrite History as DataTable + DateRangePicker
Replaces custom feed rows with DataTable. Adds a DateRangePicker
filter (client-side) defaulting to the last 7 days. Client-side
range filter is a stopgap; a server-side range param is a future
enhancement captured in the design spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:10:10 +02:00
hsiegeln
a74785f64d refactor(alerts/ui): rewrite All alerts as DataTable + SegmentedTabs filter
Replaces 4-Button filter row with DS SegmentedTabs and custom row
rendering with DataTable. Shares expandedContent renderer and
severity-driven rowAccent with Inbox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:07:38 +02:00
hsiegeln
588e0b723a refactor(alerts/ui): rewrite Inbox as DataTable with expandable rows
Replaces custom feed-row layout with the shared DataTable shell used
elsewhere in the app. Adds checkbox selection + bulk "Mark selected
read" toolbar alongside the existing "Mark all read". Uses DS
EmptyState for empty lists, severity-driven rowAccent for unread
tinting, and renderAlertExpanded for row detail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:05:39 +02:00
hsiegeln
c87c77c1cf refactor(alerts/ui): slim alerts-page.module.css to layout-only DS tokens
Drop the feed-row classes (.row, .rowUnread, .body, .meta, .time,
.message, .actions, .empty) — these are replaced by DS DataTable +
EmptyState in follow-up tasks. Keep layout helpers for page shell,
toolbar, filter bar, bulk-action bar, title cell, and DataTable
expanded content. All colors / spacing use DS tokens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:03:20 +02:00
hsiegeln
b16ea8b185 feat(alerts/ui): add shared renderAlertExpanded for DataTable rows
Extracts the per-row detail block used by Inbox/All/History DataTables
so the three pages share one rendering. Consumes AlertDto fields that
are nullable in the schema; hides missing fields instead of rendering
placeholders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:01:39 +02:00
hsiegeln
4a63149338 feat(alerts/ui): add formatRelativeTime helper
Formats ISO timestamps as `Nm ago` / `Nh ago` / `Nd ago`, falling back
to an absolute locale date string for values older than 30 days. Used
by the alert DataTable Age column.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:00:15 +02:00
hsiegeln
a2b2ccbab7 feat(alerts/ui): add severityToAccent helper for DataTable rowAccent
Pure function mapping the 3-value AlertDto.severity enum to the 2-value
DataTable rowAccent prop. INFO maps to undefined (no tint) because the
DS DataTable rowAccent only supports error|warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:57:58 +02:00
hsiegeln
52a08a8769 docs(alerts): Implementation plan — design-system alignment for /alerts pages
Task-by-task TDD plan implementing the design spec. Splits the work
into 14 tasks: helper utilities (TDD), shared renderer, CSS token
migration, per-page rewrites (Inbox/All/History/Rules/Silences),
wizard banner migration, AlertRow deletion, E2E adaptation for
ConfirmDialog, and full verification pass. Each task produces an
atomic commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:49:47 +02:00
hsiegeln
3d0a4d289b docs(alerts): Design spec — design-system alignment for /alerts pages
Rework all pages under /alerts to use @cameleer/design-system components
and tokens. Unified DataTable shell for Inbox/All/History with expandable
rows; DataTable + Dropdown + ConfirmDialog for Rules list; FormField grid
+ DataTable for Silences; DS Alert for wizard banners. Replaces undefined
CSS variables (--bg, --fg, --muted, --accent) with DS tokens and removes
raw <table>/<select>/confirm() usage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:43:19 +02:00
88 changed files with 7147 additions and 734 deletions

View File

@@ -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`.

View File

@@ -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

View File

@@ -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`).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 (~24 min).
docker compose up -d --build
# 3. Watch the server come up (health check goes green in ~6090s 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

View File

@@ -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();

View File

@@ -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() {

View File

@@ -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());
}
}

View File

@@ -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) {}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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(),

View File

@@ -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;

View File

@@ -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"));

View File

@@ -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,

View File

@@ -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);
}
}
}

View File

@@ -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")

View File

@@ -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());
}
}

View File

@@ -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');

View File

@@ -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;

View File

@@ -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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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()

View File

@@ -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()));

View File

@@ -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()

View File

@@ -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());
}

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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 " +

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -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
}

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -1,3 +1,3 @@
package com.cameleer.server.core.alerting;
public enum AlertState { PENDING, FIRING, ACKNOWLEDGED, RESOLVED }
public enum AlertState { PENDING, FIRING, RESOLVED }

View File

@@ -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
}

View File

@@ -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");
}
}

View File

@@ -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(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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.

View File

@@ -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

View File

@@ -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 });

View File

@@ -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
View File

@@ -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: {

View File

@@ -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} />);

View File

@@ -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',
};

View File

@@ -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} />
)}

View File

@@ -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',

View File

@@ -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); }

View File

@@ -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',
]);
});
});

View File

@@ -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 })) },
];
}

View File

@@ -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"] {

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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();
});
});

View File

@@ -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>
);
}

View File

@@ -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} />}

View File

@@ -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>
)}

View File

@@ -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> &mdash; 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

View File

@@ -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>
)}

View File

@@ -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>
</>
);
}

View File

@@ -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.');

View File

@@ -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 {

View File

@@ -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>
);
}

View 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}
/>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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); }

View File

@@ -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' },

View File

@@ -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);

View 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();
});
});

View 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;
}
}

View 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');
});
});

View 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',
});
}

View File

@@ -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> },

View File

@@ -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);
});
});

View File

@@ -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"}