Files
eapim-admin/WebContent/jsp/onl/admin/inflow/inflowGroupControlManDetail.jsp
T
daekuk 10a8918bb6 feat: 유량제어 그룹 버킷 분리 및 현황 화면 개선
- CustomGroupBucket 클래스 신규 생성: 초당/추가 임계치를 별도 LocalBucket으로 분리하여 개별 토큰 조회 가능
  - InflowControlManager 수정: groupBucketList 타입을 CustomGroupBucket으로 변경, makeGroupBucket() 메서드 추가
  - InflowGroupBucketService 단순화: 복잡한 리플렉션 로직 제거, CustomGroupBucket 직접 메서드 사용
  - 현황 탭에 전체 인스턴스 합산 요약 추가 (활성 인스턴스 수, 초당/추가 임계치 합산)
  - 추가 임계치 시간단위에서 '초(SEC)' 옵션 제거
  - 저장 완료 후 리스트 화면 이동 제거
2025-12-11 11:04:00 +09:00

1148 lines
44 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<%@ page language="java" contentType="text/html; charset=utf-8"%>
<%@ page import="java.io.*"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib uri="http://www.springframework.org/tags" prefix="spring"%>
<%@ include file="/jsp/common/include/localemessage.jsp" %>
<%
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Expires", "0");
%>
<html>
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<jsp:include page="/jsp/common/include/css.jsp"/>
<jsp:include page="/jsp/common/include/script.jsp"/>
<style>
/* 폼 테이블 - 기존 table_row 스타일 기반 */
.form-table { width:100%; border:0; border-top:1px solid #a3a9b1; border-left:1px solid #a3a9b1; font-size:12px; color:#333; margin-bottom:15px; }
.form-table th { padding:8px 15px; border-right:1px solid #ebebec; border-bottom:1px solid #a3a9b1; text-align:left; background:rgb(255, 247, 231); font-weight:bold; white-space:nowrap; }
.form-table td { padding:8px 15px; border-right:1px solid #a3a9b1; border-bottom:1px solid #a3a9b1; }
.form-table input[type="text"], .form-table input[type="number"] { border:1px solid #ebebec; padding:4px 8px; }
.form-table input[type="number"] { width:100px; text-align:right; }
.form-table input:disabled { background:#ebebec; color:#999; }
.form-table .select-style { display:inline-block; overflow:hidden; border:1px solid #ebebec; background:#fff; }
.form-table .select-style select { border:none; box-shadow:none; background:transparent; padding:4px 8px; min-width:80px; }
/* iOS 스타일 토글 */
.toggle-wrap { display:inline-flex; align-items:center; gap:10px; }
.toggle-label { font-size:12px; color:#333; min-width:40px; }
.ios-toggle { position:relative; display:inline-block; width:44px; height:24px; }
.ios-toggle input { display:none; }
.ios-toggle-slider { position:absolute; top:0; left:0; right:0; bottom:0; background:#ccc; border-radius:24px; cursor:pointer; transition:all 0.3s ease; }
.ios-toggle-slider::before { content:''; position:absolute; top:2px; left:2px; width:20px; height:20px; background:#fff; border-radius:50%; box-shadow:0 1px 3px rgba(0,0,0,0.2); transition:all 0.3s ease; }
.ios-toggle input:checked + .ios-toggle-slider { background:rgba(0,128,128,0.85); }
.ios-toggle input:checked + .ios-toggle-slider::before { transform:translateX(20px); }
/* 임계치 입력 그룹 */
.threshold-group { display:inline-flex; align-items:center; gap:8px; }
.threshold-group .unit { font-size:11px; color:#666; min-width:60px; }
/* 스타일드 셀렉트 */
.styled-select { appearance:none; -webkit-appearance:none; -moz-appearance:none; padding:6px 30px 6px 12px; font-size:12px; border:1px solid #ddd; border-radius:4px; background:#fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E") no-repeat right 10px center; cursor:pointer; min-width:120px; transition:all 0.2s; color:#333; }
.styled-select:hover { border-color:rgba(0,128,128,0.5); }
.styled-select:focus { outline:none; border-color:rgba(0,128,128,0.8); box-shadow:0 0 0 2px rgba(0,128,128,0.1); }
/* 힌트 아이콘 */
.hint-icon { display:inline-flex; align-items:center; cursor:pointer; color:#999; position:relative; margin-left:4px; vertical-align:middle; }
.hint-icon:hover { color:rgba(0,128,128,1); }
.hint-icon i { font-size:14px; }
.hint-icon .hint-bubble { display:none; position:absolute; bottom:calc(100% + 8px); left:0; background:#333; color:#fff; padding:10px 12px; border-radius:4px; font-size:11px; width:220px; line-height:1.5; z-index:1000; box-shadow:0 2px 8px rgba(0,0,0,0.2); white-space:normal; word-break:keep-all; }
.hint-icon .hint-bubble::after { content:''; position:absolute; top:100%; left:14px; border:6px solid transparent; border-top-color:#333; }
.hint-icon:hover .hint-bubble { display:block; }
/* 섹션 구분 라벨 */
.section-label { display:block; font-size:13px; color:#333; font-weight:bold; margin:20px 0 10px 0; padding-bottom:5px; border-bottom:2px solid rgba(0,128,128,0.3); }
/* 타이틀 래퍼 */
.title-wrap { display:flex; align-items:center; gap:12px; margin-bottom:10px; }
.title-wrap .title { margin-bottom:0; }
/* 수정일시 뱃지 */
.modified-badge { display:inline-flex; align-items:center; gap:4px; padding:4px 10px; background:linear-gradient(135deg, #f0f7f7 0%, #e8f4f4 100%); border:1px solid rgba(0,128,128,0.2); border-radius:20px; font-size:11px; color:#555; }
.modified-badge i { font-size:13px; color:rgba(0,128,128,0.7); }
/* 인터페이스 리스트 헤더 */
.interface-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
.interface-count { font-size:12px; color:#666; }
/* 인터페이스 리스트 테이블 */
.interface-list-wrap { border:1px solid #a3a9b1; border-radius:3px; max-height:250px; overflow-y:auto; margin-bottom:15px; }
.interface-list-table { width:100%; border-collapse:collapse; font-size:12px; }
.interface-list-table th { padding:8px 12px; background:rgb(255, 247, 231); border-bottom:1px solid #a3a9b1; text-align:left; font-weight:bold; position:sticky; top:0; }
.interface-list-table td { padding:8px 12px; border-bottom:1px solid #ebebec; }
.interface-list-table tr:last-child td { border-bottom:none; }
.interface-list-table tr:hover { background:#f9f9f9; }
.interface-list-table .col-id { width:200px; font-family:Consolas, monospace; color:rgb(65,125,200); }
.interface-list-table .col-action { width:80px; text-align:center; white-space:nowrap; }
/* 미니 토글 - teal 컬러 */
.mini-toggle { position:relative; display:inline-block; width:32px; height:18px; vertical-align:middle; margin-right:8px; }
.mini-toggle input { display:none; }
.mini-toggle-slider { position:absolute; top:0; left:0; right:0; bottom:0; background:#ccc; border-radius:18px; cursor:pointer; transition:all 0.2s; }
.mini-toggle-slider::before { content:''; position:absolute; top:2px; left:2px; width:14px; height:14px; background:#fff; border-radius:50%; transition:all 0.2s; }
.mini-toggle input:checked + .mini-toggle-slider { background:rgba(0,128,128,0.8); }
.mini-toggle input:checked + .mini-toggle-slider::before { transform:translateX(14px); }
/* 인라인 삭제 버튼 */
.btn-icon { background:none; border:none; cursor:pointer; color:#999; padding:2px; transition:color 0.2s; vertical-align:middle; }
.btn-icon:hover { color:#ff4d4f; }
.btn-icon i { font-size:16px; }
/* 빈 상태 메시지 */
.interface-empty { padding:30px 20px; text-align:center; color:#999; font-size:12px; }
.interface-empty i { vertical-align:middle; margin-right:4px; font-size:18px; }
/* 탭 스타일 */
.detail-tabs { display:flex; gap:0; border-bottom:2px solid rgba(0,128,128,0.3); margin-bottom:20px; }
.detail-tab { padding:10px 24px; font-size:13px; font-weight:bold; color:#666; cursor:pointer; border:none; background:transparent; border-bottom:2px solid transparent; margin-bottom:-2px; transition:all 0.2s; }
.detail-tab:hover { color:rgba(0,128,128,0.8); background:rgba(0,128,128,0.05); }
.detail-tab.active { color:rgba(0,128,128,1); border-bottom-color:rgba(0,128,128,0.8); background:rgba(0,128,128,0.05); }
.detail-tab i { vertical-align:middle; margin-right:4px; font-size:18px; }
.tab-content { display:none; }
.tab-content.active { display:block; }
/* 버킷 현황 스타일 */
.bucket-status-wrap { margin-top:10px; }
.bucket-refresh-bar { display:flex; justify-content:space-between; align-items:center; margin-bottom:15px; }
.bucket-refresh-bar .last-refresh { font-size:11px; color:#999; }
.bucket-server-list { display:flex; flex-wrap:wrap; gap:15px; }
.bucket-server-card { flex:0 0 calc(33.333% - 10px); min-width:280px; border:1px solid #ddd; border-radius:5px; overflow:hidden; }
.bucket-server-header { display:flex; justify-content:space-between; align-items:center; padding:10px 15px; background:#f9f9f9; border-bottom:1px solid #eee; }
.bucket-server-name { font-weight:bold; font-size:13px; }
.bucket-server-ip { font-size:11px; color:#888; font-family:Consolas, monospace; }
.bucket-server-status { font-size:11px; padding:3px 8px; border-radius:10px; }
.bucket-server-status.online { background:#e8f5e9; color:#2e7d32; }
.bucket-server-status.offline { background:#ffebee; color:#c62828; }
.bucket-server-status.no_data { background:#fff3e0; color:#ef6c00; }
.bucket-server-body { padding:15px; }
.bucket-info-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(280px, 1fr)); gap:15px; }
.bucket-card { background:#fafafa; border:1px solid #eee; border-radius:4px; padding:12px 15px; }
.bucket-card-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; }
.bucket-type { font-size:12px; font-weight:bold; color:#333; }
.bucket-type-badge { font-size:10px; padding:2px 6px; border-radius:3px; background:#e3f2fd; color:#1565c0; }
.bucket-progress-wrap { margin-bottom:8px; }
.bucket-progress-bar { height:8px; background:#e0e0e0; border-radius:4px; overflow:hidden; }
.bucket-progress-fill { height:100%; border-radius:4px; transition:width 0.3s ease; }
.bucket-progress-fill.low { background:linear-gradient(90deg, #4caf50, #66bb6a); }
.bucket-progress-fill.medium { background:linear-gradient(90deg, #ff9800, #ffa726); }
.bucket-progress-fill.high { background:linear-gradient(90deg, #f44336, #ef5350); }
.bucket-stats { display:flex; justify-content:space-between; font-size:11px; color:#666; }
.bucket-stats-value { font-family:Consolas, monospace; }
.bucket-empty-msg { padding:30px; text-align:center; color:#999; font-size:12px; }
.bucket-empty-msg i { font-size:36px; display:block; margin-bottom:10px; color:#ddd; }
.bucket-error-msg { color:#c62828; font-size:12px; }
/* 전체 합산 요약 */
.bucket-summary { background:linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); border:1px solid #dee2e6; border-radius:5px; padding:15px 20px; margin-bottom:20px; }
.bucket-summary-title { font-size:13px; font-weight:bold; color:#333; margin-bottom:12px; display:flex; align-items:center; gap:6px; }
.bucket-summary-title i { font-size:18px; color:rgba(0,128,128,0.8); }
.bucket-summary-grid { display:flex; gap:30px; flex-wrap:wrap; }
.bucket-summary-item { display:flex; flex-direction:column; }
.bucket-summary-label { font-size:11px; color:#666; margin-bottom:4px; }
.bucket-summary-value { font-size:18px; font-weight:bold; color:#333; font-family:Consolas, monospace; }
.bucket-summary-value .unit { font-size:12px; font-weight:normal; color:#888; margin-left:4px; }
.bucket-summary-sub { font-size:11px; color:#888; margin-top:2px; }
/* 도움말 모달 */
.help-modal-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.4); z-index:9999; }
.help-modal { position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); background:#fff; border-radius:5px; box-shadow:0 4px 20px rgba(0,0,0,0.3); width:700px; max-height:80vh; overflow-y:auto; }
.help-modal-header { display:flex; justify-content:space-between; align-items:center; padding:12px 20px; border-bottom:1px solid #ddd; background:rgb(255, 247, 231); }
.help-modal-header h3 { margin:0; font-size:14px; font-weight:bold; color:#333; }
.help-modal-close { background:none; border:none; font-size:20px; cursor:pointer; color:#666; }
.help-modal-close:hover { color:#333; }
.help-modal-body { padding:20px; font-size:12px; line-height:1.6; }
.help-modal-body h4 { margin:15px 0 8px 0; font-size:13px; color:#333; border-left:3px solid rgba(0,128,128,0.6); padding-left:10px; }
.help-modal-body h4:first-child { margin-top:0; }
.help-modal-body p { margin:6px 0; color:#555; }
.help-modal-body ul { margin:6px 0; padding-left:18px; }
.help-modal-body li { margin:4px 0; }
.help-modal-body .example-box { background:#f9f9f9; border:1px solid #ddd; border-radius:3px; padding:12px; margin:8px 0; }
.help-modal-body .example-title { font-weight:bold; color:#333; margin-bottom:6px; }
.help-modal-body table { width:100%; border-collapse:collapse; margin:8px 0; }
.help-modal-body table th, .help-modal-body table td { border:1px solid #ddd; padding:6px 10px; text-align:left; }
.help-modal-body table th { background:rgb(255, 247, 231); }
.help-modal-body .note { background:#fffde7; border:1px solid #fff176; border-radius:3px; padding:10px 12px; margin:12px 0; }
/* 인터페이스 선택 모달 */
.if-modal-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.4); z-index:9998; }
.if-modal { position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); background:#fff; border-radius:5px; box-shadow:0 4px 20px rgba(0,0,0,0.3); width:850px; }
.if-modal-header { display:flex; justify-content:space-between; align-items:center; padding:12px 20px; border-bottom:1px solid #ddd; background:rgb(255, 247, 231); border-radius:5px 5px 0 0; }
.if-modal-header h3 { margin:0; font-size:14px; font-weight:bold; color:#333; }
.if-modal-close { background:none; border:none; font-size:20px; cursor:pointer; color:#666; }
.if-modal-close:hover { color:#333; }
.if-modal-body { padding:15px 20px; }
/* 검색 영역 */
.if-search-box { display:flex; gap:8px; margin-bottom:12px; }
.if-search-box input { flex:1; border:1px solid #ebebec; padding:6px 10px; font-size:12px; }
.if-search-box button { padding:6px 12px; font-size:12px; }
/* 테이블 */
.if-table-wrap { border:1px solid #a3a9b1; border-radius:3px; max-height:400px; overflow-y:auto; }
.if-table { width:100%; border-collapse:collapse; font-size:12px; }
.if-table th { padding:8px 12px; background:rgb(255, 247, 231); border-bottom:1px solid #a3a9b1; text-align:left; font-weight:bold; position:sticky; top:0; }
.if-table td { padding:8px 12px; border-bottom:1px solid #ebebec; cursor:pointer; }
.if-table tr:last-child td { border-bottom:none; }
.if-table tbody tr:hover { background:#e8f4f4; }
.if-table tbody tr.selected { background:rgba(0,128,128,0.15); }
.if-table .col-id { width:180px; font-family:Consolas, monospace; color:rgb(65,125,200); }
.if-table-empty { padding:40px 20px; text-align:center; color:#999; }
/* 페이지네이션 */
.if-pagination { display:flex; justify-content:space-between; align-items:center; margin-top:12px; font-size:12px; }
.if-page-info { color:#666; }
.if-page-nav { display:flex; align-items:center; gap:4px; }
.if-page-btn { min-width:28px; height:28px; padding:0 6px; border:1px solid #ddd; background:#fff; cursor:pointer; font-size:11px; border-radius:3px; }
.if-page-btn:hover:not(:disabled) { background:#f0f0f0; border-color:#bbb; }
.if-page-btn:disabled { color:#ccc; cursor:default; }
.if-page-btn.active { background:rgba(0,128,128,0.8); color:#fff; border-color:rgba(0,128,128,0.8); }
.if-page-num { display:flex; gap:2px; }
/* 하단 버튼 */
.if-modal-footer { display:flex; justify-content:flex-end; gap:8px; padding:12px 20px; border-top:1px solid #ddd; background:#fafafa; border-radius:0 0 5px 5px; }
</style>
<script language="javascript">
var url ='<c:url value="/onl/admin/inflow/inflowGroupControlMan.json" />';
var url_view ='<c:url value="/onl/admin/inflow/inflowGroupControlMan.view" />';
var isDetail = false;
var interfaceList = [];
function isValid() {
if ($('input[name=groupName]').val().trim() == "") {
alert("그룹명을 입력하여 주십시요.");
$('input[name=groupName]').focus();
return false;
}
return true;
}
function updateThresholdLabel() {
var timeUnit = $('#thresholdTimeUnit').val();
var $input = $('#thresholdInput');
var $unit = $('#thresholdUnit');
var unitLabels = {
'': '',
'SEC': 'req/sec',
'MIN': 'req/min',
'HOU': 'req/hour',
'DAY': 'req/day',
'MON': 'req/month'
};
$unit.text(unitLabels[timeUnit] || 'requests');
if (timeUnit === '') {
$input.prop('disabled', true).val('');
$unit.css('visibility', 'hidden');
} else {
$input.prop('disabled', false);
$unit.css('visibility', 'visible');
}
}
function init(url, key, callback) {
// useYn 초기값 설정 (기본: 사용)
$('#useYn').val('1');
updateThresholdLabel();
if (typeof callback === 'function') {
callback(url, key);
}
}
function getSelectedTimeUnitText() {
return $('#thresholdTimeUnit option:selected').text();
}
function detail(url, key) {
if (!isDetail) return;
$.ajax({
type: "POST",
url: url,
dataType: "json",
data: {cmd: 'DETAIL', groupId: key},
success: function(json) {
$("input[name=groupId]").val(json.GROUPID || json.groupId);
$("input[name=groupName]").val(json.GROUPNAME || json.groupName);
$("input[name=thresholdPerSecond]").val(json.THRESHOLDPERSECOND || json.thresholdPerSecond);
$("input[name=threshold]").val(json.THRESHOLD || json.threshold);
$("select[name=thresholdTimeUnit]").val(json.THRESHOLDTIMEUNIT || json.thresholdTimeUnit || '');
var useYnVal = json.USEYN || json.useYn || '1';
$('#useYn').val(useYnVal);
$('#useYnToggle').prop('checked', useYnVal === '1');
$('#useYnLabel').text(useYnVal === '1' ? '사용' : '미사용');
updateThresholdLabel();
// 수정일시 표시
var modifiedAt = json.MODIFIEDAT || json.modifiedAt;
if (modifiedAt) {
$('#modifiedAtText').text(modifiedAt);
$('#modifiedAtInfo').show();
}
interfaceList = [];
if (json.interfaceList && json.interfaceList.length > 0) {
$.each(json.interfaceList, function(i, item) {
interfaceList.push({
interfaceId: item.interfaceId || item.INTERFACEID,
interfaceDesc: item.interfaceDesc || item.INTERFACEDESC
});
});
}
renderInterfaceList();
},
error: function(e) {
alert(e.responseText);
}
});
}
function renderInterfaceList() {
var container = $('#interfaceListContainer');
container.empty();
$('#interfaceCountNum').text(interfaceList.length);
if (interfaceList.length === 0) {
container.html('<tr class="interface-empty-row" id="interfaceEmpty"><td colspan="3" class="interface-empty"><i class="material-icons">inbox</i> 매핑된 API가 없습니다</td></tr>');
return;
}
$.each(interfaceList, function(i, item) {
var useYn = item.useYn !== undefined ? item.useYn : '1';
var checked = useYn === '1' ? 'checked' : '';
var row = '<tr data-id="' + item.interfaceId + '">' +
'<td class="col-id">' + item.interfaceId + '</td>' +
'<td class="col-desc">' + (item.interfaceDesc || '-') + '</td>' +
'<td class="col-action">' +
'<label class="mini-toggle" title="사용여부">' +
'<input type="checkbox" class="interface-use-toggle" data-id="' + item.interfaceId + '" ' + checked + ' />' +
'<span class="mini-toggle-slider"></span>' +
'</label>' +
'<button type="button" class="btn-icon btn-delete-item" data-id="' + item.interfaceId + '" title="삭제">' +
'<i class="material-icons">close</i>' +
'</button>' +
'</td>' +
'</tr>';
container.append(row);
});
}
function getInterfaceListJson() {
var gridData = [];
$.each(interfaceList, function(i, item) {
gridData.push({interfaceId: item.interfaceId});
});
return JSON.stringify(gridData);
}
// 인터페이스 선택 모달 관련
var ifModalState = {
data: [],
page: 1,
pageSize: 10,
totalCount: 0,
selectedItem: null
};
function openIfModal() {
ifModalState.page = 1;
ifModalState.selectedItem = null;
$('#ifSearchInput').val('');
$('#ifModal').fadeIn(200);
loadIfList();
}
function closeIfModal() {
$('#ifModal').fadeOut(200);
}
function loadIfList() {
var searchName = $('#ifSearchInput').val();
$.ajax({
type: "POST",
url: url,
dataType: "json",
data: {
cmd: 'INTERFACE_LIST',
searchName: searchName,
page: ifModalState.page,
rows: ifModalState.pageSize
},
success: function(json) {
ifModalState.data = json.rows || [];
ifModalState.totalCount = json.records || 0;
renderIfTable();
renderIfPagination();
},
error: function(e) {
alert(e.responseText);
}
});
}
function renderIfTable() {
var tbody = $('#ifTableBody');
tbody.empty();
if (ifModalState.data.length === 0) {
tbody.html('<tr><td colspan="2" class="if-table-empty">검색 결과가 없습니다</td></tr>');
return;
}
$.each(ifModalState.data, function(i, item) {
var interfaceId = item.INTERFACEID || item.interfaceId;
var interfaceDesc = item.INTERFACEDESC || item.interfaceDesc || '-';
var row = $('<tr data-id="' + interfaceId + '" data-desc="' + interfaceDesc + '">' +
'<td class="col-id">' + interfaceId + '</td>' +
'<td>' + interfaceDesc + '</td>' +
'</tr>');
tbody.append(row);
});
// 행 클릭 이벤트
tbody.find('tr').on('click', function() {
tbody.find('tr').removeClass('selected');
$(this).addClass('selected');
ifModalState.selectedItem = {
interfaceId: $(this).data('id'),
interfaceDesc: $(this).data('desc')
};
});
// 더블클릭으로 바로 선택
tbody.find('tr').on('dblclick', function() {
ifModalState.selectedItem = {
interfaceId: $(this).data('id'),
interfaceDesc: $(this).data('desc')
};
selectInterface();
});
}
function renderIfPagination() {
var totalPages = Math.ceil(ifModalState.totalCount / ifModalState.pageSize);
var currentPage = ifModalState.page;
$('#ifTotalCount').text(ifModalState.totalCount);
// 이전/다음 버튼
$('#ifPrevBtn').prop('disabled', currentPage <= 1);
$('#ifNextBtn').prop('disabled', currentPage >= totalPages);
// 페이지 번호
var pageNumContainer = $('#ifPageNum');
pageNumContainer.empty();
if (totalPages <= 0) return;
var startPage = Math.max(1, currentPage - 2);
var endPage = Math.min(totalPages, startPage + 4);
if (endPage - startPage < 4) {
startPage = Math.max(1, endPage - 4);
}
for (var i = startPage; i <= endPage; i++) {
var activeClass = (i === currentPage) ? ' active' : '';
var btn = $('<button type="button" class="if-page-btn' + activeClass + '">' + i + '</button>');
btn.data('page', i);
pageNumContainer.append(btn);
}
// 페이지 번호 클릭
pageNumContainer.find('.if-page-btn').on('click', function() {
ifModalState.page = $(this).data('page');
ifModalState.selectedItem = null;
loadIfList();
});
}
// 탭 전환 기능
function switchTab(tabName) {
$('.detail-tab').removeClass('active');
$('.detail-tab[data-tab="' + tabName + '"]').addClass('active');
$('.tab-content').removeClass('active');
$('#tab' + tabName.charAt(0).toUpperCase() + tabName.slice(1) + 'Content').addClass('active');
if (tabName === 'status' && isDetail) {
loadBucketStatus();
}
}
// 버킷 현황 조회
function loadBucketStatus() {
var groupId = $('input[name=groupId]').val();
if (!groupId) return;
$('#bucketServerList').html('<div class="bucket-empty-msg"><i class="material-icons">hourglass_empty</i>조회 중...</div>');
$.ajax({
type: 'POST',
url: url,
data: { cmd: 'LIST_BUCKET_STATUS', groupId: groupId },
dataType: 'json',
success: function(result) {
renderBucketStatus(result.servers || []);
$('#lastRefreshTime').text(formatDateTime(new Date()));
},
error: function(e) {
$('#bucketServerList').html('<div class="bucket-empty-msg bucket-error-msg"><i class="material-icons">error_outline</i>조회 실패: ' + e.statusText + '</div>');
}
});
}
function formatDateTime(date) {
var h = date.getHours();
var m = date.getMinutes();
var s = date.getSeconds();
return (h < 10 ? '0' + h : h) + ':' + (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s);
}
function renderBucketStatus(servers) {
var container = $('#bucketServerList');
container.empty();
if (servers.length === 0) {
container.html('<div class="bucket-empty-msg"><i class="material-icons">dns</i>등록된 서버가 없습니다</div>');
$('#bucketSummary').hide();
return;
}
// 전체 합산 계산
var onlineCount = 0;
var perSecondCapacity = 0;
var thresholdCapacity = 0;
var thresholdTimeUnit = '';
$.each(servers, function(i, server) {
if (server.status === 'online' && server.data && server.data.buckets) {
onlineCount++;
$.each(server.data.buckets, function(j, bucket) {
if (bucket.type === 'perSecond') {
perSecondCapacity += bucket.capacity || 0;
} else if (bucket.type === 'threshold') {
thresholdCapacity += bucket.capacity || 0;
if (!thresholdTimeUnit) thresholdTimeUnit = bucket.timeUnit;
}
});
}
});
// 요약 영역 표시
if (onlineCount > 0) {
$('#summaryInstanceCount').html(onlineCount + '<span class="unit">개</span>');
var configPerSecond = parseInt($('input[name=thresholdPerSecond]').val()) || 0;
var configThreshold = parseInt($('input[name=threshold]').val()) || 0;
if (configPerSecond > 0) {
$('#summaryPerSecond').html(formatNumber(perSecondCapacity) + '<span class="unit">req/sec</span>');
$('#summaryPerSecondCalc').text(formatNumber(configPerSecond) + ' × ' + onlineCount + ' 인스턴스');
$('#summaryPerSecondWrap').show();
} else {
$('#summaryPerSecondWrap').hide();
}
if (configThreshold > 0) {
var timeUnitLabel = getTimeUnitText(thresholdTimeUnit);
$('#summaryThreshold').html(formatNumber(thresholdCapacity) + '<span class="unit">req/' + timeUnitLabel + '</span>');
$('#summaryThresholdCalc').text(formatNumber(configThreshold) + ' × ' + onlineCount + ' 인스턴스');
$('#summaryThresholdWrap').show();
} else {
$('#summaryThresholdWrap').hide();
}
$('#bucketSummary').show();
} else {
$('#bucketSummary').hide();
}
$.each(servers, function(i, server) {
var statusClass = server.status || 'offline';
var statusText = { online: '정상', offline: '오프라인', no_data: '데이터 없음' };
var card = '<div class="bucket-server-card">' +
'<div class="bucket-server-header">' +
'<div>' +
'<span class="bucket-server-name">' + server.serverName + '</span> ' +
'<span class="bucket-server-ip">' + server.serverIp + ':' + server.serverPort + '</span>' +
'</div>' +
'<span class="bucket-server-status ' + statusClass + '">' + (statusText[statusClass] || statusClass) + '</span>' +
'</div>' +
'<div class="bucket-server-body">' + renderBucketBody(server) + '</div>' +
'</div>';
container.append(card);
});
}
function renderBucketBody(server) {
if (server.status === 'offline') {
return '<div class="bucket-error-msg">' + (server.message || '서버 연결 실패') + '</div>';
}
if (server.status === 'no_data') {
return '<div class="bucket-empty-msg" style="padding:10px;"><i class="material-icons" style="font-size:18px;">info_outline</i> ' + (server.message || '그룹이 로드되지 않음') + '</div>';
}
var data = server.data;
if (!data || !data.buckets || data.buckets.length === 0) {
return '<div class="bucket-empty-msg" style="padding:10px;">버킷 정보 없음</div>';
}
var html = '<div class="bucket-info-grid">';
$.each(data.buckets, function(j, bucket) {
var typeLabel = bucket.type === 'perSecond' ? '초당 임계치' : '추가 임계치';
var timeUnitText = getTimeUnitText(bucket.timeUnit);
var usagePercent = bucket.usagePercent || 0;
var progressClass = usagePercent < 50 ? 'low' : (usagePercent < 80 ? 'medium' : 'high');
html += '<div class="bucket-card">' +
'<div class="bucket-card-header">' +
'<span class="bucket-type">' + typeLabel + '</span>' +
'<span class="bucket-type-badge">' + timeUnitText + '</span>' +
'</div>' +
'<div class="bucket-progress-wrap">' +
'<div class="bucket-progress-bar">' +
'<div class="bucket-progress-fill ' + progressClass + '" style="width:' + usagePercent + '%"></div>' +
'</div>' +
'</div>' +
'<div class="bucket-stats">' +
'<span>사용량: <span class="bucket-stats-value">' + usagePercent.toFixed(1) + '%</span></span>' +
'<span>잔여: <span class="bucket-stats-value">' + formatNumber(bucket.availableTokens) + '</span> / ' + formatNumber(bucket.capacity) + '</span>' +
'</div>' +
'</div>';
});
html += '</div>';
return html;
}
function getTimeUnitText(timeUnit) {
var units = { SEC: '초', MIN: '분', HOU: '시간', DAY: '일', MON: '월' };
return units[timeUnit] || timeUnit || '-';
}
function formatNumber(num) {
if (num === undefined || num === null) return '-';
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function selectInterface() {
if (!ifModalState.selectedItem) {
alert('API를 선택하여 주십시오.');
return;
}
var newId = String(ifModalState.selectedItem.interfaceId);
// 현재 화면 내 중복 체크
var exists = interfaceList.some(function(item) {
return String(item.interfaceId) === newId;
});
if (exists) {
alert('이미 추가된 API입니다.');
return;
}
// 서버에 다른 그룹 등록 여부 체크
var groupId = $('input[name=groupId]').val();
$.ajax({
type: 'POST',
url: url,
data: { cmd: 'LIST_CHECK_DUPLICATE', interfaceId: newId, groupId: groupId },
dataType: 'json',
async: false,
success: function(result) {
if (result.duplicate) {
showAlert('<strong>' + newId + '</strong> API는<br/><strong>[' + result.groupName + ']</strong> 그룹에 이미 등록되어 있습니다.', {
type: 'warning',
title: '중복 등록 불가'
});
} else {
interfaceList.push(ifModalState.selectedItem);
renderInterfaceList();
closeIfModal();
}
},
error: function() {
showAlert('중복 체크 중 오류가 발생했습니다.', { type: 'error' });
}
});
}
$(document).ready(function() {
var returnUrl = getReturnUrlForReturn();
var key = "${param.groupId}";
if (key != "" && key != "null") {
isDetail = true;
}
init(url, key, detail);
$('#thresholdTimeUnit').on('change', function() {
updateThresholdLabel();
});
// 사용여부 토글 변경 시 hidden 값 및 라벨 업데이트
$('#useYnToggle').on('change', function() {
var isChecked = $(this).is(':checked');
$('#useYn').val(isChecked ? '1' : '0');
$('#useYnLabel').text(isChecked ? '사용' : '미사용');
});
// 인터페이스 삭제 버튼 (동적 요소이므로 위임)
$(document).on('click', '.btn-delete-item', function() {
var id = $(this).data('id');
interfaceList = interfaceList.filter(function(item) {
return item.interfaceId !== id;
});
renderInterfaceList();
});
// 인터페이스 사용여부 토글 (동적 요소이므로 위임)
$(document).on('change', '.interface-use-toggle', function() {
var id = $(this).data('id');
var useYn = $(this).is(':checked') ? '1' : '0';
$.each(interfaceList, function(i, item) {
if (item.interfaceId === id) {
item.useYn = useYn;
}
});
});
$("#btn_modify").click(function() {
if (!isValid()) return;
var confirmMsg = isDetail ? '변경사항을 저장하시겠습니까?' : '새 그룹을 등록하시겠습니까?';
showConfirm(confirmMsg, {
title: '저장 확인',
confirmText: '저장',
onConfirm: function() {
var postData = $('#ajaxForm').serializeArray();
if (isDetail) {
postData.push({name: "cmd", value: "UPDATE"});
} else {
postData.push({name: "cmd", value: "INSERT"});
}
postData.push({name: "interfaceListJson", value: getInterfaceListJson()});
$.ajax({
type: "POST",
url: url,
data: postData,
success: function(args) {
showAlert("<%= localeMessage.getString("common.saveMsg") %>", {
type: 'success',
title: '저장 완료'
});
},
error: function(e) {
showAlert(e.responseText, { type: 'error' });
}
});
}
});
});
$("#btn_delete").click(function() {
var groupName = $('input[name=groupName]').val();
showConfirm('<strong>[' + groupName + ']</strong> 그룹을 삭제하시겠습니까?<br/><span style="color:#999;font-size:11px;">매핑된 API 정보도 함께 삭제됩니다.</span>', {
type: 'error',
title: '삭제 확인',
confirmText: '삭제',
onConfirm: function() {
$.ajax({
type: "POST",
url: url,
data: {cmd: 'DELETE', groupId: $('input[name=groupId]').val()},
success: function(args) {
showAlert("<%= localeMessage.getString("common.deleteMsg") %>", {
type: 'success',
title: '삭제 완료',
onClose: function() {
goNav(returnUrl);
}
});
},
error: function(e) {
showAlert(e.responseText, { type: 'error' });
}
});
}
});
});
$("#btn_previous").click(function() {
goNav(returnUrl);
});
// 인터페이스 추가 버튼 → 모달 열기
$("#btn_popup_interface").click(function() {
openIfModal();
});
// 인터페이스 선택 모달 이벤트
$(".if-modal-close, #ifCancelBtn").click(function() {
closeIfModal();
});
$("#ifModal").click(function(e) {
if (e.target === this) closeIfModal();
});
$("#ifSearchBtn").click(function() {
ifModalState.page = 1;
ifModalState.selectedItem = null;
loadIfList();
});
$("#ifSearchInput").keydown(function(e) {
if (e.keyCode === 13) {
e.preventDefault();
$("#ifSearchBtn").click();
}
});
$("#ifSelectBtn").click(function() {
selectInterface();
});
$("#ifPrevBtn").click(function() {
if (ifModalState.page > 1) {
ifModalState.page--;
ifModalState.selectedItem = null;
loadIfList();
}
});
$("#ifNextBtn").click(function() {
var totalPages = Math.ceil(ifModalState.totalCount / ifModalState.pageSize);
if (ifModalState.page < totalPages) {
ifModalState.page++;
ifModalState.selectedItem = null;
loadIfList();
}
});
buttonControl(isDetail);
titleControl(isDetail);
// 탭 전환 이벤트
$('.detail-tab').click(function() {
var tabName = $(this).data('tab');
switchTab(tabName);
});
// 현황 탭 표시 (상세 조회 시에만)
if (isDetail) {
$('#tabStatus').show();
}
// 버킷 새로고침 버튼
$('#btn_refresh_bucket').click(function() {
loadBucketStatus();
});
// 도움말 모달
$("#btn_help").click(function() {
$("#helpModal").fadeIn(200);
});
$(".help-modal-close, .help-modal-overlay").click(function(e) {
if (e.target === this) {
$("#helpModal").fadeOut(200);
}
});
$(document).keydown(function(e) {
if (e.keyCode === 27) {
$("#helpModal").fadeOut(200);
$("#ifModal").fadeOut(200);
}
});
});
</script>
</head>
<body>
<div class="right_box">
<div class="content_top">
<ul class="path">
<li><a href="#">${rmsMenuPath}</a></li>
</ul>
</div>
<div class="content_middle" id="content_middle">
<div class="search_wrap">
<button type="button" class="cssbtn" id="btn_help"><i class="material-icons">help_outline</i> 도움말</button>
<button type="button" class="cssbtn" id="btn_delete" level="W" status="DETAIL"><i class="material-icons">delete</i> <%= localeMessage.getString("button.delete") %></button>
<button type="button" class="cssbtn" id="btn_modify" level="W" status="DETAIL,NEW"><i class="material-icons">save</i> <%= localeMessage.getString("button.save") %></button>
<button type="button" class="cssbtn" id="btn_previous"><i class="material-icons">arrow_back</i> <%= localeMessage.getString("button.previous") %></button>
</div>
<div class="title-wrap">
<div class="title">유량제어 그룹 관리<span class="tooltip">유량제어 그룹 관리</span></div>
<span id="modifiedAtInfo" class="modified-badge" style="display:none;">
<i class="material-icons">update</i><span id="modifiedAtText">-</span>
</span>
</div>
<!-- 탭 네비게이션 -->
<div class="detail-tabs" id="detailTabs">
<button type="button" class="detail-tab active" data-tab="config">
<i class="material-icons">settings</i> 설정
</button>
<button type="button" class="detail-tab" data-tab="status" id="tabStatus" style="display:none;">
<i class="material-icons">analytics</i> 현황
</button>
</div>
<!-- 설정 탭 -->
<div class="tab-content active" id="tabConfigContent">
<form id="ajaxForm">
<input type="hidden" name="groupId" />
<input type="hidden" name="useYn" id="useYn" value="1" />
<!-- 기본 정보 -->
<table class="form-table">
<colgroup>
<col style="width:120px" />
<col />
</colgroup>
<tbody>
<tr>
<th>그룹명 <span style="color:red">*</span></th>
<td><input type="text" name="groupName" style="width:300px" /></td>
</tr>
<tr>
<th>사용여부</th>
<td>
<div class="toggle-wrap">
<label class="ios-toggle">
<input type="checkbox" id="useYnToggle" checked />
<span class="ios-toggle-slider"></span>
</label>
<span class="toggle-label" id="useYnLabel">사용</span>
</div>
</td>
</tr>
</tbody>
</table>
<!-- 유량제어 설정 -->
<span class="section-label">유량제어 설정</span>
<table class="form-table">
<colgroup>
<col style="width:120px" />
<col />
</colgroup>
<tbody>
<tr>
<th>
초당 임계치
<span class="hint-icon"><i class="material-icons">help_outline</i>
<span class="hint-bubble">1초 단위의 순간 트래픽 제한입니다. 매 초마다 설정한 건수만큼 토큰이 리필되며, 초과 시 요청이 거부됩니다.</span>
</span>
</th>
<td>
<div class="threshold-group">
<input type="number" name="thresholdPerSecond" placeholder="0" />
<span class="unit">req/sec</span>
</div>
</td>
</tr>
<tr>
<th>
추가 임계치
<span class="hint-icon"><i class="material-icons">help_outline</i>
<span class="hint-bubble">장시간 누적 트래픽 제한입니다. 시간단위를 선택하면 해당 주기마다 토큰이 리필됩니다.</span>
</span>
</th>
<td>
<div class="threshold-group">
<select name="thresholdTimeUnit" id="thresholdTimeUnit" class="styled-select">
<option value="">미사용</option>
<option value="MIN">분 (MIN)</option>
<option value="HOU">시간 (HOU)</option>
<option value="DAY">일 (DAY)</option>
<option value="MON">월 (MON)</option>
</select>
<input type="number" name="threshold" id="thresholdInput" placeholder="0" />
<span class="unit" id="thresholdUnit">requests</span>
</div>
</td>
</tr>
</tbody>
</table>
<!-- API 매핑 -->
<span class="section-label">API 매핑</span>
<div class="interface-header">
<span class="interface-count">매핑된 API <strong id="interfaceCountNum">0</strong>개</span>
<button type="button" class="cssbtn" id="btn_popup_interface"><i class="material-icons">add</i> 추가</button>
</div>
<div class="interface-list-wrap">
<table class="interface-list-table">
<colgroup>
<col class="col-id" />
<col class="col-desc" />
<col class="col-action" />
</colgroup>
<thead>
<tr>
<th>API ID</th>
<th>설명</th>
<th>관리</th>
</tr>
</thead>
<tbody id="interfaceListContainer">
<tr class="interface-empty-row" id="interfaceEmpty">
<td colspan="3" class="interface-empty"><i class="material-icons">inbox</i> 매핑된 API가 없습니다</td>
</tr>
</tbody>
</table>
</div>
</form>
</div><!-- /tabConfigContent -->
<!-- 현황 탭 -->
<div class="tab-content" id="tabStatusContent">
<div class="bucket-status-wrap">
<div class="bucket-refresh-bar">
<span class="last-refresh">마지막 갱신: <span id="lastRefreshTime">-</span></span>
<button type="button" class="cssbtn" id="btn_refresh_bucket"><i class="material-icons">refresh</i> 새로고침</button>
</div>
<!-- 전체 합산 요약 -->
<div class="bucket-summary" id="bucketSummary" style="display:none;">
<div class="bucket-summary-title"><i class="material-icons">functions</i> 전체 인스턴스 합산</div>
<div class="bucket-summary-grid">
<div class="bucket-summary-item">
<span class="bucket-summary-label">활성 인스턴스</span>
<span class="bucket-summary-value" id="summaryInstanceCount">0<span class="unit">개</span></span>
</div>
<div class="bucket-summary-item" id="summaryPerSecondWrap">
<span class="bucket-summary-label">초당 임계치 (전체)</span>
<span class="bucket-summary-value" id="summaryPerSecond">0<span class="unit">req/sec</span></span>
<span class="bucket-summary-sub" id="summaryPerSecondCalc"></span>
</div>
<div class="bucket-summary-item" id="summaryThresholdWrap">
<span class="bucket-summary-label">추가 임계치 (전체)</span>
<span class="bucket-summary-value" id="summaryThreshold">0<span class="unit">req</span></span>
<span class="bucket-summary-sub" id="summaryThresholdCalc"></span>
</div>
</div>
</div>
<div class="bucket-server-list" id="bucketServerList">
<div class="bucket-empty-msg">
<i class="material-icons">hourglass_empty</i>
버킷 현황을 조회하려면 새로고침 버튼을 클릭하세요
</div>
</div>
</div>
</div><!-- /tabStatusContent -->
</div>
</div>
<!-- 인터페이스 선택 모달 -->
<div class="if-modal-overlay" id="ifModal">
<div class="if-modal">
<div class="if-modal-header">
<h3>API 선택</h3>
<button type="button" class="if-modal-close">&times;</button>
</div>
<div class="if-modal-body">
<div class="if-search-box">
<input type="text" id="ifSearchInput" placeholder="API ID 또는 설명 검색" />
<button type="button" class="cssbtn" id="ifSearchBtn"><i class="material-icons">search</i> 검색</button>
</div>
<div class="if-table-wrap">
<table class="if-table">
<thead>
<tr>
<th class="col-id">API ID</th>
<th>설명</th>
</tr>
</thead>
<tbody id="ifTableBody">
<tr><td colspan="2" class="if-table-empty">검색 결과가 없습니다</td></tr>
</tbody>
</table>
</div>
<div class="if-pagination">
<span class="if-page-info">총 <strong id="ifTotalCount">0</strong>건</span>
<div class="if-page-nav">
<button type="button" class="if-page-btn" id="ifPrevBtn" disabled>&lt;</button>
<div class="if-page-num" id="ifPageNum"></div>
<button type="button" class="if-page-btn" id="ifNextBtn" disabled>&gt;</button>
</div>
</div>
</div>
<div class="if-modal-footer">
<button type="button" class="cssbtn" id="ifCancelBtn">취소</button>
<button type="button" class="cssbtn" id="ifSelectBtn"><i class="material-icons">check</i> 선택</button>
</div>
</div>
</div>
<!-- 도움말 모달 -->
<div class="help-modal-overlay" id="helpModal">
<div class="help-modal">
<div class="help-modal-header">
<h3>유량제어 임계치 설명</h3>
<button type="button" class="help-modal-close">&times;</button>
</div>
<div class="help-modal-body">
<h4>개요</h4>
<p>유량제어는 <strong>Token Bucket 알고리즘</strong> 기반으로 동작합니다.
설정된 임계치만큼 토큰이 주기적으로 리필되며, 요청 1건당 토큰 1개를 소비합니다.
토큰이 부족하면 요청이 거부됩니다. </p>
<h4>초당 임계치 (thresholdPerSecond)</h4>
<p>1초 단위의 순간적인 트래픽 급증(spike)을 방어합니다.</p>
<div class="example-box">
<div class="example-title">예시: 초당 임계치 = 100</div>
<ul>
<li>매 1초마다 토큰 100개 리필</li>
<li>1초 내 100건까지 처리 가능</li>
<li>101번째 요청부터 차단</li>
</ul>
</div>
<h4>추가 임계치 (threshold) + 시간단위</h4>
<p>장시간 누적 트래픽을 제어합니다. 시간단위에 따라 토큰 리필 주기가 결정됩니다.</p>
<table>
<tr><th>시간단위</th><th>설명</th><th>예시</th></tr>
<tr><td>SEC</td><td>초</td><td>1초당 N건</td></tr>
<tr><td>MIN</td><td>분</td><td>1분당 N건</td></tr>
<tr><td>HOU</td><td>시</td><td>1시간당 N건</td></tr>
<tr><td>DAY</td><td>일</td><td>1일당 N건</td></tr>
<tr><td>MON</td><td>월</td><td>1개월당 N건</td></tr>
</table>
<div class="example-box">
<div class="example-title">예시: 추가 임계치 = 10,000 / 시간단위 = HOU</div>
<ul>
<li>매 1시간마다 토큰 10,000개 리필</li>
<li>1시간 내 10,000건까지 처리 가능</li>
<li>10,001번째 요청부터 차단</li>
</ul>
</div>
<h4>두 임계치 조합 (권장)</h4>
<p>두 조건을 함께 설정하면 <strong>이중 보호</strong>가 적용됩니다.</p>
<div class="example-box">
<div class="example-title">예시: 초당 100건 + 시간당 50,000건</div>
<ul>
<li>순간 폭주 방지: 초당 100건 제한 → 1초 내 101번째 요청부터 차단</li>
<li>장기 누적 제한: 시간당 50,000건 제한 → 시간 내 50,001번째 요청부터 차단</li>
<li>두 조건 중 하나라도 초과 시 차단 (둘 다 충족해야 통과)</li>
</ul>
</div>
<div class="note">
<strong>참고:</strong> 그룹에 매핑된 인터페이스들은 개별 유량제어 대신 그룹 유량제어가 적용됩니다.
그룹 내 모든 인터페이스의 호출이 합산되어 임계치와 비교됩니다.
</div>
</div>
</div>
</div>
</body>
</html>