Merge pull request 'feature/admin-infrastructure' (#79) from feature/admin-infrastructure into main
All checks were successful
CI / build (push) Successful in 1m10s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 16s
CI / deploy (push) Successful in 37s
CI / deploy-feature (push) Has been skipped

Reviewed-on: cameleer/cameleer3-server#79
This commit is contained in:
2026-03-17 16:51:10 +01:00
64 changed files with 7415 additions and 58 deletions

View File

@@ -1,5 +1,7 @@
package com.cameleer3.server.app.config; 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.detail.DetailService;
import com.cameleer3.server.core.indexing.SearchIndexer; import com.cameleer3.server.core.indexing.SearchIndexer;
import com.cameleer3.server.core.ingestion.IngestionService; import com.cameleer3.server.core.ingestion.IngestionService;
@@ -25,6 +27,11 @@ public class StorageBeanConfig {
return new SearchIndexer(executionStore, searchIndex, debounceMs, queueSize); return new SearchIndexer(executionStore, searchIndex, debounceMs, queueSize);
} }
@Bean
public AuditService auditService(AuditRepository auditRepository) {
return new AuditService(auditRepository);
}
@Bean @Bean
public IngestionService ingestionService(ExecutionStore executionStore, public IngestionService ingestionService(ExecutionStore executionStore,
DiagramStore diagramStore, DiagramStore diagramStore,

View File

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

View File

@@ -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<DatabaseStatusResponse> 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<ConnectionPoolResponse> 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<List<TableSizeResponse>> 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<List<ActiveQueryResponse>> 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<Void> 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";
}
}
}

View File

