13 atomic commits covering 5 hardening tasks:
Task 1-2: @Schema(discriminatorMapping) on AlertCondition, derive
polymorphic unions in enums.ts from schema
Task 3-7: AgentState / DeploymentStatus / LogLevel / ExecutionStatus
enum migrations + @Schema(allowableValues) on JvmMetric
Task 8: ContextStartupSmokeTest (unit-tier, no Testcontainers)
Task 9-12: AlertTemplateVariables registry + round-trip test +
SSOT endpoint + UI consumer
Task 13: alerting-editor.spec.ts Playwright spec
Each task has bite-sized write-test/red/green/commit steps with
exact paths and full code. Pre-flight SQL check and post-flight
self-verification scripts included.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2691 lines
104 KiB
Markdown
2691 lines
104 KiB
Markdown
# Alerting — Plan 04: Post-Ship Hardening Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Close the loop on three bug classes from Plan 03 triage — Spring wiring regressions invisible to unit tests, UI↔backend drift on Mustache template variables, and hand-maintained TypeScript enum unions caused by a springdoc polymorphism quirk. Pure hardening; no new product features.
|
||
|
||
**Architecture:** Five independent improvements landed as atomic commits. A new sealed-interface discriminator annotation on `AlertCondition` unlocks typed polymorphic schemas. Four condition String fields become proper enums; a fifth (`JvmMetricCondition.metric`) gets an `@Schema(allowableValues)` hint while staying `String` because the evaluator passes it through to the Micrometer metric store. A unit-level context-startup smoke test catches `@Autowired` regressions at `mvn test` time (not just `mvn verify`). A new `GET /api/v1/alerts/template-variables` endpoint makes the backend the single source of truth for Mustache variable metadata; UI consumes it via TanStack Query. A second Playwright spec exercises the editor paths not covered by Plan 03's smoke.
|
||
|
||
**Tech Stack:**
|
||
- Java 17 + Spring Boot 3.4.3 (existing)
|
||
- springdoc-openapi 2.8.6 (existing)
|
||
- JUnit 5 + Mockito + Testcontainers (existing)
|
||
- React 19 + TanStack Query v5 + CodeMirror 6 (existing)
|
||
- openapi-typescript (existing)
|
||
- Playwright (existing)
|
||
|
||
**Base branch:** `feat/alerting-04-hardening` off `main`. Worktree `.worktrees/alerting-04`. Commit atomically per task.
|
||
|
||
**Execution order** (per spec §8): Task 1 → 2 → 3–7 → 8 → 9–12 → 13. Task 1 is the smallest backend-only change that unlocks Task 2's TS-side cleanup. Tasks 3–7 are independent enum migrations; land them in any order. Task 8 is a new unit test, independent. Tasks 9–12 form the template-variables SSOT feature as a single coherent slice (backend registry → controller → UI consumer). Task 13 adds the Playwright spec last because it exercises everything above.
|
||
|
||
**CRITICAL process rules (per project CLAUDE.md):**
|
||
- Run `gitnexus_impact({target, direction:"upstream"})` before editing any existing Java class.
|
||
- Run `gitnexus_detect_changes()` before every commit.
|
||
- After any Java controller or DTO change, regenerate the OpenAPI schema via `cd ui && npm run generate-api:live`.
|
||
- Update `.claude/rules/app-classes.md` in the same commit that adds `AlertTemplateVariablesController`.
|
||
- Update `.claude/rules/core-classes.md` in the same commit that adds `LogLevel`.
|
||
- `mvn clean verify` must stay green through every commit.
|
||
- `cd ui && npm run typecheck && npm run test && npm run test:e2e` must stay green through every commit.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### New files
|
||
|
||
**Backend (`cameleer-server-core/`):**
|
||
```
|
||
src/main/java/com/cameleer/server/core/alerting/
|
||
├── LogLevel.java — Task 5 (TRACE..ERROR)
|
||
├── TemplateVariableDescriptor.java — Task 9 (path, type, desc, example)
|
||
└── AlertTemplateVariables.java — Task 9 (registry constant)
|
||
```
|
||
|
||
**Backend (`cameleer-server-app/`):**
|
||
```
|
||
src/main/java/com/cameleer/server/app/alerting/
|
||
├── notify/TemplateVariableDto.java — Task 11 (API response DTO)
|
||
└── controller/AlertTemplateVariablesController.java — Task 11
|
||
|
||
src/test/java/com/cameleer/server/app/
|
||
└── ContextStartupSmokeTest.java — Task 8 (@SpringBootTest webEnv=NONE)
|
||
```
|
||
|
||
**Backend (`cameleer-server-app/` — test resources):**
|
||
```
|
||
src/test/resources/
|
||
└── application-test-context-smoke.yml — Task 8 (profile: stubs DB + CH + runtime)
|
||
```
|
||
|
||
**Backend (tests):**
|
||
```
|
||
src/test/java/com/cameleer/server/app/alerting/notify/
|
||
└── NotificationContextBuilderRegistryTest.java — Task 10 (registry↔builder round-trip)
|
||
```
|
||
|
||
**Frontend:**
|
||
```
|
||
ui/src/api/queries/
|
||
└── alertTemplateVariables.ts — Task 12 (useTemplateVariables hook)
|
||
|
||
ui/src/test/e2e/
|
||
└── alerting-editor.spec.ts — Task 13 (6 cases)
|
||
```
|
||
|
||
### Modified files
|
||
|
||
**Backend (`cameleer-server-core/`):**
|
||
- `src/main/java/com/cameleer/server/core/alerting/AlertCondition.java` — Task 1 (`@Schema(discriminatorProperty, discriminatorMapping)`)
|
||
- `src/main/java/com/cameleer/server/core/alerting/AgentStateCondition.java` — Task 3 (state: `String` → `AgentState`)
|
||
- `src/main/java/com/cameleer/server/core/alerting/DeploymentStateCondition.java` — Task 4 (states: `List<String>` → `List<DeploymentStatus>`)
|
||
- `src/main/java/com/cameleer/server/core/alerting/LogPatternCondition.java` — Task 5 (level: `String` → `LogLevel`)
|
||
- `src/main/java/com/cameleer/server/core/alerting/ExchangeMatchCondition.java` — Task 6 (nested `ExchangeFilter.status`: `String` → `ExecutionStatus`)
|
||
- `src/main/java/com/cameleer/server/core/alerting/JvmMetricCondition.java` — Task 7 (`@Schema(allowableValues)` on `metric`, stays `String`)
|
||
|
||
**Backend (`cameleer-server-app/`):**
|
||
- `src/main/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluator.java` — Task 6 (unwrap `ExecutionStatus.name()` at query boundary)
|
||
- `src/main/java/com/cameleer/server/app/alerting/eval/AgentStateEvaluator.java` — Task 3 (consume enum instead of string)
|
||
- `src/main/java/com/cameleer/server/app/alerting/eval/DeploymentStateEvaluator.java` — Task 4 (consume enum list)
|
||
- `src/main/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluator.java` — Task 5 (consume `LogLevel`)
|
||
- `src/main/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilder.java` — Task 10 (refactor to use registry)
|
||
|
||
**Frontend:**
|
||
- `ui/src/api/schema.d.ts` — regenerated after each backend schema change
|
||
- `ui/src/api/openapi.json` — regenerated after each backend schema change
|
||
- `ui/src/pages/Alerts/enums.ts` — Task 2 (delete hand-declared unions; derive from schema). Task 3/4/5/6 (label maps for new enum fields). Task 7 (derive JvmMetric from schema).
|
||
- `ui/src/components/MustacheEditor/MustacheEditor.tsx` — Task 12 (variables prop)
|
||
- `ui/src/components/MustacheEditor/mustache-completion.ts` — Task 12 (variables parameter)
|
||
- `ui/src/components/MustacheEditor/mustache-linter.ts` — Task 12 (variables parameter)
|
||
- `ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx` — Task 12 (wire `useTemplateVariables()`)
|
||
- `ui/src/pages/Admin/OutboundConnectionEditor.tsx` (or equivalent) — Task 12 (same)
|
||
|
||
**Frontend (deletions):**
|
||
- `ui/src/components/MustacheEditor/alert-variables.ts` — Task 12 (deleted)
|
||
|
||
**Rule files:**
|
||
- `.claude/rules/app-classes.md` — Task 11 (add `AlertTemplateVariablesController` to flat-endpoint allow-list)
|
||
- `.claude/rules/core-classes.md` — Task 5 (add `LogLevel` under `alerting/`)
|
||
|
||
**Optional Flyway migration (only if pre-flight SQL finds mismatched legacy rows):**
|
||
- `cameleer-server-app/src/main/resources/db/migration/V15__alert_condition_enum_repair.sql` — Tasks 3–6 (one-shot repair)
|
||
|
||
---
|
||
|
||
## Pre-flight — Verify environment
|
||
|
||
- [ ] **Step 0.1: Create the worktree + branch**
|
||
|
||
```bash
|
||
git fetch origin
|
||
git worktree add -b feat/alerting-04-hardening .worktrees/alerting-04 origin/main
|
||
cd .worktrees/alerting-04
|
||
```
|
||
|
||
Expected: worktree registered at `.worktrees/alerting-04`, current branch `feat/alerting-04-hardening`.
|
||
|
||
- [ ] **Step 0.2: Baseline build + tests green**
|
||
|
||
```bash
|
||
mvn clean verify -pl cameleer-server-app -am
|
||
cd ui && npm ci && npm run typecheck && npm run test
|
||
cd ..
|
||
```
|
||
|
||
Expected: BUILD SUCCESS and typecheck + Vitest both green. If any fail on main, stop and investigate — Plan 04 assumes a green baseline.
|
||
|
||
- [ ] **Step 0.3: Start local docker stack for UI dev iteration**
|
||
|
||
```bash
|
||
docker compose -f docker-compose.yml up -d postgres clickhouse
|
||
docker compose -f docker-compose.yml ps
|
||
```
|
||
|
||
Expected: `postgres` and `clickhouse` services running. If ports are blocked, free them per user memory `feedback_local_services.md`.
|
||
|
||
- [ ] **Step 0.4: Pre-flight legacy-data check**
|
||
|
||
Before any enum migration, confirm existing `alert_rules.condition` JSON values match the upcoming enum vocabularies.
|
||
|
||
```bash
|
||
PGPASSWORD=cameleer psql -h localhost -U cameleer -d cameleer -c "
|
||
SELECT condition_kind, condition->>'level' AS val FROM alert_rules WHERE condition_kind='LOG_PATTERN'
|
||
UNION ALL
|
||
SELECT condition_kind, condition->>'state' FROM alert_rules WHERE condition_kind='AGENT_STATE'
|
||
UNION ALL
|
||
SELECT condition_kind, jsonb_array_elements_text(condition->'states')
|
||
FROM alert_rules WHERE condition_kind='DEPLOYMENT_STATE'
|
||
UNION ALL
|
||
SELECT condition_kind, condition->'filter'->>'status' FROM alert_rules WHERE condition_kind='EXCHANGE_MATCH';
|
||
"
|
||
```
|
||
|
||
Expected values, all uppercase:
|
||
- `LOG_PATTERN` levels: any of `TRACE | DEBUG | INFO | WARN | ERROR`
|
||
- `AGENT_STATE` states: any of `LIVE | STALE | DEAD | SHUTDOWN`
|
||
- `DEPLOYMENT_STATE` states (array elements): any of `STOPPED | STARTING | RUNNING | DEGRADED | STOPPING | FAILED`
|
||
- `EXCHANGE_MATCH` filter.status: any of `RUNNING | COMPLETED | FAILED | ABANDONED`
|
||
|
||
If every returned value matches the expected set, **skip the V15 Flyway migration** — the task notes below reference it only conditionally.
|
||
|
||
If any value is lowercase, mistyped, or `null`, record the actual values here and add the V15 repair migration step to Task 3/4/5/6 (whichever tasks hit mismatched rows). The migration shape is:
|
||
|
||
```sql
|
||
-- V15__alert_condition_enum_repair.sql (example — only if needed)
|
||
UPDATE alert_rules
|
||
SET condition = jsonb_set(condition, '{level}', to_jsonb(UPPER(condition->>'level')))
|
||
WHERE condition_kind = 'LOG_PATTERN'
|
||
AND condition->>'level' IS NOT NULL
|
||
AND condition->>'level' != UPPER(condition->>'level');
|
||
-- … one block per affected field
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: Springdoc discriminator mapping on `AlertCondition`
|
||
|
||
**Why:** UI `schema.d.ts` resolves `AlertCondition`'s subtypes' `kind` to `never` because the generated OpenAPI `discriminator` has no `mapping`. This is the single-annotation root-cause fix (spec §7).
|
||
|
||
**Files:**
|
||
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertCondition.java`
|
||
|
||
**GitNexus preflight:**
|
||
|
||
- [ ] **Step 1.1: Impact-check `AlertCondition`**
|
||
|
||
Run: `gitnexus_impact({target: "AlertCondition", direction: "upstream"})`
|
||
|
||
Expected: direct callers = all 6 condition subtypes + `AlertRuleRequest` + `AlertRuleResponse` + repository layer. No HIGH/CRITICAL warnings expected (annotation-only change).
|
||
|
||
- [ ] **Step 1.2: Modify `AlertCondition.java`**
|
||
|
||
Edit `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertCondition.java`:
|
||
|
||
```java
|
||
package com.cameleer.server.core.alerting;
|
||
|
||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||
import io.swagger.v3.oas.annotations.media.DiscriminatorMapping;
|
||
import io.swagger.v3.oas.annotations.media.Schema;
|
||
|
||
@Schema(
|
||
discriminatorProperty = "kind",
|
||
discriminatorMapping = {
|
||
@DiscriminatorMapping(value = "ROUTE_METRIC", schema = RouteMetricCondition.class),
|
||
@DiscriminatorMapping(value = "EXCHANGE_MATCH", schema = ExchangeMatchCondition.class),
|
||
@DiscriminatorMapping(value = "AGENT_STATE", schema = AgentStateCondition.class),
|
||
@DiscriminatorMapping(value = "DEPLOYMENT_STATE", schema = DeploymentStateCondition.class),
|
||
@DiscriminatorMapping(value = "LOG_PATTERN", schema = LogPatternCondition.class),
|
||
@DiscriminatorMapping(value = "JVM_METRIC", schema = JvmMetricCondition.class)
|
||
}
|
||
)
|
||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind", include = JsonTypeInfo.As.EXISTING_PROPERTY, visible = true)
|
||
@JsonSubTypes({
|
||
@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 = 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 {
|
||
|
||
@JsonProperty("kind")
|
||
ConditionKind kind();
|
||
AlertScope scope();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1.3: Rebuild + regenerate schema**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-core install -DskipTests
|
||
mvn -pl cameleer-server-app spring-boot:run &
|
||
SERVER_PID=$!
|
||
# Wait for :8081 (up to 60s)
|
||
until curl -fsS http://localhost:8081/api/v1/health >/dev/null 2>&1; do sleep 2; done
|
||
cd ui && npm run generate-api:live
|
||
cd ..
|
||
kill $SERVER_PID
|
||
```
|
||
|
||
- [ ] **Step 1.4: Verify OpenAPI mapping is present**
|
||
|
||
```bash
|
||
python -c "
|
||
import json
|
||
d = json.load(open('ui/src/api/openapi.json'))
|
||
disc = d['components']['schemas']['AlertCondition']['discriminator']
|
||
assert 'mapping' in disc, 'Expected discriminator.mapping to exist'
|
||
assert disc['mapping']['ROUTE_METRIC'].endswith('/RouteMetricCondition')
|
||
assert disc['mapping']['EXCHANGE_MATCH'].endswith('/ExchangeMatchCondition')
|
||
print('OK: discriminator.mapping has', len(disc['mapping']), 'entries')
|
||
"
|
||
```
|
||
|
||
Expected: `OK: discriminator.mapping has 6 entries`
|
||
|
||
**If the assertion fails** (`mapping` absent): springdoc 2.8.6 did not honor `@DiscriminatorMapping` on a sealed interface. Apply the fallback — see Step 1.4b below, then re-run Step 1.4.
|
||
|
||
- [ ] **Step 1.4b (conditional fallback): `OpenApiCustomizer` bean**
|
||
|
||
Only if Step 1.4 failed. Create `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/config/AlertConditionSchemaCustomizer.java`:
|
||
|
||
```java
|
||
package com.cameleer.server.app.alerting.config;
|
||
|
||
import io.swagger.v3.oas.models.media.Discriminator;
|
||
import io.swagger.v3.oas.models.media.Schema;
|
||
import org.springdoc.core.customizers.OpenApiCustomizer;
|
||
import org.springframework.context.annotation.Bean;
|
||
import org.springframework.context.annotation.Configuration;
|
||
|
||
import java.util.LinkedHashMap;
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* Ensures the AlertCondition polymorphic schema emits an explicit discriminator
|
||
* mapping. Without this, openapi-typescript synthesises {@code kind: "<ClassName>"}
|
||
* literals that collapse TS indexed access to {@code never}.
|
||
*
|
||
* Only wired if the @Schema(discriminatorMapping=…) annotation on the sealed
|
||
* interface is not honored by springdoc 2.8.6.
|
||
*/
|
||
@Configuration
|
||
public class AlertConditionSchemaCustomizer {
|
||
|
||
@Bean
|
||
public OpenApiCustomizer alertConditionDiscriminator() {
|
||
return openApi -> {
|
||
if (openApi.getComponents() == null || openApi.getComponents().getSchemas() == null) return;
|
||
Schema<?> alertCondition = openApi.getComponents().getSchemas().get("AlertCondition");
|
||
if (alertCondition == null) return;
|
||
|
||
Map<String, String> mapping = new LinkedHashMap<>();
|
||
mapping.put("ROUTE_METRIC", "#/components/schemas/RouteMetricCondition");
|
||
mapping.put("EXCHANGE_MATCH", "#/components/schemas/ExchangeMatchCondition");
|
||
mapping.put("AGENT_STATE", "#/components/schemas/AgentStateCondition");
|
||
mapping.put("DEPLOYMENT_STATE", "#/components/schemas/DeploymentStateCondition");
|
||
mapping.put("LOG_PATTERN", "#/components/schemas/LogPatternCondition");
|
||
mapping.put("JVM_METRIC", "#/components/schemas/JvmMetricCondition");
|
||
|
||
Discriminator discriminator = alertCondition.getDiscriminator();
|
||
if (discriminator == null) {
|
||
discriminator = new Discriminator().propertyName("kind");
|
||
alertCondition.setDiscriminator(discriminator);
|
||
}
|
||
discriminator.mapping(mapping);
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
Re-run Step 1.3 + Step 1.4.
|
||
|
||
- [ ] **Step 1.5: Verify `mvn verify` still green**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-app verify -am
|
||
```
|
||
|
||
Expected: `BUILD SUCCESS`, all ITs pass including `SpringContextSmokeIT` and alerting ITs.
|
||
|
||
- [ ] **Step 1.6: Detect-changes check**
|
||
|
||
Run: `gitnexus_detect_changes({scope: "staged"})`
|
||
|
||
Expected: only `AlertCondition.java` staged (plus `AlertConditionSchemaCustomizer.java` if fallback was needed), plus regenerated `openapi.json` and `schema.d.ts`.
|
||
|
||
- [ ] **Step 1.7: Commit**
|
||
|
||
```bash
|
||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertCondition.java
|
||
git add ui/src/api/openapi.json ui/src/api/schema.d.ts
|
||
# If fallback was used:
|
||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/config/AlertConditionSchemaCustomizer.java
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(alerting): discriminator mapping on AlertCondition
|
||
|
||
openapi-typescript was synthesising kind: "<ClassName>" literals on each
|
||
condition subtype because the generated discriminator had no mapping.
|
||
Those literals intersected with the real Jackson enum and collapsed to
|
||
never, forcing enums.ts to hand-declare every polymorphic union.
|
||
|
||
@Schema(discriminatorProperty/discriminatorMapping) on the sealed
|
||
interface makes springdoc emit an explicit mapping, so openapi-typescript
|
||
uses the real wire values (ROUTE_METRIC etc.) as literals.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Simplify `ui/src/pages/Alerts/enums.ts` — derive polymorphic unions
|
||
|
||
**Why:** With Task 1's mapping fix in place, indexed access on the condition subtypes yields real unions instead of `never`. The 4 hand-declared unions and the 30-line workaround comment can go.
|
||
|
||
**Files:**
|
||
- Modify: `ui/src/pages/Alerts/enums.ts`
|
||
|
||
- [ ] **Step 2.1: Edit `enums.ts` — replace hand-declared unions with derived types**
|
||
|
||
Full contents of `ui/src/pages/Alerts/enums.ts` after this step:
|
||
|
||
```typescript
|
||
/**
|
||
* Alerting option lists and enum types used by the rule editor.
|
||
*
|
||
* All types derived from `schema.d.ts`. Declaration order on label maps is
|
||
* load-bearing — it controls dropdown order, which the user sees.
|
||
*
|
||
* Hidden values stay in the `Record<T, string>` map (so `Record`-exhaustiveness
|
||
* still pins labels to the union) and in the type `T` (so rules carrying a
|
||
* hidden value round-trip fine through save/load) — they're simply filtered
|
||
* out of the visible option array.
|
||
*/
|
||
import type { components } from '../../api/schema';
|
||
|
||
type AlertRuleRequest = components['schemas']['AlertRuleRequest'];
|
||
type AlertRuleTarget = components['schemas']['AlertRuleTarget'];
|
||
type RouteMetricCondition = components['schemas']['RouteMetricCondition'];
|
||
type JvmMetricCondition = components['schemas']['JvmMetricCondition'];
|
||
type ExchangeMatchCondition = components['schemas']['ExchangeMatchCondition'];
|
||
|
||
export type ConditionKind = NonNullable<AlertRuleRequest['conditionKind']>;
|
||
export type Severity = NonNullable<AlertRuleRequest['severity']>;
|
||
export type TargetKind = NonNullable<AlertRuleTarget['kind']>;
|
||
export type RouteMetric = NonNullable<RouteMetricCondition['metric']>;
|
||
export type Comparator = NonNullable<RouteMetricCondition['comparator']>;
|
||
export type JvmAggregation = NonNullable<JvmMetricCondition['aggregation']>;
|
||
export type ExchangeFireMode = NonNullable<ExchangeMatchCondition['fireMode']>;
|
||
|
||
export interface Option<T extends string> { value: T; label: string }
|
||
|
||
function toOptions<T extends string>(labels: Record<T, string>, hidden?: readonly T[]): Option<T>[] {
|
||
const skip: ReadonlySet<T> = new Set(hidden ?? []);
|
||
return (Object.keys(labels) as T[])
|
||
.filter((value) => !skip.has(value))
|
||
.map((value) => ({ value, label: labels[value] }));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Label maps — declaration order = dropdown order.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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)',
|
||
DEPLOYMENT_STATE: 'Deployment state (FAILED / DEGRADED)',
|
||
LOG_PATTERN: 'Log pattern (count of matching logs)',
|
||
JVM_METRIC: 'JVM metric (heap, GC, inflight)',
|
||
};
|
||
|
||
const SEVERITY_LABELS: Record<Severity, string> = {
|
||
CRITICAL: 'Critical',
|
||
WARNING: 'Warning',
|
||
INFO: 'Info',
|
||
};
|
||
|
||
const ROUTE_METRIC_LABELS: Record<RouteMetric, string> = {
|
||
ERROR_RATE: 'Error rate',
|
||
P99_LATENCY_MS: 'P99 latency (ms)',
|
||
AVG_DURATION_MS: 'Avg duration (ms)',
|
||
THROUGHPUT: 'Throughput (msg/s)',
|
||
ERROR_COUNT: 'Error count',
|
||
};
|
||
|
||
const COMPARATOR_LABELS: Record<Comparator, string> = {
|
||
GT: '>',
|
||
GTE: '\u2265',
|
||
LT: '<',
|
||
LTE: '\u2264',
|
||
EQ: '=',
|
||
};
|
||
|
||
const JVM_AGGREGATION_LABELS: Record<JvmAggregation, string> = {
|
||
MAX: 'MAX',
|
||
AVG: 'AVG',
|
||
MIN: 'MIN',
|
||
LATEST: 'LATEST',
|
||
};
|
||
|
||
const EXCHANGE_FIRE_MODE_LABELS: Record<ExchangeFireMode, string> = {
|
||
PER_EXCHANGE: 'One alert per matching exchange',
|
||
COUNT_IN_WINDOW: 'Threshold: N matches in window',
|
||
};
|
||
|
||
const TARGET_KIND_LABELS: Record<TargetKind, string> = {
|
||
USER: 'User',
|
||
GROUP: 'Group',
|
||
ROLE: 'Role',
|
||
};
|
||
|
||
const COMPARATOR_HIDDEN: readonly Comparator[] = [
|
||
'EQ', // Exact equality on floating-point metrics is rarely useful and noisy
|
||
// — users should pick GT/LT with a sensible threshold instead.
|
||
];
|
||
|
||
const JVM_AGGREGATION_HIDDEN: readonly JvmAggregation[] = [
|
||
'LATEST', // Point-in-time reads belong on a metric dashboard, not an alert
|
||
// rule — a windowed MAX/MIN/AVG is what you want for alerting.
|
||
];
|
||
|
||
export const CONDITION_KIND_OPTIONS: Option<ConditionKind>[] = toOptions(CONDITION_KIND_LABELS);
|
||
export const SEVERITY_OPTIONS: Option<Severity>[] = toOptions(SEVERITY_LABELS);
|
||
export const ROUTE_METRIC_OPTIONS: Option<RouteMetric>[] = toOptions(ROUTE_METRIC_LABELS);
|
||
export const COMPARATOR_OPTIONS: Option<Comparator>[] = toOptions(COMPARATOR_LABELS, COMPARATOR_HIDDEN);
|
||
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);
|
||
```
|
||
|
||
- [ ] **Step 2.2: Typecheck**
|
||
|
||
```bash
|
||
cd ui && npm run typecheck
|
||
```
|
||
|
||
Expected: `0 errors`. If errors surface in call sites (e.g. in condition-forms/), they typically mean the call site relied on a manual union that's now narrower. Each error message will point to an exact file:line; fix the call site (update label maps, widen/narrow the call-site type as needed).
|
||
|
||
- [ ] **Step 2.3: Unit test**
|
||
|
||
```bash
|
||
cd ui && npm run test -- pages/Alerts/enums.test.ts
|
||
```
|
||
|
||
Expected: inline-snapshot test for every `_OPTIONS` array passes. If snapshot differs, inspect: either the schema genuinely narrowed an enum (accept snapshot) or the derived type dropped a value we care about (re-check the backend enum).
|
||
|
||
- [ ] **Step 2.4: Detect-changes check**
|
||
|
||
Run: `gitnexus_detect_changes({scope: "staged"})`
|
||
|
||
Expected: `ui/src/pages/Alerts/enums.ts` only (plus any call-site fixes you made in Step 2.2).
|
||
|
||
- [ ] **Step 2.5: Commit**
|
||
|
||
```bash
|
||
git add ui/src/pages/Alerts/enums.ts
|
||
# + any call-site fix files from step 2.2
|
||
git commit -m "$(cat <<'EOF'
|
||
refactor(ui/alerts): derive polymorphic unions from schema
|
||
|
||
Now that discriminator.mapping lands on AlertCondition (Task 1), indexed
|
||
access on each condition subtype resolves to real unions instead of
|
||
never. Deletes the 4 hand-declared unions (RouteMetric, Comparator,
|
||
JvmAggregation, ExchangeFireMode) and the 30-line workaround preamble.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: `AgentStateCondition.state` → `AgentState` enum
|
||
|
||
**Why:** Field is typed String despite a closed vocabulary. Making it `AgentState` lets springdoc emit a closed union, lets `@Valid` reject unknown values, and removes the hand-maintained option list in the UI.
|
||
|
||
**Files:**
|
||
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AgentStateCondition.java`
|
||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AgentStateEvaluator.java`
|
||
- Modify: `ui/src/pages/Alerts/enums.ts` (add `AgentStateOption` label map)
|
||
- Modify: `ui/src/pages/Alerts/RuleEditor/condition-forms/AgentStateForm.tsx` (consume derived type)
|
||
|
||
- [ ] **Step 3.1: Impact-check**
|
||
|
||
Run: `gitnexus_impact({target: "AgentStateCondition", direction: "upstream"})`
|
||
|
||
Expected: direct callers = `AgentStateEvaluator`, `PostgresAlertRuleRepository` (JSON deserialization), the rule-evaluator job. Low risk.
|
||
|
||
- [ ] **Step 3.2: Edit `AgentStateCondition.java`**
|
||
|
||
```java
|
||
package com.cameleer.server.core.alerting;
|
||
|
||
import com.cameleer.server.core.agent.AgentState;
|
||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||
|
||
public record AgentStateCondition(AlertScope scope, AgentState state, int forSeconds) implements AlertCondition {
|
||
@Override
|
||
@JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY)
|
||
public ConditionKind kind() { return ConditionKind.AGENT_STATE; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3.3: Fix `AgentStateEvaluator.java`**
|
||
|
||
Read the current file first to find where `c.state()` is used:
|
||
|
||
```bash
|
||
grep -n "state()" cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AgentStateEvaluator.java
|
||
```
|
||
|
||
Wherever the evaluator compared `state.equals(agent.state().name())` or similar, change to direct enum comparison: `c.state() == agent.state()`. If `c.state()` is passed to a store query, pass `c.state().name()` (the wire form remains unchanged).
|
||
|
||
- [ ] **Step 3.4: Compile Java side**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-core,cameleer-server-app compile
|
||
```
|
||
|
||
Expected: `BUILD SUCCESS`. If `AgentStateEvaluator` or any other caller has type errors, each one points to a specific fix (usually substituting `.name()` or `==`).
|
||
|
||
- [ ] **Step 3.5: Run Java tests**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-app test
|
||
```
|
||
|
||
Expected: all unit tests pass. If a test that constructs `new AgentStateCondition(scope, "DEAD", 30)` fails to compile, update it to `new AgentStateCondition(scope, AgentState.DEAD, 30)`.
|
||
|
||
- [ ] **Step 3.6: Regenerate schema**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-app spring-boot:run &
|
||
SERVER_PID=$!
|
||
until curl -fsS http://localhost:8081/api/v1/health >/dev/null 2>&1; do sleep 2; done
|
||
cd ui && npm run generate-api:live
|
||
cd ..
|
||
kill $SERVER_PID
|
||
```
|
||
|
||
Verify in the regenerated schema:
|
||
|
||
```bash
|
||
grep -A3 '"AgentStateCondition"' ui/src/api/openapi.json | head -20
|
||
```
|
||
|
||
Expected: `state` property has `"enum": ["LIVE", "STALE", "DEAD", "SHUTDOWN"]`.
|
||
|
||
- [ ] **Step 3.7: Update `ui/src/pages/Alerts/enums.ts`**
|
||
|
||
Append:
|
||
|
||
```typescript
|
||
type AgentStateCondition = components['schemas']['AgentStateCondition'];
|
||
export type AgentStateValue = NonNullable<AgentStateCondition['state']>;
|
||
|
||
const AGENT_STATE_LABELS: Record<AgentStateValue, string> = {
|
||
LIVE: 'Live',
|
||
STALE: 'Stale',
|
||
DEAD: 'Dead',
|
||
SHUTDOWN: 'Shutdown',
|
||
};
|
||
|
||
const AGENT_STATE_HIDDEN: readonly AgentStateValue[] = [
|
||
'LIVE', // LIVE agents are the healthy case — never an alerting condition
|
||
'SHUTDOWN', // SHUTDOWN is an operator-initiated state, not an alert-worthy anomaly
|
||
];
|
||
|
||
export const AGENT_STATE_OPTIONS: Option<AgentStateValue>[] = toOptions(AGENT_STATE_LABELS, AGENT_STATE_HIDDEN);
|
||
```
|
||
|
||
- [ ] **Step 3.8: Update `AgentStateForm.tsx`**
|
||
|
||
Read: `ui/src/pages/Alerts/RuleEditor/condition-forms/AgentStateForm.tsx`
|
||
|
||
Replace any hardcoded `<option value="DEAD">` with a loop over `AGENT_STATE_OPTIONS`:
|
||
|
||
```tsx
|
||
import { AGENT_STATE_OPTIONS, type AgentStateValue } from '../../enums';
|
||
|
||
// Inside the component, replace the state <select> with:
|
||
<Select
|
||
value={condition.state ?? ''}
|
||
onChange={(e) => onChange({ ...condition, state: e.target.value as AgentStateValue })}
|
||
>
|
||
{AGENT_STATE_OPTIONS.map((opt) => (
|
||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||
))}
|
||
</Select>
|
||
```
|
||
|
||
- [ ] **Step 3.9: UI typecheck + test**
|
||
|
||
```bash
|
||
cd ui && npm run typecheck && npm run test
|
||
```
|
||
|
||
Expected: 0 errors. If form-state.ts tests reference `state: 'DEAD'` string literals, update to `AgentState.DEAD` — the TS string union `'LIVE' | 'STALE' | 'DEAD' | 'SHUTDOWN'` still accepts the literal `'DEAD'` so existing fixtures should keep passing without source change.
|
||
|
||
- [ ] **Step 3.10: IT sanity check**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-app verify -Dit.test='AlertingFullLifecycleIT#*'
|
||
```
|
||
|
||
Expected: lifecycle IT passes, including AGENT_STATE rule evaluation.
|
||
|
||
- [ ] **Step 3.11: Commit**
|
||
|
||
```bash
|
||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AgentStateCondition.java
|
||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AgentStateEvaluator.java
|
||
git add ui/src/api/openapi.json ui/src/api/schema.d.ts
|
||
git add ui/src/pages/Alerts/enums.ts
|
||
git add ui/src/pages/Alerts/RuleEditor/condition-forms/AgentStateForm.tsx
|
||
# + any other fix files touched in 3.5/3.9
|
||
git commit -m "$(cat <<'EOF'
|
||
refactor(alerting): AgentStateCondition.state — String to AgentState enum
|
||
|
||
Springdoc now emits a closed enum union; UI derives the type and
|
||
replaces the hand-maintained dropdown value list with a label map.
|
||
@Valid on AlertRuleRequest rejects unknown values at the controller
|
||
boundary.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: `DeploymentStateCondition.states` → `List<DeploymentStatus>`
|
||
|
||
**Why:** Same reasoning as Task 3 — closed vocabulary typed as `List<String>`.
|
||
|
||
**Files:**
|
||
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/DeploymentStateCondition.java`
|
||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/DeploymentStateEvaluator.java`
|
||
- Modify: `ui/src/pages/Alerts/enums.ts` (append `DEPLOYMENT_STATE_LABELS` + `DEPLOYMENT_STATE_OPTIONS`)
|
||
- Modify: `ui/src/pages/Alerts/RuleEditor/condition-forms/DeploymentStateForm.tsx`
|
||
|
||
- [ ] **Step 4.1: Impact-check**
|
||
|
||
Run: `gitnexus_impact({target: "DeploymentStateCondition", direction: "upstream"})`
|
||
|
||
Expected: direct callers = `DeploymentStateEvaluator`, `PostgresAlertRuleRepository`, tests.
|
||
|
||
- [ ] **Step 4.2: Edit `DeploymentStateCondition.java`**
|
||
|
||
```java
|
||
package com.cameleer.server.core.alerting;
|
||
|
||
import com.cameleer.server.core.runtime.DeploymentStatus;
|
||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||
|
||
import java.util.List;
|
||
|
||
public record DeploymentStateCondition(AlertScope scope, List<DeploymentStatus> states) implements AlertCondition {
|
||
public DeploymentStateCondition { states = List.copyOf(states); }
|
||
@Override
|
||
@JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY)
|
||
public ConditionKind kind() { return ConditionKind.DEPLOYMENT_STATE; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4.3: Fix `DeploymentStateEvaluator.java`**
|
||
|
||
Read the current file, find where `states` is used. The evaluator likely compares each `deployment.status()` (already `DeploymentStatus`) against each element of `c.states()`. Change string comparison to enum equality:
|
||
|
||
```java
|
||
// Before (illustrative): if (c.states().contains(deployment.status().name())) { ... }
|
||
// After: if (c.states().contains(deployment.status())) { ... }
|
||
```
|
||
|
||
- [ ] **Step 4.4: Compile**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-core,cameleer-server-app compile
|
||
```
|
||
|
||
Expected: `BUILD SUCCESS`.
|
||
|
||
- [ ] **Step 4.5: Run Java tests**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-app test
|
||
```
|
||
|
||
Expected: all tests pass. Update any test fixture that constructs `new DeploymentStateCondition(scope, List.of("FAILED"))` to `List.of(DeploymentStatus.FAILED)`.
|
||
|
||
- [ ] **Step 4.6: Regenerate schema**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-app spring-boot:run &
|
||
SERVER_PID=$!
|
||
until curl -fsS http://localhost:8081/api/v1/health >/dev/null 2>&1; do sleep 2; done
|
||
cd ui && npm run generate-api:live
|
||
cd ..
|
||
kill $SERVER_PID
|
||
```
|
||
|
||
Verify:
|
||
|
||
```bash
|
||
grep -A5 '"DeploymentStateCondition"' ui/src/api/openapi.json | head -15
|
||
```
|
||
|
||
Expected: `states` property has `"type": "array", "items": { "enum": ["STOPPED", "STARTING", ...] }`.
|
||
|
||
- [ ] **Step 4.7: Update `enums.ts`**
|
||
|
||
Append:
|
||
|
||
```typescript
|
||
type DeploymentStateCondition = components['schemas']['DeploymentStateCondition'];
|
||
export type DeploymentStateValue = NonNullable<NonNullable<DeploymentStateCondition['states']>[number]>;
|
||
|
||
const DEPLOYMENT_STATE_LABELS: Record<DeploymentStateValue, string> = {
|
||
STOPPED: 'Stopped',
|
||
STARTING: 'Starting',
|
||
RUNNING: 'Running',
|
||
DEGRADED: 'Degraded',
|
||
STOPPING: 'Stopping',
|
||
FAILED: 'Failed',
|
||
};
|
||
|
||
const DEPLOYMENT_STATE_HIDDEN: readonly DeploymentStateValue[] = [
|
||
'STOPPED', // A stopped deployment is an operator action, not an alert.
|
||
'STARTING', // Transient.
|
||
'STOPPING', // Transient.
|
||
'RUNNING', // Healthy — never the firing condition.
|
||
];
|
||
|
||
export const DEPLOYMENT_STATE_OPTIONS: Option<DeploymentStateValue>[] =
|
||
toOptions(DEPLOYMENT_STATE_LABELS, DEPLOYMENT_STATE_HIDDEN);
|
||
```
|
||
|
||
- [ ] **Step 4.8: Update `DeploymentStateForm.tsx`**
|
||
|
||
Replace hardcoded option list with a loop over `DEPLOYMENT_STATE_OPTIONS`. Same pattern as AgentStateForm in Step 3.8 but the form is multi-select — adapt accordingly:
|
||
|
||
```tsx
|
||
import { DEPLOYMENT_STATE_OPTIONS, type DeploymentStateValue } from '../../enums';
|
||
|
||
// In the multi-select, map over DEPLOYMENT_STATE_OPTIONS instead of a hardcoded array
|
||
```
|
||
|
||
- [ ] **Step 4.9: UI typecheck + test**
|
||
|
||
```bash
|
||
cd ui && npm run typecheck && npm run test
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 4.10: Commit**
|
||
|
||
```bash
|
||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/DeploymentStateCondition.java
|
||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/DeploymentStateEvaluator.java
|
||
git add ui/src/api/openapi.json ui/src/api/schema.d.ts
|
||
git add ui/src/pages/Alerts/enums.ts
|
||
git add ui/src/pages/Alerts/RuleEditor/condition-forms/DeploymentStateForm.tsx
|
||
git commit -m "$(cat <<'EOF'
|
||
refactor(alerting): DeploymentStateCondition.states — List<String> to List<DeploymentStatus>
|
||
|
||
Same pattern as AgentState migration — typed vocabulary, @Valid
|
||
enforcement, UI-side derived options.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: New `LogLevel` enum + migrate `LogPatternCondition.level`
|
||
|
||
**Why:** `level` is a closed SLF4J-style vocabulary. `.claude/rules/core-classes.md` needs updating because a new enum lands.
|
||
|
||
**Files:**
|
||
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/LogLevel.java`
|
||
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/LogPatternCondition.java`
|
||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluator.java`
|
||
- Modify: `ui/src/pages/Alerts/enums.ts`
|
||
- Modify: `ui/src/pages/Alerts/RuleEditor/condition-forms/LogPatternForm.tsx`
|
||
- Modify: `.claude/rules/core-classes.md`
|
||
|
||
- [ ] **Step 5.1: Create `LogLevel.java`**
|
||
|
||
```java
|
||
package com.cameleer.server.core.alerting;
|
||
|
||
/**
|
||
* SLF4J-compatible log severity levels. Used by LogPatternCondition to filter
|
||
* which log rows participate in pattern-match counting.
|
||
*/
|
||
public enum LogLevel {
|
||
TRACE,
|
||
DEBUG,
|
||
INFO,
|
||
WARN,
|
||
ERROR
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5.2: Edit `LogPatternCondition.java`**
|
||
|
||
```java
|
||
package com.cameleer.server.core.alerting;
|
||
|
||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||
|
||
public record LogPatternCondition(
|
||
AlertScope scope,
|
||
LogLevel level,
|
||
String pattern,
|
||
int threshold,
|
||
int windowSeconds) implements AlertCondition {
|
||
@Override
|
||
@JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY)
|
||
public ConditionKind kind() { return ConditionKind.LOG_PATTERN; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5.3: Fix `LogPatternEvaluator.java`**
|
||
|
||
Read the current file. Where it passes `c.level()` to a ClickHouse query or filter predicate, convert to `c.level() == null ? null : c.level().name()` at the query boundary — the `logs` ClickHouse table stores log level as a text column.
|
||
|
||
- [ ] **Step 5.4: Compile + test**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-core,cameleer-server-app compile
|
||
mvn -pl cameleer-server-app test
|
||
```
|
||
|
||
Expected: `BUILD SUCCESS`. Fix any test fixture that did `new LogPatternCondition(scope, "ERROR", ...)` → `LogLevel.ERROR`.
|
||
|
||
- [ ] **Step 5.5: Regenerate schema**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-app spring-boot:run &
|
||
SERVER_PID=$!
|
||
until curl -fsS http://localhost:8081/api/v1/health >/dev/null 2>&1; do sleep 2; done
|
||
cd ui && npm run generate-api:live
|
||
cd ..
|
||
kill $SERVER_PID
|
||
grep -A4 '"LogPatternCondition"' ui/src/api/openapi.json | head -15
|
||
```
|
||
|
||
Expected: `level` has `"enum": ["TRACE","DEBUG","INFO","WARN","ERROR"]`.
|
||
|
||
- [ ] **Step 5.6: Update `enums.ts`**
|
||
|
||
Append:
|
||
|
||
```typescript
|
||
type LogPatternCondition = components['schemas']['LogPatternCondition'];
|
||
export type LogLevelValue = NonNullable<LogPatternCondition['level']>;
|
||
|
||
const LOG_LEVEL_LABELS: Record<LogLevelValue, string> = {
|
||
ERROR: 'ERROR',
|
||
WARN: 'WARN',
|
||
INFO: 'INFO',
|
||
DEBUG: 'DEBUG',
|
||
TRACE: 'TRACE',
|
||
};
|
||
|
||
export const LOG_LEVEL_OPTIONS: Option<LogLevelValue>[] = toOptions(LOG_LEVEL_LABELS);
|
||
```
|
||
|
||
- [ ] **Step 5.7: Update `LogPatternForm.tsx`**
|
||
|
||
Same pattern — replace hardcoded options with `LOG_LEVEL_OPTIONS`.
|
||
|
||
- [ ] **Step 5.8: Update `.claude/rules/core-classes.md`**
|
||
|
||
Find the `alerting/` section in the file and insert, alphabetically:
|
||
|
||
```markdown
|
||
- `LogLevel` — enum: `TRACE, DEBUG, INFO, WARN, ERROR`. SLF4J-compatible vocabulary used by `LogPatternCondition.level`.
|
||
```
|
||
|
||
- [ ] **Step 5.9: UI typecheck + test**
|
||
|
||
```bash
|
||
cd ui && npm run typecheck && npm run test
|
||
```
|
||
|
||
- [ ] **Step 5.10: Commit**
|
||
|
||
```bash
|
||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/LogLevel.java
|
||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/LogPatternCondition.java
|
||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluator.java
|
||
git add ui/src/api/openapi.json ui/src/api/schema.d.ts
|
||
git add ui/src/pages/Alerts/enums.ts
|
||
git add ui/src/pages/Alerts/RuleEditor/condition-forms/LogPatternForm.tsx
|
||
git add .claude/rules/core-classes.md
|
||
git commit -m "$(cat <<'EOF'
|
||
refactor(alerting): LogPatternCondition.level — String to LogLevel enum
|
||
|
||
Adds LogLevel {TRACE, DEBUG, INFO, WARN, ERROR} in core/alerting.
|
||
Evaluator passes .name() at the ClickHouse query boundary; wire format
|
||
unchanged.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: `ExchangeFilter.status` → `ExecutionStatus`
|
||
|
||
**Why:** Closed Camel vocabulary. `ExecutionStatus` already exists in `cameleer-common` — no new enum needed.
|
||
|
||
**Files:**
|
||
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/ExchangeMatchCondition.java` (nested `ExchangeFilter` record)
|
||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluator.java`
|
||
- Modify: `ui/src/pages/Alerts/enums.ts`
|
||
- Modify: `ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx`
|
||
|
||
- [ ] **Step 6.1: Impact-check**
|
||
|
||
Run: `gitnexus_impact({target: "ExchangeMatchCondition", direction: "upstream"})`
|
||
|
||
Expected: direct callers = `ExchangeMatchEvaluator`, `PostgresAlertRuleRepository`, `AlertRuleController` validation path.
|
||
|
||
- [ ] **Step 6.2: Edit `ExchangeMatchCondition.java`**
|
||
|
||
```java
|
||
package com.cameleer.server.core.alerting;
|
||
|
||
import com.cameleer.common.model.ExecutionStatus;
|
||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||
|
||
import java.util.Map;
|
||
|
||
public record ExchangeMatchCondition(
|
||
AlertScope scope,
|
||
ExchangeFilter filter,
|
||
FireMode fireMode,
|
||
Integer threshold,
|
||
Integer windowSeconds,
|
||
Integer perExchangeLingerSeconds
|
||
) implements AlertCondition {
|
||
|
||
public ExchangeMatchCondition {
|
||
if (fireMode == null)
|
||
throw new IllegalArgumentException("fireMode is required (PER_EXCHANGE or COUNT_IN_WINDOW)");
|
||
if (fireMode == FireMode.COUNT_IN_WINDOW && (threshold == null || windowSeconds == null))
|
||
throw new IllegalArgumentException("COUNT_IN_WINDOW requires threshold + windowSeconds");
|
||
if (fireMode == FireMode.PER_EXCHANGE && perExchangeLingerSeconds == null)
|
||
throw new IllegalArgumentException("PER_EXCHANGE requires perExchangeLingerSeconds");
|
||
}
|
||
|
||
@Override
|
||
@JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY)
|
||
public ConditionKind kind() { return ConditionKind.EXCHANGE_MATCH; }
|
||
|
||
public record ExchangeFilter(ExecutionStatus status, Map<String, String> attributes) {
|
||
public ExchangeFilter { attributes = attributes == null ? Map.of() : Map.copyOf(attributes); }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6.3: Fix `ExchangeMatchEvaluator.java`**
|
||
|
||
Read the evaluator. At lines where `filter.status()` is passed to `AlertMatchSpec` or `SearchRequest`, convert at the boundary:
|
||
|
||
```java
|
||
// Before: filter != null ? filter.status() : null
|
||
// After: filter != null && filter.status() != null ? filter.status().name() : null
|
||
```
|
||
|
||
Both call sites in the evaluator (around lines 60 and 99 per earlier grep) need this pattern.
|
||
|
||
- [ ] **Step 6.4: Compile + test**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-core,cameleer-server-app compile
|
||
mvn -pl cameleer-server-app test
|
||
```
|
||
|
||
Expected: `BUILD SUCCESS`. Fix any test fixture that passed `"FAILED"` → `ExecutionStatus.FAILED`.
|
||
|
||
- [ ] **Step 6.5: Regenerate schema**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-app spring-boot:run &
|
||
SERVER_PID=$!
|
||
until curl -fsS http://localhost:8081/api/v1/health >/dev/null 2>&1; do sleep 2; done
|
||
cd ui && npm run generate-api:live
|
||
cd ..
|
||
kill $SERVER_PID
|
||
grep -A6 '"ExchangeFilter"' ui/src/api/openapi.json
|
||
```
|
||
|
||
Expected: `status` has `"enum": ["RUNNING","COMPLETED","FAILED","ABANDONED"]`.
|
||
|
||
- [ ] **Step 6.6: Update `enums.ts`**
|
||
|
||
Append:
|
||
|
||
```typescript
|
||
type ExchangeFilter = components['schemas']['ExchangeFilter'];
|
||
export type ExchangeStatus = NonNullable<ExchangeFilter['status']>;
|
||
|
||
const EXCHANGE_STATUS_LABELS: Record<ExchangeStatus, string> = {
|
||
COMPLETED: 'Completed',
|
||
FAILED: 'Failed',
|
||
RUNNING: 'Running',
|
||
ABANDONED: 'Abandoned',
|
||
};
|
||
|
||
const EXCHANGE_STATUS_HIDDEN: readonly ExchangeStatus[] = [
|
||
// No hidden values — all four are legitimate alerting targets.
|
||
];
|
||
|
||
export const EXCHANGE_STATUS_OPTIONS: Option<ExchangeStatus>[] = toOptions(EXCHANGE_STATUS_LABELS, EXCHANGE_STATUS_HIDDEN);
|
||
```
|
||
|
||
- [ ] **Step 6.7: Update `ExchangeMatchForm.tsx`**
|
||
|
||
Replace the status input (likely a text field or hardcoded select) with a dropdown over `EXCHANGE_STATUS_OPTIONS`.
|
||
|
||
- [ ] **Step 6.8: UI typecheck + test**
|
||
|
||
```bash
|
||
cd ui && npm run typecheck && npm run test
|
||
```
|
||
|
||
- [ ] **Step 6.9: IT**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-app verify -Dit.test='AlertingFullLifecycleIT#*'
|
||
```
|
||
|
||
Expected: exchange-match rule lifecycle green.
|
||
|
||
- [ ] **Step 6.10: Commit**
|
||
|
||
```bash
|
||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/ExchangeMatchCondition.java
|
||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluator.java
|
||
git add ui/src/api/openapi.json ui/src/api/schema.d.ts
|
||
git add ui/src/pages/Alerts/enums.ts
|
||
git add ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx
|
||
git commit -m "$(cat <<'EOF'
|
||
refactor(alerting): ExchangeFilter.status — String to ExecutionStatus
|
||
|
||
Reuses com.cameleer.common.model.ExecutionStatus (RUNNING, COMPLETED,
|
||
FAILED, ABANDONED). Evaluator unwraps to .name() at the ClickHouse
|
||
query boundary; wire format unchanged.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: `@Schema(allowableValues)` on `JvmMetricCondition.metric`
|
||
|
||
**Why:** Spec §6 — field stays `String` (evaluator passes through to Micrometer metric store), but OpenAPI emits a narrowed union so UI gets autocomplete.
|
||
|
||
**Files:**
|
||
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/JvmMetricCondition.java`
|
||
- Modify: `ui/src/pages/Alerts/enums.ts`
|
||
- Modify: `ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx`
|
||
|
||
- [ ] **Step 7.1: Edit `JvmMetricCondition.java`**
|
||
|
||
```java
|
||
package com.cameleer.server.core.alerting;
|
||
|
||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||
import io.swagger.v3.oas.annotations.media.Schema;
|
||
|
||
public record JvmMetricCondition(
|
||
AlertScope scope,
|
||
@Schema(
|
||
description = "Agent-reported Micrometer metric name. Any string is accepted at the backend; " +
|
||
"the allowable-values list drives UI autocomplete.",
|
||
allowableValues = {
|
||
"process.cpu.usage.value",
|
||
"jvm.memory.used.value",
|
||
"jvm.memory.max.value",
|
||
"jvm.threads.live.value",
|
||
"jvm.gc.pause.total_time"
|
||
}
|
||
)
|
||
String metric,
|
||
AggregationOp aggregation,
|
||
Comparator comparator,
|
||
double threshold,
|
||
int windowSeconds) implements AlertCondition {
|
||
@Override
|
||
@JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY)
|
||
public ConditionKind kind() { return ConditionKind.JVM_METRIC; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7.2: Regenerate schema**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-core,cameleer-server-app compile
|
||
mvn -pl cameleer-server-app spring-boot:run &
|
||
SERVER_PID=$!
|
||
until curl -fsS http://localhost:8081/api/v1/health >/dev/null 2>&1; do sleep 2; done
|
||
cd ui && npm run generate-api:live
|
||
cd ..
|
||
kill $SERVER_PID
|
||
grep -B1 -A2 '"metric"' ui/src/api/openapi.json | grep -A2 JvmMetricCondition
|
||
```
|
||
|
||
Expected: `metric` property has `"type": "string"` + `"enum": ["process.cpu.usage.value", ...]`.
|
||
|
||
- [ ] **Step 7.3: Update `enums.ts`**
|
||
|
||
Append:
|
||
|
||
```typescript
|
||
type JvmMetricCondition = components['schemas']['JvmMetricCondition'];
|
||
export type JvmMetric = NonNullable<JvmMetricCondition['metric']>;
|
||
|
||
const JVM_METRIC_LABELS: Record<JvmMetric, string> = {
|
||
'process.cpu.usage.value': 'CPU usage (ratio 0–1)',
|
||
'jvm.memory.used.value': 'Heap used (bytes)',
|
||
'jvm.memory.max.value': 'Heap max (bytes)',
|
||
'jvm.threads.live.value': 'Live threads',
|
||
'jvm.gc.pause.total_time': 'GC pause total time (seconds)',
|
||
};
|
||
|
||
export const JVM_METRIC_OPTIONS: Option<JvmMetric>[] = toOptions(JVM_METRIC_LABELS);
|
||
```
|
||
|
||
- [ ] **Step 7.4: Update `JvmMetricForm.tsx`**
|
||
|
||
Replace the metric-name input with a dropdown backed by `JVM_METRIC_OPTIONS`. Because the backend is permissive, optionally keep a "custom metric name" escape hatch — but if the existing form was a plain dropdown, just swap the option list.
|
||
|
||
- [ ] **Step 7.5: Typecheck + tests**
|
||
|
||
```bash
|
||
cd ui && npm run typecheck && npm run test
|
||
```
|
||
|
||
Expected: all green.
|
||
|
||
- [ ] **Step 7.6: Commit**
|
||
|
||
```bash
|
||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/JvmMetricCondition.java
|
||
git add ui/src/api/openapi.json ui/src/api/schema.d.ts
|
||
git add ui/src/pages/Alerts/enums.ts
|
||
git add ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx
|
||
git commit -m "$(cat <<'EOF'
|
||
refactor(alerting): JvmMetricCondition.metric — @Schema(allowableValues) hint
|
||
|
||
Field stays String because JvmMetricEvaluator passes it through to
|
||
MetricsQueryStore as a raw Micrometer metric name. The allowableValues
|
||
hint narrows the TS type to a union of known metrics so UI can render
|
||
an autocomplete-backed dropdown. Backend remains permissive to
|
||
accommodate metrics we haven't curated yet.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Context-startup smoke test (unit-test tier, no Testcontainers)
|
||
|
||
**Why:** Existing `SpringContextSmokeIT` catches wiring regressions but only runs during `mvn verify` (Testcontainers requires Docker). A parallel unit-test tier that runs in `mvn test` catches them faster in the dev loop.
|
||
|
||
**Files:**
|
||
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/ContextStartupSmokeTest.java`
|
||
- Create: `cameleer-server-app/src/test/resources/application-test-context-smoke.yml`
|
||
|
||
- [ ] **Step 8.1: Create the test profile config**
|
||
|
||
Create `cameleer-server-app/src/test/resources/application-test-context-smoke.yml`:
|
||
|
||
```yaml
|
||
# Stubbed minimal config for context-load smoke tests.
|
||
# Replaces real infra (DB, CH, Docker) with in-memory / mock beans so the test
|
||
# runs in ~5s without Docker.
|
||
spring:
|
||
datasource:
|
||
url: jdbc:h2:mem:smoke;DB_CLOSE_DELAY=-1
|
||
driver-class-name: org.h2.Driver
|
||
username: sa
|
||
password:
|
||
flyway:
|
||
enabled: false
|
||
jpa:
|
||
hibernate:
|
||
ddl-auto: none
|
||
|
||
cameleer:
|
||
server:
|
||
tenant:
|
||
id: smoke-test
|
||
security:
|
||
jwt-secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||
bootstrap-token: "smoke-bootstrap"
|
||
cors-allowed-origins: "http://localhost:5173"
|
||
clickhouse:
|
||
enabled: false
|
||
runtime:
|
||
enabled: false
|
||
outbound-http:
|
||
trust-all: true
|
||
alerting:
|
||
evaluator-tick-interval-ms: 0 # disabled — no scheduled ticks during smoke
|
||
```
|
||
|
||
- [ ] **Step 8.2: Create the test**
|
||
|
||
Create `cameleer-server-app/src/test/java/com/cameleer/server/app/ContextStartupSmokeTest.java`:
|
||
|
||
```java
|
||
package com.cameleer.server.app;
|
||
|
||
import com.cameleer.server.app.alerting.eval.AgentStateEvaluator;
|
||
import com.cameleer.server.app.alerting.eval.ConditionEvaluator;
|
||
import com.cameleer.server.app.alerting.eval.DeploymentStateEvaluator;
|
||
import com.cameleer.server.app.alerting.eval.ExchangeMatchEvaluator;
|
||
import com.cameleer.server.app.alerting.eval.JvmMetricEvaluator;
|
||
import com.cameleer.server.app.alerting.eval.LogPatternEvaluator;
|
||
import com.cameleer.server.app.alerting.eval.PerKindCircuitBreaker;
|
||
import com.cameleer.server.app.alerting.eval.RouteMetricEvaluator;
|
||
import com.cameleer.server.app.alerting.metrics.AlertingMetrics;
|
||
import com.cameleer.server.app.alerting.notify.MustacheRenderer;
|
||
import com.cameleer.server.app.alerting.notify.NotificationContextBuilder;
|
||
import com.cameleer.server.app.alerting.notify.NotificationDispatchJob;
|
||
import com.cameleer.server.app.alerting.notify.WebhookDispatcher;
|
||
import com.cameleer.server.app.alerting.retention.AlertingRetentionJob;
|
||
import org.junit.jupiter.api.Test;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.boot.test.context.SpringBootTest;
|
||
import org.springframework.context.ApplicationContext;
|
||
import org.springframework.test.context.ActiveProfiles;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
/**
|
||
* Unit-tier context-load smoke test: runs under {@code mvn test} (no
|
||
* Testcontainers) so wiring regressions surface before they reach the
|
||
* slower {@code mvn verify} tier.
|
||
*
|
||
* Asserts the presence of every public alerting bean. If any alerting
|
||
* component loses its @Autowired or its @Component stereotype, context
|
||
* construction fails here with a clear bean-name message.
|
||
*
|
||
* Companion to {@link SpringContextSmokeIT}, which runs the same check
|
||
* against a real Postgres + ClickHouse stack via Testcontainers.
|
||
*/
|
||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||
@ActiveProfiles("test-context-smoke")
|
||
class ContextStartupSmokeTest {
|
||
|
||
@Autowired ApplicationContext ctx;
|
||
|
||
@Test
|
||
void alertingBeansAreWired() {
|
||
assertThat(ctx.getBean(AlertingMetrics.class)).isNotNull();
|
||
assertThat(ctx.getBean(NotificationContextBuilder.class)).isNotNull();
|
||
assertThat(ctx.getBean(MustacheRenderer.class)).isNotNull();
|
||
assertThat(ctx.getBean(WebhookDispatcher.class)).isNotNull();
|
||
assertThat(ctx.getBean(NotificationDispatchJob.class)).isNotNull();
|
||
assertThat(ctx.getBean(AlertingRetentionJob.class)).isNotNull();
|
||
assertThat(ctx.getBean(PerKindCircuitBreaker.class)).isNotNull();
|
||
|
||
// Each condition kind has a corresponding evaluator bean.
|
||
assertThat(ctx.getBean(RouteMetricEvaluator.class)).isNotNull();
|
||
assertThat(ctx.getBean(ExchangeMatchEvaluator.class)).isNotNull();
|
||
assertThat(ctx.getBean(AgentStateEvaluator.class)).isNotNull();
|
||
assertThat(ctx.getBean(DeploymentStateEvaluator.class)).isNotNull();
|
||
assertThat(ctx.getBean(LogPatternEvaluator.class)).isNotNull();
|
||
assertThat(ctx.getBean(JvmMetricEvaluator.class)).isNotNull();
|
||
|
||
// And they all register under the common interface.
|
||
assertThat(ctx.getBeansOfType(ConditionEvaluator.class)).hasSize(6);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8.3: Add H2 test dependency**
|
||
|
||
If `cameleer-server-app/pom.xml` doesn't already declare H2 with `scope=test`, add it. Check:
|
||
|
||
```bash
|
||
grep -A2 "<artifactId>h2</artifactId>" cameleer-server-app/pom.xml
|
||
```
|
||
|
||
If absent, add to the `<dependencies>` section:
|
||
|
||
```xml
|
||
<dependency>
|
||
<groupId>com.h2database</groupId>
|
||
<artifactId>h2</artifactId>
|
||
<scope>test</scope>
|
||
</dependency>
|
||
```
|
||
|
||
- [ ] **Step 8.4: Run the test**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-app test -Dtest=ContextStartupSmokeTest
|
||
```
|
||
|
||
Expected: `Tests run: 1, Failures: 0`. Runtime target: under 10 seconds.
|
||
|
||
If it fails on a missing bean (e.g. `BeanCreationException: No qualifying bean of type 'javax.sql.DataSource'`), the test profile in Step 8.1 needs another stub. Read the error, adjust the profile, re-run.
|
||
|
||
- [ ] **Step 8.5: Validate the test catches the target regression**
|
||
|
||
Temporarily remove `@Autowired` from `AlertingMetrics`'s production constructor:
|
||
|
||
```bash
|
||
# Edit cameleer-server-app/.../metrics/AlertingMetrics.java line 78: remove "@Autowired"
|
||
mvn -pl cameleer-server-app test -Dtest=ContextStartupSmokeTest
|
||
```
|
||
|
||
Expected: `FAILED — BeanInstantiationException` or similar, referencing `AlertingMetrics`.
|
||
|
||
Revert the change:
|
||
|
||
```bash
|
||
git checkout cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java
|
||
mvn -pl cameleer-server-app test -Dtest=ContextStartupSmokeTest
|
||
```
|
||
|
||
Expected: green again.
|
||
|
||
- [ ] **Step 8.6: Full test suite**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-app test
|
||
```
|
||
|
||
Expected: all tests green, no regressions in existing suite.
|
||
|
||
- [ ] **Step 8.7: Commit**
|
||
|
||
```bash
|
||
git add cameleer-server-app/src/test/java/com/cameleer/server/app/ContextStartupSmokeTest.java
|
||
git add cameleer-server-app/src/test/resources/application-test-context-smoke.yml
|
||
# + cameleer-server-app/pom.xml if H2 dependency was added
|
||
git commit -m "$(cat <<'EOF'
|
||
test(alerting): Spring context-startup smoke (unit-tier)
|
||
|
||
Complements SpringContextSmokeIT (Testcontainers) with a no-infra
|
||
smoke that runs under mvn test. Asserts every public alerting bean
|
||
is wired. Catches the class of @Autowired/@Component regression that
|
||
produced the #141 crashloop and the AlertingMetrics ctor bug.
|
||
|
||
Verified by temporarily deleting @Autowired on AlertingMetrics: the
|
||
test fails loudly with a BeanInstantiationException. Revert restores
|
||
green.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Template-variables registry in `cameleer-server-core`
|
||
|
||
**Why:** Single source of truth for Mustache variable metadata. NotificationContextBuilder (Task 10) will consume this registry; the controller (Task 11) exposes it; the UI (Task 12) imports it.
|
||
|
||
**Files:**
|
||
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/TemplateVariableDescriptor.java`
|
||
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertTemplateVariables.java`
|
||
|
||
- [ ] **Step 9.1: Create `TemplateVariableDescriptor.java`**
|
||
|
||
```java
|
||
package com.cameleer.server.core.alerting;
|
||
|
||
import java.util.List;
|
||
|
||
/**
|
||
* Metadata for one Mustache template variable available to alert rules.
|
||
*
|
||
* @param path dotted leaf path, e.g. "alert.firedAt"
|
||
* @param type one of: "string", "Instant", "number", "boolean", "url", "uuid"
|
||
* @param description human-readable summary rendered in the editor tooltip
|
||
* @param example sample value rendered in the autocomplete preview
|
||
* @param availableForKinds ConditionKinds where this variable is populated;
|
||
* empty list = always populated
|
||
* @param mayBeNull true if the value can legitimately be null/empty at render time
|
||
*/
|
||
public record TemplateVariableDescriptor(
|
||
String path,
|
||
String type,
|
||
String description,
|
||
String example,
|
||
List<ConditionKind> availableForKinds,
|
||
boolean mayBeNull
|
||
) {
|
||
public TemplateVariableDescriptor {
|
||
availableForKinds = List.copyOf(availableForKinds);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 9.2: Create `AlertTemplateVariables.java`**
|
||
|
||
```java
|
||
package com.cameleer.server.core.alerting;
|
||
|
||
import java.util.List;
|
||
|
||
/**
|
||
* Static registry of every Mustache variable the {@link com.cameleer.server.app.alerting.notify.NotificationContextBuilder}
|
||
* can populate. This is the single source of truth: both the context builder
|
||
* and the UI consume this list (via {@code GET /api/v1/alerts/template-variables}).
|
||
*
|
||
* Invariant: for every descriptor in {@link #ALL}, either
|
||
* (a) the builder always emits it, or
|
||
* (b) the builder emits it for exactly those {@code availableForKinds}.
|
||
*
|
||
* {@code NotificationContextBuilderRegistryTest} enforces this structurally.
|
||
*/
|
||
public final class AlertTemplateVariables {
|
||
|
||
private AlertTemplateVariables() {}
|
||
|
||
public static final List<TemplateVariableDescriptor> ALL = List.of(
|
||
// --- Always available ---
|
||
new TemplateVariableDescriptor("env.slug", "string", "Environment slug", "prod", List.of(), false),
|
||
new TemplateVariableDescriptor("env.id", "uuid", "Environment UUID", "00000000-0000-0000-0000-000000000001", List.of(), false),
|
||
new TemplateVariableDescriptor("rule.id", "uuid", "Rule UUID", "11111111-1111-1111-1111-111111111111", List.of(), false),
|
||
new TemplateVariableDescriptor("rule.name", "string", "Rule display name", "Order API error rate", List.of(), false),
|
||
new TemplateVariableDescriptor("rule.severity", "string", "Rule severity", "CRITICAL", List.of(), false),
|
||
new TemplateVariableDescriptor("rule.description", "string", "Rule description", "Paging ops if error rate >5%", List.of(), false),
|
||
new TemplateVariableDescriptor("alert.id", "uuid", "Alert instance UUID", "22222222-2222-2222-2222-222222222222", List.of(), false),
|
||
new TemplateVariableDescriptor("alert.state", "string", "Alert state", "FIRING", List.of(), false),
|
||
new TemplateVariableDescriptor("alert.firedAt", "Instant", "When the alert fired", "2026-04-20T14:33:10Z", List.of(), false),
|
||
new TemplateVariableDescriptor("alert.resolvedAt", "Instant", "When the alert resolved", "2026-04-20T14:45:00Z", List.of(), true),
|
||
new TemplateVariableDescriptor("alert.ackedBy", "string", "User who acknowledged the alert", "alice", List.of(), true),
|
||
new TemplateVariableDescriptor("alert.link", "url", "UI link to this alert", "https://cameleer.example.com/alerts/inbox/2222...", List.of(), false),
|
||
new TemplateVariableDescriptor("alert.currentValue", "number", "Observed metric value", "0.12", List.of(), true),
|
||
new TemplateVariableDescriptor("alert.threshold", "number", "Rule threshold", "0.05", List.of(), true),
|
||
|
||
// --- app subtree — all condition kinds ---
|
||
new TemplateVariableDescriptor("app.slug", "string", "App slug", "orders",
|
||
List.of(ConditionKind.ROUTE_METRIC, ConditionKind.EXCHANGE_MATCH, ConditionKind.AGENT_STATE,
|
||
ConditionKind.DEPLOYMENT_STATE, ConditionKind.LOG_PATTERN, ConditionKind.JVM_METRIC), true),
|
||
new TemplateVariableDescriptor("app.id", "uuid", "App UUID", "33333333-3333-3333-3333-333333333333",
|
||
List.of(ConditionKind.ROUTE_METRIC, ConditionKind.EXCHANGE_MATCH, ConditionKind.AGENT_STATE,
|
||
ConditionKind.DEPLOYMENT_STATE, ConditionKind.LOG_PATTERN, ConditionKind.JVM_METRIC), true),
|
||
|
||
// --- ROUTE_METRIC + EXCHANGE_MATCH ---
|
||
new TemplateVariableDescriptor("route.id", "string", "Route ID", "route-1",
|
||
List.of(ConditionKind.ROUTE_METRIC, ConditionKind.EXCHANGE_MATCH), false),
|
||
new TemplateVariableDescriptor("route.uri", "string", "Route URI", "direct:orders",
|
||
List.of(ConditionKind.ROUTE_METRIC, ConditionKind.EXCHANGE_MATCH), false),
|
||
|
||
// --- EXCHANGE_MATCH ---
|
||
new TemplateVariableDescriptor("exchange.id", "string", "Exchange ID", "exch-ab12",
|
||
List.of(ConditionKind.EXCHANGE_MATCH), false),
|
||
new TemplateVariableDescriptor("exchange.status", "string", "Exchange status", "FAILED",
|
||
List.of(ConditionKind.EXCHANGE_MATCH), false),
|
||
|
||
// --- AGENT_STATE + JVM_METRIC share agent.id/name ---
|
||
new TemplateVariableDescriptor("agent.id", "string", "Agent instance ID", "prod-orders-0",
|
||
List.of(ConditionKind.AGENT_STATE, ConditionKind.JVM_METRIC), false),
|
||
new TemplateVariableDescriptor("agent.name", "string", "Agent display name", "orders-0",
|
||
List.of(ConditionKind.AGENT_STATE, ConditionKind.JVM_METRIC), false),
|
||
// AGENT_STATE adds agent.state
|
||
new TemplateVariableDescriptor("agent.state", "string", "Agent state", "DEAD",
|
||
List.of(ConditionKind.AGENT_STATE), false),
|
||
|
||
// --- DEPLOYMENT_STATE ---
|
||
new TemplateVariableDescriptor("deployment.id", "uuid", "Deployment UUID", "44444444-4444-4444-4444-444444444444",
|
||
List.of(ConditionKind.DEPLOYMENT_STATE), false),
|
||
new TemplateVariableDescriptor("deployment.status", "string", "Deployment status", "FAILED",
|
||
List.of(ConditionKind.DEPLOYMENT_STATE), false),
|
||
|
||
// --- LOG_PATTERN ---
|
||
new TemplateVariableDescriptor("log.pattern", "string", "Matched log pattern", "TimeoutException",
|
||
List.of(ConditionKind.LOG_PATTERN), false),
|
||
new TemplateVariableDescriptor("log.matchCount", "number", "Number of matches in window", "7",
|
||
List.of(ConditionKind.LOG_PATTERN), false),
|
||
|
||
// --- JVM_METRIC ---
|
||
new TemplateVariableDescriptor("metric.name", "string", "Metric name", "jvm.memory.used.value",
|
||
List.of(ConditionKind.JVM_METRIC), false),
|
||
new TemplateVariableDescriptor("metric.value", "number", "Metric value", "92.1",
|
||
List.of(ConditionKind.JVM_METRIC), false)
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 9.3: Compile**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-core compile
|
||
```
|
||
|
||
Expected: `BUILD SUCCESS`.
|
||
|
||
- [ ] **Step 9.4: Commit (Task 9 lands standalone — registry without consumer yet)**
|
||
|
||
Defer the commit until Task 10 lands the consumer — committing the registry without a use site leaves the repo in a weird "declared but unused" state. Continue to Task 10.
|
||
|
||
---
|
||
|
||
## Task 10: Refactor `NotificationContextBuilder` to consume registry + round-trip test
|
||
|
||
**Why:** Structural guarantee that the registry and the builder agree. Prevents the drift bug that `18e6dde6` fixed.
|
||
|
||
**Files:**
|
||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilder.java`
|
||
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderRegistryTest.java`
|
||
|
||
- [ ] **Step 10.1: Impact-check**
|
||
|
||
Run: `gitnexus_impact({target: "NotificationContextBuilder", direction: "upstream"})`
|
||
|
||
Expected: direct callers = `MustacheRenderer`, `WebhookDispatcher`, test fixtures. Refactor preserves the public signature `build(rule, instance, env, uiOrigin)`, so no caller changes.
|
||
|
||
- [ ] **Step 10.2: Write the failing round-trip test first (TDD)**
|
||
|
||
Create `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderRegistryTest.java`:
|
||
|
||
```java
|
||
package com.cameleer.server.app.alerting.notify;
|
||
|
||
import com.cameleer.server.core.alerting.AlertCondition;
|
||
import com.cameleer.server.core.alerting.AlertInstance;
|
||
import com.cameleer.server.core.alerting.AlertRule;
|
||
import com.cameleer.server.core.alerting.AlertScope;
|
||
import com.cameleer.server.core.alerting.AlertSeverity;
|
||
import com.cameleer.server.core.alerting.AlertState;
|
||
import com.cameleer.server.core.alerting.AlertTemplateVariables;
|
||
import com.cameleer.server.core.alerting.ConditionKind;
|
||
import com.cameleer.server.core.alerting.RouteMetricCondition;
|
||
import com.cameleer.server.core.alerting.TemplateVariableDescriptor;
|
||
import com.cameleer.server.core.runtime.Environment;
|
||
import org.junit.jupiter.api.DisplayName;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import java.time.Instant;
|
||
import java.util.HashMap;
|
||
import java.util.LinkedHashMap;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
import java.util.Set;
|
||
import java.util.UUID;
|
||
import java.util.stream.Collectors;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
/**
|
||
* Structural round-trip between {@link AlertTemplateVariables#ALL} and
|
||
* {@link NotificationContextBuilder#build}:
|
||
*
|
||
* (a) every variable listed in ALL whose availableForKinds is empty
|
||
* (=always) must be present in the built context for every kind;
|
||
* (b) every variable listed for a specific kind must be present in the
|
||
* built context when building for that kind;
|
||
* (c) no variable may appear in the built context that is not listed
|
||
* in ALL — catches builder-added-a-leaf-without-registering drift.
|
||
*/
|
||
class NotificationContextBuilderRegistryTest {
|
||
|
||
private final NotificationContextBuilder builder = new NotificationContextBuilder();
|
||
|
||
@Test
|
||
@DisplayName("every always-available variable is present for every condition kind")
|
||
void alwaysVariablesPresentForAllKinds() {
|
||
List<String> alwaysPaths = AlertTemplateVariables.ALL.stream()
|
||
.filter(v -> v.availableForKinds().isEmpty())
|
||
.map(TemplateVariableDescriptor::path)
|
||
.toList();
|
||
|
||
for (ConditionKind kind : ConditionKind.values()) {
|
||
Map<String, Object> ctx = builder.build(ruleFor(kind), instanceFor(kind), env(), "https://ui.test");
|
||
Map<String, Object> flattened = flatten(ctx);
|
||
for (String path : alwaysPaths) {
|
||
assertThat(flattened.keySet())
|
||
.as("always-available variable %s missing from %s context", path, kind)
|
||
.contains(path);
|
||
}
|
||
}
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("kind-specific variables are present for their kind")
|
||
void kindSpecificVariablesPresent() {
|
||
for (ConditionKind kind : ConditionKind.values()) {
|
||
List<String> expectedPaths = AlertTemplateVariables.ALL.stream()
|
||
.filter(v -> v.availableForKinds().contains(kind))
|
||
.map(TemplateVariableDescriptor::path)
|
||
.toList();
|
||
|
||
Map<String, Object> ctx = builder.build(ruleFor(kind), instanceFor(kind), env(), "https://ui.test");
|
||
Map<String, Object> flattened = flatten(ctx);
|
||
for (String path : expectedPaths) {
|
||
assertThat(flattened.keySet())
|
||
.as("variable %s missing from %s context", path, kind)
|
||
.contains(path);
|
||
}
|
||
}
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("no leaf appears in the built context unless listed in the registry")
|
||
void noExtraLeaves() {
|
||
Set<String> registered = AlertTemplateVariables.ALL.stream()
|
||
.map(TemplateVariableDescriptor::path)
|
||
.collect(Collectors.toSet());
|
||
|
||
for (ConditionKind kind : ConditionKind.values()) {
|
||
Map<String, Object> ctx = builder.build(ruleFor(kind), instanceFor(kind), env(), "https://ui.test");
|
||
Set<String> flattened = flatten(ctx).keySet();
|
||
Set<String> extras = flattened.stream()
|
||
.filter(p -> !registered.contains(p))
|
||
.collect(Collectors.toSet());
|
||
assertThat(extras)
|
||
.as("kind=%s built context has leaves missing from AlertTemplateVariables.ALL", kind)
|
||
.isEmpty();
|
||
}
|
||
}
|
||
|
||
// ── fixtures ────────────────────────────────────────────────────────
|
||
|
||
private Environment env() {
|
||
return new Environment(UUID.fromString("00000000-0000-0000-0000-000000000001"), "prod", 10);
|
||
}
|
||
|
||
private AlertRule ruleFor(ConditionKind kind) {
|
||
AlertCondition condition = new RouteMetricCondition(
|
||
new AlertScope("orders", "route-1", null),
|
||
com.cameleer.server.core.alerting.RouteMetric.ERROR_RATE,
|
||
com.cameleer.server.core.alerting.Comparator.GT,
|
||
0.05,
|
||
60
|
||
);
|
||
return new AlertRule(
|
||
UUID.fromString("11111111-1111-1111-1111-111111111111"),
|
||
UUID.fromString("00000000-0000-0000-0000-000000000001"),
|
||
"test rule",
|
||
"desc",
|
||
AlertSeverity.CRITICAL,
|
||
true,
|
||
kind,
|
||
condition,
|
||
60, 0, 0, "", "",
|
||
List.of(), List.of(),
|
||
Instant.now(), "admin",
|
||
Instant.now(), "admin",
|
||
Map.of()
|
||
);
|
||
}
|
||
|
||
private AlertInstance instanceFor(ConditionKind kind) {
|
||
Map<String, Object> ctx = new LinkedHashMap<>();
|
||
ctx.put("app", Map.of("slug", "orders", "id", "33333333-3333-3333-3333-333333333333"));
|
||
ctx.put("route", Map.of("id", "route-1", "uri", "direct:orders"));
|
||
ctx.put("exchange", Map.of("id", "exch-ab12", "status", "FAILED"));
|
||
ctx.put("agent", Map.of("id", "prod-0", "name", "orders-0", "state", "DEAD"));
|
||
ctx.put("deployment", Map.of("id", "44444444-4444-4444-4444-444444444444", "status", "FAILED"));
|
||
ctx.put("log", Map.of("pattern", "TimeoutException", "matchCount", "7"));
|
||
ctx.put("metric", Map.of("name", "jvm.memory.used.value", "value", "92.1"));
|
||
|
||
return new AlertInstance(
|
||
UUID.fromString("22222222-2222-2222-2222-222222222222"),
|
||
UUID.fromString("11111111-1111-1111-1111-111111111111"),
|
||
UUID.fromString("00000000-0000-0000-0000-000000000001"),
|
||
"fp",
|
||
AlertState.FIRING,
|
||
Instant.now(), null, null, "admin", null,
|
||
0.12, 0.05,
|
||
ctx,
|
||
Map.of()
|
||
);
|
||
}
|
||
|
||
/** Flattens a nested Map into dot-separated leaf paths. */
|
||
@SuppressWarnings("unchecked")
|
||
private static Map<String, Object> flatten(Map<String, Object> src) {
|
||
Map<String, Object> out = new HashMap<>();
|
||
flattenInto("", src, out);
|
||
return out;
|
||
}
|
||
|
||
@SuppressWarnings("unchecked")
|
||
private static void flattenInto(String prefix, Map<String, Object> src, Map<String, Object> out) {
|
||
for (Map.Entry<String, Object> e : src.entrySet()) {
|
||
String key = prefix.isEmpty() ? e.getKey() : prefix + "." + e.getKey();
|
||
if (e.getValue() instanceof Map<?, ?> m) {
|
||
flattenInto(key, (Map<String, Object>) m, out);
|
||
} else {
|
||
out.put(key, e.getValue());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 10.3: Run — expect failure (no extra leaves pass, always/kind-specific likely pass too if builder is already aligned)**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-app test -Dtest=NotificationContextBuilderRegistryTest
|
||
```
|
||
|
||
If all three tests pass first-try: the registry in Step 9.2 already matches the builder. Skip Step 10.4. Otherwise, the failing test points to the exact drift.
|
||
|
||
- [ ] **Step 10.4: Align builder (or registry) until tests pass**
|
||
|
||
Read the test failure message. It names one of:
|
||
- **Missing from built context** → the builder is silent on a path the registry lists. Either add it to the builder or remove from the registry (if you added it speculatively).
|
||
- **Extra leaf in built context** → the builder emits a path the registry doesn't know about. Add it to the registry.
|
||
|
||
Iterate in the file whose contents match reality. Keep both in sync.
|
||
|
||
- [ ] **Step 10.5: Run all alerting unit tests**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-app test -Dtest='com.cameleer.server.app.alerting.*'
|
||
```
|
||
|
||
Expected: all green.
|
||
|
||
- [ ] **Step 10.6: Commit (Tasks 9 + 10 together)**
|
||
|
||
```bash
|
||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/TemplateVariableDescriptor.java
|
||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertTemplateVariables.java
|
||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilder.java
|
||
git add cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderRegistryTest.java
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(alerting): AlertTemplateVariables registry + builder round-trip test
|
||
|
||
AlertTemplateVariables.ALL is the single source of truth for Mustache
|
||
variable metadata. NotificationContextBuilderRegistryTest enforces
|
||
three structural invariants:
|
||
|
||
1. every always-available variable is present for every kind
|
||
2. every kind-specific variable is present when building for that kind
|
||
3. the builder never emits a leaf the registry doesn't know about
|
||
|
||
Breaks the UI↔backend drift class that commit 18e6dde6 patched
|
||
one-shot. Future drift fails at mvn test time.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: `AlertTemplateVariablesController` + rules update
|
||
|
||
**Why:** Expose the registry via REST so the UI can consume it. Flat endpoint (registry is tenant + env agnostic).
|
||
|
||
**Files:**
|
||
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/TemplateVariableDto.java`
|
||
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertTemplateVariablesController.java`
|
||
- Modify: `.claude/rules/app-classes.md` (add to flat allow-list)
|
||
|
||
- [ ] **Step 11.1: Create `TemplateVariableDto.java`**
|
||
|
||
```java
|
||
package com.cameleer.server.app.alerting.notify;
|
||
|
||
import com.cameleer.server.core.alerting.ConditionKind;
|
||
|
||
import java.util.List;
|
||
|
||
/**
|
||
* REST-facing DTO for /api/v1/alerts/template-variables. Mirrors
|
||
* {@code TemplateVariableDescriptor} 1:1 but kept as a distinct type so the
|
||
* public API shape is not coupled to the core record if the latter evolves.
|
||
*/
|
||
public record TemplateVariableDto(
|
||
String path,
|
||
String type,
|
||
String description,
|
||
String example,
|
||
List<ConditionKind> availableForKinds,
|
||
boolean mayBeNull
|
||
) {}
|
||
```
|
||
|
||
- [ ] **Step 11.2: Create `AlertTemplateVariablesController.java`**
|
||
|
||
```java
|
||
package com.cameleer.server.app.alerting.controller;
|
||
|
||
import com.cameleer.server.app.alerting.notify.TemplateVariableDto;
|
||
import com.cameleer.server.core.alerting.AlertTemplateVariables;
|
||
import io.swagger.v3.oas.annotations.Operation;
|
||
import org.springframework.http.CacheControl;
|
||
import org.springframework.http.ResponseEntity;
|
||
import org.springframework.security.access.prepost.PreAuthorize;
|
||
import org.springframework.web.bind.annotation.GetMapping;
|
||
import org.springframework.web.bind.annotation.RequestMapping;
|
||
import org.springframework.web.bind.annotation.RestController;
|
||
|
||
import java.time.Duration;
|
||
import java.util.List;
|
||
|
||
/**
|
||
* Exposes {@link AlertTemplateVariables#ALL} as a REST endpoint so the UI
|
||
* MustacheEditor can source its variable registry from the server (single
|
||
* source of truth with {@code NotificationContextBuilder}).
|
||
*
|
||
* Flat — the registry is tenant- and env-agnostic global metadata.
|
||
*/
|
||
@RestController
|
||
@RequestMapping("/api/v1/alerts/template-variables")
|
||
public class AlertTemplateVariablesController {
|
||
|
||
@Operation(summary = "List Mustache template variables available in alert rules")
|
||
@PreAuthorize("hasAnyRole('VIEWER','OPERATOR','ADMIN')")
|
||
@GetMapping
|
||
public ResponseEntity<List<TemplateVariableDto>> list() {
|
||
List<TemplateVariableDto> body = AlertTemplateVariables.ALL.stream()
|
||
.map(v -> new TemplateVariableDto(
|
||
v.path(), v.type(), v.description(), v.example(),
|
||
v.availableForKinds(), v.mayBeNull()))
|
||
.toList();
|
||
return ResponseEntity.ok()
|
||
.cacheControl(CacheControl.maxAge(Duration.ofHours(1)).cachePublic())
|
||
.body(body);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 11.3: Update `.claude/rules/app-classes.md`**
|
||
|
||
Find the "Flat-endpoint allow-list" table and add a row (keep alphabetical):
|
||
|
||
```markdown
|
||
| `/api/v1/alerts/template-variables` | Registry of Mustache variables is tenant + env-agnostic global metadata. |
|
||
```
|
||
|
||
Also add a bullet under the "Alerting" section of controllers (find it under the env-scoped section):
|
||
|
||
```markdown
|
||
- `AlertTemplateVariablesController` — GET `/api/v1/alerts/template-variables` (flat). VIEWER+. Returns `List<TemplateVariableDto>` mirroring `AlertTemplateVariables.ALL`. Response cached 1h via `Cache-Control: public, max-age=3600` — registry is deterministic per server version.
|
||
```
|
||
|
||
- [ ] **Step 11.4: Compile**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-app compile
|
||
```
|
||
|
||
Expected: `BUILD SUCCESS`.
|
||
|
||
- [ ] **Step 11.5: Write a controller integration test**
|
||
|
||
Create `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertTemplateVariablesControllerIT.java`:
|
||
|
||
```java
|
||
package com.cameleer.server.app.alerting.controller;
|
||
|
||
import com.cameleer.server.app.AbstractPostgresIT;
|
||
import com.cameleer.server.app.TestSecurityHelper;
|
||
import com.fasterxml.jackson.databind.JsonNode;
|
||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||
import org.junit.jupiter.api.Test;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.beans.factory.annotation.Value;
|
||
import org.springframework.http.HttpEntity;
|
||
import org.springframework.http.HttpMethod;
|
||
import org.springframework.http.ResponseEntity;
|
||
import org.springframework.web.client.RestTemplate;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class AlertTemplateVariablesControllerIT extends AbstractPostgresIT {
|
||
|
||
@Autowired TestSecurityHelper security;
|
||
@Autowired ObjectMapper objectMapper;
|
||
@Value("${local.server.port}") int port;
|
||
|
||
@Test
|
||
void viewerCanListTemplateVariables() throws Exception {
|
||
HttpEntity<Void> auth = new HttpEntity<>(security.bearer("alice", "VIEWER"));
|
||
ResponseEntity<String> resp = new RestTemplate().exchange(
|
||
"http://localhost:" + port + "/api/v1/alerts/template-variables",
|
||
HttpMethod.GET, auth, String.class);
|
||
|
||
assertThat(resp.getStatusCode().is2xxSuccessful()).isTrue();
|
||
assertThat(resp.getHeaders().getCacheControl()).contains("max-age=3600");
|
||
|
||
JsonNode body = objectMapper.readTree(resp.getBody());
|
||
assertThat(body.isArray()).isTrue();
|
||
assertThat(body.size()).isGreaterThan(20); // 26+ variables in the registry
|
||
|
||
// Spot-check a known always-available variable.
|
||
boolean hasRuleName = false;
|
||
for (JsonNode v : body) {
|
||
if ("rule.name".equals(v.get("path").asText())) {
|
||
hasRuleName = true;
|
||
assertThat(v.get("type").asText()).isEqualTo("string");
|
||
assertThat(v.get("availableForKinds").isArray()).isTrue();
|
||
assertThat(v.get("availableForKinds").size()).isEqualTo(0);
|
||
break;
|
||
}
|
||
}
|
||
assertThat(hasRuleName).isTrue();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 11.6: Run IT**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-app verify -Dit.test=AlertTemplateVariablesControllerIT
|
||
```
|
||
|
||
Expected: green.
|
||
|
||
**If `TestSecurityHelper.bearer(String, String)` doesn't exist with that signature:** read `cameleer-server-app/src/test/java/com/cameleer/server/app/TestSecurityHelper.java` and adapt the call to whatever factory method issues a bearer token for a given role. Existing controller ITs use the same helper — copy their pattern.
|
||
|
||
- [ ] **Step 11.7: Regenerate schema**
|
||
|
||
```bash
|
||
mvn -pl cameleer-server-app spring-boot:run &
|
||
SERVER_PID=$!
|
||
until curl -fsS http://localhost:8081/api/v1/health >/dev/null 2>&1; do sleep 2; done
|
||
cd ui && npm run generate-api:live
|
||
cd ..
|
||
kill $SERVER_PID
|
||
grep -A2 "/api/v1/alerts/template-variables" ui/src/api/openapi.json | head -10
|
||
```
|
||
|
||
Expected: endpoint listed, returns `List<TemplateVariableDto>`.
|
||
|
||
- [ ] **Step 11.8: Commit**
|
||
|
||
```bash
|
||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/TemplateVariableDto.java
|
||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertTemplateVariablesController.java
|
||
git add cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertTemplateVariablesControllerIT.java
|
||
git add .claude/rules/app-classes.md
|
||
git add ui/src/api/openapi.json ui/src/api/schema.d.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(alerting): template-variables SSOT endpoint
|
||
|
||
GET /api/v1/alerts/template-variables returns AlertTemplateVariables.ALL
|
||
as the single source of truth the UI consumes (replaces the hand-
|
||
maintained ui/src/components/MustacheEditor/alert-variables.ts in a
|
||
follow-up commit).
|
||
|
||
Flat endpoint — registry is tenant + env-agnostic global metadata.
|
||
VIEWER+. 1-hour Cache-Control because the registry is deterministic
|
||
per server version.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: UI — consume template-variables, delete the duplicate
|
||
|
||
**Why:** Wire the UI to the SSOT endpoint. Remove the hand-maintained `alert-variables.ts`.
|
||
|
||
**Files:**
|
||
- Create: `ui/src/api/queries/alertTemplateVariables.ts`
|
||
- Modify: `ui/src/components/MustacheEditor/MustacheEditor.tsx`
|
||
- Modify: `ui/src/components/MustacheEditor/mustache-completion.ts`
|
||
- Modify: `ui/src/components/MustacheEditor/mustache-linter.ts`
|
||
- Modify: `ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx`
|
||
- Modify: any other consumer of `ALERT_VARIABLES` (grep to find; expect at least webhook body editor + connection editor if one exists)
|
||
- Delete: `ui/src/components/MustacheEditor/alert-variables.ts`
|
||
|
||
- [ ] **Step 12.1: Find every consumer of the old registry**
|
||
|
||
```bash
|
||
grep -rn "alert-variables\|ALERT_VARIABLES\|availableVariables\|extractReferences\|unknownReferences" ui/src --include="*.ts" --include="*.tsx"
|
||
```
|
||
|
||
Record every file that appears. Each is a call site to update.
|
||
|
||
- [ ] **Step 12.2: Create `useTemplateVariables` hook**
|
||
|
||
Create `ui/src/api/queries/alertTemplateVariables.ts`:
|
||
|
||
```typescript
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { apiClient } from '../client';
|
||
import type { components } from '../schema';
|
||
|
||
export type TemplateVariable = components['schemas']['TemplateVariableDto'];
|
||
|
||
/**
|
||
* Mustache template variables available in alert rules — fetched from
|
||
* {@code GET /api/v1/alerts/template-variables}. Cached for the session
|
||
* (staleTime: Infinity) because the registry is deterministic per
|
||
* server version and the SPA is freshly loaded on each deploy.
|
||
*/
|
||
export function useTemplateVariables() {
|
||
return useQuery({
|
||
queryKey: ['alert-template-variables'],
|
||
queryFn: async () => {
|
||
const { data, error } = await apiClient.GET('/api/v1/alerts/template-variables');
|
||
if (error) throw error;
|
||
return data ?? [];
|
||
},
|
||
staleTime: Infinity,
|
||
gcTime: Infinity,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Filter variables to those available for the given ConditionKind.
|
||
* If kind is undefined (e.g. OutboundConnection editor URL field),
|
||
* returns only always-available variables.
|
||
*/
|
||
export function availableVariables(
|
||
all: readonly TemplateVariable[],
|
||
kind: components['schemas']['AlertRuleRequest']['conditionKind'] | undefined,
|
||
opts: { reducedContext?: boolean } = {},
|
||
): TemplateVariable[] {
|
||
if (opts.reducedContext) {
|
||
return all.filter((v) => v.path.startsWith('env.'));
|
||
}
|
||
if (!kind) {
|
||
return all.filter((v) => (v.availableForKinds ?? []).length === 0);
|
||
}
|
||
return all.filter(
|
||
(v) => (v.availableForKinds ?? []).length === 0
|
||
|| (v.availableForKinds ?? []).includes(kind),
|
||
);
|
||
}
|
||
|
||
/** Parse `{{path}}` references from a Mustache template.
|
||
* Ignores `{{#section}}` / `{{/section}}` / `{{!comment}}`. */
|
||
export function extractReferences(template: string): string[] {
|
||
const out: string[] = [];
|
||
const re = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g;
|
||
let m;
|
||
while ((m = re.exec(template)) !== null) out.push(m[1]);
|
||
return out;
|
||
}
|
||
|
||
/** Find references in a template that are not in the allowed-variable set. */
|
||
export function unknownReferences(
|
||
template: string,
|
||
allowed: readonly TemplateVariable[],
|
||
): string[] {
|
||
const allowedSet = new Set(allowed.map((v) => v.path));
|
||
return extractReferences(template).filter((r) => !allowedSet.has(r));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 12.3: Update `MustacheEditor.tsx`**
|
||
|
||
Read the current file:
|
||
|
||
```bash
|
||
cat ui/src/components/MustacheEditor/MustacheEditor.tsx | head -40
|
||
```
|
||
|
||
Replace the import of `alert-variables` with a prop. The `MustacheEditor` interface goes from:
|
||
|
||
```typescript
|
||
// BEFORE
|
||
interface Props {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
conditionKind?: ConditionKind;
|
||
// ... other props
|
||
}
|
||
```
|
||
|
||
to:
|
||
|
||
```typescript
|
||
// AFTER
|
||
import type { TemplateVariable } from '../../api/queries/alertTemplateVariables';
|
||
interface Props {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
variables: readonly TemplateVariable[]; // <-- new required prop
|
||
// ... other props
|
||
}
|
||
```
|
||
|
||
Pass `variables` into the completion + linter extension factories (see Steps 12.4 and 12.5).
|
||
|
||
- [ ] **Step 12.4: Update `mustache-completion.ts`**
|
||
|
||
Change the module from exporting a completion source constant to exporting a factory that takes the variable list:
|
||
|
||
```typescript
|
||
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||
import type { TemplateVariable } from '../../api/queries/alertTemplateVariables';
|
||
|
||
export function mustacheCompletion(variables: readonly TemplateVariable[]) {
|
||
return (context: CompletionContext): CompletionResult | null => {
|
||
const word = context.matchBefore(/\{\{[a-zA-Z0-9_.]*/);
|
||
if (!word) return null;
|
||
return {
|
||
from: word.from + 2, // skip the `{{`
|
||
options: variables.map((v) => ({
|
||
label: v.path,
|
||
info: `${v.type} — ${v.description} (e.g. ${v.example})`,
|
||
type: 'variable',
|
||
})),
|
||
};
|
||
};
|
||
}
|
||
```
|
||
|
||
(Adapt to the exact existing shape; preserve any fuzzy-match or boost logic it already had.)
|
||
|
||
- [ ] **Step 12.5: Update `mustache-linter.ts`**
|
||
|
||
Same pattern — function of the variable list:
|
||
|
||
```typescript
|
||
import { Diagnostic, linter } from '@codemirror/lint';
|
||
import type { TemplateVariable } from '../../api/queries/alertTemplateVariables';
|
||
import { unknownReferences } from '../../api/queries/alertTemplateVariables';
|
||
|
||
export function mustacheLinter(variables: readonly TemplateVariable[]) {
|
||
return linter((view) => {
|
||
const diagnostics: Diagnostic[] = [];
|
||
const src = view.state.doc.toString();
|
||
const unknowns = unknownReferences(src, variables);
|
||
// Map each unknown reference to its position in the doc
|
||
const re = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g;
|
||
let m;
|
||
while ((m = re.exec(src)) !== null) {
|
||
if (unknowns.includes(m[1])) {
|
||
diagnostics.push({
|
||
from: m.index,
|
||
to: m.index + m[0].length,
|
||
severity: 'error',
|
||
message: `Unknown template variable: ${m[1]}`,
|
||
});
|
||
}
|
||
}
|
||
return diagnostics;
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 12.6: Update `NotifyStep.tsx`**
|
||
|
||
```tsx
|
||
import { useTemplateVariables, availableVariables } from '../../../api/queries/alertTemplateVariables';
|
||
|
||
// Inside the component:
|
||
const { data: allVariables = [] } = useTemplateVariables();
|
||
const visible = availableVariables(allVariables, form.conditionKind);
|
||
|
||
// Wherever <MustacheEditor> was rendered without a variables prop:
|
||
<MustacheEditor value={form.notificationTitleTmpl} onChange={setTitle} variables={visible} />
|
||
<MustacheEditor value={form.notificationMessageTmpl} onChange={setMessage} variables={visible} />
|
||
```
|
||
|
||
- [ ] **Step 12.7: Update every other consumer found in Step 12.1**
|
||
|
||
For each file, import `useTemplateVariables` + `availableVariables` and pass `variables` to `<MustacheEditor>`. If a consumer is in the outbound-connection admin editor (where no `conditionKind` exists), pass `availableVariables(allVariables, undefined, { reducedContext: true })`.
|
||
|
||
- [ ] **Step 12.8: Delete the duplicate**
|
||
|
||
```bash
|
||
rm ui/src/components/MustacheEditor/alert-variables.ts
|
||
```
|
||
|
||
Grep-verify:
|
||
|
||
```bash
|
||
grep -rn "alert-variables" ui/src --include="*.ts" --include="*.tsx"
|
||
```
|
||
|
||
Expected: no output. If anything remains, that file still imports the deleted module and must be fixed.
|
||
|
||
- [ ] **Step 12.9: Typecheck + tests**
|
||
|
||
```bash
|
||
cd ui && npm run typecheck
|
||
cd ui && npm run test
|
||
```
|
||
|
||
Expected: 0 errors, all unit tests green. Vitest tests that mocked `ALERT_VARIABLES` must be updated to mock `useTemplateVariables` instead — use `vi.mock('../../api/queries/alertTemplateVariables', ...)`.
|
||
|
||
- [ ] **Step 12.10: E2E sanity**
|
||
|
||
```bash
|
||
cd ui && npm run dev &
|
||
DEV_PID=$!
|
||
sleep 10
|
||
cd ui && npm run test:e2e -- alerting.spec.ts
|
||
kill $DEV_PID
|
||
```
|
||
|
||
Expected: the existing Plan 03 smoke still passes (it touches MustacheEditor indirectly via NotifyStep).
|
||
|
||
- [ ] **Step 12.11: Commit**
|
||
|
||
```bash
|
||
git add ui/src/api/queries/alertTemplateVariables.ts
|
||
git add ui/src/components/MustacheEditor/MustacheEditor.tsx
|
||
git add ui/src/components/MustacheEditor/mustache-completion.ts
|
||
git add ui/src/components/MustacheEditor/mustache-linter.ts
|
||
git add ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx
|
||
# + every other file from Step 12.7
|
||
git rm ui/src/components/MustacheEditor/alert-variables.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
refactor(ui/alerts): consume template-variables via API
|
||
|
||
useTemplateVariables() replaces the hand-maintained
|
||
ui/src/components/MustacheEditor/alert-variables.ts registry. The
|
||
backend is now the single source of truth — any new context leaf
|
||
surfaces in the UI on next SPA reload, no manual alignment.
|
||
|
||
MustacheEditor, mustache-completion, and mustache-linter are now
|
||
functions of the variables list instead of importing a global
|
||
module. Consumers pass in the filtered list from availableVariables().
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Second Playwright spec — editor, MustacheEditor, promotion, inbox
|
||
|
||
**Why:** Plan 03's smoke only covers CREATE + DELETE. This spec adds the coverage gaps from spec §5.
|
||
|
||
**Files:**
|
||
- Create: `ui/src/test/e2e/alerting-editor.spec.ts`
|
||
|
||
- [ ] **Step 13.1: Read the existing spec for patterns**
|
||
|
||
```bash
|
||
cat ui/src/test/e2e/alerting.spec.ts
|
||
```
|
||
|
||
Note the fixture shape (`./fixtures`), selectors used for the wizard, how the seeded auth works. Mirror these.
|
||
|
||
- [ ] **Step 13.2: Create `alerting-editor.spec.ts`**
|
||
|
||
```typescript
|
||
import { test, expect, APIRequestContext } from './fixtures';
|
||
|
||
/**
|
||
* Plan 04 alerting editor coverage.
|
||
*
|
||
* Complements alerting.spec.ts with the paths that wizard CREATE + DELETE
|
||
* don't exercise:
|
||
* - rule EDIT round-trip
|
||
* - MustacheEditor autocomplete + linter in-browser
|
||
* - env-promotion warnings banner
|
||
* - inbox ack
|
||
* - bulk-read
|
||
*
|
||
* End-to-end fire→notify dispatch is covered server-side by
|
||
* AlertingFullLifecycleIT; asserting it from the UI would require
|
||
* injecting executions into ClickHouse, which is out of scope here.
|
||
*/
|
||
|
||
async function seedRule(api: APIRequestContext, envSlug: string, overrides: Record<string, unknown> = {}) {
|
||
const body = {
|
||
name: `e2e edit ${Date.now()}`,
|
||
severity: 'WARNING',
|
||
conditionKind: 'ROUTE_METRIC',
|
||
condition: {
|
||
scope: { appSlug: 'demo-app', routeId: 'route-1' },
|
||
metric: 'ERROR_RATE',
|
||
comparator: 'GT',
|
||
threshold: 0.05,
|
||
windowSeconds: 60,
|
||
},
|
||
evaluationIntervalSeconds: 30,
|
||
forDurationSeconds: 0,
|
||
reNotifyMinutes: 0,
|
||
notificationTitleTmpl: 'Alert: {{rule.name}}',
|
||
notificationMessageTmpl: 'Threshold exceeded',
|
||
webhooks: [],
|
||
targets: [],
|
||
...overrides,
|
||
};
|
||
const r = await api.post(`/api/v1/environments/${envSlug}/alerts/rules`, { data: body });
|
||
expect(r.ok()).toBeTruthy();
|
||
return await r.json();
|
||
}
|
||
|
||
async function seedFiringAlert(api: APIRequestContext, envSlug: string, ruleId: string) {
|
||
// The alerting subsystem manufactures alerts from evaluator ticks; for
|
||
// inbox tests we poke the test-evaluate endpoint that Plan 02 shipped,
|
||
// which synthesises an instance without waiting for the scheduler.
|
||
const r = await api.post(`/api/v1/environments/${envSlug}/alerts/rules/${ruleId}/test-evaluate`, {
|
||
data: { synthesize: true },
|
||
});
|
||
expect(r.ok()).toBeTruthy();
|
||
return await r.json();
|
||
}
|
||
|
||
test.describe('alerting editor', () => {
|
||
test('rule EDIT round-trip persists changes', async ({ page, api, envSlug }) => {
|
||
const rule = await seedRule(api, envSlug);
|
||
await page.goto(`/alerts/rules/${rule.id}`);
|
||
|
||
// Step to condition — change threshold
|
||
await page.getByRole('button', { name: /^condition$/i }).click();
|
||
const thresholdInput = page.getByLabel(/threshold/i);
|
||
await thresholdInput.fill('0.15');
|
||
|
||
await page.getByRole('button', { name: /^save$/i }).click();
|
||
await expect(page.getByText(/rule saved/i)).toBeVisible({ timeout: 5_000 });
|
||
|
||
// Reload — assert persisted
|
||
await page.reload();
|
||
await page.getByRole('button', { name: /^condition$/i }).click();
|
||
await expect(page.getByLabel(/threshold/i)).toHaveValue('0.15');
|
||
});
|
||
|
||
test('MustacheEditor autocomplete inserts a registry variable', async ({ page, api, envSlug }) => {
|
||
await page.goto('/alerts/rules/new');
|
||
await page.getByPlaceholder('Order API error rate').fill('autocomplete test');
|
||
await page.getByRole('button', { name: /^next$/i }).click(); // scope → condition
|
||
await page.getByRole('button', { name: /^next$/i }).click(); // condition → trigger
|
||
await page.getByRole('button', { name: /^next$/i }).click(); // trigger → notify
|
||
|
||
const titleField = page.getByLabel(/notification title/i);
|
||
await titleField.click();
|
||
await titleField.fill('');
|
||
await titleField.type('{{');
|
||
|
||
// CodeMirror renders the completion popover as a role=listbox.
|
||
const popover = page.locator('.cm-tooltip-autocomplete');
|
||
await expect(popover).toBeVisible({ timeout: 2_000 });
|
||
await expect(popover).toContainText('rule.name');
|
||
|
||
// Select the first option.
|
||
await page.keyboard.press('Enter');
|
||
await expect(titleField).toHaveValue('{{rule.name}}');
|
||
});
|
||
|
||
test('MustacheEditor linter flags unknown variables', async ({ page }) => {
|
||
await page.goto('/alerts/rules/new');
|
||
await page.getByPlaceholder('Order API error rate').fill('linter test');
|
||
for (let i = 0; i < 3; i++) await page.getByRole('button', { name: /^next$/i }).click();
|
||
|
||
const titleField = page.getByLabel(/notification title/i);
|
||
await titleField.click();
|
||
await titleField.fill('{{alert.bogus_variable}}');
|
||
|
||
// Linter diagnostics render as .cm-diagnostic elements with aria role=alert.
|
||
const diagnostic = page.locator('.cm-diagnostic-error');
|
||
await expect(diagnostic).toBeVisible({ timeout: 2_000 });
|
||
await expect(diagnostic).toContainText(/unknown template variable/i);
|
||
});
|
||
|
||
test('env-promotion warns about missing route', async ({ page, api, envSlug }) => {
|
||
// Seed rule in the current env referencing a route that doesn't exist
|
||
// in the target env.
|
||
const rule = await seedRule(api, envSlug, {
|
||
condition: {
|
||
scope: { appSlug: 'demo-app', routeId: 'route-does-not-exist-in-staging' },
|
||
metric: 'ERROR_RATE',
|
||
comparator: 'GT',
|
||
threshold: 0.05,
|
||
windowSeconds: 60,
|
||
},
|
||
});
|
||
|
||
await page.goto('/alerts/rules');
|
||
// Find the rule row and trigger promotion.
|
||
const row = page.getByText(rule.name).locator('xpath=ancestor::tr');
|
||
await row.getByRole('button', { name: /promote/i }).click();
|
||
await page.getByRole('menuitem', { name: /staging/i }).click();
|
||
|
||
// Wizard opens prefilled with a warning banner.
|
||
await expect(page.getByRole('alert').filter({ hasText: /route.*not.*found|not found in/i }))
|
||
.toBeVisible({ timeout: 5_000 });
|
||
});
|
||
|
||
test('inbox ack flips state chip', async ({ page, api, envSlug }) => {
|
||
const rule = await seedRule(api, envSlug);
|
||
const alert = await seedFiringAlert(api, envSlug, rule.id);
|
||
|
||
await page.goto('/alerts/inbox');
|
||
const row = page.getByTestId(`alert-row-${alert.id}`);
|
||
await expect(row).toBeVisible();
|
||
await expect(row.getByText('FIRING')).toBeVisible();
|
||
|
||
await row.getByRole('button', { name: /^ack/i }).click();
|
||
await expect(row.getByText('ACKNOWLEDGED')).toBeVisible({ timeout: 5_000 });
|
||
await expect(row.getByRole('button', { name: /^ack/i })).toBeDisabled();
|
||
});
|
||
|
||
test('bulk-read zeroes the notification-bell count', async ({ page, api, envSlug }) => {
|
||
const rule = await seedRule(api, envSlug);
|
||
for (let i = 0; i < 3; i++) await seedFiringAlert(api, envSlug, rule.id);
|
||
|
||
await page.goto('/alerts/inbox');
|
||
const bell = page.getByTestId('notification-bell');
|
||
await expect(bell).toContainText(/^[3-9]|\d{2,}/);
|
||
|
||
await page.getByRole('checkbox', { name: /^select all/i }).check();
|
||
await page.getByRole('button', { name: /mark selected as read/i }).click();
|
||
|
||
await expect(bell).not.toContainText(/^\d+$/, { timeout: 10_000 }); // unread count hidden or 0
|
||
});
|
||
});
|
||
```
|
||
|
||
**If any `getByTestId` selector doesn't match a rendered attribute** (e.g. `alert-row-<id>` or `notification-bell`): update the rendering component in a minimal follow-up — Plan 03 did not standardise these, so one or two `data-testid="..."` attribute adds are acceptable here. They're single-line edits and belong in this same commit.
|
||
|
||
- [ ] **Step 13.3: Check seed endpoint exists**
|
||
|
||
The seed helper uses `POST /rules/{id}/test-evaluate` (Plan 02). If `synthesize:true` isn't supported — read the controller:
|
||
|
||
```bash
|
||
grep -n "test-evaluate" cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertRuleController.java
|
||
```
|
||
|
||
If the existing endpoint doesn't synthesise alerts the test expects, skip inbox + bulk-read cases and note them in a follow-up. Ship the editor/autocomplete/linter/promotion cases alone — that's still a material coverage add.
|
||
|
||
- [ ] **Step 13.4: Run the spec**
|
||
|
||
```bash
|
||
cd ui && npm run test:e2e -- alerting-editor.spec.ts
|
||
```
|
||
|
||
Expected: all tests green. Watch for flakes — any test that fails on a timing assertion needs `waitForFunction` instead of `waitForTimeout` (use CodeMirror's own state queries).
|
||
|
||
- [ ] **Step 13.5: Run three consecutive times for stability**
|
||
|
||
```bash
|
||
for i in 1 2 3; do cd ui && npm run test:e2e -- alerting-editor.spec.ts || break; done
|
||
```
|
||
|
||
Expected: 3 consecutive green runs. If flaky, adjust the assertion to be event-driven, not timer-driven.
|
||
|
||
- [ ] **Step 13.6: Commit**
|
||
|
||
```bash
|
||
git add ui/src/test/e2e/alerting-editor.spec.ts
|
||
# + any data-testid attribute adds from Step 13.2
|
||
git commit -m "$(cat <<'EOF'
|
||
test(ui/alerts): Playwright editor spec
|
||
|
||
Covers the paths the Plan 03 smoke doesn't:
|
||
- rule EDIT round-trip
|
||
- MustacheEditor autocomplete + linter in-browser
|
||
- env-promotion warnings banner
|
||
- inbox ack
|
||
- bulk-read
|
||
|
||
Three consecutive green runs validated locally before merge.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Post-flight — Verification against spec acceptance criteria
|
||
|
||
Before requesting code review on the PR, self-verify every item from spec §10:
|
||
|
||
- [ ] **Step V.1: Regression protection for Plan 03 bug class**
|
||
|
||
Temporarily revert commit `5edf7eb2` locally:
|
||
|
||
```bash
|
||
git revert --no-commit 5edf7eb2
|
||
mvn -pl cameleer-server-app test -Dtest=ContextStartupSmokeTest
|
||
```
|
||
|
||
Expected: test FAILS.
|
||
|
||
```bash
|
||
git reset --hard HEAD
|
||
mvn -pl cameleer-server-app test -Dtest=ContextStartupSmokeTest
|
||
```
|
||
|
||
Expected: test passes again.
|
||
|
||
- [ ] **Step V.2: No hand-maintained variable registry remains**
|
||
|
||
```bash
|
||
grep -rn "ALERT_VARIABLES" ui/src --include="*.ts" --include="*.tsx"
|
||
```
|
||
|
||
Expected: no results.
|
||
|
||
```bash
|
||
test ! -f ui/src/components/MustacheEditor/alert-variables.ts && echo "File deleted" || echo "STILL PRESENT"
|
||
```
|
||
|
||
Expected: `File deleted`.
|
||
|
||
- [ ] **Step V.3: No hand-declared polymorphic unions**
|
||
|
||
```bash
|
||
grep -E "^export type (RouteMetric|Comparator|JvmAggregation|ExchangeFireMode) = '" ui/src/pages/Alerts/enums.ts
|
||
```
|
||
|
||
Expected: no results (all four are derived via `NonNullable<X['field']>` now).
|
||
|
||
- [ ] **Step V.4: All 5 condition fields typed in schema**
|
||
|
||
```bash
|
||
python -c "
|
||
import json
|
||
d = json.load(open('ui/src/api/openapi.json'))
|
||
s = d['components']['schemas']
|
||
checks = [
|
||
('AgentStateCondition', 'state', 'enum'),
|
||
('DeploymentStateCondition', 'states', 'array-of-enum'),
|
||
('LogPatternCondition', 'level', 'enum'),
|
||
('ExchangeFilter', 'status', 'enum'),
|
||
('JvmMetricCondition', 'metric', 'enum'),
|
||
]
|
||
for sch, field, kind in checks:
|
||
# Schemas are in allOf for subtypes; walk them.
|
||
obj = s[sch]
|
||
props = {}
|
||
if 'allOf' in obj:
|
||
for part in obj['allOf']:
|
||
if 'properties' in part:
|
||
props.update(part['properties'])
|
||
else:
|
||
props = obj.get('properties', {})
|
||
p = props.get(field, {})
|
||
if kind == 'enum':
|
||
has = 'enum' in p
|
||
elif kind == 'array-of-enum':
|
||
has = p.get('type') == 'array' and 'enum' in (p.get('items') or {})
|
||
else:
|
||
has = False
|
||
print(('OK' if has else 'MISS'), sch, field, kind)
|
||
"
|
||
```
|
||
|
||
Expected: five `OK` lines.
|
||
|
||
- [ ] **Step V.5: Playwright 3-consecutive-run stability**
|
||
|
||
```bash
|
||
for i in 1 2 3; do cd ui && npm run test:e2e || break; done
|
||
```
|
||
|
||
Expected: 3 green runs.
|
||
|
||
- [ ] **Step V.6: Rules docs updated**
|
||
|
||
```bash
|
||
grep -c "AlertTemplateVariablesController" .claude/rules/app-classes.md
|
||
grep -c "LogLevel" .claude/rules/core-classes.md
|
||
```
|
||
|
||
Expected: both print `1` or higher.
|
||
|
||
- [ ] **Step V.7: Full build + test suite**
|
||
|
||
```bash
|
||
mvn clean verify -pl cameleer-server-app -am
|
||
cd ui && npm run typecheck && npm run test && npm run test:e2e
|
||
```
|
||
|
||
Expected: everything green.
|
||
|
||
- [ ] **Step V.8: GitNexus re-analyze**
|
||
|
||
```bash
|
||
cd .worktrees/alerting-04 # or whichever worktree root
|
||
npx gitnexus analyze --embeddings
|
||
```
|
||
|
||
Expected: fresh index reflecting the new classes.
|
||
|
||
- [ ] **Step V.9: Open PR**
|
||
|
||
```bash
|
||
git push -u origin feat/alerting-04-hardening
|
||
gh pr create --title "feat(alerting): Plan 04 — post-ship hardening" --body "$(cat <<'EOF'
|
||
## Summary
|
||
|
||
Closes the loop on three bug classes from Plan 03 triage:
|
||
|
||
1. **Spring wiring regressions** — new `ContextStartupSmokeTest` runs at `mvn test` tier (no Testcontainers) and asserts every public alerting bean is present. Validated by reverting `5edf7eb2` — test fails loudly.
|
||
2. **UI↔backend drift on Mustache template variables** — `AlertTemplateVariables.ALL` is the single source of truth; `NotificationContextBuilderRegistryTest` enforces structural agreement with the builder; UI consumes via `GET /api/v1/alerts/template-variables`.
|
||
3. **Hand-maintained TS enum unions** — `@Schema(discriminatorProperty, discriminatorMapping)` on `AlertCondition` fixes springdoc output; 4 condition String fields become proper enums; 1 (`JvmMetricCondition.metric`) gets an `@Schema(allowableValues)` hint.
|
||
|
||
No new product features. Pure hardening.
|
||
|
||
Spec: `docs/superpowers/specs/2026-04-20-alerting-04-hardening-design.md`
|
||
Plan: `docs/superpowers/plans/2026-04-20-alerting-04-hardening.md`
|
||
|
||
## Test plan
|
||
|
||
- [x] `mvn clean verify` green
|
||
- [x] `cd ui && npm run typecheck && npm run test` green
|
||
- [x] `cd ui && npm run test:e2e` green (3 consecutive runs)
|
||
- [x] `ContextStartupSmokeTest` fails when `AlertingMetrics` loses `@Autowired` (validated locally)
|
||
- [x] `alert-variables.ts` deleted; grep shows no residual references
|
||
- [x] `.claude/rules/app-classes.md` + `core-classes.md` updated
|
||
|
||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
11 atomic commits covering 5 hardening tasks. Each commit reversible. Each task leaves the tree buildable and tests green. Total estimated effort: ~1-2 days of focused work.
|
||
|
||
**Acceptance gates enforced by this plan:**
|
||
|
||
1. `ContextStartupSmokeTest` catches `@Autowired` regressions at `mvn test` time.
|
||
2. `NotificationContextBuilderRegistryTest` catches UI/backend variable-list drift at `mvn test` time.
|
||
3. `alerting-editor.spec.ts` catches MustacheEditor + rule-EDIT + promotion + inbox regressions.
|
||
4. Springdoc + 5 enum migrations make `ui/src/pages/Alerts/enums.ts` trivial to maintain — all vocabulary derived from schema.
|
||
|
||
Plan complete.
|