feat #461: add operational status dashboard

This commit is contained in:
devmrko
2026-06-25 21:55:26 +09:00
parent 1869fe46a6
commit 2379e2c9ec
8 changed files with 372 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
# 설계서: ORDS handler와 VPD filter 적용 상태 관측성 강화 (#461)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
> **추적성** — Redmine: #461 · 관련 ADR: 없음
> · 구현 파일: `OperationStatusMapper`, `OperationStatusService`, `OperationStatusController`, `operation-status.html` · 테스트: `mvn test`, Spring Boot 기동
## 1. 목적 (Why)
보호 객체별 ORDS path, handler 존재 여부, VPD policy/function 상태, 권한 rule 수, 최근 ORDS 검증 결과를 한 화면에서 확인하게 한다.
## 2. 범위 (Scope)
- **포함**: 운영 상태 탭 추가, 보호 객체 기준 상태 집계, 상태 badge/조치 문구 표시.
- **제외**: handler source inline 편집, 자동 probe 실행, 장기 검증 로그 검색.
## 3. 인수조건 (Acceptance Criteria)
- [ ] 보호 객체마다 ORDS path와 handler 매칭 상태를 보여준다.
- [ ] VPD policy enabled 여부와 policy function status를 보여준다.
- [ ] permission/rule count와 최근 probe 상태/row count를 보여준다.
- [ ] 문제가 있는 항목에는 조치 문구를 표시한다.
- [ ] 운영 메뉴에서 접근 가능하다.
## 4. 컨텍스트 & 제약
- ORDS metadata는 기존 `dba_ords_*` view를 조회한다.
- 최근 검증은 `cb_ords_probe_audit`의 최신 row를 사용한다.
- VPD status는 `all_policies`, `all_objects`를 사용한다.
## 5. 아키텍처 개요
```
/operation-status
-> OperationStatusController
-> OperationStatusService
-> OperationStatusMapper.findRows()
cb_protected_object
+ all_policies/all_objects
+ dba_ords_* metadata
+ cb_permission/cb_permission_rule
+ cb_ords_probe_audit latest row
```
## 6. 데이터 모델
- `OperationStatusRow`: 한 보호 객체의 end-to-end 상태.
- health:
- `OK`: handler, enabled policy, valid function이 모두 확인됨.
- `WARN`: 일부 미설정 또는 최근 검증 실패.
- `ERROR`: function invalid 등 즉시 조치 필요.
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `findRows` | 보호 객체별 운영 상태를 DB에서 집계 | mapper select | 없음 | rows | DB 예외 | **복잡** |
| `healthLevel` | row 상태를 OK/WARN/ERROR로 분류 | Java method | row fields | string | 없음 | 단순 |
| `actionText` | 운영자가 볼 조치 문구 생성 | Java method | row fields | string | 없음 | 단순 |
## 8. 흐름 / 알고리즘
1. enabled 보호 객체를 조회한다.
2. object별 permission/rule count를 집계한다.
3. object별 VPD policy와 function status를 left join한다.
4. ORDS path와 metadata full path를 비교해 handler 상태를 찾는다.
5. 최신 audit row를 붙인다.
6. 화면에서 health와 action text를 계산해 표시한다.
## 9. 엣지케이스 & 에러 처리
- ORDS metadata 권한이 없으면 페이지 전체가 DB 예외를 받을 수 있다. 기존 ORDS handler 화면과 같은 전제다.
- policy가 여러 개면 policy 이름을 `LISTAGG`로 보여준다.
- 최근 probe가 없으면 `검증 이력 없음`으로 표시한다.
## 10. 테스트 계획
- `mvn test`
- Spring Boot 재기동
- `/operation-status` HTTP 200 확인
## 11. 리스크 & 대안 검토
- 선택: DB join 기반 read-only 화면. 실행 비용이 낮고 운영자가 한 번에 상태를 볼 수 있다.
- 대안: 각 화면 fragment를 iframe/htmx로 모으기. 중복 조회가 많고 상태 판정이 분산된다.
## 12. 미해결 질문 (Open Questions)
- 최신 probe 상세 request/response 저장은 현재 audit 테이블에 없으므로 별도 이슈로 확장할 수 있다.

View File

