Merge pull request 'feature/admin-infrastructure' (#79) from feature/admin-infrastructure into main
Reviewed-on: cameleer/cameleer3-server#79
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.cameleer3.server.core.admin;
|
||||||
|
|
||||||
|
public enum AuditCategory {
|
||||||
|
INFRA, AUTH, USER_MGMT, CONFIG
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.cameleer3.server.core.admin;
|
||||||
|
|
||||||
|
public enum AuditResult {
|
||||||
|
SUCCESS, FAILURE
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
321
examples/RBAC/rbac-ui-spec.md
Normal file
321
examples/RBAC/rbac-ui-spec.md
Normal 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 parent–child group hierarchy |
|
||||||
|
| Green note block (left border) | Contextual explanation of inheritance behaviour — appears wherever inherited permissions could be confusing |
|
||||||
|
| Blue tint on selected list row | Currently selected entity; detail pane reflects this entity |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entity data model (for implementation reference)
|
||||||
|
|
||||||
|
### User
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface User {
|
||||||
|
id: string; // e.g. "usr_01HX…4AF"
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
status: "active" | "inactive";
|
||||||
|
createdAt: string; // ISO date
|
||||||
|
directGroups: string[]; // group IDs — direct membership only
|
||||||
|
directRoles: string[]; // role IDs — assigned directly to this user
|
||||||
|
// Computed at read time:
|
||||||
|
effectiveGroups: string[]; // all groups including transitive
|
||||||
|
effectiveRoles: string[]; // all roles including inherited
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Group
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Group {
|
||||||
|
id: string; // e.g. "grp_02KX…9BC"
|
||||||
|
name: string;
|
||||||
|
parentGroupId?: string; // null for top-level groups
|
||||||
|
directRoles: string[]; // role IDs assigned to this group
|
||||||
|
// Computed at read time:
|
||||||
|
effectiveRoles: string[]; // direct + inherited from parent chain
|
||||||
|
memberUserIds: string[]; // direct members only
|
||||||
|
childGroupIds: string[]; // direct children only
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Role
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Role {
|
||||||
|
id: string; // e.g. "rol_00AA…1F2"
|
||||||
|
name: string; // slug, e.g. "admin", "viewer"
|
||||||
|
description: string;
|
||||||
|
scope: string; // e.g. "system-wide", "monitoring:read"
|
||||||
|
// Computed at read time:
|
||||||
|
directGroupIds: string[]; // groups this role is assigned to
|
||||||
|
directUserIds: string[]; // users this role is assigned to directly
|
||||||
|
effectivePrincipalIds: string[]; // all users who hold this role
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended API surface
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/users` | List all users with effectiveRoles and effectiveGroups |
|
||||||
|
| `GET` | `/users/:id` | Single user detail |
|
||||||
|
| `POST` | `/users/:id/roles` | Assign a role directly to a user |
|
||||||
|
| `DELETE` | `/users/:id/roles/:roleId` | Remove a direct role from a user |
|
||||||
|
| `POST` | `/users/:id/groups` | Add user to a group |
|
||||||
|
| `DELETE` | `/users/:id/groups/:groupId` | Remove user from a group |
|
||||||
|
| `GET` | `/groups` | List all groups with hierarchy |
|
||||||
|
| `GET` | `/groups/:id` | Single group detail |
|
||||||
|
| `POST` | `/groups/:id/roles` | Assign a role to a group |
|
||||||
|
| `POST` | `/groups/:id/children` | Nest a child group |
|
||||||
|
| `GET` | `/roles` | List all roles with effective principals |
|
||||||
|
| `GET` | `/roles/:id` | Single role detail |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Handoff notes for Claude Code
|
||||||
|
|
||||||
|
When implementing this in a production stack:
|
||||||
|
|
||||||
|
- **State management** — effective roles and groups should be computed server-side and returned in API responses. Do not compute inheritance chains in the frontend.
|
||||||
|
- **Component split** — `EntityListPane`, `UserDetail`, `GroupDetail`, `RoleDetail`, `InheritanceChip`, `GroupTree` are the natural component boundaries.
|
||||||
|
- **CSS tokens** — all colours use CSS variables (`--color-background-primary`, `--color-border-tertiary`, etc.) that map to the design system. Replace with your own token layer (Tailwind, CSS Modules, etc.).
|
||||||
|
- **Search** — currently client-side string matching. For large deployments, wire to a server-side search endpoint.
|
||||||
|
- **Inheritance note blocks** — always render these wherever inherited permissions are displayed. They prevent operator confusion when a user has a role they didn't expect.
|
||||||
566
examples/RBAC/rbac_management_ui.html
Normal file
566
examples/RBAC/rbac_management_ui.html
Normal 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
22
ui/src/api/queries/admin/admin-api.ts
Normal file
22
ui/src/api/queries/admin/admin-api.ts
Normal 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();
|
||||||
|
}
|
||||||
54
ui/src/api/queries/admin/audit.ts
Normal file
54
ui/src/api/queries/admin/audit.ts
Normal 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}` : ''}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
74
ui/src/api/queries/admin/database.ts
Normal file
74
ui/src/api/queries/admin/database.ts
Normal 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'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
106
ui/src/api/queries/admin/opensearch.ts
Normal file
106
ui/src/api/queries/admin/opensearch.ts
Normal 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'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
45
ui/src/api/queries/admin/thresholds.ts
Normal file
45
ui/src/api/queries/admin/thresholds.ts
Normal 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
869
ui/src/api/schema.d.ts
vendored
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
103
ui/src/components/admin/ConfirmDeleteDialog.module.css
Normal file
103
ui/src/components/admin/ConfirmDeleteDialog.module.css
Normal 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;
|
||||||
|
}
|
||||||
70
ui/src/components/admin/ConfirmDeleteDialog.tsx
Normal file
70
ui/src/components/admin/ConfirmDeleteDialog.tsx
Normal 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} ‘{resourceName}’? 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
ui/src/components/admin/RefreshableCard.module.css
Normal file
96
ui/src/components/admin/RefreshableCard.module.css
Normal 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); }
|
||||||
|
}
|
||||||
70
ui/src/components/admin/RefreshableCard.tsx
Normal file
70
ui/src/components/admin/RefreshableCard.tsx
Normal 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}`}>
|
||||||
|
▶
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!collapsed && <div className={styles.body}>{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
ui/src/components/admin/StatusBadge.module.css
Normal file
34
ui/src/components/admin/StatusBadge.module.css
Normal 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;
|
||||||
|
}
|
||||||
17
ui/src/components/admin/StatusBadge.tsx
Normal file
17
ui/src/components/admin/StatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}>⚙</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}>⚙</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
260
ui/src/pages/admin/AuditLogPage.module.css
Normal file
260
ui/src/pages/admin/AuditLogPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
225
ui/src/pages/admin/AuditLogPage.tsx
Normal file
225
ui/src/pages/admin/AuditLogPage.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
317
ui/src/pages/admin/DatabaseAdminPage.module.css
Normal file
317
ui/src/pages/admin/DatabaseAdminPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
382
ui/src/pages/admin/DatabaseAdminPage.tsx
Normal file
382
ui/src/pages/admin/DatabaseAdminPage.tsx
Normal 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`;
|
||||||
|
}
|
||||||
425
ui/src/pages/admin/OpenSearchAdminPage.module.css
Normal file
425
ui/src/pages/admin/OpenSearchAdminPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
444
ui/src/pages/admin/OpenSearchAdminPage.tsx
Normal file
444
ui/src/pages/admin/OpenSearchAdminPage.tsx
Normal 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]}`;
|
||||||
|
}
|
||||||
@@ -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> },
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user