Compare commits

8 Commits

Author SHA1 Message Date
hsiegeln
f8c1ba4988 docs(auth): document user_id convention and write-path shape
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m2s
CI / docker (push) Successful in 1m20s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 1m41s
After the UiAuth / Oidc / UserAdmin controllers were aligned to store
bare user_ids, the rules that future sessions read were still describing
the old behaviour (OutboundConnectionAdminController "strips user:
prefix" — true mechanically but the subtlety is that the strip is
the bridge between a prefixed JWT subject and an unprefixed DB key,
not a hack).

- CLAUDE.md: expand the User persistence one-liner to state the
  convention authoritatively (local `<username>`, OIDC `oidc:<sub>`,
  JWT `user:` namespace, env-scoped controllers strip for FK).
- .claude/rules/app-classes.md:
  - Add "User ID conventions" section near the top that spells out
    write-path vs read-path behaviour in one place.
  - Add UiAuthController + OidcAuthController entries under
    security/ with their upsert shape documented.
  - Soften the OutboundConnectionAdminController line to reference
    the convention instead of restating the mechanism.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:49:22 +02:00
hsiegeln
ae6473635d fix(auth): OidcAuthController + UserAdminController upsert unprefixed
Follow-up to the UiAuthController fix: every write path that puts a row
into users/user_roles/user_groups must use the bare DB key, because
the env-scoped controllers (Alert, AlertRule, AlertSilence, Outbound)
strip "user:" before using the name as an FK. If the write path stores
prefixed, first-time alerting/outbound writes fail with
alert_rules_created_by_fkey violation.

UiAuthController shipped the model in the prior commit (bare userId
for all DB/RBAC calls, "user:"-namespaced subject for JWT signing).
Bringing the other two write paths in line:

- OidcAuthController.callback:
    userId  = "oidc:" + oidcUser.subject()    // DB key, no "user:"
    subject = "user:" + userId                // JWT subject (namespaced)
  All userRepository / rbacService / applyClaimMappings calls use
  userId. Tokens still carry the namespaced subject so
  JwtAuthenticationFilter can distinguish user vs agent tokens.

- UserAdminController.createUser: userId = request.username() (bare).
  resetPassword: dropped the "user:"-strip fallback that was only
  needed because create used to prefix — now dead.

No migration. Greenfield alpha product — any pre-existing prefixed
rows in a dev DB will become orphans on next login (login upserts
the unprefixed row, old prefixed row is harmless but unused).
Operators doing a clean re-index can wipe the DB.

Read-path controllers still strip — harmless for bare DB rows, and
OIDC humans (JWT sub "user:oidc:<s>") still resolve correctly to
the new DB key "oidc:<s>" after stripping.

Verified: 45/45 alerting + outbound ITs pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:44:17 +02:00
hsiegeln
6b5aefd4c2 docs(gitnexus): re-run analyze after cleanup-batch commits
Post-commit stats: 8524 nodes / 22174 edges / 415 clusters / 300 flows
(up from 8513/22146/409/300 after the five cleanup-batch commits).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:27:27 +02:00
hsiegeln
1ea0258393 fix(auth): upsert UI login user_id unprefixed (drop docker seeder workaround)
Root cause of the mismatch that prompted the one-shot cameleer-seed
docker service: UiAuthController stored users.user_id as the JWT
subject "user:admin" (JWT sub format). Every env-scoped controller
(Alert, AlertSilence, AlertRule, OutboundConnectionAdmin) already
strips the "user:" prefix on the read path — so the rest of the
system expects the DB key to be the bare username. With UiAuth
storing prefixed, fresh docker stacks hit
"alert_rules_created_by_fkey violation" on the first rule create.

Fix: inside login(), compute `userId = request.username()` and use
it everywhere the DB/RBAC layer is touched (isLocked, getPasswordHash,
record/clearFailedLogins, upsert, assignRoleToUser, addUserToGroup,
getSystemRoleNames). Keep `subject = "user:" + userId` — we still
sign JWTs with the namespaced subject so JwtAuthenticationFilter can
distinguish user vs agent tokens.

refresh() and me() follow the same rule via a stripSubjectPrefix()
helper (JWT subject in, bare DB key out).

With the write path aligned, the docker bridge is no longer needed:
- Deleted deploy/docker/postgres-init.sql
- Deleted cameleer-seed service from docker-compose.yml

Scope: UiAuthController only. UserAdminController + OidcAuthController
still prefix on upsert — that's the bug class the triage identified
as "Option A or B either way OK". Not changing them now because:
  a) prod admins are provisioned unprefixed through some other path,
     so those two files aren't the docker-only failure observed;
  b) stripping them would need a data migration for any existing
     prod users stored prefixed, which is out of scope for a cleanup
     phase. Follow-up worth scheduling if we ever wire OIDC or admin-
     created users into alerting FKs.

Verified: 33/33 alerting+outbound controller ITs pass (9 outbound,
10 rules, 9 silences, 5 alert inbox).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:26:03 +02:00
hsiegeln
09b49f096c feat(alerting): per-severity breakdown on unread-count DTO
Spec §13 calls for the notification bell to colour-code by highest
unread severity (CRITICAL → error, WARNING → amber, INFO → muted).
The old { count } DTO forced the UI to pick one static colour, so
NotificationBell shipped with a TODO. Grow the contract instead:

  UnreadCountResponse = { total, bySeverity: { CRITICAL, WARNING, INFO } }

Guarantees:
- every severity is always present with a >=0 value (no undefined
  keys on the wire), so the UI can branch without defaults.
- total = sum of bySeverity values — kept explicit on the wire for
  cheap top-line display, not recomputed client-side.

Backend
- AlertInstanceRepository: replaces countUnreadForUser(long) with
  countUnreadBySeverityForUser returning Map<AlertSeverity, Long>.
  One SQL round-trip per (env, user) — GROUP BY ai.severity over the
  same NOT EXISTS(alert_reads) filter.
- UnreadCountResponse.from(Map) normalises and defensively copies;
  missing severities default to 0.
- InAppInboxQuery.countUnread now returns the DTO, caches the full
  response (still 5s TTL) so severity breakdown gets the same
  hit-rate as the total did before.
- AlertController just hands the DTO back.

Breaking change — no backwards-compat shim: the `count` field is
gone. UI and tests updated in the same commit; there are no other
API consumers in the tree.

Frontend
- Regenerated openapi.json + schema.d.ts against a fresh build of
  the new backend.
- NotificationBell branches badge colour on the highest unread
  severity (CRITICAL > WARNING > INFO) via new CSS variants.
- Tests cover all four paths: zero, critical-present, warning-only,
  info-only.

