Compare commits

..

17 Commits

Author SHA1 Message Date
hsiegeln
c5b6f2bbad fix(dirty-state): exclude live-pushed fields from deploy diff
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m13s
CI / docker (push) Successful in 1m2s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s
SonarQube / sonarqube (push) Successful in 4m17s
Live-pushed config fields (taps, tapVersion, tracedProcessors,
routeRecording) apply via SSE CONFIG_UPDATE — they take effect on
running agents without a redeploy and are fetched on agent restart
from application_config. They must not contribute to the
"pending deploy" diff against the last-successful-deployment snapshot.

Before this fix, applying a tap from the process diagram correctly
rolled out in real time but then marked the app "Pending Deploy (1)"
because DirtyStateCalculator compared every agentConfig field. This
also contradicted the UI rule (ui.md) that the live tabs "never mark
dirty".

Adds taps, tapVersion, tracedProcessors, routeRecording to
AGENT_CONFIG_IGNORED_KEYS. Updates the nested-path test to use a
staged field (sensitiveKeys) and adds a new test asserting that
divergent live-push fields keep dirty=false.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:42:07 +02:00
83c3ac3ef3 Merge pull request 'feat(ui): show deployment status + rich pending-deploy tooltip on app header' (#151) from feature/deployment-status-badge into main
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m20s
CI / docker (push) Successful in 23s
CI / deploy (push) Successful in 43s
CI / deploy-feature (push) Has been skipped
Reviewed-on: #151
2026-04-24 13:50:00 +02:00
7dd7317cb8 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
2026-04-24 13:49:51 +02:00
2654271494 Merge pull request 'feature/cmdk-attribute-filter' (#150) from feature/cmdk-attribute-filter into main
Some checks failed
CI / docker (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / cleanup-branch (push) Has been cancelled
CI / build (push) Has been cancelled
Reviewed-on: #150
2026-04-24 13:49:24 +02:00
hsiegeln
9aad2f3871 docs(rules): document AttributeFilter + SearchController attr param
All checks were successful
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 1m50s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:22:27 +02:00
hsiegeln
cbaac2bfa5 feat(cmdk): Enter on 'key: value' query submits as attribute facet 2026-04-24 11:21:12 +02:00
hsiegeln
7529a9ce99 feat(cmdk): synthetic facet result when query matches key: value 2026-04-24 11:18:13 +02:00
hsiegeln
09309de982 fix(cmdk): attribute clicks filter the exchange list via ?attr= instead of opening one exchange 2026-04-24 11:13:28 +02:00
hsiegeln
56c41814fc fix(ui): gate AUTO badge on attributeFilters too 2026-04-24 11:11:26 +02:00
hsiegeln
68704e15b4 feat(ui): exchange list reads ?attr= URL params and renders filter chips
(carries forward pre-existing attribute-badge color-by-key tweak)
2026-04-24 11:05:50 +02:00
hsiegeln
510206c752 feat(ui): add attribute-filter URL and facet parsing helpers 2026-04-24 10:58:35 +02:00
hsiegeln
58e9695b4c chore(ui): regenerate openapi types with AttributeFilter 2026-04-24 10:39:45 +02:00
hsiegeln
f27a0044f1 refactor(search): align ResponseStatusException imports + add wildcard HTTP test 2026-04-24 10:30:42 +02:00
hsiegeln
5c9323cfed feat(search): accept attr= multi-value query param on /executions GET
Add a repeatable attr query parameter to the GET /executions endpoint that
parses key-only (exists check) and key:value (exact or wildcard-via-*)
filters. Invalid keys are mapped to HTTP 400 via ResponseStatusException.
The POST /executions/search path already honoured attributeFilters from
the request body via the Jackson canonical ctor; an IT now proves it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:23:52 +02:00
hsiegeln
2dcbd5a772 feat(search): push AttributeFilter list into ClickHouse WHERE clause
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:13:30 +02:00
hsiegeln
f9b5f235cc feat(search): extend SearchRequest with attributeFilters (legacy ctor preserved) 2026-04-24 09:59:05 +02:00
hsiegeln
0b419db9f1 feat(search): add AttributeFilter record with key regex + wildcard pattern translation 2026-04-24 09:51:28 +02:00
18 changed files with 861 additions and 37 deletions

View File

@@ -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`.

View File

@@ -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`.

View File

@@ -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.")

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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");
}
}

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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
);
}
}

View File

@@ -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

View File

@@ -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
View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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);
}

View File

@@ -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: &ldquo;{textFilter}&rdquo;
<button
className={styles.clearSearch}
onClick={() => setSearchParams({})}
title="Clear search"
>
<X size={12} />
</button>
{textFilter && (
<>
Search: &ldquo;{textFilter}&rdquo;
<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>

View 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();
});
});

View 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] };
}