13 Commits

Author SHA1 Message Date
Rinjae 615e56687a Merge remote-tracking branch 'origin/master'
eapim-admin CI / build (push) Has been cancelled
2026-08-06 17:05:50 +09:00
Rinjae ba1161be7b Quartz 스케줄러 상태 JSP 추가
- Quartz 스케줄러 상태 표시용 quartzStatus.jsp 신규 작성
- 스케줄러/잡/트리거 정보 표시 및 자동 새로고침 기능 구현
2026-08-06 16:57:39 +09:00
eastargh 7430b03323 다시 적용
eapim-admin CI / build (push) Has been cancelled
2026-08-06 11:33:52 +09:00
eastargh 5b05489924 일단 원래데로
eapim-admin CI / build (push) Has been cancelled
2026-08-06 11:05:45 +09:00
eastargh 3c386b780b Merge branch 'master' of https://git.eactive.synology.me:8090/eapim/djbank/eapim-admin.git
eapim-admin CI / build (push) Has been cancelled
2026-08-06 10:47:01 +09:00
eastargh f928a0b1e4 전문매핑 단일 연결/해제 버튼 추가 2026-08-06 10:46:53 +09:00
Rinjae dc9e737aa9 IP 매칭 유틸리티 추가 - CIDR/와일드카드 포함(#194).
eapim-admin CI / build (push) Has been cancelled
포탈 캐시 리로드 다중 서버 지원 - UI 개선/동시 호출 추가.
내부 API 유틸/테스트 및 JSP UI 연계 최적화.
2026-08-06 09:59:23 +09:00
eastargh db57e95233 이벤트유형 CONTROL_ > CHECK_ 로 변경
eapim-admin CI / build (push) Has been cancelled
2026-08-06 09:15:57 +09:00
Rinjae 643e7fcdf2 damo-manager.jar 라이브러리 업데이트
eapim-admin CI / build (push) Has been cancelled
- eapim-admin, eapim-online, elink-portal 공통 반영
2026-08-05 17:46:55 +09:00
Rinjae 36994a57b6 속성 관리 화면 수정
eapim-admin CI / build (push) Has been cancelled
- 다중라인 값 편집 및 줄바꿈 지원 PRPTY2VAL 속성 수정
- prpty2Val 입력 필드 텍스트 영역으로 변경 및 스타일 추가
- jQuery 선택자 간소화 `<input>` → `<textarea>` for consistency
2026-08-05 15:23:46 +09:00
Rinjae 4a087e576b Merge commit '9f22d4fa4d6720aabb06a5541449ebb120397987'
eapim-admin CI / build (push) Has been cancelled
2026-08-05 14:22:34 +09:00
eastargh 9f22d4fa4d 로그상세에서 정렬클릭시 지수형 풀리지 않도록
eapim-admin CI / build (push) Has been cancelled
2026-08-05 11:36:51 +09:00
eastargh 66c01029cd UMS 설정 수정
eapim-admin CI / build (push) Has been cancelled
2026-08-05 11:12:46 +09:00
15 changed files with 773 additions and 71 deletions
@@ -28,9 +28,11 @@ function init(){
success:function(json){
new makeOptions("CODE","NAME").setObj($("select[name=searchJobUseYn]")).setNoValueInclude(true).setNoValue("",
"<%= localeMessage.getString("combo.all")%>").setData(json.useYnTypeRows).setFormat(codeName3OptionFormat).rendering();
new makeOptions("CODE","NAME").setObj($("select[name=searchJobInst]")).setNoValueInclude(true).setNoValue("",
new makeOptions("CODE","NAME").setObj($("select[name=searchJobInst]")).setNoValueInclude(true).setNoValue("",
"<%= localeMessage.getString("combo.all")%>").setData(json.instanceList).rendering();
$("select[name=searchJobUseYn]").val("Y");
putSelectFromParam();
initGrid();
@@ -44,6 +44,9 @@
.div-hover {
background-color: #EDD5DD
}
.btn_move { width:32px; min-width: 32px; height: 32px; background-color: #fff; border:1px solid #999; border-radius:4px; margin-bottom: 4px; }
</style>
<jsp:include page="/jsp/common/include/script.jsp"/>
@@ -888,7 +891,7 @@
}
});
$("input[name=auto]").click(function(){
$("#auto").click(function(){
//debugger;
jsPlumbInstance.deleteEveryConnection();
@@ -915,7 +918,7 @@
}
});
$("input[name=addAll]").click(function() {
$("#addAll").click(function() {
console.debug('addAll');
var srcRowId = sourceGrid.getGridParam( "selrow" );
var tgtRowId = targetGrid.getGridParam( "selrow" );
@@ -966,7 +969,7 @@
}
});
$("input[name=removeAll]").click(function(){
$("#removeAll").click(function(){
var tgtRowId = targetGrid.getGridParam( "selrow" );
if (tgtRowId == null) {alert("타겟레이아웃을 선택하여 주십시요.");return;}
//grid data
@@ -985,6 +988,49 @@
jsPlumbInstance.deleteConnectionsForElement(document.getElementById(targetEndpointElementId));
}
});
$("#add").click(function() {
var srcRowId = sourceGrid.getGridParam( "selrow" );
var tgtRowId = targetGrid.getGridParam( "selrow" );
if (srcRowId == null) {alert('<%=localeMessage.getString("transformManDetail.alert1")%>');return;}
if (tgtRowId == null) {alert('<%=localeMessage.getString("transformManDetail.alert2")%>');return;}
//field, ATTR 여부 판단
var sourceRow = sourceGrid.getRowData( srcRowId );
var targetRow = targetGrid.getRowData( tgtRowId );
if ( (sourceRow["loutItemType"] == 'Field' || sourceRow["loutItemType"] == 'Attr' || sourceRow["loutItemType"] == 'Array') &&
(targetRow["loutItemType"] == 'Field' || targetRow["loutItemType"] == 'Attr' || targetRow["loutItemType"] == 'Array') ){
;
}else{
alert('<%=localeMessage.getString("transformManDetail.alert3")%>');
return ;
}
targetGrid.jqGrid('setCell',tgtRowId,'CNVSNCMDNAME',addArrayTag(sourceRow['loutItemType'], addGroupTag(srcGroup,sourceRow['LOUTITEMPATH'])));
targetGrid.jqGrid('setCell',tgtRowId,'CNVSNRSULTITEMPATHNAME',addArrayTag(targetRow['loutItemType'], addGroupTag(tgtGroup,targetRow['LOUTITEMPATH'])));
targetGrid.jqGrid('setCell',tgtRowId,'CNVSNITEMSERNO',targetRow['loutItemSerno']);
try {
jsPlumbConnect(srcRowId, tgtRowId);
} catch (e) {console.error(e);}
});
$("#remove").click(function(){
var tgtRowId = targetGrid.getGridParam( "selrow" );
if (tgtRowId == null) {alert("타겟레이아웃을 선택하여 주십시요.");return;}
//grid data
var targetData = targetGrid.getRowData();
targetGrid.jqGrid('setCell', tgtRowId, 'CNVSNCMDNAME', null);
targetGrid.jqGrid('setCell', tgtRowId, 'CNVSNRSULTITEMPATHNAME', null);
targetGrid.jqGrid('setCell', tgtRowId, 'CNVSNITEMSERNO', null);
targetGrid.jqGrid('setCell', tgtRowId, 'CNVSNITEMBASCVAL', null);
const targetEndpointElementId = targetGrid.getEndpointElementId(tgtRowId);
jsPlumbInstance.deleteConnectionsForElement(document.getElementById(targetEndpointElementId));
});
$("textarea[name=CNVSNRSULTITEMPATHNAME],textarea[name=CNVSNCMDNAME],input[name=CNVSNITEMBASCVAL],input[name=CNVSNITEMSERNO]").blur(function(){
var rowId = targetGrid.getGridParam( "selrow" );
@@ -2161,12 +2207,12 @@
<div id="sourcePage"></div>
</div>
</td>
<td style="width:30%; text-align:center; vertical-align: top;">
<br /><!-- 이 br 이 없을 때 버튼이 제대로 동작 하지 않는다. -->
<input type=button id="removeAll" name="removeAll" class="btn_img btn_removeAll"/><br />
<input type=button id="addAll" name="addAll" class="btn_img btn_addAll"/><br />
<input type=button id="auto" name="auto" class="btn_img btn_auto" />
<td style="width:30%; min-width:70px; text-align:center; vertical-align: top; ">
<button type=button id="remove" name="remove" class="cssbtn btn_move" title="remove"><</button>
<button type=button id="add" name="add" class="cssbtn btn_move" title="add"> > </button><br />
<button type=button id="removeAll" name="removeAll" class="cssbtn btn_move" title="remove all"> << </button>
<button type=button id="addAll" name="addAll" class="cssbtn btn_move" title="add all"> >> </button><br />
<button type=button id="auto" name="auto" class="cssbtn btn_move" style="width: 70px; " title="auto"> Auto </button>
</td>
<td style="vertical-align:top;">
<table class="table_row" cellspacing="0">
@@ -189,7 +189,7 @@
<td>
<input type="text" id="keywordList" style="width:100%" placeholder="쉼표(,)로 구분하여 입력">
<div class="help-text" style="color:#666; font-size:12px; margin-top:5px;">
예시: #인증, #대출, #예금
예시: 인증, 대출, 예금
</div>
</td>
</tr>
@@ -327,14 +327,61 @@
}, 'json').fail(ajaxFail);
}
// ── 캐시 Reload ──
var reloadModal;
var reloadBtnHtml;
function reloadPortalCache() {
var $btn = $('#btnReloadCache');
if ($btn.prop('disabled')) return;
if (reloadBtnHtml === undefined) reloadBtnHtml = $btn.html();
$btn.prop('disabled', true)
.html('<span class="spinner-border spinner-border-sm"></span> 리로드 중...');
$.post(url, { cmd: 'TRANSACTION_RELOAD' }, function (result) {
if (result && result.success) {
alert('포탈 메뉴 캐시 리로드 완료 (항목 ' + result.itemCount + '건, ' + result.reloadedAt + ')');
} else {
alert('캐시 리로드 실패: ' + (result ? result.message : '알 수 없는 오류'));
}
}, 'json').fail(ajaxFail);
showReloadResult(result);
}, 'json').fail(ajaxFail).always(function () {
$btn.prop('disabled', false).html(reloadBtnHtml);
});
}
function showReloadResult(result) {
if (!result) {
alert('캐시 리로드 실패: 알 수 없는 오류');
return;
}
var servers = result.servers || [];
var $summary = $('#reloadSummary');
$summary.removeClass('alert-success alert-danger alert-warning')
.addClass(result.success ? 'alert-success'
: (result.successCount > 0 ? 'alert-warning' : 'alert-danger'))
.text(result.message || '');
var rows = '';
servers.forEach(function (s) {
var badge = s.success
? '<span class="badge text-bg-success">성공</span>'
: '<span class="badge text-bg-danger">실패</span>';
var detail = s.success
? ('항목 ' + (s.itemCount == null ? '-' : s.itemCount) + '건'
+ (s.reloadedAt ? ' / ' + escapeHtml(String(s.reloadedAt)) : ''))
: escapeHtml(String(s.message || '-'));
rows += '<tr>'
+ '<td class="text-center">' + badge + '</td>'
+ '<td style="word-break:break-all;">' + escapeHtml(String(s.url || '')) + '</td>'
+ '<td>' + detail + '</td>'
+ '<td class="text-end">' + (s.elapsedMs == null ? '-' : s.elapsedMs + 'ms') + '</td>'
+ '</tr>';
});
if (rows === '') {
rows = '<tr><td colspan="4" class="text-center text-muted">'
+ '호출 대상 서버가 없습니다. 아래 설정 안내를 확인하세요.</td></tr>';
}
$('#reloadResultBody').html(rows);
if (!reloadModal) reloadModal = new bootstrap.Modal(document.getElementById('reloadResultModal'));
reloadModal.show();
}
function ajaxFail(xhr) {
@@ -453,7 +500,7 @@
<button type="button" class="btn btn-sm btn-outline-danger" level="W" onclick="initializeMenus()">
<i class="bi bi-arrow-counterclockwise"></i> 메뉴 초기화</button>
<span class="spacer"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" level="W" onclick="reloadPortalCache()">
<button type="button" class="btn btn-sm btn-outline-secondary" id="btnReloadCache" level="W" onclick="reloadPortalCache()">
<i class="bi bi-arrow-repeat"></i> 캐시 Reload</button>
<button type="button" class="btn btn-sm btn-success" level="W" onclick="savePlacements()">
<i class="bi bi-save"></i> 저장</button>
@@ -463,6 +510,12 @@
드래그로 위치를 조절한 뒤 <strong>저장</strong> 버튼으로 일괄 반영합니다.
기본 항목([기본] 배지)은 [-] 시 <strong>미배치</strong>로만 이동하고, 커스텀 항목은 미배치 시 <strong>삭제</strong>됩니다.
저장 후 <strong>캐시 Reload</strong> 를 실행해야 포탈에 즉시 반영됩니다(미실행 시 TTL 경과 후 반영).
<br>
캐시 Reload 대상 서버는 포탈 프로퍼티 <code>Portal / portal.internal.menu-reload-url</code> 로 설정합니다.
포탈이 <strong>다중 서버(이중화)</strong> 로 기동된 경우 모든 서버의 리로드 URL 을
<strong>콤마(,) 또는 줄바꿈</strong> 으로 나열해야 전체 서버에 반영됩니다
(예: <code>http://10.0.0.11:39130/internal/menu/reload, http://10.0.0.12:39130/internal/menu/reload</code>).
서버별 요청 타임아웃은 <strong>3초</strong> 이며, 실행 결과는 서버별로 표시됩니다.
</div>
<div class="menu-board">
@@ -544,6 +597,39 @@
</div>
</div>
<!-- 캐시 Reload 결과 -->
<div class="modal fade" id="reloadResultModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">포탈 메뉴 캐시 Reload 결과</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="닫기"></button>
</div>
<div class="modal-body">
<div class="alert py-2 px-3" id="reloadSummary" style="font-size:13px;"></div>
<table class="table table-sm table-bordered align-middle" style="font-size:12px;">
<thead class="table-light">
<tr>
<th style="width:70px;" class="text-center">결과</th>
<th>서버 URL</th>
<th style="width:38%;">상세</th>
<th style="width:80px;" class="text-end">소요</th>
</tr>
</thead>
<tbody id="reloadResultBody"></tbody>
</table>
<div class="text-muted" style="font-size:12px;">
대상 서버는 포탈 프로퍼티 <code>Portal / portal.internal.menu-reload-url</code> 에
콤마(,) 또는 줄바꿈으로 나열합니다. 서버별 타임아웃 3초, 전 서버 병렬 호출입니다.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">닫기</button>
</div>
</div>
</div>
</div>
</div><!-- end content_middle -->
</div><!-- end right_box -->
</body>
@@ -40,7 +40,7 @@
if ($('input[name=prptyName]').val() == "") {
alert("<%= localeMessage.getString("propertyDetail.checkRequired2") %>");
return false;
} else if ($('input[name=prpty2Val]').val() == "") {
} else if ($('[name=prpty2Val]').val() == "") {
alert("<%= localeMessage.getString("propertyDetail.checkRequired3") %>");
return false;
}
@@ -90,7 +90,19 @@
'설명',
'<%= localeMessage.getString("propertyDetail.delYn") %>'],
colModel: [{name: 'PRPTYNAME', width: 50, align: 'left', editable: true},
{name: 'PRPTY2VAL', width: 150, align: 'left', editable: true},
{
name: 'PRPTY2VAL',
width: 150,
align: 'left',
editable: true,
//다행(멀티라인) 값 편집 — 관련 사이트 목록처럼 줄 단위 표기하는 값 지원
edittype: 'textarea',
editoptions: {rows: 3, cols: 60},
//줄바꿈이 그리드에서도 그대로 보이도록
cellattr: function () {
return ' style="white-space:pre-line; word-break:break-all;"';
}
},
{name: 'PRPTYDESC', width: 200, align: 'left', editable: true, edittype: 'textarea', editoptions: {rows: 2}},
{
name: 'DELETEYN',
@@ -265,7 +277,7 @@
}
var data = new Object();
data["PRPTYNAME"] = $("input[name=prptyName]").val();
data["PRPTY2VAL"] = $("input[name=prpty2Val]").val();
data["PRPTY2VAL"] = $("[name=prpty2Val]").val();
data["PRPTYDESC"] = $("input[name=prptyDesc]").val();
var rows = $("#grid")[0].rows;
@@ -346,7 +358,8 @@
<tr>
<th><%= localeMessage.getString("propertyDetail.propertyValue") %>
</th>
<td colspan="3"><input type="text" name="prpty2Val"/>
<td colspan="3"><textarea name="prpty2Val" rows="3"
style="width:100%; box-sizing:border-box; resize:vertical;"></textarea>
</td>
</tr>
<tr>
@@ -376,7 +376,7 @@
const value = bodyCodeEditor.getValue();
let prettyValue = value;
if(syntax === 'JSN'){
prettyValue = vkbeautify.json(value);
prettyValue = preserveExponential(value); //vkbeautify.json(value);
}else if(syntax === 'XML'){
prettyValue = vkbeautify.xml(value).trim();
}
@@ -407,6 +407,19 @@
}
});
}
function preserveExponential(jsonString) {
// 1. 지수 형태의 숫자(예: 1e+21, 5.43e-7)를 찾아 문자열 ""로 감쌉니다.
// JSON의 key 뒤에 오는 value 위치의 지수 표기법을 매칭합니다.
let protectedJson = jsonString.replace(/:\s*([+-]?\d+\.?\d*e[+-]?\d+)/gi, ': "$1"');
// 2. 보호된 상태로 vkbeautify를 실행하여 예쁘게 정렬합니다.
let prettyJson = vkbeautify.json(protectedJson);
// 3. (선택사항) 만약 최종 결과물에서 반드시 큰따옴표("")를 다시 제거해야 한다면 아래 주석을 해제하세요.
prettyJson = prettyJson.replace(/:\s*"([+-]?\d+\.?\d*e[+-]?\d+)"/gi, ': $1');
return prettyJson;
}
function doChangeSyntax(syntax){
if(syntax === 'JSN'){
$('#btn_pretty_body').css('display', 'block');
+343
View File
@@ -0,0 +1,343 @@
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %><%@
page import="org.quartz.*,
org.quartz.impl.matchers.GroupMatcher,
org.springframework.web.context.WebApplicationContext,
org.springframework.web.context.support.WebApplicationContextUtils,
java.util.*,
java.text.SimpleDateFormat"
%><%!
private String fmt(Date date) {
if (date == null) return "-";
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date);
}
private String relative(Date date) {
if (date == null) return "";
long diff = date.getTime() - System.currentTimeMillis();
String prefix = diff >= 0 ? "+" : "-";
long abs = Math.abs(diff) / 1000;
if (abs < 60) return prefix + abs + "s";
if (abs < 3600) return prefix + (abs / 60) + "m " + (abs % 60) + "s";
return prefix + (abs / 3600) + "h " + ((abs % 3600) / 60) + "m";
}
private String stateClass(Trigger.TriggerState state) {
if (state == Trigger.TriggerState.NORMAL) return "state-normal";
if (state == Trigger.TriggerState.PAUSED) return "state-paused";
if (state == Trigger.TriggerState.BLOCKED) return "state-blocked";
if (state == Trigger.TriggerState.ERROR) return "state-error";
return "";
}
private Map<String, Object> buildTriggerInfo(Scheduler scheduler, Trigger trigger) throws Exception {
Map<String, Object> info = new LinkedHashMap<>();
Trigger.TriggerState state = scheduler.getTriggerState(trigger.getKey());
info.put("triggerName", trigger.getKey().getName());
info.put("triggerGroup", trigger.getKey().getGroup());
info.put("state", state.name());
info.put("stateClass", stateClass(state));
info.put("prevFire", fmt(trigger.getPreviousFireTime()));
info.put("prevFireRel", relative(trigger.getPreviousFireTime()));
info.put("nextFire", fmt(trigger.getNextFireTime()));
info.put("nextFireRel", relative(trigger.getNextFireTime()));
info.put("priority", trigger.getPriority());
if (trigger instanceof CronTrigger) {
info.put("type", "Cron");
info.put("cronExp", ((CronTrigger) trigger).getCronExpression());
} else if (trigger instanceof SimpleTrigger) {
SimpleTrigger st = (SimpleTrigger) trigger;
info.put("type", "Simple");
info.put("cronExp", "interval=" + st.getRepeatInterval() + "ms, repeat=" +
(st.getRepeatCount() == SimpleTrigger.REPEAT_INDEFINITELY ? "INF" : st.getRepeatCount()));
} else {
info.put("type", trigger.getClass().getSimpleName());
info.put("cronExp", "-");
}
if (trigger.getStartTime() != null) info.put("startTime", fmt(trigger.getStartTime()));
if (trigger.getEndTime() != null) info.put("endTime", fmt(trigger.getEndTime()));
return info;
}
private List<Map<String, Object>> getJobList(Scheduler scheduler) throws Exception {
List<Map<String, Object>> jobs = new ArrayList<>();
for (String group : scheduler.getJobGroupNames()) {
for (JobKey jobKey : scheduler.getJobKeys(GroupMatcher.jobGroupEquals(group))) {
JobDetail detail = scheduler.getJobDetail(jobKey);
List<? extends Trigger> triggers = scheduler.getTriggersOfJob(jobKey);
Map<String, Object> job = new LinkedHashMap<>();
job.put("name", jobKey.getName());
job.put("group", jobKey.getGroup());
job.put("jobClass", detail.getJobClass().getSimpleName());
job.put("disallowConcurrent", detail.isConcurrentExectionDisallowed());
StringBuilder dataStr = new StringBuilder();
for (String key : detail.getJobDataMap().getKeys()) {
if (dataStr.length() > 0) dataStr.append(", ");
dataStr.append(key).append("=").append(detail.getJobDataMap().getString(key));
}
job.put("jobData", dataStr.toString());
job.put("triggerCount", triggers.size());
List<Map<String, Object>> triggerList = new ArrayList<>();
for (Trigger trigger : triggers) {
triggerList.add(buildTriggerInfo(scheduler, trigger));
}
job.put("triggerList", triggerList);
if (!triggers.isEmpty()) {
Trigger trigger = triggers.get(0);
Trigger.TriggerState state = scheduler.getTriggerState(trigger.getKey());
job.put("state", state.name());
job.put("stateClass", stateClass(state));
job.put("prevFire", fmt(trigger.getPreviousFireTime()));
job.put("prevFireRel", relative(trigger.getPreviousFireTime()));
job.put("nextFire", fmt(trigger.getNextFireTime()));
job.put("nextFireRel", relative(trigger.getNextFireTime()));
if (trigger instanceof CronTrigger) {
job.put("cronExp", ((CronTrigger) trigger).getCronExpression());
} else {
job.put("cronExp", "-");
}
} else {
job.put("state", "NO_TRIGGER");
job.put("stateClass", "state-error");
job.put("prevFire", "-");
job.put("prevFireRel", "");
job.put("nextFire", "-");
job.put("nextFireRel", "");
job.put("cronExp", "-");
}
jobs.add(job);
}
}
jobs.sort(Comparator.comparing(m -> (String) m.get("name")));
return jobs;
}
%><%
WebApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(application);
Scheduler scheduler = null;
Scheduler clusteredScheduler = null;
String error = null;
try {
scheduler = (Scheduler) ctx.getBean("scheduler");
} catch (Exception e) {
error = "scheduler bean not found: " + e.getMessage();
}
try {
clusteredScheduler = (Scheduler) ctx.getBean("clusteredScheduler");
} catch (Exception e) {
// clusteredScheduler is optional
}
// 자동 새로고침 간격 (초), 기본 10초
String refreshParam = request.getParameter("refresh");
int refreshSec = 10;
if (refreshParam != null) {
try { refreshSec = Integer.parseInt(refreshParam); } catch (NumberFormatException e) { /* ignore */ }
if (refreshSec < 1) refreshSec = 1;
}
boolean autoRefresh = refreshParam != null;
%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<% if (autoRefresh) { %>
<meta http-equiv="refresh" content="<%= refreshSec %>;url=quartzStatus.jsp?refresh=<%= refreshSec %>">
<% } %>
<title>Quartz Scheduler Status</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; margin: 20px; background: #f5f5f5; color: #333; }
h1 { font-size: 20px; margin-bottom: 5px; }
h2 { font-size: 16px; color: #555; margin: 20px 0 8px 0; border-bottom: 2px solid #ddd; padding-bottom: 4px; }
.info { font-size: 13px; color: #666; margin-bottom: 15px; }
table { width: 100%%; border-collapse: collapse; font-size: 13px; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 20px; }
th { background: #4a5568; color: #fff; padding: 8px 10px; text-align: left; font-weight: 500; }
td { padding: 7px 10px; border-bottom: 1px solid #eee; }
tr:hover td { background: #f7fafc; }
.state-normal { color: #38a169; font-weight: bold; }
.state-paused { color: #d69e2e; font-weight: bold; }
.state-blocked { color: #e53e3e; font-weight: bold; }
.state-error { color: #e53e3e; font-weight: bold; }
.mono { font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; }
.rel { color: #718096; font-size: 11px; margin-left: 4px; }
.badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 11px; }
.badge-yes { background: #c6f6d5; color: #276749; }
.badge-no { background: #fed7d7; color: #9b2c2c; }
.scheduler-meta { display: flex; gap: 20px; flex-wrap: wrap; margin-bottom: 10px; }
.scheduler-meta span { font-size: 13px; }
.scheduler-meta strong { color: #4a5568; }
.error { color: #e53e3e; padding: 10px; background: #fff5f5; border: 1px solid #fc8181; border-radius: 4px; }
.toolbar { font-size: 12px; color: #718096; }
.toolbar a { color: #4299e1; text-decoration: none; margin: 0 4px; }
.toolbar a:hover { text-decoration: underline; }
.toolbar .active { font-weight: bold; color: #2b6cb0; background: #ebf4ff; padding: 2px 6px; border-radius: 3px; }
.auto-badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; background: #bee3f8; color: #2a4365; margin-left: 6px; }
.trigger-toggle { cursor: pointer; user-select: none; }
.trigger-toggle:hover td { background: #edf2f7; }
.trigger-row { display: none; }
.trigger-row.open { display: table-row; }
.trigger-row td { background: #f7fafc; padding: 5px 10px 5px 30px; border-bottom: 1px solid #e2e8f0; font-size: 12px; }
.trigger-detail-table { width: 100%; border-collapse: collapse; font-size: 12px; margin: 0; }
.trigger-detail-table th { background: #e2e8f0; color: #4a5568; padding: 5px 8px; font-weight: 500; font-size: 11px; }
.trigger-detail-table td { padding: 4px 8px; border-bottom: 1px solid #e2e8f0; }
.trigger-detail-table tr:last-child td { border-bottom: none; }
.toggle-icon { display: inline-block; width: 16px; font-size: 11px; color: #718096; transition: transform 0.15s; }
.toggle-icon.open { transform: rotate(90deg); }
.trigger-count { font-size: 11px; color: #718096; font-weight: normal; margin-left: 4px; }
</style>
</head>
<body>
<h1>Quartz Scheduler Status
<% if (autoRefresh) { %><span class="auto-badge">Auto <%= refreshSec %>s</span><% } %>
</h1>
<p class="info">
Instance: <strong><%= System.getProperty("inst.Name", "N/A") %></strong>
| Server Time: <strong><%= new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) %></strong>
</p>
<p class="toolbar">
Auto Refresh:
<% if (!autoRefresh) { %><span class="active">OFF</span><% } else { %><a href="quartzStatus.jsp">OFF</a><% } %>
|
<% int[] intervals = {5, 10, 30, 60}; %>
<% for (int iv : intervals) { %>
<% if (autoRefresh && refreshSec == iv) { %><span class="active"><%= iv %>s</span><% } else { %><a href="quartzStatus.jsp?refresh=<%= iv %>"><%= iv %>s</a><% } %>
<% } %>
</p>
<% if (error != null) { %>
<div class="error"><%= error %></div>
<% } %>
<% if (scheduler != null) { %>
<h2>Scheduler: <%= scheduler.getSchedulerName() %> (RAM)</h2>
<div class="scheduler-meta">
<span><strong>Status:</strong> <span class="<%= scheduler.isStarted() ? "state-normal" : "state-error" %>"><%= scheduler.isStarted() ? "STARTED" : "STOPPED" %></span></span>
<span><strong>Instance ID:</strong> <%= scheduler.getSchedulerInstanceId() %></span>
<span><strong>Job Count:</strong> <%= getJobList(scheduler).size() %></span>
</div>
<table>
<tr>
<th>Job Name</th>
<th>Job Class</th>
<th>Cron</th>
<th>State</th>
<th>Concurrent</th>
<th>Prev Fire</th>
<th>Next Fire</th>
<th>Job Data</th>
</tr>
<% int jobIdx = 0; for (Map<String, Object> job : getJobList(scheduler)) { String rowId = "s_" + jobIdx; List<Map<String, Object>> tList = (List<Map<String, Object>>) job.get("triggerList"); %>
<tr class="trigger-toggle" onclick="toggleTriggers('<%= rowId %>')">
<td><span class="toggle-icon" id="icon_<%= rowId %>">&#9654;</span><strong><%= job.get("name") %></strong><span class="trigger-count">(<%= job.get("triggerCount") %> triggers)</span></td>
<td class="mono"><%= job.get("jobClass") %></td>
<td class="mono"><%= job.get("cronExp") %></td>
<td class="<%= job.get("stateClass") %>"><%= job.get("state") %></td>
<td><span class="badge <%= (Boolean)job.get("disallowConcurrent") ? "badge-yes" : "badge-no" %>">
<%= (Boolean)job.get("disallowConcurrent") ? "NO (protected)" : "YES (allowed)" %></span></td>
<td class="mono"><%= job.get("prevFire") %><span class="rel"><%= job.get("prevFireRel") %></span></td>
<td class="mono"><%= job.get("nextFire") %><span class="rel"><%= job.get("nextFireRel") %></span></td>
<td class="mono"><%= job.get("jobData") %></td>
</tr>
<tr class="trigger-row" id="triggers_<%= rowId %>">
<td colspan="8" style="padding: 6px 10px 6px 30px;">
<% if (tList.isEmpty()) { %>
<em style="color:#a0aec0;">No triggers</em>
<% } else { %>
<table class="trigger-detail-table">
<tr><th>Trigger Name</th><th>Group</th><th>Type</th><th>Expression / Interval</th><th>State</th><th>Priority</th><th>Prev Fire</th><th>Next Fire</th></tr>
<% for (Map<String, Object> t : tList) { %>
<tr>
<td><%= t.get("triggerName") %></td>
<td><%= t.get("triggerGroup") %></td>
<td><%= t.get("type") %></td>
<td class="mono"><%= t.get("cronExp") %></td>
<td class="<%= t.get("stateClass") %>"><%= t.get("state") %></td>
<td><%= t.get("priority") %></td>
<td class="mono"><%= t.get("prevFire") %><span class="rel"><%= t.get("prevFireRel") %></span></td>
<td class="mono"><%= t.get("nextFire") %><span class="rel"><%= t.get("nextFireRel") %></span></td>
</tr>
<% } %>
</table>
<% } %>
</td>
</tr>
<% jobIdx++; } %>
</table>
<% } %>
<% if (clusteredScheduler != null) { %>
<h2>Clustered Scheduler: <%= clusteredScheduler.getSchedulerName() %> (DB)</h2>
<div class="scheduler-meta">
<span><strong>Status:</strong> <span class="<%= clusteredScheduler.isStarted() ? "state-normal" : "state-error" %>"><%= clusteredScheduler.isStarted() ? "STARTED" : "STOPPED" %></span></span>
<span><strong>Instance ID:</strong> <%= clusteredScheduler.getSchedulerInstanceId() %></span>
<span><strong>Job Count:</strong> <%= getJobList(clusteredScheduler).size() %></span>
</div>
<table>
<tr>
<th>Job Name</th>
<th>Job Class</th>
<th>Cron</th>
<th>State</th>
<th>Concurrent</th>
<th>Prev Fire</th>
<th>Next Fire</th>
<th>Job Data</th>
</tr>
<% int cJobIdx = 0; for (Map<String, Object> job : getJobList(clusteredScheduler)) { String rowId = "c_" + cJobIdx; List<Map<String, Object>> tList = (List<Map<String, Object>>) job.get("triggerList"); %>
<tr class="trigger-toggle" onclick="toggleTriggers('<%= rowId %>')">
<td><span class="toggle-icon" id="icon_<%= rowId %>">&#9654;</span><strong><%= job.get("name") %></strong><span class="trigger-count">(<%= job.get("triggerCount") %> triggers)</span></td>
<td class="mono"><%= job.get("jobClass") %></td>
<td class="mono"><%= job.get("cronExp") %></td>
<td class="<%= job.get("stateClass") %>"><%= job.get("state") %></td>
<td><span class="badge <%= (Boolean)job.get("disallowConcurrent") ? "badge-yes" : "badge-no" %>">
<%= (Boolean)job.get("disallowConcurrent") ? "NO (protected)" : "YES (allowed)" %></span></td>
<td class="mono"><%= job.get("prevFire") %><span class="rel"><%= job.get("prevFireRel") %></span></td>
<td class="mono"><%= job.get("nextFire") %><span class="rel"><%= job.get("nextFireRel") %></span></td>
<td class="mono"><%= job.get("jobData") %></td>
</tr>
<tr class="trigger-row" id="triggers_<%= rowId %>">
<td colspan="8" style="padding: 6px 10px 6px 30px;">
<% if (tList.isEmpty()) { %>
<em style="color:#a0aec0;">No triggers</em>
<% } else { %>
<table class="trigger-detail-table">
<tr><th>Trigger Name</th><th>Group</th><th>Type</th><th>Expression / Interval</th><th>State</th><th>Priority</th><th>Prev Fire</th><th>Next Fire</th></tr>
<% for (Map<String, Object> t : tList) { %>
<tr>
<td><%= t.get("triggerName") %></td>
<td><%= t.get("triggerGroup") %></td>
<td><%= t.get("type") %></td>
<td class="mono"><%= t.get("cronExp") %></td>
<td class="<%= t.get("stateClass") %>"><%= t.get("state") %></td>
<td><%= t.get("priority") %></td>
<td class="mono"><%= t.get("prevFire") %><span class="rel"><%= t.get("prevFireRel") %></span></td>
<td class="mono"><%= t.get("nextFire") %><span class="rel"><%= t.get("nextFireRel") %></span></td>
</tr>
<% } %>
</table>
<% } %>
</td>
</tr>
<% cJobIdx++; } %>
</table>
<% } %>
<script>
function toggleTriggers(rowId) {
var row = document.getElementById('triggers_' + rowId);
var icon = document.getElementById('icon_' + rowId);
if (row.classList.contains('open')) {
row.classList.remove('open');
icon.classList.remove('open');
} else {
row.classList.add('open');
icon.classList.add('open');
}
}
</script>
</body>
</html>
Binary file not shown.
@@ -20,6 +20,6 @@ public class WebhookReqEventId implements Serializable {
private String webhookReqId;
@Column(name = "EVENT_TYPE", nullable = false, length = 50)
@Comment("이벤트 유형 (CONTROL_START, ERROR_START 등)")
@Comment("이벤트 유형 (CHECK_START, ERROR_START 등)")
private String eventType;
}
@@ -58,8 +58,8 @@ public class ApiStatusDetectionService {
private static final Logger log = LoggerFactory.getLogger(ApiStatusDetectionService.class);
public static final String EVENT_CONTROL_START = "CONTROL_START";
public static final String EVENT_CONTROL_END = "CONTROL_END";
public static final String EVENT_CHECK_START = "CHECK_START";
public static final String EVENT_CHECK_END = "CHECK_END";
public static final String EVENT_ERROR_START = "ERROR_START";
public static final String EVENT_ERROR_END = "ERROR_END";
public static final String EVENT_DELAY_START = "DELAY_START";
@@ -119,8 +119,8 @@ public class ApiStatusDetectionService {
/**
* 이벤트 감지
* @param event "CONTROL_START" : 점검시작
"CONTROL_END" : 점검종료
* @param event "CHECK_START" : 점검시작
"CHECK_END" : 점검종료
"ERROR_START" : 장애시작
"ERROR_END" : 장애종료
"DELAY_START" : 지연시작
@@ -154,8 +154,8 @@ public class ApiStatusDetectionService {
case EVENT_DELAY_END:
markRecovered(event, targets);
break;
case EVENT_CONTROL_START:
case EVENT_CONTROL_END:
case EVENT_CHECK_START:
case EVENT_CHECK_END:
// 점검은 관리자 등록 공지가 원천 - 재기록하지 않음
log.debug("점검 이벤트는 장애 데이터로 기록하지 않음: {} {}", event, targets);
break;
@@ -17,8 +17,8 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
/**
* API 상태 판단. 정상(N), 점검(C), 지연(D), 장애(E)
*
* 1. 정상/지연/장애 -> 점검중 : CONTROL_START
* 2. 점검 -> 점검X : CONTROL_END
* 1. 정상/지연/장애 -> 점검중 : CHECK_START
* 2. 점검 -> 점검X : CHECK_END
* 3. 정상/지연 -> 장애 : ERROR_START
* 4. 장애 -> 장애X : ERROR_END
* 5. 정상 -> 지연 : DELAY_START
@@ -34,8 +34,8 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
+ " FROM ("
+ " SELECT EAISVCNAME"
+ " , STATUS_CODE AS PREV_STATUS_CODE "
+ " , CASE WHEN STATUS_CODE != 'C' AND CTRL_YN = 'Y' THEN 'CONTROL_START'"
+ " WHEN STATUS_CODE = 'C' AND CTRL_YN = 'N' THEN 'CONTROL_END'"
+ " , CASE WHEN STATUS_CODE != 'C' AND CTRL_YN = 'Y' THEN 'CHECK_START'"
+ " WHEN STATUS_CODE = 'C' AND CTRL_YN = 'N' THEN 'CHECK_END'"
+ " WHEN STATUS_CODE IN ('N','D') AND ERR_YN = 'Y' THEN 'ERROR_START'"
+ " WHEN STATUS_CODE = 'E' AND ERR_YN = 'N' THEN 'ERROR_END'"
+ " WHEN STATUS_CODE = 'N' AND DELAY_YN = 'Y' THEN 'DELAY_START'"
@@ -45,7 +45,7 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
+ " SELECT A.EAISVCNAME"
+ " , NVL(B.STATUS_CODE, 'N') AS STATUS_CODE"
// 점검(KIND='MAINTENANCE')만 CTRL_YN 대상이다. KIND 를 안 걸면 관리자가 시작~종료 시각을
// 넣어 등록한 장애 공지까지 '점검중'으로 오판해 CONTROL_START 가 발생한다.
// 넣어 등록한 장애 공지까지 '점검중'으로 오판해 CHECK_START 가 발생한다.
+ " , NVL(( SELECT MAX('Y') FROM EMSAPP.DJB_APISTATUS_INCIDENT_API X"
+ " WHERE EXISTS (SELECT 1 FROM EMSAPP.DJB_APISTATUS_INCIDENT Y WHERE X.INCIDENT_ID = Y.INCIDENT_ID "
+ " AND Y.KIND = 'MAINTENANCE'"
@@ -179,8 +179,8 @@ public class ApiStatusService {
private String resolveStatusCode(String event) {
switch (event) {
case "CONTROL_START": return "C"; //점검
case "CONTROL_END": return "N"; //정상
case "CHECK_START": return "C"; //점검
case "CHECK_END": return "N"; //정상
case "ERROR_START": return "E"; //장애
case "ERROR_END": return "N"; //정상
case "DELAY_START": return "D"; //지연
@@ -195,10 +195,10 @@ public class ApiStatusService {
private HashMap<String, Object> getSwingChatMessage(String event, List<String> apiIds) {
String message = "";
switch (event) {
case "CONTROL_START":
case "CHECK_START":
message = "API 서비스 점검이 시작되었습니다"; //점검
break;
case "CONTROL_END":
case "CHECK_END":
message = "API 서비스 점검이 완료되었습니다"; //정상
break;
case "ERROR_START":
@@ -166,6 +166,7 @@ public class UmsService {
if ("MESSENGER".equals(messageRequest.getMessageType()) ) {
stdHeadVO.setChnl_tycd("FEP"); // 채널유형코드
stdHeadVO.setHmab_dvcd("2"); // 내외구분코드 1:내부 2:외부(타발)
// 스윙챗 알림 공용
SwingCommonVO swiComVO = new SwingCommonVO();
@@ -85,10 +85,10 @@ public class WebhookReceiveService {
log.info("[Webhook] 이벤트 처리 시작 - eventType: {}", eventType);
switch (eventType) {
case "CONTROL_START":
case "CHECK_START":
handleControlStart(data);
break;
case "CONTROL_END":
case "CHECK_END":
handleControlEnd(data);
break;
case "ERROR_START":
@@ -107,11 +107,11 @@ public class WebhookReceiveService {
/* ------------------------------------------------------------------ */
private void handleControlStart(JsonNode data) {
log.info("[Webhook] CONTROL_START 처리 - data: {}", data);
log.info("[Webhook] CHECK_START 처리 - data: {}", data);
// TODO: 점검시작 처리 로직
}
private void handleControlEnd(JsonNode data) {
log.info("[Webhook] CONTROL_END 처리 - data: {}", data);
log.info("[Webhook] CHECK_END 처리 - data: {}", data);
// TODO: 점검종료 처리 로직
}
private void handleErrorStart(JsonNode data) {
@@ -1,22 +1,40 @@
package com.eactive.eai.rms.onl.apim.portalmenu;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import org.apache.http.client.HttpClient;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
/**
* eapim-portal 메뉴 캐시 리로드 클라이언트 (admin → portal 내부 호출).
*
* <p>호출 URL 은 PTL_PROPERTY {@code Portal / portal.internal.menu-reload-url} 로 관리한다
* (기본 {@code http://127.0.0.1:39130/internal/menu/reload}).
* 포탈이 다중 인스턴스로 기동되는 환경을 위해 <b>콤마/세미콜론/줄바꿈</b> 으로 여러 URL 을 나열할 수 있으며,
* 나열된 모든 서버로 병렬 호출한다. 각 호출의 connect/read 타임아웃은 {@value #TIMEOUT_MS}ms 이다.
* portal 측은 {@code menu.internal.allow-ips} 허용 IP 목록으로 요청을 검증한다.
* 오류는 결과 Map 으로 감싸 화면에 사유를 표시한다 — 예외를 전파하지 않는다.</p>
* 오류는 결과 Map 으로 감싸 화면에 서버별 사유를 표시한다 — 예외를 전파하지 않는다.</p>
*/
@Component
public class PortalMenuCacheClient {
@@ -26,50 +44,230 @@ public class PortalMenuCacheClient {
static final String PROP_GROUP = "Portal";
static final String PROP_RELOAD_URL = "portal.internal.menu-reload-url";
static final String DEFAULT_RELOAD_URL = "http://127.0.0.1:39130/internal/menu/reload";
static final String PROP_DESCRIPTION =
"eapim-portal 메뉴 캐시 리로드 내부 URL. 다중 서버는 콤마(,)/세미콜론(;)/줄바꿈으로 구분";
/** 서버 1대당 connect/read 타임아웃(ms) */
static final int TIMEOUT_MS = 3000;
/** 병렬 호출 전체 대기 여유분(ms) — 스레드 시작/응답 파싱 지연 흡수 */
private static final int OVERALL_SLACK_MS = 2000;
/** 동시 호출 스레드 상한 */
private static final int MAX_PARALLELISM = 10;
private final PortalPropertyService portalPropertyService;
private final RestTemplate restTemplate;
@Autowired
public PortalMenuCacheClient(PortalPropertyService portalPropertyService, RestTemplate restTemplate) {
public PortalMenuCacheClient(PortalPropertyService portalPropertyService) {
this(portalPropertyService, createRestTemplateWithTimeout(TIMEOUT_MS));
}
/** 테스트용 — 타임아웃이 설정된 RestTemplate 을 직접 주입한다. */
PortalMenuCacheClient(PortalPropertyService portalPropertyService, RestTemplate restTemplate) {
this.portalPropertyService = portalPropertyService;
this.restTemplate = restTemplate;
}
private static RestTemplate createRestTemplateWithTimeout(int timeoutMs) {
RequestConfig requestConfig = RequestConfig.custom()
.setConnectionRequestTimeout(timeoutMs)
.setConnectTimeout(timeoutMs)
.setSocketTimeout(timeoutMs)
.build();
HttpClient httpClient = HttpClientBuilder.create()
.setMaxConnTotal(MAX_PARALLELISM * 2)
.setMaxConnPerRoute(MAX_PARALLELISM)
.setDefaultRequestConfig(requestConfig)
.build();
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setHttpClient(httpClient);
RestTemplate restTemplate = new RestTemplate();
restTemplate.setRequestFactory(factory);
return restTemplate;
}
/**
* @return {@code {success, message, itemCount?, reloadedAt?}}
* 설정된 모든 포탈 서버의 메뉴 캐시를 리로드한다.
*
* @return {@code {success, message, total, successCount, failCount, itemCount?, reloadedAt?,
* servers:[{url, success, message, itemCount?, reloadedAt?, elapsedMs}]}}
* — {@code success} 는 <b>모든</b> 서버가 성공한 경우에만 true
*/
public Map<String, Object> reload() {
Map<String, Object> result = new LinkedHashMap<>();
String url = resolveUrl();
try {
@SuppressWarnings("unchecked")
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class);
Map<String, Object> body = response.getBody();
if (response.getStatusCode().is2xxSuccessful() && body != null && "OK".equals(body.get("result"))) {
result.put("success", true);
result.put("message", "포탈 메뉴 캐시 리로드 완료");
result.put("itemCount", body.get("itemCount"));
result.put("reloadedAt", body.get("reloadedAt"));
} else {
result.put("success", false);
result.put("message", "포탈 응답 오류: " + (body == null ? response.getStatusCode() : body));
List<String> urls = resolveUrls();
List<Map<String, Object>> servers = callAll(urls);
int successCount = 0;
for (Map<String, Object> server : servers) {
if (Boolean.TRUE.equals(server.get("success"))) {
successCount++;
}
}
int failCount = servers.size() - successCount;
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", failCount == 0);
result.put("message", summarize(servers.size(), successCount, failCount));
result.put("total", servers.size());
result.put("successCount", successCount);
result.put("failCount", failCount);
result.put("servers", servers);
// 단일 서버 시절 응답과의 호환 — 첫 성공 서버의 값을 최상위에도 노출
for (Map<String, Object> server : servers) {
if (Boolean.TRUE.equals(server.get("success"))) {
result.put("itemCount", server.get("itemCount"));
result.put("reloadedAt", server.get("reloadedAt"));
break;
}
} catch (Exception e) {
logger.error("포탈 메뉴 캐시 리로드 실패 - url: " + url, e);
result.put("success", false);
result.put("message", "포탈 호출 실패(" + url + "): " + e.getMessage());
}
return result;
}
private String resolveUrl() {
private String summarize(int total, int successCount, int failCount) {
if (total == 0) {
return "리로드 대상 URL 이 설정되지 않았습니다 (" + PROP_GROUP + " / " + PROP_RELOAD_URL + ")";
}
if (failCount == 0) {
return "포탈 메뉴 캐시 리로드 완료 (" + successCount + "/" + total + " 서버)";
}
if (successCount == 0) {
return "포탈 메뉴 캐시 리로드 실패 (0/" + total + " 서버)";
}
return "포탈 메뉴 캐시 리로드 일부 실패 (" + successCount + "/" + total + " 서버 성공)";
}
/** 모든 URL 로 병렬 호출. 반환 순서는 {@code urls} 순서와 동일하다. */
private List<Map<String, Object>> callAll(List<String> urls) {
if (urls.isEmpty()) {
return Collections.emptyList();
}
if (urls.size() == 1) {
return Collections.singletonList(callOne(urls.get(0)));
}
List<Callable<Map<String, Object>>> tasks = new ArrayList<>(urls.size());
for (final String url : urls) {
tasks.add(new Callable<Map<String, Object>>() {
@Override
public Map<String, Object> call() {
return callOne(url);
}
});
}
ExecutorService executor = Executors.newFixedThreadPool(Math.min(urls.size(), MAX_PARALLELISM));
try {
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_RELOAD_URL,
DEFAULT_RELOAD_URL, "eapim-portal 메뉴 캐시 리로드 내부 URL");
} catch (Exception e) {
logger.warn("리로드 URL 조회 실패 - 기본값 사용: " + DEFAULT_RELOAD_URL, e);
return DEFAULT_RELOAD_URL;
List<Future<Map<String, Object>>> futures =
executor.invokeAll(tasks, TIMEOUT_MS + OVERALL_SLACK_MS, TimeUnit.MILLISECONDS);
List<Map<String, Object>> results = new ArrayList<>(urls.size());
for (int i = 0; i < futures.size(); i++) {
String url = urls.get(i);
Future<Map<String, Object>> future = futures.get(i);
try {
results.add(future.get());
} catch (CancellationException e) {
logger.warn("포탈 메뉴 캐시 리로드 타임아웃 - url: " + url);
results.add(failure(url, "응답 시간 초과(" + TIMEOUT_MS + "ms)", TIMEOUT_MS));
} catch (ExecutionException e) {
logger.error("포탈 메뉴 캐시 리로드 실패 - url: " + url, e.getCause());
results.add(failure(url, message(e.getCause()), 0L));
}
}
return results;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("포탈 메뉴 캐시 리로드 중단됨", e);
List<Map<String, Object>> results = new ArrayList<>(urls.size());
for (String url : urls) {
results.add(failure(url, "호출이 중단되었습니다", 0L));
}
return results;
} finally {
executor.shutdownNow();
}
}
/** 서버 1대 호출 — 예외를 전파하지 않고 실패 Map 으로 감싼다. */
private Map<String, Object> callOne(String url) {
long startedAt = System.currentTimeMillis();
try {
@SuppressWarnings("unchecked")
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class);
long elapsed = System.currentTimeMillis() - startedAt;
@SuppressWarnings("unchecked")
Map<String, Object> body = response.getBody();
if (response.getStatusCode().is2xxSuccessful() && body != null && "OK".equals(body.get("result"))) {
Map<String, Object> server = new LinkedHashMap<>();
server.put("url", url);
server.put("success", true);
server.put("message", "리로드 완료");
server.put("itemCount", body.get("itemCount"));
server.put("reloadedAt", body.get("reloadedAt"));
server.put("elapsedMs", elapsed);
return server;
}
logger.warn("포탈 메뉴 캐시 리로드 응답 오류 - url: " + url + ", status: " + response.getStatusCode()
+ ", body: " + body);
return failure(url, "포탈 응답 오류: " + (body == null ? response.getStatusCode() : body), elapsed);
} catch (Exception e) {
long elapsed = System.currentTimeMillis() - startedAt;
logger.error("포탈 메뉴 캐시 리로드 실패 - url: " + url, e);
return failure(url, message(e), elapsed);
}
}
private Map<String, Object> failure(String url, String message, long elapsedMs) {
Map<String, Object> server = new LinkedHashMap<>();
server.put("url", url);
server.put("success", false);
server.put("message", message);
server.put("elapsedMs", elapsedMs);
return server;
}
private String message(Throwable t) {
if (t == null) {
return "알 수 없는 오류";
}
String msg = t.getMessage();
return (msg == null || msg.trim().isEmpty()) ? t.getClass().getSimpleName() : msg;
}
private List<String> resolveUrls() {
String raw;
try {
raw = portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_RELOAD_URL,
DEFAULT_RELOAD_URL, PROP_DESCRIPTION);
} catch (Exception e) {
logger.warn("리로드 URL 조회 실패 - 기본값 사용: " + DEFAULT_RELOAD_URL, e);
raw = DEFAULT_RELOAD_URL;
}
List<String> urls = parseUrls(raw);
return urls.isEmpty() ? parseUrls(DEFAULT_RELOAD_URL) : urls;
}
/**
* 콤마/세미콜론/줄바꿈으로 구분된 URL 목록을 파싱한다.
* {@code #} 로 시작하는 줄은 주석으로 무시하고, 중복 URL 은 최초 순서만 남긴다.
*/
static List<String> parseUrls(String raw) {
if (raw == null || raw.trim().isEmpty()) {
return Collections.emptyList();
}
Set<String> urls = new LinkedHashSet<>();
for (String token : raw.split("[,;\\r\\n]")) {
String url = token.trim();
if (url.isEmpty() || url.startsWith("#")) {
continue;
}
urls.add(url);
}
return new ArrayList<>(urls);
}
}