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:
hsiegeln
2026-03-17 15:57:14 +01:00
parent 0cea8af6bc
commit c6b2f7c331
2 changed files with 238 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.ActiveQueryResponse;
import com.cameleer3.server.app.dto.ConnectionPoolResponse;
import com.cameleer3.server.app.dto.DatabaseStatusResponse;
import com.cameleer3.server.app.dto.TableSizeResponse;
import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditResult;
import com.cameleer3.server.core.admin.AuditService;
import com.zaxxer.hikari.HikariDataSource;
import com.zaxxer.hikari.HikariPoolMXBean;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import javax.sql.DataSource;
import java.util.List;
@RestController
@RequestMapping("/api/v1/admin/database")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Database Admin", description = "Database monitoring and management (ADMIN only)")
public class DatabaseAdminController {
private final JdbcTemplate jdbc;
private final DataSource dataSource;
private final AuditService auditService;
public DatabaseAdminController(JdbcTemplate jdbc, DataSource dataSource, AuditService auditService) {
this.jdbc = jdbc;
this.dataSource = dataSource;
this.auditService = auditService;
}
@GetMapping("/status")
@Operation(summary = "Get database connection status and version")
public ResponseEntity<DatabaseStatusResponse> getStatus() {
try {
String version = jdbc.queryForObject("SELECT version()", String.class);
boolean timescaleDb = Boolean.TRUE.equals(
jdbc.queryForObject("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')", Boolean.class));
String schema = jdbc.queryForObject("SELECT current_schema()", String.class);
String host = extractHost(dataSource);
return ResponseEntity.ok(new DatabaseStatusResponse(true, version, host, schema, timescaleDb));
} catch (Exception e) {
return ResponseEntity.ok(new DatabaseStatusResponse(false, null, null, null, false));
}
}
@GetMapping("/pool")
@Operation(summary = "Get HikariCP connection pool stats")
public ResponseEntity<ConnectionPoolResponse> getPool() {
HikariDataSource hds = (HikariDataSource) dataSource;
HikariPoolMXBean pool = hds.getHikariPoolMXBean();
return ResponseEntity.ok(new ConnectionPoolResponse(
pool.getActiveConnections(), pool.getIdleConnections(),
pool.getThreadsAwaitingConnection(), hds.getConnectionTimeout(),
hds.getMaximumPoolSize()));
}
@GetMapping("/tables")
@Operation(summary = "Get table sizes and row counts")
public ResponseEntity<List<TableSizeResponse>> getTables() {
var tables = jdbc.query("""
SELECT schemaname || '.' || relname AS table_name,
n_live_tup AS row_count,
pg_size_pretty(pg_total_relation_size(relid)) AS data_size,
pg_total_relation_size(relid) AS data_size_bytes,
pg_size_pretty(pg_indexes_size(relid)) AS index_size,
pg_indexes_size(relid) AS index_size_bytes
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
""", (rs, row) -> new TableSizeResponse(
rs.getString("table_name"), rs.getLong("row_count"),
rs.getString("data_size"), rs.getString("index_size"),
rs.getLong("data_size_bytes"), rs.getLong("index_size_bytes")));
return ResponseEntity.ok(tables);
}
@GetMapping("/queries")
@Operation(summary = "Get active queries")
public ResponseEntity<List<ActiveQueryResponse>> getQueries() {
var queries = jdbc.query("""
SELECT pid, EXTRACT(EPOCH FROM (now() - query_start)) AS duration_seconds,
state, query
FROM pg_stat_activity
WHERE state != 'idle' AND pid != pg_backend_pid()
ORDER BY query_start ASC
""", (rs, row) -> new ActiveQueryResponse(
rs.getInt("pid"), rs.getDouble("duration_seconds"),
rs.getString("state"), rs.getString("query")));
return ResponseEntity.ok(queries);
}
@PostMapping("/queries/{pid}/kill")
@Operation(summary = "Terminate a query by PID")
public ResponseEntity<Void> killQuery(@PathVariable int pid, HttpServletRequest request) {
var exists = jdbc.queryForObject(
"SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE pid = ? AND pid != pg_backend_pid())",
Boolean.class, pid);
if (!Boolean.TRUE.equals(exists)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No active query with PID " + pid);
}
jdbc.queryForObject("SELECT pg_terminate_backend(?)", Boolean.class, pid);
auditService.log("kill_query", AuditCategory.INFRA, "PID " + pid, null, AuditResult.SUCCESS, request);
return ResponseEntity.ok().build();
}
private String extractHost(DataSource ds) {
try {
if (ds instanceof HikariDataSource hds) {
return hds.getJdbcUrl();
}
return "unknown";
} catch (Exception e) {
return "unknown";
}
}
}

View File

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