Compare commits
23 Commits
75a41929c4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5b6f2bbad | ||
| 83c3ac3ef3 | |||
| 7dd7317cb8 | |||
| 2654271494 | |||
|
|
888f589934 | ||
|
|
9aad2f3871 | ||
|
|
cbaac2bfa5 | ||
|
|
7529a9ce99 | ||
|
|
09309de982 | ||
|
|
56c41814fc | ||
|
|
68704e15b4 | ||
|
|
510206c752 | ||
|
|
58e9695b4c | ||
|
|
f27a0044f1 | ||
|
|
5c9323cfed | ||
|
|
2dcbd5a772 | ||
|
|
f9b5f235cc | ||
|
|
0b419db9f1 | ||
|
|
5f6f9e523d | ||
|
|
35319dc666 | ||
|
|
3c2409ed6e | ||
|
|
ca401363ec | ||
|
|
b5ee9e1d1f |
@@ -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`.
|
||||
@@ -109,7 +109,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
|
||||
- `UsageAnalyticsController` — GET `/api/v1/admin/usage` (ClickHouse `usage_events`).
|
||||
- `ClickHouseAdminController` — GET `/api/v1/admin/clickhouse/**` (conditional on `infrastructureendpoints` flag).
|
||||
- `DatabaseAdminController` — GET `/api/v1/admin/database/**` (conditional on `infrastructureendpoints` flag).
|
||||
- `ServerMetricsAdminController` — `/api/v1/admin/server-metrics/**`. GET `/catalog`, GET `/instances`, POST `/query`. Generic read API over the `server_metrics` ClickHouse table so SaaS dashboards don't need direct CH access. Delegates to `ServerMetricsQueryStore` (impl `ClickHouseServerMetricsQueryStore`). Validation: metric/tag regex `^[a-zA-Z0-9._]+$`, statistic regex `^[a-z_]+$`, `to - from ≤ 31 days`, stepSeconds ∈ [10, 3600], response capped at 500 series. `IllegalArgumentException` → 400. `/query` supports `raw` + `delta` modes (delta does per-`server_instance_id` positive-clipped differences, then aggregates across instances). Derived `statistic=mean` for timers computes `sum(total|total_time)/sum(count)` per bucket.
|
||||
- `ServerMetricsAdminController` — `/api/v1/admin/server-metrics/**`. GET `/catalog`, GET `/instances`, POST `/query`. Generic read API over the `server_metrics` ClickHouse table so SaaS dashboards don't need direct CH access. Delegates to `ServerMetricsQueryStore` (impl `ClickHouseServerMetricsQueryStore`). Visibility matches ClickHouse/Database admin: `@ConditionalOnProperty(infrastructureendpoints, matchIfMissing=true)` + class-level `@PreAuthorize("hasRole('ADMIN')")`. Validation: metric/tag regex `^[a-zA-Z0-9._]+$`, statistic regex `^[a-z_]+$`, `to - from ≤ 31 days`, stepSeconds ∈ [10, 3600], response capped at 500 series. `IllegalArgumentException` → 400. `/query` supports `raw` + `delta` modes (delta does per-`server_instance_id` positive-clipped differences, then aggregates across instances). Derived `statistic=mean` for timers computes `sum(total|total_time)/sum(count)` per bucket.
|
||||
|
||||
### Other (flat)
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -21,6 +21,7 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
|
||||
|
||||
**Admin pages** (ADMIN-only, under `/admin/`):
|
||||
- **Sensitive Keys** (`ui/src/pages/Admin/SensitiveKeysPage.tsx`) — global sensitive key masking config. Shows agent built-in defaults as outlined Badge reference, editable Tag pills for custom keys, amber-highlighted push-to-agents toggle. Keys add to (not replace) agent defaults. Per-app sensitive key additions managed via `ApplicationConfigController` API. Note: `AppConfigDetailPage.tsx` exists but is not routed in `router.tsx`.
|
||||
- **Server Metrics** (`ui/src/pages/Admin/ServerMetricsAdminPage.tsx`) — dashboard over the `server_metrics` ClickHouse table. Visibility matches Database/ClickHouse pages: gated on `capabilities.infrastructureEndpoints` in `buildAdminTreeNodes`; backend is `@ConditionalOnProperty(infrastructureendpoints) + @PreAuthorize('hasRole(ADMIN)')`. Uses the generic `/api/v1/admin/server-metrics/{catalog,instances,query}` API via `ui/src/api/queries/admin/serverMetrics.ts` hooks (`useServerMetricsCatalog`, `useServerMetricsInstances`, `useServerMetricsSeries`), all three of which take a `ServerMetricsRange = { from: Date; to: Date }`. Time range is driven by the global TopBar picker via `useGlobalFilters()` — no page-local selector; bucket size auto-scales through `stepSecondsFor(windowSeconds)` (10 s up to 1 h buckets). Toolbar is just server-instance badges. Sections: Server health (agents/ingestion/auth), JVM (memory/CPU/GC/threads), HTTP & DB pools, Alerting (conditional on catalog), Deployments (conditional on catalog). Each panel is a `ThemedChart` with `Line`/`Area` children from the design system; multi-series responses are flattened into overlap rows by bucket timestamp. Alerting and Deployments rows are hidden when their metrics aren't in the catalog (zero-deploy / alerting-disabled installs).
|
||||
|
||||
## Key UI Files
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **cameleer-server** (9703 symbols, 24881 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **cameleer-server** (9731 symbols, 24987 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **cameleer-server** (9703 symbols, 24881 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **cameleer-server** (9731 symbols, 24987 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -7,7 +7,9 @@ import com.cameleer.server.core.storage.model.ServerMetricQueryRequest;
|
||||
import com.cameleer.server.core.storage.model.ServerMetricQueryResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -32,12 +34,23 @@ import java.util.Map;
|
||||
* <li>{@code GET /instances} — list server instances (useful for partitioning counter math)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Protected by the {@code /api/v1/admin/**} catch-all in {@code SecurityConfig} — requires ADMIN role.
|
||||
* <p>Visibility matches {@code ClickHouseAdminController} / {@code DatabaseAdminController}:
|
||||
* <ul>
|
||||
* <li>Conditional on {@code cameleer.server.security.infrastructureendpoints=true} (default).</li>
|
||||
* <li>Class-level {@code @PreAuthorize("hasRole('ADMIN')")} on top of the
|
||||
* {@code /api/v1/admin/**} catch-all in {@code SecurityConfig}.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@ConditionalOnProperty(
|
||||
name = "cameleer.server.security.infrastructureendpoints",
|
||||
havingValue = "true",
|
||||
matchIfMissing = true
|
||||
)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/server-metrics")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Tag(name = "Server Self-Metrics",
|
||||
description = "Read API over the server's own Micrometer registry snapshots for dashboards")
|
||||
description = "Read API over the server's own Micrometer registry snapshots (ADMIN only)")
|
||||
public class ServerMetricsAdminController {
|
||||
|
||||
/** Default lookback window for catalog/instances when from/to are omitted. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cameleer.server.app.search;
|
||||
|
||||
import com.cameleer.server.core.alerting.AlertMatchSpec;
|
||||
import com.cameleer.server.core.search.AttributeFilter;
|
||||
import com.cameleer.server.core.search.ExecutionSummary;
|
||||
import com.cameleer.server.core.search.SearchRequest;
|
||||
import com.cameleer.server.core.search.SearchResult;
|
||||
@@ -256,6 +257,23 @@ public class ClickHouseSearchIndex implements SearchIndex {
|
||||
params.add(likeTerm);
|
||||
}
|
||||
|
||||
// Structured attribute filters. Keys were validated at AttributeFilter construction
|
||||
// time against ^[a-zA-Z0-9._-]+$ so they are safe to single-quote-inline; the JSON path
|
||||
// argument of JSONExtractString does not accept a ? placeholder in ClickHouse JDBC
|
||||
// (same constraint as countExecutionsForAlerting below). Values are parameter-bound.
|
||||
for (AttributeFilter filter : request.attributeFilters()) {
|
||||
String escapedKey = filter.key().replace("'", "\\'");
|
||||
if (filter.isKeyOnly()) {
|
||||
conditions.add("JSONHas(attributes, '" + escapedKey + "')");
|
||||
} else if (filter.isWildcard()) {
|
||||
conditions.add("JSONExtractString(attributes, '" + escapedKey + "') LIKE ?");
|
||||
params.add(filter.toLikePattern());
|
||||
} else {
|
||||
conditions.add("JSONExtractString(attributes, '" + escapedKey + "') = ?");
|
||||
params.add(filter.value());
|
||||
}
|
||||
}
|
||||
|
||||
return String.join(" AND ", conditions);
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,42 @@ class SearchControllerIT extends AbstractPostgresIT {
|
||||
""", i, i, i, i, i));
|
||||
}
|
||||
|
||||
// Executions 11-12: carry structured attributes used by the attribute-filter tests.
|
||||
ingest("""
|
||||
{
|
||||
"exchangeId": "ex-search-attr-1",
|
||||
"applicationId": "test-group",
|
||||
"instanceId": "test-agent-search-it",
|
||||
"routeId": "search-route-attr-1",
|
||||
"correlationId": "corr-attr-alpha",
|
||||
"status": "COMPLETED",
|
||||
"startTime": "2026-03-12T10:00:00Z",
|
||||
"endTime": "2026-03-12T10:00:00.050Z",
|
||||
"durationMs": 50,
|
||||
"attributes": {"order": "12345", "tenant": "acme"},
|
||||
"chunkSeq": 0,
|
||||
"final": true,
|
||||
"processors": []
|
||||
}
|
||||
""");
|
||||
ingest("""
|
||||
{
|
||||
"exchangeId": "ex-search-attr-2",
|
||||
"applicationId": "test-group",
|
||||
"instanceId": "test-agent-search-it",
|
||||
"routeId": "search-route-attr-2",
|
||||
"correlationId": "corr-attr-beta",
|
||||
"status": "COMPLETED",
|
||||
"startTime": "2026-03-12T10:01:00Z",
|
||||
"endTime": "2026-03-12T10:01:00.050Z",
|
||||
"durationMs": 50,
|
||||
"attributes": {"order": "99999"},
|
||||
"chunkSeq": 0,
|
||||
"final": true,
|
||||
"processors": []
|
||||
}
|
||||
""");
|
||||
|
||||
// Wait for async ingestion + search indexing via REST (no raw SQL).
|
||||
// Probe the last seeded execution to avoid false positives from
|
||||
// other test classes that may have written into the shared CH tables.
|
||||
@@ -174,6 +210,11 @@ class SearchControllerIT extends AbstractPostgresIT {
|
||||
JsonNode body = objectMapper.readTree(r.getBody());
|
||||
assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(1);
|
||||
});
|
||||
await().atMost(30, SECONDS).untilAsserted(() -> {
|
||||
ResponseEntity<String> r = searchGet("?correlationId=corr-attr-beta");
|
||||
JsonNode body = objectMapper.readTree(r.getBody());
|
||||
assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(1);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -371,6 +412,69 @@ class SearchControllerIT extends AbstractPostgresIT {
|
||||
assertThat(body.get("limit").asInt()).isEqualTo(50);
|
||||
}
|
||||
|
||||
@Test
|
||||
void attrParam_exactMatch_filtersToMatchingExecution() throws Exception {
|
||||
ResponseEntity<String> response = searchGet("?attr=order:12345&correlationId=corr-attr-alpha");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.get("total").asLong()).isEqualTo(1);
|
||||
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha");
|
||||
}
|
||||
|
||||
@Test
|
||||
void attrParam_keyOnly_matchesAnyExecutionCarryingTheKey() throws Exception {
|
||||
ResponseEntity<String> response = searchGet("?attr=tenant&correlationId=corr-attr-alpha");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.get("total").asLong()).isEqualTo(1);
|
||||
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha");
|
||||
}
|
||||
|
||||
@Test
|
||||
void attrParam_multipleValues_produceIntersection() throws Exception {
|
||||
// order:99999 AND tenant=* should yield zero — exec-attr-2 has order=99999 but no tenant.
|
||||
ResponseEntity<String> response = searchGet("?attr=order:99999&attr=tenant");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.get("total").asLong()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void attrParam_invalidKey_returns400() throws Exception {
|
||||
ResponseEntity<String> response = searchGet("?attr=bad%20key:x");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void attributeFilters_inPostBody_filtersCorrectly() throws Exception {
|
||||
ResponseEntity<String> response = searchPost("""
|
||||
{
|
||||
"attributeFilters": [
|
||||
{"key": "order", "value": "12345"}
|
||||
],
|
||||
"correlationId": "corr-attr-alpha"
|
||||
}
|
||||
""");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.get("total").asLong()).isEqualTo(1);
|
||||
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha");
|
||||
}
|
||||
|
||||
@Test
|
||||
void attrParam_wildcardValue_matchesOnPrefix() throws Exception {
|
||||
ResponseEntity<String> response = searchGet("?attr=order:1*&correlationId=corr-attr-alpha");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.get("total").asLong()).isEqualTo(1);
|
||||
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha");
|
||||
}
|
||||
|
||||
// --- Helper methods ---
|
||||
|
||||
private void ingest(String json) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.cameleer.server.app.search;
|
||||
|
||||
import com.cameleer.server.app.storage.ClickHouseExecutionStore;
|
||||
import com.cameleer.server.core.ingestion.MergedExecution;
|
||||
import com.cameleer.server.core.search.AttributeFilter;
|
||||
import com.cameleer.server.core.search.ExecutionSummary;
|
||||
import com.cameleer.server.core.search.SearchRequest;
|
||||
import com.cameleer.server.core.search.SearchResult;
|
||||
@@ -62,7 +63,7 @@ class ClickHouseSearchIndexIT {
|
||||
500L,
|
||||
"", "", "", "", "", "",
|
||||
"hash-abc", "FULL",
|
||||
"{\"order\":\"12345\"}", "", "", "", "", "", "{\"env\":\"prod\"}",
|
||||
"", "", "", "", "", "", "{\"order\":\"12345\",\"tenant\":\"acme\"}",
|
||||
"", "",
|
||||
false, false,
|
||||
null, null
|
||||
@@ -79,7 +80,7 @@ class ClickHouseSearchIndexIT {
|
||||
"java.lang.NPE\n at Foo.bar(Foo.java:42)",
|
||||
"NullPointerException", "RUNTIME", "", "",
|
||||
"", "FULL",
|
||||
"", "", "", "", "", "", "",
|
||||
"", "", "", "", "", "", "{\"order\":\"99999\"}",
|
||||
"", "",
|
||||
false, false,
|
||||
null, null
|
||||
@@ -309,4 +310,59 @@ class ClickHouseSearchIndexIT {
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.data().get(0).executionId()).isEqualTo("exec-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_byAttributeFilter_exactMatch_matchesExec1() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null, null,
|
||||
List.of(new AttributeFilter("order", "12345")));
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.data().get(0).executionId()).isEqualTo("exec-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_byAttributeFilter_keyOnly_matchesExec1AndExec2() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null, null,
|
||||
List.of(new AttributeFilter("order", null)));
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
assertThat(result.total()).isEqualTo(2);
|
||||
assertThat(result.data()).extracting(ExecutionSummary::executionId)
|
||||
.containsExactlyInAnyOrder("exec-1", "exec-2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_byAttributeFilter_wildcardValue_matchesExec1Only() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null, null,
|
||||
List.of(new AttributeFilter("order", "123*")));
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.data().get(0).executionId()).isEqualTo("exec-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_byAttributeFilter_multipleFiltersAreAnded() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null, null,
|
||||
List.of(
|
||||
new AttributeFilter("order", "12345"),
|
||||
new AttributeFilter("tenant", "acme")));
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.data().get(0).executionId()).isEqualTo("exec-1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,13 @@ import java.util.UUID;
|
||||
*/
|
||||
public class DirtyStateCalculator {
|
||||
|
||||
// Live-pushed fields are excluded from the deploy diff: changes to them take effect
|
||||
// via SSE config-update without a redeploy, so they are not "pending deploy" when they
|
||||
// differ from the last successful deployment snapshot. See ui/rules: the Traces & Taps
|
||||
// and Route Recording tabs apply with ?apply=live and "never mark dirty".
|
||||
private static final Set<String> AGENT_CONFIG_IGNORED_KEYS = Set.of(
|
||||
"version", "updatedAt", "updatedBy", "environment", "application"
|
||||
"version", "updatedAt", "updatedBy", "environment", "application",
|
||||
"taps", "tapVersion", "tracedProcessors", "routeRecording"
|
||||
);
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.cameleer.server.core.search;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Structured attribute filter for execution search.
|
||||
* <p>
|
||||
* Value semantics:
|
||||
* <ul>
|
||||
* <li>{@code value == null} or blank -> key-exists check</li>
|
||||
* <li>{@code value} contains {@code *} -> wildcard match (translated to SQL LIKE pattern)</li>
|
||||
* <li>otherwise -> exact match</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Keys must match {@code ^[a-zA-Z0-9._-]+$} — they are later inlined into
|
||||
* ClickHouse SQL via {@code JSONExtractString}, which does not accept a
|
||||
* parameter placeholder for the JSON path. Values are always parameter-bound.
|
||||
*/
|
||||
public record AttributeFilter(String key, String value) {
|
||||
|
||||
private static final Pattern KEY_PATTERN = Pattern.compile("^[a-zA-Z0-9._-]+$");
|
||||
|
||||
public AttributeFilter {
|
||||
if (key == null || !KEY_PATTERN.matcher(key).matches()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Invalid attribute key: must match " + KEY_PATTERN.pattern() + ", got: " + key);
|
||||
}
|
||||
if (value != null && value.isBlank()) {
|
||||
value = null;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isKeyOnly() {
|
||||
return value == null;
|
||||
}
|
||||
|
||||
public boolean isWildcard() {
|
||||
return value != null && value.indexOf('*') >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a SQL LIKE pattern for wildcard matches with {@code %} / {@code _} / {@code \}
|
||||
* in the source value escaped, or {@code null} for exact / key-only filters.
|
||||
*/
|
||||
public String toLikePattern() {
|
||||
if (!isWildcard()) return null;
|
||||
StringBuilder sb = new StringBuilder(value.length() + 4);
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
switch (c) {
|
||||
case '\\' -> sb.append("\\\\");
|
||||
case '%' -> sb.append("\\%");
|
||||
case '_' -> sb.append("\\_");
|
||||
case '*' -> sb.append('%');
|
||||
default -> sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,8 @@ public record SearchRequest(
|
||||
String sortField,
|
||||
String sortDir,
|
||||
String afterExecutionId,
|
||||
String environment
|
||||
String environment,
|
||||
List<AttributeFilter> attributeFilters
|
||||
) {
|
||||
|
||||
private static final int DEFAULT_LIMIT = 50;
|
||||
@@ -83,6 +84,24 @@ public record SearchRequest(
|
||||
if (offset < 0) offset = 0;
|
||||
if (sortField == null || !ALLOWED_SORT_FIELDS.contains(sortField)) sortField = "startTime";
|
||||
if (!"asc".equalsIgnoreCase(sortDir)) sortDir = "desc";
|
||||
if (attributeFilters == null) attributeFilters = List.of();
|
||||
}
|
||||
|
||||
/** Legacy 21-arg constructor preserved for existing call sites — defaults attributeFilters to empty. */
|
||||
public SearchRequest(
|
||||
String status, Instant timeFrom, Instant timeTo,
|
||||
Long durationMin, Long durationMax, String correlationId,
|
||||
String text, String textInBody, String textInHeaders, String textInErrors,
|
||||
String routeId, String instanceId, String processorType,
|
||||
String applicationId, List<String> instanceIds,
|
||||
int offset, int limit, String sortField, String sortDir,
|
||||
String afterExecutionId, String environment
|
||||
) {
|
||||
this(status, timeFrom, timeTo, durationMin, durationMax, correlationId,
|
||||
text, textInBody, textInHeaders, textInErrors,
|
||||
routeId, instanceId, processorType, applicationId, instanceIds,
|
||||
offset, limit, sortField, sortDir, afterExecutionId, environment,
|
||||
List.of());
|
||||
}
|
||||
|
||||
/** Returns the snake_case column name for ORDER BY. */
|
||||
@@ -96,7 +115,8 @@ public record SearchRequest(
|
||||
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
|
||||
text, textInBody, textInHeaders, textInErrors,
|
||||
routeId, instanceId, processorType, applicationId, resolvedInstanceIds,
|
||||
offset, limit, sortField, sortDir, afterExecutionId, environment
|
||||
offset, limit, sortField, sortDir, afterExecutionId, environment,
|
||||
attributeFilters
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +126,8 @@ public record SearchRequest(
|
||||
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
|
||||
text, textInBody, textInHeaders, textInErrors,
|
||||
routeId, instanceId, processorType, applicationId, instanceIds,
|
||||
offset, limit, sortField, sortDir, afterExecutionId, env
|
||||
offset, limit, sortField, sortDir, afterExecutionId, env,
|
||||
attributeFilters
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,7 +143,8 @@ public record SearchRequest(
|
||||
status, ts, timeTo, durationMin, durationMax, correlationId,
|
||||
text, textInBody, textInHeaders, textInErrors,
|
||||
routeId, instanceId, processorType, applicationId, instanceIds,
|
||||
offset, limit, sortField, sortDir, afterExecutionId, environment
|
||||
offset, limit, sortField, sortDir, afterExecutionId, environment,
|
||||
attributeFilters
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -114,9 +115,9 @@ class DirtyStateCalculatorTest {
|
||||
DirtyStateCalculator calc = CALC;
|
||||
|
||||
ApplicationConfig deployed = new ApplicationConfig();
|
||||
deployed.setTracedProcessors(Map.of("proc-1", "DEBUG"));
|
||||
deployed.setSensitiveKeys(List.of("password", "token"));
|
||||
ApplicationConfig desired = new ApplicationConfig();
|
||||
desired.setTracedProcessors(Map.of("proc-1", "TRACE"));
|
||||
desired.setSensitiveKeys(List.of("password", "token", "secret"));
|
||||
UUID jarId = UUID.randomUUID();
|
||||
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, deployed, Map.of(), null);
|
||||
|
||||
@@ -124,7 +125,29 @@ class DirtyStateCalculatorTest {
|
||||
|
||||
assertThat(result.dirty()).isTrue();
|
||||
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
|
||||
.contains("agentConfig.tracedProcessors.proc-1");
|
||||
.anyMatch(f -> f.startsWith("agentConfig.sensitiveKeys"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void livePushedFields_doNotMarkDirty() {
|
||||
// Taps, tracedProcessors, and routeRecording apply via live SSE push (never redeploy),
|
||||
// so they must not appear as "pending deploy" when they differ from the last deploy snapshot.
|
||||
ApplicationConfig deployed = new ApplicationConfig();
|
||||
deployed.setTracedProcessors(Map.of("proc-1", "DEBUG"));
|
||||
deployed.setRouteRecording(Map.of("route-a", true));
|
||||
deployed.setTapVersion(1);
|
||||
|
||||
ApplicationConfig desired = new ApplicationConfig();
|
||||
desired.setTracedProcessors(Map.of("proc-1", "TRACE", "proc-2", "DEBUG"));
|
||||
desired.setRouteRecording(Map.of("route-a", false, "route-b", true));
|
||||
desired.setTapVersion(5);
|
||||
|
||||
UUID jarId = UUID.randomUUID();
|
||||
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, deployed, Map.of(), null);
|
||||
DirtyStateResult result = CALC.compute(jarId, desired, Map.of(), snap);
|
||||
|
||||
assertThat(result.dirty()).isFalse();
|
||||
assertThat(result.differences()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.cameleer.server.core.search;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class AttributeFilterTest {
|
||||
|
||||
@Test
|
||||
void keyOnly_blankValue_normalizesToNull() {
|
||||
AttributeFilter f = new AttributeFilter("order", "");
|
||||
assertThat(f.value()).isNull();
|
||||
assertThat(f.isKeyOnly()).isTrue();
|
||||
assertThat(f.isWildcard()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void keyOnly_nullValue_isKeyOnly() {
|
||||
AttributeFilter f = new AttributeFilter("order", null);
|
||||
assertThat(f.isKeyOnly()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactValue_isNotWildcard() {
|
||||
AttributeFilter f = new AttributeFilter("order", "47");
|
||||
assertThat(f.isKeyOnly()).isFalse();
|
||||
assertThat(f.isWildcard()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void starInValue_isWildcard() {
|
||||
AttributeFilter f = new AttributeFilter("order", "47*");
|
||||
assertThat(f.isWildcard()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidKey_throws() {
|
||||
assertThatThrownBy(() -> new AttributeFilter("bad key", "x"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("attribute key");
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankKey_throws() {
|
||||
assertThatThrownBy(() -> new AttributeFilter(" ", null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void wildcardPattern_escapesLikeMetaCharacters() {
|
||||
AttributeFilter f = new AttributeFilter("order", "a_b%c\\d*");
|
||||
assertThat(f.toLikePattern()).isEqualTo("a\\_b\\%c\\\\d%");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactValue_toLikePattern_returnsNull() {
|
||||
AttributeFilter f = new AttributeFilter("order", "47");
|
||||
assertThat(f.toLikePattern()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchRequest_canonicalCtor_acceptsAttributeFilters() {
|
||||
SearchRequest r = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null, null,
|
||||
java.util.List.of(new AttributeFilter("order", "47")));
|
||||
assertThat(r.attributeFilters()).hasSize(1);
|
||||
assertThat(r.attributeFilters().get(0).key()).isEqualTo("order");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchRequest_legacyCtor_defaultsAttributeFiltersToEmpty() {
|
||||
SearchRequest r = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null, null);
|
||||
assertThat(r.attributeFilters()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchRequest_compactCtor_normalizesNullAttributeFilters() {
|
||||
SearchRequest r = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null, null,
|
||||
null);
|
||||
assertThat(r.attributeFilters()).isNotNull().isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -208,7 +208,16 @@ All query endpoints require JWT with `VIEWER` role or higher.
|
||||
|
||||
The server snapshots its own Micrometer registry into ClickHouse every 60 s (table `server_metrics`) — JVM, HTTP, DB pools, agent/ingestion business metrics, and alerting metrics. Use this instead of running an external Prometheus when building a server-health dashboard. The live scrape endpoint `/api/v1/prometheus` remains available for traditional scraping.
|
||||
|
||||
See [`docs/server-self-metrics.md`](./server-self-metrics.md) for the full metric catalog, suggested panels, and example queries.
|
||||
Two ways to consume:
|
||||
|
||||
| Consumer | How |
|
||||
|---|---|
|
||||
| Web UI (built-in) | `/admin/server-metrics` — 17 panels across Server Health / JVM / HTTP & DB / Alerting / Deployments with a 15 min–7 d time picker. ADMIN-only, hidden when `infrastructureendpoints=false`. |
|
||||
| Programmatic | Generic REST API under `/api/v1/admin/server-metrics/{catalog,instances,query}`. Same visibility rules. Designed for SaaS control planes that embed server health in their own console. |
|
||||
|
||||
Persistence can be disabled entirely with `cameleer.server.self-metrics.enabled=false`. Snapshot cadence via `cameleer.server.self-metrics.interval-ms` (default `60000`).
|
||||
|
||||
See [`docs/server-self-metrics.md`](./server-self-metrics.md) for the full metric catalog, API contract, and ready-to-paste query bodies for each panel.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
# Server Self-Metrics — Reference for Dashboard Builders
|
||||
|
||||
This is the reference for the SaaS team building the server-health dashboard. It documents the `server_metrics` ClickHouse table, every series you can expect to find in it, and the queries we recommend for each dashboard panel.
|
||||
This is the reference for anyone building a server-health dashboard on top of the Cameleer server. It documents the `server_metrics` ClickHouse table, every series you can expect to find in it, and the queries we recommend for each dashboard panel.
|
||||
|
||||
> **tl;dr** — Every 60 s, every meter in the server's Micrometer registry (all `cameleer.*`, all `alerting_*`, and the full Spring Boot Actuator set) is written into ClickHouse as one row per `(meter, statistic)` pair. No external Prometheus required.
|
||||
|
||||
---
|
||||
|
||||
## Built-in admin dashboard
|
||||
|
||||
The server ships a ready-to-use dashboard at **`/admin/server-metrics`** in the web UI. It renders the 17 panels listed below using `ThemedChart` from the design system. The window is driven by the app-wide time-range control in the TopBar (same one used by Exchanges, Dashboard, and Runtime), so every panel automatically reflects the range you've selected globally. Visibility mirrors the Database and ClickHouse admin pages:
|
||||
|
||||
- Requires the `ADMIN` role.
|
||||
- Hidden when `cameleer.server.security.infrastructureendpoints=false` (both the backend endpoints and the sidebar entry disappear).
|
||||
|
||||
Use this page for single-tenant installs and dev/staging — it's the fastest path to "is the server healthy right now?". For multi-tenant control planes, cross-environment rollups, or embedding metrics inside an existing operations console, call the REST API below instead.
|
||||
|
||||
---
|
||||
|
||||
## Table schema
|
||||
|
||||
```sql
|
||||
@@ -507,3 +518,5 @@ Below are 17 panels, each expressed as a single `POST /api/v1/admin/server-metri
|
||||
|
||||
- 2026-04-23 — initial write. Write-only backend.
|
||||
- 2026-04-23 — added generic REST API (`/api/v1/admin/server-metrics/{catalog,instances,query}`) so dashboards don't need direct ClickHouse access. All 17 suggested panels now expressed as single-endpoint queries.
|
||||
- 2026-04-24 — shipped the built-in `/admin/server-metrics` UI dashboard. Gated by `infrastructureendpoints` + ADMIN, identical visibility to `/admin/{database,clickhouse}`. Source: `ui/src/pages/Admin/ServerMetricsAdminPage.tsx`.
|
||||
- 2026-04-24 — dashboard now uses the global time-range control (`useGlobalFilters`) instead of a page-local picker. Bucket size auto-scales with the selected window (10 s → 1 h). Query hooks now take a `ServerMetricsRange = { from: Date; to: Date }` instead of a `windowSeconds` number so they work for any absolute or rolling range the TopBar supplies.
|
||||
|
||||
File diff suppressed because one or more lines are too long
125
ui/src/api/queries/admin/serverMetrics.ts
Normal file
125
ui/src/api/queries/admin/serverMetrics.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adminFetch } from './admin-api';
|
||||
import { useRefreshInterval } from '../use-refresh-interval';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ServerMetricCatalogEntry {
|
||||
metricName: string;
|
||||
metricType: string;
|
||||
statistics: string[];
|
||||
tagKeys: string[];
|
||||
}
|
||||
|
||||
export interface ServerInstanceInfo {
|
||||
serverInstanceId: string;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface ServerMetricPoint {
|
||||
t: string;
|
||||
v: number;
|
||||
}
|
||||
|
||||
export interface ServerMetricSeries {
|
||||
tags: Record<string, string>;
|
||||
points: ServerMetricPoint[];
|
||||
}
|
||||
|
||||
export interface ServerMetricQueryResponse {
|
||||
metric: string;
|
||||
statistic: string;
|
||||
aggregation: string;
|
||||
mode: string;
|
||||
stepSeconds: number;
|
||||
series: ServerMetricSeries[];
|
||||
}
|
||||
|
||||
export interface ServerMetricQueryRequest {
|
||||
metric: string;
|
||||
statistic?: string | null;
|
||||
from: string;
|
||||
to: string;
|
||||
stepSeconds?: number | null;
|
||||
groupByTags?: string[] | null;
|
||||
filterTags?: Record<string, string> | null;
|
||||
aggregation?: string | null;
|
||||
mode?: string | null;
|
||||
serverInstanceIds?: string[] | null;
|
||||
}
|
||||
|
||||
// ── Range helper ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Time range driving every hook below. Callers pass the window they want
|
||||
* to render; the hooks never invent their own "now" — that's the job of
|
||||
* the global time-range control.
|
||||
*/
|
||||
export interface ServerMetricsRange {
|
||||
from: Date;
|
||||
to: Date;
|
||||
}
|
||||
|
||||
function serializeRange(range: ServerMetricsRange) {
|
||||
return {
|
||||
from: range.from.toISOString(),
|
||||
to: range.to.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Query Hooks ────────────────────────────────────────────────────────
|
||||
|
||||
export function useServerMetricsCatalog(range: ServerMetricsRange) {
|
||||
const refetchInterval = useRefreshInterval(60_000);
|
||||
const { from, to } = serializeRange(range);
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'server-metrics', 'catalog', from, to],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ from, to });
|
||||
return adminFetch<ServerMetricCatalogEntry[]>(`/server-metrics/catalog?${params}`);
|
||||
},
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
export function useServerMetricsInstances(range: ServerMetricsRange) {
|
||||
const refetchInterval = useRefreshInterval(60_000);
|
||||
const { from, to } = serializeRange(range);
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'server-metrics', 'instances', from, to],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ from, to });
|
||||
return adminFetch<ServerInstanceInfo[]>(`/server-metrics/instances?${params}`);
|
||||
},
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic time-series query against the server_metrics table.
|
||||
*
|
||||
* The caller owns the window — passing the globally-selected range keeps
|
||||
* every panel aligned with the app-wide time control and allows inspection
|
||||
* of historical windows, not just "last N seconds from now".
|
||||
*/
|
||||
export function useServerMetricsSeries(
|
||||
request: Omit<ServerMetricQueryRequest, 'from' | 'to'>,
|
||||
range: ServerMetricsRange,
|
||||
opts?: { enabled?: boolean },
|
||||
) {
|
||||
const refetchInterval = useRefreshInterval(30_000);
|
||||
const { from, to } = serializeRange(range);
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'server-metrics', 'query', request, from, to],
|
||||
queryFn: () => {
|
||||
const body: ServerMetricQueryRequest = { ...request, from, to };
|
||||
return adminFetch<ServerMetricQueryResponse>('/server-metrics/query', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
refetchInterval,
|
||||
enabled: opts?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
200
ui/src/api/schema.d.ts
vendored
200
ui/src/api/schema.d.ts
vendored
@@ -1037,6 +1037,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/server-metrics/query": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Generic time-series query
|
||||
* @description Returns bucketed series for a single metric_name. Supports aggregation (avg/sum/max/min/latest), group-by-tag, filter-by-tag, counter delta mode, and a derived 'mean' statistic for timers.
|
||||
*/
|
||||
post: operations["query"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/roles": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1556,7 +1576,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* Find the latest diagram for this app's route in this environment
|
||||
* @description Resolves agents in this env for this app, then looks up the latest diagram for the route they reported. Env scope prevents a dev route from returning a prod diagram.
|
||||
* @description Returns the most recently stored diagram for (app, env, route). Independent of the agent registry, so routes removed from the current app version still resolve.
|
||||
*/
|
||||
get: operations["findByAppAndRoute"];
|
||||
put?: never;
|
||||
@@ -1912,6 +1932,46 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/server-metrics/instances": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List server_instance_id values observed in the window
|
||||
* @description Returns first/last seen timestamps — use to partition counter-delta computations.
|
||||
*/
|
||||
get: operations["instances"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/server-metrics/catalog": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List metric names observed in the window
|
||||
* @description For each metric_name, returns metric_type, the set of statistics emitted, and the union of tag keys.
|
||||
*/
|
||||
get: operations["catalog"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/rbac/stats": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2209,6 +2269,17 @@ export interface components {
|
||||
[key: string]: number;
|
||||
};
|
||||
sensitiveKeys?: string[];
|
||||
/** Format: int32 */
|
||||
exportBatchSize?: number;
|
||||
/** Format: int32 */
|
||||
exportQueueSize?: number;
|
||||
/** Format: int64 */
|
||||
exportFlushIntervalMs?: number;
|
||||
exportOverflowMode?: string;
|
||||
/** Format: int64 */
|
||||
exportBlockTimeoutMs?: number;
|
||||
/** Format: int32 */
|
||||
flushRecordThreshold?: number;
|
||||
};
|
||||
TapDefinition: {
|
||||
tapId?: string;
|
||||
@@ -2630,6 +2701,12 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
};
|
||||
AttributeFilter: {
|
||||
key?: string;
|
||||
value?: string;
|
||||
keyOnly?: boolean;
|
||||
wildcard?: boolean;
|
||||
};
|
||||
SearchRequest: {
|
||||
status?: string;
|
||||
/** Format: date-time */
|
||||
@@ -2658,6 +2735,7 @@ export interface components {
|
||||
sortDir?: string;
|
||||
afterExecutionId?: string;
|
||||
environment?: string;
|
||||
attributeFilters?: components["schemas"]["AttributeFilter"][];
|
||||
};
|
||||
ExecutionSummary: {
|
||||
executionId: string;
|
||||
@@ -2967,6 +3045,42 @@ export interface components {
|
||||
SetPasswordRequest: {
|
||||
password?: string;
|
||||
};
|
||||
QueryBody: {
|
||||
metric?: string;
|
||||
statistic?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
/** Format: int32 */
|
||||
stepSeconds?: number;
|
||||
groupByTags?: string[];
|
||||
filterTags?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
aggregation?: string;
|
||||
mode?: string;
|
||||
serverInstanceIds?: string[];
|
||||
};
|
||||
ServerMetricPoint: {
|
||||
/** Format: date-time */
|
||||
t?: string;
|
||||
/** Format: double */
|
||||
v?: number;
|
||||
};
|
||||
ServerMetricQueryResponse: {
|
||||
metric?: string;
|
||||
statistic?: string;
|
||||
aggregation?: string;
|
||||
mode?: string;
|
||||
/** Format: int32 */
|
||||
stepSeconds?: number;
|
||||
series?: components["schemas"]["ServerMetricSeries"][];
|
||||
};
|
||||
ServerMetricSeries: {
|
||||
tags?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
points?: components["schemas"]["ServerMetricPoint"][];
|
||||
};
|
||||
CreateRoleRequest: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
@@ -3491,6 +3605,19 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
avgDurationMs?: number;
|
||||
};
|
||||
ServerInstanceInfo: {
|
||||
serverInstanceId?: string;
|
||||
/** Format: date-time */
|
||||
firstSeen?: string;
|
||||
/** Format: date-time */
|
||||
lastSeen?: string;
|
||||
};
|
||||
ServerMetricCatalogEntry: {
|
||||
metricName?: string;
|
||||
metricType?: string;
|
||||
statistics?: string[];
|
||||
tagKeys?: string[];
|
||||
};
|
||||
SensitiveKeysConfig: {
|
||||
keys?: string[];
|
||||
};
|
||||
@@ -6246,6 +6373,30 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
query: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["QueryBody"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ServerMetricQueryResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listRoles: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -7068,6 +7219,7 @@ export interface operations {
|
||||
agentId?: string;
|
||||
processorType?: string;
|
||||
application?: string;
|
||||
attr?: string[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
sortField?: string;
|
||||
@@ -7822,6 +7974,52 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
instances: {
|
||||
parameters: {
|
||||
query?: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ServerInstanceInfo"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
catalog: {
|
||||
parameters: {
|
||||
query?: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ServerMetricCatalogEntry"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getStats: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -44,6 +44,7 @@ import { EnvironmentSwitcherModal } from './EnvironmentSwitcherModal';
|
||||
import { envColorVar } from './env-colors';
|
||||
import { useScope } from '../hooks/useScope';
|
||||
import { formatDuration } from '../utils/format-utils';
|
||||
import { parseFacetQuery, formatAttrParam } from '../utils/attribute-filter';
|
||||
import {
|
||||
buildAppTreeNodes,
|
||||
buildAdminTreeNodes,
|
||||
@@ -111,7 +112,11 @@ function buildSearchData(
|
||||
id: `attr-key-${key}`,
|
||||
category: 'attribute',
|
||||
title: key,
|
||||
meta: 'attribute key',
|
||||
meta: 'attribute key — filter list',
|
||||
// Path carries the facet in query-string form; handlePaletteSelect routes
|
||||
// attribute results to the current scope, so the leading segment below is
|
||||
// only used as a fallback when no scope is active.
|
||||
path: `/exchanges?attr=${encodeURIComponent(key)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -690,7 +695,19 @@ function LayoutContent() {
|
||||
}
|
||||
}
|
||||
|
||||
return [...catalogRef.current, ...exchangeItems, ...attributeItems, ...alertingSearchData];
|
||||
const facet = parseFacetQuery(debouncedQuery ?? '');
|
||||
const facetItems: SearchResult[] =
|
||||
facet
|
||||
? [{
|
||||
id: `facet-${formatAttrParam(facet)}`,
|
||||
category: 'attribute' as const,
|
||||
title: `Filter: ${facet.key} = "${facet.value}"${facet.value?.includes('*') ? ' (wildcard)' : ''}`,
|
||||
meta: 'apply attribute filter',
|
||||
path: `/exchanges?attr=${encodeURIComponent(formatAttrParam(facet))}`,
|
||||
}]
|
||||
: [];
|
||||
|
||||
return [...facetItems, ...catalogRef.current, ...exchangeItems, ...attributeItems, ...alertingSearchData];
|
||||
}, [isAdminPage, catalogRef.current, exchangeResults, debouncedQuery, alertingSearchData]);
|
||||
|
||||
const searchData = isAdminPage ? adminSearchData : operationalSearchData;
|
||||
@@ -705,6 +722,7 @@ function LayoutContent() {
|
||||
oidc: 'OIDC',
|
||||
database: 'Database',
|
||||
clickhouse: 'ClickHouse',
|
||||
'server-metrics': 'Server Metrics',
|
||||
appconfig: 'App Config',
|
||||
};
|
||||
const parts = location.pathname.split('/').filter(Boolean);
|
||||
@@ -743,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(':');
|
||||
@@ -751,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 = {
|
||||
@@ -765,7 +809,7 @@ function LayoutContent() {
|
||||
}
|
||||
}
|
||||
setPaletteOpen(false);
|
||||
}, [navigate, setPaletteOpen]);
|
||||
}, [navigate, setPaletteOpen, scope.appId, scope.routeId]);
|
||||
|
||||
const handlePaletteSubmit = useCallback((query: string) => {
|
||||
if (isAdminPage) {
|
||||
@@ -779,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) => {
|
||||
|
||||
@@ -110,6 +110,7 @@ export function buildAdminTreeNodes(opts?: { infrastructureEndpoints?: boolean }
|
||||
{ id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },
|
||||
{ id: 'admin:outbound-connections', label: 'Outbound Connections', path: '/admin/outbound-connections' },
|
||||
{ id: 'admin:sensitive-keys', label: 'Sensitive Keys', path: '/admin/sensitive-keys' },
|
||||
...(showInfra ? [{ id: 'admin:server-metrics', label: 'Server Metrics', path: '/admin/server-metrics' }] : []),
|
||||
{ id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' },
|
||||
];
|
||||
return nodes;
|
||||
|
||||
81
ui/src/pages/Admin/ServerMetricsAdminPage.module.css
Normal file
81
ui/src/pages/Admin/ServerMetricsAdminPage.module.css
Normal file
@@ -0,0 +1,81 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.instanceStrip {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.rowTriple {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin: 4px 0 4px 2px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.chartHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.chartMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Tighten chart card internals for denser grid */
|
||||
.compactCard {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.rowTriple,
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
478
ui/src/pages/Admin/ServerMetricsAdminPage.tsx
Normal file
478
ui/src/pages/Admin/ServerMetricsAdminPage.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
ThemedChart, Area, Line, CHART_COLORS,
|
||||
Badge, EmptyState, Spinner, useGlobalFilters,
|
||||
} from '@cameleer/design-system';
|
||||
import {
|
||||
useServerMetricsCatalog,
|
||||
useServerMetricsInstances,
|
||||
useServerMetricsSeries,
|
||||
type ServerMetricQueryResponse,
|
||||
type ServerMetricSeries,
|
||||
type ServerMetricsRange,
|
||||
} from '../../api/queries/admin/serverMetrics';
|
||||
import chartCardStyles from '../../styles/chart-card.module.css';
|
||||
import styles from './ServerMetricsAdminPage.module.css';
|
||||
|
||||
// ── Step picker ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Choose a bucket width that keeps the rendered series readable regardless
|
||||
* of the window size the global time-range control hands us.
|
||||
*
|
||||
* Targets roughly 30–120 points per series — any denser and the chart
|
||||
* becomes a blur; any sparser and short windows look empty. Clamped to the
|
||||
* [10, 3600] range the backend accepts.
|
||||
*/
|
||||
function stepSecondsFor(windowSeconds: number): number {
|
||||
if (windowSeconds <= 30 * 60) return 10; // ≤ 30 min → 10 s buckets
|
||||
if (windowSeconds <= 2 * 60 * 60) return 60; // ≤ 2 h → 1 min
|
||||
if (windowSeconds <= 12 * 60 * 60) return 300; // ≤ 12 h → 5 min
|
||||
if (windowSeconds <= 48 * 60 * 60) return 900; // ≤ 48 h → 15 min
|
||||
return 3600; // longer → 1 h
|
||||
}
|
||||
|
||||
// ── Panel component ────────────────────────────────────────────────────
|
||||
|
||||
interface PanelProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
metric: string;
|
||||
statistic?: string;
|
||||
groupByTags?: string[];
|
||||
filterTags?: Record<string, string>;
|
||||
aggregation?: string;
|
||||
mode?: 'raw' | 'delta';
|
||||
yLabel?: string;
|
||||
asArea?: boolean;
|
||||
range: ServerMetricsRange;
|
||||
stepSeconds: number;
|
||||
formatValue?: (v: number) => string;
|
||||
}
|
||||
|
||||
function Panel({
|
||||
title, subtitle, metric, statistic, groupByTags, filterTags,
|
||||
aggregation, mode = 'raw', yLabel, asArea = false,
|
||||
range, stepSeconds, formatValue,
|
||||
}: PanelProps) {
|
||||
const { data, isLoading, isError, error } = useServerMetricsSeries(
|
||||
{ metric, statistic, groupByTags, filterTags, aggregation, mode, stepSeconds },
|
||||
range,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${chartCardStyles.chartCard} ${styles.compactCard}`}>
|
||||
<div className={styles.chartHeader}>
|
||||
<span className={styles.chartTitle}>{title}</span>
|
||||
{subtitle && <span className={styles.chartMeta}>{subtitle}</span>}
|
||||
</div>
|
||||
<PanelBody
|
||||
data={data}
|
||||
loading={isLoading}
|
||||
error={isError ? (error as Error | null)?.message ?? 'query failed' : null}
|
||||
yLabel={yLabel}
|
||||
asArea={asArea}
|
||||
formatValue={formatValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelBody({
|
||||
data, loading, error, yLabel, asArea, formatValue,
|
||||
}: {
|
||||
data: ServerMetricQueryResponse | undefined;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
yLabel?: string;
|
||||
asArea?: boolean;
|
||||
formatValue?: (v: number) => string;
|
||||
}) {
|
||||
const points = useMemo(() => flatten(data?.series ?? []), [data]);
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ minHeight: 160, display: 'grid', placeItems: 'center' }}>
|
||||
<Spinner />
|
||||
</div>;
|
||||
}
|
||||
if (error) {
|
||||
return <EmptyState title="Query failed" description={error} />;
|
||||
}
|
||||
if (!data || data.series.length === 0 || points.rows.length === 0) {
|
||||
return <EmptyState title="No data" description="No samples in the selected window" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedChart data={points.rows} height={180} xDataKey="t" xTickFormatter={formatTime}
|
||||
yLabel={yLabel} yTickFormatter={formatValue}>
|
||||
{points.seriesKeys.map((key, idx) => {
|
||||
const color = CHART_COLORS[idx % CHART_COLORS.length];
|
||||
return asArea ? (
|
||||
<Area key={key} dataKey={key} name={key} stroke={color} fill={color}
|
||||
fillOpacity={0.18} strokeWidth={2} dot={false} />
|
||||
) : (
|
||||
<Line key={key} dataKey={key} name={key} stroke={color} strokeWidth={2} dot={false} />
|
||||
);
|
||||
})}
|
||||
</ThemedChart>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn ServerMetricSeries[] into a single array of rows keyed by series label.
|
||||
* Multiple series become overlapping lines on the same time axis; buckets are
|
||||
* merged on `t` so Recharts can render them as one dataset.
|
||||
*/
|
||||
function flatten(series: ServerMetricSeries[]): { rows: Array<Record<string, number | string>>; seriesKeys: string[] } {
|
||||
if (series.length === 0) return { rows: [], seriesKeys: [] };
|
||||
|
||||
const seriesKeys = series.map(seriesLabel);
|
||||
const rowsByTime = new Map<string, Record<string, number | string>>();
|
||||
series.forEach((s, i) => {
|
||||
const key = seriesKeys[i];
|
||||
for (const p of s.points) {
|
||||
let row = rowsByTime.get(p.t);
|
||||
if (!row) {
|
||||
row = { t: p.t };
|
||||
rowsByTime.set(p.t, row);
|
||||
}
|
||||
row[key] = p.v;
|
||||
}
|
||||
});
|
||||
const rows = Array.from(rowsByTime.values()).sort((a, b) =>
|
||||
(a.t as string).localeCompare(b.t as string));
|
||||
return { rows, seriesKeys };
|
||||
}
|
||||
|
||||
function seriesLabel(s: ServerMetricSeries): string {
|
||||
const entries = Object.entries(s.tags);
|
||||
if (entries.length === 0) return 'value';
|
||||
return entries.map(([k, v]) => `${k}=${v}`).join(' · ');
|
||||
}
|
||||
|
||||
function formatTime(iso: string | number): string {
|
||||
const d = typeof iso === 'number' ? new Date(iso) : new Date(String(iso));
|
||||
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatMB(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
function formatPct(frac: number): string {
|
||||
return `${(frac * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ServerMetricsAdminPage() {
|
||||
// Drive the entire page from the global time-range control in the TopBar.
|
||||
const { timeRange } = useGlobalFilters();
|
||||
const range: ServerMetricsRange = useMemo(
|
||||
() => ({ from: timeRange.start, to: timeRange.end }),
|
||||
[timeRange.start, timeRange.end],
|
||||
);
|
||||
const windowSeconds = Math.max(
|
||||
1,
|
||||
Math.round((range.to.getTime() - range.from.getTime()) / 1000),
|
||||
);
|
||||
const stepSeconds = stepSecondsFor(windowSeconds);
|
||||
|
||||
const { data: catalog } = useServerMetricsCatalog(range);
|
||||
const { data: instances } = useServerMetricsInstances(range);
|
||||
|
||||
const has = (metricName: string) =>
|
||||
(catalog ?? []).some((c) => c.metricName === metricName);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
{/* Toolbar — just server-instance badges. Time range is driven by
|
||||
the global time-range control in the TopBar. */}
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.instanceStrip}>
|
||||
{(instances ?? []).slice(0, 8).map((i) => (
|
||||
<Badge key={i.serverInstanceId} label={i.serverInstanceId} variant="outlined" />
|
||||
))}
|
||||
{(instances ?? []).length > 8 && (
|
||||
<Badge label={`+${(instances ?? []).length - 8}`} variant="outlined" />
|
||||
)}
|
||||
{(instances ?? []).length === 0 && (
|
||||
<Badge label="no samples in window" variant="outlined" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 1: Server health */}
|
||||
<section>
|
||||
<div className={styles.sectionTitle}>
|
||||
Server health
|
||||
<span className={styles.sectionSubtitle}>agents, ingestion, auth</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<Panel
|
||||
title="Agents by state"
|
||||
subtitle="stacked area"
|
||||
metric="cameleer.agents.connected"
|
||||
statistic="value"
|
||||
groupByTags={['state']}
|
||||
aggregation="avg"
|
||||
asArea
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
<Panel
|
||||
title="Ingestion buffer depth"
|
||||
subtitle="by type"
|
||||
metric="cameleer.ingestion.buffer.size"
|
||||
statistic="value"
|
||||
groupByTags={['type']}
|
||||
aggregation="avg"
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.row} style={{ marginTop: 14 }}>
|
||||
<Panel
|
||||
title="Ingestion drops / interval"
|
||||
subtitle="per-bucket delta"
|
||||
metric="cameleer.ingestion.drops"
|
||||
statistic="count"
|
||||
groupByTags={['reason']}
|
||||
mode="delta"
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
<Panel
|
||||
title="Auth failures / interval"
|
||||
subtitle="per-bucket delta"
|
||||
metric="cameleer.auth.failures"
|
||||
statistic="count"
|
||||
groupByTags={['reason']}
|
||||
mode="delta"
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Row 2: JVM */}
|
||||
<section>
|
||||
<div className={styles.sectionTitle}>
|
||||
JVM
|
||||
<span className={styles.sectionSubtitle}>memory, CPU, threads, GC</span>
|
||||
</div>
|
||||
<div className={styles.rowTriple}>
|
||||
<Panel
|
||||
title="Heap used"
|
||||
subtitle="sum across pools"
|
||||
metric="jvm.memory.used"
|
||||
statistic="value"
|
||||
filterTags={{ area: 'heap' }}
|
||||
aggregation="sum"
|
||||
asArea
|
||||
yLabel="MB"
|
||||
formatValue={formatMB}
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
<Panel
|
||||
title="CPU usage"
|
||||
subtitle="process + system"
|
||||
metric="process.cpu.usage"
|
||||
statistic="value"
|
||||
aggregation="avg"
|
||||
asArea
|
||||
yLabel="%"
|
||||
formatValue={formatPct}
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
<Panel
|
||||
title="GC pause max"
|
||||
subtitle="by cause"
|
||||
metric="jvm.gc.pause"
|
||||
statistic="max"
|
||||
groupByTags={['cause']}
|
||||
aggregation="max"
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.row} style={{ marginTop: 14 }}>
|
||||
<Panel
|
||||
title="Thread count"
|
||||
subtitle="live threads"
|
||||
metric="jvm.threads.live"
|
||||
statistic="value"
|
||||
aggregation="avg"
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
<Panel
|
||||
title="Heap committed vs max"
|
||||
subtitle="sum across pools"
|
||||
metric="jvm.memory.committed"
|
||||
statistic="value"
|
||||
filterTags={{ area: 'heap' }}
|
||||
aggregation="sum"
|
||||
yLabel="MB"
|
||||
formatValue={formatMB}
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Row 3: HTTP + DB */}
|
||||
<section>
|
||||
<div className={styles.sectionTitle}>
|
||||
HTTP & DB pools
|
||||
<span className={styles.sectionSubtitle}>requests, Hikari saturation</span>
|
||||
</div>
|
||||
<div className={styles.rowTriple}>
|
||||
<Panel
|
||||
title="HTTP latency — mean by URI"
|
||||
subtitle="SUCCESS only"
|
||||
metric="http.server.requests"
|
||||
statistic="mean"
|
||||
groupByTags={['uri']}
|
||||
filterTags={{ outcome: 'SUCCESS' }}
|
||||
aggregation="avg"
|
||||
yLabel="s"
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
<Panel
|
||||
title="HTTP requests / interval"
|
||||
subtitle="all outcomes"
|
||||
metric="http.server.requests"
|
||||
statistic="count"
|
||||
mode="delta"
|
||||
aggregation="sum"
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
<Panel
|
||||
title="Hikari pool — active vs pending"
|
||||
subtitle="by pool"
|
||||
metric="hikaricp.connections.active"
|
||||
statistic="value"
|
||||
groupByTags={['pool']}
|
||||
aggregation="avg"
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.row} style={{ marginTop: 14 }}>
|
||||
<Panel
|
||||
title="Hikari acquire timeouts"
|
||||
subtitle="per-bucket delta"
|
||||
metric="hikaricp.connections.timeout"
|
||||
statistic="count"
|
||||
groupByTags={['pool']}
|
||||
mode="delta"
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
<Panel
|
||||
title="Log events by level"
|
||||
subtitle="per-bucket delta"
|
||||
metric="logback.events"
|
||||
statistic="count"
|
||||
groupByTags={['level']}
|
||||
mode="delta"
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Row 4: Alerting */}
|
||||
{(has('alerting_instances_total')
|
||||
|| has('alerting_eval_errors_total')
|
||||
|| has('alerting_webhook_delivery_duration_seconds')) && (
|
||||
<section>
|
||||
<div className={styles.sectionTitle}>
|
||||
Alerting
|
||||
<span className={styles.sectionSubtitle}>instances, eval errors, webhook delivery</span>
|
||||
</div>
|
||||
<div className={styles.rowTriple}>
|
||||
{has('alerting_instances_total') && (
|
||||
<Panel
|
||||
title="Alert instances by state"
|
||||
subtitle="stacked"
|
||||
metric="alerting_instances_total"
|
||||
statistic="value"
|
||||
groupByTags={['state']}
|
||||
aggregation="avg"
|
||||
asArea
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
)}
|
||||
{has('alerting_eval_errors_total') && (
|
||||
<Panel
|
||||
title="Eval errors / interval"
|
||||
subtitle="by kind"
|
||||
metric="alerting_eval_errors_total"
|
||||
statistic="count"
|
||||
groupByTags={['kind']}
|
||||
mode="delta"
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
)}
|
||||
{has('alerting_webhook_delivery_duration_seconds') && (
|
||||
<Panel
|
||||
title="Webhook delivery max"
|
||||
subtitle="max latency per bucket"
|
||||
metric="alerting_webhook_delivery_duration_seconds"
|
||||
statistic="max"
|
||||
aggregation="max"
|
||||
yLabel="s"
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Row 5: Deployments (only when runtime orchestration is enabled) */}
|
||||
{(has('cameleer.deployments.outcome') || has('cameleer.deployments.duration')) && (
|
||||
<section>
|
||||
<div className={styles.sectionTitle}>
|
||||
Deployments
|
||||
<span className={styles.sectionSubtitle}>outcomes, duration</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
{has('cameleer.deployments.outcome') && (
|
||||
<Panel
|
||||
title="Deploy outcomes / interval"
|
||||
subtitle="by status"
|
||||
metric="cameleer.deployments.outcome"
|
||||
statistic="count"
|
||||
groupByTags={['status']}
|
||||
mode="delta"
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
)}
|
||||
{has('cameleer.deployments.duration') && (
|
||||
<Panel
|
||||
title="Deploy duration mean"
|
||||
subtitle="total_time / count"
|
||||
metric="cameleer.deployments.duration"
|
||||
statistic="mean"
|
||||
aggregation="avg"
|
||||
yLabel="s"
|
||||
range={range}
|
||||
stepSeconds={stepSeconds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useLocation, useNavigate } from 'react-router';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertDialog, Badge, Button, Tabs, useToast } from '@cameleer/design-system';
|
||||
import { AlertDialog, Badge, Button, StatusDot, Tabs, useToast } from '@cameleer/design-system';
|
||||
import { useEnvironmentStore } from '../../../api/environment-store';
|
||||
import { useEnvironments } from '../../../api/queries/admin/environments';
|
||||
import {
|
||||
@@ -39,6 +39,16 @@ import styles from './AppDeploymentPage.module.css';
|
||||
|
||||
type TabKey = 'monitoring' | 'resources' | 'variables' | 'sensitive-keys' | 'deployment' | 'traces' | 'recording';
|
||||
|
||||
const STATUS_COLORS: Record<string, 'success' | 'warning' | 'error' | 'auto' | 'running'> = {
|
||||
RUNNING: 'running', STARTING: 'warning', FAILED: 'error', STOPPED: 'auto',
|
||||
DEGRADED: 'warning', STOPPING: 'auto',
|
||||
};
|
||||
|
||||
const DEPLOY_STATUS_DOT: Record<string, 'live' | 'stale' | 'dead' | 'success' | 'warning' | 'error' | 'running'> = {
|
||||
RUNNING: 'live', STARTING: 'running', DEGRADED: 'stale',
|
||||
STOPPING: 'stale', STOPPED: 'dead', FAILED: 'error',
|
||||
};
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 100);
|
||||
}
|
||||
@@ -393,9 +403,35 @@ export default function AppDeploymentPage() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<h2 style={{ margin: 0 }}>{app ? app.displayName : 'Create Application'}</h2>
|
||||
{app && !deploymentInProgress && (dirty.anyLocalEdit || serverDirtyAgainstDeploy) && (
|
||||
<Badge label="Pending deploy" color="warning" />
|
||||
{app && latestDeployment && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<StatusDot variant={DEPLOY_STATUS_DOT[latestDeployment.status] ?? 'dead'} />
|
||||
<Badge
|
||||
label={latestDeployment.status}
|
||||
color={STATUS_COLORS[latestDeployment.status] ?? 'auto'}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{app && !deploymentInProgress && (dirty.anyLocalEdit || serverDirtyAgainstDeploy) && (() => {
|
||||
const diffs = dirtyState?.differences ?? [];
|
||||
const noSnapshot = diffs.length === 1 && diffs[0].field === 'snapshot';
|
||||
const tooltip = dirty.anyLocalEdit
|
||||
? 'Local edits not yet saved — see tabs marked with *.'
|
||||
: noSnapshot
|
||||
? 'No successful deployment recorded for this app yet.'
|
||||
: diffs.length > 0
|
||||
? `Differs from last successful deploy:\n` +
|
||||
diffs.map((d) => `• ${d.field}\n staged: ${d.staged}\n deployed: ${d.deployed}`).join('\n')
|
||||
: 'Server reports config differs from last successful deploy.';
|
||||
return (
|
||||
<span title={tooltip} style={{ display: 'inline-flex' }}>
|
||||
<Badge
|
||||
label={dirty.anyLocalEdit ? 'Pending deploy' : `Pending deploy (${diffs.length})`}
|
||||
color="warning"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{dirty.anyLocalEdit && (
|
||||
|
||||
@@ -139,3 +139,23 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.attrChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.attrChip code {
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { useEnvironmentStore } from '../../api/environment-store'
|
||||
import type { ExecutionSummary } from '../../api/types'
|
||||
import { attributeBadgeColor } from '../../utils/attribute-color'
|
||||
import { parseAttrParam, formatAttrParam } from '../../utils/attribute-filter';
|
||||
import type { AttributeFilter } from '../../utils/attribute-filter';
|
||||
import { formatDuration, statusLabel } from '../../utils/format-utils'
|
||||
import styles from './Dashboard.module.css'
|
||||
import tableStyles from '../../styles/table-section.module.css'
|
||||
@@ -84,7 +86,7 @@ function buildColumns(hasAttributes: boolean): Column<Row>[] {
|
||||
<div className={styles.attrCell}>
|
||||
{shown.map(([k, v]) => (
|
||||
<span key={k} title={k}>
|
||||
<Badge label={String(v)} color={attributeBadgeColor(String(v))} />
|
||||
<Badge label={String(v)} color={attributeBadgeColor(k)} />
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && <span className={styles.attrOverflow}>+{overflow}</span>}
|
||||
@@ -147,6 +149,12 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const textFilter = searchParams.get('text') || undefined
|
||||
const attributeFilters = useMemo<AttributeFilter[]>(
|
||||
() => searchParams.getAll('attr')
|
||||
.map(parseAttrParam)
|
||||
.filter((f): f is AttributeFilter => f != null),
|
||||
[searchParams],
|
||||
);
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(activeExchangeId)
|
||||
const [sortField, setSortField] = useState<string>('startTime')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
||||
@@ -180,12 +188,13 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo
|
||||
environment: selectedEnv,
|
||||
status: statusParam,
|
||||
text: textFilter,
|
||||
attributeFilters: attributeFilters.length > 0 ? attributeFilters : undefined,
|
||||
sortField,
|
||||
sortDir,
|
||||
offset: 0,
|
||||
limit: textFilter ? 200 : 50,
|
||||
limit: textFilter || attributeFilters.length > 0 ? 200 : 50,
|
||||
},
|
||||
!textFilter,
|
||||
!textFilter && attributeFilters.length === 0,
|
||||
)
|
||||
|
||||
// ─── Rows ────────────────────────────────────────────────────────────────
|
||||
@@ -221,17 +230,46 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo
|
||||
<div className={`${tableStyles.tableSection} ${styles.tableWrap}`}>
|
||||
<div className={tableStyles.tableHeader}>
|
||||
<span className={tableStyles.tableTitle}>
|
||||
{textFilter ? (
|
||||
{textFilter || attributeFilters.length > 0 ? (
|
||||
<>
|
||||
<Search size={14} style={{ marginRight: 4, verticalAlign: -2 }} />
|
||||
Search: “{textFilter}”
|
||||
<button
|
||||
className={styles.clearSearch}
|
||||
onClick={() => setSearchParams({})}
|
||||
title="Clear search"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
{textFilter && (
|
||||
<>
|
||||
Search: “{textFilter}”
|
||||
<button
|
||||
className={styles.clearSearch}
|
||||
onClick={() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete('text');
|
||||
setSearchParams(next);
|
||||
}}
|
||||
title="Clear text search"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{attributeFilters.map((f, i) => (
|
||||
<span key={`${f.key}:${f.value ?? ''}:${i}`} className={styles.attrChip}>
|
||||
{f.value === undefined
|
||||
? <>has <code>{f.key}</code></>
|
||||
: <><code>{f.key}</code> = <code>{f.value}</code></>}
|
||||
<button
|
||||
className={styles.clearSearch}
|
||||
onClick={() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
const remaining = next.getAll('attr')
|
||||
.filter(a => a !== formatAttrParam(f));
|
||||
next.delete('attr');
|
||||
remaining.forEach(a => next.append('attr', a));
|
||||
setSearchParams(next);
|
||||
}}
|
||||
title="Remove filter"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
) : 'Recent Exchanges'}
|
||||
</span>
|
||||
@@ -239,7 +277,7 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo
|
||||
<span className={tableStyles.tableMeta}>
|
||||
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
|
||||
</span>
|
||||
{!textFilter && <Badge label="AUTO" color="success" />}
|
||||
{!textFilter && attributeFilters.length === 0 && <Badge label="AUTO" color="success" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const AuditLogPage = lazy(() => import('./pages/Admin/AuditLogPage'));
|
||||
const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage'));
|
||||
const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
|
||||
const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage'));
|
||||
const ServerMetricsAdminPage = lazy(() => import('./pages/Admin/ServerMetricsAdminPage'));
|
||||
const EnvironmentsPage = lazy(() => import('./pages/Admin/EnvironmentsPage'));
|
||||
const OutboundConnectionsPage = lazy(() => import('./pages/Admin/OutboundConnectionsPage'));
|
||||
const OutboundConnectionEditor = lazy(() => import('./pages/Admin/OutboundConnectionEditor'));
|
||||
@@ -105,6 +106,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'sensitive-keys', element: <SuspenseWrapper><SensitiveKeysPage /></SuspenseWrapper> },
|
||||
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
||||
{ path: 'clickhouse', element: <SuspenseWrapper><ClickHouseAdminPage /></SuspenseWrapper> },
|
||||
{ path: 'server-metrics', element: <SuspenseWrapper><ServerMetricsAdminPage /></SuspenseWrapper> },
|
||||
{ path: 'environments', element: <SuspenseWrapper><EnvironmentsPage /></SuspenseWrapper> },
|
||||
],
|
||||
}],
|
||||
|
||||
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