From fa3bc592d1db18df4fc4da286f9e09d9e9959201 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:32:20 +0100 Subject: [PATCH 01/18] feat: add Flyway V9 (thresholds) and V10 (audit_log) migrations --- .../resources/db/migration/V10__audit_log.sql | 18 ++++++++++++++++++ .../db/migration/V9__admin_thresholds.sql | 7 +++++++ 2 files changed, 25 insertions(+) create mode 100644 cameleer3-server-app/src/main/resources/db/migration/V10__audit_log.sql create mode 100644 cameleer3-server-app/src/main/resources/db/migration/V9__admin_thresholds.sql diff --git a/cameleer3-server-app/src/main/resources/db/migration/V10__audit_log.sql b/cameleer3-server-app/src/main/resources/db/migration/V10__audit_log.sql new file mode 100644 index 00000000..d10230a6 --- /dev/null +++ b/cameleer3-server-app/src/main/resources/db/migration/V10__audit_log.sql @@ -0,0 +1,18 @@ +CREATE TABLE audit_log ( + id BIGSERIAL PRIMARY KEY, + timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + username TEXT NOT NULL, + action TEXT NOT NULL, + category TEXT NOT NULL, + target TEXT, + detail JSONB, + result TEXT NOT NULL, + ip_address TEXT, + user_agent TEXT +); + +CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC); +CREATE INDEX idx_audit_log_username ON audit_log (username); +CREATE INDEX idx_audit_log_category ON audit_log (category); +CREATE INDEX idx_audit_log_action ON audit_log (action); +CREATE INDEX idx_audit_log_target ON audit_log (target); diff --git a/cameleer3-server-app/src/main/resources/db/migration/V9__admin_thresholds.sql b/cameleer3-server-app/src/main/resources/db/migration/V9__admin_thresholds.sql new file mode 100644 index 00000000..9b618c97 --- /dev/null +++ b/cameleer3-server-app/src/main/resources/db/migration/V9__admin_thresholds.sql @@ -0,0 +1,7 @@ +CREATE TABLE admin_thresholds ( + id INTEGER PRIMARY KEY DEFAULT 1, + config JSONB NOT NULL DEFAULT '{}', + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_by TEXT NOT NULL, + CONSTRAINT single_row CHECK (id = 1) +); From a0944a1c72e5974f2e6433041ba7d137e6f847ea Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:36:21 +0100 Subject: [PATCH 02/18] feat: add audit domain model, repository interface, AuditService, and unit test Co-Authored-By: Claude Opus 4.6 (1M context) --- .../server/app/admin/AuditServiceTest.java | 49 +++++++++++++++++++ cameleer3-server-core/pom.xml | 10 ++++ .../server/core/admin/AuditCategory.java | 5 ++ .../server/core/admin/AuditRecord.java | 24 +++++++++ .../server/core/admin/AuditRepository.java | 25 ++++++++++ .../server/core/admin/AuditResult.java | 5 ++ .../server/core/admin/AuditService.java | 49 +++++++++++++++++++ 7 files changed, 167 insertions(+) create mode 100644 cameleer3-server-app/src/test/java/com/cameleer3/server/app/admin/AuditServiceTest.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditCategory.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditRecord.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditRepository.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditResult.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditService.java diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/admin/AuditServiceTest.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/admin/AuditServiceTest.java new file mode 100644 index 00000000..abf75a0c --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/admin/AuditServiceTest.java @@ -0,0 +1,49 @@ +package com.cameleer3.server.app.admin; + +import com.cameleer3.server.core.admin.*; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AuditServiceTest { + private AuditRepository mockRepository; + private AuditService auditService; + + @BeforeEach + void setUp() { + mockRepository = mock(AuditRepository.class); + auditService = new AuditService(mockRepository); + } + + @Test + void log_withExplicitUsername_insertsRecordWithCorrectFields() { + var request = mock(HttpServletRequest.class); + when(request.getRemoteAddr()).thenReturn("192.168.1.1"); + when(request.getHeader("User-Agent")).thenReturn("Mozilla/5.0"); + + auditService.log("admin", "kill_query", AuditCategory.INFRA, "PID 42", + Map.of("query", "SELECT 1"), AuditResult.SUCCESS, request); + + var captor = ArgumentCaptor.forClass(AuditRecord.class); + verify(mockRepository).insert(captor.capture()); + var record = captor.getValue(); + assertEquals("admin", record.username()); + assertEquals("kill_query", record.action()); + assertEquals(AuditCategory.INFRA, record.category()); + assertEquals("PID 42", record.target()); + assertEquals("192.168.1.1", record.ipAddress()); + assertEquals("Mozilla/5.0", record.userAgent()); + } + + @Test + void log_withNullRequest_handlesGracefully() { + auditService.log("admin", "test", AuditCategory.CONFIG, null, null, AuditResult.SUCCESS, null); + verify(mockRepository).insert(any(AuditRecord.class)); + } +} diff --git a/cameleer3-server-core/pom.xml b/cameleer3-server-core/pom.xml index 544d4a93..5e2e517c 100644 --- a/cameleer3-server-core/pom.xml +++ b/cameleer3-server-core/pom.xml @@ -27,6 +27,16 @@ org.slf4j slf4j-api + + jakarta.servlet + jakarta.servlet-api + provided + + + org.springframework.security + spring-security-core + provided + org.junit.jupiter junit-jupiter diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditCategory.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditCategory.java new file mode 100644 index 00000000..39854a79 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditCategory.java @@ -0,0 +1,5 @@ +package com.cameleer3.server.core.admin; + +public enum AuditCategory { + INFRA, AUTH, USER_MGMT, CONFIG +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditRecord.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditRecord.java new file mode 100644 index 00000000..6e4f6f1b --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditRecord.java @@ -0,0 +1,24 @@ +package com.cameleer3.server.core.admin; + +import java.time.Instant; +import java.util.Map; + +public record AuditRecord( + long id, + Instant timestamp, + String username, + String action, + AuditCategory category, + String target, + Map detail, + AuditResult result, + String ipAddress, + String userAgent +) { + /** Factory for creating new records (id and timestamp assigned by DB) */ + public static AuditRecord create(String username, String action, AuditCategory category, + String target, Map detail, AuditResult result, + String ipAddress, String userAgent) { + return new AuditRecord(0, null, username, action, category, target, detail, result, ipAddress, userAgent); + } +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditRepository.java new file mode 100644 index 00000000..a7bfdce0 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditRepository.java @@ -0,0 +1,25 @@ +package com.cameleer3.server.core.admin; + +import java.time.Instant; +import java.util.List; + +public interface AuditRepository { + + void insert(AuditRecord record); + + record AuditQuery( + String username, + AuditCategory category, + String search, + Instant from, + Instant to, + String sort, + String order, + int page, + int size + ) {} + + record AuditPage(List items, long totalCount) {} + + AuditPage find(AuditQuery query); +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditResult.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditResult.java new file mode 100644 index 00000000..d8be64ed --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditResult.java @@ -0,0 +1,5 @@ +package com.cameleer3.server.core.admin; + +public enum AuditResult { + SUCCESS, FAILURE +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditService.java new file mode 100644 index 00000000..5a5b3324 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditService.java @@ -0,0 +1,49 @@ +package com.cameleer3.server.core.admin; + +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Map; + +public class AuditService { + private static final Logger log = LoggerFactory.getLogger(AuditService.class); + private final AuditRepository repository; + + public AuditService(AuditRepository repository) { + this.repository = repository; + } + + /** Log an action using the current SecurityContext for username */ + public void log(String action, AuditCategory category, String target, + Map detail, AuditResult result, + HttpServletRequest request) { + String username = extractUsername(); + log(username, action, category, target, detail, result, request); + } + + /** Log an action with explicit username (for pre-auth contexts like login) */ + public void log(String username, String action, AuditCategory category, String target, + Map detail, AuditResult result, + HttpServletRequest request) { + String ip = request != null ? request.getRemoteAddr() : null; + String userAgent = request != null ? request.getHeader("User-Agent") : null; + AuditRecord record = AuditRecord.create(username, action, category, target, detail, result, ip, userAgent); + + repository.insert(record); + + log.info("AUDIT: user={} action={} category={} target={} result={}", + username, action, category, target, result); + } + + private String extractUsername() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getName() != null) { + String name = auth.getName(); + return name.startsWith("user:") ? name.substring(5) : name; + } + return "unknown"; + } +} From 4d33592015c0e43290bd92f7e1ca829ee214f16c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:43:16 +0100 Subject: [PATCH 03/18] feat: add ThresholdConfig, ThresholdRepository, SearchIndexerStats, and instrument SearchIndexer Co-Authored-By: Claude Opus 4.6 (1M context) --- .../server/core/admin/ThresholdConfig.java | 36 +++++++++++ .../core/admin/ThresholdRepository.java | 8 +++ .../server/core/indexing/SearchIndexer.java | 64 ++++++++++++++++++- .../core/indexing/SearchIndexerStats.java | 14 ++++ 4 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/ThresholdConfig.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/ThresholdRepository.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/indexing/SearchIndexerStats.java diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/ThresholdConfig.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/ThresholdConfig.java new file mode 100644 index 00000000..58714c28 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/ThresholdConfig.java @@ -0,0 +1,36 @@ +package com.cameleer3.server.core.admin; + +public record ThresholdConfig( + DatabaseThresholds database, + OpenSearchThresholds opensearch +) { + public record DatabaseThresholds( + int connectionPoolWarning, + int connectionPoolCritical, + double queryDurationWarning, + double queryDurationCritical + ) { + public static DatabaseThresholds defaults() { + return new DatabaseThresholds(80, 95, 1.0, 10.0); + } + } + + public record OpenSearchThresholds( + String clusterHealthWarning, + String clusterHealthCritical, + int queueDepthWarning, + int queueDepthCritical, + int jvmHeapWarning, + int jvmHeapCritical, + int failedDocsWarning, + int failedDocsCritical + ) { + public static OpenSearchThresholds defaults() { + return new OpenSearchThresholds("YELLOW", "RED", 100, 500, 75, 90, 1, 10); + } + } + + public static ThresholdConfig defaults() { + return new ThresholdConfig(DatabaseThresholds.defaults(), OpenSearchThresholds.defaults()); + } +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/ThresholdRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/ThresholdRepository.java new file mode 100644 index 00000000..2e9a02f9 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/ThresholdRepository.java @@ -0,0 +1,8 @@ +package com.cameleer3.server.core.admin; + +import java.util.Optional; + +public interface ThresholdRepository { + Optional find(); + void save(ThresholdConfig config, String updatedBy); +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/indexing/SearchIndexer.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/indexing/SearchIndexer.java index 6cff9e8d..f616e35d 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/indexing/SearchIndexer.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/indexing/SearchIndexer.java @@ -9,11 +9,13 @@ import com.cameleer3.server.core.storage.model.ExecutionDocument.ProcessorDoc; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; -public class SearchIndexer { +public class SearchIndexer implements SearchIndexerStats { private static final Logger log = LoggerFactory.getLogger(SearchIndexer.class); @@ -26,6 +28,14 @@ public class SearchIndexer { private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor( r -> { Thread t = new Thread(r, "search-indexer"); t.setDaemon(true); return t; }); + private final AtomicLong failedCount = new AtomicLong(); + private final AtomicLong indexedCount = new AtomicLong(); + private volatile Instant lastIndexedAt; + + private final AtomicLong rateWindowStartMs = new AtomicLong(System.currentTimeMillis()); + private final AtomicLong rateWindowCount = new AtomicLong(); + private volatile double lastRate; + public SearchIndexer(ExecutionStore executionStore, SearchIndex searchIndex, long debounceMs, int queueCapacity) { this.executionStore = executionStore; @@ -68,11 +78,63 @@ public class SearchIndexer { exec.status(), exec.correlationId(), exec.exchangeId(), exec.startTime(), exec.endTime(), exec.durationMs(), exec.errorMessage(), exec.errorStacktrace(), processorDocs)); + + indexedCount.incrementAndGet(); + lastIndexedAt = Instant.now(); + updateRate(); } catch (Exception e) { + failedCount.incrementAndGet(); log.error("Failed to index execution {}", executionId, e); } } + private void updateRate() { + long now = System.currentTimeMillis(); + long windowStart = rateWindowStartMs.get(); + long count = rateWindowCount.incrementAndGet(); + long elapsed = now - windowStart; + if (elapsed >= 15_000) { // 15-second window + lastRate = count / (elapsed / 1000.0); + rateWindowStartMs.set(now); + rateWindowCount.set(0); + } + } + + @Override + public int getQueueDepth() { + return pending.size(); + } + + @Override + public int getMaxQueueSize() { + return queueCapacity; + } + + @Override + public long getFailedCount() { + return failedCount.get(); + } + + @Override + public long getIndexedCount() { + return indexedCount.get(); + } + + @Override + public Instant getLastIndexedAt() { + return lastIndexedAt; + } + + @Override + public long getDebounceMs() { + return debounceMs; + } + + @Override + public double getIndexingRate() { + return lastRate; + } + public void shutdown() { scheduler.shutdown(); } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/indexing/SearchIndexerStats.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/indexing/SearchIndexerStats.java new file mode 100644 index 00000000..e743fe9d --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/indexing/SearchIndexerStats.java @@ -0,0 +1,14 @@ +package com.cameleer3.server.core.indexing; + +import java.time.Instant; + +public interface SearchIndexerStats { + int getQueueDepth(); + int getMaxQueueSize(); + long getFailedCount(); + long getIndexedCount(); + Instant getLastIndexedAt(); + long getDebounceMs(); + /** Approximate indexing rate in docs/sec over last measurement window */ + double getIndexingRate(); +} From e8842e3bdcaba684b828278e66f2336b5dcf46c5 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:51:13 +0100 Subject: [PATCH 04/18] feat: add Postgres implementations for AuditRepository and ThresholdRepository Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/storage/PostgresAuditRepository.java | 131 ++++++++++++++++++ .../storage/PostgresThresholdRepository.java | 58 ++++++++ 2 files changed, 189 insertions(+) create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAuditRepository.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresThresholdRepository.java diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAuditRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAuditRepository.java new file mode 100644 index 00000000..c03084ce --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAuditRepository.java @@ -0,0 +1,131 @@ +package com.cameleer3.server.app.storage; + +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditRecord; +import com.cameleer3.server.core.admin.AuditRepository; +import com.cameleer3.server.core.admin.AuditResult; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Repository +public class PostgresAuditRepository implements AuditRepository { + + private static final Set ALLOWED_SORT_COLUMNS = + Set.of("timestamp", "username", "action", "category"); + private static final int MAX_PAGE_SIZE = 100; + + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + + public PostgresAuditRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + } + + @Override + public void insert(AuditRecord record) { + String detailJson = null; + if (record.detail() != null) { + try { + detailJson = objectMapper.writeValueAsString(record.detail()); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize audit detail", e); + } + } + + jdbc.update(""" + INSERT INTO audit_log (username, action, category, target, detail, result, ip_address, user_agent) + VALUES (?, ?, ?, ?, ?::jsonb, ?, ?, ?) + """, + record.username(), record.action(), + record.category() != null ? record.category().name() : null, + record.target(), detailJson, + record.result() != null ? record.result().name() : null, + record.ipAddress(), record.userAgent()); + } + + @Override + public AuditPage find(AuditQuery query) { + int pageSize = Math.min(query.size() > 0 ? query.size() : 20, MAX_PAGE_SIZE); + int offset = query.page() * pageSize; + + StringBuilder where = new StringBuilder("WHERE timestamp >= ? AND timestamp <= ?"); + List params = new ArrayList<>(); + params.add(Timestamp.from(query.from())); + params.add(Timestamp.from(query.to())); + + if (query.username() != null && !query.username().isBlank()) { + where.append(" AND username = ?"); + params.add(query.username()); + } + if (query.category() != null) { + where.append(" AND category = ?"); + params.add(query.category().name()); + } + if (query.search() != null && !query.search().isBlank()) { + where.append(" AND (action ILIKE ? OR target ILIKE ?)"); + String like = "%" + query.search() + "%"; + params.add(like); + params.add(like); + } + + // Count query + String countSql = "SELECT COUNT(*) FROM audit_log " + where; + Long totalCount = jdbc.queryForObject(countSql, Long.class, params.toArray()); + + // Sort column validation + String sortCol = ALLOWED_SORT_COLUMNS.contains(query.sort()) ? query.sort() : "timestamp"; + String order = "asc".equalsIgnoreCase(query.order()) ? "ASC" : "DESC"; + + String dataSql = "SELECT * FROM audit_log " + where + + " ORDER BY " + sortCol + " " + order + + " LIMIT ? OFFSET ?"; + List dataParams = new ArrayList<>(params); + dataParams.add(pageSize); + dataParams.add(offset); + + List items = jdbc.query(dataSql, (rs, rowNum) -> mapRecord(rs), dataParams.toArray()); + return new AuditPage(items, totalCount != null ? totalCount : 0); + } + + @SuppressWarnings("unchecked") + private AuditRecord mapRecord(ResultSet rs) throws SQLException { + Map detail = null; + String detailStr = rs.getString("detail"); + if (detailStr != null) { + try { + detail = objectMapper.readValue(detailStr, Map.class); + } catch (JsonProcessingException e) { + // leave detail as null if unparseable + } + } + + Timestamp ts = rs.getTimestamp("timestamp"); + String categoryStr = rs.getString("category"); + String resultStr = rs.getString("result"); + + return new AuditRecord( + rs.getLong("id"), + ts != null ? ts.toInstant() : null, + rs.getString("username"), + rs.getString("action"), + categoryStr != null ? AuditCategory.valueOf(categoryStr) : null, + rs.getString("target"), + detail, + resultStr != null ? AuditResult.valueOf(resultStr) : null, + rs.getString("ip_address"), + rs.getString("user_agent") + ); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresThresholdRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresThresholdRepository.java new file mode 100644 index 00000000..0a9f9606 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresThresholdRepository.java @@ -0,0 +1,58 @@ +package com.cameleer3.server.app.storage; + +import com.cameleer3.server.core.admin.ThresholdConfig; +import com.cameleer3.server.core.admin.ThresholdRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public class PostgresThresholdRepository implements ThresholdRepository { + + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + + public PostgresThresholdRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + } + + @Override + public Optional find() { + List results = jdbc.query( + "SELECT config FROM admin_thresholds WHERE id = 1", + (rs, rowNum) -> { + String json = rs.getString("config"); + try { + return objectMapper.readValue(json, ThresholdConfig.class); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to deserialize threshold config", e); + } + }); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + + @Override + public void save(ThresholdConfig config, String updatedBy) { + String json; + try { + json = objectMapper.writeValueAsString(config); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize threshold config", e); + } + + jdbc.update(""" + INSERT INTO admin_thresholds (id, config, updated_by, updated_at) + VALUES (1, ?::jsonb, ?, now()) + ON CONFLICT (id) DO UPDATE SET + config = EXCLUDED.config, + updated_by = EXCLUDED.updated_by, + updated_at = now() + """, + json, updatedBy); + } +} From 1d6ae00b1cf539b0ef87a41fd693dcabcbf44047 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:51:22 +0100 Subject: [PATCH 05/18] feat: wire AuditService, enable method security, retrofit audit logging into existing controllers Co-Authored-By: Claude Opus 4.6 (1M context) --- .../server/app/config/StorageBeanConfig.java | 7 ++++++ .../controller/OidcConfigAdminController.java | 22 +++++++++++++++---- .../app/controller/UserAdminController.java | 21 +++++++++++++++--- .../app/security/OidcAuthController.java | 15 +++++++++++-- .../server/app/security/SecurityConfig.java | 2 ++ .../server/app/security/UiAuthController.java | 17 ++++++++++++-- 6 files changed, 73 insertions(+), 11 deletions(-) diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/StorageBeanConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/StorageBeanConfig.java index 92f34943..9a971357 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/StorageBeanConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/StorageBeanConfig.java @@ -1,5 +1,7 @@ package com.cameleer3.server.app.config; +import com.cameleer3.server.core.admin.AuditRepository; +import com.cameleer3.server.core.admin.AuditService; import com.cameleer3.server.core.detail.DetailService; import com.cameleer3.server.core.indexing.SearchIndexer; import com.cameleer3.server.core.ingestion.IngestionService; @@ -25,6 +27,11 @@ public class StorageBeanConfig { return new SearchIndexer(executionStore, searchIndex, debounceMs, queueSize); } + @Bean + public AuditService auditService(AuditRepository auditRepository) { + return new AuditService(auditRepository); + } + @Bean public IngestionService ingestionService(ExecutionStore executionStore, DiagramStore diagramStore, diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java index de0d4a7c..1fbd445c 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java @@ -5,8 +5,12 @@ import com.cameleer3.server.app.dto.OidcAdminConfigRequest; import com.cameleer3.server.app.dto.OidcAdminConfigResponse; import com.cameleer3.server.app.dto.OidcTestResult; import com.cameleer3.server.app.security.OidcTokenExchanger; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditResult; +import com.cameleer3.server.core.admin.AuditService; import com.cameleer3.server.core.security.OidcConfig; import com.cameleer3.server.core.security.OidcConfigRepository; +import jakarta.servlet.http.HttpServletRequest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -16,6 +20,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -26,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import java.util.List; +import java.util.Map; import java.util.Optional; /** @@ -35,17 +41,21 @@ import java.util.Optional; @RestController @RequestMapping("/api/v1/admin/oidc") @Tag(name = "OIDC Config Admin", description = "OIDC provider configuration (ADMIN only)") +@PreAuthorize("hasRole('ADMIN')") public class OidcConfigAdminController { private static final Logger log = LoggerFactory.getLogger(OidcConfigAdminController.class); private final OidcConfigRepository configRepository; private final OidcTokenExchanger tokenExchanger; + private final AuditService auditService; public OidcConfigAdminController(OidcConfigRepository configRepository, - OidcTokenExchanger tokenExchanger) { + OidcTokenExchanger tokenExchanger, + AuditService auditService) { this.configRepository = configRepository; this.tokenExchanger = tokenExchanger; + this.auditService = auditService; } @GetMapping @@ -64,7 +74,8 @@ public class OidcConfigAdminController { @ApiResponse(responseCode = "200", description = "Configuration saved") @ApiResponse(responseCode = "400", description = "Invalid configuration", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - public ResponseEntity saveConfig(@RequestBody OidcAdminConfigRequest request) { + public ResponseEntity saveConfig(@RequestBody OidcAdminConfigRequest request, + HttpServletRequest httpRequest) { // Resolve client_secret: if masked or empty, preserve existing String clientSecret = request.clientSecret(); if (clientSecret == null || clientSecret.isBlank() || clientSecret.equals("********")) { @@ -95,6 +106,7 @@ public class OidcConfigAdminController { configRepository.save(config); tokenExchanger.invalidateCache(); + auditService.log("update_oidc", AuditCategory.CONFIG, "oidc", Map.of(), AuditResult.SUCCESS, httpRequest); log.info("OIDC configuration updated: enabled={}, issuer={}", config.enabled(), config.issuerUri()); return ResponseEntity.ok(OidcAdminConfigResponse.from(config)); } @@ -104,7 +116,7 @@ public class OidcConfigAdminController { @ApiResponse(responseCode = "200", description = "Provider reachable") @ApiResponse(responseCode = "400", description = "Provider unreachable or misconfigured", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - public ResponseEntity testConnection() { + public ResponseEntity testConnection(HttpServletRequest httpRequest) { Optional config = configRepository.find(); if (config.isEmpty() || !config.get().enabled()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, @@ -114,6 +126,7 @@ public class OidcConfigAdminController { try { tokenExchanger.invalidateCache(); String authEndpoint = tokenExchanger.getAuthorizationEndpoint(); + auditService.log("test_oidc", AuditCategory.CONFIG, "oidc", null, AuditResult.SUCCESS, httpRequest); return ResponseEntity.ok(new OidcTestResult("ok", authEndpoint)); } catch (Exception e) { log.warn("OIDC connectivity test failed: {}", e.getMessage()); @@ -125,9 +138,10 @@ public class OidcConfigAdminController { @DeleteMapping @Operation(summary = "Delete OIDC configuration") @ApiResponse(responseCode = "204", description = "Configuration deleted") - public ResponseEntity deleteConfig() { + public ResponseEntity deleteConfig(HttpServletRequest httpRequest) { configRepository.delete(); tokenExchanger.invalidateCache(); + auditService.log("delete_oidc", AuditCategory.CONFIG, "oidc", null, AuditResult.SUCCESS, httpRequest); log.info("OIDC configuration deleted"); return ResponseEntity.noContent().build(); } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java index e0cfd1b3..2f837c86 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java @@ -1,11 +1,16 @@ package com.cameleer3.server.app.controller; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditResult; +import com.cameleer3.server.core.admin.AuditService; import com.cameleer3.server.core.security.UserInfo; import com.cameleer3.server.core.security.UserRepository; +import jakarta.servlet.http.HttpServletRequest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -15,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.Map; /** * Admin endpoints for user management. @@ -23,12 +29,15 @@ import java.util.List; @RestController @RequestMapping("/api/v1/admin/users") @Tag(name = "User Admin", description = "User management (ADMIN only)") +@PreAuthorize("hasRole('ADMIN')") public class UserAdminController { private final UserRepository userRepository; + private final AuditService auditService; - public UserAdminController(UserRepository userRepository) { + public UserAdminController(UserRepository userRepository, AuditService auditService) { this.userRepository = userRepository; + this.auditService = auditService; } @GetMapping @@ -53,19 +62,25 @@ public class UserAdminController { @ApiResponse(responseCode = "200", description = "Roles updated") @ApiResponse(responseCode = "404", description = "User not found") public ResponseEntity updateRoles(@PathVariable String userId, - @RequestBody RolesRequest request) { + @RequestBody RolesRequest request, + HttpServletRequest httpRequest) { if (userRepository.findById(userId).isEmpty()) { return ResponseEntity.notFound().build(); } userRepository.updateRoles(userId, request.roles()); + auditService.log("update_roles", AuditCategory.USER_MGMT, userId, + Map.of("roles", request.roles()), AuditResult.SUCCESS, httpRequest); return ResponseEntity.ok().build(); } @DeleteMapping("/{userId}") @Operation(summary = "Delete user") @ApiResponse(responseCode = "204", description = "User deleted") - public ResponseEntity deleteUser(@PathVariable String userId) { + public ResponseEntity deleteUser(@PathVariable String userId, + HttpServletRequest httpRequest) { userRepository.delete(userId); + auditService.log("delete_user", AuditCategory.USER_MGMT, userId, + null, AuditResult.SUCCESS, httpRequest); return ResponseEntity.noContent().build(); } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java index 81394eaa..db7e0a73 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java @@ -3,11 +3,15 @@ package com.cameleer3.server.app.security; import com.cameleer3.server.app.dto.AuthTokenResponse; import com.cameleer3.server.app.dto.ErrorResponse; import com.cameleer3.server.app.dto.OidcPublicConfigResponse; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditResult; +import com.cameleer3.server.core.admin.AuditService; import com.cameleer3.server.core.security.JwtService; import com.cameleer3.server.core.security.OidcConfig; import com.cameleer3.server.core.security.OidcConfigRepository; import com.cameleer3.server.core.security.UserInfo; import com.cameleer3.server.core.security.UserRepository; +import jakarta.servlet.http.HttpServletRequest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -27,6 +31,7 @@ import org.springframework.web.server.ResponseStatusException; import java.net.URI; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.Optional; /** @@ -46,15 +51,18 @@ public class OidcAuthController { private final OidcConfigRepository configRepository; private final JwtService jwtService; private final UserRepository userRepository; + private final AuditService auditService; public OidcAuthController(OidcTokenExchanger tokenExchanger, OidcConfigRepository configRepository, JwtService jwtService, - UserRepository userRepository) { + UserRepository userRepository, + AuditService auditService) { this.tokenExchanger = tokenExchanger; this.configRepository = configRepository; this.jwtService = jwtService; this.userRepository = userRepository; + this.auditService = auditService; } /** @@ -100,7 +108,8 @@ public class OidcAuthController { @ApiResponse(responseCode = "403", description = "Account not provisioned", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) @ApiResponse(responseCode = "404", description = "OIDC not configured or disabled") - public ResponseEntity callback(@RequestBody CallbackRequest request) { + public ResponseEntity callback(@RequestBody CallbackRequest request, + HttpServletRequest httpRequest) { Optional config = configRepository.find(); if (config.isEmpty() || !config.get().enabled()) { return ResponseEntity.notFound().build(); @@ -132,6 +141,8 @@ public class OidcAuthController { String displayName = oidcUser.name() != null && !oidcUser.name().isBlank() ? oidcUser.name() : oidcUser.email(); + auditService.log(userId, "login_oidc", AuditCategory.AUTH, null, + Map.of("provider", config.get().issuerUri()), AuditResult.SUCCESS, httpRequest); return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName, oidcUser.idToken())); } catch (ResponseStatusException e) { throw e; diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java index 2a6f786b..3add6b7a 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java @@ -6,6 +6,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -27,6 +28,7 @@ import java.util.List; */ @Configuration @EnableWebSecurity +@EnableMethodSecurity public class SecurityConfig { @Bean diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java index 50a56486..6fd1805d 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java @@ -2,7 +2,11 @@ package com.cameleer3.server.app.security; import com.cameleer3.server.app.dto.AuthTokenResponse; import com.cameleer3.server.app.dto.ErrorResponse; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditResult; +import com.cameleer3.server.core.admin.AuditService; import com.cameleer3.server.core.security.JwtService; +import jakarta.servlet.http.HttpServletRequest; import com.cameleer3.server.core.security.JwtService.JwtValidationResult; import com.cameleer3.server.core.security.UserInfo; import com.cameleer3.server.core.security.UserRepository; @@ -23,6 +27,7 @@ import org.springframework.web.server.ResponseStatusException; import java.time.Instant; import java.util.List; +import java.util.Map; /** * Authentication endpoints for the UI (local credentials). @@ -41,12 +46,14 @@ public class UiAuthController { private final JwtService jwtService; private final SecurityProperties properties; private final UserRepository userRepository; + private final AuditService auditService; public UiAuthController(JwtService jwtService, SecurityProperties properties, - UserRepository userRepository) { + UserRepository userRepository, AuditService auditService) { this.jwtService = jwtService; this.properties = properties; this.userRepository = userRepository; + this.auditService = auditService; } @PostMapping("/login") @@ -54,19 +61,24 @@ public class UiAuthController { @ApiResponse(responseCode = "200", description = "Login successful") @ApiResponse(responseCode = "401", description = "Invalid credentials", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - public ResponseEntity login(@RequestBody LoginRequest request) { + public ResponseEntity login(@RequestBody LoginRequest request, + HttpServletRequest httpRequest) { String configuredUser = properties.getUiUser(); String configuredPassword = properties.getUiPassword(); if (configuredUser == null || configuredUser.isBlank() || configuredPassword == null || configuredPassword.isBlank()) { log.warn("UI authentication attempted but CAMELEER_UI_USER / CAMELEER_UI_PASSWORD not configured"); + auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null, + Map.of("reason", "UI authentication not configured"), AuditResult.FAILURE, httpRequest); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "UI authentication not configured"); } if (!configuredUser.equals(request.username()) || !configuredPassword.equals(request.password())) { log.debug("UI login failed for user: {}", request.username()); + auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null, + Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials"); } @@ -84,6 +96,7 @@ public class UiAuthController { String accessToken = jwtService.createAccessToken(subject, "user", roles); String refreshToken = jwtService.createRefreshToken(subject, "user", roles); + auditService.log(request.username(), "login", AuditCategory.AUTH, null, null, AuditResult.SUCCESS, httpRequest); log.info("UI user logged in: {}", request.username()); return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, request.username(), null)); } From 0cea8af6bc968d2d6eed1a8c12ac76653b065e2c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:51:31 +0100 Subject: [PATCH 06/18] feat: add response/request DTOs for admin infrastructure endpoints Co-Authored-By: Claude Opus 4.6 (1M context) --- .../server/app/dto/ActiveQueryResponse.java | 11 ++ .../server/app/dto/AuditLogPageResponse.java | 15 ++ .../app/dto/ConnectionPoolResponse.java | 12 ++ .../app/dto/DatabaseStatusResponse.java | 12 ++ .../server/app/dto/IndexInfoResponse.java | 14 ++ .../server/app/dto/IndicesPageResponse.java | 16 ++ .../app/dto/OpenSearchStatusResponse.java | 12 ++ .../server/app/dto/PerformanceResponse.java | 13 ++ .../server/app/dto/PipelineStatsResponse.java | 16 ++ .../server/app/dto/TableSizeResponse.java | 13 ++ .../app/dto/ThresholdConfigRequest.java | 144 ++++++++++++++++++ 11 files changed, 278 insertions(+) create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ActiveQueryResponse.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AuditLogPageResponse.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ConnectionPoolResponse.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/DatabaseStatusResponse.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/IndexInfoResponse.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/IndicesPageResponse.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OpenSearchStatusResponse.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/PerformanceResponse.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/PipelineStatsResponse.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/TableSizeResponse.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ThresholdConfigRequest.java diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ActiveQueryResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ActiveQueryResponse.java new file mode 100644 index 00000000..39046869 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ActiveQueryResponse.java @@ -0,0 +1,11 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Currently running database query") +public record ActiveQueryResponse( + @Schema(description = "Backend process ID") int pid, + @Schema(description = "Query duration in seconds") double durationSeconds, + @Schema(description = "Backend state (active, idle, etc.)") String state, + @Schema(description = "SQL query text") String query +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AuditLogPageResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AuditLogPageResponse.java new file mode 100644 index 00000000..b1b1ea58 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AuditLogPageResponse.java @@ -0,0 +1,15 @@ +package com.cameleer3.server.app.dto; + +import com.cameleer3.server.core.admin.AuditRecord; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "Paginated audit log entries") +public record AuditLogPageResponse( + @Schema(description = "Audit log entries") List items, + @Schema(description = "Total number of matching entries") long totalCount, + @Schema(description = "Current page number (0-based)") int page, + @Schema(description = "Page size") int pageSize, + @Schema(description = "Total number of pages") int totalPages +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ConnectionPoolResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ConnectionPoolResponse.java new file mode 100644 index 00000000..cc3f0b60 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ConnectionPoolResponse.java @@ -0,0 +1,12 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "HikariCP connection pool statistics") +public record ConnectionPoolResponse( + @Schema(description = "Number of currently active connections") int activeConnections, + @Schema(description = "Number of idle connections") int idleConnections, + @Schema(description = "Number of threads waiting for a connection") int pendingThreads, + @Schema(description = "Maximum wait time in milliseconds") long maxWaitMs, + @Schema(description = "Maximum pool size") int maxPoolSize +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/DatabaseStatusResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/DatabaseStatusResponse.java new file mode 100644 index 00000000..c845c355 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/DatabaseStatusResponse.java @@ -0,0 +1,12 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Database connection and version status") +public record DatabaseStatusResponse( + @Schema(description = "Whether the database is reachable") boolean connected, + @Schema(description = "PostgreSQL version string") String version, + @Schema(description = "Database host") String host, + @Schema(description = "Current schema search path") String schema, + @Schema(description = "Whether TimescaleDB extension is available") boolean timescaleDb +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/IndexInfoResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/IndexInfoResponse.java new file mode 100644 index 00000000..6ab5dcd3 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/IndexInfoResponse.java @@ -0,0 +1,14 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "OpenSearch index information") +public record IndexInfoResponse( + @Schema(description = "Index name") String name, + @Schema(description = "Document count") long docCount, + @Schema(description = "Human-readable index size") String size, + @Schema(description = "Index size in bytes") long sizeBytes, + @Schema(description = "Index health status") String health, + @Schema(description = "Number of primary shards") int primaryShards, + @Schema(description = "Number of replica shards") int replicaShards +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/IndicesPageResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/IndicesPageResponse.java new file mode 100644 index 00000000..469ab84e --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/IndicesPageResponse.java @@ -0,0 +1,16 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "Paginated list of OpenSearch indices") +public record IndicesPageResponse( + @Schema(description = "Index list for current page") List indices, + @Schema(description = "Total number of indices") long totalIndices, + @Schema(description = "Total document count across all indices") long totalDocs, + @Schema(description = "Human-readable total size") String totalSize, + @Schema(description = "Current page number (0-based)") int page, + @Schema(description = "Page size") int pageSize, + @Schema(description = "Total number of pages") int totalPages +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OpenSearchStatusResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OpenSearchStatusResponse.java new file mode 100644 index 00000000..612982fe --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OpenSearchStatusResponse.java @@ -0,0 +1,12 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "OpenSearch cluster status") +public record OpenSearchStatusResponse( + @Schema(description = "Whether the cluster is reachable") boolean reachable, + @Schema(description = "Cluster health status (GREEN, YELLOW, RED)") String clusterHealth, + @Schema(description = "OpenSearch version") String version, + @Schema(description = "Number of nodes in the cluster") int nodeCount, + @Schema(description = "OpenSearch host") String host +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/PerformanceResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/PerformanceResponse.java new file mode 100644 index 00000000..d34a3fad --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/PerformanceResponse.java @@ -0,0 +1,13 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "OpenSearch performance metrics") +public record PerformanceResponse( + @Schema(description = "Query cache hit rate (0.0-1.0)") double queryCacheHitRate, + @Schema(description = "Request cache hit rate (0.0-1.0)") double requestCacheHitRate, + @Schema(description = "Average search latency in milliseconds") double searchLatencyMs, + @Schema(description = "Average indexing latency in milliseconds") double indexingLatencyMs, + @Schema(description = "JVM heap used in bytes") long jvmHeapUsedBytes, + @Schema(description = "JVM heap max in bytes") long jvmHeapMaxBytes +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/PipelineStatsResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/PipelineStatsResponse.java new file mode 100644 index 00000000..f4285dc5 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/PipelineStatsResponse.java @@ -0,0 +1,16 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.Instant; + +@Schema(description = "Search indexing pipeline statistics") +public record PipelineStatsResponse( + @Schema(description = "Current queue depth") int queueDepth, + @Schema(description = "Maximum queue size") int maxQueueSize, + @Schema(description = "Number of failed indexing operations") long failedCount, + @Schema(description = "Number of successfully indexed documents") long indexedCount, + @Schema(description = "Debounce interval in milliseconds") long debounceMs, + @Schema(description = "Current indexing rate (docs/sec)") double indexingRate, + @Schema(description = "Timestamp of last indexed document") Instant lastIndexedAt +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/TableSizeResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/TableSizeResponse.java new file mode 100644 index 00000000..6849b528 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/TableSizeResponse.java @@ -0,0 +1,13 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Table size and row count information") +public record TableSizeResponse( + @Schema(description = "Table name") String tableName, + @Schema(description = "Approximate row count") long rowCount, + @Schema(description = "Human-readable data size") String dataSize, + @Schema(description = "Human-readable index size") String indexSize, + @Schema(description = "Data size in bytes") long dataSizeBytes, + @Schema(description = "Index size in bytes") long indexSizeBytes +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ThresholdConfigRequest.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ThresholdConfigRequest.java new file mode 100644 index 00000000..736210cb --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ThresholdConfigRequest.java @@ -0,0 +1,144 @@ +package com.cameleer3.server.app.dto; + +import com.cameleer3.server.core.admin.ThresholdConfig; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Schema(description = "Threshold configuration for admin monitoring") +public record ThresholdConfigRequest( + @Valid @NotNull DatabaseThresholdsRequest database, + @Valid @NotNull OpenSearchThresholdsRequest opensearch +) { + + @Schema(description = "Database monitoring thresholds") + public record DatabaseThresholdsRequest( + @Min(0) @Max(100) + @Schema(description = "Connection pool usage warning threshold (percentage)") + int connectionPoolWarning, + + @Min(0) @Max(100) + @Schema(description = "Connection pool usage critical threshold (percentage)") + int connectionPoolCritical, + + @Positive + @Schema(description = "Query duration warning threshold (seconds)") + double queryDurationWarning, + + @Positive + @Schema(description = "Query duration critical threshold (seconds)") + double queryDurationCritical + ) {} + + @Schema(description = "OpenSearch monitoring thresholds") + public record OpenSearchThresholdsRequest( + @NotBlank + @Schema(description = "Cluster health warning threshold (GREEN, YELLOW, RED)") + String clusterHealthWarning, + + @NotBlank + @Schema(description = "Cluster health critical threshold (GREEN, YELLOW, RED)") + String clusterHealthCritical, + + @Min(0) + @Schema(description = "Queue depth warning threshold") + int queueDepthWarning, + + @Min(0) + @Schema(description = "Queue depth critical threshold") + int queueDepthCritical, + + @Min(0) @Max(100) + @Schema(description = "JVM heap usage warning threshold (percentage)") + int jvmHeapWarning, + + @Min(0) @Max(100) + @Schema(description = "JVM heap usage critical threshold (percentage)") + int jvmHeapCritical, + + @Min(0) + @Schema(description = "Failed document count warning threshold") + int failedDocsWarning, + + @Min(0) + @Schema(description = "Failed document count critical threshold") + int failedDocsCritical + ) {} + + /** Convert to core domain model */ + public ThresholdConfig toConfig() { + return new ThresholdConfig( + new ThresholdConfig.DatabaseThresholds( + database.connectionPoolWarning(), + database.connectionPoolCritical(), + database.queryDurationWarning(), + database.queryDurationCritical() + ), + new ThresholdConfig.OpenSearchThresholds( + opensearch.clusterHealthWarning(), + opensearch.clusterHealthCritical(), + opensearch.queueDepthWarning(), + opensearch.queueDepthCritical(), + opensearch.jvmHeapWarning(), + opensearch.jvmHeapCritical(), + opensearch.failedDocsWarning(), + opensearch.failedDocsCritical() + ) + ); + } + + /** Validate semantic constraints beyond annotation-level validation */ + public List validate() { + List errors = new ArrayList<>(); + + if (database != null) { + if (database.connectionPoolWarning() > database.connectionPoolCritical()) { + errors.add("database.connectionPoolWarning must be <= connectionPoolCritical"); + } + if (database.queryDurationWarning() > database.queryDurationCritical()) { + errors.add("database.queryDurationWarning must be <= queryDurationCritical"); + } + } + + if (opensearch != null) { + if (opensearch.queueDepthWarning() > opensearch.queueDepthCritical()) { + errors.add("opensearch.queueDepthWarning must be <= queueDepthCritical"); + } + if (opensearch.jvmHeapWarning() > opensearch.jvmHeapCritical()) { + errors.add("opensearch.jvmHeapWarning must be <= jvmHeapCritical"); + } + if (opensearch.failedDocsWarning() > opensearch.failedDocsCritical()) { + errors.add("opensearch.failedDocsWarning must be <= failedDocsCritical"); + } + // Validate health severity ordering: GREEN < YELLOW < RED + int warningSeverity = healthSeverity(opensearch.clusterHealthWarning()); + int criticalSeverity = healthSeverity(opensearch.clusterHealthCritical()); + if (warningSeverity < 0) { + errors.add("opensearch.clusterHealthWarning must be GREEN, YELLOW, or RED"); + } + if (criticalSeverity < 0) { + errors.add("opensearch.clusterHealthCritical must be GREEN, YELLOW, or RED"); + } + if (warningSeverity >= 0 && criticalSeverity >= 0 && warningSeverity > criticalSeverity) { + errors.add("opensearch.clusterHealthWarning severity must be <= clusterHealthCritical (GREEN < YELLOW < RED)"); + } + } + + return errors; + } + + private static final Map HEALTH_SEVERITY = + Map.of("GREEN", 0, "YELLOW", 1, "RED", 2); + + private static int healthSeverity(String health) { + return HEALTH_SEVERITY.getOrDefault(health != null ? health.toUpperCase() : "", -1); + } +} From c6b2f7c33147d2bf968540737a27ff54efd530d6 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:57:14 +0100 Subject: [PATCH 07/18] feat: add DatabaseAdminController with status, pool, tables, queries, and kill endpoints Co-Authored-By: Claude Opus 4.6 (1M context) --- .../controller/DatabaseAdminController.java | 129 ++++++++++++++++++ .../controller/DatabaseAdminControllerIT.java | 109 +++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DatabaseAdminController.java create mode 100644 cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DatabaseAdminControllerIT.java diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DatabaseAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DatabaseAdminController.java new file mode 100644 index 00000000..3bd0affd --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DatabaseAdminController.java @@ -0,0 +1,129 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.dto.ActiveQueryResponse; +import com.cameleer3.server.app.dto.ConnectionPoolResponse; +import com.cameleer3.server.app.dto.DatabaseStatusResponse; +import com.cameleer3.server.app.dto.TableSizeResponse; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditResult; +import com.cameleer3.server.core.admin.AuditService; +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.HikariPoolMXBean; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import javax.sql.DataSource; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/admin/database") +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "Database Admin", description = "Database monitoring and management (ADMIN only)") +public class DatabaseAdminController { + + private final JdbcTemplate jdbc; + private final DataSource dataSource; + private final AuditService auditService; + + public DatabaseAdminController(JdbcTemplate jdbc, DataSource dataSource, AuditService auditService) { + this.jdbc = jdbc; + this.dataSource = dataSource; + this.auditService = auditService; + } + + @GetMapping("/status") + @Operation(summary = "Get database connection status and version") + public ResponseEntity getStatus() { + try { + String version = jdbc.queryForObject("SELECT version()", String.class); + boolean timescaleDb = Boolean.TRUE.equals( + jdbc.queryForObject("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')", Boolean.class)); + String schema = jdbc.queryForObject("SELECT current_schema()", String.class); + String host = extractHost(dataSource); + return ResponseEntity.ok(new DatabaseStatusResponse(true, version, host, schema, timescaleDb)); + } catch (Exception e) { + return ResponseEntity.ok(new DatabaseStatusResponse(false, null, null, null, false)); + } + } + + @GetMapping("/pool") + @Operation(summary = "Get HikariCP connection pool stats") + public ResponseEntity getPool() { + HikariDataSource hds = (HikariDataSource) dataSource; + HikariPoolMXBean pool = hds.getHikariPoolMXBean(); + return ResponseEntity.ok(new ConnectionPoolResponse( + pool.getActiveConnections(), pool.getIdleConnections(), + pool.getThreadsAwaitingConnection(), hds.getConnectionTimeout(), + hds.getMaximumPoolSize())); + } + + @GetMapping("/tables") + @Operation(summary = "Get table sizes and row counts") + public ResponseEntity> getTables() { + var tables = jdbc.query(""" + SELECT schemaname || '.' || relname AS table_name, + n_live_tup AS row_count, + pg_size_pretty(pg_total_relation_size(relid)) AS data_size, + pg_total_relation_size(relid) AS data_size_bytes, + pg_size_pretty(pg_indexes_size(relid)) AS index_size, + pg_indexes_size(relid) AS index_size_bytes + FROM pg_stat_user_tables + ORDER BY pg_total_relation_size(relid) DESC + """, (rs, row) -> new TableSizeResponse( + rs.getString("table_name"), rs.getLong("row_count"), + rs.getString("data_size"), rs.getString("index_size"), + rs.getLong("data_size_bytes"), rs.getLong("index_size_bytes"))); + return ResponseEntity.ok(tables); + } + + @GetMapping("/queries") + @Operation(summary = "Get active queries") + public ResponseEntity> getQueries() { + var queries = jdbc.query(""" + SELECT pid, EXTRACT(EPOCH FROM (now() - query_start)) AS duration_seconds, + state, query + FROM pg_stat_activity + WHERE state != 'idle' AND pid != pg_backend_pid() + ORDER BY query_start ASC + """, (rs, row) -> new ActiveQueryResponse( + rs.getInt("pid"), rs.getDouble("duration_seconds"), + rs.getString("state"), rs.getString("query"))); + return ResponseEntity.ok(queries); + } + + @PostMapping("/queries/{pid}/kill") + @Operation(summary = "Terminate a query by PID") + public ResponseEntity killQuery(@PathVariable int pid, HttpServletRequest request) { + var exists = jdbc.queryForObject( + "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE pid = ? AND pid != pg_backend_pid())", + Boolean.class, pid); + if (!Boolean.TRUE.equals(exists)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No active query with PID " + pid); + } + jdbc.queryForObject("SELECT pg_terminate_backend(?)", Boolean.class, pid); + auditService.log("kill_query", AuditCategory.INFRA, "PID " + pid, null, AuditResult.SUCCESS, request); + return ResponseEntity.ok().build(); + } + + private String extractHost(DataSource ds) { + try { + if (ds instanceof HikariDataSource hds) { + return hds.getJdbcUrl(); + } + return "unknown"; + } catch (Exception e) { + return "unknown"; + } + } +} diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DatabaseAdminControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DatabaseAdminControllerIT.java new file mode 100644 index 00000000..dc40bed5 --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DatabaseAdminControllerIT.java @@ -0,0 +1,109 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.AbstractPostgresIT; +import com.cameleer3.server.app.TestSecurityHelper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +class DatabaseAdminControllerIT extends AbstractPostgresIT { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TestSecurityHelper securityHelper; + + private String adminJwt; + private String viewerJwt; + + @BeforeEach + void setUp() { + adminJwt = securityHelper.adminToken(); + viewerJwt = securityHelper.viewerToken(); + } + + @Test + void getStatus_asAdmin_returns200WithConnected() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/database/status", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.get("connected").asBoolean()).isTrue(); + assertThat(body.get("version").asText()).contains("PostgreSQL"); + assertThat(body.has("schema")).isTrue(); + } + + @Test + void getStatus_asViewer_returns403() { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/database/status", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void getPool_asAdmin_returns200WithPoolStats() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/database/pool", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.has("activeConnections")).isTrue(); + assertThat(body.has("idleConnections")).isTrue(); + assertThat(body.get("maxPoolSize").asInt()).isGreaterThan(0); + } + + @Test + void getTables_asAdmin_returns200WithTableList() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/database/tables", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.isArray()).isTrue(); + } + + @Test + void getQueries_asAdmin_returns200() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/database/queries", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.isArray()).isTrue(); + } + + @Test + void killQuery_unknownPid_returns404() { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/database/queries/999999/kill", HttpMethod.POST, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } +} From c6da858c2f948d848533405649806a8cdc056da7 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:57:18 +0100 Subject: [PATCH 08/18] feat: add OpenSearchAdminController with status, pipeline, indices, performance, and delete endpoints Co-Authored-By: Claude Opus 4.6 (1M context) --- .../controller/OpenSearchAdminController.java | 248 ++++++++++++++++++ .../OpenSearchAdminControllerIT.java | 112 ++++++++ 2 files changed, 360 insertions(+) create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OpenSearchAdminController.java create mode 100644 cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/OpenSearchAdminControllerIT.java diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OpenSearchAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OpenSearchAdminController.java new file mode 100644 index 00000000..f4f13ff4 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OpenSearchAdminController.java @@ -0,0 +1,248 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.dto.IndexInfoResponse; +import com.cameleer3.server.app.dto.IndicesPageResponse; +import com.cameleer3.server.app.dto.OpenSearchStatusResponse; +import com.cameleer3.server.app.dto.PerformanceResponse; +import com.cameleer3.server.app.dto.PipelineStatsResponse; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditResult; +import com.cameleer3.server.core.admin.AuditService; +import com.cameleer3.server.core.indexing.SearchIndexerStats; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import org.opensearch.client.Request; +import org.opensearch.client.Response; +import org.opensearch.client.RestClient; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.opensearch.cluster.HealthResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.io.InputStream; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/admin/opensearch") +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "OpenSearch Admin", description = "OpenSearch monitoring and management (ADMIN only)") +public class OpenSearchAdminController { + + private final OpenSearchClient client; + private final RestClient restClient; + private final SearchIndexerStats indexerStats; + private final AuditService auditService; + private final ObjectMapper objectMapper; + private final String opensearchUrl; + + public OpenSearchAdminController(OpenSearchClient client, RestClient restClient, + SearchIndexerStats indexerStats, AuditService auditService, + ObjectMapper objectMapper, + @Value("${opensearch.url:http://localhost:9200}") String opensearchUrl) { + this.client = client; + this.restClient = restClient; + this.indexerStats = indexerStats; + this.auditService = auditService; + this.objectMapper = objectMapper; + this.opensearchUrl = opensearchUrl; + } + + @GetMapping("/status") + @Operation(summary = "Get OpenSearch cluster status and version") + public ResponseEntity getStatus() { + try { + HealthResponse health = client.cluster().health(); + String version = client.info().version().number(); + return ResponseEntity.ok(new OpenSearchStatusResponse( + true, + health.status().name(), + version, + health.numberOfNodes(), + opensearchUrl)); + } catch (Exception e) { + return ResponseEntity.ok(new OpenSearchStatusResponse( + false, "UNREACHABLE", null, 0, opensearchUrl)); + } + } + + @GetMapping("/pipeline") + @Operation(summary = "Get indexing pipeline statistics") + public ResponseEntity getPipeline() { + return ResponseEntity.ok(new PipelineStatsResponse( + indexerStats.getQueueDepth(), + indexerStats.getMaxQueueSize(), + indexerStats.getFailedCount(), + indexerStats.getIndexedCount(), + indexerStats.getDebounceMs(), + indexerStats.getIndexingRate(), + indexerStats.getLastIndexedAt())); + } + + @GetMapping("/indices") + @Operation(summary = "Get OpenSearch indices with pagination") + public ResponseEntity getIndices( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "") String search) { + try { + Response response = restClient.performRequest( + new Request("GET", "/_cat/indices?format=json&h=index,health,docs.count,store.size,pri,rep&bytes=b")); + JsonNode indices; + try (InputStream is = response.getEntity().getContent()) { + indices = objectMapper.readTree(is); + } + + List allIndices = new ArrayList<>(); + for (JsonNode idx : indices) { + String name = idx.path("index").asText(""); + if (!search.isEmpty() && !name.contains(search)) { + continue; + } + allIndices.add(new IndexInfoResponse( + name, + parseLong(idx.path("docs.count").asText("0")), + humanSize(parseLong(idx.path("store.size").asText("0"))), + parseLong(idx.path("store.size").asText("0")), + idx.path("health").asText("unknown"), + parseInt(idx.path("pri").asText("0")), + parseInt(idx.path("rep").asText("0")))); + } + + allIndices.sort(Comparator.comparing(IndexInfoResponse::name)); + + long totalDocs = allIndices.stream().mapToLong(IndexInfoResponse::docCount).sum(); + long totalBytes = allIndices.stream().mapToLong(IndexInfoResponse::sizeBytes).sum(); + int totalIndices = allIndices.size(); + int totalPages = Math.max(1, (int) Math.ceil((double) totalIndices / size)); + + int fromIndex = Math.min(page * size, totalIndices); + int toIndex = Math.min(fromIndex + size, totalIndices); + List pageItems = allIndices.subList(fromIndex, toIndex); + + return ResponseEntity.ok(new IndicesPageResponse( + pageItems, totalIndices, totalDocs, + humanSize(totalBytes), page, size, totalPages)); + } catch (Exception e) { + return ResponseEntity.ok(new IndicesPageResponse( + List.of(), 0, 0, "0 B", page, size, 0)); + } + } + + @DeleteMapping("/indices/{name}") + @Operation(summary = "Delete an OpenSearch index") + public ResponseEntity deleteIndex(@PathVariable String name, HttpServletRequest request) { + try { + boolean exists = client.indices().exists(r -> r.index(name)).value(); + if (!exists) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Index not found: " + name); + } + client.indices().delete(r -> r.index(name)); + auditService.log("delete_index", AuditCategory.INFRA, name, null, AuditResult.SUCCESS, request); + return ResponseEntity.ok().build(); + } catch (ResponseStatusException e) { + throw e; + } catch (Exception e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete index: " + e.getMessage()); + } + } + + @GetMapping("/performance") + @Operation(summary = "Get OpenSearch performance metrics") + public ResponseEntity getPerformance() { + try { + Response response = restClient.performRequest( + new Request("GET", "/_nodes/stats/jvm,indices")); + JsonNode root; + try (InputStream is = response.getEntity().getContent()) { + root = objectMapper.readTree(is); + } + + JsonNode nodes = root.path("nodes"); + long heapUsed = 0, heapMax = 0; + long queryCacheHits = 0, queryCacheMisses = 0; + long requestCacheHits = 0, requestCacheMisses = 0; + long searchQueryTotal = 0, searchQueryTimeMs = 0; + long indexTotal = 0, indexTimeMs = 0; + + var it = nodes.fields(); + while (it.hasNext()) { + var entry = it.next(); + JsonNode node = entry.getValue(); + + JsonNode jvm = node.path("jvm").path("mem"); + heapUsed += jvm.path("heap_used_in_bytes").asLong(0); + heapMax += jvm.path("heap_max_in_bytes").asLong(0); + + JsonNode indicesNode = node.path("indices"); + JsonNode queryCache = indicesNode.path("query_cache"); + queryCacheHits += queryCache.path("hit_count").asLong(0); + queryCacheMisses += queryCache.path("miss_count").asLong(0); + + JsonNode requestCache = indicesNode.path("request_cache"); + requestCacheHits += requestCache.path("hit_count").asLong(0); + requestCacheMisses += requestCache.path("miss_count").asLong(0); + + JsonNode searchNode = indicesNode.path("search"); + searchQueryTotal += searchNode.path("query_total").asLong(0); + searchQueryTimeMs += searchNode.path("query_time_in_millis").asLong(0); + + JsonNode indexing = indicesNode.path("indexing"); + indexTotal += indexing.path("index_total").asLong(0); + indexTimeMs += indexing.path("index_time_in_millis").asLong(0); + } + + double queryCacheHitRate = (queryCacheHits + queryCacheMisses) > 0 + ? (double) queryCacheHits / (queryCacheHits + queryCacheMisses) : 0.0; + double requestCacheHitRate = (requestCacheHits + requestCacheMisses) > 0 + ? (double) requestCacheHits / (requestCacheHits + requestCacheMisses) : 0.0; + double searchLatency = searchQueryTotal > 0 + ? (double) searchQueryTimeMs / searchQueryTotal : 0.0; + double indexingLatency = indexTotal > 0 + ? (double) indexTimeMs / indexTotal : 0.0; + + return ResponseEntity.ok(new PerformanceResponse( + queryCacheHitRate, requestCacheHitRate, + searchLatency, indexingLatency, + heapUsed, heapMax)); + } catch (Exception e) { + return ResponseEntity.ok(new PerformanceResponse(0, 0, 0, 0, 0, 0)); + } + } + + private static long parseLong(String s) { + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + return 0; + } + } + + private static int parseInt(String s) { + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + return 0; + } + } + + private static String humanSize(long bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0); + if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024)); + return String.format("%.1f GB", bytes / (1024.0 * 1024 * 1024)); + } +} diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/OpenSearchAdminControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/OpenSearchAdminControllerIT.java new file mode 100644 index 00000000..0f5284dc --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/OpenSearchAdminControllerIT.java @@ -0,0 +1,112 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.AbstractPostgresIT; +import com.cameleer3.server.app.TestSecurityHelper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +class OpenSearchAdminControllerIT extends AbstractPostgresIT { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TestSecurityHelper securityHelper; + + private String adminJwt; + private String viewerJwt; + + @BeforeEach + void setUp() { + adminJwt = securityHelper.adminToken(); + viewerJwt = securityHelper.viewerToken(); + } + + @Test + void getStatus_asAdmin_returns200() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/opensearch/status", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.get("reachable").asBoolean()).isTrue(); + assertThat(body.has("clusterHealth")).isTrue(); + assertThat(body.has("version")).isTrue(); + } + + @Test + void getStatus_asViewer_returns403() { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/opensearch/status", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void getPipeline_asAdmin_returns200() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/opensearch/pipeline", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.has("queueDepth")).isTrue(); + assertThat(body.has("maxQueueSize")).isTrue(); + assertThat(body.has("indexedCount")).isTrue(); + } + + @Test + void getIndices_asAdmin_returns200() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/opensearch/indices", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.has("indices")).isTrue(); + assertThat(body.has("totalIndices")).isTrue(); + assertThat(body.has("page")).isTrue(); + } + + @Test + void deleteIndex_nonExistent_returns404() { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/opensearch/indices/nonexistent-index-xyz", HttpMethod.DELETE, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void getPerformance_asAdmin_returns200() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/opensearch/performance", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.has("queryCacheHitRate")).isTrue(); + assertThat(body.has("jvmHeapUsedBytes")).isTrue(); + } +} From 321b8808cc7cdc8ee307667896b564fcb7553da4 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:57:23 +0100 Subject: [PATCH 09/18] feat: add ThresholdAdminController and AuditLogController with integration tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/controller/AuditLogController.java | 68 ++++++++++ .../controller/ThresholdAdminController.java | 62 +++++++++ .../app/controller/AuditLogControllerIT.java | 112 ++++++++++++++++ .../ThresholdAdminControllerIT.java | 125 ++++++++++++++++++ 4 files changed, 367 insertions(+) create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AuditLogController.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ThresholdAdminController.java create mode 100644 cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AuditLogControllerIT.java create mode 100644 cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ThresholdAdminControllerIT.java diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AuditLogController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AuditLogController.java new file mode 100644 index 00000000..87663c6a --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AuditLogController.java @@ -0,0 +1,68 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.dto.AuditLogPageResponse; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditRepository; +import com.cameleer3.server.core.admin.AuditRepository.AuditPage; +import com.cameleer3.server.core.admin.AuditRepository.AuditQuery; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +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.time.LocalDate; +import java.time.ZoneOffset; + +@RestController +@RequestMapping("/api/v1/admin/audit") +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "Audit Log", description = "Audit log viewer (ADMIN only)") +public class AuditLogController { + + private final AuditRepository auditRepository; + + public AuditLogController(AuditRepository auditRepository) { + this.auditRepository = auditRepository; + } + + @GetMapping + @Operation(summary = "Search audit log entries with pagination") + public ResponseEntity getAuditLog( + @RequestParam(required = false) String username, + @RequestParam(required = false) String category, + @RequestParam(required = false) String search, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @RequestParam(defaultValue = "timestamp") String sort, + @RequestParam(defaultValue = "desc") String order, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "25") int size) { + + size = Math.min(size, 100); + + Instant fromInstant = from != null ? from.atStartOfDay(ZoneOffset.UTC).toInstant() : null; + Instant toInstant = to != null ? to.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant() : null; + + AuditCategory cat = null; + if (category != null && !category.isEmpty()) { + try { + cat = AuditCategory.valueOf(category.toUpperCase()); + } catch (IllegalArgumentException ignored) { + // invalid category is treated as no filter + } + } + + AuditQuery query = new AuditQuery(username, cat, search, fromInstant, toInstant, sort, order, page, size); + AuditPage result = auditRepository.find(query); + + int totalPages = Math.max(1, (int) Math.ceil((double) result.totalCount() / size)); + return ResponseEntity.ok(new AuditLogPageResponse( + result.items(), result.totalCount(), page, size, totalPages)); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ThresholdAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ThresholdAdminController.java new file mode 100644 index 00000000..56b34cac --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ThresholdAdminController.java @@ -0,0 +1,62 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.dto.ThresholdConfigRequest; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditResult; +import com.cameleer3.server.core.admin.AuditService; +import com.cameleer3.server.core.admin.ThresholdConfig; +import com.cameleer3.server.core.admin.ThresholdRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/admin/thresholds") +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "Threshold Admin", description = "Monitoring threshold configuration (ADMIN only)") +public class ThresholdAdminController { + + private final ThresholdRepository thresholdRepository; + private final AuditService auditService; + + public ThresholdAdminController(ThresholdRepository thresholdRepository, AuditService auditService) { + this.thresholdRepository = thresholdRepository; + this.auditService = auditService; + } + + @GetMapping + @Operation(summary = "Get current threshold configuration") + public ResponseEntity getThresholds() { + ThresholdConfig config = thresholdRepository.find().orElse(ThresholdConfig.defaults()); + return ResponseEntity.ok(config); + } + + @PutMapping + @Operation(summary = "Update threshold configuration") + public ResponseEntity updateThresholds(@Valid @RequestBody ThresholdConfigRequest request, + HttpServletRequest httpRequest) { + List errors = request.validate(); + if (!errors.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors)); + } + + ThresholdConfig config = request.toConfig(); + thresholdRepository.save(config, null); + auditService.log("update_thresholds", AuditCategory.CONFIG, "thresholds", + Map.of("config", config), AuditResult.SUCCESS, httpRequest); + return ResponseEntity.ok(config); + } +} diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AuditLogControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AuditLogControllerIT.java new file mode 100644 index 00000000..138bb192 --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AuditLogControllerIT.java @@ -0,0 +1,112 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.AbstractPostgresIT; +import com.cameleer3.server.app.TestSecurityHelper; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditResult; +import com.cameleer3.server.core.admin.AuditService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class AuditLogControllerIT extends AbstractPostgresIT { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TestSecurityHelper securityHelper; + + @Autowired + private AuditService auditService; + + private String adminJwt; + private String viewerJwt; + + @BeforeEach + void setUp() { + adminJwt = securityHelper.adminToken(); + viewerJwt = securityHelper.viewerToken(); + } + + @Test + void getAuditLog_asAdmin_returns200() throws Exception { + // Insert a test audit entry + auditService.log("test-admin", "test_action", AuditCategory.CONFIG, + "test-target", Map.of("key", "value"), AuditResult.SUCCESS, null); + + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/audit", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.has("items")).isTrue(); + assertThat(body.has("totalCount")).isTrue(); + assertThat(body.get("totalCount").asLong()).isGreaterThanOrEqualTo(1); + } + + @Test + void getAuditLog_asViewer_returns403() { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/audit", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void getAuditLog_withCategoryFilter_returnsFilteredResults() throws Exception { + auditService.log("filter-test", "infra_action", AuditCategory.INFRA, + "infra-target", null, AuditResult.SUCCESS, null); + + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/audit?category=INFRA", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.get("items").isArray()).isTrue(); + } + + @Test + void getAuditLog_withPagination_respectsPageSize() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/audit?page=0&size=5", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.get("pageSize").asInt()).isEqualTo(5); + } + + @Test + void getAuditLog_maxPageSizeEnforced() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/audit?size=500", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.get("pageSize").asInt()).isEqualTo(100); + } +} diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ThresholdAdminControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ThresholdAdminControllerIT.java new file mode 100644 index 00000000..11c4aef7 --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ThresholdAdminControllerIT.java @@ -0,0 +1,125 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.AbstractPostgresIT; +import com.cameleer3.server.app.TestSecurityHelper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +class ThresholdAdminControllerIT extends AbstractPostgresIT { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TestSecurityHelper securityHelper; + + private String adminJwt; + private String viewerJwt; + + @BeforeEach + void setUp() { + adminJwt = securityHelper.adminToken(); + viewerJwt = securityHelper.viewerToken(); + } + + @Test + void getThresholds_asAdmin_returnsDefaults() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/thresholds", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.has("database")).isTrue(); + assertThat(body.has("opensearch")).isTrue(); + assertThat(body.path("database").path("connectionPoolWarning").asInt()).isEqualTo(80); + } + + @Test + void getThresholds_asViewer_returns403() { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/thresholds", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void updateThresholds_asAdmin_returns200() throws Exception { + String json = """ + { + "database": { + "connectionPoolWarning": 70, + "connectionPoolCritical": 90, + "queryDurationWarning": 2.0, + "queryDurationCritical": 15.0 + }, + "opensearch": { + "clusterHealthWarning": "YELLOW", + "clusterHealthCritical": "RED", + "queueDepthWarning": 200, + "queueDepthCritical": 1000, + "jvmHeapWarning": 80, + "jvmHeapCritical": 95, + "failedDocsWarning": 5, + "failedDocsCritical": 20 + } + } + """; + + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/thresholds", HttpMethod.PUT, + new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.path("database").path("connectionPoolWarning").asInt()).isEqualTo(70); + } + + @Test + void updateThresholds_invalidWarningGreaterThanCritical_returns400() { + String json = """ + { + "database": { + "connectionPoolWarning": 95, + "connectionPoolCritical": 80, + "queryDurationWarning": 2.0, + "queryDurationCritical": 15.0 + }, + "opensearch": { + "clusterHealthWarning": "YELLOW", + "clusterHealthCritical": "RED", + "queueDepthWarning": 100, + "queueDepthCritical": 500, + "jvmHeapWarning": 75, + "jvmHeapCritical": 90, + "failedDocsWarning": 1, + "failedDocsCritical": 10 + } + } + """; + + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/thresholds", HttpMethod.PUT, + new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } +} From 4d5a4842b94dded4dbefbf315e3da0d2ec3bdec5 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:09:14 +0100 Subject: [PATCH 10/18] feat: add shared admin UI components (StatusBadge, RefreshableCard, ConfirmDeleteDialog) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../admin/ConfirmDeleteDialog.module.css | 103 ++++++++++++++++++ .../components/admin/ConfirmDeleteDialog.tsx | 70 ++++++++++++ .../admin/RefreshableCard.module.css | 96 ++++++++++++++++ ui/src/components/admin/RefreshableCard.tsx | 70 ++++++++++++ .../components/admin/StatusBadge.module.css | 34 ++++++ ui/src/components/admin/StatusBadge.tsx | 17 +++ 6 files changed, 390 insertions(+) create mode 100644 ui/src/components/admin/ConfirmDeleteDialog.module.css create mode 100644 ui/src/components/admin/ConfirmDeleteDialog.tsx create mode 100644 ui/src/components/admin/RefreshableCard.module.css create mode 100644 ui/src/components/admin/RefreshableCard.tsx create mode 100644 ui/src/components/admin/StatusBadge.module.css create mode 100644 ui/src/components/admin/StatusBadge.tsx diff --git a/ui/src/components/admin/ConfirmDeleteDialog.module.css b/ui/src/components/admin/ConfirmDeleteDialog.module.css new file mode 100644 index 00000000..481aea74 --- /dev/null +++ b/ui/src/components/admin/ConfirmDeleteDialog.module.css @@ -0,0 +1,103 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.dialog { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 24px; + width: 420px; + max-width: 90vw; +} + +.title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 12px; +} + +.message { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 16px; + line-height: 1.5; +} + +.label { + display: block; + font-size: 12px; + color: var(--text-muted); + margin-bottom: 6px; +} + +.input { + width: 100%; + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 14px; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.input:focus { + border-color: var(--amber-dim); + box-shadow: 0 0 0 3px var(--amber-glow); +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +.btnCancel { + padding: 8px 20px; + border-radius: var(--radius-sm); + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + font-family: var(--font-body); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; +} + +.btnCancel:hover { + border-color: var(--amber-dim); + color: var(--text-primary); +} + +.btnDelete { + padding: 8px 20px; + border-radius: var(--radius-sm); + background: transparent; + border: 1px solid var(--rose-dim); + color: var(--rose); + font-family: var(--font-body); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.btnDelete:hover:not(:disabled) { + background: var(--rose-glow); +} + +.btnDelete:disabled { + opacity: 0.4; + cursor: not-allowed; +} diff --git a/ui/src/components/admin/ConfirmDeleteDialog.tsx b/ui/src/components/admin/ConfirmDeleteDialog.tsx new file mode 100644 index 00000000..f81d2d92 --- /dev/null +++ b/ui/src/components/admin/ConfirmDeleteDialog.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import styles from './ConfirmDeleteDialog.module.css'; + +interface ConfirmDeleteDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + resourceName: string; + resourceType: string; +} + +export function ConfirmDeleteDialog({ + isOpen, + onClose, + onConfirm, + resourceName, + resourceType, +}: ConfirmDeleteDialogProps) { + const [confirmText, setConfirmText] = useState(''); + + if (!isOpen) return null; + + const canDelete = confirmText === resourceName; + + function handleClose() { + setConfirmText(''); + onClose(); + } + + function handleConfirm() { + if (!canDelete) return; + setConfirmText(''); + onConfirm(); + } + + return ( +
+
e.stopPropagation()}> +

Confirm Deletion

+

+ Delete {resourceType} ‘{resourceName}’? This cannot be undone. +

+ + setConfirmText(e.target.value)} + placeholder={resourceName} + autoFocus + /> +
+ + +
+
+
+ ); +} diff --git a/ui/src/components/admin/RefreshableCard.module.css b/ui/src/components/admin/RefreshableCard.module.css new file mode 100644 index 00000000..15bf02a9 --- /dev/null +++ b/ui/src/components/admin/RefreshableCard.module.css @@ -0,0 +1,96 @@ +.card { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + margin-bottom: 16px; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-subtle); +} + +.headerClickable { + cursor: pointer; + user-select: none; +} + +.headerClickable:hover { + background: var(--bg-hover); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; +} + +.titleRow { + display: flex; + align-items: center; + gap: 8px; +} + +.chevron { + font-size: 10px; + color: var(--text-muted); + transition: transform 0.2s; +} + +.chevronOpen { + transform: rotate(90deg); +} + +.title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.autoIndicator { + font-size: 10px; + color: var(--text-muted); + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: 99px; + padding: 1px 6px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.refreshBtn { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 16px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.15s; +} + +.refreshBtn:hover { + border-color: var(--amber-dim); + color: var(--text-primary); +} + +.refreshBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.refreshing { + animation: spin 1s linear infinite; +} + +.body { + padding: 20px; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/ui/src/components/admin/RefreshableCard.tsx b/ui/src/components/admin/RefreshableCard.tsx new file mode 100644 index 00000000..10e9ba80 --- /dev/null +++ b/ui/src/components/admin/RefreshableCard.tsx @@ -0,0 +1,70 @@ +import { type ReactNode, useState } from 'react'; +import styles from './RefreshableCard.module.css'; + +interface RefreshableCardProps { + title: string; + onRefresh?: () => void; + isRefreshing?: boolean; + autoRefresh?: boolean; + collapsible?: boolean; + defaultCollapsed?: boolean; + children: ReactNode; +} + +export function RefreshableCard({ + title, + onRefresh, + isRefreshing, + autoRefresh, + collapsible, + defaultCollapsed, + children, +}: RefreshableCardProps) { + const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false); + + const headerProps = collapsible + ? { + onClick: () => setCollapsed((c) => !c), + className: `${styles.header} ${styles.headerClickable}`, + role: 'button' as const, + tabIndex: 0, + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setCollapsed((c) => !c); + } + }, + } + : { className: styles.header }; + + return ( +
+
+
+ {collapsible && ( + + ▶ + + )} +

{title}

+ {autoRefresh && auto} +
+ {onRefresh && ( + + )} +
+ {!collapsed &&
{children}
} +
+ ); +} diff --git a/ui/src/components/admin/StatusBadge.module.css b/ui/src/components/admin/StatusBadge.module.css new file mode 100644 index 00000000..e2a4c9a6 --- /dev/null +++ b/ui/src/components/admin/StatusBadge.module.css @@ -0,0 +1,34 @@ +.badge { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.healthy { + background: #22c55e; +} + +.warning { + background: #eab308; +} + +.critical { + background: #ef4444; +} + +.unknown { + background: #6b7280; +} + +.label { + font-size: 13px; + color: var(--text-secondary); + font-weight: 500; +} diff --git a/ui/src/components/admin/StatusBadge.tsx b/ui/src/components/admin/StatusBadge.tsx new file mode 100644 index 00000000..9d92f1ad --- /dev/null +++ b/ui/src/components/admin/StatusBadge.tsx @@ -0,0 +1,17 @@ +import styles from './StatusBadge.module.css'; + +export type Status = 'healthy' | 'warning' | 'critical' | 'unknown'; + +interface StatusBadgeProps { + status: Status; + label?: string; +} + +export function StatusBadge({ status, label }: StatusBadgeProps) { + return ( + + + {label && {label}} + + ); +} From 9fbda7715cc7e592da6395fdbe6e8d55885bfe45 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:09:23 +0100 Subject: [PATCH 11/18] feat: restructure admin sidebar with collapsible sub-navigation and new routes Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/layout/AppSidebar.module.css | 42 ++++++++++ ui/src/components/layout/AppSidebar.tsx | 77 ++++++++++++++++--- ui/src/router.tsx | 7 ++ 3 files changed, 115 insertions(+), 11 deletions(-) diff --git a/ui/src/components/layout/AppSidebar.module.css b/ui/src/components/layout/AppSidebar.module.css index eba3e514..0261d18c 100644 --- a/ui/src/components/layout/AppSidebar.module.css +++ b/ui/src/components/layout/AppSidebar.module.css @@ -209,6 +209,44 @@ text-align: center; } +/* ─── Admin Sub-Menu ─── */ +.adminChevron { + margin-left: 6px; + font-size: 8px; + color: var(--text-muted); +} + +.adminSubMenu { + display: flex; + flex-direction: column; +} + +.adminSubItem { + display: block; + padding: 6px 16px 6px 42px; + font-size: 12px; + color: var(--text-muted); + text-decoration: none; + transition: all 0.1s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.adminSubItem:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.adminSubItemActive { + color: var(--amber); + background: var(--amber-glow); +} + +.sidebarCollapsed .adminSubMenu { + display: none; +} + /* ─── Responsive ─── */ @media (max-width: 1024px) { .sidebar { @@ -242,4 +280,8 @@ .sidebar .bottomLabel { display: none; } + + .sidebar .adminSubMenu { + display: none; + } } diff --git a/ui/src/components/layout/AppSidebar.tsx b/ui/src/components/layout/AppSidebar.tsx index 1ebfa9f8..6426004a 100644 --- a/ui/src/components/layout/AppSidebar.tsx +++ b/ui/src/components/layout/AppSidebar.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react'; -import { NavLink, useParams } from 'react-router'; +import { NavLink, useParams, useLocation } from 'react-router'; import { useAgents } from '../../api/queries/agents'; import { useAuthStore } from '../../auth/auth-store'; import type { AgentInstance } from '../../api/types'; @@ -112,18 +112,73 @@ export function AppSidebar({ collapsed }: AppSidebarProps) { {/* Bottom: Admin */} {roles.includes('ADMIN') && (
- - `${styles.bottomItem} ${isActive ? styles.bottomItemActive : ''}` - } - title="Admin" - > - - Admin - +
)} ); } + +const ADMIN_LINKS = [ + { to: '/admin/database', label: 'Database' }, + { to: '/admin/opensearch', label: 'OpenSearch' }, + { to: '/admin/audit', label: 'Audit Log' }, + { to: '/admin/oidc', label: 'OIDC' }, +]; + +function AdminSubMenu({ collapsed: sidebarCollapsed }: { collapsed: boolean }) { + const location = useLocation(); + const isAdminActive = location.pathname.startsWith('/admin'); + + const [open, setOpen] = useState(() => { + try { + return localStorage.getItem('cameleer-admin-sidebar-open') === 'true'; + } catch { + return false; + } + }); + + function toggle() { + const next = !open; + setOpen(next); + try { + localStorage.setItem('cameleer-admin-sidebar-open', String(next)); + } catch { /* ignore */ } + } + + return ( + <> + + {open && !sidebarCollapsed && ( +
+ {ADMIN_LINKS.map((link) => ( + + `${styles.adminSubItem} ${isActive ? styles.adminSubItemActive : ''}` + } + > + {link.label} + + ))} +
+ )} + + ); +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 406bbbcb..e808603f 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -10,6 +10,9 @@ import { RoutePage } from './pages/routes/RoutePage'; import { AppScopedView } from './pages/dashboard/AppScopedView'; const SwaggerPage = lazy(() => import('./pages/swagger/SwaggerPage').then(m => ({ default: m.SwaggerPage }))); +const DatabaseAdminPage = lazy(() => import('./pages/admin/DatabaseAdminPage').then(m => ({ default: m.DatabaseAdminPage }))); +const OpenSearchAdminPage = lazy(() => import('./pages/admin/OpenSearchAdminPage').then(m => ({ default: m.OpenSearchAdminPage }))); +const AuditLogPage = lazy(() => import('./pages/admin/AuditLogPage').then(m => ({ default: m.AuditLogPage }))); export const router = createBrowserRouter([ { @@ -30,6 +33,10 @@ export const router = createBrowserRouter([ { path: 'executions', element: }, { path: 'apps/:group', element: }, { path: 'apps/:group/routes/:routeId', element: }, + { path: 'admin', element: }, + { path: 'admin/database', element: }, + { path: 'admin/opensearch', element: }, + { path: 'admin/audit', element: }, { path: 'admin/oidc', element: }, { path: 'swagger', element: }, ], From b61c32729bb2c845f2a9041bf9154eb68a303929 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:09:31 +0100 Subject: [PATCH 12/18] feat: add React Query hooks for admin infrastructure endpoints Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/api/queries/admin/admin-api.ts | 22 ++++++ ui/src/api/queries/admin/audit.ts | 45 +++++++++++ ui/src/api/queries/admin/database.ts | 71 +++++++++++++++++ ui/src/api/queries/admin/opensearch.ts | 102 +++++++++++++++++++++++++ ui/src/api/queries/admin/thresholds.ts | 33 ++++++++ 5 files changed, 273 insertions(+) create mode 100644 ui/src/api/queries/admin/admin-api.ts create mode 100644 ui/src/api/queries/admin/audit.ts create mode 100644 ui/src/api/queries/admin/database.ts create mode 100644 ui/src/api/queries/admin/opensearch.ts create mode 100644 ui/src/api/queries/admin/thresholds.ts diff --git a/ui/src/api/queries/admin/admin-api.ts b/ui/src/api/queries/admin/admin-api.ts new file mode 100644 index 00000000..20c71eb7 --- /dev/null +++ b/ui/src/api/queries/admin/admin-api.ts @@ -0,0 +1,22 @@ +import { config } from '../../../config'; +import { useAuthStore } from '../../../auth/auth-store'; + +export async function adminFetch(path: string, options?: RequestInit): Promise { + const token = useAuthStore.getState().accessToken; + const res = await fetch(`${config.apiBaseUrl}/admin${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + 'X-Cameleer-Protocol-Version': '1', + ...options?.headers, + }, + }); + if (res.status === 401 || res.status === 403) { + useAuthStore.getState().logout(); + throw new Error('Unauthorized'); + } + if (!res.ok) throw new Error(`API error: ${res.status}`); + if (res.status === 204) return undefined as T; + return res.json(); +} diff --git a/ui/src/api/queries/admin/audit.ts b/ui/src/api/queries/admin/audit.ts new file mode 100644 index 00000000..2ea3399a --- /dev/null +++ b/ui/src/api/queries/admin/audit.ts @@ -0,0 +1,45 @@ +import { useQuery } from '@tanstack/react-query'; +import { adminFetch } from './admin-api'; + +export interface AuditEvent { + id: string; + timestamp: string; + username: string; + category: string; + action: string; + target: string; + result: string; + detail: Record; +} + +export interface AuditLogParams { + from?: string; + to?: string; + username?: string; + category?: string; + search?: string; + page?: number; + size?: number; +} + +export interface AuditLogResponse { + events: AuditEvent[]; + total: number; +} + +export function useAuditLog(params: AuditLogParams) { + const query = new URLSearchParams(); + if (params.from) query.set('from', params.from); + if (params.to) query.set('to', params.to); + if (params.username) query.set('username', params.username); + if (params.category) query.set('category', params.category); + if (params.search) query.set('search', params.search); + if (params.page !== undefined) query.set('page', String(params.page)); + if (params.size !== undefined) query.set('size', String(params.size)); + const qs = query.toString(); + + return useQuery({ + queryKey: ['admin', 'audit', params], + queryFn: () => adminFetch(`/audit${qs ? `?${qs}` : ''}`), + }); +} diff --git a/ui/src/api/queries/admin/database.ts b/ui/src/api/queries/admin/database.ts new file mode 100644 index 00000000..b83eb7bf --- /dev/null +++ b/ui/src/api/queries/admin/database.ts @@ -0,0 +1,71 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { adminFetch } from './admin-api'; + +export interface DatabaseStatus { + connected: boolean; + version: string; + host: string; + schema: string; +} + +export interface PoolStats { + activeConnections: number; + idleConnections: number; + pendingConnections: number; + maxConnections: number; + maxWaitMillis: number; +} + +export interface TableInfo { + tableName: string; + rowEstimate: number; + dataSize: string; + indexSize: string; +} + +export interface ActiveQuery { + pid: number; + durationMs: number; + state: string; + query: string; +} + +export function useDatabaseStatus() { + return useQuery({ + queryKey: ['admin', 'database', 'status'], + queryFn: () => adminFetch('/database/status'), + }); +} + +export function useDatabasePool() { + return useQuery({ + queryKey: ['admin', 'database', 'pool'], + queryFn: () => adminFetch('/database/pool'), + refetchInterval: 15000, + }); +} + +export function useDatabaseTables() { + return useQuery({ + queryKey: ['admin', 'database', 'tables'], + queryFn: () => adminFetch('/database/tables'), + }); +} + +export function useDatabaseQueries() { + return useQuery({ + queryKey: ['admin', 'database', 'queries'], + queryFn: () => adminFetch('/database/queries'), + refetchInterval: 15000, + }); +} + +export function useKillQuery() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (pid: number) => { + await adminFetch(`/database/queries/${pid}`, { method: 'DELETE' }); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'database', 'queries'] }), + }); +} diff --git a/ui/src/api/queries/admin/opensearch.ts b/ui/src/api/queries/admin/opensearch.ts new file mode 100644 index 00000000..15b5133b --- /dev/null +++ b/ui/src/api/queries/admin/opensearch.ts @@ -0,0 +1,102 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { adminFetch } from './admin-api'; + +export interface OpenSearchStatus { + connected: boolean; + clusterName: string; + clusterHealth: string; + version: string; + numberOfNodes: number; + host: string; +} + +export interface PipelineStats { + queueDepth: number; + maxQueueSize: number; + totalIndexed: number; + totalFailed: number; + avgLatencyMs: number; +} + +export interface IndexInfo { + name: string; + health: string; + status: string; + docsCount: number; + storeSize: string; + primaryShards: number; + replicas: number; +} + +export interface PerformanceStats { + queryCacheHitRate: number; + requestCacheHitRate: number; + avgQueryLatencyMs: number; + avgIndexLatencyMs: number; + jvmHeapUsedPercent: number; + jvmHeapUsedBytes: number; + jvmHeapMaxBytes: number; +} + +export interface IndicesParams { + search?: string; + health?: string; + sortBy?: string; + sortDir?: 'asc' | 'desc'; + page?: number; + size?: number; +} + +export function useOpenSearchStatus() { + return useQuery({ + queryKey: ['admin', 'opensearch', 'status'], + queryFn: () => adminFetch('/opensearch/status'), + }); +} + +export function usePipelineStats() { + return useQuery({ + queryKey: ['admin', 'opensearch', 'pipeline'], + queryFn: () => adminFetch('/opensearch/pipeline'), + refetchInterval: 15000, + }); +} + +export function useIndices(params: IndicesParams) { + const query = new URLSearchParams(); + if (params.search) query.set('search', params.search); + if (params.health) query.set('health', params.health); + if (params.sortBy) query.set('sortBy', params.sortBy); + if (params.sortDir) query.set('sortDir', params.sortDir); + if (params.page !== undefined) query.set('page', String(params.page)); + if (params.size !== undefined) query.set('size', String(params.size)); + const qs = query.toString(); + + return useQuery({ + queryKey: ['admin', 'opensearch', 'indices', params], + queryFn: () => + adminFetch<{ indices: IndexInfo[]; total: number }>( + `/opensearch/indices${qs ? `?${qs}` : ''}`, + ), + }); +} + +export function usePerformanceStats() { + return useQuery({ + queryKey: ['admin', 'opensearch', 'performance'], + queryFn: () => adminFetch('/opensearch/performance'), + refetchInterval: 15000, + }); +} + +export function useDeleteIndex() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (indexName: string) => { + await adminFetch(`/opensearch/indices/${encodeURIComponent(indexName)}`, { + method: 'DELETE', + }); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'opensearch', 'indices'] }), + }); +} diff --git a/ui/src/api/queries/admin/thresholds.ts b/ui/src/api/queries/admin/thresholds.ts new file mode 100644 index 00000000..ffcc09eb --- /dev/null +++ b/ui/src/api/queries/admin/thresholds.ts @@ -0,0 +1,33 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { adminFetch } from './admin-api'; + +export interface Thresholds { + poolWarningPercent: number; + poolCriticalPercent: number; + queryDurationWarningSeconds: number; + queryDurationCriticalSeconds: number; + osQueueWarningPercent: number; + osQueueCriticalPercent: number; + osHeapWarningPercent: number; + osHeapCriticalPercent: number; +} + +export function useThresholds() { + return useQuery({ + queryKey: ['admin', 'thresholds'], + queryFn: () => adminFetch('/thresholds'), + }); +} + +export function useSaveThresholds() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: Thresholds) => { + await adminFetch('/thresholds', { + method: 'PUT', + body: JSON.stringify(body), + }); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'thresholds'] }), + }); +} From 0edbdea2ebd78b7e182d6b6c81c56a0447043e84 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:10:56 +0100 Subject: [PATCH 13/18] feat: add Database admin page with pool, tables, queries, and thresholds UI Co-Authored-By: Claude Opus 4.6 (1M context) --- .../pages/admin/DatabaseAdminPage.module.css | 317 +++++++++++++++ ui/src/pages/admin/DatabaseAdminPage.tsx | 379 ++++++++++++++++++ 2 files changed, 696 insertions(+) create mode 100644 ui/src/pages/admin/DatabaseAdminPage.module.css create mode 100644 ui/src/pages/admin/DatabaseAdminPage.tsx diff --git a/ui/src/pages/admin/DatabaseAdminPage.module.css b/ui/src/pages/admin/DatabaseAdminPage.module.css new file mode 100644 index 00000000..944e0246 --- /dev/null +++ b/ui/src/pages/admin/DatabaseAdminPage.module.css @@ -0,0 +1,317 @@ +.page { + max-width: 960px; + margin: 0 auto; + padding: 32px 16px; +} + +.pageTitle { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 24px; +} + +.headerInfo { + display: flex; + flex-direction: column; + gap: 8px; +} + +.headerMeta { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.metaItem { + font-size: 12px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.globalRefresh { + padding: 8px 16px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-raised); + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.globalRefresh:hover { + border-color: var(--amber-dim); + color: var(--text-primary); +} + +.loading { + text-align: center; + padding: 32px; + color: var(--text-muted); + font-size: 14px; +} + +.accessDenied { + text-align: center; + padding: 64px 16px; + color: var(--text-muted); + font-size: 14px; +} + +/* ─── Progress Bar ─── */ +.progressContainer { + margin-bottom: 16px; +} + +.progressLabel { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.progressPct { + font-weight: 600; + font-family: var(--font-mono); +} + +.progressBar { + height: 8px; + background: var(--bg-raised); + border-radius: 4px; + overflow: hidden; +} + +.progressFill { + height: 100%; + border-radius: 4px; + transition: width 0.3s ease; +} + +/* ─── Metrics Grid ─── */ +.metricsGrid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} + +.metric { + display: flex; + flex-direction: column; + align-items: center; + padding: 12px; + background: var(--bg-raised); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); +} + +.metricValue { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + font-family: var(--font-mono); +} + +.metricLabel { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 4px; +} + +/* ─── Tables ─── */ +.tableWrapper { + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.table th { + text-align: left; + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + border-bottom: 1px solid var(--border-subtle); + white-space: nowrap; +} + +.table td { + padding: 8px 12px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-subtle); +} + +.table tbody tr:hover { + background: var(--bg-hover); +} + +.mono { + font-family: var(--font-mono); + font-size: 12px; +} + +.queryCell { + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + font-size: 11px; +} + +.rowWarning { + background: rgba(234, 179, 8, 0.06); +} + +.killBtn { + padding: 4px 10px; + border-radius: var(--radius-sm); + background: transparent; + border: 1px solid var(--rose-dim); + color: var(--rose); + font-size: 11px; + cursor: pointer; + transition: all 0.15s; +} + +.killBtn:hover { + background: var(--rose-glow); +} + +.emptyState { + text-align: center; + padding: 24px; + color: var(--text-muted); + font-size: 13px; +} + +/* ─── Maintenance ─── */ +.maintenanceGrid { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.maintenanceBtn { + padding: 8px 16px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-raised); + color: var(--text-muted); + font-size: 13px; + cursor: not-allowed; + opacity: 0.5; +} + +/* ─── Thresholds ─── */ +.thresholdGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 16px; +} + +.thresholdField { + display: flex; + flex-direction: column; + gap: 4px; +} + +.thresholdLabel { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} + +.thresholdInput { + width: 100%; + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 12px; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + outline: none; + transition: border-color 0.2s; +} + +.thresholdInput:focus { + border-color: var(--amber-dim); + box-shadow: 0 0 0 3px var(--amber-glow); +} + +.thresholdActions { + display: flex; + align-items: center; + gap: 12px; +} + +.btnPrimary { + padding: 8px 20px; + border-radius: var(--radius-sm); + border: 1px solid var(--amber); + background: var(--amber); + color: #0a0e17; + font-family: var(--font-body); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; +} + +.btnPrimary:hover { + background: var(--amber-hover); + border-color: var(--amber-hover); +} + +.btnPrimary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.successMsg { + font-size: 12px; + color: var(--green); +} + +.errorMsg { + font-size: 12px; + color: var(--rose); +} + +@media (max-width: 640px) { + .metricsGrid { + grid-template-columns: repeat(2, 1fr); + } + + .thresholdGrid { + grid-template-columns: 1fr; + } + + .header { + flex-direction: column; + gap: 12px; + } +} diff --git a/ui/src/pages/admin/DatabaseAdminPage.tsx b/ui/src/pages/admin/DatabaseAdminPage.tsx new file mode 100644 index 00000000..3edbff07 --- /dev/null +++ b/ui/src/pages/admin/DatabaseAdminPage.tsx @@ -0,0 +1,379 @@ +import { useState } from 'react'; +import { useAuthStore } from '../../auth/auth-store'; +import { StatusBadge } from '../../components/admin/StatusBadge'; +import { RefreshableCard } from '../../components/admin/RefreshableCard'; +import { ConfirmDeleteDialog } from '../../components/admin/ConfirmDeleteDialog'; +import { + useDatabaseStatus, + useDatabasePool, + useDatabaseTables, + useDatabaseQueries, + useKillQuery, +} from '../../api/queries/admin/database'; +import { useThresholds, useSaveThresholds, type Thresholds } from '../../api/queries/admin/thresholds'; +import styles from './DatabaseAdminPage.module.css'; + +export function DatabaseAdminPage() { + const roles = useAuthStore((s) => s.roles); + + if (!roles.includes('ADMIN')) { + return ( +
+
+ Access Denied — this page requires the ADMIN role. +
+
+ ); + } + + return ; +} + +function DatabaseAdminContent() { + const status = useDatabaseStatus(); + const pool = useDatabasePool(); + const tables = useDatabaseTables(); + const queries = useDatabaseQueries(); + const thresholds = useThresholds(); + + if (status.isLoading) { + return ( +
+

Database Administration

+
Loading...
+
+ ); + } + + const db = status.data; + + return ( +
+
+
+

Database Administration

+
+ + {db?.version && {db.version}} + {db?.host && {db.host}} + {db?.schema && Schema: {db.schema}} +
+
+ +
+ + + + + + +
+ ); +} + +function PoolSection({ + pool, + warningPct, + criticalPct, +}: { + pool: ReturnType; + warningPct?: number; + criticalPct?: number; +}) { + const data = pool.data; + if (!data) return null; + + const usagePct = data.maxConnections > 0 + ? Math.round((data.activeConnections / data.maxConnections) * 100) + : 0; + const barColor = + criticalPct && usagePct >= criticalPct ? '#ef4444' + : warningPct && usagePct >= warningPct ? '#eab308' + : '#22c55e'; + + return ( + pool.refetch()} + isRefreshing={pool.isFetching} + autoRefresh + > +
+
+ {data.activeConnections} / {data.maxConnections} connections + {usagePct}% +
+
+
+
+
+
+
+ {data.activeConnections} + Active +
+
+ {data.idleConnections} + Idle +
+
+ {data.pendingConnections} + Pending +
+
+ {data.maxWaitMillis}ms + Max Wait +
+
+ + ); +} + +function TablesSection({ tables }: { tables: ReturnType }) { + const data = tables.data; + + return ( + tables.refetch()} + isRefreshing={tables.isFetching} + > + {!data ? ( +
Loading...
+ ) : ( +
+ + + + + + + + + + + {data.map((t) => ( + + + + + + + ))} + +
TableRowsData SizeIndex Size
{t.tableName}{t.rowEstimate.toLocaleString()}{t.dataSize}{t.indexSize}
+
+ )} +
+ ); +} + +function QueriesSection({ + queries, + warningSeconds, +}: { + queries: ReturnType; + warningSeconds?: number; +}) { + const [killTarget, setKillTarget] = useState(null); + const killMutation = useKillQuery(); + const data = queries.data; + + const warningMs = (warningSeconds ?? 30) * 1000; + + return ( + queries.refetch()} + isRefreshing={queries.isFetching} + autoRefresh + > + {!data || data.length === 0 ? ( +
No active queries
+ ) : ( +
+ + + + + + + + + + + + {data.map((q) => ( + warningMs ? styles.rowWarning : undefined} + > + + + + + + + ))} + +
PIDDurationStateQuery
{q.pid}{formatDuration(q.durationMs)}{q.state} + {q.query.length > 100 ? `${q.query.slice(0, 100)}...` : q.query} + + +
+
+ )} + setKillTarget(null)} + onConfirm={() => { + if (killTarget !== null) { + killMutation.mutate(killTarget); + setKillTarget(null); + } + }} + resourceName={String(killTarget ?? '')} + resourceType="query (PID)" + /> +
+ ); +} + +function MaintenanceSection() { + return ( + +
+ + + +
+
+ ); +} + +function ThresholdsSection({ thresholds }: { thresholds?: Thresholds }) { + const [form, setForm] = useState(null); + const saveMutation = useSaveThresholds(); + const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); + + const current = form ?? thresholds; + if (!current) return null; + + function update(key: keyof Thresholds, value: number) { + setForm((prev) => ({ ...(prev ?? thresholds!), [key]: value })); + } + + async function handleSave() { + if (!form && !thresholds) return; + const data = form ?? thresholds!; + try { + await saveMutation.mutateAsync(data); + setStatus({ type: 'success', msg: 'Thresholds saved.' }); + setTimeout(() => setStatus(null), 3000); + } catch { + setStatus({ type: 'error', msg: 'Failed to save thresholds.' }); + } + } + + return ( + +
+
+ + update('poolWarningPercent', Number(e.target.value))} + /> +
+
+ + update('poolCriticalPercent', Number(e.target.value))} + /> +
+
+ + update('queryDurationWarningSeconds', Number(e.target.value))} + /> +
+
+ + update('queryDurationCriticalSeconds', Number(e.target.value))} + /> +
+
+
+ + {status && ( + + {status.msg} + + )} +
+
+ ); +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + return `${m}m ${s % 60}s`; +} From 6b9988f43a5db2ace87264d7780fe73dbbf39d5d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:11:01 +0100 Subject: [PATCH 14/18] feat: add OpenSearch admin page with pipeline, indices, performance, and thresholds UI Co-Authored-By: Claude Opus 4.6 (1M context) --- .../admin/OpenSearchAdminPage.module.css | 425 +++++++++++++++ ui/src/pages/admin/OpenSearchAdminPage.tsx | 490 ++++++++++++++++++ 2 files changed, 915 insertions(+) create mode 100644 ui/src/pages/admin/OpenSearchAdminPage.module.css create mode 100644 ui/src/pages/admin/OpenSearchAdminPage.tsx diff --git a/ui/src/pages/admin/OpenSearchAdminPage.module.css b/ui/src/pages/admin/OpenSearchAdminPage.module.css new file mode 100644 index 00000000..cca61734 --- /dev/null +++ b/ui/src/pages/admin/OpenSearchAdminPage.module.css @@ -0,0 +1,425 @@ +.page { + max-width: 960px; + margin: 0 auto; + padding: 32px 16px; +} + +.pageTitle { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 24px; +} + +.headerInfo { + display: flex; + flex-direction: column; + gap: 8px; +} + +.headerMeta { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.metaItem { + font-size: 12px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.globalRefresh { + padding: 8px 16px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-raised); + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.globalRefresh:hover { + border-color: var(--amber-dim); + color: var(--text-primary); +} + +.loading { + text-align: center; + padding: 32px; + color: var(--text-muted); + font-size: 14px; +} + +.accessDenied { + text-align: center; + padding: 64px 16px; + color: var(--text-muted); + font-size: 14px; +} + +/* ─── Progress Bar ─── */ +.progressContainer { + margin-bottom: 16px; +} + +.progressLabel { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.progressPct { + font-weight: 600; + font-family: var(--font-mono); +} + +.progressBar { + height: 8px; + background: var(--bg-raised); + border-radius: 4px; + overflow: hidden; +} + +.progressFill { + height: 100%; + border-radius: 4px; + transition: width 0.3s ease; +} + +/* ─── Metrics Grid ─── */ +.metricsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} + +.metric { + display: flex; + flex-direction: column; + align-items: center; + padding: 12px; + background: var(--bg-raised); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); +} + +.metricValue { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + font-family: var(--font-mono); +} + +.metricLabel { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 4px; +} + +/* ─── Filter Row ─── */ +.filterRow { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.filterInput { + flex: 1; + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 12px; + color: var(--text-primary); + font-size: 13px; + outline: none; + transition: border-color 0.2s; +} + +.filterInput:focus { + border-color: var(--amber-dim); +} + +.filterInput::placeholder { + color: var(--text-muted); +} + +.filterSelect { + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 12px; + color: var(--text-primary); + font-size: 13px; + outline: none; + cursor: pointer; +} + +/* ─── Tables ─── */ +.tableWrapper { + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.table th { + text-align: left; + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + border-bottom: 1px solid var(--border-subtle); + white-space: nowrap; +} + +.sortableHeader { + cursor: pointer; + user-select: none; +} + +.sortableHeader:hover { + color: var(--text-primary); +} + +.sortArrow { + font-size: 9px; +} + +.table td { + padding: 8px 12px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-subtle); +} + +.table tbody tr:hover { + background: var(--bg-hover); +} + +.mono { + font-family: var(--font-mono); + font-size: 12px; +} + +.healthBadge { + display: inline-block; + padding: 2px 8px; + border-radius: 99px; + font-size: 11px; + font-weight: 500; + text-transform: capitalize; +} + +.healthGreen { + background: rgba(34, 197, 94, 0.1); + color: #22c55e; +} + +.healthYellow { + background: rgba(234, 179, 8, 0.1); + color: #eab308; +} + +.healthRed { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; +} + +.deleteBtn { + padding: 4px 10px; + border-radius: var(--radius-sm); + background: transparent; + border: 1px solid var(--rose-dim); + color: var(--rose); + font-size: 11px; + cursor: pointer; + transition: all 0.15s; +} + +.deleteBtn:hover { + background: var(--rose-glow); +} + +.emptyState { + text-align: center; + padding: 24px; + color: var(--text-muted); + font-size: 13px; +} + +/* ─── Pagination ─── */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--border-subtle); +} + +.pageBtn { + padding: 6px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-raised); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; +} + +.pageBtn:hover:not(:disabled) { + border-color: var(--amber-dim); + color: var(--text-primary); +} + +.pageBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.pageInfo { + font-size: 12px; + color: var(--text-muted); +} + +/* ─── Heap Section ─── */ +.heapSection { + margin-top: 16px; +} + +/* ─── Operations ─── */ +.operationsGrid { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.operationBtn { + padding: 8px 16px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-raised); + color: var(--text-muted); + font-size: 13px; + cursor: not-allowed; + opacity: 0.5; +} + +/* ─── Thresholds ─── */ +.thresholdGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 16px; +} + +.thresholdField { + display: flex; + flex-direction: column; + gap: 4px; +} + +.thresholdLabel { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} + +.thresholdInput { + width: 100%; + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 12px; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + outline: none; + transition: border-color 0.2s; +} + +.thresholdInput:focus { + border-color: var(--amber-dim); + box-shadow: 0 0 0 3px var(--amber-glow); +} + +.thresholdActions { + display: flex; + align-items: center; + gap: 12px; +} + +.btnPrimary { + padding: 8px 20px; + border-radius: var(--radius-sm); + border: 1px solid var(--amber); + background: var(--amber); + color: #0a0e17; + font-family: var(--font-body); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; +} + +.btnPrimary:hover { + background: var(--amber-hover); + border-color: var(--amber-hover); +} + +.btnPrimary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.successMsg { + font-size: 12px; + color: var(--green); +} + +.errorMsg { + font-size: 12px; + color: var(--rose); +} + +@media (max-width: 640px) { + .metricsGrid { + grid-template-columns: repeat(2, 1fr); + } + + .thresholdGrid { + grid-template-columns: 1fr; + } + + .header { + flex-direction: column; + gap: 12px; + } + + .filterRow { + flex-direction: column; + } +} diff --git a/ui/src/pages/admin/OpenSearchAdminPage.tsx b/ui/src/pages/admin/OpenSearchAdminPage.tsx new file mode 100644 index 00000000..45653e05 --- /dev/null +++ b/ui/src/pages/admin/OpenSearchAdminPage.tsx @@ -0,0 +1,490 @@ +import { useState } from 'react'; +import { useAuthStore } from '../../auth/auth-store'; +import { StatusBadge, type Status } from '../../components/admin/StatusBadge'; +import { RefreshableCard } from '../../components/admin/RefreshableCard'; +import { ConfirmDeleteDialog } from '../../components/admin/ConfirmDeleteDialog'; +import { + useOpenSearchStatus, + usePipelineStats, + useIndices, + usePerformanceStats, + useDeleteIndex, + type IndicesParams, +} from '../../api/queries/admin/opensearch'; +import { useThresholds, useSaveThresholds, type Thresholds } from '../../api/queries/admin/thresholds'; +import styles from './OpenSearchAdminPage.module.css'; + +function clusterHealthToStatus(health: string | undefined): Status { + switch (health?.toLowerCase()) { + case 'green': return 'healthy'; + case 'yellow': return 'warning'; + case 'red': return 'critical'; + default: return 'unknown'; + } +} + +export function OpenSearchAdminPage() { + const roles = useAuthStore((s) => s.roles); + + if (!roles.includes('ADMIN')) { + return ( +
+
+ Access Denied — this page requires the ADMIN role. +
+
+ ); + } + + return ; +} + +function OpenSearchAdminContent() { + const status = useOpenSearchStatus(); + const pipeline = usePipelineStats(); + const performance = usePerformanceStats(); + const thresholds = useThresholds(); + + if (status.isLoading) { + return ( +
+

OpenSearch Administration

+
Loading...
+
+ ); + } + + const os = status.data; + + return ( +
+
+
+

OpenSearch Administration

+
+ + {os?.version && v{os.version}} + {os?.numberOfNodes !== undefined && ( + {os.numberOfNodes} node(s) + )} + {os?.host && {os.host}} +
+
+ +
+ + + + + + +
+ ); +} + +function PipelineSection({ + pipeline, + thresholds, +}: { + pipeline: ReturnType; + thresholds?: Thresholds; +}) { + const data = pipeline.data; + if (!data) return null; + + const queuePct = data.maxQueueSize > 0 + ? Math.round((data.queueDepth / data.maxQueueSize) * 100) + : 0; + const barColor = + thresholds?.osQueueCriticalPercent && queuePct >= thresholds.osQueueCriticalPercent ? '#ef4444' + : thresholds?.osQueueWarningPercent && queuePct >= thresholds.osQueueWarningPercent ? '#eab308' + : '#22c55e'; + + return ( + pipeline.refetch()} + isRefreshing={pipeline.isFetching} + autoRefresh + > +
+
+ Queue: {data.queueDepth} / {data.maxQueueSize} + {queuePct}% +
+
+
+
+
+
+
+ {data.totalIndexed.toLocaleString()} + Total Indexed +
+
+ {data.totalFailed.toLocaleString()} + Total Failed +
+
+ {data.avgLatencyMs}ms + Avg Latency +
+
+ + ); +} + +function IndicesSection() { + const [search, setSearch] = useState(''); + const [healthFilter, setHealthFilter] = useState(''); + const [sortBy, setSortBy] = useState('name'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); + const [page, setPage] = useState(0); + const pageSize = 10; + const [deleteTarget, setDeleteTarget] = useState(null); + + const params: IndicesParams = { + search: search || undefined, + health: healthFilter || undefined, + sortBy, + sortDir, + page, + size: pageSize, + }; + + const indices = useIndices(params); + const deleteMutation = useDeleteIndex(); + + function toggleSort(col: string) { + if (sortBy === col) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortBy(col); + setSortDir('asc'); + } + setPage(0); + } + + const data = indices.data; + const totalPages = data ? Math.ceil(data.total / pageSize) : 0; + + return ( + indices.refetch()} + isRefreshing={indices.isFetching} + > +
+ { setSearch(e.target.value); setPage(0); }} + /> + +
+ + {!data ? ( +
Loading...
+ ) : ( + <> +
+ + + + + + + + + + + + + {data.indices.map((idx) => ( + + + + + + + + + ))} + {data.indices.length === 0 && ( + + + + )} + +
Shards
{idx.name} + + {idx.health} + + {idx.docsCount.toLocaleString()}{idx.storeSize}{idx.primaryShards}p / {idx.replicas}r + +
No indices found
+
+ + {totalPages > 1 && ( +
+ + + Page {page + 1} of {totalPages} + + +
+ )} + + )} + + setDeleteTarget(null)} + onConfirm={() => { + if (deleteTarget) { + deleteMutation.mutate(deleteTarget); + setDeleteTarget(null); + } + }} + resourceName={deleteTarget ?? ''} + resourceType="index" + /> +
+ ); +} + +function SortHeader({ + label, + col, + current, + dir, + onSort, +}: { + label: string; + col: string; + current: string; + dir: 'asc' | 'desc'; + onSort: (col: string) => void; +}) { + const isActive = current === col; + return ( + onSort(col)} + > + {label} + {isActive && {dir === 'asc' ? ' \u25B2' : ' \u25BC'}} + + ); +} + +function PerformanceSection({ + performance, + thresholds, +}: { + performance: ReturnType; + thresholds?: Thresholds; +}) { + const data = performance.data; + if (!data) return null; + + const heapPct = data.jvmHeapUsedPercent; + const heapColor = + thresholds?.osHeapCriticalPercent && heapPct >= thresholds.osHeapCriticalPercent ? '#ef4444' + : thresholds?.osHeapWarningPercent && heapPct >= thresholds.osHeapWarningPercent ? '#eab308' + : '#22c55e'; + + return ( + performance.refetch()} + isRefreshing={performance.isFetching} + autoRefresh + > +
+
+ {(data.queryCacheHitRate * 100).toFixed(1)}% + Query Cache Hit +
+
+ {(data.requestCacheHitRate * 100).toFixed(1)}% + Request Cache Hit +
+
+ {data.avgQueryLatencyMs}ms + Query Latency +
+
+ {data.avgIndexLatencyMs}ms + Index Latency +
+
+
+
+ JVM Heap: {formatBytes(data.jvmHeapUsedBytes)} / {formatBytes(data.jvmHeapMaxBytes)} + {heapPct}% +
+
+
+
+
+ + ); +} + +function OperationsSection() { + return ( + +
+ + + +
+
+ ); +} + +function OsThresholdsSection({ thresholds }: { thresholds?: Thresholds }) { + const [form, setForm] = useState(null); + const saveMutation = useSaveThresholds(); + const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); + + const current = form ?? thresholds; + if (!current) return null; + + function update(key: keyof Thresholds, value: number) { + setForm((prev) => ({ ...(prev ?? thresholds!), [key]: value })); + } + + async function handleSave() { + const data = form ?? thresholds!; + try { + await saveMutation.mutateAsync(data); + setStatus({ type: 'success', msg: 'Thresholds saved.' }); + setTimeout(() => setStatus(null), 3000); + } catch { + setStatus({ type: 'error', msg: 'Failed to save thresholds.' }); + } + } + + return ( + +
+
+ + update('osQueueWarningPercent', Number(e.target.value))} + /> +
+
+ + update('osQueueCriticalPercent', Number(e.target.value))} + /> +
+
+ + update('osHeapWarningPercent', Number(e.target.value))} + /> +
+
+ + update('osHeapCriticalPercent', Number(e.target.value))} + /> +
+
+
+ + {status && ( + + {status.msg} + + )} +
+
+ ); +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; +} From 7c949274c5b3f78f269a69e1f1db8ce8d5db93e1 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:11:16 +0100 Subject: [PATCH 15/18] feat: add Audit Log admin page with filtering, pagination, and detail expansion Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/pages/admin/AuditLogPage.module.css | 260 +++++++++++++++++++++ ui/src/pages/admin/AuditLogPage.tsx | 225 ++++++++++++++++++ 2 files changed, 485 insertions(+) create mode 100644 ui/src/pages/admin/AuditLogPage.module.css create mode 100644 ui/src/pages/admin/AuditLogPage.tsx diff --git a/ui/src/pages/admin/AuditLogPage.module.css b/ui/src/pages/admin/AuditLogPage.module.css new file mode 100644 index 00000000..2d24e5c8 --- /dev/null +++ b/ui/src/pages/admin/AuditLogPage.module.css @@ -0,0 +1,260 @@ +.page { + max-width: 1100px; + margin: 0 auto; + padding: 32px 16px; +} + +.pageTitle { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.totalCount { + font-size: 13px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.accessDenied { + text-align: center; + padding: 64px 16px; + color: var(--text-muted); + font-size: 14px; +} + +/* ─── Filters ─── */ +.filters { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 20px; + padding: 16px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); +} + +.filterGroup { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 120px; +} + +.filterGroup:nth-child(3), +.filterGroup:nth-child(5) { + flex: 1; + min-width: 150px; +} + +.filterLabel { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} + +.filterInput { + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 7px 10px; + color: var(--text-primary); + font-size: 12px; + outline: none; + transition: border-color 0.2s; +} + +.filterInput:focus { + border-color: var(--amber-dim); +} + +.filterInput::placeholder { + color: var(--text-muted); +} + +.filterSelect { + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 7px 10px; + color: var(--text-primary); + font-size: 12px; + outline: none; + cursor: pointer; +} + +/* ─── Table ─── */ +.tableWrapper { + overflow-x: auto; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.table th { + text-align: left; + padding: 10px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + border-bottom: 1px solid var(--border-subtle); + white-space: nowrap; +} + +.table td { + padding: 8px 12px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-subtle); +} + +.eventRow { + cursor: pointer; + transition: background 0.1s; +} + +.eventRow:hover { + background: var(--bg-hover); +} + +.eventRowExpanded { + background: var(--bg-hover); +} + +.mono { + font-family: var(--font-mono); + font-size: 11px; + white-space: nowrap; +} + +.categoryBadge { + display: inline-block; + padding: 2px 8px; + border-radius: 99px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + background: var(--bg-raised); + border: 1px solid var(--border); + color: var(--text-secondary); +} + +.resultBadge { + display: inline-block; + padding: 2px 8px; + border-radius: 99px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; +} + +.resultSuccess { + background: rgba(34, 197, 94, 0.1); + color: #22c55e; +} + +.resultFailure { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; +} + +/* ─── Detail Row ─── */ +.detailRow td { + padding: 0 12px 12px; + background: var(--bg-hover); +} + +.detailJson { + margin: 0; + padding: 12px; + background: var(--bg-base); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +/* ─── Pagination ─── */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-top: 16px; +} + +.pageBtn { + padding: 6px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-raised); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; +} + +.pageBtn:hover:not(:disabled) { + border-color: var(--amber-dim); + color: var(--text-primary); +} + +.pageBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.pageInfo { + font-size: 12px; + color: var(--text-muted); +} + +.loading { + text-align: center; + padding: 32px; + color: var(--text-muted); + font-size: 14px; +} + +.emptyState { + text-align: center; + padding: 48px 16px; + color: var(--text-muted); + font-size: 13px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); +} + +@media (max-width: 768px) { + .filters { + flex-direction: column; + } + + .filterGroup { + min-width: unset; + } +} diff --git a/ui/src/pages/admin/AuditLogPage.tsx b/ui/src/pages/admin/AuditLogPage.tsx new file mode 100644 index 00000000..cf39e5fa --- /dev/null +++ b/ui/src/pages/admin/AuditLogPage.tsx @@ -0,0 +1,225 @@ +import { useState } from 'react'; +import { useAuthStore } from '../../auth/auth-store'; +import { useAuditLog, type AuditLogParams } from '../../api/queries/admin/audit'; +import styles from './AuditLogPage.module.css'; + +function defaultFrom(): string { + const d = new Date(); + d.setDate(d.getDate() - 7); + return d.toISOString().slice(0, 10); +} + +function defaultTo(): string { + return new Date().toISOString().slice(0, 10); +} + +export function AuditLogPage() { + const roles = useAuthStore((s) => s.roles); + + if (!roles.includes('ADMIN')) { + return ( +
+
+ Access Denied — this page requires the ADMIN role. +
+
+ ); + } + + return ; +} + +function AuditLogContent() { + const [from, setFrom] = useState(defaultFrom); + const [to, setTo] = useState(defaultTo); + const [username, setUsername] = useState(''); + const [category, setCategory] = useState(''); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(0); + const [expandedRow, setExpandedRow] = useState(null); + const pageSize = 25; + + const params: AuditLogParams = { + from: from || undefined, + to: to || undefined, + username: username || undefined, + category: category || undefined, + search: search || undefined, + page, + size: pageSize, + }; + + const audit = useAuditLog(params); + const data = audit.data; + const totalPages = data ? Math.ceil(data.total / pageSize) : 0; + const showingFrom = data && data.total > 0 ? page * pageSize + 1 : 0; + const showingTo = data ? Math.min((page + 1) * pageSize, data.total) : 0; + + return ( +
+
+

Audit Log

+ {data && ( + {data.total.toLocaleString()} events + )} +
+ +
+
+ + { setFrom(e.target.value); setPage(0); }} + /> +
+
+ + { setTo(e.target.value); setPage(0); }} + /> +
+
+ + { setUsername(e.target.value); setPage(0); }} + /> +
+
+ + +
+
+ + { setSearch(e.target.value); setPage(0); }} + /> +
+
+ + {audit.isLoading ? ( +
Loading...
+ ) : !data || data.events.length === 0 ? ( +
No audit events found for the selected filters.
+ ) : ( + <> +
+ + + + + + + + + + + + + {data.events.map((event) => ( + <> + + setExpandedRow((prev) => (prev === event.id ? null : event.id)) + } + > + + + + + + + + {expandedRow === event.id && ( + + + + )} + + ))} + +
TimestampUserCategoryActionTargetResult
+ {formatTimestamp(event.timestamp)} + {event.username} + {event.category} + {event.action}{event.target} + + {event.result} + +
+
+                            {JSON.stringify(event.detail, null, 2)}
+                          
+
+
+ +
+ + + Showing {showingFrom}-{showingTo} of {data.total.toLocaleString()} + + +
+ + )} +
+ ); +} + +function formatTimestamp(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } catch { + return iso; + } +} From 329e4b0b1612d1429015d67eda9ee25e3e5b16d6 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:21:25 +0100 Subject: [PATCH 16/18] added RBAC mock and spec to examples --- examples/RBAC/rbac-ui-spec.md | 321 +++++++++++++++ examples/RBAC/rbac_management_ui.html | 566 ++++++++++++++++++++++++++ 2 files changed, 887 insertions(+) create mode 100644 examples/RBAC/rbac-ui-spec.md create mode 100644 examples/RBAC/rbac_management_ui.html diff --git a/examples/RBAC/rbac-ui-spec.md b/examples/RBAC/rbac-ui-spec.md new file mode 100644 index 00000000..44ae74e4 --- /dev/null +++ b/examples/RBAC/rbac-ui-spec.md @@ -0,0 +1,321 @@ +# RBAC Management UI — Design Specification + +## Overview + +This document describes the Monitor RBAC management interface: its layout, navigation, entity model, visual conventions, badge/chip meanings, and inheritance behaviour. It is intended as a handoff reference for developers implementing the production version. + +--- + +## Application layout + +The app is a two-column shell with a fixed top bar. + +``` +┌─────────────────────────────────────────────────┐ +│ Top bar (brand + environment badge + avatar) │ +├──────────────┬──────────────────────────────────┤ +│ │ │ +│ Sidebar │ Main panel │ +│ (200px) │ (fills remaining width) │ +│ │ │ +└──────────────┴──────────────────────────────────┘ +``` + +### Top bar + +| Element | Purpose | +|---|---| +| Brand dot (green) | Indicates a live/healthy connection to the monitoring backend | +| `Monitor RBAC` wordmark | App name | +| Environment badge (`production`, `staging`, etc.) | Reminds operators which environment they are modifying — destructive changes in production are intentional | +| User avatar circle | Current operator identity; initials derived from name | + +### Sidebar + +Three navigation sections: + +- **Overview** — `Dashboard` (system summary + inheritance model diagram) +- **Identity** — `Users`, `Groups`, `Roles` (the three core entity types) +- **Audit** — `Audit log` (change history, out of scope for this spec) + +Each identity nav item shows a **count badge** (e.g. `8`, `5`, `6`) reflecting the total number of entities of that type. The active item is indicated by a green left border accent and bold label. + +--- + +## Panels + +### Dashboard (Overview) + +Displays three **stat cards** at the top: + +| Card | Content | +|---|---| +| Users | Total user count + active sub-count | +| Groups | Total group count + max nesting depth | +| Roles | Total role count + note about direct vs inherited | + +Below the stat cards is an **inheritance model diagram** — a three-column schematic showing how Groups → Roles on groups → Users form the inheritance chain. This is a read-only orientation aid, not interactive. + +A note block (green left border) explains the inheritance rule in plain language. + +--- + +### Users panel + +Split into a **list pane** (left, ~52% width) and a **detail pane** (right). + +#### List pane + +Each row is a user card containing: + +| Element | Description | +|---|---| +| Avatar circle | Two-letter initials; background colour varies by user for visual distinction | +| Name | Full display name | +| Meta line | Email address · primary group path (e.g. `Engineering → Backend`) | +| Tag row | Compact role and group badges (see Badge reference below) | +| Status dot | Green = active, grey = inactive/suspended | + +A search input at the top filters rows by any visible text (name, email, group, role). + +Clicking a row selects it (blue tint) and loads the detail pane. + +#### Detail pane — user + +Shows full user information organised into sections: + +| Section | Contents | +|---|---| +| Header | Avatar, full name, email address | +| Fields | Status, internal ID (truncated), created date | +| Group membership | Chips for every group the user directly belongs to. Sub-groups are also shown if membership was inherited via a parent group, with a small `via GroupName` annotation. | +| Effective roles | All roles the user holds — both direct assignments and roles inherited through group membership (see Role chips below) | +| Group tree | A visual indented tree showing the ancestry path of the user's groups | + +--- + +### Groups panel + +Same split layout as Users. + +#### List pane — group cards + +| Element | Description | +|---|---| +| Avatar square (rounded) | Two-letter abbreviation; colour indicates domain (green = engineering, amber = ops, red = admin) | +| Name | Group display name | +| Meta line | Parent group (if nested) · member count | +| Tag row | Roles assigned directly to this group; inherited roles shown with italic/faded styling | + +#### Detail pane — group + +| Section | Contents | +|---|---| +| Header | Avatar, group name, hierarchy level label | +| Fields | Internal ID | +| Members (direct) | Name chips for users who are direct members of this group | +| Child groups | Chips for any groups nested inside this one | +| Assigned roles | Roles directly assigned to this group — what all members will inherit | +| Inheritance note | Plain-language explanation of how roles propagate to children | +| Group hierarchy | Indented tree showing parent → this group → children | + +--- + +### Roles panel + +Same split layout. + +#### List pane — role cards + +| Element | Description | +|---|---| +| Avatar square | Two-letter abbreviation of the role name | +| Name | Role identifier (lowercase slug) | +| Meta line | Short description of access level · assignment count | +| Tag row | Groups and/or users the role is directly assigned to | + +#### Detail pane — role + +| Section | Contents | +|---|---| +| Header | Avatar, role name, description | +| Fields | Internal ID, scope | +| Assigned to groups | Group chips where this role is directly configured | +| Assigned to users (direct) | User chips that hold this role outside of any group | +| Effective principals | All principals (users) who effectively have this role, whether directly or via group inheritance | +| Inheritance note | Explains direct vs inherited assignments | + +--- + +## Badge and chip reference + +### Role tags (amber / orange) + +Appear on user and group list rows to show which roles apply. + +| Style | Meaning | +|---|---| +| Solid amber background, normal text | **Direct assignment** — the role is explicitly assigned to this entity | +| Faded / italic text, dashed border | **Inherited role** — the role flows from a parent group, not assigned directly | + +In the detail pane, inherited role chips include a small `↑ GroupName` annotation identifying the source group. + +### Group tags (green) + +Appear on user list rows and role detail panes. + +| Style | Meaning | +|---|---| +| Solid green background | The entity belongs to or is assigned to this group directly | + +### Status dot + +| Colour | Meaning | +|---|---| +| Green (filled) | User account is active | +| Grey (filled) | User account is inactive or suspended | + +### Environment badge (top bar) + +| Value | Meaning | +|---|---| +| `production` | Live system — changes are immediate and real | +| `staging` | Pre-production — safe for testing | + +### Count badge (sidebar nav) + +Small pill next to each nav label showing the total number of entities of that type. Updates to reflect search/filter state when implemented. + +--- + +## Inheritance model + +The RBAC system implements **two inheritance axes**: + +### 1. Group → child group + +Groups can be nested to any depth. A child group inherits all roles assigned to its parent group. This is transitive — a role on `Engineering` propagates to `Backend` and `Frontend`, and would continue to any groups nested inside those. + +``` +Engineering (role: viewer) +├── Backend (role: editor, inherits: viewer) +└── Frontend (role: editor, inherits: viewer) +``` + +### 2. Group → member users + +All roles effective on a group (direct + inherited from parent groups) are inherited by every user who is a member of that group. + +``` +User: Alice + Direct member of: Engineering, Backend + Effective roles: + - admin (direct on Alice) + - viewer (inherited via Engineering) + - editor (inherited via Backend) +``` + +### Role resolution + +When checking if a user has a given role, the system should: + +1. Check direct role assignments on the user. +2. For each group the user belongs to (directly or transitively), check all roles on that group. +3. Union the full set — **no role negation** in the base model (roles only grant, never deny). + +This makes effective role computation a union of all reachable role sets across the user's group membership graph. + +--- + +## Visual conventions + +| Convention | Meaning | +|---|---| +| Dashed chip border | Inherited / transitive — not directly configured here | +| `↑ GroupName` annotation | Points to the source of an inherited permission | +| Green left border on nav item | Currently active section | +| Indented tree with corner connector | Shows parent–child group hierarchy | +| Green note block (left border) | Contextual explanation of inheritance behaviour — appears wherever inherited permissions could be confusing | +| Blue tint on selected list row | Currently selected entity; detail pane reflects this entity | + +--- + +## Entity data model (for implementation reference) + +### User + +```ts +interface User { + id: string; // e.g. "usr_01HX…4AF" + name: string; + email: string; + status: "active" | "inactive"; + createdAt: string; // ISO date + directGroups: string[]; // group IDs — direct membership only + directRoles: string[]; // role IDs — assigned directly to this user + // Computed at read time: + effectiveGroups: string[]; // all groups including transitive + effectiveRoles: string[]; // all roles including inherited +} +``` + +### Group + +```ts +interface Group { + id: string; // e.g. "grp_02KX…9BC" + name: string; + parentGroupId?: string; // null for top-level groups + directRoles: string[]; // role IDs assigned to this group + // Computed at read time: + effectiveRoles: string[]; // direct + inherited from parent chain + memberUserIds: string[]; // direct members only + childGroupIds: string[]; // direct children only +} +``` + +### Role + +```ts +interface Role { + id: string; // e.g. "rol_00AA…1F2" + name: string; // slug, e.g. "admin", "viewer" + description: string; + scope: string; // e.g. "system-wide", "monitoring:read" + // Computed at read time: + directGroupIds: string[]; // groups this role is assigned to + directUserIds: string[]; // users this role is assigned to directly + effectivePrincipalIds: string[]; // all users who hold this role +} +``` + +--- + +## Recommended API surface + +| Method | Path | Description | +|---|---|---| +| `GET` | `/users` | List all users with effectiveRoles and effectiveGroups | +| `GET` | `/users/:id` | Single user detail | +| `POST` | `/users/:id/roles` | Assign a role directly to a user | +| `DELETE` | `/users/:id/roles/:roleId` | Remove a direct role from a user | +| `POST` | `/users/:id/groups` | Add user to a group | +| `DELETE` | `/users/:id/groups/:groupId` | Remove user from a group | +| `GET` | `/groups` | List all groups with hierarchy | +| `GET` | `/groups/:id` | Single group detail | +| `POST` | `/groups/:id/roles` | Assign a role to a group | +| `POST` | `/groups/:id/children` | Nest a child group | +| `GET` | `/roles` | List all roles with effective principals | +| `GET` | `/roles/:id` | Single role detail | + +--- + +## Handoff notes for Claude Code + +When implementing this in a production stack: + +- **State management** — effective roles and groups should be computed server-side and returned in API responses. Do not compute inheritance chains in the frontend. +- **Component split** — `EntityListPane`, `UserDetail`, `GroupDetail`, `RoleDetail`, `InheritanceChip`, `GroupTree` are the natural component boundaries. +- **CSS tokens** — all colours use CSS variables (`--color-background-primary`, `--color-border-tertiary`, etc.) that map to the design system. Replace with your own token layer (Tailwind, CSS Modules, etc.). +- **Search** — currently client-side string matching. For large deployments, wire to a server-side search endpoint. +- **Inheritance note blocks** — always render these wherever inherited permissions are displayed. They prevent operator confusion when a user has a role they didn't expect. diff --git a/examples/RBAC/rbac_management_ui.html b/examples/RBAC/rbac_management_ui.html new file mode 100644 index 00000000..8f10a2b1 --- /dev/null +++ b/examples/RBAC/rbac_management_ui.html @@ -0,0 +1,566 @@ + + + +
+ +
+
+
+ Monitor RBAC +
+
+ production +
A
+
+
+ + + + + +
+ + +
+
+
RBAC overview
Inheritance model and system summary
+
+
+
Users
8
6 active
+
Groups
5
Nested up to 3 levels
+
Roles
6
Direct + inherited
+
+
+
Inheritance model
+
+
+
Groups
+
Engineering
+
→ Backend
+
→ Frontend
+
Ops
+
Admins
+
+
+
+
Roles on groups
+
viewer
+
editor
+
deployer
+
admin
+
+
+
+
Users inherit
+
alice
+
bob
+
carol
+
+ 5 more…
+
+
+
+ Users inherit all roles from every group they belong to — and transitively from parent groups. Roles can also be assigned directly to users, overriding or extending inherited permissions. +
+
+
+ + +
+
+
Users
Manage identities, group membership and direct roles
+ +
+
+
+ +
+
+
AL
+
+
Alice Lang
+
alice@corp.io · Engineering → Backend
+
adminviewerBackend
+
+
+
+
+
BK
+
+
Bob Kim
+
bob@corp.io · Engineering → Frontend
+
editorFrontend
+
+
+
+
+
CS
+
+
Carol Sanz
+
carol@corp.io · Ops
+
deployerviewerOps
+
+
+
+
+
DM
+
+
Dan Müller
+
dan@corp.io · Admins
+
adminAdmins
+
+
+
+
+
EP
+
+
Eve Park
+
eve@corp.io · Engineering → Backend
+
editorBackend
+
+
+
+
+
FR
+
+
Frank Rossi
+
frank@corp.io · (no groups)
+
viewer
+
+
+
+
+
+
+ +
AL
+
Alice Lang
+ +
Status● Active
+
IDusr_01HX…4AF
+
Created2024-03-12
+
+
+
Group membership direct only
+ + + Engineering + + + + Backend + via Engineering + +
+
+
Effective roles direct + inherited
+ admin + + viewer + ↑ Engineering + + + editor + ↑ Backend + +
Dashed roles are inherited transitively through group membership.
+
+
+
Group tree
+
Engineering
+
Backend child group
+
+
+
+
+ + +
+
+
Groups
Organise users in nested hierarchies; roles propagate to all members
+ +
+
+
+ +
+
+
EN
+
+
Engineering
+
Top-level · 2 child groups · 5 members
+
viewer
+
+
+
+
BE
+
+
Backend
+
Child of Engineering · 3 members
+
editorviewer
+
+
+
+
FE
+
+
Frontend
+
Child of Engineering · 2 members
+
editorviewer
+
+
+
+
OP
+
+
Ops
+
Top-level · 2 members
+
deployerviewer
+
+
+
+
AD
+
+
Admins
+
Top-level · 1 member
+
admin
+
+
+
+
+
+
EN
+
Engineering
+ +
IDgrp_02KX…9BC
+
+
+
Members direct
+ Alice LangEve ParkBob Kim +
+ all members of Backend, Frontend
+
+
+
Child groups
+ Backend + Frontend +
+
+
Assigned roles on this group
+ viewer +
Child groups Backend and Frontend inherit viewer, and additionally carry their own editor role.
+
+
+
Group hierarchy
+
Engineering
+
Backend
+
Frontend
+
+
+
+
+ + +
+
+
Roles
Define permission scopes; assign to users or groups
+ +
+
+
+ +
+
+
AD
+
+
admin
+
Full access · 2 direct assignments
+
AdminsAlice
+
+
+
+
ED
+
+
editor
+
Read + write · 2 group assignments
+
BackendFrontend
+
+
+
+
DE
+
+
deployer
+
Deploy access · 1 assignment
+
Ops
+
+
+
+
VI
+
+
viewer
+
Read-only · 1 group assignment
+
Engineering
+
+
+
+
AU
+
+
auditor
+
Audit log access · 0 assignments
+
+
+
+
+
+
AD
+
admin
+ +
IDrol_00AA…1F2
+
Scopesystem-wide
+
+
+
Assigned to groups
+ Admins +
+
+
Assigned to users (direct)
+ Alice Lang +
+
+
Effective principals via inheritance
+ Alice Lang + Dan Müller + …via Admins group +
Dan inherits admin through the Admins group. Alice holds it directly.
+
+
+
+
+ +
+
+ + From 038b663b8c0a1d0203438e2888db2be851078980 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:36:11 +0100 Subject: [PATCH 17/18] fix: align frontend interfaces with backend DTO field names Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/api/queries/admin/audit.ts | 19 ++- ui/src/api/queries/admin/database.ts | 15 ++- ui/src/api/queries/admin/opensearch.ts | 44 +++---- ui/src/api/queries/admin/thresholds.ts | 36 ++++-- ui/src/pages/admin/AuditLogPage.tsx | 16 +-- ui/src/pages/admin/DatabaseAdminPage.tsx | 59 ++++----- ui/src/pages/admin/OpenSearchAdminPage.tsx | 132 +++++++-------------- 7 files changed, 153 insertions(+), 168 deletions(-) diff --git a/ui/src/api/queries/admin/audit.ts b/ui/src/api/queries/admin/audit.ts index 2ea3399a..46edd70f 100644 --- a/ui/src/api/queries/admin/audit.ts +++ b/ui/src/api/queries/admin/audit.ts @@ -2,14 +2,16 @@ import { useQuery } from '@tanstack/react-query'; import { adminFetch } from './admin-api'; export interface AuditEvent { - id: string; + id: number; timestamp: string; username: string; - category: string; action: string; + category: string; target: string; - result: string; detail: Record; + result: string; + ipAddress: string; + userAgent: string; } export interface AuditLogParams { @@ -18,13 +20,18 @@ export interface AuditLogParams { username?: string; category?: string; search?: string; + sort?: string; + order?: string; page?: number; size?: number; } export interface AuditLogResponse { - events: AuditEvent[]; - total: number; + items: AuditEvent[]; + totalCount: number; + page: number; + pageSize: number; + totalPages: number; } export function useAuditLog(params: AuditLogParams) { @@ -34,6 +41,8 @@ export function useAuditLog(params: AuditLogParams) { if (params.username) query.set('username', params.username); if (params.category) query.set('category', params.category); if (params.search) query.set('search', params.search); + if (params.sort) query.set('sort', params.sort); + if (params.order) query.set('order', params.order); if (params.page !== undefined) query.set('page', String(params.page)); if (params.size !== undefined) query.set('size', String(params.size)); const qs = query.toString(); diff --git a/ui/src/api/queries/admin/database.ts b/ui/src/api/queries/admin/database.ts index b83eb7bf..662e888b 100644 --- a/ui/src/api/queries/admin/database.ts +++ b/ui/src/api/queries/admin/database.ts @@ -6,26 +6,29 @@ export interface DatabaseStatus { version: string; host: string; schema: string; + timescaleDb: boolean; } export interface PoolStats { activeConnections: number; idleConnections: number; - pendingConnections: number; - maxConnections: number; - maxWaitMillis: number; + pendingThreads: number; + maxPoolSize: number; + maxWaitMs: number; } export interface TableInfo { tableName: string; - rowEstimate: number; + rowCount: number; dataSize: string; indexSize: string; + dataSizeBytes: number; + indexSizeBytes: number; } export interface ActiveQuery { pid: number; - durationMs: number; + durationSeconds: number; state: string; query: string; } @@ -64,7 +67,7 @@ export function useKillQuery() { const qc = useQueryClient(); return useMutation({ mutationFn: async (pid: number) => { - await adminFetch(`/database/queries/${pid}`, { method: 'DELETE' }); + await adminFetch(`/database/queries/${pid}/kill`, { method: 'POST' }); }, onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'database', 'queries'] }), }); diff --git a/ui/src/api/queries/admin/opensearch.ts b/ui/src/api/queries/admin/opensearch.ts index 15b5133b..0f2ecaa3 100644 --- a/ui/src/api/queries/admin/opensearch.ts +++ b/ui/src/api/queries/admin/opensearch.ts @@ -2,47 +2,54 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { adminFetch } from './admin-api'; export interface OpenSearchStatus { - connected: boolean; - clusterName: string; + reachable: boolean; clusterHealth: string; version: string; - numberOfNodes: number; + nodeCount: number; host: string; } export interface PipelineStats { queueDepth: number; maxQueueSize: number; - totalIndexed: number; - totalFailed: number; - avgLatencyMs: number; + indexedCount: number; + failedCount: number; + debounceMs: number; + indexingRate: number; + lastIndexedAt: string | null; } export interface IndexInfo { name: string; health: string; - status: string; - docsCount: number; - storeSize: string; + docCount: number; + size: string; + sizeBytes: number; primaryShards: number; - replicas: number; + replicaShards: number; +} + +export interface IndicesPageResponse { + indices: IndexInfo[]; + totalIndices: number; + totalDocs: number; + totalSize: string; + page: number; + pageSize: number; + totalPages: number; } export interface PerformanceStats { queryCacheHitRate: number; requestCacheHitRate: number; - avgQueryLatencyMs: number; - avgIndexLatencyMs: number; - jvmHeapUsedPercent: number; + searchLatencyMs: number; + indexingLatencyMs: number; jvmHeapUsedBytes: number; jvmHeapMaxBytes: number; } export interface IndicesParams { search?: string; - health?: string; - sortBy?: string; - sortDir?: 'asc' | 'desc'; page?: number; size?: number; } @@ -65,9 +72,6 @@ export function usePipelineStats() { export function useIndices(params: IndicesParams) { const query = new URLSearchParams(); if (params.search) query.set('search', params.search); - if (params.health) query.set('health', params.health); - if (params.sortBy) query.set('sortBy', params.sortBy); - if (params.sortDir) query.set('sortDir', params.sortDir); if (params.page !== undefined) query.set('page', String(params.page)); if (params.size !== undefined) query.set('size', String(params.size)); const qs = query.toString(); @@ -75,7 +79,7 @@ export function useIndices(params: IndicesParams) { return useQuery({ queryKey: ['admin', 'opensearch', 'indices', params], queryFn: () => - adminFetch<{ indices: IndexInfo[]; total: number }>( + adminFetch( `/opensearch/indices${qs ? `?${qs}` : ''}`, ), }); diff --git a/ui/src/api/queries/admin/thresholds.ts b/ui/src/api/queries/admin/thresholds.ts index ffcc09eb..3a7aaa1d 100644 --- a/ui/src/api/queries/admin/thresholds.ts +++ b/ui/src/api/queries/admin/thresholds.ts @@ -1,29 +1,41 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { adminFetch } from './admin-api'; -export interface Thresholds { - poolWarningPercent: number; - poolCriticalPercent: number; - queryDurationWarningSeconds: number; - queryDurationCriticalSeconds: number; - osQueueWarningPercent: number; - osQueueCriticalPercent: number; - osHeapWarningPercent: number; - osHeapCriticalPercent: number; +export interface DatabaseThresholds { + connectionPoolWarning: number; + connectionPoolCritical: number; + queryDurationWarning: number; + queryDurationCritical: number; +} + +export interface OpenSearchThresholds { + clusterHealthWarning: string; + clusterHealthCritical: string; + queueDepthWarning: number; + queueDepthCritical: number; + jvmHeapWarning: number; + jvmHeapCritical: number; + failedDocsWarning: number; + failedDocsCritical: number; +} + +export interface ThresholdConfig { + database: DatabaseThresholds; + opensearch: OpenSearchThresholds; } export function useThresholds() { return useQuery({ queryKey: ['admin', 'thresholds'], - queryFn: () => adminFetch('/thresholds'), + queryFn: () => adminFetch('/thresholds'), }); } export function useSaveThresholds() { const qc = useQueryClient(); return useMutation({ - mutationFn: async (body: Thresholds) => { - await adminFetch('/thresholds', { + mutationFn: async (body: ThresholdConfig) => { + await adminFetch('/thresholds', { method: 'PUT', body: JSON.stringify(body), }); diff --git a/ui/src/pages/admin/AuditLogPage.tsx b/ui/src/pages/admin/AuditLogPage.tsx index cf39e5fa..1aa1f3ad 100644 --- a/ui/src/pages/admin/AuditLogPage.tsx +++ b/ui/src/pages/admin/AuditLogPage.tsx @@ -36,7 +36,7 @@ function AuditLogContent() { const [category, setCategory] = useState(''); const [search, setSearch] = useState(''); const [page, setPage] = useState(0); - const [expandedRow, setExpandedRow] = useState(null); + const [expandedRow, setExpandedRow] = useState(null); const pageSize = 25; const params: AuditLogParams = { @@ -51,16 +51,16 @@ function AuditLogContent() { const audit = useAuditLog(params); const data = audit.data; - const totalPages = data ? Math.ceil(data.total / pageSize) : 0; - const showingFrom = data && data.total > 0 ? page * pageSize + 1 : 0; - const showingTo = data ? Math.min((page + 1) * pageSize, data.total) : 0; + const totalPages = data?.totalPages ?? 0; + const showingFrom = data && data.totalCount > 0 ? page * pageSize + 1 : 0; + const showingTo = data ? Math.min((page + 1) * pageSize, data.totalCount) : 0; return (

Audit Log

{data && ( - {data.total.toLocaleString()} events + {data.totalCount.toLocaleString()} events )}
@@ -121,7 +121,7 @@ function AuditLogContent() { {audit.isLoading ? (
Loading...
- ) : !data || data.events.length === 0 ? ( + ) : !data || data.items.length === 0 ? (
No audit events found for the selected filters.
) : ( <> @@ -138,7 +138,7 @@ function AuditLogContent() { - {data.events.map((event) => ( + {data.items.map((event) => ( <> - Showing {showingFrom}-{showingTo} of {data.total.toLocaleString()} + Showing {showingFrom}-{showingTo} of {data.totalCount.toLocaleString()}
@@ -328,8 +331,8 @@ function ThresholdsSection({ thresholds }: { thresholds?: Thresholds }) { update('poolCriticalPercent', Number(e.target.value))} + value={current.database.connectionPoolCritical} + onChange={(e) => updateDb('connectionPoolCritical', Number(e.target.value))} />
@@ -337,8 +340,8 @@ function ThresholdsSection({ thresholds }: { thresholds?: Thresholds }) { update('queryDurationWarningSeconds', Number(e.target.value))} + value={current.database.queryDurationWarning} + onChange={(e) => updateDb('queryDurationWarning', Number(e.target.value))} />
@@ -346,8 +349,8 @@ function ThresholdsSection({ thresholds }: { thresholds?: Thresholds }) { update('queryDurationCriticalSeconds', Number(e.target.value))} + value={current.database.queryDurationCritical} + onChange={(e) => updateDb('queryDurationCritical', Number(e.target.value))} />
@@ -370,9 +373,9 @@ function ThresholdsSection({ thresholds }: { thresholds?: Thresholds }) { ); } -function formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms`; - const s = Math.floor(ms / 1000); +function formatDuration(seconds: number): string { + if (seconds < 1) return `${Math.round(seconds * 1000)}ms`; + const s = Math.floor(seconds); if (s < 60) return `${s}s`; const m = Math.floor(s / 60); return `${m}m ${s % 60}s`; diff --git a/ui/src/pages/admin/OpenSearchAdminPage.tsx b/ui/src/pages/admin/OpenSearchAdminPage.tsx index 45653e05..f069acc8 100644 --- a/ui/src/pages/admin/OpenSearchAdminPage.tsx +++ b/ui/src/pages/admin/OpenSearchAdminPage.tsx @@ -11,7 +11,7 @@ import { useDeleteIndex, type IndicesParams, } from '../../api/queries/admin/opensearch'; -import { useThresholds, useSaveThresholds, type Thresholds } from '../../api/queries/admin/thresholds'; +import { useThresholds, useSaveThresholds, type ThresholdConfig } from '../../api/queries/admin/thresholds'; import styles from './OpenSearchAdminPage.module.css'; function clusterHealthToStatus(health: string | undefined): Status { @@ -67,8 +67,8 @@ function OpenSearchAdminContent() { label={os?.clusterHealth ?? 'Unknown'} /> {os?.version && v{os.version}} - {os?.numberOfNodes !== undefined && ( - {os.numberOfNodes} node(s) + {os?.nodeCount !== undefined && ( + {os.nodeCount} node(s) )} {os?.host && {os.host}}
@@ -100,7 +100,7 @@ function PipelineSection({ thresholds, }: { pipeline: ReturnType; - thresholds?: Thresholds; + thresholds?: ThresholdConfig; }) { const data = pipeline.data; if (!data) return null; @@ -109,8 +109,8 @@ function PipelineSection({ ? Math.round((data.queueDepth / data.maxQueueSize) * 100) : 0; const barColor = - thresholds?.osQueueCriticalPercent && queuePct >= thresholds.osQueueCriticalPercent ? '#ef4444' - : thresholds?.osQueueWarningPercent && queuePct >= thresholds.osQueueWarningPercent ? '#eab308' + thresholds?.opensearch?.queueDepthCritical && data.queueDepth >= thresholds.opensearch.queueDepthCritical ? '#ef4444' + : thresholds?.opensearch?.queueDepthWarning && data.queueDepth >= thresholds.opensearch.queueDepthWarning ? '#eab308' : '#22c55e'; return ( @@ -134,16 +134,16 @@ function PipelineSection({
- {data.totalIndexed.toLocaleString()} + {data.indexedCount.toLocaleString()} Total Indexed
- {data.totalFailed.toLocaleString()} + {data.failedCount.toLocaleString()} Total Failed
- {data.avgLatencyMs}ms - Avg Latency + {data.indexingRate.toFixed(1)}/s + Indexing Rate
@@ -152,18 +152,12 @@ function PipelineSection({ function IndicesSection() { const [search, setSearch] = useState(''); - const [healthFilter, setHealthFilter] = useState(''); - const [sortBy, setSortBy] = useState('name'); - const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const [page, setPage] = useState(0); const pageSize = 10; const [deleteTarget, setDeleteTarget] = useState(null); const params: IndicesParams = { search: search || undefined, - health: healthFilter || undefined, - sortBy, - sortDir, page, size: pageSize, }; @@ -171,18 +165,8 @@ function IndicesSection() { const indices = useIndices(params); const deleteMutation = useDeleteIndex(); - function toggleSort(col: string) { - if (sortBy === col) { - setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); - } else { - setSortBy(col); - setSortDir('asc'); - } - setPage(0); - } - const data = indices.data; - const totalPages = data ? Math.ceil(data.total / pageSize) : 0; + const totalPages = data?.totalPages ?? 0; return ( { setSearch(e.target.value); setPage(0); }} /> - {!data ? ( @@ -218,10 +192,10 @@ function IndicesSection() { - - - - + + + + @@ -235,9 +209,9 @@ function IndicesSection() { {idx.health} - - - + + + - ); -} - function PerformanceSection({ performance, thresholds, }: { performance: ReturnType; - thresholds?: Thresholds; + thresholds?: ThresholdConfig; }) { const data = performance.data; if (!data) return null; - const heapPct = data.jvmHeapUsedPercent; + const heapPct = data.jvmHeapMaxBytes > 0 + ? Math.round((data.jvmHeapUsedBytes / data.jvmHeapMaxBytes) * 100) + : 0; const heapColor = - thresholds?.osHeapCriticalPercent && heapPct >= thresholds.osHeapCriticalPercent ? '#ef4444' - : thresholds?.osHeapWarningPercent && heapPct >= thresholds.osHeapWarningPercent ? '#eab308' + thresholds?.opensearch?.jvmHeapCritical && heapPct >= thresholds.opensearch.jvmHeapCritical ? '#ef4444' + : thresholds?.opensearch?.jvmHeapWarning && heapPct >= thresholds.opensearch.jvmHeapWarning ? '#eab308' : '#22c55e'; return ( @@ -358,11 +309,11 @@ function PerformanceSection({ Request Cache Hit
- {data.avgQueryLatencyMs}ms + {data.searchLatencyMs.toFixed(1)}ms Query Latency
- {data.avgIndexLatencyMs}ms + {data.indexingLatencyMs.toFixed(1)}ms Index Latency
@@ -400,16 +351,19 @@ function OperationsSection() { ); } -function OsThresholdsSection({ thresholds }: { thresholds?: Thresholds }) { - const [form, setForm] = useState(null); +function OsThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) { + const [form, setForm] = useState(null); const saveMutation = useSaveThresholds(); const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); const current = form ?? thresholds; if (!current) return null; - function update(key: keyof Thresholds, value: number) { - setForm((prev) => ({ ...(prev ?? thresholds!), [key]: value })); + function updateOs(key: keyof ThresholdConfig['opensearch'], value: number | string) { + setForm((prev) => { + const base = prev ?? thresholds!; + return { ...base, opensearch: { ...base.opensearch, [key]: value } }; + }); } async function handleSave() { @@ -427,21 +381,21 @@ function OsThresholdsSection({ thresholds }: { thresholds?: Thresholds }) {
- + update('osQueueWarningPercent', Number(e.target.value))} + value={current.opensearch.queueDepthWarning} + onChange={(e) => updateOs('queueDepthWarning', Number(e.target.value))} />
- + update('osQueueCriticalPercent', Number(e.target.value))} + value={current.opensearch.queueDepthCritical} + onChange={(e) => updateOs('queueDepthCritical', Number(e.target.value))} />
@@ -449,8 +403,8 @@ function OsThresholdsSection({ thresholds }: { thresholds?: Thresholds }) { update('osHeapWarningPercent', Number(e.target.value))} + value={current.opensearch.jvmHeapWarning} + onChange={(e) => updateOs('jvmHeapWarning', Number(e.target.value))} />
@@ -458,8 +412,8 @@ function OsThresholdsSection({ thresholds }: { thresholds?: Thresholds }) { update('osHeapCriticalPercent', Number(e.target.value))} + value={current.opensearch.jvmHeapCritical} + onChange={(e) => updateOs('jvmHeapCritical', Number(e.target.value))} />
From 4bc48afbf830b1d4b03ab9c4bba0c7d08d0a18aa Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:37:43 +0100 Subject: [PATCH 18/18] chore: regenerate OpenAPI spec and TypeScript types for admin endpoints Downloaded from deployed feature branch server. Patched PositionedNode to include children field (missing from server-generated spec). Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/api/openapi.json | 1017 ++++++++++++++++++++++++++++++++++++++- ui/src/api/schema.d.ts | 869 ++++++++++++++++++++++++++++++++- 2 files changed, 1851 insertions(+), 35 deletions(-) diff --git a/ui/src/api/openapi.json b/ui/src/api/openapi.json index 8b8028ef..9e06c159 100644 --- a/ui/src/api/openapi.json +++ b/ui/src/api/openapi.json @@ -4,7 +4,12 @@ "title": "Cameleer3 Server API", "version": "1.0" }, - "servers": [], + "servers": [ + { + "url": "/api/v1", + "description": "Relative" + } + ], "security": [ { "bearer": [] @@ -12,8 +17,12 @@ ], "tags": [ { - "name": "Agent SSE", - "description": "Server-Sent Events endpoint for agent communication" + "name": "Database Admin", + "description": "Database monitoring and management (ADMIN only)" + }, + { + "name": "Threshold Admin", + "description": "Monitoring threshold configuration (ADMIN only)" }, { "name": "Agent Commands", @@ -27,10 +36,6 @@ "name": "Agent Management", "description": "Agent registration and lifecycle endpoints" }, - { - "name": "Ingestion", - "description": "Data ingestion endpoints" - }, { "name": "Authentication", "description": "Login and token refresh endpoints" @@ -39,17 +44,33 @@ "name": "OIDC Config Admin", "description": "OIDC provider configuration (ADMIN only)" }, + { + "name": "Search", + "description": "Transaction search endpoints" + }, + { + "name": "Agent SSE", + "description": "Server-Sent Events endpoint for agent communication" + }, + { + "name": "Ingestion", + "description": "Data ingestion endpoints" + }, + { + "name": "Audit Log", + "description": "Audit log viewer (ADMIN only)" + }, { "name": "Diagrams", "description": "Diagram rendering endpoints" }, { - "name": "Detail", - "description": "Execution detail and processor snapshot endpoints" + "name": "OpenSearch Admin", + "description": "OpenSearch monitoring and management (ADMIN only)" }, { - "name": "Search", - "description": "Transaction search endpoints" + "name": "Detail", + "description": "Execution detail and processor snapshot endpoints" } ], "paths": { @@ -90,6 +111,56 @@ } } }, + "/admin/thresholds": { + "get": { + "tags": [ + "Threshold Admin" + ], + "summary": "Get current threshold configuration", + "operationId": "getThresholds", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ThresholdConfig" + } + } + } + } + } + }, + "put": { + "tags": [ + "Threshold Admin" + ], + "summary": "Update threshold configuration", + "operationId": "updateThresholds", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThresholdConfigRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ThresholdConfig" + } + } + } + } + } + } + }, "/admin/oidc": { "get": { "tags": [ @@ -373,9 +444,6 @@ "responses": { "202": { "description": "Data accepted for processing" - }, - "503": { - "description": "Buffer full, retry later" } } } @@ -401,9 +469,6 @@ "responses": { "202": { "description": "Data accepted for processing" - }, - "503": { - "description": "Buffer full, retry later" } } } @@ -916,6 +981,31 @@ } } }, + "/admin/database/queries/{pid}/kill": { + "post": { + "tags": [ + "Database Admin" + ], + "summary": "Terminate a query by PID", + "operationId": "killQuery", + "parameters": [ + { + "name": "pid", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/search/stats": { "get": { "tags": [ @@ -1175,7 +1265,14 @@ } }, "404": { - "description": "No diagram found for the given group and route" + "description": "No diagram found for the given group and route", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DiagramLayout" + } + } + } } } } @@ -1458,6 +1555,338 @@ } } } + }, + "/admin/opensearch/status": { + "get": { + "tags": [ + "OpenSearch Admin" + ], + "summary": "Get OpenSearch cluster status and version", + "operationId": "getStatus", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/OpenSearchStatusResponse" + } + } + } + } + } + } + }, + "/admin/opensearch/pipeline": { + "get": { + "tags": [ + "OpenSearch Admin" + ], + "summary": "Get indexing pipeline statistics", + "operationId": "getPipeline", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PipelineStatsResponse" + } + } + } + } + } + } + }, + "/admin/opensearch/performance": { + "get": { + "tags": [ + "OpenSearch Admin" + ], + "summary": "Get OpenSearch performance metrics", + "operationId": "getPerformance", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PerformanceResponse" + } + } + } + } + } + } + }, + "/admin/opensearch/indices": { + "get": { + "tags": [ + "OpenSearch Admin" + ], + "summary": "Get OpenSearch indices with pagination", + "operationId": "getIndices", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/IndicesPageResponse" + } + } + } + } + } + } + }, + "/admin/database/tables": { + "get": { + "tags": [ + "Database Admin" + ], + "summary": "Get table sizes and row counts", + "operationId": "getTables", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TableSizeResponse" + } + } + } + } + } + } + } + }, + "/admin/database/status": { + "get": { + "tags": [ + "Database Admin" + ], + "summary": "Get database connection status and version", + "operationId": "getStatus_1", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DatabaseStatusResponse" + } + } + } + } + } + } + }, + "/admin/database/queries": { + "get": { + "tags": [ + "Database Admin" + ], + "summary": "Get active queries", + "operationId": "getQueries", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActiveQueryResponse" + } + } + } + } + } + } + } + }, + "/admin/database/pool": { + "get": { + "tags": [ + "Database Admin" + ], + "summary": "Get HikariCP connection pool stats", + "operationId": "getPool", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ConnectionPoolResponse" + } + } + } + } + } + } + }, + "/admin/audit": { + "get": { + "tags": [ + "Audit Log" + ], + "summary": "Search audit log entries with pagination", + "operationId": "getAuditLog", + "parameters": [ + { + "name": "username", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "category", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "timestamp" + } + }, + { + "name": "order", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "desc" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 25 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/AuditLogPageResponse" + } + } + } + } + } + } + }, + "/admin/opensearch/indices/{name}": { + "delete": { + "tags": [ + "OpenSearch Admin" + ], + "summary": "Delete an OpenSearch index", + "operationId": "deleteIndex", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } } }, "components": { @@ -1473,6 +1902,173 @@ } } }, + "DatabaseThresholdsRequest": { + "type": "object", + "description": "Database monitoring thresholds", + "properties": { + "connectionPoolWarning": { + "type": "integer", + "format": "int32", + "description": "Connection pool usage warning threshold (percentage)", + "maximum": 100, + "minimum": 0 + }, + "connectionPoolCritical": { + "type": "integer", + "format": "int32", + "description": "Connection pool usage critical threshold (percentage)", + "maximum": 100, + "minimum": 0 + }, + "queryDurationWarning": { + "type": "number", + "format": "double", + "description": "Query duration warning threshold (seconds)" + }, + "queryDurationCritical": { + "type": "number", + "format": "double", + "description": "Query duration critical threshold (seconds)" + } + } + }, + "OpenSearchThresholdsRequest": { + "type": "object", + "description": "OpenSearch monitoring thresholds", + "properties": { + "clusterHealthWarning": { + "type": "string", + "description": "Cluster health warning threshold (GREEN, YELLOW, RED)", + "minLength": 1 + }, + "clusterHealthCritical": { + "type": "string", + "description": "Cluster health critical threshold (GREEN, YELLOW, RED)", + "minLength": 1 + }, + "queueDepthWarning": { + "type": "integer", + "format": "int32", + "description": "Queue depth warning threshold", + "minimum": 0 + }, + "queueDepthCritical": { + "type": "integer", + "format": "int32", + "description": "Queue depth critical threshold", + "minimum": 0 + }, + "jvmHeapWarning": { + "type": "integer", + "format": "int32", + "description": "JVM heap usage warning threshold (percentage)", + "maximum": 100, + "minimum": 0 + }, + "jvmHeapCritical": { + "type": "integer", + "format": "int32", + "description": "JVM heap usage critical threshold (percentage)", + "maximum": 100, + "minimum": 0 + }, + "failedDocsWarning": { + "type": "integer", + "format": "int32", + "description": "Failed document count warning threshold", + "minimum": 0 + }, + "failedDocsCritical": { + "type": "integer", + "format": "int32", + "description": "Failed document count critical threshold", + "minimum": 0 + } + } + }, + "ThresholdConfigRequest": { + "type": "object", + "description": "Threshold configuration for admin monitoring", + "properties": { + "database": { + "$ref": "#/components/schemas/DatabaseThresholdsRequest" + }, + "opensearch": { + "$ref": "#/components/schemas/OpenSearchThresholdsRequest" + } + }, + "required": [ + "database", + "opensearch" + ] + }, + "DatabaseThresholds": { + "type": "object", + "properties": { + "connectionPoolWarning": { + "type": "integer", + "format": "int32" + }, + "connectionPoolCritical": { + "type": "integer", + "format": "int32" + }, + "queryDurationWarning": { + "type": "number", + "format": "double" + }, + "queryDurationCritical": { + "type": "number", + "format": "double" + } + } + }, + "OpenSearchThresholds": { + "type": "object", + "properties": { + "clusterHealthWarning": { + "type": "string" + }, + "clusterHealthCritical": { + "type": "string" + }, + "queueDepthWarning": { + "type": "integer", + "format": "int32" + }, + "queueDepthCritical": { + "type": "integer", + "format": "int32" + }, + "jvmHeapWarning": { + "type": "integer", + "format": "int32" + }, + "jvmHeapCritical": { + "type": "integer", + "format": "int32" + }, + "failedDocsWarning": { + "type": "integer", + "format": "int32" + }, + "failedDocsCritical": { + "type": "integer", + "format": "int32" + } + } + }, + "ThresholdConfig": { + "type": "object", + "properties": { + "database": { + "$ref": "#/components/schemas/DatabaseThresholds" + }, + "opensearch": { + "$ref": "#/components/schemas/OpenSearchThresholds" + } + } + }, "OidcAdminConfigRequest": { "type": "object", "description": "OIDC configuration update request", @@ -1732,8 +2328,8 @@ }, "required": [ "accessToken", - "refreshToken", - "displayName" + "displayName", + "refreshToken" ] }, "CallbackRequest": { @@ -2336,6 +2932,387 @@ "roles", "userId" ] + }, + "OpenSearchStatusResponse": { + "type": "object", + "description": "OpenSearch cluster status", + "properties": { + "reachable": { + "type": "boolean", + "description": "Whether the cluster is reachable" + }, + "clusterHealth": { + "type": "string", + "description": "Cluster health status (GREEN, YELLOW, RED)" + }, + "version": { + "type": "string", + "description": "OpenSearch version" + }, + "nodeCount": { + "type": "integer", + "format": "int32", + "description": "Number of nodes in the cluster" + }, + "host": { + "type": "string", + "description": "OpenSearch host" + } + } + }, + "PipelineStatsResponse": { + "type": "object", + "description": "Search indexing pipeline statistics", + "properties": { + "queueDepth": { + "type": "integer", + "format": "int32", + "description": "Current queue depth" + }, + "maxQueueSize": { + "type": "integer", + "format": "int32", + "description": "Maximum queue size" + }, + "failedCount": { + "type": "integer", + "format": "int64", + "description": "Number of failed indexing operations" + }, + "indexedCount": { + "type": "integer", + "format": "int64", + "description": "Number of successfully indexed documents" + }, + "debounceMs": { + "type": "integer", + "format": "int64", + "description": "Debounce interval in milliseconds" + }, + "indexingRate": { + "type": "number", + "format": "double", + "description": "Current indexing rate (docs/sec)" + }, + "lastIndexedAt": { + "type": "string", + "format": "date-time", + "description": "Timestamp of last indexed document" + } + } + }, + "PerformanceResponse": { + "type": "object", + "description": "OpenSearch performance metrics", + "properties": { + "queryCacheHitRate": { + "type": "number", + "format": "double", + "description": "Query cache hit rate (0.0-1.0)" + }, + "requestCacheHitRate": { + "type": "number", + "format": "double", + "description": "Request cache hit rate (0.0-1.0)" + }, + "searchLatencyMs": { + "type": "number", + "format": "double", + "description": "Average search latency in milliseconds" + }, + "indexingLatencyMs": { + "type": "number", + "format": "double", + "description": "Average indexing latency in milliseconds" + }, + "jvmHeapUsedBytes": { + "type": "integer", + "format": "int64", + "description": "JVM heap used in bytes" + }, + "jvmHeapMaxBytes": { + "type": "integer", + "format": "int64", + "description": "JVM heap max in bytes" + } + } + }, + "IndexInfoResponse": { + "type": "object", + "description": "OpenSearch index information", + "properties": { + "name": { + "type": "string", + "description": "Index name" + }, + "docCount": { + "type": "integer", + "format": "int64", + "description": "Document count" + }, + "size": { + "type": "string", + "description": "Human-readable index size" + }, + "sizeBytes": { + "type": "integer", + "format": "int64", + "description": "Index size in bytes" + }, + "health": { + "type": "string", + "description": "Index health status" + }, + "primaryShards": { + "type": "integer", + "format": "int32", + "description": "Number of primary shards" + }, + "replicaShards": { + "type": "integer", + "format": "int32", + "description": "Number of replica shards" + } + } + }, + "IndicesPageResponse": { + "type": "object", + "description": "Paginated list of OpenSearch indices", + "properties": { + "indices": { + "type": "array", + "description": "Index list for current page", + "items": { + "$ref": "#/components/schemas/IndexInfoResponse" + } + }, + "totalIndices": { + "type": "integer", + "format": "int64", + "description": "Total number of indices" + }, + "totalDocs": { + "type": "integer", + "format": "int64", + "description": "Total document count across all indices" + }, + "totalSize": { + "type": "string", + "description": "Human-readable total size" + }, + "page": { + "type": "integer", + "format": "int32", + "description": "Current page number (0-based)" + }, + "pageSize": { + "type": "integer", + "format": "int32", + "description": "Page size" + }, + "totalPages": { + "type": "integer", + "format": "int32", + "description": "Total number of pages" + } + } + }, + "TableSizeResponse": { + "type": "object", + "description": "Table size and row count information", + "properties": { + "tableName": { + "type": "string", + "description": "Table name" + }, + "rowCount": { + "type": "integer", + "format": "int64", + "description": "Approximate row count" + }, + "dataSize": { + "type": "string", + "description": "Human-readable data size" + }, + "indexSize": { + "type": "string", + "description": "Human-readable index size" + }, + "dataSizeBytes": { + "type": "integer", + "format": "int64", + "description": "Data size in bytes" + }, + "indexSizeBytes": { + "type": "integer", + "format": "int64", + "description": "Index size in bytes" + } + } + }, + "DatabaseStatusResponse": { + "type": "object", + "description": "Database connection and version status", + "properties": { + "connected": { + "type": "boolean", + "description": "Whether the database is reachable" + }, + "version": { + "type": "string", + "description": "PostgreSQL version string" + }, + "host": { + "type": "string", + "description": "Database host" + }, + "schema": { + "type": "string", + "description": "Current schema search path" + }, + "timescaleDb": { + "type": "boolean", + "description": "Whether TimescaleDB extension is available" + } + } + }, + "ActiveQueryResponse": { + "type": "object", + "description": "Currently running database query", + "properties": { + "pid": { + "type": "integer", + "format": "int32", + "description": "Backend process ID" + }, + "durationSeconds": { + "type": "number", + "format": "double", + "description": "Query duration in seconds" + }, + "state": { + "type": "string", + "description": "Backend state (active, idle, etc.)" + }, + "query": { + "type": "string", + "description": "SQL query text" + } + } + }, + "ConnectionPoolResponse": { + "type": "object", + "description": "HikariCP connection pool statistics", + "properties": { + "activeConnections": { + "type": "integer", + "format": "int32", + "description": "Number of currently active connections" + }, + "idleConnections": { + "type": "integer", + "format": "int32", + "description": "Number of idle connections" + }, + "pendingThreads": { + "type": "integer", + "format": "int32", + "description": "Number of threads waiting for a connection" + }, + "maxWaitMs": { + "type": "integer", + "format": "int64", + "description": "Maximum wait time in milliseconds" + }, + "maxPoolSize": { + "type": "integer", + "format": "int32", + "description": "Maximum pool size" + } + } + }, + "AuditLogPageResponse": { + "type": "object", + "description": "Paginated audit log entries", + "properties": { + "items": { + "type": "array", + "description": "Audit log entries", + "items": { + "$ref": "#/components/schemas/AuditRecord" + } + }, + "totalCount": { + "type": "integer", + "format": "int64", + "description": "Total number of matching entries" + }, + "page": { + "type": "integer", + "format": "int32", + "description": "Current page number (0-based)" + }, + "pageSize": { + "type": "integer", + "format": "int32", + "description": "Page size" + }, + "totalPages": { + "type": "integer", + "format": "int32", + "description": "Total number of pages" + } + } + }, + "AuditRecord": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "username": { + "type": "string" + }, + "action": { + "type": "string" + }, + "category": { + "type": "string", + "enum": [ + "INFRA", + "AUTH", + "USER_MGMT", + "CONFIG" + ] + }, + "target": { + "type": "string" + }, + "detail": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "result": { + "type": "string", + "enum": [ + "SUCCESS", + "FAILURE" + ] + }, + "ipAddress": { + "type": "string" + }, + "userAgent": { + "type": "string" + } + } } }, "securitySchemes": { diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index 8c48a1b4..69715ba9 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -21,6 +21,24 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/thresholds": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get current threshold configuration */ + get: operations["getThresholds"]; + /** Update threshold configuration */ + put: operations["updateThresholds"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/oidc": { parameters: { query?: never; @@ -326,6 +344,23 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/database/queries/{pid}/kill": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Terminate a query by PID */ + post: operations["killQuery"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/search/stats": { parameters: { query?: never; @@ -526,6 +561,176 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/opensearch/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get OpenSearch cluster status and version */ + get: operations["getStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/opensearch/pipeline": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get indexing pipeline statistics */ + get: operations["getPipeline"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/opensearch/performance": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get OpenSearch performance metrics */ + get: operations["getPerformance"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/opensearch/indices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get OpenSearch indices with pagination */ + get: operations["getIndices"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/database/tables": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get table sizes and row counts */ + get: operations["getTables"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/database/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get database connection status and version */ + get: operations["getStatus_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/database/queries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get active queries */ + get: operations["getQueries"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/database/pool": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get HikariCP connection pool stats */ + get: operations["getPool"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/audit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Search audit log entries with pagination */ + get: operations["getAuditLog"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/opensearch/indices/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete an OpenSearch index */ + delete: operations["deleteIndex"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -533,6 +738,101 @@ export interface components { RolesRequest: { roles?: string[]; }; + /** @description Database monitoring thresholds */ + DatabaseThresholdsRequest: { + /** + * Format: int32 + * @description Connection pool usage warning threshold (percentage) + */ + connectionPoolWarning?: number; + /** + * Format: int32 + * @description Connection pool usage critical threshold (percentage) + */ + connectionPoolCritical?: number; + /** + * Format: double + * @description Query duration warning threshold (seconds) + */ + queryDurationWarning?: number; + /** + * Format: double + * @description Query duration critical threshold (seconds) + */ + queryDurationCritical?: number; + }; + /** @description OpenSearch monitoring thresholds */ + OpenSearchThresholdsRequest: { + /** @description Cluster health warning threshold (GREEN, YELLOW, RED) */ + clusterHealthWarning?: string; + /** @description Cluster health critical threshold (GREEN, YELLOW, RED) */ + clusterHealthCritical?: string; + /** + * Format: int32 + * @description Queue depth warning threshold + */ + queueDepthWarning?: number; + /** + * Format: int32 + * @description Queue depth critical threshold + */ + queueDepthCritical?: number; + /** + * Format: int32 + * @description JVM heap usage warning threshold (percentage) + */ + jvmHeapWarning?: number; + /** + * Format: int32 + * @description JVM heap usage critical threshold (percentage) + */ + jvmHeapCritical?: number; + /** + * Format: int32 + * @description Failed document count warning threshold + */ + failedDocsWarning?: number; + /** + * Format: int32 + * @description Failed document count critical threshold + */ + failedDocsCritical?: number; + }; + /** @description Threshold configuration for admin monitoring */ + ThresholdConfigRequest: { + database: components["schemas"]["DatabaseThresholdsRequest"]; + opensearch: components["schemas"]["OpenSearchThresholdsRequest"]; + }; + DatabaseThresholds: { + /** Format: int32 */ + connectionPoolWarning?: number; + /** Format: int32 */ + connectionPoolCritical?: number; + /** Format: double */ + queryDurationWarning?: number; + /** Format: double */ + queryDurationCritical?: number; + }; + OpenSearchThresholds: { + clusterHealthWarning?: string; + clusterHealthCritical?: string; + /** Format: int32 */ + queueDepthWarning?: number; + /** Format: int32 */ + queueDepthCritical?: number; + /** Format: int32 */ + jvmHeapWarning?: number; + /** Format: int32 */ + jvmHeapCritical?: number; + /** Format: int32 */ + failedDocsWarning?: number; + /** Format: int32 */ + failedDocsCritical?: number; + }; + ThresholdConfig: { + database?: components["schemas"]["DatabaseThresholds"]; + opensearch?: components["schemas"]["OpenSearchThresholds"]; + }; /** @description OIDC configuration update request */ OidcAdminConfigRequest: { enabled?: boolean; @@ -816,6 +1116,279 @@ export interface components { /** Format: date-time */ createdAt: string; }; + /** @description OpenSearch cluster status */ + OpenSearchStatusResponse: { + /** @description Whether the cluster is reachable */ + reachable?: boolean; + /** @description Cluster health status (GREEN, YELLOW, RED) */ + clusterHealth?: string; + /** @description OpenSearch version */ + version?: string; + /** + * Format: int32 + * @description Number of nodes in the cluster + */ + nodeCount?: number; + /** @description OpenSearch host */ + host?: string; + }; + /** @description Search indexing pipeline statistics */ + PipelineStatsResponse: { + /** + * Format: int32 + * @description Current queue depth + */ + queueDepth?: number; + /** + * Format: int32 + * @description Maximum queue size + */ + maxQueueSize?: number; + /** + * Format: int64 + * @description Number of failed indexing operations + */ + failedCount?: number; + /** + * Format: int64 + * @description Number of successfully indexed documents + */ + indexedCount?: number; + /** + * Format: int64 + * @description Debounce interval in milliseconds + */ + debounceMs?: number; + /** + * Format: double + * @description Current indexing rate (docs/sec) + */ + indexingRate?: number; + /** + * Format: date-time + * @description Timestamp of last indexed document + */ + lastIndexedAt?: string; + }; + /** @description OpenSearch performance metrics */ + PerformanceResponse: { + /** + * Format: double + * @description Query cache hit rate (0.0-1.0) + */ + queryCacheHitRate?: number; + /** + * Format: double + * @description Request cache hit rate (0.0-1.0) + */ + requestCacheHitRate?: number; + /** + * Format: double + * @description Average search latency in milliseconds + */ + searchLatencyMs?: number; + /** + * Format: double + * @description Average indexing latency in milliseconds + */ + indexingLatencyMs?: number; + /** + * Format: int64 + * @description JVM heap used in bytes + */ + jvmHeapUsedBytes?: number; + /** + * Format: int64 + * @description JVM heap max in bytes + */ + jvmHeapMaxBytes?: number; + }; + /** @description OpenSearch index information */ + IndexInfoResponse: { + /** @description Index name */ + name?: string; + /** + * Format: int64 + * @description Document count + */ + docCount?: number; + /** @description Human-readable index size */ + size?: string; + /** + * Format: int64 + * @description Index size in bytes + */ + sizeBytes?: number; + /** @description Index health status */ + health?: string; + /** + * Format: int32 + * @description Number of primary shards + */ + primaryShards?: number; + /** + * Format: int32 + * @description Number of replica shards + */ + replicaShards?: number; + }; + /** @description Paginated list of OpenSearch indices */ + IndicesPageResponse: { + /** @description Index list for current page */ + indices?: components["schemas"]["IndexInfoResponse"][]; + /** + * Format: int64 + * @description Total number of indices + */ + totalIndices?: number; + /** + * Format: int64 + * @description Total document count across all indices + */ + totalDocs?: number; + /** @description Human-readable total size */ + totalSize?: string; + /** + * Format: int32 + * @description Current page number (0-based) + */ + page?: number; + /** + * Format: int32 + * @description Page size + */ + pageSize?: number; + /** + * Format: int32 + * @description Total number of pages + */ + totalPages?: number; + }; + /** @description Table size and row count information */ + TableSizeResponse: { + /** @description Table name */ + tableName?: string; + /** + * Format: int64 + * @description Approximate row count + */ + rowCount?: number; + /** @description Human-readable data size */ + dataSize?: string; + /** @description Human-readable index size */ + indexSize?: string; + /** + * Format: int64 + * @description Data size in bytes + */ + dataSizeBytes?: number; + /** + * Format: int64 + * @description Index size in bytes + */ + indexSizeBytes?: number; + }; + /** @description Database connection and version status */ + DatabaseStatusResponse: { + /** @description Whether the database is reachable */ + connected?: boolean; + /** @description PostgreSQL version string */ + version?: string; + /** @description Database host */ + host?: string; + /** @description Current schema search path */ + schema?: string; + /** @description Whether TimescaleDB extension is available */ + timescaleDb?: boolean; + }; + /** @description Currently running database query */ + ActiveQueryResponse: { + /** + * Format: int32 + * @description Backend process ID + */ + pid?: number; + /** + * Format: double + * @description Query duration in seconds + */ + durationSeconds?: number; + /** @description Backend state (active, idle, etc.) */ + state?: string; + /** @description SQL query text */ + query?: string; + }; + /** @description HikariCP connection pool statistics */ + ConnectionPoolResponse: { + /** + * Format: int32 + * @description Number of currently active connections + */ + activeConnections?: number; + /** + * Format: int32 + * @description Number of idle connections + */ + idleConnections?: number; + /** + * Format: int32 + * @description Number of threads waiting for a connection + */ + pendingThreads?: number; + /** + * Format: int64 + * @description Maximum wait time in milliseconds + */ + maxWaitMs?: number; + /** + * Format: int32 + * @description Maximum pool size + */ + maxPoolSize?: number; + }; + /** @description Paginated audit log entries */ + AuditLogPageResponse: { + /** @description Audit log entries */ + items?: components["schemas"]["AuditRecord"][]; + /** + * Format: int64 + * @description Total number of matching entries + */ + totalCount?: number; + /** + * Format: int32 + * @description Current page number (0-based) + */ + page?: number; + /** + * Format: int32 + * @description Page size + */ + pageSize?: number; + /** + * Format: int32 + * @description Total number of pages + */ + totalPages?: number; + }; + AuditRecord: { + /** Format: int64 */ + id?: number; + /** Format: date-time */ + timestamp?: string; + username?: string; + action?: string; + /** @enum {string} */ + category?: "INFRA" | "AUTH" | "USER_MGMT" | "CONFIG"; + target?: string; + detail?: { + [key: string]: Record; + }; + /** @enum {string} */ + result?: "SUCCESS" | "FAILURE"; + ipAddress?: string; + userAgent?: string; + }; }; responses: never; parameters: never; @@ -856,6 +1429,50 @@ export interface operations { }; }; }; + getThresholds: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ThresholdConfig"]; + }; + }; + }; + }; + updateThresholds: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ThresholdConfigRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ThresholdConfig"]; + }; + }; + }; + }; getConfig: { parameters: { query?: never; @@ -1034,13 +1651,6 @@ export interface operations { }; content?: never; }; - /** @description Buffer full, retry later */ - 503: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; }; }; ingestDiagrams: { @@ -1063,13 +1673,6 @@ export interface operations { }; content?: never; }; - /** @description Buffer full, retry later */ - 503: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; }; }; refresh: { @@ -1471,6 +2074,26 @@ export interface operations { }; }; }; + killQuery: { + parameters: { + query?: never; + header?: never; + path: { + pid: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; stats: { parameters: { query: { @@ -1615,7 +2238,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + "*/*": components["schemas"]["DiagramLayout"]; + }; }; }; }; @@ -1826,4 +2451,218 @@ export interface operations { }; }; }; + getStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["OpenSearchStatusResponse"]; + }; + }; + }; + }; + getPipeline: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["PipelineStatsResponse"]; + }; + }; + }; + }; + getPerformance: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["PerformanceResponse"]; + }; + }; + }; + }; + getIndices: { + parameters: { + query?: { + page?: number; + size?: number; + search?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IndicesPageResponse"]; + }; + }; + }; + }; + getTables: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["TableSizeResponse"][]; + }; + }; + }; + }; + getStatus_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["DatabaseStatusResponse"]; + }; + }; + }; + }; + getQueries: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ActiveQueryResponse"][]; + }; + }; + }; + }; + getPool: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ConnectionPoolResponse"]; + }; + }; + }; + }; + getAuditLog: { + parameters: { + query?: { + username?: string; + category?: string; + search?: string; + from?: string; + to?: string; + sort?: string; + order?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AuditLogPageResponse"]; + }; + }; + }; + }; + deleteIndex: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; }
NameHealthDocsSize Shards
{idx.docsCount.toLocaleString()}{idx.storeSize}{idx.primaryShards}p / {idx.replicas}r{idx.docCount.toLocaleString()}{idx.size}{idx.primaryShards}p / {idx.replicaShards}r onSort(col)} - > - {label} - {isActive && {dir === 'asc' ? ' \u25B2' : ' \u25BC'}} -