10a8918bb6
- CustomGroupBucket 클래스 신규 생성: 초당/추가 임계치를 별도 LocalBucket으로 분리하여 개별 토큰 조회 가능 - InflowControlManager 수정: groupBucketList 타입을 CustomGroupBucket으로 변경, makeGroupBucket() 메서드 추가 - InflowGroupBucketService 단순화: 복잡한 리플렉션 로직 제거, CustomGroupBucket 직접 메서드 사용 - 현황 탭에 전체 인스턴스 합산 요약 추가 (활성 인스턴스 수, 초당/추가 임계치 합산) - 추가 임계치 시간단위에서 '초(SEC)' 옵션 제거 - 저장 완료 후 리스트 화면 이동 제거
1148 lines
44 KiB
Plaintext
1148 lines
44 KiB
Plaintext
<%@ 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">×</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><</button>
|
||
<div class="if-page-num" id="ifPageNum"></div>
|
||
<button type="button" class="if-page-btn" id="ifNextBtn" disabled>></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">×</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>
|