Compare commits
9 Commits
e5c8fff0f9
...
58009d7c23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58009d7c23 | ||
|
|
b799d55835 | ||
|
|
166568edea | ||
|
|
f049a0a6a0 | ||
|
|
f8e382c217 | ||
|
|
c7e5c7fa2d | ||
|
|
0995ab35c4 | ||
|
|
480a53c80c | ||
|
|
d3ce5e861b |
@@ -64,7 +64,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
|
||||
- `AgentListController` — GET `/api/v1/environments/{envSlug}/agents` (registered agents with runtime metrics, filtered to env).
|
||||
- `AgentEventsController` — GET `/api/v1/environments/{envSlug}/agents/events` (lifecycle events; cursor-paginated, returns `{ data, nextCursor, hasMore }`; order `(timestamp DESC, insert_id DESC)`; cursor is base64url of `"{timestampIso}|{insert_id_uuid}"` — `insert_id` is a stable UUID column used as a same-millisecond tiebreak).
|
||||
- `AgentMetricsController` — GET `/api/v1/environments/{envSlug}/agents/{agentId}/metrics` (JVM/Camel metrics). Rejects cross-env agents (404) as defence-in-depth.
|
||||
- `DiagramRenderController` — GET `/api/v1/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram` (env-scoped lookup). Also GET `/api/v1/diagrams/{contentHash}/render` (flat — content hashes are globally unique).
|
||||
- `DiagramRenderController` — GET `/api/v1/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram` returns the most recent diagram for (app, env, route) via `DiagramStore.findLatestContentHashForAppRoute`. Registry-independent — routes whose publishing agents were removed still resolve. Also GET `/api/v1/diagrams/{contentHash}/render` (flat — content hashes are globally unique), the point-in-time path consumed by the exchange viewer via `ExecutionDetail.diagramContentHash`.
|
||||
- `AlertRuleController` — `/api/v1/environments/{envSlug}/alerts/rules`. GET list / POST create / GET `{id}` / PUT `{id}` / DELETE `{id}` / POST `{id}/enable` / POST `{id}/disable` / POST `{id}/render-preview` / POST `{id}/test-evaluate`. OPERATOR+ for mutations, VIEWER+ for reads. CRITICAL: attribute keys in `ExchangeMatchCondition.filter.attributes` are validated at rule-save time against `^[a-zA-Z0-9._-]+$` — they are later inlined into ClickHouse SQL. `AgentLifecycleCondition` is allowlist-only — the `AgentLifecycleEventType` enum (REGISTERED / RE_REGISTERED / DEREGISTERED / WENT_STALE / WENT_DEAD / RECOVERED) plus the record compact ctor (non-empty `eventTypes`, `withinSeconds ≥ 1`) do the validation; custom agent-emitted event types are tracked in backlog issue #145. Webhook validation: verifies `outboundConnectionId` exists and `isAllowedInEnvironment`. Null notification templates default to `""` (NOT NULL constraint). Audit: `ALERT_RULE_CHANGE`.
|
||||
- `AlertController` — `/api/v1/environments/{envSlug}/alerts`. GET list (inbox filtered by userId/groupIds/roleNames via `InAppInboxQuery`; optional multi-value `state`, `severity`, tri-state `acked`, tri-state `read` query params; soft-deleted rows always excluded) / GET `/unread-count` / GET `{id}` / POST `{id}/ack` / POST `{id}/read` / POST `/bulk-read` / POST `/bulk-ack` (VIEWER+) / DELETE `{id}` (OPERATOR+, soft-delete) / POST `/bulk-delete` (OPERATOR+) / POST `{id}/restore` (OPERATOR+, clears `deleted_at`). `requireLiveInstance` helper returns 404 on soft-deleted rows; `restore` explicitly fetches regardless of `deleted_at`. `BulkIdsRequest` is the shared body for bulk-read/ack/delete (`{ instanceIds }`). `AlertDto` includes `readAt`; `deletedAt` is intentionally NOT on the wire. Inbox SQL: `? = ANY(target_user_ids) OR target_group_ids && ? OR target_role_names && ?` — requires at least one matching target (no broadcast concept).
|
||||
- `AlertSilenceController` — `/api/v1/environments/{envSlug}/alerts/silences`. GET list / POST create / DELETE `{id}`. 422 if `endsAt <= startsAt`. OPERATOR+ for mutations, VIEWER+ for list. Audit: `ALERT_SILENCE_CHANGE`.
|
||||
|
||||
@@ -54,7 +54,7 @@ paths:
|
||||
|
||||
## storage/ — Storage abstractions
|
||||
|
||||
- `ExecutionStore`, `MetricsStore`, `MetricsQueryStore`, `StatsStore`, `DiagramStore`, `RouteCatalogStore`, `SearchIndex`, `LogIndex` — interfaces
|
||||
- `ExecutionStore`, `MetricsStore`, `MetricsQueryStore`, `StatsStore`, `DiagramStore`, `RouteCatalogStore`, `SearchIndex`, `LogIndex` — interfaces. `DiagramStore.findLatestContentHashForAppRoute(appId, routeId, env)` resolves the latest diagram by (app, env, route) without consulting the agent registry, so routes whose publishing agents were removed between app versions still resolve. `findContentHashForRoute(route, instance)` is retained for the ingestion path that stamps a per-execution `diagramContentHash` at ingest time (point-in-time link from `ExecutionDetail`/`ExecutionSummary`).
|
||||
- `RouteCatalogEntry` — record: applicationId, routeId, environment, firstSeen, lastSeen
|
||||
- `LogEntryResult` — log query result record
|
||||
- `model/` — `ExecutionDocument`, `MetricTimeSeries`, `MetricsSnapshot`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **cameleer-server** (9321 symbols, 24004 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **cameleer-server** (9548 symbols, 24461 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **cameleer-server** (9321 symbols, 24004 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **cameleer-server** (9548 symbols, 24461 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -196,7 +196,16 @@ public class CatalogController {
|
||||
}
|
||||
|
||||
Set<String> routeIds = routesByApp.getOrDefault(slug, Set.of());
|
||||
List<String> agentIds = agents.stream().map(AgentInfo::instanceId).toList();
|
||||
|
||||
// Resolve the env slug for this row early so fromUri can survive
|
||||
// cross-env queries (env==null) against managed apps.
|
||||
String rowEnvSlug = envSlug;
|
||||
if (app != null && rowEnvSlug.isEmpty()) {
|
||||
try {
|
||||
rowEnvSlug = envService.getById(app.environmentId()).slug();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
final String resolvedEnvSlug = rowEnvSlug;
|
||||
|
||||
// Routes
|
||||
List<RouteSummary> routeSummaries = routeIds.stream()
|
||||
@@ -204,7 +213,7 @@ public class CatalogController {
|
||||
String key = slug + "/" + routeId;
|
||||
long count = routeExchangeCounts.getOrDefault(key, 0L);
|
||||
Instant lastSeen = routeLastSeen.get(key);
|
||||
String fromUri = resolveFromEndpointUri(routeId, agentIds);
|
||||
String fromUri = resolveFromEndpointUri(slug, routeId, resolvedEnvSlug);
|
||||
String state = routeStateRegistry.getState(slug, routeId).name().toLowerCase();
|
||||
String routeState = "started".equals(state) ? null : state;
|
||||
return new RouteSummary(routeId, count, lastSeen, fromUri, routeState);
|
||||
@@ -258,15 +267,9 @@ public class CatalogController {
|
||||
String healthTooltip = buildHealthTooltip(app != null, deployStatus, agentHealth, agents.size());
|
||||
|
||||
String displayName = app != null ? app.displayName() : slug;
|
||||
String appEnvSlug = envSlug;
|
||||
if (app != null && appEnvSlug.isEmpty()) {
|
||||
try {
|
||||
appEnvSlug = envService.getById(app.environmentId()).slug();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
catalog.add(new CatalogApp(
|
||||
slug, displayName, app != null, appEnvSlug,
|
||||
slug, displayName, app != null, resolvedEnvSlug,
|
||||
health, healthTooltip, agents.size(), routeSummaries, agentSummaries,
|
||||
totalExchanges, deploymentSummary
|
||||
));
|
||||
@@ -275,8 +278,11 @@ public class CatalogController {
|
||||
return ResponseEntity.ok(catalog);
|
||||
}
|
||||
|
||||
private String resolveFromEndpointUri(String routeId, List<String> agentIds) {
|
||||
return diagramStore.findContentHashForRouteByAgents(routeId, agentIds)
|
||||
private String resolveFromEndpointUri(String applicationId, String routeId, String environment) {
|
||||
if (environment == null || environment.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return diagramStore.findLatestContentHashForAppRoute(applicationId, routeId, environment)
|
||||
.flatMap(diagramStore::findByContentHash)
|
||||
.map(RouteGraph::getRoot)
|
||||
.map(root -> root.getEndpointUri())
|
||||
|
||||
@@ -2,8 +2,6 @@ package com.cameleer.server.app.controller;
|
||||
|
||||
import com.cameleer.common.graph.RouteGraph;
|
||||
import com.cameleer.server.app.web.EnvPath;
|
||||
import com.cameleer.server.core.agent.AgentInfo;
|
||||
import com.cameleer.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer.server.core.diagram.DiagramLayout;
|
||||
import com.cameleer.server.core.diagram.DiagramRenderer;
|
||||
import com.cameleer.server.core.runtime.Environment;
|
||||
@@ -21,7 +19,6 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
@@ -42,14 +39,11 @@ public class DiagramRenderController {
|
||||
|
||||
private final DiagramStore diagramStore;
|
||||
private final DiagramRenderer diagramRenderer;
|
||||
private final AgentRegistryService registryService;
|
||||
|
||||
public DiagramRenderController(DiagramStore diagramStore,
|
||||
DiagramRenderer diagramRenderer,
|
||||
AgentRegistryService registryService) {
|
||||
DiagramRenderer diagramRenderer) {
|
||||
this.diagramStore = diagramStore;
|
||||
this.diagramRenderer = diagramRenderer;
|
||||
this.registryService = registryService;
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/diagrams/{contentHash}/render")
|
||||
@@ -90,8 +84,8 @@ public class DiagramRenderController {
|
||||
|
||||
@GetMapping("/api/v1/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram")
|
||||
@Operation(summary = "Find the latest diagram for this app's route in this environment",
|
||||
description = "Resolves agents in this env for this app, then looks up the latest diagram for the route "
|
||||
+ "they reported. Env scope prevents a dev route from returning a prod diagram.")
|
||||
description = "Returns the most recently stored diagram for (app, env, route). Independent of the "
|
||||
+ "agent registry, so routes removed from the current app version still resolve.")
|
||||
@ApiResponse(responseCode = "200", description = "Diagram layout returned")
|
||||
@ApiResponse(responseCode = "404", description = "No diagram found")
|
||||
public ResponseEntity<DiagramLayout> findByAppAndRoute(
|
||||
@@ -99,15 +93,7 @@ public class DiagramRenderController {
|
||||
@PathVariable String appSlug,
|
||||
@PathVariable String routeId,
|
||||
@RequestParam(defaultValue = "LR") String direction) {
|
||||
List<String> agentIds = registryService.findByApplicationAndEnvironment(appSlug, env.slug()).stream()
|
||||
.map(AgentInfo::instanceId)
|
||||
.toList();
|
||||
|
||||
if (agentIds.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
Optional<String> contentHash = diagramStore.findContentHashForRouteByAgents(routeId, agentIds);
|
||||
Optional<String> contentHash = diagramStore.findLatestContentHashForAppRoute(appSlug, routeId, env.slug());
|
||||
if (contentHash.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
@@ -132,13 +132,12 @@ public class RouteCatalogController {
|
||||
List<AgentInfo> agents = agentsByApp.getOrDefault(appId, List.of());
|
||||
|
||||
Set<String> routeIds = routesByApp.getOrDefault(appId, Set.of());
|
||||
List<String> agentIds = agents.stream().map(AgentInfo::instanceId).toList();
|
||||
List<RouteSummary> routeSummaries = routeIds.stream()
|
||||
.map(routeId -> {
|
||||
String key = appId + "/" + routeId;
|
||||
long count = routeExchangeCounts.getOrDefault(key, 0L);
|
||||
Instant lastSeen = routeLastSeen.get(key);
|
||||
String fromUri = resolveFromEndpointUri(routeId, agentIds);
|
||||
String fromUri = resolveFromEndpointUri(appId, routeId, envSlug);
|
||||
String state = routeStateRegistry.getState(appId, routeId).name().toLowerCase();
|
||||
String routeState = "started".equals(state) ? null : state;
|
||||
return new RouteSummary(routeId, count, lastSeen, fromUri, routeState);
|
||||
@@ -160,8 +159,8 @@ public class RouteCatalogController {
|
||||
return ResponseEntity.ok(catalog);
|
||||
}
|
||||
|
||||
private String resolveFromEndpointUri(String routeId, List<String> agentIds) {
|
||||
return diagramStore.findContentHashForRouteByAgents(routeId, agentIds)
|
||||
private String resolveFromEndpointUri(String applicationId, String routeId, String environment) {
|
||||
return diagramStore.findLatestContentHashForAppRoute(applicationId, routeId, environment)
|
||||
.flatMap(diagramStore::findByContentHash)
|
||||
.map(RouteGraph::getRoot)
|
||||
.map(root -> root.getEndpointUri())
|
||||
|
||||
@@ -16,8 +16,6 @@ import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
@@ -57,6 +55,12 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""";
|
||||
|
||||
private static final String SELECT_HASH_FOR_APP_ROUTE = """
|
||||
SELECT content_hash FROM route_diagrams
|
||||
WHERE tenant_id = ? AND application_id = ? AND environment = ? AND route_id = ?
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""";
|
||||
|
||||
private static final String SELECT_DEFINITIONS_FOR_APP = """
|
||||
SELECT DISTINCT route_id, definition FROM route_diagrams
|
||||
WHERE tenant_id = ? AND application_id = ? AND environment = ?
|
||||
@@ -68,6 +72,8 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
|
||||
// (routeId + "\0" + instanceId) → contentHash
|
||||
private final ConcurrentHashMap<String, String> hashCache = new ConcurrentHashMap<>();
|
||||
// (applicationId + "\0" + environment + "\0" + routeId) → most recent contentHash
|
||||
private final ConcurrentHashMap<String, String> appRouteHashCache = new ConcurrentHashMap<>();
|
||||
// contentHash → deserialized RouteGraph
|
||||
private final ConcurrentHashMap<String, RouteGraph> graphCache = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -92,12 +98,37 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to warm diagram hash cache — lookups will fall back to ClickHouse: {}", e.getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
jdbc.query(
|
||||
"SELECT application_id, environment, route_id, " +
|
||||
"argMax(content_hash, created_at) AS content_hash " +
|
||||
"FROM route_diagrams WHERE tenant_id = ? " +
|
||||
"GROUP BY application_id, environment, route_id",
|
||||
rs -> {
|
||||
String key = appRouteCacheKey(
|
||||
rs.getString("application_id"),
|
||||
rs.getString("environment"),
|
||||
rs.getString("route_id"));
|
||||
appRouteHashCache.put(key, rs.getString("content_hash"));
|
||||
},
|
||||
tenantId);
|
||||
log.info("Diagram app-route cache warmed: {} entries", appRouteHashCache.size());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to warm diagram app-route cache — lookups will fall back to ClickHouse: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static String cacheKey(String routeId, String instanceId) {
|
||||
return routeId + "\0" + instanceId;
|
||||
}
|
||||
|
||||
private static String appRouteCacheKey(String applicationId, String environment, String routeId) {
|
||||
return (applicationId != null ? applicationId : "") + "\0"
|
||||
+ (environment != null ? environment : "") + "\0"
|
||||
+ (routeId != null ? routeId : "");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void store(TaggedDiagram diagram) {
|
||||
try {
|
||||
@@ -122,6 +153,7 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
|
||||
// Update caches
|
||||
hashCache.put(cacheKey(routeId, agentId), contentHash);
|
||||
appRouteHashCache.put(appRouteCacheKey(applicationId, environment, routeId), contentHash);
|
||||
graphCache.put(contentHash, graph);
|
||||
|
||||
log.debug("Stored diagram for route={} agent={} with hash={}", routeId, agentId, contentHash);
|
||||
@@ -170,33 +202,29 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> findContentHashForRouteByAgents(String routeId, List<String> agentIds) {
|
||||
if (agentIds == null || agentIds.isEmpty()) {
|
||||
public Optional<String> findLatestContentHashForAppRoute(String applicationId,
|
||||
String routeId,
|
||||
String environment) {
|
||||
if (applicationId == null || applicationId.isBlank()
|
||||
|| routeId == null || routeId.isBlank()
|
||||
|| environment == null || environment.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// Try cache first — return first hit
|
||||
for (String agentId : agentIds) {
|
||||
String cached = hashCache.get(cacheKey(routeId, agentId));
|
||||
String key = appRouteCacheKey(applicationId, environment, routeId);
|
||||
String cached = appRouteHashCache.get(key);
|
||||
if (cached != null) {
|
||||
return Optional.of(cached);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to ClickHouse
|
||||
String placeholders = String.join(", ", Collections.nCopies(agentIds.size(), "?"));
|
||||
String sql = "SELECT content_hash FROM route_diagrams " +
|
||||
"WHERE tenant_id = ? AND route_id = ? AND instance_id IN (" + placeholders + ") " +
|
||||
"ORDER BY created_at DESC LIMIT 1";
|
||||
var params = new ArrayList<Object>();
|
||||
params.add(tenantId);
|
||||
params.add(routeId);
|
||||
params.addAll(agentIds);
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(sql, params.toArray());
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
||||
SELECT_HASH_FOR_APP_ROUTE, tenantId, applicationId, environment, routeId);
|
||||
if (rows.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of((String) rows.get(0).get("content_hash"));
|
||||
String hash = (String) rows.get(0).get("content_hash");
|
||||
appRouteHashCache.put(key, hash);
|
||||
return Optional.of(hash);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -166,6 +166,157 @@ class DiagramRenderControllerIT extends AbstractPostgresIT {
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByAppAndRoute_returnsLatestDiagram_noLiveAgentPrereq() {
|
||||
// The env-scoped /routes/{routeId}/diagram endpoint no longer depends
|
||||
// on the agent registry — routes whose publishing agents have been
|
||||
// removed must still resolve. The seed step stored a diagram for
|
||||
// route "render-test-route" under app "test-group" / env "default",
|
||||
// so the same lookup must succeed even though the registry-driven
|
||||
// "find agents for app" path used to be a hard 404 prerequisite.
|
||||
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);
|
||||
headers.set("Accept", "application/json");
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/environments/default/apps/test-group/routes/render-test-route/diagram",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(headers),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).contains("nodes");
|
||||
assertThat(response.getBody()).contains("edges");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByAppAndRoute_returns404ForUnknownRoute() {
|
||||
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);
|
||||
headers.set("Accept", "application/json");
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/environments/default/apps/test-group/routes/nonexistent-route/diagram",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(headers),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exchangeDiagramHash_pinsPointInTimeEvenAfterNewerVersion() throws Exception {
|
||||
// Point-in-time guarantee: an execution's stored diagramContentHash
|
||||
// must keep resolving to the route shape captured at execution time,
|
||||
// even after a newer diagram version for the same route is stored.
|
||||
// Content-hash addressing + never-delete of route_diagrams makes this
|
||||
// automatic — this test locks the invariant in.
|
||||
HttpHeaders viewerHeaders = securityHelper.authHeadersNoBody(viewerJwt);
|
||||
viewerHeaders.set("Accept", "application/json");
|
||||
|
||||
// Snapshot the pinned v1 render via the flat content-hash endpoint
|
||||
// BEFORE a newer version is stored, so the post-v2 fetch can compare
|
||||
// byte-for-byte.
|
||||
ResponseEntity<String> pinnedBefore = restTemplate.exchange(
|
||||
"/api/v1/diagrams/{hash}/render",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(viewerHeaders),
|
||||
String.class,
|
||||
contentHash);
|
||||
assertThat(pinnedBefore.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
// Also snapshot the by-route "latest" render for the same route.
|
||||
ResponseEntity<String> latestBefore = restTemplate.exchange(
|
||||
"/api/v1/environments/default/apps/test-group/routes/render-test-route/diagram",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(viewerHeaders),
|
||||
String.class);
|
||||
assertThat(latestBefore.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
// Store a materially different v2 for the same (app, env, route).
|
||||
// The renderer walks the `root` tree (not the legacy flat `nodes`
|
||||
// list that the seed payload uses), so v2 uses the tree shape and
|
||||
// will render non-empty output — letting us detect the version flip.
|
||||
String newerDiagramJson = """
|
||||
{
|
||||
"routeId": "render-test-route",
|
||||
"description": "v2 with extra step",
|
||||
"version": 2,
|
||||
"root": {
|
||||
"id": "n1",
|
||||
"type": "ENDPOINT",
|
||||
"label": "timer:tick-v2",
|
||||
"children": [
|
||||
{
|
||||
"id": "n2",
|
||||
"type": "BEAN",
|
||||
"label": "myBeanV2",
|
||||
"children": [
|
||||
{
|
||||
"id": "n3",
|
||||
"type": "TO",
|
||||
"label": "log:out-v2",
|
||||
"children": [
|
||||
{"id": "n4", "type": "TO", "label": "log:audit"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"edges": [
|
||||
{"source": "n1", "target": "n2", "edgeType": "FLOW"},
|
||||
{"source": "n2", "target": "n3", "edgeType": "FLOW"},
|
||||
{"source": "n3", "target": "n4", "edgeType": "FLOW"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
restTemplate.postForEntity(
|
||||
"/api/v1/data/diagrams",
|
||||
new HttpEntity<>(newerDiagramJson, securityHelper.authHeaders(jwt)),
|
||||
String.class);
|
||||
|
||||
// Invariant 1: The execution's stored diagramContentHash must not
|
||||
// drift — exchanges stay pinned to the version captured at ingest.
|
||||
ResponseEntity<String> detailAfter = restTemplate.exchange(
|
||||
"/api/v1/environments/default/executions?correlationId=render-probe-corr",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(viewerHeaders),
|
||||
String.class);
|
||||
JsonNode search = objectMapper.readTree(detailAfter.getBody());
|
||||
String execId = search.get("data").get(0).get("executionId").asText();
|
||||
ResponseEntity<String> exec = restTemplate.exchange(
|
||||
"/api/v1/executions/" + execId,
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(viewerHeaders),
|
||||
String.class);
|
||||
JsonNode execBody = objectMapper.readTree(exec.getBody());
|
||||
assertThat(execBody.path("diagramContentHash").asText()).isEqualTo(contentHash);
|
||||
|
||||
// Invariant 2: The pinned render (by H1) must be byte-identical
|
||||
// before and after v2 is stored — content-hash addressing is stable.
|
||||
ResponseEntity<String> pinnedAfter = restTemplate.exchange(
|
||||
"/api/v1/diagrams/{hash}/render",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(viewerHeaders),
|
||||
String.class,
|
||||
contentHash);
|
||||
assertThat(pinnedAfter.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(pinnedAfter.getBody()).isEqualTo(pinnedBefore.getBody());
|
||||
|
||||
// Invariant 3: The by-route "latest" endpoint must now surface v2,
|
||||
// so its body differs from the pre-v2 snapshot. Retry briefly to
|
||||
// absorb the diagram-ingest flush path.
|
||||
await().atMost(20, SECONDS).untilAsserted(() -> {
|
||||
ResponseEntity<String> latestAfter = restTemplate.exchange(
|
||||
"/api/v1/environments/default/apps/test-group/routes/render-test-route/diagram",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(viewerHeaders),
|
||||
String.class);
|
||||
assertThat(latestAfter.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(latestAfter.getBody()).isNotEqualTo(latestBefore.getBody());
|
||||
assertThat(latestAfter.getBody()).contains("myBeanV2");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWithNoAcceptHeader_defaultsToSvg() {
|
||||
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);
|
||||
|
||||
@@ -155,21 +155,51 @@ class ClickHouseDiagramStoreIT {
|
||||
}
|
||||
|
||||
@Test
|
||||
void findContentHashForRouteByAgents_returnsHash() {
|
||||
RouteGraph graph = buildGraph("route-4", "node-z");
|
||||
store.store(tagged("agent-10", "app-b", graph));
|
||||
store.store(tagged("agent-20", "app-b", graph));
|
||||
void findLatestContentHashForAppRoute_returnsLatestAcrossInstances() throws InterruptedException {
|
||||
// v1 published by one agent, v2 by a different agent. The app+env+route
|
||||
// resolver must pick v2 regardless of which instance produced it, and
|
||||
// must keep working even if neither instance is "live" anywhere.
|
||||
RouteGraph v1 = buildGraph("evolving-route", "n-a");
|
||||
v1.setDescription("v1");
|
||||
RouteGraph v2 = buildGraph("evolving-route", "n-a", "n-b");
|
||||
v2.setDescription("v2");
|
||||
|
||||
Optional<String> result = store.findContentHashForRouteByAgents(
|
||||
"route-4", java.util.List.of("agent-10", "agent-20"));
|
||||
store.store(new TaggedDiagram("publisher-old", "versioned-app", "default", v1));
|
||||
Thread.sleep(10);
|
||||
store.store(new TaggedDiagram("publisher-new", "versioned-app", "default", v2));
|
||||
|
||||
assertThat(result).isPresent();
|
||||
Optional<String> hashOpt = store.findLatestContentHashForAppRoute(
|
||||
"versioned-app", "evolving-route", "default");
|
||||
assertThat(hashOpt).isPresent();
|
||||
|
||||
RouteGraph retrieved = store.findByContentHash(hashOpt.get()).orElseThrow();
|
||||
assertThat(retrieved.getDescription()).isEqualTo("v2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findContentHashForRouteByAgents_emptyListReturnsEmpty() {
|
||||
Optional<String> result = store.findContentHashForRouteByAgents("route-x", java.util.List.of());
|
||||
assertThat(result).isEmpty();
|
||||
void findLatestContentHashForAppRoute_isolatesByAppAndEnv() {
|
||||
RouteGraph graph = buildGraph("shared-route", "node-1");
|
||||
store.store(new TaggedDiagram("a1", "app-alpha", "dev", graph));
|
||||
store.store(new TaggedDiagram("a2", "app-beta", "prod", graph));
|
||||
|
||||
// Same route id exists across two (app, env) combos. The resolver must
|
||||
// return empty for a mismatch on either dimension.
|
||||
assertThat(store.findLatestContentHashForAppRoute("app-alpha", "shared-route", "dev"))
|
||||
.isPresent();
|
||||
assertThat(store.findLatestContentHashForAppRoute("app-alpha", "shared-route", "prod"))
|
||||
.isEmpty();
|
||||
assertThat(store.findLatestContentHashForAppRoute("app-beta", "shared-route", "dev"))
|
||||
.isEmpty();
|
||||
assertThat(store.findLatestContentHashForAppRoute("app-gamma", "shared-route", "dev"))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findLatestContentHashForAppRoute_emptyInputsReturnEmpty() {
|
||||
assertThat(store.findLatestContentHashForAppRoute(null, "r", "default")).isEmpty();
|
||||
assertThat(store.findLatestContentHashForAppRoute("app", null, "default")).isEmpty();
|
||||
assertThat(store.findLatestContentHashForAppRoute("app", "r", null)).isEmpty();
|
||||
assertThat(store.findLatestContentHashForAppRoute("", "r", "default")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.cameleer.server.core.storage;
|
||||
import com.cameleer.common.graph.RouteGraph;
|
||||
import com.cameleer.server.core.ingestion.TaggedDiagram;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -15,7 +14,18 @@ public interface DiagramStore {
|
||||
|
||||
Optional<String> findContentHashForRoute(String routeId, String instanceId);
|
||||
|
||||
Optional<String> findContentHashForRouteByAgents(String routeId, List<String> instanceIds);
|
||||
/**
|
||||
* Return the most recently stored {@code content_hash} for the given
|
||||
* {@code (applicationId, environment, routeId)} triple, regardless of the
|
||||
* agent instance that produced it.
|
||||
*
|
||||
* <p>Unlike {@link #findContentHashForRoute(String, String)}, this lookup
|
||||
* is independent of the agent registry — so it keeps working for routes
|
||||
* whose publishing agents have since been redeployed or removed.
|
||||
*/
|
||||
Optional<String> findLatestContentHashForAppRoute(String applicationId,
|
||||
String routeId,
|
||||
String environment);
|
||||
|
||||
Map<String, String> findProcessorRouteMapping(String applicationId, String environment);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ class ChunkAccumulatorTest {
|
||||
public void store(com.cameleer.server.core.ingestion.TaggedDiagram d) {}
|
||||
public Optional<com.cameleer.common.graph.RouteGraph> findByContentHash(String h) { return Optional.empty(); }
|
||||
public Optional<String> findContentHashForRoute(String r, String a) { return Optional.empty(); }
|
||||
public Optional<String> findContentHashForRouteByAgents(String r, List<String> a) { return Optional.empty(); }
|
||||
public Optional<String> findLatestContentHashForAppRoute(String app, String r, String env) { return Optional.empty(); }
|
||||
public Map<String, String> findProcessorRouteMapping(String app, String env) { return Map.of(); }
|
||||
};
|
||||
|
||||
|
||||
@@ -38,14 +38,16 @@ export interface CatalogApp {
|
||||
deployment: DeploymentSummary | null;
|
||||
}
|
||||
|
||||
export function useCatalog(environment?: string) {
|
||||
export function useCatalog(environment?: string, from?: string, to?: string) {
|
||||
const refetchInterval = useRefreshInterval(15_000);
|
||||
return useQuery({
|
||||
queryKey: ['catalog', environment],
|
||||
queryKey: ['catalog', environment, from, to],
|
||||
queryFn: async () => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
const params = new URLSearchParams();
|
||||
if (environment) params.set('environment', environment);
|
||||
if (from) params.set('from', from);
|
||||
if (to) params.set('to', to);
|
||||
const qs = params.toString();
|
||||
const res = await fetch(`${config.apiBaseUrl}/catalog${qs ? `?${qs}` : ''}`, {
|
||||
headers: {
|
||||
|
||||
@@ -361,7 +361,9 @@ function LayoutContent() {
|
||||
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
||||
const setSelectedEnvRaw = useEnvironmentStore((s) => s.setEnvironment);
|
||||
|
||||
const { data: catalog } = useCatalog(selectedEnv);
|
||||
const catalogFrom = timeRange.start.toISOString();
|
||||
const catalogTo = timeRange.end.toISOString();
|
||||
const { data: catalog } = useCatalog(selectedEnv, catalogFrom, catalogTo);
|
||||
// Env is always required now (path-based endpoint). For cross-env "all agents"
|
||||
// we'd need a separate flat endpoint; sidebar uses env-filtered list directly.
|
||||
const { data: agents } = useAgents(); // env pulled from store internally
|
||||
@@ -729,7 +731,6 @@ function LayoutContent() {
|
||||
// --- Callbacks ----------------------------------------------------
|
||||
const handleLogout = useCallback(() => {
|
||||
logout();
|
||||
useEnvironmentStore.getState().setEnvironment(undefined);
|
||||
navigate('/login');
|
||||
}, [logout, navigate]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user