Compare commits

...

10 Commits

Author SHA1 Message Date
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
11 changed files with 579 additions and 27 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

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

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 {
return;
}
const facet = parseFacetQuery(query);
const baseParts = ['/exchanges'];
if (scope.appId) baseParts.push(scope.appId);
if (scope.routeId) baseParts.push(scope.routeId);
navigate(`${baseParts.join('/')}?text=${encodeURIComponent(query)}`);
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,25 +230,54 @@ 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 }} />
{textFilter && (
<>
Search: &ldquo;{textFilter}&rdquo;
<button
className={styles.clearSearch}
onClick={() => setSearchParams({})}
title="Clear search"
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>
<div className={tableStyles.tableRight}>
<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] };
}