@@ -0,0 +1,69 @@
package com.cloudhandson.vpdbackoffice.domain.operation;
public record OperationStatusRow(
long objectId,
String owner,
String objectName,
String ordsPath,
String enabledYn,
Integer permissionCount,
Integer ruleCount,
String policyNames,
String policyEnabled,
String functionName,
String functionStatus,
Long handlerId,
String handlerMethod,
String handlerSourceType,
String handlerFullPath,
String lastProbeStatus,
Integer lastProbeRowCount,
String lastProbeErrorCode,
String lastProbeAt
) {
public String displayName() {
return owner + "." + objectName;
}
public String healthLevel() {
if (functionStatus != null && !"VALID".equalsIgnoreCase(functionStatus)) {
return "ERROR";
}
if (handlerId == null || policyNames == null || policyNames.isBlank()
|| policyEnabled == null || !policyEnabled.toUpperCase().contains("YES")) {
return "WARN";
}
if (lastProbeStatus != null && !lastProbeStatus.isBlank()
&& !lastProbeStatus.toUpperCase().contains("SUCCESS")
&& !lastProbeStatus.toUpperCase().contains("ALLOW")) {
return "WARN";
}
return "OK";
}
public String actionText() {
if (handlerId == null) {
return "ORDS path와 handler schema/module/template 매핑을 확인하세요.";
}
if (policyNames == null || policyNames.isBlank()) {
return "VPD policy를 적용하세요.";
}
if (policyEnabled == null || !policyEnabled.toUpperCase().contains("YES")) {
return "VPD policy enable 상태를 확인하세요.";
}
if (functionStatus != null && !"VALID".equalsIgnoreCase(functionStatus)) {
return "Policy function 컴파일 오류를 확인하세요.";
}
if (lastProbeStatus == null || lastProbeStatus.isBlank()) {
return "ORDS 검증을 한 번 실행하세요.";
}
return "현재 상태에서 즉시 조치할 항목은 없습니다.";
}
public String permissionSummary() {
int permissions = permissionCount == null ? 0 : permissionCount;
int rules = ruleCount == null ? 0 : ruleCount;
return permissions + " permissions / " + rules + " rules";
}
}

View File

@@ -0,0 +1,11 @@
package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.operation.OperationStatusRow;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OperationStatusMapper {
List<OperationStatusRow> findRows();
}

View File

@@ -0,0 +1,20 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.operation.OperationStatusRow;
import com.cloudhandson.vpdbackoffice.mapper.OperationStatusMapper;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class OperationStatusService {
private final OperationStatusMapper mapper;
public OperationStatusService(OperationStatusMapper mapper) {
this.mapper = mapper;
}
public List<OperationStatusRow> findRows() {
return mapper.findRows();
}
}

View File

@@ -0,0 +1,22 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.OperationStatusService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class OperationStatusController {
private final OperationStatusService service;
public OperationStatusController(OperationStatusService service) {
this.service = service;
}
@GetMapping("/operation-status")
public String status(Model model) {
model.addAttribute("rows", service.findRows());
return "operation-status";
}
}

View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.OperationStatusMapper">
<select id="findRows" resultType="com.cloudhandson.vpdbackoffice.domain.operation.OperationStatusRow">
WITH permission_counts AS (
SELECT p.target_name,
COUNT(DISTINCT p.perm_id) AS permission_count,
COUNT(r.rule_id) AS rule_count
FROM cb_permission p
LEFT JOIN cb_permission_rule r ON r.perm_id = p.perm_id
GROUP BY p.target_name
),
policy_status AS (
SELECT p.object_owner,
p.object_name,
LISTAGG(p.policy_name, ', ') WITHIN GROUP (ORDER BY p.policy_name) AS policy_names,
LISTAGG(p.enable, ', ') WITHIN GROUP (ORDER BY p.policy_name) AS policy_enabled,
LISTAGG(p.pf_owner || '.' || NVL2(p.package, p.package || '.', '') || p.function, ', ')
WITHIN GROUP (ORDER BY p.policy_name) AS function_name,
LISTAGG(NVL(o.status, 'UNKNOWN'), ', ') WITHIN GROUP (ORDER BY p.policy_name) AS function_status
FROM all_policies p
LEFT JOIN all_objects o
ON o.owner = p.pf_owner
AND o.object_name = NVL(p.package, p.function)
AND o.object_type IN ('FUNCTION', 'PACKAGE', 'PACKAGE BODY')
GROUP BY p.object_owner, p.object_name
),
ords_handlers AS (
SELECT h.id AS handler_id,
h.method AS handler_method,
h.source_type AS handler_source_type,
TRIM(BOTH '/' FROM s.pattern) || '/' ||
TRIM(BOTH '/' FROM m.uri_prefix) || '/' ||
TRIM(BOTH '/' FROM t.uri_template) AS handler_full_path
FROM dba_ords_schemas s
JOIN dba_ords_modules m ON m.schema_id = s.id
JOIN dba_ords_templates t ON t.module_id = m.id
JOIN dba_ords_handlers h ON h.template_id = t.id
WHERE s.parsing_schema = 'CB_ORDS'
),
latest_probe AS (
SELECT object_id,
status AS last_probe_status,
row_count AS last_probe_row_count,
error_code AS last_probe_error_code,
TO_CHAR(created_at, 'YYYY-MM-DD HH24:MI:SS') AS last_probe_at
FROM (
SELECT a.*,
ROW_NUMBER() OVER (PARTITION BY a.object_id ORDER BY a.created_at DESC, a.audit_id DESC) AS rn
FROM cb_ords_probe_audit a
WHERE a.object_id IS NOT NULL
)
WHERE rn = 1
)
SELECT po.object_id,
po.owner,
po.object_name,
po.ords_path,
po.enabled_yn,
NVL(pc.permission_count, 0) AS permission_count,
NVL(pc.rule_count, 0) AS rule_count,
ps.policy_names,
ps.policy_enabled,
ps.function_name,
ps.function_status,
oh.handler_id,
oh.handler_method,
oh.handler_source_type,
oh.handler_full_path,
lp.last_probe_status,
lp.last_probe_row_count,
lp.last_probe_error_code,
lp.last_probe_at
FROM cb_protected_object po
LEFT JOIN permission_counts pc ON pc.target_name = po.object_name
LEFT JOIN policy_status ps ON ps.object_owner = po.owner AND ps.object_name = po.object_name
LEFT JOIN ords_handlers oh ON oh.handler_full_path = TRIM(BOTH '/' FROM po.ords_path)
LEFT JOIN latest_probe lp ON lp.object_id = po.object_id
WHERE po.enabled_yn = 'Y'
ORDER BY po.owner, po.object_name
</select>
</mapper>

