diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DatabaseAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DatabaseAdminController.java new file mode 100644 index 00000000..3bd0affd --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DatabaseAdminController.java @@ -0,0 +1,129 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.dto.ActiveQueryResponse; +import com.cameleer3.server.app.dto.ConnectionPoolResponse; +import com.cameleer3.server.app.dto.DatabaseStatusResponse; +import com.cameleer3.server.app.dto.TableSizeResponse; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditResult; +import com.cameleer3.server.core.admin.AuditService; +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.HikariPoolMXBean; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import javax.sql.DataSource; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/admin/database") +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "Database Admin", description = "Database monitoring and management (ADMIN only)") +public class DatabaseAdminController { + + private final JdbcTemplate jdbc; + private final DataSource dataSource; + private final AuditService auditService; + + public DatabaseAdminController(JdbcTemplate jdbc, DataSource dataSource, AuditService auditService) { + this.jdbc = jdbc; + this.dataSource = dataSource; + this.auditService = auditService; + } + + @GetMapping("/status") + @Operation(summary = "Get database connection status and version") + public ResponseEntity getStatus() { + try { + String version = jdbc.queryForObject("SELECT version()", String.class); + boolean timescaleDb = Boolean.TRUE.equals( + jdbc.queryForObject("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')", Boolean.class)); + String schema = jdbc.queryForObject("SELECT current_schema()", String.class); + String host = extractHost(dataSource); + return ResponseEntity.ok(new DatabaseStatusResponse(true, version, host, schema, timescaleDb)); + } catch (Exception e) { + return ResponseEntity.ok(new DatabaseStatusResponse(false, null, null, null, false)); + } + } + + @GetMapping("/pool") + @Operation(summary = "Get HikariCP connection pool stats") + public ResponseEntity getPool() { + HikariDataSource hds = (HikariDataSource) dataSource; + HikariPoolMXBean pool = hds.getHikariPoolMXBean(); + return ResponseEntity.ok(new ConnectionPoolResponse( + pool.getActiveConnections(), pool.getIdleConnections(), + pool.getThreadsAwaitingConnection(), hds.getConnectionTimeout(), + hds.getMaximumPoolSize())); + } + + @GetMapping("/tables") + @Operation(summary = "Get table sizes and row counts") + public ResponseEntity> getTables() { + var tables = jdbc.query(""" + SELECT schemaname || '.' || relname AS table_name, + n_live_tup AS row_count, + pg_size_pretty(pg_total_relation_size(relid)) AS data_size, + pg_total_relation_size(relid) AS data_size_bytes, + pg_size_pretty(pg_indexes_size(relid)) AS index_size, + pg_indexes_size(relid) AS index_size_bytes + FROM pg_stat_user_tables + ORDER BY pg_total_relation_size(relid) DESC + """, (rs, row) -> new TableSizeResponse( + rs.getString("table_name"), rs.getLong("row_count"), + rs.getString("data_size"), rs.getString("index_size"), + rs.getLong("data_size_bytes"), rs.getLong("index_size_bytes"))); + return ResponseEntity.ok(tables); + } + + @GetMapping("/queries") + @Operation(summary = "Get active queries") + public ResponseEntity> getQueries() { + var queries = jdbc.query(""" + SELECT pid, EXTRACT(EPOCH FROM (now() - query_start)) AS duration_seconds, + state, query + FROM pg_stat_activity + WHERE state != 'idle' AND pid != pg_backend_pid() + ORDER BY query_start ASC + """, (rs, row) -> new ActiveQueryResponse( + rs.getInt("pid"), rs.getDouble("duration_seconds"), + rs.getString("state"), rs.getString("query"))); + return ResponseEntity.ok(queries); + } + + @PostMapping("/queries/{pid}/kill") + @Operation(summary = "Terminate a query by PID") + public ResponseEntity killQuery(@PathVariable int pid, HttpServletRequest request) { + var exists = jdbc.queryForObject( + "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE pid = ? AND pid != pg_backend_pid())", + Boolean.class, pid); + if (!Boolean.TRUE.equals(exists)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No active query with PID " + pid); + } + jdbc.queryForObject("SELECT pg_terminate_backend(?)", Boolean.class, pid); + auditService.log("kill_query", AuditCategory.INFRA, "PID " + pid, null, AuditResult.SUCCESS, request); + return ResponseEntity.ok().build(); + } + + private String extractHost(DataSource ds) { + try { + if (ds instanceof HikariDataSource hds) { + return hds.getJdbcUrl(); + } + return "unknown"; + } catch (Exception e) { + return "unknown"; + } + } +} diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DatabaseAdminControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DatabaseAdminControllerIT.java new file mode 100644 index 00000000..dc40bed5 --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DatabaseAdminControllerIT.java @@ -0,0 +1,109 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.AbstractPostgresIT; +import com.cameleer3.server.app.TestSecurityHelper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +class DatabaseAdminControllerIT extends AbstractPostgresIT { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TestSecurityHelper securityHelper; + + private String adminJwt; + private String viewerJwt; + + @BeforeEach + void setUp() { + adminJwt = securityHelper.adminToken(); + viewerJwt = securityHelper.viewerToken(); + } + + @Test + void getStatus_asAdmin_returns200WithConnected() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/database/status", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.get("connected").asBoolean()).isTrue(); + assertThat(body.get("version").asText()).contains("PostgreSQL"); + assertThat(body.has("schema")).isTrue(); + } + + @Test + void getStatus_asViewer_returns403() { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/database/status", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void getPool_asAdmin_returns200WithPoolStats() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/database/pool", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.has("activeConnections")).isTrue(); + assertThat(body.has("idleConnections")).isTrue(); + assertThat(body.get("maxPoolSize").asInt()).isGreaterThan(0); + } + + @Test + void getTables_asAdmin_returns200WithTableList() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/database/tables", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.isArray()).isTrue(); + } + + @Test + void getQueries_asAdmin_returns200() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/database/queries", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.isArray()).isTrue(); + } + + @Test + void killQuery_unknownPid_returns404() { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/database/queries/999999/kill", HttpMethod.POST, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } +}