feat: add DatabaseAdminController with status, pool, tables, queries, and kill endpoints
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user