@@ -5,8 +5,12 @@ import com.cameleer3.server.app.dto.OidcAdminConfigRequest;
import com.cameleer3.server.app.dto.OidcAdminConfigResponse; import com.cameleer3.server.app.dto.OidcAdminConfigResponse;
import com.cameleer3.server.app.dto.OidcTestResult; import com.cameleer3.server.app.dto.OidcTestResult;
import com.cameleer3.server.app.security.OidcTokenExchanger; 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.OidcConfig;
import com.cameleer3.server.core.security.OidcConfigRepository; 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.Operation;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
@@ -16,6 +20,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; 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.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; 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 org.springframework.web.server.ResponseStatusException;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -35,17 +41,21 @@ import java.util.Optional;
@RestController @RestController
@RequestMapping("/api/v1/admin/oidc") @RequestMapping("/api/v1/admin/oidc")
@Tag(name = "OIDC Config Admin", description = "OIDC provider configuration (ADMIN only)") @Tag(name = "OIDC Config Admin", description = "OIDC provider configuration (ADMIN only)")
@PreAuthorize("hasRole('ADMIN')")
public class OidcConfigAdminController { public class OidcConfigAdminController {
private static final Logger log = LoggerFactory.getLogger(OidcConfigAdminController.class); private static final Logger log = LoggerFactory.getLogger(OidcConfigAdminController.class);
private final OidcConfigRepository configRepository; private final OidcConfigRepository configRepository;
private final OidcTokenExchanger tokenExchanger; private final OidcTokenExchanger tokenExchanger;
private final AuditService auditService;
public OidcConfigAdminController(OidcConfigRepository configRepository, public OidcConfigAdminController(OidcConfigRepository configRepository,
OidcTokenExchanger tokenExchanger) { OidcTokenExchanger tokenExchanger,
AuditService auditService) {
this.configRepository = configRepository; this.configRepository = configRepository;
this.tokenExchanger = tokenExchanger; this.tokenExchanger = tokenExchanger;
this.auditService = auditService;
} }
@GetMapping @GetMapping
@@ -64,7 +74,8 @@ public class OidcConfigAdminController {
@ApiResponse(responseCode = "200", description = "Configuration saved") @ApiResponse(responseCode = "200", description = "Configuration saved")
@ApiResponse(responseCode = "400", description = "Invalid configuration", @ApiResponse(responseCode = "400", description = "Invalid configuration",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))) content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<OidcAdminConfigResponse> saveConfig(@RequestBody OidcAdminConfigRequest request) { public ResponseEntity<OidcAdminConfigResponse> saveConfig(@RequestBody OidcAdminConfigRequest request,
HttpServletRequest httpRequest) {
// Resolve client_secret: if masked or empty, preserve existing // Resolve client_secret: if masked or empty, preserve existing
String clientSecret = request.clientSecret(); String clientSecret = request.clientSecret();
if (clientSecret == null || clientSecret.isBlank() || clientSecret.equals("********")) { if (clientSecret == null || clientSecret.isBlank() || clientSecret.equals("********")) {
@@ -95,6 +106,7 @@ public class OidcConfigAdminController {
configRepository.save(config); configRepository.save(config);
tokenExchanger.invalidateCache(); 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()); log.info("OIDC configuration updated: enabled={}, issuer={}", config.enabled(), config.issuerUri());
return ResponseEntity.ok(OidcAdminConfigResponse.from(config)); return ResponseEntity.ok(OidcAdminConfigResponse.from(config));
} }
@@ -104,7 +116,7 @@ public class OidcConfigAdminController {
@ApiResponse(responseCode = "200", description = "Provider reachable") @ApiResponse(responseCode = "200", description = "Provider reachable")
@ApiResponse(responseCode = "400", description = "Provider unreachable or misconfigured", @ApiResponse(responseCode = "400", description = "Provider unreachable or misconfigured",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))) content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<OidcTestResult> testConnection() { public ResponseEntity<OidcTestResult> testConnection(HttpServletRequest httpRequest) {
Optional<OidcConfig> config = configRepository.find(); Optional<OidcConfig> config = configRepository.find();
if (config.isEmpty() || !config.get().enabled()) { if (config.isEmpty() || !config.get().enabled()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
@@ -114,6 +126,7 @@ public class OidcConfigAdminController {
try { try {
tokenExchanger.invalidateCache(); tokenExchanger.invalidateCache();
String authEndpoint = tokenExchanger.getAuthorizationEndpoint(); String authEndpoint = tokenExchanger.getAuthorizationEndpoint();
auditService.log("test_oidc", AuditCategory.CONFIG, "oidc", null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(new OidcTestResult("ok", authEndpoint)); return ResponseEntity.ok(new OidcTestResult("ok", authEndpoint));
} catch (Exception e) { } catch (Exception e) {
log.warn("OIDC connectivity test failed: {}", e.getMessage()); log.warn("OIDC connectivity test failed: {}", e.getMessage());
@@ -125,9 +138,10 @@ public class OidcConfigAdminController {
@DeleteMapping @DeleteMapping
@Operation(summary = "Delete OIDC configuration") @Operation(summary = "Delete OIDC configuration")
@ApiResponse(responseCode = "204", description = "Configuration deleted") @ApiResponse(responseCode = "204", description = "Configuration deleted")
public ResponseEntity<Void> deleteConfig() { public ResponseEntity<Void> deleteConfig(HttpServletRequest httpRequest) {
configRepository.delete(); configRepository.delete();
tokenExchanger.invalidateCache(); tokenExchanger.invalidateCache();
auditService.log("delete_oidc", AuditCategory.CONFIG, "oidc", null, AuditResult.SUCCESS, httpRequest);
log.info("OIDC configuration deleted"); log.info("OIDC configuration deleted");
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }

View File

@@ -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<OpenSearchStatusResponse> 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<PipelineStatsResponse> 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<IndicesPageResponse> 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<IndexInfoResponse> 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<IndexInfoResponse> 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<Void> 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<PerformanceResponse> 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));
}
}

View File

@@ -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<ThresholdConfig> getThresholds() {
ThresholdConfig config = thresholdRepository.find().orElse(ThresholdConfig.defaults());
return ResponseEntity.ok(config);
}
@PutMapping
@Operation(summary = "Update threshold configuration")
public ResponseEntity<ThresholdConfig> updateThresholds(@Valid @RequestBody ThresholdConfigRequest request,
HttpServletRequest httpRequest) {
List<String> 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);
}
}

View File

@@ -1,11 +1,16 @@
package com.cameleer3.server.app.controller; 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.UserInfo;
import com.cameleer3.server.core.security.UserRepository; 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.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity; 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.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; 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 org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* Admin endpoints for user management. * Admin endpoints for user management.
@@ -23,12 +29,15 @@ import java.util.List;
@RestController @RestController
@RequestMapping("/api/v1/admin/users") @RequestMapping("/api/v1/admin/users")
@Tag(name = "User Admin", description = "User management (ADMIN only)") @Tag(name = "User Admin", description = "User management (ADMIN only)")
@PreAuthorize("hasRole('ADMIN')")
public class UserAdminController { public class UserAdminController {
private final UserRepository userRepository; private final UserRepository userRepository;
private final AuditService auditService;
public UserAdminController(UserRepository userRepository) { public UserAdminController(UserRepository userRepository, AuditService auditService) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.auditService = auditService;
} }
@GetMapping @GetMapping
@@ -53,19 +62,25 @@ public class UserAdminController {
@ApiResponse(responseCode = "200", description = "Roles updated") @ApiResponse(responseCode = "200", description = "Roles updated")
@ApiResponse(responseCode = "404", description = "User not found") @ApiResponse(responseCode = "404", description = "User not found")
public ResponseEntity<Void> updateRoles(@PathVariable String userId, public ResponseEntity<Void> updateRoles(@PathVariable String userId,
@RequestBody RolesRequest request) { @RequestBody RolesRequest request,
HttpServletRequest httpRequest) {
if (userRepository.findById(userId).isEmpty()) { if (userRepository.findById(userId).isEmpty()) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
userRepository.updateRoles(userId, request.roles()); 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(); return ResponseEntity.ok().build();
} }
@DeleteMapping("/{userId}") @DeleteMapping("/{userId}")
@Operation(summary = "Delete user") @Operation(summary = "Delete user")
@ApiResponse(responseCode = "204", description = "User deleted") @ApiResponse(responseCode = "204", description = "User deleted")
public ResponseEntity<Void> deleteUser(@PathVariable String userId) { public ResponseEntity<Void> deleteUser(@PathVariable String userId,
HttpServletRequest httpRequest) {
userRepository.delete(userId); userRepository.delete(userId);
auditService.log("delete_user", AuditCategory.USER_MGMT, userId,
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String> validate() {
List<String> 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<String, Integer> 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);
}
}

View File

@@ -3,11 +3,15 @@ package com.cameleer3.server.app.security;
import com.cameleer3.server.app.dto.AuthTokenResponse; import com.cameleer3.server.app.dto.AuthTokenResponse;
import com.cameleer3.server.app.dto.ErrorResponse; import com.cameleer3.server.app.dto.ErrorResponse;
import com.cameleer3.server.app.dto.OidcPublicConfigResponse; 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.JwtService;
import com.cameleer3.server.core.security.OidcConfig; import com.cameleer3.server.core.security.OidcConfig;
import com.cameleer3.server.core.security.OidcConfigRepository; import com.cameleer3.server.core.security.OidcConfigRepository;
import com.cameleer3.server.core.security.UserInfo; import com.cameleer3.server.core.security.UserInfo;
import com.cameleer3.server.core.security.UserRepository; 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.Operation;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
@@ -27,6 +31,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.net.URI; import java.net.URI;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -46,15 +51,18 @@ public class OidcAuthController {
private final OidcConfigRepository configRepository; private final OidcConfigRepository configRepository;
private final JwtService jwtService; private final JwtService jwtService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final AuditService auditService;
public OidcAuthController(OidcTokenExchanger tokenExchanger, public OidcAuthController(OidcTokenExchanger tokenExchanger,
OidcConfigRepository configRepository, OidcConfigRepository configRepository,
JwtService jwtService, JwtService jwtService,
UserRepository userRepository) { UserRepository userRepository,
AuditService auditService) {
this.tokenExchanger = tokenExchanger; this.tokenExchanger = tokenExchanger;
this.configRepository = configRepository; this.configRepository = configRepository;
this.jwtService = jwtService; this.jwtService = jwtService;
this.userRepository = userRepository; this.userRepository = userRepository;
this.auditService = auditService;
} }
/** /**
@@ -100,7 +108,8 @@ public class OidcAuthController {
@ApiResponse(responseCode = "403", description = "Account not provisioned", @ApiResponse(responseCode = "403", description = "Account not provisioned",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))) content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@ApiResponse(responseCode = "404", description = "OIDC not configured or disabled") @ApiResponse(responseCode = "404", description = "OIDC not configured or disabled")
public ResponseEntity<AuthTokenResponse> callback(@RequestBody CallbackRequest request) { public ResponseEntity<AuthTokenResponse> callback(@RequestBody CallbackRequest request,
HttpServletRequest httpRequest) {
Optional<OidcConfig> config = configRepository.find(); Optional<OidcConfig> config = configRepository.find();
if (config.isEmpty() || !config.get().enabled()) { if (config.isEmpty() || !config.get().enabled()) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
@@ -132,6 +141,8 @@ public class OidcAuthController {
String displayName = oidcUser.name() != null && !oidcUser.name().isBlank() String displayName = oidcUser.name() != null && !oidcUser.name().isBlank()
? oidcUser.name() : oidcUser.email(); ? 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())); return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName, oidcUser.idToken()));
} catch (ResponseStatusException e) { } catch (ResponseStatusException e) {
throw e; throw e;

View File

@@ -6,6 +6,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; 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.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
@@ -27,6 +28,7 @@ import java.util.List;
*/ */
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig { public class SecurityConfig {
@Bean @Bean

View File

@@ -2,7 +2,11 @@ package com.cameleer3.server.app.security;
import com.cameleer3.server.app.dto.AuthTokenResponse; import com.cameleer3.server.app.dto.AuthTokenResponse;
import com.cameleer3.server.app.dto.ErrorResponse; 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 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.JwtService.JwtValidationResult;
import com.cameleer3.server.core.security.UserInfo; import com.cameleer3.server.core.security.UserInfo;
import com.cameleer3.server.core.security.UserRepository; import com.cameleer3.server.core.security.UserRepository;
@@ -23,6 +27,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* Authentication endpoints for the UI (local credentials). * Authentication endpoints for the UI (local credentials).
@@ -41,12 +46,14 @@ public class UiAuthController {
private final JwtService jwtService; private final JwtService jwtService;
private final SecurityProperties properties; private final SecurityProperties properties;
private final UserRepository userRepository; private final UserRepository userRepository;
private final AuditService auditService;
public UiAuthController(JwtService jwtService, SecurityProperties properties, public UiAuthController(JwtService jwtService, SecurityProperties properties,
UserRepository userRepository) { UserRepository userRepository, AuditService auditService) {
this.jwtService = jwtService; this.jwtService = jwtService;
this.properties = properties; this.properties = properties;
this.userRepository = userRepository; this.userRepository = userRepository;
this.auditService = auditService;
} }
@PostMapping("/login") @PostMapping("/login")
@@ -54,19 +61,24 @@ public class UiAuthController {
@ApiResponse(responseCode = "200", description = "Login successful") @ApiResponse(responseCode = "200", description = "Login successful")
@ApiResponse(responseCode = "401", description = "Invalid credentials", @ApiResponse(responseCode = "401", description = "Invalid credentials",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))) content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<AuthTokenResponse> login(@RequestBody LoginRequest request) { public ResponseEntity<AuthTokenResponse> login(@RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
String configuredUser = properties.getUiUser(); String configuredUser = properties.getUiUser();
String configuredPassword = properties.getUiPassword(); String configuredPassword = properties.getUiPassword();
if (configuredUser == null || configuredUser.isBlank() if (configuredUser == null || configuredUser.isBlank()
|| configuredPassword == null || configuredPassword.isBlank()) { || configuredPassword == null || configuredPassword.isBlank()) {
log.warn("UI authentication attempted but CAMELEER_UI_USER / CAMELEER_UI_PASSWORD not configured"); 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"); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "UI authentication not configured");
} }
if (!configuredUser.equals(request.username()) if (!configuredUser.equals(request.username())
|| !configuredPassword.equals(request.password())) { || !configuredPassword.equals(request.password())) {
log.debug("UI login failed for user: {}", request.username()); 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"); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials");
} }
@@ -84,6 +96,7 @@ public class UiAuthController {
String accessToken = jwtService.createAccessToken(subject, "user", roles); String accessToken = jwtService.createAccessToken(subject, "user", roles);
String refreshToken = jwtService.createRefreshToken(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()); log.info("UI user logged in: {}", request.username());
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, request.username(), null)); return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, request.username(), null));
} }

View File

@@ -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<String> 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<Object> 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<Object> dataParams = new ArrayList<>(params);
dataParams.add(pageSize);
dataParams.add(offset);
List<AuditRecord> 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<String, Object> 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")
);
}
}

View File

@@ -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<ThresholdConfig> find() {
List<ThresholdConfig> 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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String> 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<String> 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<String> 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<String> response = restTemplate.exchange(
"/api/v1/admin/thresholds", HttpMethod.PUT,
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
}

View File

@@ -27,6 +27,16 @@
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId> <artifactId>slf4j-api</artifactId>
</dependency> </dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
<scope>provided</scope>
</dependency>
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <artifactId>junit-jupiter</artifactId>

View File

@@ -0,0 +1,5 @@
package com.cameleer3.server.core.admin;
public enum AuditCategory {
INFRA, AUTH, USER_MGMT, CONFIG
}

View File

@@ -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<String, Object> 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<String, Object> detail, AuditResult result,
String ipAddress, String userAgent) {
return new AuditRecord(0, null, username, action, category, target, detail, result, ipAddress, userAgent);
}
}

View File

@@ -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<AuditRecord> items, long totalCount) {}
AuditPage find(AuditQuery query);
}

View File

@@ -0,0 +1,5 @@
package com.cameleer3.server.core.admin;
public enum AuditResult {
SUCCESS, FAILURE
}

View File

@@ -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<String, Object> 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<String, Object> 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";
}
}

View File

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

View File

@@ -0,0 +1,8 @@
package com.cameleer3.server.core.admin;
import java.util.Optional;
public interface ThresholdRepository {
Optional<ThresholdConfig> find();
void save(ThresholdConfig config, String updatedBy);
}

View File

@@ -9,11 +9,13 @@ import com.cameleer3.server.core.storage.model.ExecutionDocument.ProcessorDoc;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.*; 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); private static final Logger log = LoggerFactory.getLogger(SearchIndexer.class);
@@ -26,6 +28,14 @@ public class SearchIndexer {
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor( private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(
r -> { Thread t = new Thread(r, "search-indexer"); t.setDaemon(true); return t; }); 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, public SearchIndexer(ExecutionStore executionStore, SearchIndex searchIndex,
long debounceMs, int queueCapacity) { long debounceMs, int queueCapacity) {
this.executionStore = executionStore; this.executionStore = executionStore;
@@ -68,11 +78,63 @@ public class SearchIndexer {
exec.status(), exec.correlationId(), exec.exchangeId(), exec.status(), exec.correlationId(), exec.exchangeId(),
exec.startTime(), exec.endTime(), exec.durationMs(), exec.startTime(), exec.endTime(), exec.durationMs(),
exec.errorMessage(), exec.errorStacktrace(), processorDocs)); exec.errorMessage(), exec.errorStacktrace(), processorDocs));
indexedCount.incrementAndGet();
lastIndexedAt = Instant.now();
updateRate();
} catch (Exception e) { } catch (Exception e) {
failedCount.incrementAndGet();
log.error("Failed to index execution {}", executionId, e); 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() { public void shutdown() {
scheduler.shutdown(); scheduler.shutdown();
} }

View File

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

View File

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

View File

@@ -0,0 +1,566 @@
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-sans, sans-serif); background: transparent; }
.app { display: grid; grid-template-columns: 200px 1fr; grid-template-rows: 48px 1fr; height: 640px; border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-lg); overflow: hidden; }
/* Top bar */
.topbar { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; border-bottom: 0.5px solid var(--color-border-tertiary); background: var(--color-background-primary); }
.topbar-brand { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 500; color: var(--color-text-primary); }
.brand-dot { width: 8px; height: 8px; border-radius: 50%; background: #1D9E75; }
.topbar-actions { display: flex; align-items: center; gap: 8px; }
.badge-env { font-size: 11px; padding: 2px 8px; border-radius: 4px; background: var(--color-background-secondary); color: var(--color-text-secondary); border: 0.5px solid var(--color-border-tertiary); }
/* Sidebar */
.sidebar { background: var(--color-background-secondary); border-right: 0.5px solid var(--color-border-tertiary); padding: 12px 0; overflow-y: auto; }
.sidebar-section { margin-bottom: 20px; }
.sidebar-label { font-size: 10px; font-weight: 500; color: var(--color-text-tertiary); text-transform: uppercase; letter-spacing: 0.08em; padding: 0 16px; margin-bottom: 4px; }
.nav-item { display: flex; align-items: center; gap: 8px; padding: 7px 16px; font-size: 13px; color: var(--color-text-secondary); cursor: pointer; transition: background 0.1s; border-left: 2px solid transparent; }
.nav-item:hover { background: var(--color-background-primary); color: var(--color-text-primary); }
.nav-item.active { background: var(--color-background-primary); color: var(--color-text-primary); font-weight: 500; border-left-color: #1D9E75; }
.nav-icon { width: 14px; height: 14px; opacity: 0.6; flex-shrink: 0; }
.nav-item.active .nav-icon { opacity: 1; }
.nav-count { margin-left: auto; font-size: 11px; background: var(--color-background-tertiary); color: var(--color-text-tertiary); padding: 1px 6px; border-radius: 10px; }
/* Main content */
.main { background: var(--color-background-primary); overflow-y: auto; display: flex; flex-direction: column; }
/* Panels */
.panel { display: none; flex-direction: column; height: 100%; }
.panel.active { display: flex; }
/* Panel header */
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px 12px; border-bottom: 0.5px solid var(--color-border-tertiary); flex-shrink: 0; }
.panel-title { font-size: 15px; font-weight: 500; color: var(--color-text-primary); }
.panel-subtitle { font-size: 12px; color: var(--color-text-tertiary); margin-top: 2px; }
.btn-add { font-size: 12px; padding: 6px 12px; border: 0.5px solid var(--color-border-secondary); border-radius: var(--border-radius-md); background: transparent; color: var(--color-text-primary); cursor: pointer; display: flex; align-items: center; gap: 4px; }
.btn-add:hover { background: var(--color-background-secondary); }
/* Search bar */
.search-bar { padding: 10px 20px; border-bottom: 0.5px solid var(--color-border-tertiary); flex-shrink: 0; }
.search-input { width: 100%; padding: 6px 10px; font-size: 12px; border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-md); background: var(--color-background-secondary); color: var(--color-text-primary); outline: none; }
.search-input:focus { border-color: var(--color-border-primary); background: var(--color-background-primary); }
/* Entity list */
.entity-list { flex: 1; overflow-y: auto; }
.entity-item { display: flex; align-items: center; gap: 10px; padding: 10px 20px; border-bottom: 0.5px solid var(--color-border-tertiary); cursor: pointer; transition: background 0.1s; }
.entity-item:hover { background: var(--color-background-secondary); }
.entity-item.selected { background: var(--color-background-info); }
.avatar { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 500; flex-shrink: 0; }
.av-user { background: #E6F1FB; color: #0C447C; }
.av-group { background: #E1F5EE; color: #0F6E56; border-radius: 8px; }
.av-role { background: #FAEEDA; color: #633806; border-radius: 6px; }
.entity-info { flex: 1; min-width: 0; }
.entity-name { font-size: 13px; font-weight: 500; color: var(--color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.entity-meta { font-size: 11px; color: var(--color-text-tertiary); margin-top: 1px; }
.tag-list { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
.tag { font-size: 10px; padding: 1px 6px; border-radius: 4px; }
.tag-role { background: #FAEEDA; color: #633806; }
.tag-group { background: #E1F5EE; color: #0F6E56; }
.tag-inherited { opacity: 0.65; font-style: italic; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.status-active { background: #1D9E75; }
.status-inactive { background: var(--color-border-secondary); }
/* Detail pane (right side of entity) */
.split { display: flex; flex: 1; overflow: hidden; }
.list-pane { width: 52%; border-right: 0.5px solid var(--color-border-tertiary); display: flex; flex-direction: column; overflow: hidden; }
.detail-pane { flex: 1; overflow-y: auto; padding: 16px 18px; }
.detail-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--color-text-tertiary); font-size: 13px; gap: 8px; }
.detail-section { margin-bottom: 20px; }
.detail-section-title { font-size: 11px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.07em; color: var(--color-text-tertiary); margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; }
.detail-section-title span { font-size: 10px; color: var(--color-text-tertiary); text-transform: none; letter-spacing: 0; }
.chip { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; padding: 3px 8px; border-radius: 20px; border: 0.5px solid var(--color-border-tertiary); color: var(--color-text-secondary); background: var(--color-background-secondary); margin: 2px; }
.chip svg { width: 10px; height: 10px; opacity: 0.5; }
.chip.inherited { border-style: dashed; color: var(--color-text-tertiary); }
.chip-remove { cursor: pointer; opacity: 0.4; }
.chip-remove:hover { opacity: 0.9; }
.inherit-note { font-size: 11px; color: var(--color-text-tertiary); font-style: italic; margin-top: 6px; padding: 6px 8px; background: var(--color-background-secondary); border-radius: var(--border-radius-md); border-left: 2px solid #9FE1CB; }
.field-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.field-label { font-size: 11px; color: var(--color-text-tertiary); width: 70px; flex-shrink: 0; }
.field-val { font-size: 12px; color: var(--color-text-primary); }
.field-val.mono { font-family: var(--font-mono, monospace); font-size: 11px; color: var(--color-text-secondary); }
.detail-avatar { width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 500; margin-bottom: 12px; }
.detail-name { font-size: 16px; font-weight: 500; color: var(--color-text-primary); margin-bottom: 4px; }
.detail-email { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 12px; }
.divider { border: none; border-top: 0.5px solid var(--color-border-tertiary); margin: 12px 0; }
/* Breadcrumb tree */
.tree-row { display: flex; align-items: center; gap: 6px; padding: 5px 0; font-size: 12px; color: var(--color-text-secondary); }
.tree-indent { width: 16px; flex-shrink: 0; display: flex; justify-content: center; }
.tree-line { width: 1px; height: 16px; background: var(--color-border-tertiary); }
.tree-corner { width: 10px; height: 10px; border-left: 0.5px solid var(--color-border-tertiary); border-bottom: 0.5px solid var(--color-border-tertiary); border-bottom-left-radius: 2px; }
/* Overview panel */
.overview-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; padding: 16px 20px; }
.stat-card { background: var(--color-background-secondary); border-radius: var(--border-radius-md); padding: 14px; }
.stat-label { font-size: 11px; color: var(--color-text-tertiary); margin-bottom: 6px; }
.stat-value { font-size: 22px; font-weight: 500; color: var(--color-text-primary); line-height: 1; }
.stat-sub { font-size: 11px; color: var(--color-text-tertiary); margin-top: 4px; }
.inheritance-diagram { margin: 16px 20px 0; }
.inh-title { font-size: 11px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.07em; color: var(--color-text-tertiary); margin-bottom: 10px; }
.inh-row { display: flex; align-items: flex-start; gap: 0; }
.inh-col { flex: 1; }
.inh-col-title { font-size: 11px; font-weight: 500; color: var(--color-text-secondary); margin-bottom: 6px; text-align: center; }
.inh-arrow { width: 40px; display: flex; align-items: center; justify-content: center; padding-top: 22px; color: var(--color-text-tertiary); font-size: 14px; }
.inh-item { font-size: 11px; padding: 4px 8px; border-radius: var(--border-radius-md); border: 0.5px solid var(--color-border-tertiary); margin-bottom: 4px; color: var(--color-text-secondary); background: var(--color-background-secondary); text-align: center; }
.inh-item.group { border-color: #9FE1CB; color: #0F6E56; background: #E1F5EE; }
.inh-item.role { border-color: #FAC775; color: #633806; background: #FAEEDA; }
.inh-item.user { border-color: #B5D4F4; color: #0C447C; background: #E6F1FB; }
/* Tabs */
.tabs { display: flex; gap: 0; border-bottom: 0.5px solid var(--color-border-tertiary); margin-bottom: 14px; }
.tab { font-size: 12px; padding: 6px 12px; cursor: pointer; color: var(--color-text-secondary); border-bottom: 2px solid transparent; margin-bottom: -0.5px; }
.tab.active { color: var(--color-text-primary); border-bottom-color: #1D9E75; font-weight: 500; }
</style>
<div class="app">
<!-- Top bar -->
<div class="topbar">
<div class="topbar-brand">
<div class="brand-dot"></div>
Monitor RBAC
</div>
<div class="topbar-actions">
<span class="badge-env">production</span>
<div style="width:24px;height:24px;border-radius:50%;background:#E6F1FB;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500;color:#0C447C">A</div>
</div>
</div>
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-section">
<div class="sidebar-label">Overview</div>
<div class="nav-item" onclick="showPanel('overview')">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="5" height="5" rx="1"/><rect x="9" y="2" width="5" height="5" rx="1"/><rect x="2" y="9" width="5" height="5" rx="1"/><rect x="9" y="9" width="5" height="5" rx="1"/></svg>
Dashboard
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-label">Identity</div>
<div class="nav-item active" id="nav-users" onclick="showPanel('users')">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="5" r="3"/><path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6"/></svg>
Users
<span class="nav-count">8</span>
</div>
<div class="nav-item" id="nav-groups" onclick="showPanel('groups')">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="5.5" cy="5.5" r="2.5"/><circle cx="10.5" cy="5.5" r="2.5"/><path d="M1 14c0-2.5 2-4.5 4.5-4.5"/><path d="M15 14c0-2.5-2-4.5-4.5-4.5"/><path d="M5.5 9.5c0-2.5 2.25-4.5 5-4.5"/></svg>
Groups
<span class="nav-count">5</span>
</div>
<div class="nav-item" id="nav-roles" onclick="showPanel('roles')">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2l1.5 4H14l-3.5 2.5 1.5 4L8 10l-4 2.5 1.5-4L2 6h4.5z"/></svg>
Roles
<span class="nav-count">6</span>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-label">Audit</div>
<div class="nav-item" onclick="showPanel('overview')">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3h10v10H3z"/><path d="M6 7h4M6 10h2"/></svg>
Audit log
</div>
</div>
</nav>
<!-- Main area -->
<main class="main">
<!-- OVERVIEW PANEL -->
<div class="panel active" id="panel-overview">
<div class="panel-header">
<div><div class="panel-title">RBAC overview</div><div class="panel-subtitle">Inheritance model and system summary</div></div>
</div>
<div class="overview-grid">
<div class="stat-card"><div class="stat-label">Users</div><div class="stat-value">8</div><div class="stat-sub">6 active</div></div>
<div class="stat-card"><div class="stat-label">Groups</div><div class="stat-value">5</div><div class="stat-sub">Nested up to 3 levels</div></div>
<div class="stat-card"><div class="stat-label">Roles</div><div class="stat-value">6</div><div class="stat-sub">Direct + inherited</div></div>
</div>
<div class="inheritance-diagram">
<div class="inh-title">Inheritance model</div>
<div class="inh-row">
<div class="inh-col">
<div class="inh-col-title">Groups</div>
<div class="inh-item group">Engineering</div>
<div class="inh-item group" style="margin-left:10px;font-size:10px">→ Backend</div>
<div class="inh-item group" style="margin-left:10px;font-size:10px">→ Frontend</div>
<div class="inh-item group">Ops</div>
<div class="inh-item group">Admins</div>
</div>
<div class="inh-arrow"></div>
<div class="inh-col">
<div class="inh-col-title">Roles on groups</div>
<div class="inh-item role">viewer</div>
<div class="inh-item role">editor</div>
<div class="inh-item role">deployer</div>
<div class="inh-item role">admin</div>
</div>
<div class="inh-arrow"></div>
<div class="inh-col">
<div class="inh-col-title">Users inherit</div>
<div class="inh-item user">alice</div>
<div class="inh-item user">bob</div>
<div class="inh-item user">carol</div>
<div class="inh-item" style="font-size:10px;color:var(--color-text-tertiary)">+ 5 more…</div>
</div>
</div>
<div class="inherit-note" style="margin-top:12px">
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.
</div>
</div>
</div>
<!-- USERS PANEL -->
<div class="panel" id="panel-users">
<div class="panel-header">
<div><div class="panel-title">Users</div><div class="panel-subtitle">Manage identities, group membership and direct roles</div></div>
<button class="btn-add">+ Add user</button>
</div>
<div class="split">
<div class="list-pane">
<div class="search-bar"><input class="search-input" placeholder="Search users…" oninput="filterList(this,'user-items')"/></div>
<div class="entity-list" id="user-items">
<div class="entity-item selected" onclick="selectUser(this,'alice')">
<div class="avatar av-user">AL</div>
<div class="entity-info">
<div class="entity-name">Alice Lang</div>
<div class="entity-meta">alice@corp.io · Engineering → Backend</div>
<div class="tag-list"><span class="tag tag-role">admin</span><span class="tag tag-role tag-inherited">viewer</span><span class="tag tag-group">Backend</span></div>
</div>
<div class="status-dot status-active"></div>
</div>
<div class="entity-item" onclick="selectUser(this,'bob')">
<div class="avatar av-user">BK</div>
<div class="entity-info">
<div class="entity-name">Bob Kim</div>
<div class="entity-meta">bob@corp.io · Engineering → Frontend</div>
<div class="tag-list"><span class="tag tag-role tag-inherited">editor</span><span class="tag tag-group">Frontend</span></div>
</div>
<div class="status-dot status-active"></div>
</div>
<div class="entity-item" onclick="selectUser(this,'carol')">
<div class="avatar av-user">CS</div>
<div class="entity-info">
<div class="entity-name">Carol Sanz</div>
<div class="entity-meta">carol@corp.io · Ops</div>
<div class="tag-list"><span class="tag tag-role">deployer</span><span class="tag tag-role tag-inherited">viewer</span><span class="tag tag-group">Ops</span></div>
</div>
<div class="status-dot status-active"></div>
</div>
<div class="entity-item" onclick="selectUser(this,'dan')">
<div class="avatar av-user">DM</div>
<div class="entity-info">
<div class="entity-name">Dan Müller</div>
<div class="entity-meta">dan@corp.io · Admins</div>
<div class="tag-list"><span class="tag tag-role tag-inherited">admin</span><span class="tag tag-group">Admins</span></div>
</div>
<div class="status-dot status-active"></div>
</div>
<div class="entity-item" onclick="selectUser(this,'eve')">
<div class="avatar av-user" style="background:#FBEAF0;color:#72243E">EP</div>
<div class="entity-info">
<div class="entity-name">Eve Park</div>
<div class="entity-meta">eve@corp.io · Engineering → Backend</div>
<div class="tag-list"><span class="tag tag-role tag-inherited">editor</span><span class="tag tag-group">Backend</span></div>
</div>
<div class="status-dot status-active"></div>
</div>
<div class="entity-item" onclick="selectUser(this,'frank')">
<div class="avatar av-user" style="background:#F1EFE8;color:#444441">FR</div>
<div class="entity-info">
<div class="entity-name">Frank Rossi</div>
<div class="entity-meta">frank@corp.io · (no groups)</div>
<div class="tag-list"><span class="tag tag-role">viewer</span></div>
</div>
<div class="status-dot status-inactive"></div>
</div>
</div>
</div>
<div class="detail-pane" id="user-detail">
<!-- Alice default -->
<div class="detail-avatar av-user" style="width:44px;height:44px;font-size:15px">AL</div>
<div class="detail-name">Alice Lang</div>
<div class="detail-email">alice@corp.io</div>
<div class="field-row"><span class="field-label">Status</span><span class="field-val" style="color:#0F6E56;font-size:12px">● Active</span></div>
<div class="field-row"><span class="field-label">ID</span><span class="field-val mono">usr_01HX…4AF</span></div>
<div class="field-row"><span class="field-label">Created</span><span class="field-val">2024-03-12</span></div>
<hr class="divider">
<div class="detail-section">
<div class="detail-section-title">Group membership <span>direct only</span></div>
<span class="chip">
<svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2"><rect x="1" y="1" width="8" height="8" rx="1.5"/></svg>
Engineering
</span>
<span class="chip">
<svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2"><rect x="1" y="1" width="8" height="8" rx="1.5"/></svg>
Backend
<span style="opacity:0.4;margin-left:2px;font-size:9px">via Engineering</span>
</span>
</div>
<div class="detail-section">
<div class="detail-section-title">Effective roles <span>direct + inherited</span></div>
<span class="chip" style="border-color:#FAC775;color:#633806;background:#FAEEDA">admin</span>
<span class="chip inherited" style="border-color:#FAC775;color:#854F0B">
viewer
<span style="font-size:9px;opacity:0.6;margin-left:2px">↑ Engineering</span>
</span>
<span class="chip inherited" style="border-color:#FAC775;color:#854F0B">
editor
<span style="font-size:9px;opacity:0.6;margin-left:2px">↑ Backend</span>
</span>
<div class="inherit-note">Dashed roles are inherited transitively through group membership.</div>
</div>
<div class="detail-section">
<div class="detail-section-title">Group tree</div>
<div class="tree-row"><div class="tree-indent"></div>Engineering</div>
<div class="tree-row"><div class="tree-indent"><div class="tree-corner"></div></div>Backend <span style="font-size:10px;color:var(--color-text-tertiary);margin-left:4px">child group</span></div>
</div>
</div>
</div>
</div>
<!-- GROUPS PANEL -->
<div class="panel" id="panel-groups">
<div class="panel-header">
<div><div class="panel-title">Groups</div><div class="panel-subtitle">Organise users in nested hierarchies; roles propagate to all members</div></div>
<button class="btn-add">+ Add group</button>
</div>
<div class="split">
<div class="list-pane">
<div class="search-bar"><input class="search-input" placeholder="Search groups…"/></div>
<div class="entity-list">
<div class="entity-item selected" onclick="selectGroup(this,'engineering')">
<div class="avatar av-group">EN</div>
<div class="entity-info">
<div class="entity-name">Engineering</div>
<div class="entity-meta">Top-level · 2 child groups · 5 members</div>
<div class="tag-list"><span class="tag tag-role">viewer</span></div>
</div>
</div>
<div class="entity-item" onclick="selectGroup(this,'backend')">
<div class="avatar av-group" style="font-size:10px">BE</div>
<div class="entity-info">
<div class="entity-name">Backend</div>
<div class="entity-meta">Child of Engineering · 3 members</div>
<div class="tag-list"><span class="tag tag-role">editor</span><span class="tag tag-role tag-inherited">viewer</span></div>
</div>
</div>
<div class="entity-item" onclick="selectGroup(this,'frontend')">
<div class="avatar av-group" style="font-size:10px">FE</div>
<div class="entity-info">
<div class="entity-name">Frontend</div>
<div class="entity-meta">Child of Engineering · 2 members</div>
<div class="tag-list"><span class="tag tag-role">editor</span><span class="tag tag-role tag-inherited">viewer</span></div>
</div>
</div>
<div class="entity-item" onclick="selectGroup(this,'ops')">
<div class="avatar av-group" style="background:#FAEEDA;color:#633806">OP</div>
<div class="entity-info">
<div class="entity-name">Ops</div>
<div class="entity-meta">Top-level · 2 members</div>
<div class="tag-list"><span class="tag tag-role">deployer</span><span class="tag tag-role">viewer</span></div>
</div>
</div>
<div class="entity-item" onclick="selectGroup(this,'admins')">
<div class="avatar av-group" style="background:#FCEBEB;color:#791F1F">AD</div>
<div class="entity-info">
<div class="entity-name">Admins</div>
<div class="entity-meta">Top-level · 1 member</div>
<div class="tag-list"><span class="tag tag-role">admin</span></div>
</div>
</div>
</div>
</div>
<div class="detail-pane" id="group-detail">
<div class="detail-avatar av-group" style="width:44px;height:44px;font-size:15px">EN</div>
<div class="detail-name">Engineering</div>
<div class="detail-email">Top-level group</div>
<div class="field-row"><span class="field-label">ID</span><span class="field-val mono">grp_02KX…9BC</span></div>
<hr class="divider">
<div class="detail-section">
<div class="detail-section-title">Members <span>direct</span></div>
<span class="chip">Alice Lang</span><span class="chip">Eve Park</span><span class="chip">Bob Kim</span>
<div style="font-size:11px;color:var(--color-text-tertiary);margin-top:6px">+ all members of Backend, Frontend</div>
</div>
<div class="detail-section">
<div class="detail-section-title">Child groups</div>
<span class="chip" style="border-color:#9FE1CB;color:#0F6E56;background:#E1F5EE">Backend</span>
<span class="chip" style="border-color:#9FE1CB;color:#0F6E56;background:#E1F5EE">Frontend</span>
</div>
<div class="detail-section">
<div class="detail-section-title">Assigned roles <span>on this group</span></div>
<span class="chip" style="border-color:#FAC775;color:#633806;background:#FAEEDA">viewer</span>
<div class="inherit-note">Child groups Backend and Frontend inherit <strong style="font-weight:500">viewer</strong>, and additionally carry their own <strong style="font-weight:500">editor</strong> role.</div>
</div>
<div class="detail-section">
<div class="detail-section-title">Group hierarchy</div>
<div class="tree-row">Engineering</div>
<div class="tree-row"><div class="tree-indent"><div class="tree-corner"></div></div>Backend</div>
<div class="tree-row"><div class="tree-indent"><div class="tree-corner"></div></div>Frontend</div>
</div>
</div>
</div>
</div>
<!-- ROLES PANEL -->
<div class="panel" id="panel-roles">
<div class="panel-header">
<div><div class="panel-title">Roles</div><div class="panel-subtitle">Define permission scopes; assign to users or groups</div></div>
<button class="btn-add">+ Add role</button>
</div>
<div class="split">
<div class="list-pane">
<div class="search-bar"><input class="search-input" placeholder="Search roles…"/></div>
<div class="entity-list">
<div class="entity-item selected">
<div class="avatar av-role">AD</div>
<div class="entity-info">
<div class="entity-name">admin</div>
<div class="entity-meta">Full access · 2 direct assignments</div>
<div class="tag-list"><span class="tag tag-group">Admins</span><span class="tag tag-group">Alice</span></div>
</div>
</div>
<div class="entity-item">
<div class="avatar av-role">ED</div>
<div class="entity-info">
<div class="entity-name">editor</div>
<div class="entity-meta">Read + write · 2 group assignments</div>
<div class="tag-list"><span class="tag tag-group">Backend</span><span class="tag tag-group">Frontend</span></div>
</div>
</div>
<div class="entity-item">
<div class="avatar av-role">DE</div>
<div class="entity-info">
<div class="entity-name">deployer</div>
<div class="entity-meta">Deploy access · 1 assignment</div>
<div class="tag-list"><span class="tag tag-group">Ops</span></div>
</div>
</div>
<div class="entity-item">
<div class="avatar av-role">VI</div>
<div class="entity-info">
<div class="entity-name">viewer</div>
<div class="entity-meta">Read-only · 1 group assignment</div>
<div class="tag-list"><span class="tag tag-group">Engineering</span></div>
</div>
</div>
<div class="entity-item">
<div class="avatar av-role">AU</div>
<div class="entity-info">
<div class="entity-name">auditor</div>
<div class="entity-meta">Audit log access · 0 assignments</div>
</div>
</div>
</div>
</div>
<div class="detail-pane">
<div class="detail-avatar av-role" style="width:44px;height:44px;font-size:15px">AD</div>
<div class="detail-name">admin</div>
<div class="detail-email">Full administrative access</div>
<div class="field-row"><span class="field-label">ID</span><span class="field-val mono">rol_00AA…1F2</span></div>
<div class="field-row"><span class="field-label">Scope</span><span class="field-val">system-wide</span></div>
<hr class="divider">
<div class="detail-section">
<div class="detail-section-title">Assigned to groups</div>
<span class="chip" style="border-color:#9FE1CB;color:#0F6E56;background:#E1F5EE">Admins</span>
</div>
<div class="detail-section">
<div class="detail-section-title">Assigned to users (direct)</div>
<span class="chip" style="border-color:#B5D4F4;color:#0C447C;background:#E6F1FB">Alice Lang</span>
</div>
<div class="detail-section">
<div class="detail-section-title">Effective principals <span>via inheritance</span></div>
<span class="chip">Alice Lang</span>
<span class="chip">Dan Müller</span>
<span class="chip inherited">…via Admins group</span>
<div class="inherit-note">Dan inherits admin through the Admins group. Alice holds it directly.</div>
</div>
</div>
</div>
</div>
</main>
</div>
<script>
function showPanel(name) {
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
const panel = document.getElementById('panel-' + name);
if (panel) panel.classList.add('active');
const nav = document.getElementById('nav-' + name);
if (nav) nav.classList.add('active');
}
function filterList(input, listId) {
const q = input.value.toLowerCase();
document.querySelectorAll('#' + listId + ' .entity-item').forEach(item => {
item.style.display = item.textContent.toLowerCase().includes(q) ? '' : 'none';
});
}
const userDetails = {
alice: {
initials: 'AL', name: 'Alice Lang', email: 'alice@corp.io', id: 'usr_01HX…4AF', created: '2024-03-12',
groups: ['Engineering','Backend'], roles: ['admin'], inherited: [{role:'viewer',via:'Engineering'},{role:'editor',via:'Backend'}],
tree: [['Engineering',''],['Backend','child group']]
},
bob: {
initials: 'BK', name: 'Bob Kim', email: 'bob@corp.io', id: 'usr_02BY…8CD', created: '2024-04-01',
groups: ['Engineering','Frontend'], roles: [], inherited: [{role:'editor',via:'Frontend'},{role:'viewer',via:'Engineering'}],
tree: [['Engineering',''],['Frontend','child group']]
},
carol: {
initials: 'CS', name: 'Carol Sanz', email: 'carol@corp.io', id: 'usr_03CS…2EF', created: '2024-02-20',
groups: ['Ops'], roles: ['deployer'], inherited: [{role:'viewer',via:'Ops'}],
tree: [['Ops','']]
},
dan: {
initials: 'DM', name: 'Dan Müller', email: 'dan@corp.io', id: 'usr_04DM…6GH', created: '2023-11-15',
groups: ['Admins'], roles: [], inherited: [{role:'admin',via:'Admins'}],
tree: [['Admins','']]
},
eve: {
initials: 'EP', name: 'Eve Park', email: 'eve@corp.io', id: 'usr_05EP…3IJ', created: '2024-05-10',
groups: ['Engineering','Backend'], roles: [], inherited: [{role:'editor',via:'Backend'},{role:'viewer',via:'Engineering'}],
tree: [['Engineering',''],['Backend','child group']]
},
frank: {
initials: 'FR', name: 'Frank Rossi', email: 'frank@corp.io', id: 'usr_06FR…7KL', created: '2024-06-01',
groups: [], roles: ['viewer'], inherited: [],
tree: []
}
};
function selectUser(el, key) {
document.querySelectorAll('#panel-users .entity-item').forEach(i => i.classList.remove('selected'));
el.classList.add('selected');
const u = userDetails[key];
if (!u) return;
const direct = u.roles.map(r => `<span class="chip" style="border-color:#FAC775;color:#633806;background:#FAEEDA">${r}</span>`).join('');
const inh = u.inherited.map(r => `<span class="chip inherited" style="border-color:#FAC775;color:#854F0B">${r.role} <span style="font-size:9px;opacity:0.6;margin-left:2px">↑ ${r.via}</span></span>`).join('');
const grpChips = u.groups.length ? u.groups.map(g => `<span class="chip"><svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2" width="10" height="10"><rect x="1" y="1" width="8" height="8" rx="1.5"/></svg>${g}</span>`).join('') : '<span style="font-size:12px;color:var(--color-text-tertiary)">No group membership</span>';
const treeRows = u.tree.length ? u.tree.map((t,i) => i===0 ? `<div class="tree-row">${t[0]}</div>` : `<div class="tree-row"><div class="tree-indent"><div class="tree-corner"></div></div>${t[0]} <span style="font-size:10px;color:var(--color-text-tertiary);margin-left:4px">${t[1]}</span></div>`).join('') : '<span style="font-size:12px;color:var(--color-text-tertiary)">No group hierarchy</span>';
const inhNote = u.inherited.length ? `<div class="inherit-note">Dashed roles are inherited transitively through group membership.</div>` : '';
document.getElementById('user-detail').innerHTML = `
<div class="detail-avatar av-user" style="width:44px;height:44px;font-size:15px">${u.initials}</div>
<div class="detail-name">${u.name}</div>
<div class="detail-email">${u.email}</div>
<div class="field-row"><span class="field-label">Status</span><span class="field-val" style="color:#0F6E56;font-size:12px">● Active</span></div>
<div class="field-row"><span class="field-label">ID</span><span class="field-val mono">${u.id}</span></div>
<div class="field-row"><span class="field-label">Created</span><span class="field-val">${u.created}</span></div>
<hr class="divider">
<div class="detail-section"><div class="detail-section-title">Group membership <span>direct</span></div>${grpChips}</div>
<div class="detail-section"><div class="detail-section-title">Effective roles <span>direct + inherited</span></div>${direct}${inh}${inhNote}</div>
<div class="detail-section"><div class="detail-section-title">Group tree</div>${treeRows}</div>
`;
}
function selectGroup(el, key) {
document.querySelectorAll('#panel-groups .entity-item').forEach(i => i.classList.remove('selected'));
el.classList.add('selected');
}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
import { config } from '../../../config';
import { useAuthStore } from '../../../auth/auth-store';
export async function adminFetch<T>(path: string, options?: RequestInit): Promise<T> {
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();
}

View File

@@ -0,0 +1,54 @@
import { useQuery } from '@tanstack/react-query';
import { adminFetch } from './admin-api';
export interface AuditEvent {
id: number;
timestamp: string;
username: string;
action: string;
category: string;
target: string;
detail: Record<string, unknown>;
result: string;
ipAddress: string;
userAgent: string;
}
export interface AuditLogParams {
from?: string;
to?: string;
username?: string;
category?: string;
search?: string;
sort?: string;
order?: string;
page?: number;
size?: number;
}
export interface AuditLogResponse {
items: AuditEvent[];
totalCount: number;
page: number;
pageSize: number;
totalPages: 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.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();
return useQuery({
queryKey: ['admin', 'audit', params],
queryFn: () => adminFetch<AuditLogResponse>(`/audit${qs ? `?${qs}` : ''}`),
});
}

View File

@@ -0,0 +1,74 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { adminFetch } from './admin-api';
export interface DatabaseStatus {
connected: boolean;
version: string;
host: string;
schema: string;
timescaleDb: boolean;
}
export interface PoolStats {
activeConnections: number;
idleConnections: number;
pendingThreads: number;
maxPoolSize: number;
maxWaitMs: number;
}
export interface TableInfo {
tableName: string;
rowCount: number;
dataSize: string;
indexSize: string;
dataSizeBytes: number;
indexSizeBytes: number;
}
export interface ActiveQuery {
pid: number;
durationSeconds: number;
state: string;
query: string;
}
export function useDatabaseStatus() {
return useQuery({
queryKey: ['admin', 'database', 'status'],
queryFn: () => adminFetch<DatabaseStatus>('/database/status'),
});
}
export function useDatabasePool() {
return useQuery({
queryKey: ['admin', 'database', 'pool'],
queryFn: () => adminFetch<PoolStats>('/database/pool'),
refetchInterval: 15000,
});
}
export function useDatabaseTables() {
return useQuery({
queryKey: ['admin', 'database', 'tables'],
queryFn: () => adminFetch<TableInfo[]>('/database/tables'),
});
}
export function useDatabaseQueries() {
return useQuery({
queryKey: ['admin', 'database', 'queries'],
queryFn: () => adminFetch<ActiveQuery[]>('/database/queries'),
refetchInterval: 15000,
});
}
export function useKillQuery() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (pid: number) => {
await adminFetch<void>(`/database/queries/${pid}/kill`, { method: 'POST' });
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'database', 'queries'] }),
});
}

View File

@@ -0,0 +1,106 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { adminFetch } from './admin-api';
export interface OpenSearchStatus {
reachable: boolean;
clusterHealth: string;
version: string;
nodeCount: number;
host: string;
}
export interface PipelineStats {
queueDepth: number;
maxQueueSize: number;
indexedCount: number;
failedCount: number;
debounceMs: number;
indexingRate: number;
lastIndexedAt: string | null;
}
export interface IndexInfo {
name: string;
health: string;
docCount: number;
size: string;
sizeBytes: number;
primaryShards: 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;
searchLatencyMs: number;
indexingLatencyMs: number;
jvmHeapUsedBytes: number;
jvmHeapMaxBytes: number;
}
export interface IndicesParams {
search?: string;
page?: number;
size?: number;
}
export function useOpenSearchStatus() {
return useQuery({
queryKey: ['admin', 'opensearch', 'status'],
queryFn: () => adminFetch<OpenSearchStatus>('/opensearch/status'),
});
}
export function usePipelineStats() {
return useQuery({
queryKey: ['admin', 'opensearch', 'pipeline'],
queryFn: () => adminFetch<PipelineStats>('/opensearch/pipeline'),
refetchInterval: 15000,
});
}
export function useIndices(params: IndicesParams) {
const query = new URLSearchParams();
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', 'opensearch', 'indices', params],
queryFn: () =>
adminFetch<IndicesPageResponse>(
`/opensearch/indices${qs ? `?${qs}` : ''}`,
),
});
}
export function usePerformanceStats() {
return useQuery({
queryKey: ['admin', 'opensearch', 'performance'],
queryFn: () => adminFetch<PerformanceStats>('/opensearch/performance'),
refetchInterval: 15000,
});
}
export function useDeleteIndex() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (indexName: string) => {
await adminFetch<void>(`/opensearch/indices/${encodeURIComponent(indexName)}`, {
method: 'DELETE',
});
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'opensearch', 'indices'] }),
});
}

