Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import br.com.fiap.vigisus.exception.MunicipioNotFoundException;
import br.com.fiap.vigisus.exception.RecursoNaoEncontradoException;
import br.com.fiap.vigisus.model.Municipio;
import br.com.fiap.vigisus.service.AdminMetricsService;
import br.com.fiap.vigisus.service.EncaminhamentoService;
import br.com.fiap.vigisus.service.IaService;
import br.com.fiap.vigisus.service.MunicipioService;
Expand All @@ -34,12 +35,15 @@ public class BuscaCompletaUseCase {
private final PrevisaoRiscoService previsaoRiscoService;
private final EncaminhamentoService encaminhamentoService;
private final MunicipioService municipioService;
private final AdminMetricsService adminMetricsService;

public BuscaCompletaResponse buscarPorPergunta(String pergunta) {
adminMetricsService.registrarBuscaIa(pergunta);
IntencaoDTO intencao = iaService.interpretarPergunta(pergunta);
Municipio municipio = encontrarMunicipio(intencao.getMunicipio(), intencao.getUf());
String coIbge = municipio.getCoIbge();
intencao.setCoIbge(coIbge);
adminMetricsService.registrarBusca(municipio.getNoMunicipio(), municipio.getSgUf());

BuscaCompletaResponse response = buscarPorCoIbge(
coIbge,
Expand All @@ -56,6 +60,8 @@ public BuscaCompletaResponse buscarDireto(String municipio, String uf, String do
.findFirst()
.orElseThrow(() -> new MunicipioNotFoundException(municipio.trim() + " / " + uf.trim()));

adminMetricsService.registrarBusca(municipioEncontrado.getNoMunicipio(), municipioEncontrado.getSgUf());

return buscarPorCoIbge(
municipioEncontrado.getCoIbge(),
resolverDoenca(doenca),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
* Por serem dados abertos, todos os endpoints de consulta são públicos.
* Não há dados pessoais de pacientes — apenas estatísticas agregadas.
*
* NOTA: /actuator/** e /admin/** são servidos exclusivamente na porta 9090
* (management.server.port), portanto não precisam de regras aqui.
*
* ROADMAP v2.0:
* Endpoints administrativos (/api/admin/**, /actuator/**) receberão
* autenticação JWT para operadores de saúde pública.
* Endpoints administrativos receberão autenticação JWT para operadores de saúde pública.
*/
@Configuration
@EnableWebSecurity
Expand All @@ -28,7 +30,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configure(http))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/api/**").permitAll()
.anyRequest().authenticated()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package br.com.fiap.vigisus.controller;

import br.com.fiap.vigisus.service.AdminMetricsService;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* Dashboard administrativo do VigiSUS.
*
* <p>Este controller é servido exclusivamente na porta 9090
* (management.server.port), nunca exposto na porta pública 8080.
* A anotação @Hidden impede que apareça no Swagger público.
*/
@Hidden
@RestController
@RequestMapping("/admin")
@RequiredArgsConstructor
public class AdminDashboardController {

private final AdminMetricsService adminMetricsService;

@GetMapping("/resumo")
public ResponseEntity<Map<String, Object>> resumo() {
Map<String, Object> kpis = new LinkedHashMap<>();
kpis.put("buscas_total", adminMetricsService.getBuscasTotal());
kpis.put("buscas_ia", adminMetricsService.getBuscasIa());
kpis.put("triagens", adminMetricsService.getTriagens());
kpis.put("cache_hits", adminMetricsService.getCacheHits());
kpis.put("timestamp", Instant.now().toString());
return ResponseEntity.ok(kpis);
}

@GetMapping("/top-municipios")
public ResponseEntity<List<Map<String, Object>>> topMunicipios(
@RequestParam(defaultValue = "10") int top) {
return ResponseEntity.ok(adminMetricsService.getTopMunicipios(top));
}

@GetMapping("/top-estados")
public ResponseEntity<List<Map<String, Object>>> topEstados(
@RequestParam(defaultValue = "10") int top) {
return ResponseEntity.ok(adminMetricsService.getTopEstados(top));
}

@GetMapping("/municipios-risco")
public ResponseEntity<List<Map<String, Object>>> municipiosRisco(
@RequestParam(defaultValue = "50") int top) {
return ResponseEntity.ok(adminMetricsService.getTopMunicipios(top));
}

@GetMapping("/buscas-ia")
public ResponseEntity<List<Map<String, Object>>> buscasIa(
@RequestParam(defaultValue = "20") int top) {
return ResponseEntity.ok(adminMetricsService.getTopPerguntasIa(top));
}

@GetMapping(value = "/index.html", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> dashboard() {
String html = """
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VigiSUS — Dashboard Admin</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; background: #f4f6f9; color: #333; }
header { background: #0a6e3f; color: white; padding: 16px 24px; }
header h1 { margin: 0; font-size: 1.5rem; }
.container { max-width: 1100px; margin: 24px auto; padding: 0 16px; }
.cards { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 32px; }
.card { background: white; border-radius: 8px; box-shadow: 0 2px 6px rgba(0,0,0,.1);
padding: 20px 24px; flex: 1; min-width: 180px; }
.card h2 { margin: 0 0 8px; font-size: .85rem; color: #666; text-transform: uppercase; }
.card .value { font-size: 2rem; font-weight: bold; color: #0a6e3f; }
table { width: 100%; border-collapse: collapse; background: white;
border-radius: 8px; overflow: hidden;
box-shadow: 0 2px 6px rgba(0,0,0,.1); margin-bottom: 32px; }
th { background: #0a6e3f; color: white; padding: 10px 14px; text-align: left; font-size: .85rem; }
td { padding: 10px 14px; border-bottom: 1px solid #eee; font-size: .9rem; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f0faf5; }
h3 { color: #0a6e3f; margin-bottom: 8px; }
.refresh { float: right; background: #0a6e3f; color: white; border: none;
padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: .85rem; }
.refresh:hover { background: #085c34; }
footer { text-align: center; padding: 24px; color: #999; font-size: .8rem; }
</style>
</head>
<body>
<header>
<h1>&#128200; VigiSUS — Dashboard Administrativo</h1>
</header>
<div class="container">
<button class="refresh" onclick="location.reload()">&#8635; Atualizar</button>
<br><br>
<div class="cards" id="kpis">Carregando KPIs...</div>
<h3>&#127970; Top Municípios Consultados</h3>
<table id="tbl-municipios">
<thead><tr><th>#</th><th>Município</th><th>Consultas</th></tr></thead>
<tbody>Carregando...</tbody>
</table>
<h3>&#127463;&#127479; Top Estados Consultados</h3>
<table id="tbl-estados">
<thead><tr><th>#</th><th>Estado</th><th>Consultas</th></tr></thead>
<tbody>Carregando...</tbody>
</table>
<h3>&#129302; Perguntas Frequentes (IA)</h3>
<table id="tbl-ia">
<thead><tr><th>#</th><th>Pergunta</th><th>Frequência</th></tr></thead>
<tbody>Carregando...</tbody>
</table>
</div>
<footer>VigiSUS &mdash; Plataforma de Vigilância Epidemiológica do SUS &mdash; Admin Port 9090</footer>
<script>
async function carregarKpis() {
const r = await fetch('/admin/resumo');
const d = await r.json();
document.getElementById('kpis').innerHTML = [
['Buscas Total', d.buscas_total],
['Buscas com IA', d.buscas_ia],
['Triagens', d.triagens],
['Cache Hits', d.cache_hits],
].map(([t,v]) => `<div class="card"><h2>${t}</h2><div class="value">${v}</div></div>`).join('');
}
async function carregarTabela(url, id, colunas) {
const r = await fetch(url);
const rows = await r.json();
const tbody = document.querySelector(`#${id} tbody`);
if (!rows.length) { tbody.innerHTML = '<tr><td colspan="3" style="color:#999">Sem dados ainda</td></tr>'; return; }
tbody.innerHTML = rows.map(row =>
`<tr>${colunas.map(c => `<td>${row[c] ?? ''}</td>`).join('')}</tr>`
).join('');
}
carregarKpis();
carregarTabela('/admin/top-municipios?top=10', 'tbl-municipios', ['posicao','nome','total']);
carregarTabela('/admin/top-estados?top=10', 'tbl-estados', ['posicao','nome','total']);
carregarTabela('/admin/buscas-ia?top=20', 'tbl-ia', ['posicao','nome','total']);
</script>
</body>
</html>
""";
return ResponseEntity.ok()
.contentType(MediaType.TEXT_HTML)
.body(html);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package br.com.fiap.vigisus.service;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

/**
* Coleta e expõe métricas operacionais do VigiSUS para o dashboard administrativo.
* Os contadores são mantidos em memória e também registrados no MeterRegistry
* para exposição via /actuator/metrics na porta 9090.
*/
@Service
public class AdminMetricsService {

private final Counter buscasTotalCounter;
private final Counter buscasIaCounter;
private final Counter triagensCounter;
private final Counter cacheHitsCounter;
private final Counter buscasMunicipioCounter;

private final AtomicLong buscasTotal = new AtomicLong(0);
private final AtomicLong buscasIa = new AtomicLong(0);
private final AtomicLong triagens = new AtomicLong(0);
private final AtomicLong cacheHits = new AtomicLong(0);

private final ConcurrentHashMap<String, AtomicLong> contagemMunicipios = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, AtomicLong> contagemEstados = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, AtomicLong> contagemPerguntasIa = new ConcurrentHashMap<>();

public AdminMetricsService(MeterRegistry meterRegistry) {
this.buscasTotalCounter = Counter.builder("vigisus.buscas.total")
.description("Total de buscas realizadas")
.register(meterRegistry);
this.buscasIaCounter = Counter.builder("vigisus.buscas.ia")
.description("Buscas que utilizaram interpretação por IA")
.register(meterRegistry);
this.triagensCounter = Counter.builder("vigisus.triagens.total")
.description("Total de triagens realizadas")
.register(meterRegistry);
this.cacheHitsCounter = Counter.builder("vigisus.cache.hits")
.description("Total de acertos de cache")
.register(meterRegistry);
this.buscasMunicipioCounter = Counter.builder("vigisus.buscas.municipio")
.description("Total de buscas que identificaram um município")
.register(meterRegistry);
}

public void registrarBusca(String municipio, String sgUf) {
buscasTotalCounter.increment();
buscasTotal.incrementAndGet();
if (municipio != null && !municipio.isBlank()) {
buscasMunicipioCounter.increment();
String chave = municipio.trim() + (sgUf != null ? "/" + sgUf.trim().toUpperCase() : "");
contagemMunicipios.computeIfAbsent(chave, k -> new AtomicLong(0)).incrementAndGet();
if (sgUf != null && !sgUf.isBlank()) {
contagemEstados.computeIfAbsent(sgUf.trim().toUpperCase(), k -> new AtomicLong(0)).incrementAndGet();
}
}
}

public void registrarBuscaIa(String pergunta) {
buscasIaCounter.increment();
buscasIa.incrementAndGet();
if (pergunta != null && !pergunta.isBlank()) {
String chave = pergunta.trim().toLowerCase();
contagemPerguntasIa.computeIfAbsent(chave, k -> new AtomicLong(0)).incrementAndGet();
}
}

public void registrarTriagem() {
triagensCounter.increment();
triagens.incrementAndGet();
}

public void registrarCacheHit() {
cacheHitsCounter.increment();
cacheHits.incrementAndGet();
}

public long getBuscasTotal() {
return buscasTotal.get();
}

public long getBuscasIa() {
return buscasIa.get();
}

public long getTriagens() {
return triagens.get();
}

public long getCacheHits() {
return cacheHits.get();
}

public List<Map<String, Object>> getTopMunicipios(int top) {
return rankearContagem(contagemMunicipios, top);
}

public List<Map<String, Object>> getTopEstados(int top) {
return rankearContagem(contagemEstados, top);
}

public List<Map<String, Object>> getTopPerguntasIa(int top) {
return rankearContagem(contagemPerguntasIa, top);
}

private List<Map<String, Object>> rankearContagem(ConcurrentHashMap<String, AtomicLong> contagem, int top) {
List<Map.Entry<String, AtomicLong>> entries = new ArrayList<>(contagem.entrySet());
entries.sort(Comparator.comparingLong((Map.Entry<String, AtomicLong> e) -> e.getValue().get()).reversed());

List<Map<String, Object>> resultado = new ArrayList<>();
int limite = Math.min(top, entries.size());
for (int i = 0; i < limite; i++) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("nome", entries.get(i).getKey());
item.put("total", entries.get(i).getValue().get());
item.put("posicao", i + 1);
resultado.add(Collections.unmodifiableMap(item));
}
return Collections.unmodifiableList(resultado);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import br.com.fiap.vigisus.dto.PerfilEpidemiologicoResponse;
import br.com.fiap.vigisus.dto.TriagemRequest;
import br.com.fiap.vigisus.dto.TriagemResponse;
import br.com.fiap.vigisus.service.AdminMetricsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
Expand Down Expand Up @@ -41,8 +42,10 @@ public class TriagemService {
private final IaService iaService;
private final CalculadoraScoreTriagem calculadoraScoreTriagem;
private final PriorizacaoTriagemPolicy priorizacaoTriagemPolicy;
private final AdminMetricsService adminMetricsService;

public TriagemResponse avaliar(TriagemRequest req) {
adminMetricsService.registrarTriagem();
double score = calcularScore(req);

int anoAtual = Year.now().getValue();
Expand Down
5 changes: 4 additions & 1 deletion backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ server:
port: 8080

management:
server:
port: 9090
endpoints:
web:
exposure:
include: health,info
include: health,info,metrics
base-path: /actuator
endpoint:
health:
show-details: always
Expand Down
Loading