View File

@@ -44,6 +44,7 @@
<div class="rw-menu-group"> <div class="rw-menu-group">
<button class="rw-menu-trigger" type="button">운영</button> <button class="rw-menu-trigger" type="button">운영</button>
<div class="rw-menu-panel"> <div class="rw-menu-panel">
<a class="nav-link" href="/operation-status">운영 상태</a>
<a class="nav-link" href="/vpd-policies">VPD 설정</a> <a class="nav-link" href="/vpd-policies">VPD 설정</a>
<a class="nav-link" href="/vpd-filter-policies">Filter Policy 관리</a> <a class="nav-link" href="/vpd-filter-policies">Filter Policy 관리</a>
<a class="nav-link" href="/settings">설정</a> <a class="nav-link" href="/settings">설정</a>

View File

@@ -0,0 +1,77 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('운영 상태')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>운영 상태</h1>
<p>보호 객체별 ORDS handler, VPD policy, 권한 rule, 최근 검증 상태를 확인합니다.</p>
</div>
<section class="content-band">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Health</th>
<th>Object</th>
<th>ORDS</th>
<th>VPD</th>
<th>권한</th>
<th>최근 검증</th>
<th>조치</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${rows}">
<td>
<span class="badge"
th:classappend="${row.healthLevel() == 'OK'} ? ' text-bg-success' : (${row.healthLevel() == 'ERROR'} ? ' text-bg-danger' : ' text-bg-warning')"
th:text="${row.healthLevel()}">OK</span>
</td>
<td>
<strong th:text="${row.displayName()}">ADMIN.TABLE</strong>
<div class="text-muted small" th:text="${row.enabledYn()}">Y</div>
</td>
<td>
<code th:text="${row.ordsPath()}">cb-ords/path</code>
<div class="small mt-1">
<span th:if="${row.handlerId() != null}">
<span class="badge text-bg-secondary" th:text="${row.handlerMethod()}">POST</span>
<span th:text="${row.handlerSourceType()}">plsql/block</span>
</span>
<span th:unless="${row.handlerId() != null}" class="text-danger">handler 없음</span>
</div>
</td>
<td>
<div><code th:text="${row.policyNames()} ?: '-'">POLICY</code></div>
<div class="small">
<span th:text="${row.policyEnabled()} ?: '-'">YES</span>
<span> / </span>
<span th:text="${row.functionStatus()} ?: '-'">VALID</span>
</div>
<div class="text-muted small" th:text="${row.functionName()} ?: '-'">ADMIN.FILTER</div>
</td>
<td th:text="${row.permissionSummary()}">1 permissions / 1 rules</td>
<td>
<div th:text="${row.lastProbeStatus()} ?: '검증 이력 없음'">SUCCESS</div>
<div class="text-muted small">
<span th:text="${row.lastProbeRowCount()} ?: '-'">0</span>
<span> rows</span>
<span th:if="${row.lastProbeAt() != null}" th:text="${' / ' + row.lastProbeAt()}"></span>
</div>
<div class="text-danger small" th:if="${row.lastProbeErrorCode() != null}" th:text="${row.lastProbeErrorCode()}"></div>
</td>
<td class="small" th:text="${row.actionText()}">조치 없음</td>
</tr>
<tr th:if="${#lists.isEmpty(rows)}">
<td colspan="7" class="text-muted">등록된 활성 보호 객체가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>