diff --git a/.claude/rules/app-classes.md b/.claude/rules/app-classes.md
index f10bccbc..971643fb 100644
--- a/.claude/rules/app-classes.md
+++ b/.claude/rules/app-classes.md
@@ -109,6 +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.
### Other (flat)
@@ -147,7 +148,8 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
- `ClickHouseDiagramStore`, `ClickHouseAgentEventRepository`
- `ClickHouseUsageTracker` — usage_events for billing
- `ClickHouseRouteCatalogStore` — persistent route catalog with first_seen cache, warm-loaded on startup
-- `ClickHouseServerMetricsStore` — periodic dumps of the server's own Micrometer registry into the `server_metrics` table. Tenant-stamped (bound at the scheduler, not the bean); no `environment` column (server straddles envs). Batch-insert via `JdbcTemplate.batchUpdate` with `Map(String, String)` tag binding. Written by `ServerMetricsSnapshotScheduler`, query via `/api/v1/admin/clickhouse/query` (no dedicated endpoint yet).
+- `ClickHouseServerMetricsStore` — periodic dumps of the server's own Micrometer registry into the `server_metrics` table. Tenant-stamped (bound at the scheduler, not the bean); no `environment` column (server straddles envs). Batch-insert via `JdbcTemplate.batchUpdate` with `Map(String, String)` tag binding. Written by `ServerMetricsSnapshotScheduler`.
+- `ClickHouseServerMetricsQueryStore` — read side of `server_metrics` for dashboards. Implements `ServerMetricsQueryStore`. `catalog(from,to)` returns name+type+statistics+tagKeys, `listInstances(from,to)` returns server_instance_ids with first/last seen, `query(request)` builds bucketed time-series with `raw` or `delta` mode and supports a derived `mean` statistic for timers. All identifier inputs regex-validated; tenant_id always bound; max range 31 days; series count capped at 500. Exposed via `ServerMetricsAdminController`.
## search/ — ClickHouse search and log stores
diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java
index e8996f49..6df6a51b 100644
--- a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java
@@ -9,6 +9,7 @@ import com.cameleer.server.app.storage.ClickHouseRouteCatalogStore;
import com.cameleer.server.core.storage.RouteCatalogStore;
import com.cameleer.server.app.storage.ClickHouseMetricsQueryStore;
import com.cameleer.server.app.storage.ClickHouseMetricsStore;
+import com.cameleer.server.app.storage.ClickHouseServerMetricsQueryStore;
import com.cameleer.server.app.storage.ClickHouseServerMetricsStore;
import com.cameleer.server.app.storage.ClickHouseStatsStore;
import com.cameleer.server.core.admin.AuditRepository;
@@ -74,6 +75,13 @@ public class StorageBeanConfig {
return new ClickHouseServerMetricsStore(clickHouseJdbc);
}
+ @Bean
+ public ServerMetricsQueryStore clickHouseServerMetricsQueryStore(
+ TenantProperties tenantProperties,
+ @Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
+ return new ClickHouseServerMetricsQueryStore(tenantProperties.getId(), clickHouseJdbc);
+ }
+
// ── Execution Store ──────────────────────────────────────────────────
@Bean
diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ServerMetricsAdminController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ServerMetricsAdminController.java
new file mode 100644
index 00000000..676dbd8c
--- /dev/null
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ServerMetricsAdminController.java
@@ -0,0 +1,135 @@
+package com.cameleer.server.app.controller;
+
+import com.cameleer.server.core.storage.ServerMetricsQueryStore;
+import com.cameleer.server.core.storage.model.ServerInstanceInfo;
+import com.cameleer.server.core.storage.model.ServerMetricCatalogEntry;
+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.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+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 java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Generic read API over the ClickHouse {@code server_metrics} table. Lets
+ * SaaS control planes build server-health dashboards without requiring direct
+ * ClickHouse access.
+ *
+ *
Three endpoints cover all 17 panels in {@code docs/server-self-metrics.md}:
+ *
+ * - {@code GET /catalog} — discover available metric names, types, statistics, and tags
+ * - {@code POST /query} — generic time-series query with aggregation, grouping, filtering, and counter-delta mode
+ * - {@code GET /instances} — list server instances (useful for partitioning counter math)
+ *
+ *
+ * Protected by the {@code /api/v1/admin/**} catch-all in {@code SecurityConfig} — requires ADMIN role.
+ */
+@RestController
+@RequestMapping("/api/v1/admin/server-metrics")
+@Tag(name = "Server Self-Metrics",
+ description = "Read API over the server's own Micrometer registry snapshots for dashboards")
+public class ServerMetricsAdminController {
+
+ /** Default lookback window for catalog/instances when from/to are omitted. */
+ private static final long DEFAULT_LOOKBACK_SECONDS = 3_600L;
+
+ private final ServerMetricsQueryStore store;
+
+ public ServerMetricsAdminController(ServerMetricsQueryStore store) {
+ this.store = store;
+ }
+
+ @GetMapping("/catalog")
+ @Operation(summary = "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.")
+ public ResponseEntity> catalog(
+ @RequestParam(required = false) String from,
+ @RequestParam(required = false) String to) {
+ Instant[] window = resolveWindow(from, to);
+ return ResponseEntity.ok(store.catalog(window[0], window[1]));
+ }
+
+ @GetMapping("/instances")
+ @Operation(summary = "List server_instance_id values observed in the window",
+ description = "Returns first/last seen timestamps — use to partition counter-delta computations.")
+ public ResponseEntity> instances(
+ @RequestParam(required = false) String from,
+ @RequestParam(required = false) String to) {
+ Instant[] window = resolveWindow(from, to);
+ return ResponseEntity.ok(store.listInstances(window[0], window[1]));
+ }
+
+ @PostMapping("/query")
+ @Operation(summary = "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.")
+ public ResponseEntity query(@RequestBody QueryBody body) {
+ ServerMetricQueryRequest request = new ServerMetricQueryRequest(
+ body.metric(),
+ body.statistic(),
+ parseInstant(body.from(), "from"),
+ parseInstant(body.to(), "to"),
+ body.stepSeconds(),
+ body.groupByTags(),
+ body.filterTags(),
+ body.aggregation(),
+ body.mode(),
+ body.serverInstanceIds());
+ return ResponseEntity.ok(store.query(request));
+ }
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ResponseEntity