AdminApiController.java
package com.taxonomy.shared.controller;
import com.taxonomy.analysis.service.LlmProvider;
import com.taxonomy.analysis.service.LlmProviderConfig;
import com.taxonomy.analysis.service.LlmService;
import com.taxonomy.catalog.service.TaxonomyService;
import com.taxonomy.dto.AiAvailabilityLevel;
import com.taxonomy.dto.AiStatusResponse;
import com.taxonomy.shared.service.HealthSummaryService;
import com.taxonomy.shared.service.LogRingBufferService;
import com.taxonomy.shared.service.PromptTemplateService;
import com.taxonomy.shared.service.PromptTemplateService.PromptCategory;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
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.web.bind.annotation.DeleteMapping;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api")
@Tag(name = "Administration")
public class AdminApiController {
private final LlmService llmService;
private final LlmProviderConfig llmProviderConfig;
private final PromptTemplateService promptTemplateService;
private final TaxonomyService taxonomyService;
private final LogRingBufferService logRingBufferService;
private final HealthSummaryService healthSummaryService;
public AdminApiController(LlmService llmService,
LlmProviderConfig llmProviderConfig,
PromptTemplateService promptTemplateService,
TaxonomyService taxonomyService,
LogRingBufferService logRingBufferService,
HealthSummaryService healthSummaryService) {
this.llmService = llmService;
this.llmProviderConfig = llmProviderConfig;
this.promptTemplateService = promptTemplateService;
this.taxonomyService = taxonomyService;
this.logRingBufferService = logRingBufferService;
this.healthSummaryService = healthSummaryService;
}
@Operation(summary = "Check AI availability",
description = "Returns whether an LLM provider is available and which one is active",
tags = {"Administration"})
@GetMapping("/ai-status")
public ResponseEntity<AiStatusResponse> aiStatus() {
AiAvailabilityLevel level = llmService.getAvailabilityLevel();
String provider = level != AiAvailabilityLevel.UNAVAILABLE
? llmService.getActiveProviderName() : null;
return ResponseEntity.ok(new AiStatusResponse(level, provider, llmService.getAvailableProviders()));
}
@Operation(summary = "Startup status",
description = "Returns the initialization state of the taxonomy data. Poll this endpoint after receiving a 503 to know when the app is ready.",
tags = {"Status"})
@GetMapping("/status/startup")
public ResponseEntity<Map<String, Object>> startupStatus() {
Map<String, Object> status = new LinkedHashMap<>();
status.put("initialized", taxonomyService.isInitialized());
status.put("status", taxonomyService.getInitStatus());
com.taxonomy.shared.service.AppInitializationStateService stateService = taxonomyService.getStateService();
status.put("phase", stateService.getState().name());
status.put("phaseMessage", stateService.getMessage());
status.put("phaseUpdatedAt", stateService.getUpdatedAt().toString());
Runtime rt = Runtime.getRuntime();
long heapUsed = rt.totalMemory() - rt.freeMemory();
long heapMax = rt.maxMemory();
Map<String, Object> memory = new LinkedHashMap<>();
memory.put("heapUsedMB", heapUsed / (1024 * 1024));
memory.put("heapMaxMB", heapMax / (1024 * 1024));
memory.put("heapUsagePercent", Math.round((double) heapUsed / heapMax * 100));
memory.put("threadCount", Thread.activeCount());
status.put("memory", memory);
return ResponseEntity.ok(status);
}
@Operation(summary = "Get LLM diagnostics",
description = "Returns diagnostic information about the LLM provider (ROLE_ADMIN only)",
tags = {"Administration"})
@ApiResponse(responseCode = "403", description = "ROLE_ADMIN required")
@GetMapping("/diagnostics")
public ResponseEntity<Map<String, Object>> diagnostics(HttpServletRequest request) {
if (!isAdminAuthorized(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
Map<String, Object> diagnostics = new LinkedHashMap<>(llmService.getDiagnostics());
LlmProvider provider = llmService.getActiveProvider();
diagnostics.put("providerConfigured", llmProviderConfig.isProviderConfigured(provider));
if (provider == LlmProvider.CUSTOM_OPENAI
&& !llmProviderConfig.hasConfiguredApiKey(provider)) {
// The custom endpoint is intentionally usable without authentication. Do not expose
// the internal transport marker as though it were a configured operator secret.
diagnostics.put("apiKeyConfigured", false);
diagnostics.put("apiKeyPrefix", null);
diagnostics.put("authenticationMode", "NONE");
} else if (llmProviderConfig.hasConfiguredApiKey(provider)) {
diagnostics.put("authenticationMode", "BEARER");
} else {
diagnostics.put("authenticationMode", "NOT_APPLICABLE");
}
return ResponseEntity.ok(diagnostics);
}
@Operation(summary = "Check admin UI authorization",
description = "Returns whether the authenticated user must remain outside the admin UI",
tags = {"Administration"})
@GetMapping("/admin/status")
public ResponseEntity<Map<String, Boolean>> adminStatus(HttpServletRequest request) {
return ResponseEntity.ok(Map.of("passwordRequired", !isAdminAuthorized(request)));
}
/**
* Backward-compatible endpoint for the existing admin modal. Authorization is
* derived exclusively from the authenticated ROLE_ADMIN identity; a second
* application password is intentionally not accepted.
*/
@Operation(summary = "Verify admin authorization",
description = "Confirms whether the authenticated user has ROLE_ADMIN",
tags = {"Administration"})
@PostMapping("/admin/verify")
public ResponseEntity<Map<String, Object>> verifyAdmin(
@RequestBody(required = false) Map<String, String> ignoredBody,
HttpServletRequest request) {
boolean valid = isAdminAuthorized(request);
Map<String, Object> response = new LinkedHashMap<>();
response.put("valid", valid);
if (valid) {
// Kept only for compatibility with older UI code. The server never
// trusts this value; every protected request is authorized by ROLE_ADMIN.
response.put("token", "role-admin");
}
return ResponseEntity.ok(response);
}
@Operation(summary = "List all prompt templates",
description = "Returns all prompt templates (ROLE_ADMIN only)",
tags = {"Administration"})
@GetMapping("/prompts")
public ResponseEntity<List<Map<String, Object>>> getAllPrompts(HttpServletRequest request) {
if (!isAdminAuthorized(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
List<Map<String, Object>> result = new ArrayList<>();
for (String code : promptTemplateService.getAllTemplateCodes()) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("code", code);
entry.put("name", promptTemplateService.getTaxonomyName(code));
entry.put("template", promptTemplateService.getTemplate(code));
entry.put("overridden", promptTemplateService.isOverridden(code));
result.add(entry);
}
return ResponseEntity.ok(result);
}
@Operation(summary = "Get prompt template",
description = "Returns a specific prompt template by code (ROLE_ADMIN only)",
tags = {"Administration"})
@GetMapping("/prompts/{code}")
public ResponseEntity<Map<String, Object>> getPrompt(
@Parameter(description = "Template code") @PathVariable String code,
HttpServletRequest request) {
if (!isAdminAuthorized(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("code", code);
result.put("name", promptTemplateService.getTaxonomyName(code));
result.put("template", promptTemplateService.getTemplate(code));
result.put("defaultTemplate", promptTemplateService.getDefaultTemplate(code));
result.put("overridden", promptTemplateService.isOverridden(code));
return ResponseEntity.ok(result);
}
@Operation(summary = "Update prompt template",
description = "Overrides a prompt template (ROLE_ADMIN only)",
tags = {"Administration"})
@PutMapping("/prompts/{code}")
public ResponseEntity<Map<String, Object>> savePrompt(
@PathVariable String code,
@RequestBody Map<String, String> body,
HttpServletRequest request) {
if (!isAdminAuthorized(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
String template = body.get("template");
if (template == null) {
return ResponseEntity.badRequest().build();
}
promptTemplateService.setTemplate(code, template);
return ResponseEntity.ok(Map.of("code", code, "overridden", true));
}
@Operation(summary = "Reset prompt template",
description = "Resets a prompt template to its default (ROLE_ADMIN only)",
tags = {"Administration"})
@DeleteMapping("/prompts/{code}")
public ResponseEntity<Map<String, Object>> resetPrompt(
@PathVariable String code,
HttpServletRequest request) {
if (!isAdminAuthorized(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
promptTemplateService.resetTemplate(code);
return ResponseEntity.ok(Map.of("code", code, "overridden", false));
}
@Operation(summary = "List prompt templates by category",
description = "Returns all prompt templates grouped by category (ROLE_ADMIN only)",
tags = {"Administration"})
@GetMapping("/prompts/categories")
public ResponseEntity<Map<String, List<Map<String, Object>>>> getPromptsByCategory(
HttpServletRequest request) {
if (!isAdminAuthorized(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
Map<String, List<Map<String, Object>>> categorized = new LinkedHashMap<>();
for (PromptCategory category : PromptCategory.values()) {
List<Map<String, Object>> entries = new ArrayList<>();
for (String code : promptTemplateService.getTemplateCodesByCategory(category)) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("code", code);
entry.put("name", promptTemplateService.getTaxonomyName(code));
entry.put("template", promptTemplateService.getTemplate(code));
entry.put("overridden", promptTemplateService.isOverridden(code));
entries.add(entry);
}
categorized.put(category.name(), entries);
}
return ResponseEntity.ok(categorized);
}
@Operation(summary = "Get recent log entries",
description = "Returns recent application log entries from the in-memory ring buffer (ROLE_ADMIN only)",
tags = {"Administration"})
@GetMapping("/admin/logs")
public ResponseEntity<List<LogRingBufferService.LogEntry>> getLogs(
@Parameter(description = "Filter by log level (e.g. ERROR, WARN, INFO)")
@RequestParam(required = false) String level,
@Parameter(description = "Filter by logger name substring")
@RequestParam(required = false) String component,
@Parameter(description = "Max number of entries to return (max 500)")
@RequestParam(defaultValue = "100") int limit,
HttpServletRequest request) {
if (!isAdminAuthorized(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
return ResponseEntity.ok(logRingBufferService.getEntries(level, component, Math.min(limit, 500)));
}
@Operation(summary = "Get health summary",
description = "Aggregated health status from startup, AI, embedding, and memory subsystems (ROLE_ADMIN only)",
tags = {"Administration"})
@GetMapping("/admin/health-summary")
public ResponseEntity<Map<String, Object>> getHealthSummary(HttpServletRequest request) {
if (!isAdminAuthorized(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
return ResponseEntity.ok(healthSummaryService.getSummary());
}
private boolean isAdminAuthorized(HttpServletRequest request) {
return request.isUserInRole("ADMIN") || request.isUserInRole("ROLE_ADMIN");
}
}