Merge branch 'main' into feature/deployment-status-badge
Some checks failed
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m7s
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m6s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
CI / docker (push) Successful in 1m48s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Failing after 2m19s
Some checks failed
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m7s
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m6s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
CI / docker (push) Successful in 1m48s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Failing after 2m19s
This commit is contained in:
@@ -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.
|
- `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.
|
- `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.
|
- `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`.
|
- `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).
|
- `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`.
|
- `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
|
## 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`.
|
- `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
|
- `ExecutionStats`, `ExecutionSummary` — stats aggregation records
|
||||||
- `StatsTimeseries`, `TopError` — timeseries and error DTOs
|
- `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`.
|
- `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.AppSettings;
|
||||||
import com.cameleer.server.core.admin.AppSettingsRepository;
|
import com.cameleer.server.core.admin.AppSettingsRepository;
|
||||||
import com.cameleer.server.core.runtime.Environment;
|
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.ExecutionStats;
|
||||||
import com.cameleer.server.core.search.ExecutionSummary;
|
import com.cameleer.server.core.search.ExecutionSummary;
|
||||||
import com.cameleer.server.core.search.SearchRequest;
|
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 com.cameleer.server.core.storage.StatsStore;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -57,11 +61,19 @@ public class SearchController {
|
|||||||
@RequestParam(name = "agentId", required = false) String instanceId,
|
@RequestParam(name = "agentId", required = false) String instanceId,
|
||||||
@RequestParam(required = false) String processorType,
|
@RequestParam(required = false) String processorType,
|
||||||
@RequestParam(required = false) String application,
|
@RequestParam(required = false) String application,
|
||||||
|
@RequestParam(name = "attr", required = false) List<String> attr,
|
||||||
@RequestParam(defaultValue = "0") int offset,
|
@RequestParam(defaultValue = "0") int offset,
|
||||||
@RequestParam(defaultValue = "50") int limit,
|
@RequestParam(defaultValue = "50") int limit,
|
||||||
@RequestParam(required = false) String sortField,
|
@RequestParam(required = false) String sortField,
|
||||||
@RequestParam(required = false) String sortDir) {
|
@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(
|
SearchRequest request = new SearchRequest(
|
||||||
status, timeFrom, timeTo,
|
status, timeFrom, timeTo,
|
||||||
null, null,
|
null, null,
|
||||||
@@ -72,12 +84,36 @@ public class SearchController {
|
|||||||
offset, limit,
|
offset, limit,
|
||||||
sortField, sortDir,
|
sortField, sortDir,
|
||||||
null,
|
null,
|
||||||
env.slug()
|
env.slug(),
|
||||||
|
attributeFilters
|
||||||
);
|
);
|
||||||
|
|
||||||
return ResponseEntity.ok(searchService.search(request));
|
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")
|
@PostMapping("/executions/search")
|
||||||
@Operation(summary = "Advanced search with all filters",
|
@Operation(summary = "Advanced search with all filters",
|
||||||
description = "Env from the path overrides any environment field in the body.")
|
description = "Env from the path overrides any environment field in the body.")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.cameleer.server.app.search;
|
package com.cameleer.server.app.search;
|
||||||
|
|
||||||
import com.cameleer.server.core.alerting.AlertMatchSpec;
|
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.ExecutionSummary;
|
||||||
import com.cameleer.server.core.search.SearchRequest;
|
import com.cameleer.server.core.search.SearchRequest;
|
||||||
import com.cameleer.server.core.search.SearchResult;
|
import com.cameleer.server.core.search.SearchResult;
|
||||||
@@ -256,6 +257,23 @@ public class ClickHouseSearchIndex implements SearchIndex {
|
|||||||
params.add(likeTerm);
|
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);
|
return String.join(" AND ", conditions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,42 @@ class SearchControllerIT extends AbstractPostgresIT {
|
|||||||
""", i, i, i, i, i));
|
""", 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).
|
// Wait for async ingestion + search indexing via REST (no raw SQL).
|
||||||
// Probe the last seeded execution to avoid false positives from
|
// Probe the last seeded execution to avoid false positives from
|
||||||
// other test classes that may have written into the shared CH tables.
|
// 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());
|
JsonNode body = objectMapper.readTree(r.getBody());
|
||||||
assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(1);
|
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
|
@Test
|
||||||
@@ -371,6 +412,69 @@ class SearchControllerIT extends AbstractPostgresIT {
|
|||||||
assertThat(body.get("limit").asInt()).isEqualTo(50);
|
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 ---
|
// --- Helper methods ---
|
||||||
|
|
||||||
private void ingest(String json) {
|
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.app.storage.ClickHouseExecutionStore;
|
||||||
import com.cameleer.server.core.ingestion.MergedExecution;
|
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.ExecutionSummary;
|
||||||
import com.cameleer.server.core.search.SearchRequest;
|
import com.cameleer.server.core.search.SearchRequest;
|
||||||
import com.cameleer.server.core.search.SearchResult;
|
import com.cameleer.server.core.search.SearchResult;
|
||||||
@@ -62,7 +63,7 @@ class ClickHouseSearchIndexIT {
|
|||||||
500L,
|
500L,
|
||||||
"", "", "", "", "", "",
|
"", "", "", "", "", "",
|
||||||
"hash-abc", "FULL",
|
"hash-abc", "FULL",
|
||||||
"{\"order\":\"12345\"}", "", "", "", "", "", "{\"env\":\"prod\"}",
|
"", "", "", "", "", "", "{\"order\":\"12345\",\"tenant\":\"acme\"}",
|
||||||
"", "",
|
"", "",
|
||||||
false, false,
|
false, false,
|
||||||
null, null
|
null, null
|
||||||
@@ -79,7 +80,7 @@ class ClickHouseSearchIndexIT {
|
|||||||
"java.lang.NPE\n at Foo.bar(Foo.java:42)",
|
"java.lang.NPE\n at Foo.bar(Foo.java:42)",
|
||||||
"NullPointerException", "RUNTIME", "", "",
|
"NullPointerException", "RUNTIME", "", "",
|
||||||
"", "FULL",
|
"", "FULL",
|
||||||
"", "", "", "", "", "", "",
|
"", "", "", "", "", "", "{\"order\":\"99999\"}",
|
||||||
"", "",
|
"", "",
|
||||||
false, false,
|
false, false,
|
||||||
null, null
|
null, null
|
||||||
@@ -309,4 +310,59 @@ class ClickHouseSearchIndexIT {
|
|||||||
assertThat(result.total()).isEqualTo(1);
|
assertThat(result.total()).isEqualTo(1);
|
||||||
assertThat(result.data().get(0).executionId()).isEqualTo("exec-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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 sortField,
|
||||||
String sortDir,
|
String sortDir,
|
||||||
String afterExecutionId,
|
String afterExecutionId,
|
||||||
String environment
|
String environment,
|
||||||
|
List<AttributeFilter> attributeFilters
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private static final int DEFAULT_LIMIT = 50;
|
private static final int DEFAULT_LIMIT = 50;
|
||||||
@@ -83,6 +84,24 @@ public record SearchRequest(
|
|||||||
if (offset < 0) offset = 0;
|
if (offset < 0) offset = 0;
|
||||||
if (sortField == null || !ALLOWED_SORT_FIELDS.contains(sortField)) sortField = "startTime";
|
if (sortField == null || !ALLOWED_SORT_FIELDS.contains(sortField)) sortField = "startTime";
|
||||||
if (!"asc".equalsIgnoreCase(sortDir)) sortDir = "desc";
|
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. */
|
/** Returns the snake_case column name for ORDER BY. */
|
||||||
@@ -96,7 +115,8 @@ public record SearchRequest(
|
|||||||
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
|
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
|
||||||
text, textInBody, textInHeaders, textInErrors,
|
text, textInBody, textInHeaders, textInErrors,
|
||||||
routeId, instanceId, processorType, applicationId, resolvedInstanceIds,
|
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,
|
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
|
||||||
text, textInBody, textInHeaders, textInErrors,
|
text, textInBody, textInHeaders, textInErrors,
|
||||||
routeId, instanceId, processorType, applicationId, instanceIds,
|
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,
|
status, ts, timeTo, durationMin, durationMax, correlationId,
|
||||||
text, textInBody, textInHeaders, textInErrors,
|
text, textInBody, textInHeaders, textInErrors,
|
||||||
routeId, instanceId, processorType, applicationId, instanceIds,
|
routeId, instanceId, processorType, applicationId, instanceIds,
|
||||||
offset, limit, sortField, sortDir, afterExecutionId, environment
|
offset, limit, sortField, sortDir, afterExecutionId, environment,
|
||||||
|
attributeFilters
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/roles": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1556,7 +1576,7 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Find the latest diagram for this app's route in this environment
|
* 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"];
|
get: operations["findByAppAndRoute"];
|
||||||
put?: never;
|
put?: never;
|
||||||
@@ -1912,6 +1932,46 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/rbac/stats": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2209,6 +2269,17 @@ export interface components {
|
|||||||
[key: string]: number;
|
[key: string]: number;
|
||||||
};
|
};
|
||||||
sensitiveKeys?: string[];
|
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: {
|
TapDefinition: {
|
||||||
tapId?: string;
|
tapId?: string;
|
||||||
@@ -2630,6 +2701,12 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
};
|
};
|
||||||
|
AttributeFilter: {
|
||||||
|
key?: string;
|
||||||
|
value?: string;
|
||||||
|
keyOnly?: boolean;
|
||||||
|
wildcard?: boolean;
|
||||||
|
};
|
||||||
SearchRequest: {
|
SearchRequest: {
|
||||||
status?: string;
|
status?: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -2658,6 +2735,7 @@ export interface components {
|
|||||||
sortDir?: string;
|
sortDir?: string;
|
||||||
afterExecutionId?: string;
|
afterExecutionId?: string;
|
||||||
environment?: string;
|
environment?: string;
|
||||||
|
attributeFilters?: components["schemas"]["AttributeFilter"][];
|
||||||
};
|
};
|
||||||
ExecutionSummary: {
|
ExecutionSummary: {
|
||||||
executionId: string;
|
executionId: string;
|
||||||
@@ -2967,6 +3045,42 @@ export interface components {
|
|||||||
SetPasswordRequest: {
|
SetPasswordRequest: {
|
||||||
password?: string;
|
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: {
|
CreateRoleRequest: {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -3491,6 +3605,19 @@ export interface components {
|
|||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
avgDurationMs?: number;
|
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: {
|
SensitiveKeysConfig: {
|
||||||
keys?: string[];
|
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: {
|
listRoles: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -7068,6 +7219,7 @@ export interface operations {
|
|||||||
agentId?: string;
|
agentId?: string;
|
||||||
processorType?: string;
|
processorType?: string;
|
||||||
application?: string;
|
application?: string;
|
||||||
|
attr?: string[];
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
sortField?: string;
|
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: {
|
getStats: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { EnvironmentSwitcherModal } from './EnvironmentSwitcherModal';
|
|||||||
import { envColorVar } from './env-colors';
|
import { envColorVar } from './env-colors';
|
||||||
import { useScope } from '../hooks/useScope';
|
import { useScope } from '../hooks/useScope';
|
||||||
import { formatDuration } from '../utils/format-utils';
|
import { formatDuration } from '../utils/format-utils';
|
||||||
|
import { parseFacetQuery, formatAttrParam } from '../utils/attribute-filter';
|
||||||
import {
|
import {
|
||||||
buildAppTreeNodes,
|
buildAppTreeNodes,
|
||||||
buildAdminTreeNodes,
|
buildAdminTreeNodes,
|
||||||
@@ -111,7 +112,11 @@ function buildSearchData(
|
|||||||
id: `attr-key-${key}`,
|
id: `attr-key-${key}`,
|
||||||
category: 'attribute',
|
category: 'attribute',
|
||||||
title: key,
|
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]);
|
}, [isAdminPage, catalogRef.current, exchangeResults, debouncedQuery, alertingSearchData]);
|
||||||
|
|
||||||
const searchData = isAdminPage ? adminSearchData : operationalSearchData;
|
const searchData = isAdminPage ? adminSearchData : operationalSearchData;
|
||||||
@@ -744,6 +761,32 @@ function LayoutContent() {
|
|||||||
setPaletteOpen(false);
|
setPaletteOpen(false);
|
||||||
return;
|
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 (result.path) {
|
||||||
if (ADMIN_CATEGORIES.has(result.category)) {
|
if (ADMIN_CATEGORIES.has(result.category)) {
|
||||||
const itemId = result.id.split(':').slice(1).join(':');
|
const itemId = result.id.split(':').slice(1).join(':');
|
||||||
@@ -752,7 +795,7 @@ function LayoutContent() {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const state: Record<string, unknown> = { sidebarReveal: result.path };
|
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);
|
const parts = result.path.split('/').filter(Boolean);
|
||||||
if (parts.length === 4 && parts[0] === 'exchanges') {
|
if (parts.length === 4 && parts[0] === 'exchanges') {
|
||||||
state.selectedExchange = {
|
state.selectedExchange = {
|
||||||
@@ -766,7 +809,7 @@ function LayoutContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setPaletteOpen(false);
|
setPaletteOpen(false);
|
||||||
}, [navigate, setPaletteOpen]);
|
}, [navigate, setPaletteOpen, scope.appId, scope.routeId]);
|
||||||
|
|
||||||
const handlePaletteSubmit = useCallback((query: string) => {
|
const handlePaletteSubmit = useCallback((query: string) => {
|
||||||
if (isAdminPage) {
|
if (isAdminPage) {
|
||||||
@@ -780,12 +823,18 @@ function LayoutContent() {
|
|||||||
} else {
|
} else {
|
||||||
navigate('/admin/rbac');
|
navigate('/admin/rbac');
|
||||||
}
|
}
|
||||||
} else {
|
return;
|
||||||
const baseParts = ['/exchanges'];
|
|
||||||
if (scope.appId) baseParts.push(scope.appId);
|
|
||||||
if (scope.routeId) baseParts.push(scope.routeId);
|
|
||||||
navigate(`${baseParts.join('/')}?text=${encodeURIComponent(query)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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]);
|
}, [isAdminPage, adminSearchData, handlePaletteSelect, navigate, scope.appId, scope.routeId]);
|
||||||
|
|
||||||
const handleSidebarNavigate = useCallback((path: string) => {
|
const handleSidebarNavigate = useCallback((path: string) => {
|
||||||
|
|||||||
@@ -139,3 +139,23 @@
|
|||||||
color: var(--text-muted);
|
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 { useEnvironmentStore } from '../../api/environment-store'
|
||||||
import type { ExecutionSummary } from '../../api/types'
|
import type { ExecutionSummary } from '../../api/types'
|
||||||
import { attributeBadgeColor } from '../../utils/attribute-color'
|
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 { formatDuration, statusLabel } from '../../utils/format-utils'
|
||||||
import styles from './Dashboard.module.css'
|
import styles from './Dashboard.module.css'
|
||||||
import tableStyles from '../../styles/table-section.module.css'
|
import tableStyles from '../../styles/table-section.module.css'
|
||||||
@@ -84,7 +86,7 @@ function buildColumns(hasAttributes: boolean): Column<Row>[] {
|
|||||||
<div className={styles.attrCell}>
|
<div className={styles.attrCell}>
|
||||||
{shown.map(([k, v]) => (
|
{shown.map(([k, v]) => (
|
||||||
<span key={k} title={k}>
|
<span key={k} title={k}>
|
||||||
<Badge label={String(v)} color={attributeBadgeColor(String(v))} />
|
<Badge label={String(v)} color={attributeBadgeColor(k)} />
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{overflow > 0 && <span className={styles.attrOverflow}>+{overflow}</span>}
|
{overflow > 0 && <span className={styles.attrOverflow}>+{overflow}</span>}
|
||||||
@@ -147,6 +149,12 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const textFilter = searchParams.get('text') || undefined
|
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 [selectedId, setSelectedId] = useState<string | undefined>(activeExchangeId)
|
||||||
const [sortField, setSortField] = useState<string>('startTime')
|
const [sortField, setSortField] = useState<string>('startTime')
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
||||||
@@ -180,12 +188,13 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo
|
|||||||
environment: selectedEnv,
|
environment: selectedEnv,
|
||||||
status: statusParam,
|
status: statusParam,
|
||||||
text: textFilter,
|
text: textFilter,
|
||||||
|
attributeFilters: attributeFilters.length > 0 ? attributeFilters : undefined,
|
||||||
sortField,
|
sortField,
|
||||||
sortDir,
|
sortDir,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: textFilter ? 200 : 50,
|
limit: textFilter || attributeFilters.length > 0 ? 200 : 50,
|
||||||
},
|
},
|
||||||
!textFilter,
|
!textFilter && attributeFilters.length === 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── Rows ────────────────────────────────────────────────────────────────
|
// ─── Rows ────────────────────────────────────────────────────────────────
|
||||||
@@ -221,17 +230,46 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo
|
|||||||
<div className={`${tableStyles.tableSection} ${styles.tableWrap}`}>
|
<div className={`${tableStyles.tableSection} ${styles.tableWrap}`}>
|
||||||
<div className={tableStyles.tableHeader}>
|
<div className={tableStyles.tableHeader}>
|
||||||
<span className={tableStyles.tableTitle}>
|
<span className={tableStyles.tableTitle}>
|
||||||
{textFilter ? (
|
{textFilter || attributeFilters.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Search size={14} style={{ marginRight: 4, verticalAlign: -2 }} />
|
<Search size={14} style={{ marginRight: 4, verticalAlign: -2 }} />
|
||||||
Search: “{textFilter}”
|
{textFilter && (
|
||||||
<button
|
<>
|
||||||
className={styles.clearSearch}
|
Search: “{textFilter}”
|
||||||
onClick={() => setSearchParams({})}
|
<button
|
||||||
title="Clear search"
|
className={styles.clearSearch}
|
||||||
>
|
onClick={() => {
|
||||||
<X size={12} />
|
const next = new URLSearchParams(searchParams);
|
||||||
</button>
|
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'}
|
) : 'Recent Exchanges'}
|
||||||
</span>
|
</span>
|
||||||
@@ -239,7 +277,7 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo
|
|||||||
<span className={tableStyles.tableMeta}>
|
<span className={tableStyles.tableMeta}>
|
||||||
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
|
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
|
||||||
</span>
|
</span>
|
||||||
{!textFilter && <Badge label="AUTO" color="success" />}
|
{!textFilter && attributeFilters.length === 0 && <Badge label="AUTO" color="success" />}
|
||||||
</div>
|
</div>
|
||||||
</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