feat #461: add operational status dashboard
This commit is contained in:
89
docs/design/461-operational-observability/README.md
Normal file
89
docs/design/461-operational-observability/README.md
Normal 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 테이블에 없으므로 별도 이슈로 확장할 수 있다.
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
83
src/main/resources/mapper/OperationStatusMapper.xml
Normal file
83
src/main/resources/mapper/OperationStatusMapper.xml
Normal 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>
|
||||
@@ -44,6 +44,7 @@
|
||||
<div class="rw-menu-group">
|
||||
<button class="rw-menu-trigger" type="button">운영</button>
|
||||
<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-filter-policies">Filter Policy 관리</a>
|
||||
<a class="nav-link" href="/settings">설정</a>
|
||||
|
||||
77
src/main/resources/templates/operation-status.html
Normal file
77
src/main/resources/templates/operation-status.html
Normal 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>
|
||||
Reference in New Issue
Block a user