Compare commits
17 Commits
888f589934
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5b6f2bbad | ||
| 83c3ac3ef3 | |||
| 7dd7317cb8 | |||
| 2654271494 | |||
|
|
9aad2f3871 | ||
|
|
cbaac2bfa5 | ||
|
|
7529a9ce99 | ||
|
|
09309de982 | ||
|
|
56c41814fc | ||
|
|
68704e15b4 | ||
|
|
510206c752 | ||
|
|
58e9695b4c | ||
|
|
f27a0044f1 | ||
|
|
5c9323cfed | ||
|
|
2dcbd5a772 | ||
|
|
f9b5f235cc | ||
|
|
0b419db9f1 |
@@ -57,7 +57,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
|
||||
- `DeploymentController` — `/api/v1/environments/{envSlug}/apps/{appSlug}/deployments`. GET list / POST create (body `{ appVersionId }`) / POST `{id}/stop` / POST `{id}/promote` (body `{ targetEnvironment: slug }` — target app slug must exist in target env) / GET `{id}/logs`. All lifecycle ops (`POST /` deploy, `POST /{id}/stop`, `POST /{id}/promote`) audited under `AuditCategory.DEPLOYMENT`. Action codes: `deploy_app`, `stop_deployment`, `promote_deployment`. Acting user resolved via the `user:` prefix-strip convention; both SUCCESS and FAILURE branches write audit rows. `created_by` (TEXT, nullable) populated from `SecurityContextHolder` and surfaced on the `Deployment` DTO.
|
||||
- `ApplicationConfigController` — `/api/v1/environments/{envSlug}`. GET `/config` (list), GET/PUT `/apps/{appSlug}/config`, GET `/apps/{appSlug}/processor-routes`, POST `/apps/{appSlug}/config/test-expression`. PUT accepts `?apply=staged|live` (default `live`). `live` saves to DB and pushes `CONFIG_UPDATE` SSE to live agents in this env (existing behavior); `staged` saves to DB only, skipping the SSE push — used by the unified app deployment page. Audit action is `stage_app_config` for staged writes, `update_app_config` for live. Invalid `apply` values return 400.
|
||||
- `AppSettingsController` — `/api/v1/environments/{envSlug}`. GET `/app-settings` (list), GET/PUT/DELETE `/apps/{appSlug}/settings`. ADMIN/OPERATOR only.
|
||||
- `SearchController` — `/api/v1/environments/{envSlug}`. GET `/executions`, POST `/executions/search`, GET `/stats`, `/stats/timeseries`, `/stats/timeseries/by-app`, `/stats/timeseries/by-route`, `/stats/punchcard`, `/attributes/keys`, `/errors/top`.
|
||||
- `SearchController` — `/api/v1/environments/{envSlug}`. GET `/executions`, POST `/executions/search`, GET `/stats`, `/stats/timeseries`, `/stats/timeseries/by-app`, `/stats/timeseries/by-route`, `/stats/punchcard`, `/attributes/keys`, `/errors/top`. GET `/executions` accepts repeat `attr` query params: `attr=order` (key-exists), `attr=order:47` (exact), `attr=order:4*` (wildcard — `*` maps to SQL LIKE `%`). First `:` splits key/value; later colons stay in the value. Invalid keys → 400. POST `/executions/search` accepts the same filters via `SearchRequest.attributeFilters` in the body.
|
||||
- `LogQueryController` — GET `/api/v1/environments/{envSlug}/logs` (filters: source (multi, comma-split, OR-joined), level (multi, comma-split, OR-joined), application, agentId, exchangeId, logger, q, time range, instanceIds (multi, comma-split, AND-joined as WHERE instance_id IN (...) — used by the Checkpoint detail drawer to scope logs to a deployment's replicas); sort asc/desc). Cursor-paginated, returns `{ data, nextCursor, hasMore, levelCounts }`; cursor is base64url of `"{timestampIso}|{insert_id_uuid}"` — same-millisecond tiebreak via the `insert_id` UUID column on `logs`.
|
||||
- `RouteCatalogController` — GET `/api/v1/environments/{envSlug}/routes` (merged route catalog from registry + ClickHouse; env filter unconditional).
|
||||
- `RouteMetricsController` — GET `/api/v1/environments/{envSlug}/routes/metrics`, GET `/api/v1/environments/{envSlug}/routes/metrics/processors`.
|
||||
|
||||
@@ -47,7 +47,8 @@ paths:
|
||||
## search/ — Execution search and stats
|
||||
|
||||
- `SearchService` — search, count, stats, statsForApp, statsForRoute, timeseries, timeseriesForApp, timeseriesForRoute, timeseriesGroupedByApp, timeseriesGroupedByRoute, slaCompliance, slaCountsByApp, slaCountsByRoute, topErrors, activeErrorTypes, punchcard, distinctAttributeKeys. `statsForRoute`/`timeseriesForRoute` take `(routeId, applicationId)` — app filter is applied to `stats_1m_route`.
|
||||
- `SearchRequest` / `SearchResult` — search DTOs
|
||||
- `SearchRequest` / `SearchResult` — search DTOs. `SearchRequest.attributeFilters: List<AttributeFilter>` carries structured facet filters for execution attributes — key-only (exists), exact (key=value), or wildcard (`*` in value). The 21-arg legacy ctor is preserved for call-site churn; the compact ctor normalises null → `List.of()`.
|
||||
- `AttributeFilter(key, value)` — record with key regex `^[a-zA-Z0-9._-]+$` (inlined into SQL, same constraint as alerting), `value == null` means key-exists, `value` containing `*` becomes a SQL LIKE pattern via `toLikePattern()`.
|
||||
- `ExecutionStats`, `ExecutionSummary` — stats aggregation records
|
||||
- `StatsTimeseries`, `TopError` — timeseries and error DTOs
|
||||
- `LogSearchRequest` / `LogSearchResponse` — log search DTOs. `LogSearchRequest.sources` / `levels` are `List<String>` (null-normalized, multi-value OR); `cursor` + `limit` + `sort` drive keyset pagination. Response carries `nextCursor` + `hasMore` + per-level `levelCounts`.
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.cameleer.server.app.web.EnvPath;
|
||||
import com.cameleer.server.core.admin.AppSettings;
|
||||
import com.cameleer.server.core.admin.AppSettingsRepository;
|
||||
import com.cameleer.server.core.runtime.Environment;
|
||||
import com.cameleer.server.core.search.AttributeFilter;
|
||||
import com.cameleer.server.core.search.ExecutionStats;
|
||||
import com.cameleer.server.core.search.ExecutionSummary;
|
||||
import com.cameleer.server.core.search.SearchRequest;
|
||||
@@ -14,6 +15,7 @@ import com.cameleer.server.core.search.TopError;
|
||||
import com.cameleer.server.core.storage.StatsStore;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -21,8 +23,10 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -57,11 +61,19 @@ public class SearchController {
|
||||
@RequestParam(name = "agentId", required = false) String instanceId,
|
||||
@RequestParam(required = false) String processorType,
|
||||
@RequestParam(required = false) String application,
|
||||
@RequestParam(name = "attr", required = false) List<String> attr,
|
||||
@RequestParam(defaultValue = "0") int offset,
|
||||
@RequestParam(defaultValue = "50") int limit,
|
||||
@RequestParam(required = false) String sortField,
|
||||
@RequestParam(required = false) String sortDir) {
|
||||
|
||||
List<AttributeFilter> attributeFilters;
|
||||
try {
|
||||
attributeFilters = parseAttrParams(attr);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e);
|
||||
}
|
||||
|
||||
SearchRequest request = new SearchRequest(
|
||||
status, timeFrom, timeTo,
|
||||
null, null,
|
||||
@@ -72,12 +84,36 @@ public class SearchController {
|
||||
offset, limit,
|
||||
sortField, sortDir,
|
||||
null,
|
||||
env.slug()
|
||||
env.slug(),
|
||||
attributeFilters
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(searchService.search(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses {@code attr} query params of the form {@code key} (key-only) or {@code key:value}
|
||||
* (exact or wildcard via {@code *}). Splits on the first {@code :}; later colons are part of
|
||||
* the value. Blank / null list → empty result. Key validation is delegated to
|
||||
* {@link AttributeFilter}'s compact constructor, which throws {@link IllegalArgumentException}
|
||||
* on invalid keys (mapped to 400 by the caller).
|
||||
*/
|
||||
static List<AttributeFilter> parseAttrParams(List<String> raw) {
|
||||
if (raw == null || raw.isEmpty()) return List.of();
|
||||
List<AttributeFilter> out = new ArrayList<>(raw.size());
|
||||
for (String entry : raw) {
|
||||
if (entry == null || entry.isBlank()) continue;
|
||||
int colon = entry.indexOf(':');
|
||||
if (colon < 0) {
|
||||
out.add(new AttributeFilter(entry.trim(), null));
|
||||
} else {
|
||||
out.add(new AttributeFilter(entry.substring(0, colon).trim(),
|
||||
entry.substring(colon + 1)));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@PostMapping("/executions/search")
|
||||
@Operation(summary = "Advanced search with all filters",
|
||||
description = "Env from the path overrides any environment field in the body.")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cameleer.server.app.search;
|
||||
|
||||
import com.cameleer.server.core.alerting.AlertMatchSpec;
|
||||
import com.cameleer.server.core.search.AttributeFilter;
|
||||
import com.cameleer.server.core.search.ExecutionSummary;
|
||||
import com.cameleer.server.core.search.SearchRequest;
|
||||
import com.cameleer.server.core.search.SearchResult;
|
||||
@@ -256,6 +257,23 @@ public class ClickHouseSearchIndex implements SearchIndex {
|
||||
params.add(likeTerm);
|
||||
}
|
||||
|
||||
// Structured attribute filters. Keys were validated at AttributeFilter construction
|
||||
// time against ^[a-zA-Z0-9._-]+$ so they are safe to single-quote-inline; the JSON path
|
||||
// argument of JSONExtractString does not accept a ? placeholder in ClickHouse JDBC
|
||||
// (same constraint as countExecutionsForAlerting below). Values are parameter-bound.
|
||||
for (AttributeFilter filter : request.attributeFilters()) {
|
||||
String escapedKey = filter.key().replace("'", "\\'");
|
||||
if (filter.isKeyOnly()) {
|
||||
conditions.add("JSONHas(attributes, '" + escapedKey + "')");
|
||||
} else if (filter.isWildcard()) {
|
||||
conditions.add("JSONExtractString(attributes, '" + escapedKey + "') LIKE ?");
|
||||
params.add(filter.toLikePattern());
|
||||
} else {
|
||||
conditions.add("JSONExtractString(attributes, '" + escapedKey + "') = ?");
|
||||
params.add(filter.value());
|
||||
}
|
||||
}
|
||||
|
||||
return String.join(" AND ", conditions);
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,42 @@ class SearchControllerIT extends AbstractPostgresIT {
|
||||
""", i, i, i, i, i));
|
||||
}
|
||||
|
||||
// Executions 11-12: carry structured attributes used by the attribute-filter tests.
|
||||
ingest("""
|
||||
{
|
||||
"exchangeId": "ex-search-attr-1",
|
||||
"applicationId": "test-group",
|
||||
"instanceId": "test-agent-search-it",
|
||||
"routeId": "search-route-attr-1",
|
||||
"correlationId": "corr-attr-alpha",
|
||||
"status": "COMPLETED",
|
||||
"startTime": "2026-03-12T10:00:00Z",
|
||||
"endTime": "2026-03-12T10:00:00.050Z",
|
||||
"durationMs": 50,
|
||||
"attributes": {"order": "12345", "tenant": "acme"},
|
||||
"chunkSeq": 0,
|
||||
"final": true,
|
||||
"processors": []
|
||||
}
|
||||
""");
|
||||
ingest("""
|
||||
{
|
||||
"exchangeId": "ex-search-attr-2",
|
||||
"applicationId": "test-group",
|
||||
"instanceId": "test-agent-search-it",
|
||||
"routeId": "search-route-attr-2",
|
||||
"correlationId": "corr-attr-beta",
|
||||
"status": "COMPLETED",
|
||||
"startTime": "2026-03-12T10:01:00Z",
|
||||
"endTime": "2026-03-12T10:01:00.050Z",
|
||||
"durationMs": 50,
|
||||
"attributes": {"order": "99999"},
|
||||
"chunkSeq": 0,
|
||||
"final": true,
|
||||
"processors": []
|
||||
}
|
||||
""");
|
||||
|
||||
// Wait for async ingestion + search indexing via REST (no raw SQL).
|
||||
// Probe the last seeded execution to avoid false positives from
|
||||
// other test classes that may have written into the shared CH tables.
|
||||
@@ -174,6 +210,11 @@ class SearchControllerIT extends AbstractPostgresIT {
|
||||
JsonNode body = objectMapper.readTree(r.getBody());
|
||||
assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(1);
|
||||
});
|
||||
await().atMost(30, SECONDS).untilAsserted(() -> {
|
||||
ResponseEntity<String> r = searchGet("?correlationId=corr-attr-beta");
|
||||
JsonNode body = objectMapper.readTree(r.getBody());
|
||||
assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(1);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -371,6 +412,69 @@ class SearchControllerIT extends AbstractPostgresIT {
|
||||
assertThat(body.get("limit").asInt()).isEqualTo(50);
|
||||
}
|
||||
|
||||
@Test
|
||||
void attrParam_exactMatch_filtersToMatchingExecution() throws Exception {
|
||||
ResponseEntity<String> response = searchGet("?attr=order:12345&correlationId=corr-attr-alpha");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.get("total").asLong()).isEqualTo(1);
|
||||
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha");
|
||||
}
|
||||
|
||||
@Test
|
||||
void attrParam_keyOnly_matchesAnyExecutionCarryingTheKey() throws Exception {
|
||||
ResponseEntity<String> response = searchGet("?attr=tenant&correlationId=corr-attr-alpha");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.get("total").asLong()).isEqualTo(1);
|
||||
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha");
|
||||
}
|
||||
|
||||
@Test
|
||||
void attrParam_multipleValues_produceIntersection() throws Exception {
|
||||
// order:99999 AND tenant=* should yield zero — exec-attr-2 has order=99999 but no tenant.
|
||||
ResponseEntity<String> response = searchGet("?attr=order:99999&attr=tenant");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.get("total").asLong()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void attrParam_invalidKey_returns400() throws Exception {
|
||||
ResponseEntity<String> response = searchGet("?attr=bad%20key:x");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void attributeFilters_inPostBody_filtersCorrectly() throws Exception {
|
||||
ResponseEntity<String> response = searchPost("""
|
||||
{
|
||||
"attributeFilters": [
|
||||
{"key": "order", "value": "12345"}
|
||||
],
|
||||
"correlationId": "corr-attr-alpha"
|
||||
}
|
||||
""");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.get("total").asLong()).isEqualTo(1);
|
||||
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha");
|
||||
}
|
||||
|
||||
@Test
|
||||
void attrParam_wildcardValue_matchesOnPrefix() throws Exception {
|
||||
ResponseEntity<String> response = searchGet("?attr=order:1*&correlationId=corr-attr-alpha");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.get("total").asLong()).isEqualTo(1);
|
||||
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha");
|
||||
}
|
||||
|
||||
// --- Helper methods ---
|
||||
|
||||
private void ingest(String json) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.cameleer.server.app.search;
|
||||
|
||||
import com.cameleer.server.app.storage.ClickHouseExecutionStore;
|
||||
import com.cameleer.server.core.ingestion.MergedExecution;
|
||||
import com.cameleer.server.core.search.AttributeFilter;
|
||||
import com.cameleer.server.core.search.ExecutionSummary;
|
||||
import com.cameleer.server.core.search.SearchRequest;
|
||||
import com.cameleer.server.core.search.SearchResult;
|
||||
@@ -62,7 +63,7 @@ class ClickHouseSearchIndexIT {
|
||||
500L,
|
||||
"", "", "", "", "", "",
|
||||
"hash-abc", "FULL",
|
||||
"{\"order\":\"12345\"}", "", "", "", "", "", "{\"env\":\"prod\"}",
|
||||
"", "", "", "", "", "", "{\"order\":\"12345\",\"tenant\":\"acme\"}",
|
||||
"", "",
|
||||
false, false,
|
||||
null, null
|
||||
@@ -79,7 +80,7 @@ class ClickHouseSearchIndexIT {
|
||||
"java.lang.NPE\n at Foo.bar(Foo.java:42)",
|
||||
"NullPointerException", "RUNTIME", "", "",
|
||||
"", "FULL",
|
||||
"", "", "", "", "", "", "",
|
||||
"", "", "", "", "", "", "{\"order\":\"99999\"}",
|
||||
"", "",
|
||||
false, false,
|
||||
null, null
|
||||
@@ -309,4 +310,59 @@ class ClickHouseSearchIndexIT {
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.data().get(0).executionId()).isEqualTo("exec-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_byAttributeFilter_exactMatch_matchesExec1() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null, null,
|
||||
List.of(new AttributeFilter("order", "12345")));
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.data().get(0).executionId()).isEqualTo("exec-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_byAttributeFilter_keyOnly_matchesExec1AndExec2() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null, null,
|
||||
List.of(new AttributeFilter("order", null)));
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
assertThat(result.total()).isEqualTo(2);
|
||||
assertThat(result.data()).extracting(ExecutionSummary::executionId)
|
||||
.containsExactlyInAnyOrder("exec-1", "exec-2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_byAttributeFilter_wildcardValue_matchesExec1Only() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null, null,
|
||||
List.of(new AttributeFilter("order", "123*")));
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.data().get(0).executionId()).isEqualTo("exec-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_byAttributeFilter_multipleFiltersAreAnded() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null, null,
|
||||
List.of(
|
||||
new AttributeFilter("order", "12345"),
|
||||
new AttributeFilter("tenant", "acme")));
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.data().get(0).executionId()).isEqualTo("exec-1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,13 @@ import java.util.UUID;
|
||||
*/
|
||||
public class DirtyStateCalculator {
|
||||
|
||||
// Live-pushed fields are excluded from the deploy diff: changes to them take effect
|
||||
// via SSE config-update without a redeploy, so they are not "pending deploy" when they
|
||||
// differ from the last successful deployment snapshot. See ui/rules: the Traces & Taps
|
||||
// and Route Recording tabs apply with ?apply=live and "never mark dirty".
|
||||
private static final Set<String> AGENT_CONFIG_IGNORED_KEYS = Set.of(
|
||||
"version", "updatedAt", "updatedBy", "environment", "application"
|
||||
"version", "updatedAt", "updatedBy", "environment", "application",
|
||||
"taps", "tapVersion", "tracedProcessors", "routeRecording"
|
||||
);
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.cameleer.server.core.search;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Structured attribute filter for execution search.
|
||||
* <p>
|
||||
* Value semantics:
|
||||
* <ul>
|
||||
* <li>{@code value == null} or blank -> key-exists check</li>
|
||||
* <li>{@code value} contains {@code *} -> wildcard match (translated to SQL LIKE pattern)</li>
|
||||
* <li>otherwise -> exact match</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Keys must match {@code ^[a-zA-Z0-9._-]+$} — they are later inlined into
|
||||
* ClickHouse SQL via {@code JSONExtractString}, which does not accept a
|
||||
* parameter placeholder for the JSON path. Values are always parameter-bound.
|
||||
*/
|
||||
public record AttributeFilter(String key, String value) {
|
||||
|
||||
private static final Pattern KEY_PATTERN = Pattern.compile("^[a-zA-Z0-9._-]+$");
|
||||
|
||||
public AttributeFilter {
|
||||
if (key == null || !KEY_PATTERN.matcher(key).matches()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Invalid attribute key: must match " + KEY_PATTERN.pattern() + ", got: " + key);
|
||||
}
|
||||
if (value != null && value.isBlank()) {
|
||||
value = null;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isKeyOnly() {
|
||||
return value == null;
|
||||
}
|
||||
|
||||
public boolean isWildcard() {
|
||||
return value != null && value.indexOf('*') >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a SQL LIKE pattern for wildcard matches with {@code %} / {@code _} / {@code \}
|
||||
* in the source value escaped, or {@code null} for exact / key-only filters.
|
||||
*/
|
||||
public String toLikePattern() {
|
||||
if (!isWildcard()) return null;
|
||||
StringBuilder sb = new StringBuilder(value.length() + 4);
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
switch (c) {
|
||||
case '\\' -> sb.append("\\\\");
|
||||
case '%' -> sb.append("\\%");
|
||||
case '_' -> sb.append("\\_");
|
||||
case '*' -> sb.append('%');
|
||||
default -> sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,8 @@ public record SearchRequest(
|
||||
String sortField,
|
||||
String sortDir,
|
||||
String afterExecutionId,
|
||||
String environment
|
||||
String environment,
|
||||
List<AttributeFilter> attributeFilters
|
||||
) {
|
||||
|
||||
private static final int DEFAULT_LIMIT = 50;
|
||||
@@ -83,6 +84,24 @@ public record SearchRequest(
|
||||
if (offset < 0) offset = 0;
|
||||
if (sortField == null || !ALLOWED_SORT_FIELDS.contains(sortField)) sortField = "startTime";
|
||||
if (!"asc".equalsIgnoreCase(sortDir)) sortDir = "desc";
|
||||
if (attributeFilters == null) attributeFilters = List.of();
|
||||
}
|
||||
|
||||
/** Legacy 21-arg constructor preserved for existing call sites — defaults attributeFilters to empty. */
|
||||
public SearchRequest(
|
||||
String status, Instant timeFrom, Instant timeTo,
|
||||
Long durationMin, Long durationMax, String correlationId,
|
||||
String text, String textInBody, String textInHeaders, String textInErrors,
|
||||
String routeId, String instanceId, String processorType,
|
||||
String applicationId, List<String> instanceIds,
|
||||
int offset, int limit, String sortField, String sortDir,
|
||||
String afterExecutionId, String environment
|
||||
) {
|
||||
this(status, timeFrom, timeTo, durationMin, durationMax, correlationId,
|
||||
text, textInBody, textInHeaders, textInErrors,
|
||||
routeId, instanceId, processorType, applicationId, instanceIds,
|
||||
offset, limit, sortField, sortDir, afterExecutionId, environment,
|
||||
List.of());
|
||||
}
|
||||
|
||||
/** Returns the snake_case column name for ORDER BY. */
|
||||
@@ -96,7 +115,8 @@ public record SearchRequest(
|
||||
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
|
||||
text, textInBody, textInHeaders, textInErrors,
|
||||
routeId, instanceId, processorType, applicationId, resolvedInstanceIds,
|
||||
offset, limit, sortField, sortDir, afterExecutionId, environment
|
||||
offset, limit, sortField, sortDir, afterExecutionId, environment,
|
||||
attributeFilters
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +126,8 @@ public record SearchRequest(
|
||||
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
|
||||
text, textInBody, textInHeaders, textInErrors,
|
||||
routeId, instanceId, processorType, applicationId, instanceIds,
|
||||
offset, limit, sortField, sortDir, afterExecutionId, env
|
||||
offset, limit, sortField, sortDir, afterExecutionId, env,
|
||||
attributeFilters
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,7 +143,8 @@ public record SearchRequest(
|
||||
status, ts, timeTo, durationMin, durationMax, correlationId,
|
||||
text, textInBody, textInHeaders, textInErrors,
|
||||
routeId, instanceId, processorType, applicationId, instanceIds,
|
||||
offset, limit, sortField, sortDir, afterExecutionId, environment
|
||||
offset, limit, sortField, sortDir, afterExecutionId, environment,
|
||||
attributeFilters
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -114,9 +115,9 @@ class DirtyStateCalculatorTest {
|
||||
DirtyStateCalculator calc = CALC;
|
||||
|
||||
ApplicationConfig deployed = new ApplicationConfig();
|
||||
deployed.setTracedProcessors(Map.of("proc-1", "DEBUG"));
|
||||
deployed.setSensitiveKeys(List.of("password", "token"));
|
||||
ApplicationConfig desired = new ApplicationConfig();
|
||||
desired.setTracedProcessors(Map.of("proc-1", "TRACE"));
|
||||
desired.setSensitiveKeys(List.of("password", "token", "secret"));
|
||||
UUID jarId = UUID.randomUUID();
|
||||
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, deployed, Map.of(), null);
|
||||
|
||||
@@ -124,7 +125,29 @@ class DirtyStateCalculatorTest {
|
||||
|
||||
assertThat(result.dirty()).isTrue();
|
||||
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
|
||||
.contains("agentConfig.tracedProcessors.proc-1");
|
||||
.anyMatch(f -> f.startsWith("agentConfig.sensitiveKeys"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void livePushedFields_doNotMarkDirty() {
|
||||
// Taps, tracedProcessors, and routeRecording apply via live SSE push (never redeploy),
|
||||
// so they must not appear as "pending deploy" when they differ from the last deploy snapshot.
|
||||
ApplicationConfig deployed = new ApplicationConfig();
|
||||
deployed.setTracedProcessors(Map.of("proc-1", "DEBUG"));
|
||||
deployed.setRouteRecording(Map.of("route-a", true));
|
||||
deployed.setTapVersion(1);
|
||||
|
||||
ApplicationConfig desired = new ApplicationConfig();
|
||||
desired.setTracedProcessors(Map.of("proc-1", "TRACE", "proc-2", "DEBUG"));
|
||||
desired.setRouteRecording(Map.of("route-a", false, "route-b", true));
|
||||
desired.setTapVersion(5);
|
||||
|
||||
UUID jarId = UUID.randomUUID();
|
||||
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, deployed, Map.of(), null);
|
||||
DirtyStateResult result = CALC.compute(jarId, desired, Map.of(), snap);
|
||||
|
||||
assertThat(result.dirty()).isFalse();
|
||||
assertThat(result.differences()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.cameleer.server.core.search;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class AttributeFilterTest {
|
||||
|
||||
@Test
|
||||
void keyOnly_blankValue_normalizesToNull() {
|
||||
AttributeFilter f = new AttributeFilter("order", "");
|
||||
assertThat(f.value()).isNull();
|
||||
assertThat(f.isKeyOnly()).isTrue();
|
||||
assertThat(f.isWildcard()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void keyOnly_nullValue_isKeyOnly() {
|
||||
AttributeFilter f = new AttributeFilter("order", null);
|
||||
assertThat(f.isKeyOnly()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactValue_isNotWildcard() {
|
||||
AttributeFilter f = new AttributeFilter("order", "47");
|
||||
assertThat(f.isKeyOnly()).isFalse();
|
||||
assertThat(f.isWildcard()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void starInValue_isWildcard() {
|
||||
AttributeFilter f = new AttributeFilter("order", "47*");
|
||||
assertThat(f.isWildcard()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidKey_throws() {
|
||||
assertThatThrownBy(() -> new AttributeFilter("bad key", "x"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("attribute key");
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankKey_throws() {
|
||||
assertThatThrownBy(() -> new AttributeFilter(" ", null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void wildcardPattern_escapesLikeMetaCharacters() {
|
||||
AttributeFilter f = new AttributeFilter("order", "a_b%c\\d*");
|
||||
assertThat(f.toLikePattern()).isEqualTo("a\\_b\\%c\\\\d%");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactValue_toLikePattern_returnsNull() {
|
||||
AttributeFilter f = new AttributeFilter("order", "47");
|
||||
assertThat(f.toLikePattern()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchRequest_canonicalCtor_acceptsAttributeFilters() {
|
||||
SearchRequest r = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null, null,
|
||||
java.util.List.of(new AttributeFilter("order", "47")));
|
||||
assertThat(r.attributeFilters()).hasSize(1);
|
||||
assertThat(r.attributeFilters().get(0).key()).isEqualTo("order");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchRequest_legacyCtor_defaultsAttributeFiltersToEmpty() {
|
||||
SearchRequest r = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null, null);
|
||||
assertThat(r.attributeFilters()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchRequest_compactCtor_normalizesNullAttributeFilters() {
|
||||
SearchRequest r = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null, null,
|
||||
null);
|
||||
assertThat(r.attributeFilters()).isNotNull().isEmpty();
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
200
ui/src/api/schema.d.ts
vendored
200
ui/src/api/schema.d.ts
vendored
@@ -1037,6 +1037,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/server-metrics/query": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Generic time-series query
|
||||
* @description Returns bucketed series for a single metric_name. Supports aggregation (avg/sum/max/min/latest), group-by-tag, filter-by-tag, counter delta mode, and a derived 'mean' statistic for timers.
|
||||
*/
|
||||
post: operations["query"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/roles": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1556,7 +1576,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
get: operations["findByAppAndRoute"];
|
||||
put?: never;
|
||||
@@ -1912,6 +1932,46 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/server-metrics/instances": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List server_instance_id values observed in the window
|
||||
* @description Returns first/last seen timestamps — use to partition counter-delta computations.
|
||||
*/
|
||||
get: operations["instances"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/server-metrics/catalog": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List metric names observed in the window
|
||||
* @description For each metric_name, returns metric_type, the set of statistics emitted, and the union of tag keys.
|
||||
*/
|
||||
get: operations["catalog"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/rbac/stats": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2209,6 +2269,17 @@ export interface components {
|
||||
[key: string]: number;
|
||||
};
|
||||
sensitiveKeys?: string[];
|
||||
/** Format: int32 */
|
||||
exportBatchSize?: number;
|
||||
/** Format: int32 */
|
||||
exportQueueSize?: number;
|
||||
/** Format: int64 */
|
||||
exportFlushIntervalMs?: number;
|
||||
exportOverflowMode?: string;
|
||||
/** Format: int64 */
|
||||
exportBlockTimeoutMs?: number;
|
||||
/** Format: int32 */
|
||||
flushRecordThreshold?: number;
|
||||
};
|
||||
TapDefinition: {
|
||||
tapId?: string;
|
||||
@@ -2630,6 +2701,12 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
};
|
||||
AttributeFilter: {
|
||||
key?: string;
|
||||
value?: string;
|
||||
keyOnly?: boolean;
|
||||
wildcard?: boolean;
|
||||
};
|
||||
SearchRequest: {
|
||||
status?: string;
|
||||
/** Format: date-time */
|
||||
@@ -2658,6 +2735,7 @@ export interface components {
|
||||
sortDir?: string;
|
||||
afterExecutionId?: string;
|
||||
environment?: string;
|
||||
attributeFilters?: components["schemas"]["AttributeFilter"][];
|
||||
};
|
||||
ExecutionSummary: {
|
||||
executionId: string;
|
||||
@@ -2967,6 +3045,42 @@ export interface components {
|
||||
SetPasswordRequest: {
|
||||
password?: string;
|
||||
};
|
||||
QueryBody: {
|
||||
metric?: string;
|
||||
statistic?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
/** Format: int32 */
|
||||
stepSeconds?: number;
|
||||
groupByTags?: string[];
|
||||
filterTags?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
aggregation?: string;
|
||||
mode?: string;
|
||||
serverInstanceIds?: string[];
|
||||
};
|
||||
ServerMetricPoint: {
|
||||
/** Format: date-time */
|
||||
t?: string;
|
||||
/** Format: double */
|
||||
v?: number;
|
||||
};
|
||||
ServerMetricQueryResponse: {
|
||||
metric?: string;
|
||||
statistic?: string;
|
||||
aggregation?: string;
|
||||
mode?: string;
|
||||
/** Format: int32 */
|
||||
stepSeconds?: number;
|
||||
series?: components["schemas"]["ServerMetricSeries"][];
|
||||
};
|
||||
ServerMetricSeries: {
|
||||
tags?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
points?: components["schemas"]["ServerMetricPoint"][];
|
||||
};
|
||||
CreateRoleRequest: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
@@ -3491,6 +3605,19 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
avgDurationMs?: number;
|
||||
};
|
||||
ServerInstanceInfo: {
|
||||
serverInstanceId?: string;
|
||||
/** Format: date-time */
|
||||
firstSeen?: string;
|
||||
/** Format: date-time */
|
||||
lastSeen?: string;
|
||||
};
|
||||
ServerMetricCatalogEntry: {
|
||||
metricName?: string;
|
||||
metricType?: string;
|
||||
statistics?: string[];
|
||||
tagKeys?: string[];
|
||||
};
|
||||
SensitiveKeysConfig: {
|
||||
keys?: string[];
|
||||
};
|
||||
@@ -6246,6 +6373,30 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
query: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["QueryBody"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ServerMetricQueryResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listRoles: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -7068,6 +7219,7 @@ export interface operations {
|
||||
agentId?: string;
|
||||
processorType?: string;
|
||||
application?: string;
|
||||
attr?: string[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
sortField?: string;
|
||||
@@ -7822,6 +7974,52 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
instances: {
|
||||
parameters: {
|
||||
query?: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ServerInstanceInfo"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
catalog: {
|
||||
parameters: {
|
||||
query?: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ServerMetricCatalogEntry"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getStats: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -44,6 +44,7 @@ import { EnvironmentSwitcherModal } from './EnvironmentSwitcherModal';
|
||||
import { envColorVar } from './env-colors';
|
||||
import { useScope } from '../hooks/useScope';
|
||||
import { formatDuration } from '../utils/format-utils';
|
||||
import { parseFacetQuery, formatAttrParam } from '../utils/attribute-filter';
|
||||
import {
|
||||
buildAppTreeNodes,
|
||||
buildAdminTreeNodes,
|
||||
@@ -111,7 +112,11 @@ function buildSearchData(
|
||||
id: `attr-key-${key}`,
|
||||
category: 'attribute',
|
||||
title: key,
|
||||
meta: 'attribute key',
|
||||
meta: 'attribute key — filter list',
|
||||
// Path carries the facet in query-string form; handlePaletteSelect routes
|
||||
// attribute results to the current scope, so the leading segment below is
|
||||
// only used as a fallback when no scope is active.
|
||||
path: `/exchanges?attr=${encodeURIComponent(key)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -690,7 +695,19 @@ function LayoutContent() {
|
||||
}
|
||||
}
|
||||
|
||||
return [...catalogRef.current, ...exchangeItems, ...attributeItems, ...alertingSearchData];
|
||||
const facet = parseFacetQuery(debouncedQuery ?? '');
|
||||
const facetItems: SearchResult[] =
|
||||
facet
|
||||
? [{
|
||||
id: `facet-${formatAttrParam(facet)}`,
|
||||
category: 'attribute' as const,
|
||||
title: `Filter: ${facet.key} = "${facet.value}"${facet.value?.includes('*') ? ' (wildcard)' : ''}`,
|
||||
meta: 'apply attribute filter',
|
||||
path: `/exchanges?attr=${encodeURIComponent(formatAttrParam(facet))}`,
|
||||
}]
|
||||
: [];
|
||||
|
||||
return [...facetItems, ...catalogRef.current, ...exchangeItems, ...attributeItems, ...alertingSearchData];
|
||||
}, [isAdminPage, catalogRef.current, exchangeResults, debouncedQuery, alertingSearchData]);
|
||||
|
||||
const searchData = isAdminPage ? adminSearchData : operationalSearchData;
|
||||
@@ -744,6 +761,32 @@ function LayoutContent() {
|
||||
setPaletteOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.category === 'attribute') {
|
||||
// Three sources feed 'attribute' results:
|
||||
// - buildSearchData → id `attr-key-<key>` (key-only)
|
||||
// - operationalSearchData per-exchange → id `<execId>-attr-<key>`, title `key = "value"`
|
||||
// - synthetic facet (Task 9) → id `facet-<serialized>` where <serialized> is already
|
||||
// the URL `attr=` form (`key` or `key:value`)
|
||||
let attrParam: string | null = null;
|
||||
if (typeof result.id === 'string' && result.id.startsWith('attr-key-')) {
|
||||
attrParam = result.id.substring('attr-key-'.length);
|
||||
} else if (typeof result.id === 'string' && result.id.startsWith('facet-')) {
|
||||
attrParam = result.id.substring('facet-'.length);
|
||||
} else if (typeof result.title === 'string') {
|
||||
const m = /^([a-zA-Z0-9._-]+)\s*=\s*"([^"]*)"/.exec(result.title);
|
||||
if (m) attrParam = `${m[1]}:${m[2]}`;
|
||||
}
|
||||
if (attrParam) {
|
||||
const base = ['/exchanges'];
|
||||
if (scope.appId) base.push(scope.appId);
|
||||
if (scope.routeId) base.push(scope.routeId);
|
||||
navigate(`${base.join('/')}?attr=${encodeURIComponent(attrParam)}`);
|
||||
}
|
||||
setPaletteOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.path) {
|
||||
if (ADMIN_CATEGORIES.has(result.category)) {
|
||||
const itemId = result.id.split(':').slice(1).join(':');
|
||||
@@ -752,7 +795,7 @@ function LayoutContent() {
|
||||
});
|
||||
} else {
|
||||
const state: Record<string, unknown> = { sidebarReveal: result.path };
|
||||
if (result.category === 'exchange' || result.category === 'attribute') {
|
||||
if (result.category === 'exchange') {
|
||||
const parts = result.path.split('/').filter(Boolean);
|
||||
if (parts.length === 4 && parts[0] === 'exchanges') {
|
||||
state.selectedExchange = {
|
||||
@@ -766,7 +809,7 @@ function LayoutContent() {
|
||||
}
|
||||
}
|
||||
setPaletteOpen(false);
|
||||
}, [navigate, setPaletteOpen]);
|
||||
}, [navigate, setPaletteOpen, scope.appId, scope.routeId]);
|
||||
|
||||
const handlePaletteSubmit = useCallback((query: string) => {
|
||||
if (isAdminPage) {
|
||||
@@ -780,12 +823,18 @@ function LayoutContent() {
|
||||
} else {
|
||||
navigate('/admin/rbac');
|
||||
}
|
||||
} else {
|
||||
const baseParts = ['/exchanges'];
|
||||
if (scope.appId) baseParts.push(scope.appId);
|
||||
if (scope.routeId) baseParts.push(scope.routeId);
|
||||
navigate(`${baseParts.join('/')}?text=${encodeURIComponent(query)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const facet = parseFacetQuery(query);
|
||||
const baseParts = ['/exchanges'];
|
||||
if (scope.appId) baseParts.push(scope.appId);
|
||||
if (scope.routeId) baseParts.push(scope.routeId);
|
||||
if (facet) {
|
||||
navigate(`${baseParts.join('/')}?attr=${encodeURIComponent(formatAttrParam(facet))}`);
|
||||
return;
|
||||
}
|
||||
navigate(`${baseParts.join('/')}?text=${encodeURIComponent(query)}`);
|
||||
}, [isAdminPage, adminSearchData, handlePaletteSelect, navigate, scope.appId, scope.routeId]);
|
||||
|
||||
const handleSidebarNavigate = useCallback((path: string) => {
|
||||
|
||||
@@ -139,3 +139,23 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.attrChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.attrChip code {
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { useEnvironmentStore } from '../../api/environment-store'
|
||||
import type { ExecutionSummary } from '../../api/types'
|
||||
import { attributeBadgeColor } from '../../utils/attribute-color'
|
||||
import { parseAttrParam, formatAttrParam } from '../../utils/attribute-filter';
|
||||
import type { AttributeFilter } from '../../utils/attribute-filter';
|
||||
import { formatDuration, statusLabel } from '../../utils/format-utils'
|
||||
import styles from './Dashboard.module.css'
|
||||
import tableStyles from '../../styles/table-section.module.css'
|
||||
@@ -84,7 +86,7 @@ function buildColumns(hasAttributes: boolean): Column<Row>[] {
|
||||
<div className={styles.attrCell}>
|
||||
{shown.map(([k, v]) => (
|
||||
<span key={k} title={k}>
|
||||
<Badge label={String(v)} color={attributeBadgeColor(String(v))} />
|
||||
<Badge label={String(v)} color={attributeBadgeColor(k)} />
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && <span className={styles.attrOverflow}>+{overflow}</span>}
|
||||
@@ -147,6 +149,12 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const textFilter = searchParams.get('text') || undefined
|
||||
const attributeFilters = useMemo<AttributeFilter[]>(
|
||||
() => searchParams.getAll('attr')
|
||||
.map(parseAttrParam)
|
||||
.filter((f): f is AttributeFilter => f != null),
|
||||
[searchParams],
|
||||
);
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(activeExchangeId)
|
||||
const [sortField, setSortField] = useState<string>('startTime')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
||||
@@ -180,12 +188,13 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo
|
||||
environment: selectedEnv,
|
||||
status: statusParam,
|
||||
text: textFilter,
|
||||
attributeFilters: attributeFilters.length > 0 ? attributeFilters : undefined,
|
||||
sortField,
|
||||
sortDir,
|
||||
offset: 0,
|
||||
limit: textFilter ? 200 : 50,
|
||||
limit: textFilter || attributeFilters.length > 0 ? 200 : 50,
|
||||
},
|
||||
!textFilter,
|
||||
!textFilter && attributeFilters.length === 0,
|
||||
)
|
||||
|
||||
// ─── Rows ────────────────────────────────────────────────────────────────
|
||||
@@ -221,17 +230,46 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo
|
||||
<div className={`${tableStyles.tableSection} ${styles.tableWrap}`}>
|
||||
<div className={tableStyles.tableHeader}>
|
||||
<span className={tableStyles.tableTitle}>
|
||||
{textFilter ? (
|
||||
{textFilter || attributeFilters.length > 0 ? (
|
||||
<>
|
||||
<Search size={14} style={{ marginRight: 4, verticalAlign: -2 }} />
|
||||
Search: “{textFilter}”
|
||||
<button
|
||||
className={styles.clearSearch}
|
||||
onClick={() => setSearchParams({})}
|
||||
title="Clear search"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
{textFilter && (
|
||||
<>
|
||||
Search: “{textFilter}”
|
||||
<button
|
||||
className={styles.clearSearch}
|
||||
onClick={() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete('text');
|
||||
setSearchParams(next);
|
||||
}}
|
||||
title="Clear text search"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{attributeFilters.map((f, i) => (
|
||||
<span key={`${f.key}:${f.value ?? ''}:${i}`} className={styles.attrChip}>
|
||||
{f.value === undefined
|
||||
? <>has <code>{f.key}</code></>
|
||||
: <><code>{f.key}</code> = <code>{f.value}</code></>}
|
||||
<button
|
||||
className={styles.clearSearch}
|
||||
onClick={() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
const remaining = next.getAll('attr')
|
||||
.filter(a => a !== formatAttrParam(f));
|
||||
next.delete('attr');
|
||||
remaining.forEach(a => next.append('attr', a));
|
||||
setSearchParams(next);
|
||||
}}
|
||||
title="Remove filter"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
) : 'Recent Exchanges'}
|
||||
</span>
|
||||
@@ -239,7 +277,7 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo
|
||||
<span className={tableStyles.tableMeta}>
|
||||
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
|
||||
</span>
|
||||
{!textFilter && <Badge label="AUTO" color="success" />}
|
||||
{!textFilter && attributeFilters.length === 0 && <Badge label="AUTO" color="success" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
69
ui/src/utils/attribute-filter.test.ts
Normal file
69
ui/src/utils/attribute-filter.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseAttrParam, formatAttrParam, parseFacetQuery } from './attribute-filter';
|
||||
|
||||
describe('parseAttrParam', () => {
|
||||
it('returns key-only for input without colon', () => {
|
||||
expect(parseAttrParam('order')).toEqual({ key: 'order' });
|
||||
});
|
||||
|
||||
it('splits on first colon, trims key, preserves value as-is', () => {
|
||||
expect(parseAttrParam('order:47')).toEqual({ key: 'order', value: '47' });
|
||||
});
|
||||
|
||||
it('treats a value containing colons as a single value', () => {
|
||||
expect(parseAttrParam('trace-id:abc:123')).toEqual({ key: 'trace-id', value: 'abc:123' });
|
||||
});
|
||||
|
||||
it('returns null for blank input', () => {
|
||||
expect(parseAttrParam('')).toBeNull();
|
||||
expect(parseAttrParam(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for missing key', () => {
|
||||
expect(parseAttrParam(':x')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the key contains invalid characters', () => {
|
||||
expect(parseAttrParam('bad key:1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAttrParam', () => {
|
||||
it('returns bare key for key-only filter', () => {
|
||||
expect(formatAttrParam({ key: 'order' })).toBe('order');
|
||||
});
|
||||
|
||||
it('joins with colon when value is present', () => {
|
||||
expect(formatAttrParam({ key: 'order', value: '47' })).toBe('order:47');
|
||||
});
|
||||
|
||||
it('joins with colon when value is empty string', () => {
|
||||
expect(formatAttrParam({ key: 'order', value: '' })).toBe('order:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFacetQuery', () => {
|
||||
it('matches `key: value`', () => {
|
||||
expect(parseFacetQuery('order: 47')).toEqual({ key: 'order', value: '47' });
|
||||
});
|
||||
|
||||
it('matches `key:value` without spaces', () => {
|
||||
expect(parseFacetQuery('order:47')).toEqual({ key: 'order', value: '47' });
|
||||
});
|
||||
|
||||
it('matches wildcard values', () => {
|
||||
expect(parseFacetQuery('order: 4*')).toEqual({ key: 'order', value: '4*' });
|
||||
});
|
||||
|
||||
it('returns null when the key contains invalid characters', () => {
|
||||
expect(parseFacetQuery('bad key: 1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null without a colon', () => {
|
||||
expect(parseFacetQuery('order')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null with an empty value side', () => {
|
||||
expect(parseFacetQuery('order: ')).toBeNull();
|
||||
});
|
||||
});
|
||||
37
ui/src/utils/attribute-filter.ts
Normal file
37
ui/src/utils/attribute-filter.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface AttributeFilter {
|
||||
key: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
const KEY_REGEX = /^[a-zA-Z0-9._-]+$/;
|
||||
|
||||
/** Parses a single `?attr=` URL value. Returns null for invalid / blank input. */
|
||||
export function parseAttrParam(raw: string): AttributeFilter | null {
|
||||
if (!raw) return null;
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
|
||||
const colon = trimmed.indexOf(':');
|
||||
if (colon < 0) {
|
||||
return KEY_REGEX.test(trimmed) ? { key: trimmed } : null;
|
||||
}
|
||||
|
||||
const key = trimmed.substring(0, colon).trim();
|
||||
const value = raw.substring(raw.indexOf(':') + 1);
|
||||
if (!KEY_REGEX.test(key)) return null;
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
/** Serialises an AttributeFilter back to a URL `?attr=` value. */
|
||||
export function formatAttrParam(f: AttributeFilter): string {
|
||||
return f.value === undefined ? f.key : `${f.key}:${f.value}`;
|
||||
}
|
||||
|
||||
const FACET_REGEX = /^\s*([a-zA-Z0-9._-]+)\s*:\s*(\S(?:.*\S)?)\s*$/;
|
||||
|
||||
/** Parses a cmd-k query like `order: 47` into a facet descriptor. */
|
||||
export function parseFacetQuery(query: string): AttributeFilter | null {
|
||||
const m = FACET_REGEX.exec(query);
|
||||
if (!m) return null;
|
||||
return { key: m[1], value: m[2] };
|
||||
}
|
||||
Reference in New Issue
Block a user