Tests: 7 unit tests + 12 ITs (incl. new grouping + empty-map)
       + 49 vitest (was 46; +3 severity-branch assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:15:56 +02:00
hsiegeln
18cacb33ee docs(alerting): align @JsonTypeInfo spec with shipped code
Design spec and Plan 02 described AlertCondition polymorphism as
Id.DEDUCTION, but the code that shipped in PR #140 uses Id.NAME with
property="kind" and include=EXISTING_PROPERTY. The `kind` field is
real on every subtype and the DB stores it in a separate column
(condition_kind), so reading the discriminator directly is simpler
than deduction — update the docs to match. Also add `"kind"` to the
example JSON payloads so they match on-wire reality.

OutboundAuth (Plan 01) correctly still uses Id.DEDUCTION and is
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:04:17 +02:00
hsiegeln
d850d00bab docs(gitnexus): refresh index stats + repo name (alerting-02 → cameleer-server)
Re-ran `npx gitnexus analyze --embeddings` after PR #144 merge.
8513 symbols / 22146 relationships / 300 execution flows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:02:18 +02:00
hsiegeln
579b5f1a04 chore(ui): delete unused usePageVisible hook
Added as a reusable primitive during Plan 03 Task 9, but the intended
consumer (NotificationBell live-region refresh) was removed during
code review, leaving the hook unused. Delete it — YAGNI; reintroduce
when a real consumer shows up.

Verified upstream impact (gitnexus): 0 callers, LOW risk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:02:04 +02:00
24 changed files with 349 additions and 276 deletions

View File

@@ -35,6 +35,20 @@ These paths intentionally stay flat (no `/environments/{envSlug}` prefix). Every
ClickHouse is shared across tenants. Every ClickHouse query must filter by `tenant_id` (from `CAMELEER_SERVER_TENANT_ID` env var, resolved via `TenantContext`/config) in addition to `environment`. New controllers added under `/environments/{envSlug}/...` must preserve this — the env filter from the path does not replace the tenant filter. ClickHouse is shared across tenants. Every ClickHouse query must filter by `tenant_id` (from `CAMELEER_SERVER_TENANT_ID` env var, resolved via `TenantContext`/config) in addition to `environment`. New controllers added under `/environments/{envSlug}/...` must preserve this — the env filter from the path does not replace the tenant filter.
## User ID conventions
`users.user_id` stores the **bare** identifier:
- Local users: `<username>` (e.g. `admin`, `alice`)
- OIDC users: `oidc:<sub>` (e.g. `oidc:c7a93b…`)
JWT subjects carry a `user:` namespace prefix (`user:admin`, `user:oidc:<sub>`) so `JwtAuthenticationFilter` can distinguish user tokens from agent tokens. All three write paths upsert the **bare** form:
- `UiAuthController.login` — computes `userId = request.username()`, signs with `subject = "user:" + userId`.
- `OidcAuthController.callback``userId = "oidc:" + oidcUser.subject()`, signs with `subject = "user:" + userId`.
- `UserAdminController.createUser``userId = request.username()`.
Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `AlertSilenceController`, `OutboundConnectionAdminController`) strip `"user:"` from `SecurityContextHolder.authentication.name` before using it as an FK. All FKs to `users(user_id)` (e.g. `alert_rules.created_by`, `outbound_connections.created_by`, `alert_reads.user_id`, `user_roles.user_id`, `user_groups.user_id`) therefore reference the bare form. If you add a new controller that needs the acting user id for an FK insert, follow the same strip pattern.
## controller/ — REST endpoints ## controller/ — REST endpoints
### Env-scoped (user-facing data & config) ### Env-scoped (user-facing data & config)
@@ -143,7 +157,8 @@ ClickHouse is shared across tenants. Every ClickHouse query must filter by `tena
- `SecurityConfig` — WebSecurityFilterChain, JWT filter, CORS, OIDC conditional. `/api/v1/admin/outbound-connections/**` GETs permit OPERATOR in addition to ADMIN (defense-in-depth at controller level); mutations remain ADMIN-only. Alerting matchers: GET `/environments/*/alerts/**` VIEWER+; POST/PUT/DELETE rules and silences OPERATOR+; ack/read/bulk-read VIEWER+; POST `/alerts/notifications/*/retry` OPERATOR+. - `SecurityConfig` — WebSecurityFilterChain, JWT filter, CORS, OIDC conditional. `/api/v1/admin/outbound-connections/**` GETs permit OPERATOR in addition to ADMIN (defense-in-depth at controller level); mutations remain ADMIN-only. Alerting matchers: GET `/environments/*/alerts/**` VIEWER+; POST/PUT/DELETE rules and silences OPERATOR+; ack/read/bulk-read VIEWER+; POST `/alerts/notifications/*/retry` OPERATOR+.
- `JwtAuthenticationFilter` — OncePerRequestFilter, validates Bearer tokens - `JwtAuthenticationFilter` — OncePerRequestFilter, validates Bearer tokens
- `JwtServiceImpl` — HMAC-SHA256 JWT (Nimbus JOSE) - `JwtServiceImpl` — HMAC-SHA256 JWT (Nimbus JOSE)
- `OidcAuthController` — /api/v1/auth/oidc (login-uri, token-exchange, logout) - `UiAuthController``/api/v1/auth` (login, refresh, me). Upserts `users.user_id = request.username()` (bare); signs JWTs with `subject = "user:" + userId`. `refresh`/`me` strip the `"user:"` prefix from incoming subjects via `stripSubjectPrefix()` before any DB/RBAC lookup.
- `OidcAuthController``/api/v1/auth/oidc` (login-uri, token-exchange, logout). Upserts `users.user_id = "oidc:" + oidcUser.subject()` (no `user:` prefix); signs JWTs with `subject = "user:oidc:" + oidcUser.subject()`. `applyClaimMappings` + `getSystemRoleNames` calls all use the bare `oidc:<sub>` form.
- `OidcTokenExchanger` — code -> tokens, role extraction from access_token then id_token - `OidcTokenExchanger` — code -> tokens, role extraction from access_token then id_token
- `OidcProviderHelper` — OIDC discovery, JWK source cache - `OidcProviderHelper` — OIDC discovery, JWK source cache
@@ -168,7 +183,7 @@ ClickHouse is shared across tenants. Every ClickHouse query must filter by `tena
- `crypto/SecretCipher` — AES-GCM symmetric cipher with key derived via HMAC-SHA256(jwtSecret, "cameleer-outbound-secret-v1"). Ciphertext format: base64(IV(12 bytes) || GCM output with 128-bit tag). `encrypt` throws `IllegalStateException`; `decrypt` throws `IllegalArgumentException` on tamper/wrong-key/malformed. - `crypto/SecretCipher` — AES-GCM symmetric cipher with key derived via HMAC-SHA256(jwtSecret, "cameleer-outbound-secret-v1"). Ciphertext format: base64(IV(12 bytes) || GCM output with 128-bit tag). `encrypt` throws `IllegalStateException`; `decrypt` throws `IllegalArgumentException` on tamper/wrong-key/malformed.
- `storage/PostgresOutboundConnectionRepository` — JdbcTemplate impl. `save()` upserts by id; JSONB serialization via ObjectMapper; UUID arrays via `ConnectionCallback`. Reads `created_by`/`updated_by` as String (= users.user_id TEXT). - `storage/PostgresOutboundConnectionRepository` — JdbcTemplate impl. `save()` upserts by id; JSONB serialization via ObjectMapper; UUID arrays via `ConnectionCallback`. Reads `created_by`/`updated_by` as String (= users.user_id TEXT).
- `OutboundConnectionServiceImpl` — service layer. Tenant bound at construction via `cameleer.server.tenant.id` property. Uniqueness check via `findByName`. Narrowing-envs guard: rejects update that removes envs while rules reference the connection (rulesReferencing stubbed in Plan 01, wired in Plan 02). Delete guard: rejects if referenced by rules. - `OutboundConnectionServiceImpl` — service layer. Tenant bound at construction via `cameleer.server.tenant.id` property. Uniqueness check via `findByName`. Narrowing-envs guard: rejects update that removes envs while rules reference the connection (rulesReferencing stubbed in Plan 01, wired in Plan 02). Delete guard: rejects if referenced by rules.
- `controller/OutboundConnectionAdminController` — REST controller. Class-level `@PreAuthorize("hasRole('ADMIN')")` defaults; GETs relaxed to ADMIN|OPERATOR. Extracts acting user id from `SecurityContextHolder.authentication.name`, strips "user:" prefix. Audit via `AuditCategory.OUTBOUND_CONNECTION_CHANGE`. - `controller/OutboundConnectionAdminController` — REST controller. Class-level `@PreAuthorize("hasRole('ADMIN')")` defaults; GETs relaxed to ADMIN|OPERATOR. Resolves acting user id via the user-id convention (strip `"user:"` from `authentication.name` → matches `users.user_id` FK). Audit via `AuditCategory.OUTBOUND_CONNECTION_CHANGE`.
- `dto/OutboundConnectionRequest` — Bean Validation: `@NotBlank` name, `@Pattern("^https://.+")` url, `@NotNull` method/tlsTrustMode/auth. Compact ctor throws `IllegalArgumentException` if TRUST_PATHS with empty paths list. - `dto/OutboundConnectionRequest` — Bean Validation: `@NotBlank` name, `@Pattern("^https://.+")` url, `@NotNull` method/tlsTrustMode/auth. Compact ctor throws `IllegalArgumentException` if TRUST_PATHS with empty paths list.
- `dto/OutboundConnectionDto` — response DTO. `hmacSecretSet: boolean` instead of the ciphertext; `authKind: OutboundAuthKind` instead of the full auth config. - `dto/OutboundConnectionDto` — response DTO. `hmacSecretSet: boolean` instead of the ciphertext; `authKind: OutboundAuthKind` instead of the full auth config.
- `dto/OutboundConnectionTestResult` — result of POST `/{id}/test`: status, latencyMs, responseSnippet (first 512 chars), tlsProtocol/cipherSuite/peerCertSubject (protocol is "TLS" stub; enriched in Plan 02 follow-up), error (nullable). - `dto/OutboundConnectionTestResult` — result of POST `/{id}/test`: status, latencyMs, responseSnippet (first 512 chars), tlsProtocol/cipherSuite/peerCertSubject (protocol is "TLS" stub; enriched in Plan 02 follow-up), error (nullable).

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **alerting-02** (7810 symbols, 20082 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** (8524 symbols, 22174 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. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
@@ -17,7 +17,7 @@ This project is indexed by GitNexus as **alerting-02** (7810 symbols, 20082 rela
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue 1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation 2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/alerting-02/process/{processName}` — trace the full execution flow step by step 3. `READ gitnexus://repo/cameleer-server/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed 4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring ## When Refactoring
@@ -56,10 +56,10 @@ This project is indexed by GitNexus as **alerting-02** (7810 symbols, 20082 rela
| Resource | Use for | | Resource | Use for |
|----------|---------| |----------|---------|
| `gitnexus://repo/alerting-02/context` | Codebase overview, check index freshness | | `gitnexus://repo/cameleer-server/context` | Codebase overview, check index freshness |
| `gitnexus://repo/alerting-02/clusters` | All functional areas | | `gitnexus://repo/cameleer-server/clusters` | All functional areas |
| `gitnexus://repo/alerting-02/processes` | All execution flows | | `gitnexus://repo/cameleer-server/processes` | All execution flows |
| `gitnexus://repo/alerting-02/process/{name}` | Step-by-step execution trace | | `gitnexus://repo/cameleer-server/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing ## Self-Check Before Finishing

View File

@@ -51,7 +51,7 @@ java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_SERVER_SECURITY_OIDCISSUERURI` is set. Scope-based role mapping via `SystemRole.normalizeScope()`. System roles synced on every OIDC login via `applyClaimMappings()` in `OidcAuthController` (calls `clearManagedAssignments` + `assignManagedRole` on `RbacService`) — always overwrites managed role assignments; uses managed assignment origin to avoid touching group-inherited or directly-assigned roles. Supports ES384, ES256, RS256. - OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_SERVER_SECURITY_OIDCISSUERURI` is set. Scope-based role mapping via `SystemRole.normalizeScope()`. System roles synced on every OIDC login via `applyClaimMappings()` in `OidcAuthController` (calls `clearManagedAssignments` + `assignManagedRole` on `RbacService`) — always overwrites managed role assignments; uses managed assignment origin to avoid touching group-inherited or directly-assigned roles. Supports ES384, ES256, RS256.
- OIDC role extraction: `OidcTokenExchanger` reads roles from the **access_token** first (JWT with `at+jwt` type), then falls back to id_token. `OidcConfig` includes `audience` (RFC 8707 resource indicator) and `additionalScopes`. All provider-specific configuration is external — no provider-specific code in the server. - OIDC role extraction: `OidcTokenExchanger` reads roles from the **access_token** first (JWT with `at+jwt` type), then falls back to id_token. `OidcConfig` includes `audience` (RFC 8707 resource indicator) and `additionalScopes`. All provider-specific configuration is external — no provider-specific code in the server.
- Sensitive keys: Global enforced baseline for masking sensitive data in agent payloads. Merge rule: `final = global UNION per-app` (case-insensitive dedup, per-app can only add, never remove global keys). - Sensitive keys: Global enforced baseline for masking sensitive data in agent payloads. Merge rule: `final = global UNION per-app` (case-insensitive dedup, per-app can only add, never remove global keys).
- User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users` - User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users`. `users.user_id` is the **bare** identifier — local users as `<username>`, OIDC users as `oidc:<sub>`. JWT `sub` carries the `user:` namespace prefix so `JwtAuthenticationFilter` can tell user tokens from agent tokens; write paths (`UiAuthController`, `OidcAuthController`, `UserAdminController`) all upsert unprefixed, and env-scoped read-path controllers strip the `user:` prefix before using the value as an FK to `users.user_id` / `user_roles.user_id`. Alerting / outbound FKs (`alert_rules.created_by`, `outbound_connections.created_by`, …) therefore all reference the bare form.
- Usage analytics: ClickHouse `usage_events` table tracks authenticated UI requests, flushed every 5s - Usage analytics: ClickHouse `usage_events` table tracks authenticated UI requests, flushed every 5s
## Database Migrations ## Database Migrations
@@ -97,7 +97,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **alerting-02** (7810 symbols, 20082 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** (8524 symbols, 22174 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. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
@@ -113,7 +113,7 @@ This project is indexed by GitNexus as **alerting-02** (7810 symbols, 20082 rela
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue 1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation 2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/alerting-02/process/{processName}` — trace the full execution flow step by step 3. `READ gitnexus://repo/cameleer-server/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed 4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring ## When Refactoring
@@ -152,10 +152,10 @@ This project is indexed by GitNexus as **alerting-02** (7810 symbols, 20082 rela
| Resource | Use for | | Resource | Use for |
|----------|---------| |----------|---------|
| `gitnexus://repo/alerting-02/context` | Codebase overview, check index freshness | | `gitnexus://repo/cameleer-server/context` | Codebase overview, check index freshness |
| `gitnexus://repo/alerting-02/clusters` | All functional areas | | `gitnexus://repo/cameleer-server/clusters` | All functional areas |
| `gitnexus://repo/alerting-02/processes` | All execution flows | | `gitnexus://repo/cameleer-server/processes` | All execution flows |
| `gitnexus://repo/alerting-02/process/{name}` | Step-by-step execution trace | | `gitnexus://repo/cameleer-server/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing ## Self-Check Before Finishing

View File

@@ -63,9 +63,7 @@ public class AlertController {
@GetMapping("/unread-count") @GetMapping("/unread-count")
public UnreadCountResponse unreadCount(@EnvPath Environment env) { public UnreadCountResponse unreadCount(@EnvPath Environment env) {
String userId = currentUserId(); return inboxQuery.countUnread(env.id(), currentUserId());
long count = inboxQuery.countUnread(env.id(), userId);
return new UnreadCountResponse(count);
} }
@GetMapping("/{id}") @GetMapping("/{id}")

View File

@@ -1,3 +1,29 @@
package com.cameleer.server.app.alerting.dto; package com.cameleer.server.app.alerting.dto;
public record UnreadCountResponse(long count) {} import com.cameleer.server.core.alerting.AlertSeverity;
import java.util.EnumMap;
import java.util.Map;
/**
* Response shape for {@code GET /alerts/unread-count}.
* <p>
* {@code total} is the sum of {@code bySeverity} values. The UI branches bell colour on
* the highest severity present, so callers can inspect the map directly.
*/
public record UnreadCountResponse(long total, Map<AlertSeverity, Long> bySeverity) {
public UnreadCountResponse {
// Defensive copy + fill in missing severities as 0 so the UI never sees null/undefined.
EnumMap<AlertSeverity, Long> normalized = new EnumMap<>(AlertSeverity.class);
for (AlertSeverity s : AlertSeverity.values()) normalized.put(s, 0L);
if (bySeverity != null) bySeverity.forEach((k, v) -> normalized.put(k, v == null ? 0L : v));
bySeverity = Map.copyOf(normalized);
}
public static UnreadCountResponse from(Map<AlertSeverity, Long> counts) {
long total = counts == null ? 0L
: counts.values().stream().filter(v -> v != null).mapToLong(Long::longValue).sum();
return new UnreadCountResponse(total, counts == null ? Map.of() : counts);
}
}

View File

@@ -1,7 +1,9 @@
package com.cameleer.server.app.alerting.notify; package com.cameleer.server.app.alerting.notify;
import com.cameleer.server.app.alerting.dto.UnreadCountResponse;
import com.cameleer.server.core.alerting.AlertInstance; import com.cameleer.server.core.alerting.AlertInstance;
import com.cameleer.server.core.alerting.AlertInstanceRepository; import com.cameleer.server.core.alerting.AlertInstanceRepository;
import com.cameleer.server.core.alerting.AlertSeverity;
import com.cameleer.server.core.rbac.RbacService; import com.cameleer.server.core.rbac.RbacService;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -17,7 +19,8 @@ import java.util.concurrent.ConcurrentHashMap;
* <p> * <p>
* {@link #listInbox} returns alerts the user is allowed to see (targeted directly or via group/role). * {@link #listInbox} returns alerts the user is allowed to see (targeted directly or via group/role).
* {@link #countUnread} is memoized per {@code (envId, userId)} for 5 seconds to avoid hammering * {@link #countUnread} is memoized per {@code (envId, userId)} for 5 seconds to avoid hammering
* the database on every page render. * the database on every page render. The memo caches the full per-severity breakdown so
* the UI can branch bell colour on the highest unread severity without a second call.
*/ */
@Component @Component
public class InAppInboxQuery { public class InAppInboxQuery {
@@ -31,8 +34,8 @@ public class InAppInboxQuery {
/** Cache key for the unread count memo. */ /** Cache key for the unread count memo. */
private record Key(UUID envId, String userId) {} private record Key(UUID envId, String userId) {}
/** Cache entry: cached count + expiry timestamp. */ /** Cache entry: cached response + expiry timestamp. */
private record Entry(long count, Instant expiresAt) {} private record Entry(UnreadCountResponse response, Instant expiresAt) {}
private final ConcurrentHashMap<Key, Entry> memo = new ConcurrentHashMap<>(); private final ConcurrentHashMap<Key, Entry> memo = new ConcurrentHashMap<>();
@@ -57,20 +60,21 @@ public class InAppInboxQuery {
} }
/** /**
* Returns the count of unread (un-acked) alert instances visible to the user. * Returns the unread (un-acked) alert count for the user, broken down by severity.
* <p> * <p>
* The result is memoized for 5 seconds per {@code (envId, userId)}. * Memoized for 5 seconds per {@code (envId, userId)}.
*/ */
public long countUnread(UUID envId, String userId) { public UnreadCountResponse countUnread(UUID envId, String userId) {
Key key = new Key(envId, userId); Key key = new Key(envId, userId);
Instant now = Instant.now(clock); Instant now = Instant.now(clock);
Entry cached = memo.get(key); Entry cached = memo.get(key);
if (cached != null && now.isBefore(cached.expiresAt())) { if (cached != null && now.isBefore(cached.expiresAt())) {
return cached.count(); return cached.response();
} }
long count = instanceRepo.countUnreadForUser(envId, userId); Map<AlertSeverity, Long> bySeverity = instanceRepo.countUnreadBySeverityForUser(envId, userId);
memo.put(key, new Entry(count, now.plusMillis(MEMO_TTL_MS))); UnreadCountResponse response = UnreadCountResponse.from(bySeverity);
return count; memo.put(key, new Entry(response, now.plusMillis(MEMO_TTL_MS)));
return response;
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -118,18 +118,24 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
} }
@Override @Override
public long countUnreadForUser(UUID environmentId, String userId) { public Map<AlertSeverity, Long> countUnreadBySeverityForUser(UUID environmentId, String userId) {
String sql = """ String sql = """
SELECT COUNT(*) FROM alert_instances ai SELECT ai.severity::text AS severity, COUNT(*) AS cnt
FROM alert_instances ai
WHERE ai.environment_id = ? WHERE ai.environment_id = ?
AND ? = ANY(ai.target_user_ids) AND ? = ANY(ai.target_user_ids)
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM alert_reads ar SELECT 1 FROM alert_reads ar
WHERE ar.user_id = ? AND ar.alert_instance_id = ai.id WHERE ar.user_id = ? AND ar.alert_instance_id = ai.id
) )
GROUP BY ai.severity
"""; """;
Long count = jdbc.queryForObject(sql, Long.class, environmentId, userId, userId); EnumMap<AlertSeverity, Long> counts = new EnumMap<>(AlertSeverity.class);
return count == null ? 0L : count; 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);
return counts;
} }
@Override @Override

View File

@@ -93,7 +93,9 @@ public class UserAdminController {
return ResponseEntity.badRequest() return ResponseEntity.badRequest()
.body(Map.of("error", "Local user creation is disabled when OIDC is enabled. Users are provisioned automatically via SSO.")); .body(Map.of("error", "Local user creation is disabled when OIDC is enabled. Users are provisioned automatically via SSO."));
} }
String userId = "user:" + request.username(); // DB key is the bare username (matches alert_rules.created_by FK shape used by
// the env-scoped read-path controllers, which strip "user:" from JWT subjects).
String userId = request.username();
UserInfo user = new UserInfo(userId, "local", UserInfo user = new UserInfo(userId, "local",
request.email() != null ? request.email() : "", request.email() != null ? request.email() : "",
request.displayName() != null ? request.displayName() : request.username(), request.displayName() != null ? request.displayName() : request.username(),
@@ -215,9 +217,7 @@ public class UserAdminController {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
} }
// Extract bare username from "user:username" format for policy check List<String> violations = PasswordPolicyValidator.validate(request.password(), userId);
String username = userId.startsWith("user:") ? userId.substring(5) : userId;
List<String> violations = PasswordPolicyValidator.validate(request.password(), username);
if (!violations.isEmpty()) { if (!violations.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Password policy violation: " + String.join("; ", violations)); "Password policy violation: " + String.join("; ", violations));

View File

@@ -140,28 +140,29 @@ public class OidcAuthController {
OidcTokenExchanger.OidcUserInfo oidcUser = OidcTokenExchanger.OidcUserInfo oidcUser =
tokenExchanger.exchange(request.code(), request.redirectUri()); tokenExchanger.exchange(request.code(), request.redirectUri());
String userId = "user:oidc:" + oidcUser.subject(); // DB key is unprefixed (matches alert_rules.created_by FK shape used by the
// env-scoped read-path controllers). JWT subject keeps the "user:" namespace
// so JwtAuthenticationFilter can still distinguish user vs agent tokens.
String userId = "oidc:" + oidcUser.subject();
String subject = "user:" + userId;
String issuerHost = URI.create(config.get().issuerUri()).getHost(); String issuerHost = URI.create(config.get().issuerUri()).getHost();
String provider = "oidc:" + issuerHost; String provider = "oidc:" + issuerHost;
// Check auto-signup gate: if disabled, user must already exist
Optional<UserInfo> existingUser = userRepository.findById(userId); Optional<UserInfo> existingUser = userRepository.findById(userId);
if (!config.get().autoSignup() && existingUser.isEmpty()) { if (!config.get().autoSignup() && existingUser.isEmpty()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Account not provisioned. Contact your administrator."); "Account not provisioned. Contact your administrator.");
} }
// Upsert user (without roles -- roles are in user_roles table)
userRepository.upsert(new UserInfo( userRepository.upsert(new UserInfo(
userId, provider, oidcUser.email(), oidcUser.name(), Instant.now())); userId, provider, oidcUser.email(), oidcUser.name(), Instant.now()));
// Apply claim mapping rules to assign managed roles/groups from JWT claims
applyClaimMappings(userId, oidcUser.allClaims(), oidcUser.roles(), config.get()); applyClaimMappings(userId, oidcUser.allClaims(), oidcUser.roles(), config.get());
List<String> roles = rbacService.getSystemRoleNames(userId); List<String> roles = rbacService.getSystemRoleNames(userId);
String accessToken = jwtService.createAccessToken(userId, "user", roles); String accessToken = jwtService.createAccessToken(subject, "user", roles);
String refreshToken = jwtService.createRefreshToken(userId, "user", roles); String refreshToken = jwtService.createRefreshToken(subject, "user", roles);
String displayName = oidcUser.name() != null && !oidcUser.name().isBlank() String displayName = oidcUser.name() != null && !oidcUser.name().isBlank()
? oidcUser.name() : oidcUser.email(); ? oidcUser.name() : oidcUser.email();

View File

@@ -77,27 +77,30 @@ public class UiAuthController {
HttpServletRequest httpRequest) { HttpServletRequest httpRequest) {
String configuredUser = properties.getUiUser(); String configuredUser = properties.getUiUser();
String configuredPassword = properties.getUiPassword(); String configuredPassword = properties.getUiPassword();
String subject = "user:" + request.username(); // The JWT subject carries a "user:" namespace prefix so the auth filter
// can distinguish user vs agent tokens. The DB row keys (users.user_id,
// user_roles.user_id, alert_rules.created_by FK, …) are the bare username:
// every env-scoped controller strips the prefix on the read path via
// stripSubjectPrefix(...), so the write path here must match.
String userId = request.username();
String subject = "user:" + userId;
// Check account lockout before attempting authentication if (userRepository.isLocked(userId)) {
if (userRepository.isLocked(subject)) {
auditService.log(request.username(), "login_locked", AuditCategory.AUTH, null, auditService.log(request.username(), "login_locked", AuditCategory.AUTH, null,
Map.of("reason", "Account locked"), AuditResult.FAILURE, httpRequest); Map.of("reason", "Account locked"), AuditResult.FAILURE, httpRequest);
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
"Account locked due to too many failed attempts. Try again later."); "Account locked due to too many failed attempts. Try again later.");
} }
// Try env-var admin first
boolean envMatch = configuredUser != null && !configuredUser.isBlank() boolean envMatch = configuredUser != null && !configuredUser.isBlank()
&& configuredPassword != null && !configuredPassword.isBlank() && configuredPassword != null && !configuredPassword.isBlank()
&& configuredUser.equals(request.username()) && configuredUser.equals(request.username())
&& configuredPassword.equals(request.password()); && configuredPassword.equals(request.password());
if (!envMatch) { if (!envMatch) {
// Try per-user password Optional<String> hash = userRepository.getPasswordHash(userId);
Optional<String> hash = userRepository.getPasswordHash(subject);
if (hash.isEmpty() || !passwordEncoder.matches(request.password(), hash.get())) { if (hash.isEmpty() || !passwordEncoder.matches(request.password(), hash.get())) {
userRepository.recordFailedLogin(subject); userRepository.recordFailedLogin(userId);
log.debug("UI login failed for user: {}", request.username()); log.debug("UI login failed for user: {}", request.username());
auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null, auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null,
Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest); Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest);
@@ -105,23 +108,22 @@ public class UiAuthController {
} }
} }
// Successful login — clear any failed attempt counter userRepository.clearFailedLogins(userId);
userRepository.clearFailedLogins(subject);
if (envMatch) { if (envMatch) {
// Env-var admin: upsert and ensure ADMIN role + Admins group // Env-var admin: upsert unprefixed and ensure ADMIN role + Admins group
try { try {
userRepository.upsert(new UserInfo( userRepository.upsert(new UserInfo(
subject, "local", "", request.username(), Instant.now())); userId, "local", "", request.username(), Instant.now()));
rbacService.assignRoleToUser(subject, SystemRole.ADMIN_ID); rbacService.assignRoleToUser(userId, SystemRole.ADMIN_ID);
rbacService.addUserToGroup(subject, SystemRole.ADMINS_GROUP_ID); rbacService.addUserToGroup(userId, SystemRole.ADMINS_GROUP_ID);
} catch (Exception e) { } catch (Exception e) {
log.warn("Failed to upsert local admin to store (login continues): {}", e.getMessage()); log.warn("Failed to upsert local admin to store (login continues): {}", e.getMessage());
} }
} }
// Per-user logins: user already exists in DB (created by admin) // Per-user logins: user already exists in DB (created by admin)
List<String> roles = rbacService.getSystemRoleNames(subject); List<String> roles = rbacService.getSystemRoleNames(userId);
if (roles.isEmpty()) { if (roles.isEmpty()) {
roles = List.of("VIEWER"); roles = List.of("VIEWER");
} }
@@ -152,9 +154,10 @@ public class UiAuthController {
String accessToken = jwtService.createAccessToken(result.subject(), "user", roles); String accessToken = jwtService.createAccessToken(result.subject(), "user", roles);
String refreshToken = jwtService.createRefreshToken(result.subject(), "user", roles); String refreshToken = jwtService.createRefreshToken(result.subject(), "user", roles);
String displayName = userRepository.findById(result.subject()) String userId = stripSubjectPrefix(result.subject());
String displayName = userRepository.findById(userId)
.map(UserInfo::displayName) .map(UserInfo::displayName)
.orElse(result.subject()); .orElse(userId);
auditService.log(result.subject(), "token_refresh", AuditCategory.AUTH, null, null, AuditResult.SUCCESS, httpRequest); auditService.log(result.subject(), "token_refresh", AuditCategory.AUTH, null, null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName, null)); return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName, null));
} catch (ResponseStatusException e) { } catch (ResponseStatusException e) {
@@ -173,13 +176,22 @@ public class UiAuthController {
if (authentication == null || authentication.getName() == null) { if (authentication == null || authentication.getName() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated"); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
} }
UserDetail detail = rbacService.getUser(authentication.getName()); UserDetail detail = rbacService.getUser(stripSubjectPrefix(authentication.getName()));
if (detail == null) { if (detail == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found"); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found");
} }
return ResponseEntity.ok(detail); return ResponseEntity.ok(detail);
} }
/**
* Map a JWT subject ({@code "user:<name>"} or {@code "user:oidc:<sub>"}) to the DB key:
* just the bare username. FKs on {@code alert_rules.created_by},
* {@code outbound_connections.created_by}, etc. reference the unprefixed row.
*/
private static String stripSubjectPrefix(String subject) {
return subject != null && subject.startsWith("user:") ? subject.substring(5) : subject;
}
public record LoginRequest(String username, String password) {} public record LoginRequest(String username, String password) {}
public record RefreshRequest(String refreshToken) {} public record RefreshRequest(String refreshToken) {}
} }

View File

@@ -1,7 +1,9 @@
package com.cameleer.server.app.alerting.notify; package com.cameleer.server.app.alerting.notify;
import com.cameleer.server.app.alerting.dto.UnreadCountResponse;
import com.cameleer.server.core.alerting.AlertInstance; import com.cameleer.server.core.alerting.AlertInstance;
import com.cameleer.server.core.alerting.AlertInstanceRepository; import com.cameleer.server.core.alerting.AlertInstanceRepository;
import com.cameleer.server.core.alerting.AlertSeverity;
import com.cameleer.server.core.rbac.GroupSummary; import com.cameleer.server.core.rbac.GroupSummary;
import com.cameleer.server.core.rbac.RbacService; import com.cameleer.server.core.rbac.RbacService;
import com.cameleer.server.core.rbac.RoleSummary; import com.cameleer.server.core.rbac.RoleSummary;
@@ -14,7 +16,9 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Clock; import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.EnumMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
@@ -45,7 +49,6 @@ class InAppInboxQueryTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// Build a Clock that delegates to the atomic counter so we can advance time precisely
tickableClock = new Clock() { tickableClock = new Clock() {
@Override public ZoneOffset getZone() { return ZoneOffset.UTC; } @Override public ZoneOffset getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(java.time.ZoneId zone) { return this; } @Override public Clock withZone(java.time.ZoneId zone) { return this; }
@@ -54,8 +57,6 @@ class InAppInboxQueryTest {
query = new InAppInboxQuery(instanceRepo, rbacService, tickableClock); query = new InAppInboxQuery(instanceRepo, rbacService, tickableClock);
// RbacService stubs: return no groups/roles by default.
// Lenient: countUnread tests don't invoke listInbox → stubs would otherwise be flagged unused.
lenient().when(rbacService.getEffectiveGroupsForUser(anyString())).thenReturn(List.of()); lenient().when(rbacService.getEffectiveGroupsForUser(anyString())).thenReturn(List.of());
lenient().when(rbacService.getEffectiveRolesForUser(anyString())).thenReturn(List.of()); lenient().when(rbacService.getEffectiveRolesForUser(anyString())).thenReturn(List.of());
} }
@@ -83,75 +84,107 @@ class InAppInboxQueryTest {
USER_ID, List.of("OPERATOR"), 20); USER_ID, List.of("OPERATOR"), 20);
} }
// -------------------------------------------------------------------------
// countUnread — bySeverity shape
// -------------------------------------------------------------------------
@Test
void countUnread_totalIsSumOfBySeverityValues() {
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID))
.thenReturn(severities(4L, 2L, 1L));
UnreadCountResponse response = query.countUnread(ENV_ID, USER_ID);
assertThat(response.total()).isEqualTo(7L);
assertThat(response.bySeverity())
.containsEntry(AlertSeverity.CRITICAL, 4L)
.containsEntry(AlertSeverity.WARNING, 2L)
.containsEntry(AlertSeverity.INFO, 1L);
}
@Test
void countUnread_fillsMissingSeveritiesWithZero() {
// Repository returns only CRITICAL — WARNING/INFO must default to 0.
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID))
.thenReturn(Map.of(AlertSeverity.CRITICAL, 3L));
UnreadCountResponse response = query.countUnread(ENV_ID, USER_ID);
assertThat(response.total()).isEqualTo(3L);
assertThat(response.bySeverity())
.containsEntry(AlertSeverity.CRITICAL, 3L)
.containsEntry(AlertSeverity.WARNING, 0L)
.containsEntry(AlertSeverity.INFO, 0L);
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// countUnread — memoization // countUnread — memoization
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@Test
void countUnread_firstCallHitsRepository() {
when(instanceRepo.countUnreadForUser(ENV_ID, USER_ID)).thenReturn(7L);
long count = query.countUnread(ENV_ID, USER_ID);
assertThat(count).isEqualTo(7L);
verify(instanceRepo, times(1)).countUnreadForUser(ENV_ID, USER_ID);
}
@Test @Test
void countUnread_secondCallWithin5sUsesCache() { void countUnread_secondCallWithin5sUsesCache() {
when(instanceRepo.countUnreadForUser(ENV_ID, USER_ID)).thenReturn(5L); when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID))
.thenReturn(severities(1L, 2L, 2L));
long first = query.countUnread(ENV_ID, USER_ID); UnreadCountResponse first = query.countUnread(ENV_ID, USER_ID);
// Advance time by 4 seconds — still within TTL
nowMillis.addAndGet(4_000L); nowMillis.addAndGet(4_000L);
long second = query.countUnread(ENV_ID, USER_ID); UnreadCountResponse second = query.countUnread(ENV_ID, USER_ID);
assertThat(first).isEqualTo(5L); assertThat(first.total()).isEqualTo(5L);
assertThat(second).isEqualTo(5L); assertThat(second.total()).isEqualTo(5L);
// Repository must have been called exactly once verify(instanceRepo, times(1)).countUnreadBySeverityForUser(ENV_ID, USER_ID);
verify(instanceRepo, times(1)).countUnreadForUser(ENV_ID, USER_ID);
} }
@Test @Test
void countUnread_callAfter5sRefreshesCache() { void countUnread_callAfter5sRefreshesCache() {
when(instanceRepo.countUnreadForUser(ENV_ID, USER_ID)) when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID))
.thenReturn(3L) // first call .thenReturn(severities(1L, 1L, 1L)) // first call — total 3
.thenReturn(9L); // after cache expires .thenReturn(severities(4L, 3L, 2L)); // after TTL — total 9
long first = query.countUnread(ENV_ID, USER_ID); UnreadCountResponse first = query.countUnread(ENV_ID, USER_ID);
// Advance by exactly 5001 ms — TTL expired
nowMillis.addAndGet(5_001L); nowMillis.addAndGet(5_001L);
long third = query.countUnread(ENV_ID, USER_ID); UnreadCountResponse third = query.countUnread(ENV_ID, USER_ID);
assertThat(first).isEqualTo(3L); assertThat(first.total()).isEqualTo(3L);
assertThat(third).isEqualTo(9L); assertThat(third.total()).isEqualTo(9L);
// Repository called twice: once on cold-miss, once after TTL expiry verify(instanceRepo, times(2)).countUnreadBySeverityForUser(ENV_ID, USER_ID);
verify(instanceRepo, times(2)).countUnreadForUser(ENV_ID, USER_ID);
} }
@Test @Test
void countUnread_differentUsersDontShareCache() { void countUnread_differentUsersDontShareCache() {
when(instanceRepo.countUnreadForUser(ENV_ID, "alice")).thenReturn(2L); when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, "alice"))
when(instanceRepo.countUnreadForUser(ENV_ID, "bob")).thenReturn(8L); .thenReturn(severities(0L, 1L, 1L));
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, "bob"))
.thenReturn(severities(2L, 2L, 4L));
long alice = query.countUnread(ENV_ID, "alice"); assertThat(query.countUnread(ENV_ID, "alice").total()).isEqualTo(2L);
long bob = query.countUnread(ENV_ID, "bob"); assertThat(query.countUnread(ENV_ID, "bob").total()).isEqualTo(8L);
verify(instanceRepo).countUnreadBySeverityForUser(ENV_ID, "alice");
assertThat(alice).isEqualTo(2L); verify(instanceRepo).countUnreadBySeverityForUser(ENV_ID, "bob");
assertThat(bob).isEqualTo(8L);
verify(instanceRepo).countUnreadForUser(ENV_ID, "alice");
verify(instanceRepo).countUnreadForUser(ENV_ID, "bob");
} }
@Test @Test
void countUnread_differentEnvsDontShareCache() { void countUnread_differentEnvsDontShareCache() {
UUID envA = UUID.randomUUID(); UUID envA = UUID.randomUUID();
UUID envB = UUID.randomUUID(); UUID envB = UUID.randomUUID();
when(instanceRepo.countUnreadForUser(envA, USER_ID)).thenReturn(1L); when(instanceRepo.countUnreadBySeverityForUser(envA, USER_ID))
when(instanceRepo.countUnreadForUser(envB, USER_ID)).thenReturn(4L); .thenReturn(severities(0L, 0L, 1L));
when(instanceRepo.countUnreadBySeverityForUser(envB, USER_ID))
.thenReturn(severities(1L, 1L, 2L));
assertThat(query.countUnread(envA, USER_ID)).isEqualTo(1L); assertThat(query.countUnread(envA, USER_ID).total()).isEqualTo(1L);
assertThat(query.countUnread(envB, USER_ID)).isEqualTo(4L); assertThat(query.countUnread(envB, USER_ID).total()).isEqualTo(4L);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private static Map<AlertSeverity, Long> severities(long critical, long warning, long info) {
EnumMap<AlertSeverity, Long> m = new EnumMap<>(AlertSeverity.class);
m.put(AlertSeverity.CRITICAL, critical);
m.put(AlertSeverity.WARNING, warning);
m.put(AlertSeverity.INFO, info);
return m;
} }
} }

View File

@@ -108,20 +108,51 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
} }
@Test @Test
void countUnreadForUser_decreasesAfterMarkRead() { void countUnreadBySeverityForUser_decreasesAfterMarkRead() {
var inst = newInstance(ruleId, List.of(userId), List.of(), List.of()); var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
repo.save(inst); repo.save(inst);
long before = repo.countUnreadForUser(envId, userId); var before = repo.countUnreadBySeverityForUser(envId, userId);
assertThat(before).isEqualTo(1L); 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) // Insert read record directly (AlertReadRepository not yet wired in this test)
jdbcTemplate.update( jdbcTemplate.update(
"INSERT INTO alert_reads (user_id, alert_instance_id) VALUES (?, ?) ON CONFLICT DO NOTHING", "INSERT INTO alert_reads (user_id, alert_instance_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
userId, inst.id()); userId, inst.id());
long after = repo.countUnreadForUser(envId, userId); var after = repo.countUnreadBySeverityForUser(envId, userId);
assertThat(after).isEqualTo(0L); assertThat(after.values()).allMatch(v -> v == 0L);
}
@Test
void countUnreadBySeverityForUser_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);
UUID infoRule = seedRuleWithSeverity("info", AlertSeverity.INFO);
repo.save(newInstance(critRule, AlertSeverity.CRITICAL, List.of(userId), List.of(), List.of()));
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);
assertThat(counts)
.containsEntry(AlertSeverity.CRITICAL, 1L)
.containsEntry(AlertSeverity.WARNING, 1L)
.containsEntry(AlertSeverity.INFO, 1L);
}
@Test
void countUnreadBySeverityForUser_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);
assertThat(counts).hasSize(3);
assertThat(counts.values()).allMatch(v -> v == 0L);
} }
@Test @Test
@@ -228,15 +259,34 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
List<String> userIds, List<String> userIds,
List<UUID> groupIds, List<UUID> groupIds,
List<String> roleNames) { List<String> roleNames) {
return newInstance(ruleId, AlertSeverity.WARNING, userIds, groupIds, roleNames);
}
private AlertInstance newInstance(UUID ruleId,
AlertSeverity severity,
List<String> userIds,
List<UUID> groupIds,
List<String> roleNames) {
return new AlertInstance( return new AlertInstance(
UUID.randomUUID(), ruleId, Map.of(), envId, UUID.randomUUID(), ruleId, Map.of(), envId,
AlertState.FIRING, AlertSeverity.WARNING, AlertState.FIRING, severity,
Instant.now(), null, null, null, null, Instant.now(), null, null, null, null,
false, null, null, false, null, null,
Map.of(), "title", "message", Map.of(), "title", "message",
userIds, groupIds, roleNames); userIds, groupIds, roleNames);
} }
/** Inserts a minimal alert_rule with the given severity. */
private UUID seedRuleWithSeverity(String name, AlertSeverity severity) {
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 (?, ?, ?, ?::severity_enum, 'AGENT_STATE', '{}'::jsonb, 't', 'm', 'sys-user', 'sys-user')",
id, envId, name + "-" + id, severity.name());
return id;
}
/** Inserts a minimal alert_rule with re_notify_minutes=0 and returns its id. */ /** Inserts a minimal alert_rule with re_notify_minutes=0 and returns its id. */
private UUID seedRule(String name) { private UUID seedRule(String name) {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();

View File

@@ -2,6 +2,7 @@ package com.cameleer.server.core.alerting;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -14,7 +15,14 @@ public interface AlertInstanceRepository {
String userId, String userId,
List<String> userRoleNames, List<String> userRoleNames,
int limit); int limit);
long countUnreadForUser(UUID environmentId, String userId);
/**
* Count unread alert instances for the user, grouped by severity.
* <p>
* 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.
*/
Map<AlertSeverity, Long> countUnreadBySeverityForUser(UUID environmentId, String userId);
void ack(UUID id, String userId, Instant when); void ack(UUID id, String userId, Instant when);
void resolve(UUID id, Instant when); void resolve(UUID id, Instant when);
void markSilenced(UUID id, boolean silenced); void markSilenced(UUID id, boolean silenced);

View File

@@ -1,41 +0,0 @@
-- Dev-stack seed: pre-create the `admin` user row without the `user:` prefix.
--
-- Why: the UI login controller stores the local admin as `user_id='user:admin'`
-- (JWT `sub` format), but the alerting + outbound controllers resolve the FK
-- via `authentication.name` with the `user:` prefix stripped, i.e. `admin`.
-- In k8s these controllers happily insert `admin` because production admins are
-- provisioned through the admin API with unprefixed user_ids. In the local
-- docker stack there's no such provisioning step, so the FK check fails with
-- "alert_rules_created_by_fkey violation" on the first rule create.
--
-- Seeding a row with `user_id='admin'` here bridges the gap so E2E smokes,
-- API probes, and manual dev sessions can create alerting rows straight away.
-- Flyway owns the schema in tenant_default; this script only INSERTs idempotently
-- and is gated on the schema existing.
DO $$
DECLARE
schema_exists bool;
table_exists bool;
BEGIN
SELECT EXISTS(
SELECT 1 FROM information_schema.schemata WHERE schema_name = 'tenant_default'
) INTO schema_exists;
IF NOT schema_exists THEN
RAISE NOTICE 'tenant_default schema not yet migrated — skipping admin seed (Flyway will run on server start)';
RETURN;
END IF;
SELECT EXISTS(
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'tenant_default' AND table_name = 'users'
) INTO table_exists;
IF NOT table_exists THEN
RAISE NOTICE 'tenant_default.users not yet migrated — skipping admin seed';
RETURN;
END IF;
INSERT INTO tenant_default.users (user_id, provider, email, display_name)
VALUES ('admin', 'local', '', 'admin')
ON CONFLICT (user_id) DO NOTHING;
END $$;

View File

@@ -130,25 +130,6 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
# Run-once seeder: waits for the server to be healthy (i.e. Flyway migrations
# finished) and inserts a `user_id='admin'` row (without the `user:` prefix)
# so alerting-controller FKs succeed. See deploy/docker/postgres-init.sql for
# the full rationale. Idempotent — exits 0 if the row already exists.
cameleer-seed:
image: postgres:16
container_name: cameleer-seed
depends_on:
cameleer-server:
condition: service_healthy
environment:
PGPASSWORD: cameleer_dev
volumes:
- ./deploy/docker/postgres-init.sql:/seed.sql:ro
entrypoint: ["sh", "-c"]
command:
- "psql -h cameleer-postgres -U cameleer -d cameleer -v ON_ERROR_STOP=1 -f /seed.sql"
restart: "no"
volumes: volumes:
cameleer-pgdata: cameleer-pgdata:
cameleer-chdata: cameleer-chdata:

View File

@@ -32,7 +32,7 @@ mvn clean compile # confirm Plan 01 code compiles as baseline
|---|---| |---|---|
| `AlertingProperties.java` | Not here — see app module. | | `AlertingProperties.java` | Not here — see app module. |
| `AlertRule.java` | Immutable record: id, environmentId, name, description, severity, enabled, conditionKind, condition, evaluationIntervalSeconds, forDurationSeconds, reNotifyMinutes, notificationTitleTmpl, notificationMessageTmpl, webhooks, targets, nextEvaluationAt, claimedBy, claimedUntil, evalState, audit fields. | | `AlertRule.java` | Immutable record: id, environmentId, name, description, severity, enabled, conditionKind, condition, evaluationIntervalSeconds, forDurationSeconds, reNotifyMinutes, notificationTitleTmpl, notificationMessageTmpl, webhooks, targets, nextEvaluationAt, claimedBy, claimedUntil, evalState, audit fields. |
| `AlertCondition.java` | Sealed interface; Jackson DEDUCTION polymorphism root. | | `AlertCondition.java` | Sealed interface; Jackson `kind`-based polymorphism root (Id.NAME + EXISTING_PROPERTY). |
| `RouteMetricCondition.java` | Record: scope, metric, comparator, threshold, windowSeconds. | | `RouteMetricCondition.java` | Record: scope, metric, comparator, threshold, windowSeconds. |
| `ExchangeMatchCondition.java` | Record: scope, filter, fireMode, threshold, windowSeconds, perExchangeLingerSeconds. | | `ExchangeMatchCondition.java` | Record: scope, filter, fireMode, threshold, windowSeconds, perExchangeLingerSeconds. |
| `AgentStateCondition.java` | Record: scope, state, forSeconds. | | `AgentStateCondition.java` | Record: scope, state, forSeconds. |
@@ -126,7 +126,7 @@ mvn clean compile # confirm Plan 01 code compiles as baseline
- **One commit per task.** Commit messages: `feat(alerting): …`, `test(alerting): …`, `fix(alerting): …`, `chore(alerting): …`, `docs(alerting): …`. - **One commit per task.** Commit messages: `feat(alerting): …`, `test(alerting): …`, `fix(alerting): …`, `chore(alerting): …`, `docs(alerting): …`.
- **Tenant invariant.** Every ClickHouse query and Postgres table referencing observability data filters by `tenantId` (injected via `AlertingBeanConfig` from `cameleer.server.tenant.id`). - **Tenant invariant.** Every ClickHouse query and Postgres table referencing observability data filters by `tenantId` (injected via `AlertingBeanConfig` from `cameleer.server.tenant.id`).
- **No `FINAL`** on the two new CH count methods — alerting tolerates brief duplicate counts. - **No `FINAL`** on the two new CH count methods — alerting tolerates brief duplicate counts.
- **Jackson polymorphism** via `@JsonTypeInfo(use = DEDUCTION)` with `@JsonSubTypes` on `AlertCondition`. - **Jackson polymorphism** via `@JsonTypeInfo(use = Id.NAME, property = "kind", include = EXISTING_PROPERTY)` with `@JsonSubTypes` on `AlertCondition`.
- **Pure `core/`, Spring-only in `app/`.** No `@Component`, `@Service`, or `@Scheduled` annotations in `cameleer-server-core`. - **Pure `core/`, Spring-only in `app/`.** No `@Component`, `@Service`, or `@Scheduled` annotations in `cameleer-server-core`.
- **Claim polling.** `FOR UPDATE SKIP LOCKED` + `claimed_by` / `claimed_until` with 30 s TTL. - **Claim polling.** `FOR UPDATE SKIP LOCKED` + `claimed_by` / `claimed_until` with 30 s TTL.
- **Instance id** for claim ownership: use `InetAddress.getLocalHost().getHostName() + ":" + processPid()`; exposed as a bean `"alertingInstanceId"` of type `String`. - **Instance id** for claim ownership: use `InetAddress.getLocalHost().getHostName() + ":" + processPid()`; exposed as a bean `"alertingInstanceId"` of type `String`.
@@ -403,7 +403,7 @@ git commit -m "feat(alerting): add ALERT_RULE_CHANGE + ALERT_SILENCE_CHANGE audi
## Phase 2 — Core domain model ## Phase 2 — Core domain model
Each task in this phase adds a small, focused set of pure-Java records and enums under `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/`. All records use canonical constructors with explicit `@NotNull`-style defensive copying only for mutable collections (`List.copyOf`, `Map.copyOf`). Jackson polymorphism is handled by `@JsonTypeInfo(use = DEDUCTION)` on `AlertCondition`. Each task in this phase adds a small, focused set of pure-Java records and enums under `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/`. All records use canonical constructors with explicit `@NotNull`-style defensive copying only for mutable collections (`List.copyOf`, `Map.copyOf`). Jackson polymorphism is handled by `@JsonTypeInfo(use = Id.NAME, property = "kind", include = EXISTING_PROPERTY)` on `AlertCondition` — the subtype is read from the existing `kind` field each record exposes.
### Task 3: Enums + `AlertScope` ### Task 3: Enums + `AlertScope`
@@ -606,14 +606,15 @@ package com.cameleer.server.core.alerting;
import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo;
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind",
include = JsonTypeInfo.As.EXISTING_PROPERTY, visible = true)
@JsonSubTypes({ @JsonSubTypes({
@JsonSubTypes.Type(RouteMetricCondition.class), @JsonSubTypes.Type(value = RouteMetricCondition.class, name = "ROUTE_METRIC"),
@JsonSubTypes.Type(ExchangeMatchCondition.class), @JsonSubTypes.Type(value = ExchangeMatchCondition.class, name = "EXCHANGE_MATCH"),
@JsonSubTypes.Type(AgentStateCondition.class), @JsonSubTypes.Type(value = AgentStateCondition.class, name = "AGENT_STATE"),
@JsonSubTypes.Type(DeploymentStateCondition.class), @JsonSubTypes.Type(value = DeploymentStateCondition.class, name = "DEPLOYMENT_STATE"),
@JsonSubTypes.Type(LogPatternCondition.class), @JsonSubTypes.Type(value = LogPatternCondition.class, name = "LOG_PATTERN"),
@JsonSubTypes.Type(JvmMetricCondition.class) @JsonSubTypes.Type(value = JvmMetricCondition.class, name = "JVM_METRIC")
}) })
public sealed interface AlertCondition permits public sealed interface AlertCondition permits
RouteMetricCondition, ExchangeMatchCondition, AgentStateCondition, RouteMetricCondition, ExchangeMatchCondition, AgentStateCondition,

View File

@@ -286,7 +286,7 @@ CREATE TABLE alert_rules (
enabled boolean NOT NULL DEFAULT true, enabled boolean NOT NULL DEFAULT true,
condition_kind condition_kind_enum NOT NULL, condition_kind condition_kind_enum NOT NULL,
condition jsonb NOT NULL, -- sealed-subtype payload, Jackson-DEDUCTION polymorphic condition jsonb NOT NULL, -- sealed-subtype payload, Jackson polymorphic on `kind`
evaluation_interval_seconds int NOT NULL DEFAULT 60 CHECK (evaluation_interval_seconds >= 5), evaluation_interval_seconds int NOT NULL DEFAULT 60 CHECK (evaluation_interval_seconds >= 5),
for_duration_seconds int NOT NULL DEFAULT 0 CHECK (for_duration_seconds >= 0), for_duration_seconds int NOT NULL DEFAULT 0 CHECK (for_duration_seconds >= 0),
@@ -423,14 +423,15 @@ outbound_connections (delete) — blocked by FK from rules.webhooks JSONB
### Jackson polymorphism for conditions ### Jackson polymorphism for conditions
```java ```java
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind",
include = JsonTypeInfo.As.EXISTING_PROPERTY, visible = true)
@JsonSubTypes({ @JsonSubTypes({
@Type(RouteMetricCondition.class), @Type(value = RouteMetricCondition.class, name = "ROUTE_METRIC"),
@Type(ExchangeMatchCondition.class), @Type(value = ExchangeMatchCondition.class, name = "EXCHANGE_MATCH"),
@Type(AgentStateCondition.class), @Type(value = AgentStateCondition.class, name = "AGENT_STATE"),
@Type(DeploymentStateCondition.class), @Type(value = DeploymentStateCondition.class, name = "DEPLOYMENT_STATE"),
@Type(LogPatternCondition.class), @Type(value = LogPatternCondition.class, name = "LOG_PATTERN"),
@Type(JvmMetricCondition.class), @Type(value = JvmMetricCondition.class, name = "JVM_METRIC"),
}) })
public sealed interface AlertCondition permits public sealed interface AlertCondition permits
RouteMetricCondition, ExchangeMatchCondition, AgentStateCondition, RouteMetricCondition, ExchangeMatchCondition, AgentStateCondition,
@@ -439,37 +440,40 @@ public sealed interface AlertCondition permits
} }
``` ```
Jackson deduces the subtype from the set of present fields. Bean Validation (`@Valid`) on each record validates at the controller boundary. Each payload carries its own `kind` field, which Jackson reads (`EXISTING_PROPERTY`) to pick the subtype and the record still exposes as `ConditionKind kind()`. Bean Validation (`@Valid`) on each record validates at the controller boundary.
Example condition payloads: Example condition payloads:
```json ```json
// ROUTE_METRIC // ROUTE_METRIC
{ "scope": {"appSlug":"orders","routeId":"route-1"}, { "kind": "ROUTE_METRIC",
"scope": {"appSlug":"orders","routeId":"route-1"},
"metric": "P99_LATENCY_MS", "comparator": "GT", "threshold": 2000, "windowSeconds": 300 } "metric": "P99_LATENCY_MS", "comparator": "GT", "threshold": 2000, "windowSeconds": 300 }
// EXCHANGE_MATCH — PER_EXCHANGE // EXCHANGE_MATCH — PER_EXCHANGE
{ "scope": {"appSlug":"orders"}, { "kind": "EXCHANGE_MATCH",
"scope": {"appSlug":"orders"},
"filter": {"status":"FAILED","attributes":{"type":"payment"}}, "filter": {"status":"FAILED","attributes":{"type":"payment"}},
"fireMode": "PER_EXCHANGE", "perExchangeLingerSeconds": 300 } "fireMode": "PER_EXCHANGE", "perExchangeLingerSeconds": 300 }
// EXCHANGE_MATCH — COUNT_IN_WINDOW // EXCHANGE_MATCH — COUNT_IN_WINDOW
{ "scope": {"appSlug":"orders"}, { "kind": "EXCHANGE_MATCH",
"scope": {"appSlug":"orders"},
"filter": {"status":"FAILED"}, "filter": {"status":"FAILED"},
"fireMode": "COUNT_IN_WINDOW", "threshold": 5, "windowSeconds": 900 } "fireMode": "COUNT_IN_WINDOW", "threshold": 5, "windowSeconds": 900 }
// AGENT_STATE // AGENT_STATE
{ "scope": {"appSlug":"orders"}, "state": "DEAD", "forSeconds": 60 } { "kind": "AGENT_STATE", "scope": {"appSlug":"orders"}, "state": "DEAD", "forSeconds": 60 }
// DEPLOYMENT_STATE // DEPLOYMENT_STATE
{ "scope": {"appSlug":"orders"}, "states": ["FAILED","DEGRADED"] } { "kind": "DEPLOYMENT_STATE", "scope": {"appSlug":"orders"}, "states": ["FAILED","DEGRADED"] }
// LOG_PATTERN // LOG_PATTERN
{ "scope": {"appSlug":"orders"}, "level": "ERROR", { "kind": "LOG_PATTERN", "scope": {"appSlug":"orders"}, "level": "ERROR",
"pattern": "TimeoutException", "threshold": 5, "windowSeconds": 900 } "pattern": "TimeoutException", "threshold": 5, "windowSeconds": 900 }
// JVM_METRIC // JVM_METRIC
{ "scope": {"appSlug":"orders"}, "metric": "heap_used_percent", { "kind": "JVM_METRIC", "scope": {"appSlug":"orders"}, "metric": "heap_used_percent",
"aggregation": "MAX", "comparator": "GT", "threshold": 90, "windowSeconds": 300 } "aggregation": "MAX", "comparator": "GT", "threshold": 90, "windowSeconds": 300 }
``` ```

File diff suppressed because one or more lines are too long

View File

@@ -3257,7 +3257,10 @@ export interface components {
}; };
UnreadCountResponse: { UnreadCountResponse: {
/** Format: int64 */ /** Format: int64 */
count?: number; total?: number;
bySeverity?: {
[key: string]: number;
};
}; };
/** @description Agent instance summary with runtime metrics */ /** @description Agent instance summary with runtime metrics */
AgentInstanceResponse: { AgentInstanceResponse: {

View File

@@ -18,10 +18,12 @@
height: 16px; height: 16px;
padding: 0 4px; padding: 0 4px;
border-radius: 8px; border-radius: 8px;
background: var(--error);
color: var(--bg); color: var(--bg);
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
line-height: 16px; line-height: 16px;
text-align: center; text-align: center;
} }
.badgeCritical { background: var(--error); }
.badgeWarning { background: var(--amber); }
.badgeInfo { background: var(--muted); }

View File

@@ -19,6 +19,13 @@ function wrapper({ children }: { children: ReactNode }) {
); );
} }
function mockResponse(total: number, bySeverity: Record<string, number> = {}) {
(apiClient.GET as any).mockResolvedValue({
data: { total, bySeverity },
error: null,
});
}
describe('NotificationBell', () => { describe('NotificationBell', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -26,22 +33,37 @@ describe('NotificationBell', () => {
}); });
it('renders bell with no badge when zero unread', async () => { it('renders bell with no badge when zero unread', async () => {
(apiClient.GET as any).mockResolvedValue({ mockResponse(0, { CRITICAL: 0, WARNING: 0, INFO: 0 });
data: { count: 0 },
error: null,
});
render(<NotificationBell />, { wrapper }); render(<NotificationBell />, { wrapper });
expect(await screen.findByRole('button', { name: /notifications/i })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: /notifications/i })).toBeInTheDocument();
// Badge is only rendered when count > 0; no numeric text should appear.
expect(screen.queryByText(/^\d+$/)).toBeNull(); expect(screen.queryByText(/^\d+$/)).toBeNull();
}); });
it('shows unread count badge when unread alerts exist', async () => { it('shows unread total in badge', async () => {
(apiClient.GET as any).mockResolvedValue({ mockResponse(3, { CRITICAL: 1, WARNING: 2, INFO: 0 });
data: { count: 3 },
error: null,
});
render(<NotificationBell />, { wrapper }); render(<NotificationBell />, { wrapper });
expect(await screen.findByText('3')).toBeInTheDocument(); expect(await screen.findByText('3')).toBeInTheDocument();
}); });
it('colours badge as CRITICAL when any critical unread present', async () => {
mockResponse(5, { CRITICAL: 1, WARNING: 4, INFO: 0 });
render(<NotificationBell />, { wrapper });
const badge = await screen.findByText('5');
expect(badge.className).toMatch(/badgeCritical/);
});
it('colours badge as WARNING when only warnings+info unread', async () => {
mockResponse(3, { CRITICAL: 0, WARNING: 2, INFO: 1 });
render(<NotificationBell />, { wrapper });
const badge = await screen.findByText('3');
expect(badge.className).toMatch(/badgeWarning/);
expect(badge.className).not.toMatch(/badgeCritical/);
});
it('colours badge as INFO when only info unread', async () => {
mockResponse(2, { CRITICAL: 0, WARNING: 0, INFO: 2 });
render(<NotificationBell />, { wrapper });
const badge = await screen.findByText('2');
expect(badge.className).toMatch(/badgeInfo/);
});
}); });

View File

@@ -6,33 +6,33 @@ import css from './NotificationBell.module.css';
/** /**
* Global notification bell shown in the layout header. Links to the alerts * Global notification bell shown in the layout header. Links to the alerts
* inbox and renders a badge with the unread-alert count for the currently * inbox and renders a badge coloured by the highest unread severity
* selected environment. * (CRITICAL > WARNING > INFO) — matches the sidebar SeverityBadge palette.
*
* Polling pause when the tab is hidden is handled by `useUnreadCount`'s
* `refetchIntervalInBackground: false`; no separate visibility subscription
* is needed. If per-severity coloring (spec §13) is re-introduced, the
* backend `UnreadCountResponse` must grow a `bySeverity` map.
*/ */
export function NotificationBell() { export function NotificationBell() {
const env = useSelectedEnv(); const env = useSelectedEnv();
const { data } = useUnreadCount(); const { data } = useUnreadCount();
const count = data?.count ?? 0;
if (!env) return null; if (!env) return null;
const total = data?.total ?? 0;
const bySeverity = data?.bySeverity ?? {};
const severityClass =
(bySeverity.CRITICAL ?? 0) > 0 ? css.badgeCritical
: (bySeverity.WARNING ?? 0) > 0 ? css.badgeWarning
: css.badgeInfo;
return ( return (
<Link <Link
to="/alerts/inbox" to="/alerts/inbox"
role="button" role="button"
aria-label={`Notifications (${count} unread)`} aria-label={`Notifications (${total} unread)`}
className={css.bell} className={css.bell}
> >
<Bell size={16} /> <Bell size={16} />
{count > 0 && ( {total > 0 && (
<span className={css.badge} aria-hidden> <span className={`${css.badge} ${severityClass}`} aria-hidden>
{count > 99 ? '99+' : count} {total > 99 ? '99+' : total}
</span> </span>
)} )}
</Link> </Link>

View File

@@ -1,30 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { usePageVisible } from './usePageVisible';
describe('usePageVisible', () => {
beforeEach(() => {
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
configurable: true,
writable: true,
});
});
it('returns true when visible, false when hidden', () => {
const { result } = renderHook(() => usePageVisible());
expect(result.current).toBe(true);
act(() => {
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
document.dispatchEvent(new Event('visibilitychange'));
});
expect(result.current).toBe(false);
act(() => {
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
document.dispatchEvent(new Event('visibilitychange'));
});
expect(result.current).toBe(true);
});
});

View File

@@ -1,22 +0,0 @@
import { useEffect, useState } from 'react';
/**
* Tracks Page Visibility API state for the current document.
*
* Returns `true` when the tab is visible, `false` when hidden. Useful for
* pausing work (polling, animations, expensive DOM effects) while the tab
* is backgrounded. SSR-safe: defaults to `true` when `document` is undefined.
*/
export function usePageVisible(): boolean {
const [visible, setVisible] = useState(() =>
typeof document === 'undefined' ? true : document.visibilityState === 'visible',
);
useEffect(() => {
const onChange = () => setVisible(document.visibilityState === 'visible');
document.addEventListener('visibilitychange', onChange);
return () => document.removeEventListener('visibilitychange', onChange);
}, []);
return visible;
}