View File

@@ -0,0 +1,45 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { adminFetch } from './admin-api';
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<ThresholdConfig>('/thresholds'),
});
}
export function useSaveThresholds() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: ThresholdConfig) => {
await adminFetch<ThresholdConfig>('/thresholds', {
method: 'PUT',
body: JSON.stringify(body),
});
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'thresholds'] }),
});
}

869
ui/src/api/schema.d.ts vendored
View File

@@ -21,6 +21,24 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/admin/oidc": {
parameters: { parameters: {
query?: never; query?: never;
@@ -326,6 +344,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/search/stats": {
parameters: { parameters: {
query?: never; query?: never;
@@ -526,6 +561,176 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
@@ -533,6 +738,101 @@ export interface components {
RolesRequest: { RolesRequest: {
roles?: string[]; 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 */ /** @description OIDC configuration update request */
OidcAdminConfigRequest: { OidcAdminConfigRequest: {
enabled?: boolean; enabled?: boolean;
@@ -816,6 +1116,279 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
createdAt: string; 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<string, never>;
};
/** @enum {string} */
result?: "SUCCESS" | "FAILURE";
ipAddress?: string;
userAgent?: string;
};
}; };
responses: never; responses: never;
parameters: 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: { getConfig: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1034,13 +1651,6 @@ export interface operations {
}; };
content?: never; content?: never;
}; };
/** @description Buffer full, retry later */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
}; };
}; };
ingestDiagrams: { ingestDiagrams: {
@@ -1063,13 +1673,6 @@ export interface operations {
}; };
content?: never; content?: never;
}; };
/** @description Buffer full, retry later */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
}; };
}; };
refresh: { 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: { stats: {
parameters: { parameters: {
query: { query: {
@@ -1615,7 +2238,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown; [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;
};
};
};
} }

View File

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

View File

@@ -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 (
<div className={styles.overlay} onClick={handleClose}>
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
<h3 className={styles.title}>Confirm Deletion</h3>
<p className={styles.message}>
Delete {resourceType} &lsquo;{resourceName}&rsquo;? This cannot be undone.
</p>
<label className={styles.label}>
Type <strong>{resourceName}</strong> to confirm:
</label>
<input
className={styles.input}
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={resourceName}
autoFocus
/>
<div className={styles.actions}>
<button type="button" className={styles.btnCancel} onClick={handleClose}>
Cancel
</button>
<button
type="button"
className={styles.btnDelete}
onClick={handleConfirm}
disabled={!canDelete}
>
Delete
</button>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -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 (
<div className={styles.card}>
<div {...headerProps}>
<div className={styles.titleRow}>
{collapsible && (
<span className={`${styles.chevron} ${collapsed ? '' : styles.chevronOpen}`}>
&#9654;
</span>
)}
<h3 className={styles.title}>{title}</h3>
{autoRefresh && <span className={styles.autoIndicator}>auto</span>}
</div>
{onRefresh && (
<button
type="button"
className={`${styles.refreshBtn} ${isRefreshing ? styles.refreshing : ''}`}
onClick={(e) => {
e.stopPropagation();
onRefresh();
}}
disabled={isRefreshing}
title="Refresh"
>
&#8635;
</button>
)}
</div>
{!collapsed && <div className={styles.body}>{children}</div>}
</div>
);
}

View File

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

View File

@@ -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 (
<span className={styles.badge}>
<span className={`${styles.dot} ${styles[status]}`} />
{label && <span className={styles.label}>{label}</span>}
</span>
);
}

View File

@@ -209,6 +209,44 @@
text-align: center; 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 ─── */ /* ─── Responsive ─── */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.sidebar { .sidebar {
@@ -242,4 +280,8 @@
.sidebar .bottomLabel { .sidebar .bottomLabel {
display: none; display: none;
} }
.sidebar .adminSubMenu {
display: none;
}
} }

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'; 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 { useAgents } from '../../api/queries/agents';
import { useAuthStore } from '../../auth/auth-store'; import { useAuthStore } from '../../auth/auth-store';
import type { AgentInstance } from '../../api/types'; import type { AgentInstance } from '../../api/types';
@@ -112,18 +112,73 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
{/* Bottom: Admin */} {/* Bottom: Admin */}
{roles.includes('ADMIN') && ( {roles.includes('ADMIN') && (
<div className={styles.bottom}> <div className={styles.bottom}>
<NavLink <AdminSubMenu collapsed={collapsed} />
to="/admin/oidc"
className={({ isActive }) =>
`${styles.bottomItem} ${isActive ? styles.bottomItemActive : ''}`
}
title="Admin"
>
<span className={styles.bottomIcon}>&#9881;</span>
<span className={styles.bottomLabel}>Admin</span>
</NavLink>
</div> </div>
)} )}
</aside> </aside>
); );
} }
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 (
<>
<button
type="button"
className={`${styles.bottomItem} ${isAdminActive ? styles.bottomItemActive : ''}`}
onClick={toggle}
title="Admin"
>
<span className={styles.bottomIcon}>&#9881;</span>
<span className={styles.bottomLabel}>
Admin
{!sidebarCollapsed && (
<span className={styles.adminChevron}>
{open ? '\u25BC' : '\u25B6'}
</span>
)}
</span>
</button>
{open && !sidebarCollapsed && (
<div className={styles.adminSubMenu}>
{ADMIN_LINKS.map((link) => (
<NavLink
key={link.to}
to={link.to}
className={({ isActive }) =>
`${styles.adminSubItem} ${isActive ? styles.adminSubItemActive : ''}`
}
>
{link.label}
</NavLink>
))}
</div>
)}
</>
);
}

View File

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

View File

@@ -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 (
<div className={styles.page}>
<div className={styles.accessDenied}>
Access Denied this page requires the ADMIN role.
</div>
</div>
);
}
return <AuditLogContent />;
}
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<number | null>(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?.totalPages ?? 0;
const showingFrom = data && data.totalCount > 0 ? page * pageSize + 1 : 0;
const showingTo = data ? Math.min((page + 1) * pageSize, data.totalCount) : 0;
return (
<div className={styles.page}>
<div className={styles.header}>
<h1 className={styles.pageTitle}>Audit Log</h1>
{data && (
<span className={styles.totalCount}>{data.totalCount.toLocaleString()} events</span>
)}
</div>
<div className={styles.filters}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>From</label>
<input
type="date"
className={styles.filterInput}
value={from}
onChange={(e) => { setFrom(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>To</label>
<input
type="date"
className={styles.filterInput}
value={to}
onChange={(e) => { setTo(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>User</label>
<input
type="text"
className={styles.filterInput}
placeholder="Username..."
value={username}
onChange={(e) => { setUsername(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Category</label>
<select
className={styles.filterSelect}
value={category}
onChange={(e) => { setCategory(e.target.value); setPage(0); }}
>
<option value="">All</option>
<option value="INFRA">INFRA</option>
<option value="AUTH">AUTH</option>
<option value="USER_MGMT">USER_MGMT</option>
<option value="CONFIG">CONFIG</option>
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Search</label>
<input
type="text"
className={styles.filterInput}
placeholder="Search..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
</div>
</div>
{audit.isLoading ? (
<div className={styles.loading}>Loading...</div>
) : !data || data.items.length === 0 ? (
<div className={styles.emptyState}>No audit events found for the selected filters.</div>
) : (
<>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>Timestamp</th>
<th>User</th>
<th>Category</th>
<th>Action</th>
<th>Target</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{data.items.map((event) => (
<>
<tr
key={event.id}
className={`${styles.eventRow} ${expandedRow === event.id ? styles.eventRowExpanded : ''}`}
onClick={() =>
setExpandedRow((prev) => (prev === event.id ? null : event.id))
}
>
<td className={styles.mono}>
{formatTimestamp(event.timestamp)}
</td>
<td>{event.username}</td>
<td>
<span className={styles.categoryBadge}>{event.category}</span>
</td>
<td>{event.action}</td>
<td className={styles.mono}>{event.target}</td>
<td>
<span
className={`${styles.resultBadge} ${
event.result === 'SUCCESS' ? styles.resultSuccess : styles.resultFailure
}`}
>
{event.result}
</span>
</td>
</tr>
{expandedRow === event.id && (
<tr key={`${event.id}-detail`} className={styles.detailRow}>
<td colSpan={6}>
<pre className={styles.detailJson}>
{JSON.stringify(event.detail, null, 2)}
</pre>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
</div>
<div className={styles.pagination}>
<button
type="button"
className={styles.pageBtn}
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</button>
<span className={styles.pageInfo}>
Showing {showingFrom}-{showingTo} of {data.totalCount.toLocaleString()}
</span>
<button
type="button"
className={styles.pageBtn}
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</>
)}
</div>
);
}
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;
}
}

View File

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

View File

@@ -0,0 +1,382 @@
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 ThresholdConfig } 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 (
<div className={styles.page}>
<div className={styles.accessDenied}>
Access Denied this page requires the ADMIN role.
</div>
</div>
);
}
return <DatabaseAdminContent />;
}
function DatabaseAdminContent() {
const status = useDatabaseStatus();
const pool = useDatabasePool();
const tables = useDatabaseTables();
const queries = useDatabaseQueries();
const thresholds = useThresholds();
if (status.isLoading) {
return (
<div className={styles.page}>
<h1 className={styles.pageTitle}>Database Administration</h1>
<div className={styles.loading}>Loading...</div>
</div>
);
}
const db = status.data;
return (
<div className={styles.page}>
<div className={styles.header}>
<div className={styles.headerInfo}>
<h1 className={styles.pageTitle}>Database Administration</h1>
<div className={styles.headerMeta}>
<StatusBadge
status={db?.connected ? 'healthy' : 'critical'}
label={db?.connected ? 'Connected' : 'Disconnected'}
/>
{db?.version && <span className={styles.metaItem}>{db.version}</span>}
{db?.host && <span className={styles.metaItem}>{db.host}</span>}
{db?.schema && <span className={styles.metaItem}>Schema: {db.schema}</span>}
</div>
</div>
<button
type="button"
className={styles.globalRefresh}
onClick={() => {
status.refetch();
pool.refetch();
tables.refetch();
queries.refetch();
}}
>
Refresh All
</button>
</div>
<PoolSection
pool={pool}
warningPct={thresholds.data?.database?.connectionPoolWarning}
criticalPct={thresholds.data?.database?.connectionPoolCritical}
/>
<TablesSection tables={tables} />
<QueriesSection
queries={queries}
warningSeconds={thresholds.data?.database?.queryDurationWarning}
/>
<MaintenanceSection />
<ThresholdsSection thresholds={thresholds.data} />
</div>
);
}
function PoolSection({
pool,
warningPct,
criticalPct,
}: {
pool: ReturnType<typeof useDatabasePool>;
warningPct?: number;
criticalPct?: number;
}) {
const data = pool.data;
if (!data) return null;
const usagePct = data.maxPoolSize > 0
? Math.round((data.activeConnections / data.maxPoolSize) * 100)
: 0;
const barColor =
criticalPct && usagePct >= criticalPct ? '#ef4444'
: warningPct && usagePct >= warningPct ? '#eab308'
: '#22c55e';
return (
<RefreshableCard
title="Connection Pool"
onRefresh={() => pool.refetch()}
isRefreshing={pool.isFetching}
autoRefresh
>
<div className={styles.progressContainer}>
<div className={styles.progressLabel}>
{data.activeConnections} / {data.maxPoolSize} connections
<span className={styles.progressPct}>{usagePct}%</span>
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${usagePct}%`, background: barColor }}
/>
</div>
</div>
<div className={styles.metricsGrid}>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.activeConnections}</span>
<span className={styles.metricLabel}>Active</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.idleConnections}</span>
<span className={styles.metricLabel}>Idle</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.pendingThreads}</span>
<span className={styles.metricLabel}>Pending</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.maxWaitMs}ms</span>
<span className={styles.metricLabel}>Max Wait</span>
</div>
</div>
</RefreshableCard>
);
}
function TablesSection({ tables }: { tables: ReturnType<typeof useDatabaseTables> }) {
const data = tables.data;
return (
<RefreshableCard
title="Table Sizes"
onRefresh={() => tables.refetch()}
isRefreshing={tables.isFetching}
>
{!data ? (
<div className={styles.loading}>Loading...</div>
) : (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>Table</th>
<th>Rows</th>
<th>Data Size</th>
<th>Index Size</th>
</tr>
</thead>
<tbody>
{data.map((t) => (
<tr key={t.tableName}>
<td className={styles.mono}>{t.tableName}</td>
<td>{t.rowCount.toLocaleString()}</td>
<td>{t.dataSize}</td>
<td>{t.indexSize}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</RefreshableCard>
);
}
function QueriesSection({
queries,
warningSeconds,
}: {
queries: ReturnType<typeof useDatabaseQueries>;
warningSeconds?: number;
}) {
const [killTarget, setKillTarget] = useState<number | null>(null);
const killMutation = useKillQuery();
const data = queries.data;
const warningSec = warningSeconds ?? 30;
return (
<RefreshableCard
title="Active Queries"
onRefresh={() => queries.refetch()}
isRefreshing={queries.isFetching}
autoRefresh
>
{!data || data.length === 0 ? (
<div className={styles.emptyState}>No active queries</div>
) : (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>PID</th>
<th>Duration</th>
<th>State</th>
<th>Query</th>
<th></th>
</tr>
</thead>
<tbody>
{data.map((q) => (
<tr
key={q.pid}
className={q.durationSeconds > warningSec ? styles.rowWarning : undefined}
>
<td className={styles.mono}>{q.pid}</td>
<td>{formatDuration(q.durationSeconds)}</td>
<td>{q.state}</td>
<td className={styles.queryCell} title={q.query}>
{q.query.length > 100 ? `${q.query.slice(0, 100)}...` : q.query}
</td>
<td>
<button
type="button"
className={styles.killBtn}
onClick={() => setKillTarget(q.pid)}
>
Kill
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<ConfirmDeleteDialog
isOpen={killTarget !== null}
onClose={() => setKillTarget(null)}
onConfirm={() => {
if (killTarget !== null) {
killMutation.mutate(killTarget);
setKillTarget(null);
}
}}
resourceName={String(killTarget ?? '')}
resourceType="query (PID)"
/>
</RefreshableCard>
);
}
function MaintenanceSection() {
return (
<RefreshableCard title="Maintenance">
<div className={styles.maintenanceGrid}>
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
VACUUM ANALYZE
</button>
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
REINDEX
</button>
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
Refresh Aggregates
</button>
</div>
</RefreshableCard>
);
}
function ThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
const [form, setForm] = useState<ThresholdConfig | null>(null);
const saveMutation = useSaveThresholds();
const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
const current = form ?? thresholds;
if (!current) return null;
function updateDb(key: keyof ThresholdConfig['database'], value: number) {
setForm((prev) => {
const base = prev ?? thresholds!;
return { ...base, database: { ...base.database, [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 (
<RefreshableCard title="Thresholds" collapsible defaultCollapsed>
<div className={styles.thresholdGrid}>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Pool Warning %</label>
<input
type="number"
className={styles.thresholdInput}
value={current.database.connectionPoolWarning}
onChange={(e) => updateDb('connectionPoolWarning', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Pool Critical %</label>
<input
type="number"
className={styles.thresholdInput}
value={current.database.connectionPoolCritical}
onChange={(e) => updateDb('connectionPoolCritical', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Query Warning (s)</label>
<input
type="number"
className={styles.thresholdInput}
value={current.database.queryDurationWarning}
onChange={(e) => updateDb('queryDurationWarning', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Query Critical (s)</label>
<input
type="number"
className={styles.thresholdInput}
value={current.database.queryDurationCritical}
onChange={(e) => updateDb('queryDurationCritical', Number(e.target.value))}
/>
</div>
</div>
<div className={styles.thresholdActions}>
<button
type="button"
className={styles.btnPrimary}
onClick={handleSave}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? 'Saving...' : 'Save Thresholds'}
</button>
{status && (
<span className={status.type === 'success' ? styles.successMsg : styles.errorMsg}>
{status.msg}
</span>
)}
</div>
</RefreshableCard>
);
}
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`;
}

View File

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

View File

@@ -0,0 +1,444 @@
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 ThresholdConfig } 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 (
<div className={styles.page}>
<div className={styles.accessDenied}>
Access Denied this page requires the ADMIN role.
</div>
</div>
);
}
return <OpenSearchAdminContent />;
}
function OpenSearchAdminContent() {
const status = useOpenSearchStatus();
const pipeline = usePipelineStats();
const performance = usePerformanceStats();
const thresholds = useThresholds();
if (status.isLoading) {
return (
<div className={styles.page}>
<h1 className={styles.pageTitle}>OpenSearch Administration</h1>
<div className={styles.loading}>Loading...</div>
</div>
);
}
const os = status.data;
return (
<div className={styles.page}>
<div className={styles.header}>
<div className={styles.headerInfo}>
<h1 className={styles.pageTitle}>OpenSearch Administration</h1>
<div className={styles.headerMeta}>
<StatusBadge
status={clusterHealthToStatus(os?.clusterHealth)}
label={os?.clusterHealth ?? 'Unknown'}
/>
{os?.version && <span className={styles.metaItem}>v{os.version}</span>}
{os?.nodeCount !== undefined && (
<span className={styles.metaItem}>{os.nodeCount} node(s)</span>
)}
{os?.host && <span className={styles.metaItem}>{os.host}</span>}
</div>
</div>
<button
type="button"
className={styles.globalRefresh}
onClick={() => {
status.refetch();
pipeline.refetch();
performance.refetch();
}}
>
Refresh All
</button>
</div>
<PipelineSection pipeline={pipeline} thresholds={thresholds.data} />
<IndicesSection />
<PerformanceSection performance={performance} thresholds={thresholds.data} />
<OperationsSection />
<OsThresholdsSection thresholds={thresholds.data} />
</div>
);
}
function PipelineSection({
pipeline,
thresholds,
}: {
pipeline: ReturnType<typeof usePipelineStats>;
thresholds?: ThresholdConfig;
}) {
const data = pipeline.data;
if (!data) return null;
const queuePct = data.maxQueueSize > 0
? Math.round((data.queueDepth / data.maxQueueSize) * 100)
: 0;
const barColor =
thresholds?.opensearch?.queueDepthCritical && data.queueDepth >= thresholds.opensearch.queueDepthCritical ? '#ef4444'
: thresholds?.opensearch?.queueDepthWarning && data.queueDepth >= thresholds.opensearch.queueDepthWarning ? '#eab308'
: '#22c55e';
return (
<RefreshableCard
title="Indexing Pipeline"
onRefresh={() => pipeline.refetch()}
isRefreshing={pipeline.isFetching}
autoRefresh
>
<div className={styles.progressContainer}>
<div className={styles.progressLabel}>
Queue: {data.queueDepth} / {data.maxQueueSize}
<span className={styles.progressPct}>{queuePct}%</span>
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${queuePct}%`, background: barColor }}
/>
</div>
</div>
<div className={styles.metricsGrid}>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.indexedCount.toLocaleString()}</span>
<span className={styles.metricLabel}>Total Indexed</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.failedCount.toLocaleString()}</span>
<span className={styles.metricLabel}>Total Failed</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.indexingRate.toFixed(1)}/s</span>
<span className={styles.metricLabel}>Indexing Rate</span>
</div>
</div>
</RefreshableCard>
);
}
function IndicesSection() {
const [search, setSearch] = useState('');
const [page, setPage] = useState(0);
const pageSize = 10;
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const params: IndicesParams = {
search: search || undefined,
page,
size: pageSize,
};
const indices = useIndices(params);
const deleteMutation = useDeleteIndex();
const data = indices.data;
const totalPages = data?.totalPages ?? 0;
return (
<RefreshableCard
title="Indices"
onRefresh={() => indices.refetch()}
isRefreshing={indices.isFetching}
>
<div className={styles.filterRow}>
<input
className={styles.filterInput}
type="text"
placeholder="Search indices..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
</div>
{!data ? (
<div className={styles.loading}>Loading...</div>
) : (
<>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>Name</th>
<th>Health</th>
<th>Docs</th>
<th>Size</th>
<th>Shards</th>
<th></th>
</tr>
</thead>
<tbody>
{data.indices.map((idx) => (
<tr key={idx.name}>
<td className={styles.mono}>{idx.name}</td>
<td>
<span className={`${styles.healthBadge} ${styles[`health${idx.health.charAt(0).toUpperCase()}${idx.health.slice(1)}`]}`}>
{idx.health}
</span>
</td>
<td>{idx.docCount.toLocaleString()}</td>
<td>{idx.size}</td>
<td>{idx.primaryShards}p / {idx.replicaShards}r</td>
<td>
<button
type="button"
className={styles.deleteBtn}
onClick={() => setDeleteTarget(idx.name)}
>
Delete
</button>
</td>
</tr>
))}
{data.indices.length === 0 && (
<tr>
<td colSpan={6} className={styles.emptyState}>No indices found</td>
</tr>
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className={styles.pagination}>
<button
type="button"
className={styles.pageBtn}
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</button>
<span className={styles.pageInfo}>
Page {page + 1} of {totalPages}
</span>
<button
type="button"
className={styles.pageBtn}
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
)}
</>
)}
<ConfirmDeleteDialog
isOpen={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget);
setDeleteTarget(null);
}
}}
resourceName={deleteTarget ?? ''}
resourceType="index"
/>
</RefreshableCard>
);
}
function PerformanceSection({
performance,
thresholds,
}: {
performance: ReturnType<typeof usePerformanceStats>;
thresholds?: ThresholdConfig;
}) {
const data = performance.data;
if (!data) return null;
const heapPct = data.jvmHeapMaxBytes > 0
? Math.round((data.jvmHeapUsedBytes / data.jvmHeapMaxBytes) * 100)
: 0;
const heapColor =
thresholds?.opensearch?.jvmHeapCritical && heapPct >= thresholds.opensearch.jvmHeapCritical ? '#ef4444'
: thresholds?.opensearch?.jvmHeapWarning && heapPct >= thresholds.opensearch.jvmHeapWarning ? '#eab308'
: '#22c55e';
return (
<RefreshableCard
title="Performance"
onRefresh={() => performance.refetch()}
isRefreshing={performance.isFetching}
autoRefresh
>
<div className={styles.metricsGrid}>
<div className={styles.metric}>
<span className={styles.metricValue}>{(data.queryCacheHitRate * 100).toFixed(1)}%</span>
<span className={styles.metricLabel}>Query Cache Hit</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{(data.requestCacheHitRate * 100).toFixed(1)}%</span>
<span className={styles.metricLabel}>Request Cache Hit</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.searchLatencyMs.toFixed(1)}ms</span>
<span className={styles.metricLabel}>Query Latency</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.indexingLatencyMs.toFixed(1)}ms</span>
<span className={styles.metricLabel}>Index Latency</span>
</div>
</div>
<div className={styles.heapSection}>
<div className={styles.progressLabel}>
JVM Heap: {formatBytes(data.jvmHeapUsedBytes)} / {formatBytes(data.jvmHeapMaxBytes)}
<span className={styles.progressPct}>{heapPct}%</span>
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${heapPct}%`, background: heapColor }}
/>
</div>
</div>
</RefreshableCard>
);
}
function OperationsSection() {
return (
<RefreshableCard title="Operations">
<div className={styles.operationsGrid}>
<button type="button" className={styles.operationBtn} disabled title="Coming soon">
Force Merge
</button>
<button type="button" className={styles.operationBtn} disabled title="Coming soon">
Flush
</button>
<button type="button" className={styles.operationBtn} disabled title="Coming soon">
Clear Cache
</button>
</div>
</RefreshableCard>
);
}
function OsThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
const [form, setForm] = useState<ThresholdConfig | null>(null);
const saveMutation = useSaveThresholds();
const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
const current = form ?? thresholds;
if (!current) return null;
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() {
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 (
<RefreshableCard title="Thresholds" collapsible defaultCollapsed>
<div className={styles.thresholdGrid}>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Queue Warning</label>
<input
type="number"
className={styles.thresholdInput}
value={current.opensearch.queueDepthWarning}
onChange={(e) => updateOs('queueDepthWarning', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Queue Critical</label>
<input
type="number"
className={styles.thresholdInput}
value={current.opensearch.queueDepthCritical}
onChange={(e) => updateOs('queueDepthCritical', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Heap Warning %</label>
<input
type="number"
className={styles.thresholdInput}
value={current.opensearch.jvmHeapWarning}
onChange={(e) => updateOs('jvmHeapWarning', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Heap Critical %</label>
<input
type="number"
className={styles.thresholdInput}
value={current.opensearch.jvmHeapCritical}
onChange={(e) => updateOs('jvmHeapCritical', Number(e.target.value))}
/>
</div>
</div>
<div className={styles.thresholdActions}>
<button
type="button"
className={styles.btnPrimary}
onClick={handleSave}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? 'Saving...' : 'Save Thresholds'}
</button>
{status && (
<span className={status.type === 'success' ? styles.successMsg : styles.errorMsg}>
{status.msg}
</span>
)}
</div>
</RefreshableCard>
);
}
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]}`;
}

View File

@@ -10,6 +10,9 @@ import { RoutePage } from './pages/routes/RoutePage';
import { AppScopedView } from './pages/dashboard/AppScopedView'; import { AppScopedView } from './pages/dashboard/AppScopedView';
const SwaggerPage = lazy(() => import('./pages/swagger/SwaggerPage').then(m => ({ default: m.SwaggerPage }))); 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([ export const router = createBrowserRouter([
{ {
@@ -30,6 +33,10 @@ export const router = createBrowserRouter([
{ path: 'executions', element: <ExecutionExplorer /> }, { path: 'executions', element: <ExecutionExplorer /> },
{ path: 'apps/:group', element: <AppScopedView /> }, { path: 'apps/:group', element: <AppScopedView /> },
{ path: 'apps/:group/routes/:routeId', element: <RoutePage /> }, { path: 'apps/:group/routes/:routeId', element: <RoutePage /> },
{ path: 'admin', element: <Navigate to="/admin/database" replace /> },
{ path: 'admin/database', element: <Suspense fallback={null}><DatabaseAdminPage /></Suspense> },
{ path: 'admin/opensearch', element: <Suspense fallback={null}><OpenSearchAdminPage /></Suspense> },
{ path: 'admin/audit', element: <Suspense fallback={null}><AuditLogPage /></Suspense> },
{ path: 'admin/oidc', element: <OidcAdminPage /> }, { path: 'admin/oidc', element: <OidcAdminPage /> },
{ path: 'swagger', element: <Suspense fallback={null}><SwaggerPage /></Suspense> }, { path: 'swagger', element: <Suspense fallback={null}><SwaggerPage /></Suspense> },
], ],