유량제어 도움말 개선

This commit is contained in:
daekuk
2025-12-11 14:26:46 +09:00
parent 35a737d505
commit 2f0aa5d6a6
4 changed files with 327 additions and 290 deletions
@@ -0,0 +1,191 @@
<%@ page language="java" contentType="text/html; charset=utf-8"%>
<!-- 유량제어 그룹 상세 페이지 스타일 -->
<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>
@@ -0,0 +1,89 @@
<%@ page language="java" contentType="text/html; charset=utf-8"%>
<!-- 유량제어 그룹 도움말 모달 -->
<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> 기반으로 동작합니다.
설정된 임계치만큼 토큰이 <strong>균등하게 리필</strong>되며, 요청 1건당 토큰 1개를 소비합니다.
토큰이 부족하면 요청이 거부됩니다.</p>
<h4>토큰 리필 방식 - Greedy (균등 리필)</h4>
<p>토큰은 설정된 시간이 지난 후 한꺼번에 리필되는 것이 아니라, <strong>매 순간 균등하게 리필</strong>됩니다.</p>
<div class="note" style="background:#e3f2fd; border-color:#2196f3; margin-bottom:12px;">
<strong>왜 균등 리필인가?</strong><br/>
한꺼번에 리필하면 리필 직후 트래픽이 몰려 백엔드에 부하가 집중됩니다.
균등 리필은 트래픽을 <strong>시간축에 고르게 분산</strong>시켜 시스템 안정성을 높입니다.
AWS, Google Cloud, Nginx 등 대부분의 Rate Limiter가 이 방식을 채택합니다.
</div>
<div class="example-box">
<div class="example-title">예시: 분당 120건 설정 시 리필 속도</div>
<ul>
<li>120건 ÷ 60초 = <strong>초당 2건씩</strong> 균등 리필</li>
<li>1초마다 2개의 토큰이 자동으로 채워짐</li>
</ul>
</div>
<div class="example-box">
<div class="example-title">동작 예시: 분당 120건, 10건 요청 처리 후</div>
<table style="width:100%; margin-top:8px;">
<tr><th style="width:100px;">시간</th><th>상황</th><th style="width:80px;">토큰 수</th></tr>
<tr><td>10:00:00</td><td>초기 상태 (만땅)</td><td style="text-align:center;"><strong>120개</strong></td></tr>
<tr><td>10:00:01</td><td>요청 10건 처리</td><td style="text-align:center;">110개</td></tr>
<tr><td>10:00:02</td><td>1초 경과 → 2개 리필</td><td style="text-align:center;">112개</td></tr>
<tr><td>10:00:03</td><td>1초 경과 → 2개 리필</td><td style="text-align:center;">114개</td></tr>
<tr><td>10:00:04</td><td>1초 경과 → 2개 리필</td><td style="text-align:center;">116개</td></tr>
<tr><td>10:00:05</td><td>1초 경과 → 2개 리필</td><td style="text-align:center;">118개</td></tr>
<tr><td>10:00:06</td><td>1초 경과 → 2개 리필 (복구 완료)</td><td style="text-align:center;"><strong>120개</strong></td></tr>
</table>
<p style="margin-top:10px; color:#666; font-size:11px;">
※ 10건 소비 후 5초만에 복구됨 (10건 ÷ 초당2건 = 5초)
</p>
</div>
<div class="note" style="background:#fff3e0; border-color:#ffb74d;">
<strong>주의:</strong> "분당 120건"은 "1분 후에 120개가 한꺼번에 채워진다"는 의미가 아닙니다.
매 초마다 2개씩 균등하게 리필되어, 결과적으로 1분 동안 최대 120건을 처리할 수 있다는 의미입니다.
</div>
<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>리필 속도 예시 (120건 설정 시)</th></tr>
<tr><td>MIN</td><td>분</td><td>초당 2건 (120÷60)</td></tr>
<tr><td>HOU</td><td>시</td><td>초당 0.033건 (120÷3600)</td></tr>
<tr><td>DAY</td><td>일</td><td>초당 0.00139건 (120÷86400)</td></tr>
<tr><td>MON</td><td>월</td><td>초당 0.0000463건 (120÷2592000)</td></tr>
</table>
<h4>두 임계치 조합 (권장)</h4>
<p>두 조건을 함께 설정하면 <strong>이중 보호</strong>가 적용됩니다.</p>
<div class="example-box">
<div class="example-title">예시: 초당 100건 + 시간당 50,000건</div>
<ul>
<li><strong>초당 임계치</strong>: 순간 폭주 방지 → 1초에 101건 이상 요청 시 차단</li>
<li><strong>추가 임계치</strong>: 장기 누적 제한 → 시간당 50,001건 이상 요청 시 차단</li>
<li>두 조건 중 하나라도 초과 시 차단 (둘 다 충족해야 통과)</li>
</ul>
</div>
<div class="note">
<strong>참고:</strong> 그룹에 매핑된 인터페이스들은 개별 유량제어 대신 그룹 유량제어가 적용됩니다.
그룹 내 모든 인터페이스의 호출이 합산되어 임계치와 비교됩니다.
</div>
</div>
</div>
</div>
@@ -0,0 +1,41 @@
<%@ page language="java" contentType="text/html; charset=utf-8"%>
<!-- 인터페이스 선택 모달 -->
<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>
@@ -16,195 +16,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>
<!-- 페이지 커스텀 스타일 (fragment) -->
<jsp:include page="fragments/inflowGroupDetailStyle.jsp"/>
<script language="javascript">
var url ='<c:url value="/onl/admin/inflow/inflowGroupControlMan.json" />';
@@ -1071,107 +884,10 @@ $(document).ready(function() {
</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>
<!-- 인터페이스 선택 모달 (fragment) -->
<jsp:include page="fragments/inflowGroupInterfaceModal.jsp"/>
<!-- 도움말 모달 -->
<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>
<!-- 도움말 모달 (fragment) -->
<jsp:include page="fragments/inflowGroupHelpModal.jsp"/>
</body>
</html>