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,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">
<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>

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>