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