API 통계 도메인 구조 개편 및 조회 기능 확장:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- API 통계 엔티티/리포지토리 다층 나눔 (분/시/일/월)
- API 상세/요약 통계 및 기간별 누적 통계 DTO 추가
- 고객센터 연락처 조회 로직 추가 (@ModelAttribute) - 글로벌 푸터 적용
- @EMSDataSource 지원 엔티티-패키지 다중 검색 적용
- 기존 ApiStatisticsRepository 삭제, 분리된 구성으로 대체
This commit is contained in:
Rinjae
2026-07-16 11:20:44 +09:00
parent 227d4d8abd
commit 1cb9606602
34 changed files with 1775 additions and 462 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ gateway:
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
entity-package: com.eactive.eai.data.entity.onl
entity-package: com.eactive.eai.data.entity.onl,com.eactive.apim.gateway.data.statistics.entity
portal:
+1 -1
View File
@@ -46,5 +46,5 @@ gateway:
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
entity-package: com.eactive.eai.data.entity.onl
entity-package: com.eactive.eai.data.entity.onl,com.eactive.apim.gateway.data.statistics.entity
+1 -1
View File
@@ -98,7 +98,7 @@ gateway:
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
entity-package: com.eactive.eai.data.entity.onl
entity-package: com.eactive.eai.data.entity.onl,com.eactive.apim.gateway.data.statistics.entity
portal:
file:
@@ -1,18 +1,74 @@
/* API Statistics Page Styles */
.api-statistics-container {
/*max-width: 1200px;*/
margin: 0 auto;
padding: 48px 0px;
}
/* Search Section */
.statistics-notice {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 12px 18px;
background: #eef4fc;
border: 1px solid #d6e4f5;
border-radius: 10px;
font-size: 13px;
color: #4a5568;
}
.statistics-notice-latest {
color: #0049B4;
font-weight: 600;
}
.statistics-search-section {
display: flex;
justify-content: flex-end;
flex-direction: column;
align-items: flex-end;
gap: 12px;
margin-bottom: 30px;
}
.search-mode-tabs {
display: inline-flex;
background: #eef1f5;
border-radius: 22px;
padding: 4px;
}
.mode-tab {
border: none;
background: transparent;
padding: 7px 22px;
border-radius: 18px;
font-size: 14px;
color: #666;
cursor: pointer;
transition: all 0.2s;
}
.mode-tab.active {
background: #0049B4;
color: #fff;
font-weight: 600;
}
.month-range-picker {
display: flex;
align-items: center;
gap: 8px;
}
.month-input {
border: none;
padding: 8px 12px;
font-size: 14px;
color: #333;
background: transparent;
outline: none;
cursor: pointer;
}
.search-filter-row {
display: flex;
align-items: center;
@@ -54,16 +110,13 @@
cursor: pointer;
transition: background 0.2s;
}
.btn-search:hover {
background: #003d99;
}
.btn-search svg {
stroke: #fff;
}
/* Summary Card */
.statistics-summary-card {
background: #f5f8fc;
border-radius: 16px;
@@ -103,11 +156,9 @@
outline: none;
transition: all 0.2s;
}
.app-select:hover {
background-color: #f0f5ff;
}
.app-select:focus {
border-color: #003d99;
box-shadow: 0 0 0 3px rgba(0, 73, 180, 0.1);
@@ -122,7 +173,6 @@
color: #333;
margin-bottom: 16px;
}
.summary-total-text strong {
font-size: 24px;
color: #0049B4;
@@ -140,11 +190,12 @@
gap: 8px;
font-size: 14px;
}
.summary-detail-item.success .detail-icon {
color: #4A90D9;
}
.summary-detail-item.timeout .detail-icon {
color: #F5C36B;
}
.summary-detail-item.failure .detail-icon {
color: #F5A3B5;
}
@@ -163,7 +214,6 @@
color: #999;
}
/* Donut Chart */
.summary-center {
flex: 0 0 200px;
display: flex;
@@ -180,7 +230,6 @@
transition: stroke-dasharray 0.3s ease, stroke-dashoffset 0.3s ease;
}
/* Rate Display */
.summary-right {
flex: 0 0 230px;
display: flex;
@@ -195,7 +244,6 @@
padding: 16px 0;
border-bottom: 1px solid #e0e0e0;
}
.rate-display:last-child {
border-bottom: none;
}
@@ -205,11 +253,12 @@
height: 12px;
border-radius: 50%;
}
.rate-indicator.success {
background: #4A90D9;
}
.rate-indicator.timeout {
background: #F5C36B;
}
.rate-indicator.failure {
background: #F5A3B5;
}
@@ -228,6 +277,10 @@
color: #4A90D9;
}
.timeout-rate .rate-value {
color: #F5C36B;
}
.failure-rate .rate-value {
color: #F5A3B5;
}
@@ -237,12 +290,15 @@
color: #999;
}
/* Statistics Table Card */
.statistics-table-card {
background: #fff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
margin-bottom: 30px;
}
.statistics-table-card:last-child {
margin-bottom: 0;
}
.table-header {
@@ -262,7 +318,6 @@
font-weight: 600;
margin: 0;
}
.table-title svg {
stroke: #fff;
}
@@ -279,7 +334,6 @@
font-size: 14px;
transition: background 0.2s;
}
.btn-download:hover {
background: rgba(255, 255, 255, 0.3);
color: #fff;
@@ -293,7 +347,6 @@
width: 100%;
border-collapse: collapse;
}
.statistics-table thead th {
padding: 16px 20px;
text-align: left;
@@ -302,16 +355,23 @@
border-bottom: 1px solid #e0e0e0;
background: #fafafa;
}
.statistics-table tbody td {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
color: #333;
}
.statistics-table tbody tr:hover {
background: #f9f9f9;
}
.statistics-table tfoot .total-row {
background: #e3f2fd;
}
.statistics-table tfoot .total-row td {
padding: 16px 20px;
font-weight: 600;
color: #0049B4;
border-bottom: none;
}
.api-name {
display: flex;
@@ -328,20 +388,16 @@
background: #f0f0f0;
border-radius: 6px;
}
.api-icon svg {
stroke: #666;
}
.api-icon.total {
background: #e3f2fd;
}
.api-icon.total svg {
stroke: #0049B4;
}
/* Progress Bar */
.progress-cell {
min-width: 200px;
}
@@ -359,83 +415,64 @@
transition: width 0.3s ease;
}
.progress-timeout {
background: #F5C36B;
transition: width 0.3s ease;
}
.progress-failure {
background: #F5A3B5;
transition: width 0.3s ease;
}
/* Total Row */
.statistics-table tfoot .total-row {
background: #e3f2fd;
}
.statistics-table tfoot .total-row td {
padding: 16px 20px;
font-weight: 600;
color: #0049B4;
border-bottom: none;
}
/* Empty State */
.empty-row td {
text-align: center;
padding: 40px 20px;
color: #999;
}
/* Responsive */
@media (max-width: 992px) {
.summary-content {
flex-direction: column;
gap: 30px;
}
.summary-left,
.summary-center,
.summary-right {
flex: none;
width: 100%;
}
.summary-center {
order: -1;
}
.summary-right {
flex-direction: row;
justify-content: center;
}
}
@media (max-width: 768px) {
.api-statistics-container {
padding: 20px 16px;
}
.statistics-summary-card {
padding: 24px 20px;
}
.app-toggle-buttons {
flex-wrap: wrap;
}
.toggle-btn {
flex: 1;
text-align: center;
}
.statistics-table thead th,
.statistics-table tbody td,
.statistics-table tfoot td {
padding: 12px 10px;
font-size: 13px;
}
.progress-cell {
min-width: 100px;
}
.search-filter-row {
flex-wrap: wrap;
}
+80 -1
View File
@@ -19148,12 +19148,72 @@ input[type=checkbox]:checked + .custom-checkbox {
padding: 48px 0px;
}
.statistics-notice {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 12px 18px;
background: #eef4fc;
border: 1px solid #d6e4f5;
border-radius: 10px;
font-size: 13px;
color: #4a5568;
}
.statistics-notice-latest {
color: #0049B4;
font-weight: 600;
}
.statistics-search-section {
display: flex;
justify-content: flex-end;
flex-direction: column;
align-items: flex-end;
gap: 12px;
margin-bottom: 30px;
}
.search-mode-tabs {
display: inline-flex;
background: #eef1f5;
border-radius: 22px;
padding: 4px;
}
.mode-tab {
border: none;
background: transparent;
padding: 7px 22px;
border-radius: 18px;
font-size: 14px;
color: #666;
cursor: pointer;
transition: all 0.2s;
}
.mode-tab.active {
background: #0049B4;
color: #fff;
font-weight: 600;
}
.month-range-picker {
display: flex;
align-items: center;
gap: 8px;
}
.month-input {
border: none;
padding: 8px 12px;
font-size: 14px;
color: #333;
background: transparent;
outline: none;
cursor: pointer;
}
.search-filter-row {
display: flex;
align-items: center;
@@ -19278,6 +19338,9 @@ input[type=checkbox]:checked + .custom-checkbox {
.summary-detail-item.success .detail-icon {
color: #4A90D9;
}
.summary-detail-item.timeout .detail-icon {
color: #F5C36B;
}
.summary-detail-item.failure .detail-icon {
color: #F5A3B5;
}
@@ -19338,6 +19401,9 @@ input[type=checkbox]:checked + .custom-checkbox {
.rate-indicator.success {
background: #4A90D9;
}
.rate-indicator.timeout {
background: #F5C36B;
}
.rate-indicator.failure {
background: #F5A3B5;
}
@@ -19356,6 +19422,10 @@ input[type=checkbox]:checked + .custom-checkbox {
color: #4A90D9;
}
.timeout-rate .rate-value {
color: #F5C36B;
}
.failure-rate .rate-value {
color: #F5A3B5;
}
@@ -19370,6 +19440,10 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
margin-bottom: 30px;
}
.statistics-table-card:last-child {
margin-bottom: 0;
}
.table-header {
@@ -19486,6 +19560,11 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: width 0.3s ease;
}
.progress-timeout {
background: #F5C36B;
transition: width 0.3s ease;
}
.progress-failure {
background: #F5A3B5;
transition: width 0.3s ease;
File diff suppressed because one or more lines are too long
@@ -11,13 +11,77 @@
padding: 48px 0px;
}
// 집계 안내 문구
.statistics-notice {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 12px 18px;
background: #eef4fc;
border: 1px solid #d6e4f5;
border-radius: 10px;
font-size: 13px;
color: #4a5568;
}
.statistics-notice-latest {
color: #0049B4;
font-weight: 600;
}
// Search Section
.statistics-search-section {
display: flex;
justify-content: flex-end;
flex-direction: column;
align-items: flex-end;
gap: 12px;
margin-bottom: 30px;
}
// 일별/월별 모드 토글
.search-mode-tabs {
display: inline-flex;
background: #eef1f5;
border-radius: 22px;
padding: 4px;
}
.mode-tab {
border: none;
background: transparent;
padding: 7px 22px;
border-radius: 18px;
font-size: 14px;
color: #666;
cursor: pointer;
transition: all 0.2s;
&.active {
background: #0049B4;
color: #fff;
font-weight: 600;
}
}
// 월 선택 (월별 모드)
.month-range-picker {
display: flex;
align-items: center;
gap: 8px;
}
.month-input {
border: none;
padding: 8px 12px;
font-size: 14px;
color: #333;
background: transparent;
outline: none;
cursor: pointer;
}
.search-filter-row {
display: flex;
align-items: center;
@@ -149,6 +213,10 @@
color: #4A90D9;
}
&.timeout .detail-icon {
color: #F5C36B;
}
&.failure .detail-icon {
color: #F5A3B5;
}
@@ -214,6 +282,10 @@
background: #4A90D9;
}
&.timeout {
background: #F5C36B;
}
&.failure {
background: #F5A3B5;
}
@@ -233,6 +305,10 @@
color: #4A90D9;
}
.timeout-rate .rate-value {
color: #F5C36B;
}
.failure-rate .rate-value {
color: #F5A3B5;
}
@@ -248,6 +324,11 @@
border-radius: 16px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
margin-bottom: 30px;
&:last-child {
margin-bottom: 0;
}
}
.table-header {
@@ -377,6 +458,11 @@
transition: width 0.3s ease;
}
.progress-timeout {
background: #F5C36B;
transition: width 0.3s ease;
}
.progress-failure {
background: #F5A3B5;
transition: width 0.3s ease;
@@ -14,16 +14,41 @@
<link rel="stylesheet" type="text/css" th:href="@{/css/api-statistics.css}"/>
<section class="api-statistics-container">
<!-- 집계 안내 문구 (집계 주기 분 + 최신 집계 시각, 서버 계산값) -->
<div class="statistics-notice">
<span th:if="${statsAggregationMinute != null}"
th:text="'통계 데이터는 매시 ' + ${statsAggregationMinute} + '분 집계되며, 집계 이전 시간 기준으로 생성됩니다.'">통계 안내</span>
<span th:if="${statsAggregationMinute == null}">통계 데이터는 매시 집계되며, 집계 이전 시간 기준으로 생성됩니다.</span>
<span th:if="${statsLatestTime != null}" class="statistics-notice-latest"
th:text="'※ 최신 집계 시각: ' + ${statsLatestTime} + ' 기준'">최신 집계 시각</span>
</div>
<!-- Search Filter -->
<div class="statistics-search-section">
<div class="search-mode-tabs">
<button type="button" class="mode-tab active" data-mode="DAILY">일별</button>
<button type="button" class="mode-tab" data-mode="MONTHLY">월별</button>
</div>
<div class="search-filter-row">
<div class="date-range-picker">
<!-- 일별: 날짜 범위 -->
<div class="date-range-picker" id="dailyFilter">
<input type="date" id="startDate" class="date-input"
th:value="${#temporals.format(searchDto.startDate, 'yyyy-MM-dd')}">
<span class="date-separator">-</span>
<input type="date" id="endDate" class="date-input"
th:value="${#temporals.format(searchDto.endDate, 'yyyy-MM-dd')}">
</div>
<!-- 월별: 특정 년월 단일 선택 (가용월 목록) -->
<div class="month-range-picker" id="monthlyFilter" style="display:none;">
<select id="month" class="month-input">
<option th:each="m,st : ${availableMonths}" th:value="${m}"
th:text="${#strings.substring(m,0,4) + '-' + #strings.substring(m,4,6)}"
th:selected="${st.index == 0}">2026-07</option>
</select>
</div>
<button type="button" class="btn-search" id="btnSearch">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
@@ -42,9 +67,9 @@
<select id="appSelect" class="app-select">
<option value="">전체 앱</option>
<option th:each="app : ${appList}"
th:value="${app.clientid}"
th:text="${app.clientname}"
th:selected="${searchDto.clientId != null and searchDto.clientId == app.clientid}">
th:value="${app.clientId}"
th:text="${app.clientName}"
th:selected="${searchDto.clientId != null and searchDto.clientId == app.clientId}">
앱 이름
</option>
</select>
@@ -52,46 +77,55 @@
<div class="summary-info">
<p class="summary-total-text">
총 API 호출 수는 <strong id="totalAttempted" th:text="${#numbers.formatInteger(summary.totalAttempted, 0, 'COMMA')}">0</strong>건입니다.
총 API 호출 수는 <strong id="totalCount" th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</strong>건입니다.
</p>
<div class="summary-detail-list">
<div class="summary-detail-item success">
<span class="detail-icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="8" cy="8" r="6"/>
</svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>
</span>
<span class="detail-label">성공</span>
<span class="detail-value" id="totalCompleted" th:text="${#numbers.formatInteger(summary.totalCompleted, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="successRateText" th:text="${summary.successRate}">0</span>%)</span>
<span class="detail-value" id="successCount" th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="successRateText" th:text="${summary.totalCount == 0 ? '-' : summary.successRate}">0</span>%)</span>
</div>
<div class="summary-detail-item timeout">
<span class="detail-icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>
</span>
<span class="detail-label">타임아웃</span>
<span class="detail-value" id="timeoutCount" th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="timeoutRateText" th:text="${summary.totalCount == 0 ? '-' : summary.timeoutRate}">0</span>%)</span>
</div>
<div class="summary-detail-item failure">
<span class="detail-icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="8" cy="8" r="6"/>
</svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>
</span>
<span class="detail-label">실패</span>
<span class="detail-value" id="totalFailed" th:text="${#numbers.formatInteger(summary.totalFailed, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="failureRateText" th:text="${summary.failureRate}">0</span>%)</span>
<span class="detail-value" id="errorCount" th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="errorRateText" th:text="${summary.totalCount == 0 ? '-' : summary.errorRate}">0</span>%)</span>
</div>
</div>
</div>
</div>
<!-- Center: Donut Chart (SVG) -->
<!-- Center: Donut Chart (SVG, 3 segments) -->
<div class="summary-center">
<svg id="donutChart" width="200" height="200" viewBox="0 0 200 200">
<circle class="donut-bg" cx="100" cy="100" r="70" fill="none" stroke="#e0e0e0" stroke-width="24"/>
<circle id="donutSuccess" class="donut-segment" cx="100" cy="100" r="70" fill="none"
stroke="#4A90D9" stroke-width="24" stroke-linecap="round"
stroke="#4A90D9" stroke-width="24" stroke-linecap="butt"
th:attr="stroke-dasharray=${summary.successRate * 4.398} + ' 439.8'"
transform="rotate(-90 100 100)"/>
<circle id="donutFailure" class="donut-segment" cx="100" cy="100" r="70" fill="none"
stroke="#F5A3B5" stroke-width="24" stroke-linecap="round"
th:attr="stroke-dasharray=${summary.failureRate * 4.398} + ' 439.8',
stroke-dashoffset="0" transform="rotate(-90 100 100)"/>
<circle id="donutTimeout" class="donut-segment" cx="100" cy="100" r="70" fill="none"
stroke="#F5C36B" stroke-width="24" stroke-linecap="butt"
th:attr="stroke-dasharray=${summary.timeoutRate * 4.398} + ' 439.8',
stroke-dashoffset='-' + ${summary.successRate * 4.398}"
transform="rotate(-90 100 100)"/>
<circle id="donutError" class="donut-segment" cx="100" cy="100" r="70" fill="none"
stroke="#F5A3B5" stroke-width="24" stroke-linecap="butt"
th:attr="stroke-dasharray=${summary.errorRate * 4.398} + ' 439.8',
stroke-dashoffset='-' + ${(summary.successRate + summary.timeoutRate) * 4.398}"
transform="rotate(-90 100 100)"/>
</svg>
</div>
@@ -100,13 +134,19 @@
<div class="rate-display success-rate">
<span class="rate-indicator success"></span>
<span class="rate-label">성공률</span>
<span class="rate-value" id="successRate" th:text="${summary.successRate}">0</span>
<span class="rate-value" id="successRate" th:text="${summary.totalCount == 0 ? '-' : summary.successRate}">0</span>
<span class="rate-percent">%</span>
</div>
<div class="rate-display timeout-rate">
<span class="rate-indicator timeout"></span>
<span class="rate-label">타임아웃율</span>
<span class="rate-value" id="timeoutRate" th:text="${summary.totalCount == 0 ? '-' : summary.timeoutRate}">0</span>
<span class="rate-percent">%</span>
</div>
<div class="rate-display failure-rate">
<span class="rate-indicator failure"></span>
<span class="rate-label">실패률</span>
<span class="rate-value" id="failureRate" th:text="${summary.failureRate}">0</span>
<span class="rate-value" id="errorRate" th:text="${summary.totalCount == 0 ? '-' : summary.errorRate}">0</span>
<span class="rate-percent">%</span>
</div>
</div>
@@ -118,20 +158,11 @@
<div class="table-header">
<h3 class="table-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18"/>
<path d="M18 9l-5 5-4-4-4 4"/>
<path d="M3 3v18h18"/><path d="M18 9l-5 5-4-4-4 4"/>
</svg>
API별 통계
</h3>
<a th:href="@{/statistics/api/download(startDate=${searchDto.startDate},endDate=${searchDto.endDate},clientId=${searchDto.clientId})}"
class="btn-download" id="btnDownload">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
다운로드
</a>
<!-- 다운로드 버튼은 UI에서 제거됨 (백엔드 /statistics/api/download 는 유지) -->
</div>
<div class="table-container">
@@ -141,6 +172,7 @@
<th>API명</th>
<th>API호출수</th>
<th>성공</th>
<th>타임아웃</th>
<th>실패</th>
<th>통계</th>
</tr>
@@ -155,19 +187,20 @@
</span>
<span th:text="${detail.apiName}">API 이름</span>
</td>
<td th:text="${#numbers.formatInteger(detail.attemptedCount, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(detail.completedCount, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(detail.failedCount, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(detail.totalCount, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(detail.successCount, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(detail.timeoutCount, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(detail.errorCount, 0, 'COMMA')}">0</td>
<td class="progress-cell">
<div class="progress-bar">
<div class="progress-success" th:style="'width:' + ${detail.successRate} + '%'"></div>
<div class="progress-failure" th:style="'width:' + ${detail.failureRate} + '%'"></div>
<div class="progress-timeout" th:style="'width:' + ${detail.timeoutRate} + '%'"></div>
<div class="progress-failure" th:style="'width:' + ${detail.errorRate} + '%'"></div>
</div>
</td>
</tr>
<!-- Empty State -->
<tr th:if="${details == null or details.isEmpty()}" class="empty-row">
<td colspan="5">데이터가 없습니다.</td>
<td colspan="6">데이터가 없습니다.</td>
</tr>
</tbody>
<tfoot id="statisticsTableFoot" th:if="${details != null and !details.isEmpty()}">
@@ -175,19 +208,20 @@
<td class="api-name">
<span class="api-icon total">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18"/>
<path d="M18 9l-5 5-4-4-4 4"/>
<path d="M3 3v18h18"/><path d="M18 9l-5 5-4-4-4 4"/>
</svg>
</span>
<span>전체</span>
</td>
<td th:text="${#numbers.formatInteger(summary.totalAttempted, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(summary.totalCompleted, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(summary.totalFailed, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</td>
<td class="progress-cell">
<div class="progress-bar">
<div class="progress-success" th:style="'width:' + ${summary.successRate} + '%'"></div>
<div class="progress-failure" th:style="'width:' + ${summary.failureRate} + '%'"></div>
<div class="progress-timeout" th:style="'width:' + ${summary.timeoutRate} + '%'"></div>
<div class="progress-failure" th:style="'width:' + ${summary.errorRate} + '%'"></div>
</div>
</td>
</tr>
@@ -196,59 +230,102 @@
</div>
</div>
<!-- 기간별(일별/월별) 누적 통계 -->
<div class="statistics-table-card">
<div class="table-header">
<h3 class="table-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>
</svg>
<span id="periodTableTitle">일별 통계</span>
</h3>
</div>
<div class="table-container">
<table class="statistics-table">
<thead>
<tr>
<th id="periodLabel">일자</th>
<th>API호출수</th>
<th>성공</th>
<th>타임아웃</th>
<th>실패</th>
</tr>
</thead>
<tbody id="periodTableBody">
<tr th:each="p : ${periods}">
<td th:text="${p.period}">2026-07-15 (수)</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.totalCount, 0, 'COMMA') : '-'}">0</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.successCount, 0, 'COMMA') : '-'}">0</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.timeoutCount, 0, 'COMMA') : '-'}">0</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.errorCount, 0, 'COMMA') : '-'}">0</td>
</tr>
<tr th:if="${periods == null or periods.isEmpty()}" class="empty-row">
<td colspan="5">데이터가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
$(document).ready(function() {
// Initial data from server
let currentData = {
summary: {
totalAttempted: [[${summary.totalAttempted}]],
totalCompleted: [[${summary.totalCompleted}]],
totalFailed: [[${summary.totalFailed}]],
totalCount: [[${summary.totalCount}]],
successCount: [[${summary.successCount}]],
timeoutCount: [[${summary.timeoutCount}]],
errorCount: [[${summary.errorCount}]],
successRate: [[${summary.successRate}]],
failureRate: [[${summary.failureRate}]]
timeoutRate: [[${summary.timeoutRate}]],
errorRate: [[${summary.errorRate}]]
},
details: [[${details}]]
details: [[${details}]],
periods: [[${periods}]]
};
let selectedClientId = [[${searchDto.clientId}]] || '';
let currentMode = 'DAILY';
const CIRCUMFERENCE = 439.8; // 2 * PI * 70
const MAX_DATE_RANGE_DAYS = 40;
// Update SVG Donut Chart
// ── Donut (성공/타임아웃/실패 3세그먼트) ──
function updateChart() {
var successRate = currentData.summary.successRate || 0;
var failureRate = currentData.summary.failureRate || 0;
var successLength = successRate * CIRCUMFERENCE / 100;
var failureLength = failureRate * CIRCUMFERENCE / 100;
$('#donutSuccess').attr('stroke-dasharray', successLength + ' ' + CIRCUMFERENCE);
$('#donutFailure').attr('stroke-dasharray', failureLength + ' ' + CIRCUMFERENCE);
$('#donutFailure').attr('stroke-dashoffset', -successLength);
var s = currentData.summary.successRate || 0;
var t = currentData.summary.timeoutRate || 0;
var e = currentData.summary.errorRate || 0;
var sl = s * CIRCUMFERENCE / 100;
var tl = t * CIRCUMFERENCE / 100;
var el = e * CIRCUMFERENCE / 100;
$('#donutSuccess').attr('stroke-dasharray', sl + ' ' + CIRCUMFERENCE).attr('stroke-dashoffset', 0);
$('#donutTimeout').attr('stroke-dasharray', tl + ' ' + CIRCUMFERENCE).attr('stroke-dashoffset', -sl);
$('#donutError').attr('stroke-dasharray', el + ' ' + CIRCUMFERENCE).attr('stroke-dashoffset', -(sl + tl));
}
// Update Summary Display
// ── Summary ──
function updateSummaryDisplay() {
$('#totalAttempted').text(numberWithCommas(currentData.summary.totalAttempted));
$('#totalCompleted').text(numberWithCommas(currentData.summary.totalCompleted));
$('#totalFailed').text(numberWithCommas(currentData.summary.totalFailed));
$('#successRate, #successRateText').text(currentData.summary.successRate);
$('#failureRate, #failureRateText').text(currentData.summary.failureRate);
var sm = currentData.summary;
var t = sm.totalCount;
$('#totalCount').text(numberWithCommas(sm.totalCount));
$('#successCount').text(numberWithCommas(sm.successCount));
$('#timeoutCount').text(numberWithCommas(sm.timeoutCount));
$('#errorCount').text(numberWithCommas(sm.errorCount));
$('#successRate, #successRateText').text(dashRate(t, sm.successRate));
$('#timeoutRate, #timeoutRateText').text(dashRate(t, sm.timeoutRate));
$('#errorRate, #errorRateText').text(dashRate(t, sm.errorRate));
}
// Update Table
// ── API별 표 ──
function updateTable() {
const tbody = $('#statisticsTableBody');
const tfoot = $('#statisticsTableFoot');
tbody.empty();
if (currentData.details && currentData.details.length > 0) {
currentData.details.forEach(function(detail) {
const row = `
currentData.details.forEach(function(d) {
tbody.append(`
<tr>
<td class="api-name">
<span class="api-icon">
@@ -256,166 +333,163 @@
<rect x="3" y="3" width="18" height="18" rx="2"/>
</svg>
</span>
<span>${detail.apiName || '-'}</span>
<span>${d.apiName || '-'}</span>
</td>
<td>${numberWithCommas(detail.attemptedCount)}</td>
<td>${numberWithCommas(detail.completedCount)}</td>
<td>${numberWithCommas(detail.failedCount)}</td>
<td>${numberWithCommas(d.totalCount)}</td>
<td>${numberWithCommas(d.successCount)}</td>
<td>${numberWithCommas(d.timeoutCount)}</td>
<td>${numberWithCommas(d.errorCount)}</td>
<td class="progress-cell">
<div class="progress-bar">
<div class="progress-success" style="width:${detail.successRate}%"></div>
<div class="progress-failure" style="width:${detail.failureRate}%"></div>
<div class="progress-success" style="width:${d.successRate}%"></div>
<div class="progress-timeout" style="width:${d.timeoutRate}%"></div>
<div class="progress-failure" style="width:${d.errorRate}%"></div>
</div>
</td>
</tr>
`;
tbody.append(row);
</tr>`);
});
renderTotalFooter();
} else {
tbody.html('<tr class="empty-row"><td colspan="6">데이터가 없습니다.</td></tr>');
$('#statisticsTableFoot').remove();
}
}
// Update footer
if (tfoot.length === 0) {
const footerHtml = `
<tfoot id="statisticsTableFoot">
<tr class="total-row">
<td class="api-name">
<span class="api-icon total">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18"/>
<path d="M18 9l-5 5-4-4-4 4"/>
</svg>
</span>
<span>전체</span>
</td>
<td>${numberWithCommas(currentData.summary.totalAttempted)}</td>
<td>${numberWithCommas(currentData.summary.totalCompleted)}</td>
<td>${numberWithCommas(currentData.summary.totalFailed)}</td>
<td class="progress-cell">
<div class="progress-bar">
<div class="progress-success" style="width:${currentData.summary.successRate}%"></div>
<div class="progress-failure" style="width:${currentData.summary.failureRate}%"></div>
</div>
</td>
</tr>
</tfoot>
`;
tbody.after(footerHtml);
} else {
tfoot.find('td:eq(1)').text(numberWithCommas(currentData.summary.totalAttempted));
tfoot.find('td:eq(2)').text(numberWithCommas(currentData.summary.totalCompleted));
tfoot.find('td:eq(3)').text(numberWithCommas(currentData.summary.totalFailed));
tfoot.find('.progress-success').css('width', currentData.summary.successRate + '%');
tfoot.find('.progress-failure').css('width', currentData.summary.failureRate + '%');
}
function renderTotalFooter() {
var sm = currentData.summary;
var footerHtml = `
<tfoot id="statisticsTableFoot">
<tr class="total-row">
<td class="api-name">
<span class="api-icon total">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18"/><path d="M18 9l-5 5-4-4-4 4"/>
</svg>
</span>
<span>전체</span>
</td>
<td>${numberWithCommas(sm.totalCount)}</td>
<td>${numberWithCommas(sm.successCount)}</td>
<td>${numberWithCommas(sm.timeoutCount)}</td>
<td>${numberWithCommas(sm.errorCount)}</td>
<td class="progress-cell">
<div class="progress-bar">
<div class="progress-success" style="width:${sm.successRate}%"></div>
<div class="progress-timeout" style="width:${sm.timeoutRate}%"></div>
<div class="progress-failure" style="width:${sm.errorRate}%"></div>
</div>
</td>
</tr>
</tfoot>`;
$('#statisticsTableFoot').remove();
$('#statisticsTableBody').after(footerHtml);
}
// ── 기간별(일/월) 표 ──
function updatePeriodTable() {
const tbody = $('#periodTableBody');
tbody.empty();
if (currentData.periods && currentData.periods.length > 0) {
currentData.periods.forEach(function(p) {
tbody.append(`
<tr>
<td>${p.period || '-'}</td>
<td>${p.hasData ? numberWithCommas(p.totalCount) : '-'}</td>
<td>${p.hasData ? numberWithCommas(p.successCount) : '-'}</td>
<td>${p.hasData ? numberWithCommas(p.timeoutCount) : '-'}</td>
<td>${p.hasData ? numberWithCommas(p.errorCount) : '-'}</td>
</tr>`);
});
} else {
tbody.html('<tr class="empty-row"><td colspan="5">데이터가 없습니다.</td></tr>');
tfoot.remove();
}
}
// Update Download Link
function updateDownloadLink() {
const startDate = $('#startDate').val();
const endDate = $('#endDate').val();
let downloadUrl = `/statistics/api/download?startDate=${startDate}&endDate=${endDate}`;
if (selectedClientId) {
downloadUrl += `&clientId=${selectedClientId}`;
}
$('#btnDownload').attr('href', downloadUrl);
}
// Number formatting
function numberWithCommas(x) {
if (x === null || x === undefined) return '0';
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
// 데이터 없음(해당 total 0)일 때: 건수는 0(그대로) 표기, 비율(%)만 '-' 로 표기
function dashRate(total, val) {
return (!total || total == 0) ? '-' : val;
}
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
function getCsrfToken() {
var meta = document.querySelector('meta[name="_csrf"]');
return meta ? meta.getAttribute('content') : '';
}
// Validate date range (max 40 days)
function validateDateRange(startDate, endDate) {
if (!startDate || !endDate) {
return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
}
var start = new Date(startDate);
var end = new Date(endDate);
if (start > end) {
return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
}
if (!startDate || !endDate) return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
var start = new Date(startDate), end = new Date(endDate);
if (start > end) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
var diffDays = Math.floor((end - start) / (1000 * 60 * 60 * 24));
if (diffDays > MAX_DATE_RANGE_DAYS) {
return { valid: false, message: '조회 기간은 최대 ' + MAX_DATE_RANGE_DAYS + '일까지 가능합니다.' };
}
if (diffDays > MAX_DATE_RANGE_DAYS) return { valid: false, message: '조회 기간은 최대 ' + MAX_DATE_RANGE_DAYS + '일까지 가능합니다.' };
return { valid: true };
}
// Search Statistics
function searchStatistics() {
const startDate = $('#startDate').val();
const endDate = $('#endDate').val();
function buildPayload() {
return {
mode: currentMode,
startDate: $('#startDate').val(),
endDate: $('#endDate').val(),
month: $('#month').val() || null,
clientId: selectedClientId || null
};
}
// Client-side validation
var validation = validateDateRange(startDate, endDate);
if (!validation.valid) {
alert(validation.message);
return;
function searchStatistics() {
if (currentMode === 'MONTHLY') {
if (!$('#month').val()) { alert('조회할 월을 선택해주세요.'); return; }
} else {
var v = validateDateRange($('#startDate').val(), $('#endDate').val());
if (!v.valid) { alert(v.message); return; }
}
$.ajax({
url: '/statistics/api/search',
type: 'POST',
contentType: 'application/json',
beforeSend: function(xhr) {
xhr.setRequestHeader('X-XSRF-TOKEN', getCsrfToken());
},
data: JSON.stringify({
startDate: startDate,
endDate: endDate,
clientId: selectedClientId || null
}),
beforeSend: function(xhr) { xhr.setRequestHeader('X-XSRF-TOKEN', getCsrfToken()); },
data: JSON.stringify(buildPayload()),
success: function(data) {
currentData = data;
updateSummaryDisplay();
updateChart();
updateTable();
updateDownloadLink();
updatePeriodTable();
},
error: function(xhr, status, error) {
console.error('Search failed:', error);
error: function(xhr) {
var errorMsg = '통계 조회에 실패했습니다.';
if (xhr.responseJSON && xhr.responseJSON.error) {
errorMsg = xhr.responseJSON.error;
}
if (xhr.responseJSON && xhr.responseJSON.error) errorMsg = xhr.responseJSON.error;
alert(errorMsg);
}
});
}
// Event Handlers
$('#btnSearch').on('click', function() {
searchStatistics();
});
// App selection change
$('#appSelect').on('change', function() {
selectedClientId = $(this).val();
searchStatistics();
});
// Enter key search
$('#startDate, #endDate').on('keypress', function(e) {
if (e.which === 13) {
searchStatistics();
// ── 모드 토글 ──
$('.mode-tab').on('click', function() {
currentMode = $(this).data('mode');
$('.mode-tab').removeClass('active');
$(this).addClass('active');
// 하단 기간표는 월별 조회에서도 '일별'로 표기하므로 라벨은 항상 일자 유지
if (currentMode === 'MONTHLY') {
$('#dailyFilter').hide();
$('#monthlyFilter').show();
} else {
$('#monthlyFilter').hide();
$('#dailyFilter').show();
}
});
// Date change auto search
$('#startDate, #endDate').on('change', function() {
searchStatistics();
});
$('#btnSearch').on('click', searchStatistics);
$('#appSelect').on('change', function() { selectedClientId = $(this).val(); searchStatistics(); });
$('#startDate, #endDate').on('change', searchStatistics);
$('#month').on('change', searchStatistics);
$('#startDate, #endDate').on('keypress', function(e) { if (e.which === 13) searchStatistics(); });
});
</script>
</th:block>
@@ -26,7 +26,7 @@
<option>DJBank 모바일뱅킹</option>
</select>
</div>
<p class="footer-contact">고객센터 1588-3388</p>
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
</div>
</div>
</div>