From aa30ca9fcfbe66690c95d9f475a84f16a7a83cc3 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Fri, 19 Dec 2025 15:51:44 +0900 Subject: [PATCH] =?UTF-8?q?API=20=ED=86=B5=EA=B3=84=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ApiStatisticsController.java | 112 +++++ .../dto/ApiStatisticsDetailDto.java | 54 +++ .../dto/ApiStatisticsResultDto.java | 29 ++ .../dto/ApiStatisticsSearchDto.java | 66 +++ .../dto/ApiStatisticsSummaryDto.java | 59 +++ .../repository/ApiStatisticsRepository.java | 81 ++++ .../service/ApiStatisticsService.java | 172 +++++++ .../resources/static/css/api-statistics.css | 442 ++++++++++++++++++ .../views/apps/statistics/apiStatistics.html | 431 +++++++++++++++++ 9 files changed, 1446 insertions(+) create mode 100644 src/main/java/com/eactive/apim/portal/apps/statistics/controller/ApiStatisticsController.java create mode 100644 src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsDetailDto.java create mode 100644 src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsResultDto.java create mode 100644 src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsSearchDto.java create mode 100644 src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsSummaryDto.java create mode 100644 src/main/java/com/eactive/apim/portal/apps/statistics/repository/ApiStatisticsRepository.java create mode 100644 src/main/java/com/eactive/apim/portal/apps/statistics/service/ApiStatisticsService.java create mode 100644 src/main/resources/static/css/api-statistics.css create mode 100644 src/main/resources/templates/views/apps/statistics/apiStatistics.html diff --git a/src/main/java/com/eactive/apim/portal/apps/statistics/controller/ApiStatisticsController.java b/src/main/java/com/eactive/apim/portal/apps/statistics/controller/ApiStatisticsController.java new file mode 100644 index 0000000..1c3ad11 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/apps/statistics/controller/ApiStatisticsController.java @@ -0,0 +1,112 @@ +package com.eactive.apim.portal.apps.statistics.controller; + +import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsResultDto; +import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSearchDto; +import com.eactive.apim.portal.apps.statistics.service.ApiStatisticsService; +import com.eactive.apim.portal.common.util.SecurityUtil; +import com.eactive.apim.portal.portalorg.entity.PortalOrg; +import java.io.IOException; +import java.time.LocalDate; +import javax.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +/** + * API 통계 Controller + */ +@Slf4j +@Controller +@RequestMapping("/statistics/api") +@RequiredArgsConstructor +public class ApiStatisticsController { + + private final ApiStatisticsService apiStatisticsService; + + /** + * 통계 페이지 진입 + */ + @GetMapping + public String statisticsPage(Model model) { + PortalOrg org = getPortalOrg(); + if (org == null) { + return "redirect:/login"; + } + + String orgId = org.getId(); + + // 앱 목록 조회 (선택용) + model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId)); + + // 기본 조회 (7일 전 ~ 오늘, 전체 앱) + ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto(); + searchDto.setStartDate(LocalDate.now().minusDays(7)); + searchDto.setEndDate(LocalDate.now()); + searchDto.setClientId(null); + + ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto); + model.addAttribute("summary", result.getSummary()); + model.addAttribute("details", result.getDetails()); + model.addAttribute("searchDto", searchDto); + + return "apps/statistics/apiStatistics"; + } + + /** + * 통계 조회 (AJAX) + */ + @PostMapping("/search") + @ResponseBody + public ResponseEntity searchStatistics(@RequestBody ApiStatisticsSearchDto searchDto) { + PortalOrg org = getPortalOrg(); + if (org == null) { + return ResponseEntity.status(401).build(); + } + + // 날짜 범위 검증 (최대 40일) + if (!searchDto.isValidDateRange()) { + return ResponseEntity.badRequest() + .body(java.util.Collections.singletonMap("error", + "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.")); + } + + String orgId = org.getId(); + ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto); + return ResponseEntity.ok(result); + } + + /** + * CSV 다운로드 + */ + @GetMapping("/download") + public void downloadCsv(ApiStatisticsSearchDto searchDto, HttpServletResponse response) throws IOException { + PortalOrg org = getPortalOrg(); + if (org == null) { + response.sendError(401, "Unauthorized"); + return; + } + + // 날짜 범위 검증 (최대 40일) + if (!searchDto.isValidDateRange()) { + response.sendError(400, "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다."); + return; + } + + String orgId = org.getId(); + apiStatisticsService.downloadCsv(orgId, searchDto, response); + } + + private PortalOrg getPortalOrg() { + if (SecurityUtil.getPortalAuthenticatedUser() != null) { + return SecurityUtil.getPortalAuthenticatedUser().getPortalOrg(); + } + return null; + } +} diff --git a/src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsDetailDto.java b/src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsDetailDto.java new file mode 100644 index 0000000..16e6038 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsDetailDto.java @@ -0,0 +1,54 @@ +package com.eactive.apim.portal.apps.statistics.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 개별 API 통계 상세 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApiStatisticsDetailDto { + + private String apiId; + private String apiName; + private Long attemptedCount; // 호출 건 + private Long completedCount; // 성공 건 + private Long failedCount; // 실패 건 + private Double successRate; // 성공율 (%) + private Double failureRate; // 실패율 (%) + + /** + * Repository 조회용 생성자 + */ + public ApiStatisticsDetailDto(String apiId, String apiName, Long attemptedCount, Long completedCount) { + this.apiId = apiId; + this.apiName = apiName; + this.attemptedCount = attemptedCount != null ? attemptedCount : 0L; + this.completedCount = completedCount != null ? completedCount : 0L; + } + + /** + * 실패건, 성공률, 실패율 계산 + */ + public void calculateRates() { + if (attemptedCount == null) { + attemptedCount = 0L; + } + if (completedCount == null) { + completedCount = 0L; + } + + this.failedCount = attemptedCount - completedCount; + + if (attemptedCount > 0) { + this.successRate = Math.round((completedCount * 1000.0) / attemptedCount) / 10.0; + this.failureRate = Math.round((failedCount * 1000.0) / attemptedCount) / 10.0; + } else { + this.successRate = 0.0; + this.failureRate = 0.0; + } + } +} diff --git a/src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsResultDto.java b/src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsResultDto.java new file mode 100644 index 0000000..b254cb6 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsResultDto.java @@ -0,0 +1,29 @@ +package com.eactive.apim.portal.apps.statistics.dto; + +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * API 통계 조회 결과 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApiStatisticsResultDto { + + private ApiStatisticsSummaryDto summary; + private List details; + + /** + * 빈 결과 생성 + */ + public static ApiStatisticsResultDto empty() { + return new ApiStatisticsResultDto( + ApiStatisticsSummaryDto.empty(), + new ArrayList<>() + ); + } +} diff --git a/src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsSearchDto.java b/src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsSearchDto.java new file mode 100644 index 0000000..87de30e --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsSearchDto.java @@ -0,0 +1,66 @@ +package com.eactive.apim.portal.apps.statistics.dto; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +/** + * API 통계 검색 조건 DTO + */ +@Data +public class ApiStatisticsSearchDto { + + /** + * 최대 조회 가능 일수 + */ + public static final int MAX_DATE_RANGE_DAYS = 40; + + @DateTimeFormat(pattern = "yyyy-MM-dd") + private LocalDate startDate; + + @DateTimeFormat(pattern = "yyyy-MM-dd") + private LocalDate endDate; + + /** + * 선택된 앱의 clientId (null 또는 빈값이면 전체 앱) + */ + private String clientId; + + public ApiStatisticsSearchDto() { + this.startDate = LocalDate.now().minusDays(7); + this.endDate = LocalDate.now(); + this.clientId = null; + } + + /** + * 특정 앱이 선택되었는지 여부 + */ + public boolean hasSelectedApp() { + return clientId != null && !clientId.trim().isEmpty(); + } + + /** + * 날짜 범위가 유효한지 검증 (최대 40일) + */ + public boolean isValidDateRange() { + if (startDate == null || endDate == null) { + return false; + } + if (startDate.isAfter(endDate)) { + return false; + } + long daysBetween = ChronoUnit.DAYS.between(startDate, endDate); + return daysBetween <= MAX_DATE_RANGE_DAYS; + } + + /** + * 조회 기간 일수 반환 + */ + public long getDateRangeDays() { + if (startDate == null || endDate == null) { + return 0; + } + return ChronoUnit.DAYS.between(startDate, endDate); + } +} diff --git a/src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsSummaryDto.java b/src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsSummaryDto.java new file mode 100644 index 0000000..bc4411f --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsSummaryDto.java @@ -0,0 +1,59 @@ +package com.eactive.apim.portal.apps.statistics.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * API 전체 통계 요약 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApiStatisticsSummaryDto { + + private Long totalAttempted; // 전체 호출 건 + private Long totalCompleted; // 전체 성공 건 + private Long totalFailed; // 전체 실패 건 + private Double successRate; // 성공율 (%) + private Double failureRate; // 실패율 (%) + + /** + * Repository 조회용 생성자 (attempted, completed만) + */ + public ApiStatisticsSummaryDto(Long totalAttempted, Long totalCompleted) { + this.totalAttempted = totalAttempted != null ? totalAttempted : 0L; + this.totalCompleted = totalCompleted != null ? totalCompleted : 0L; + } + + /** + * 실패건, 성공률, 실패율 계산 + */ + public void calculateRates() { + if (totalAttempted == null) { + totalAttempted = 0L; + } + if (totalCompleted == null) { + totalCompleted = 0L; + } + + this.totalFailed = totalAttempted - totalCompleted; + + if (totalAttempted > 0) { + this.successRate = Math.round((totalCompleted * 1000.0) / totalAttempted) / 10.0; + this.failureRate = Math.round((totalFailed * 1000.0) / totalAttempted) / 10.0; + } else { + this.successRate = 0.0; + this.failureRate = 0.0; + } + } + + /** + * 빈 통계 생성 + */ + public static ApiStatisticsSummaryDto empty() { + ApiStatisticsSummaryDto dto = new ApiStatisticsSummaryDto(0L, 0L); + dto.calculateRates(); + return dto; + } +} diff --git a/src/main/java/com/eactive/apim/portal/apps/statistics/repository/ApiStatisticsRepository.java b/src/main/java/com/eactive/apim/portal/apps/statistics/repository/ApiStatisticsRepository.java new file mode 100644 index 0000000..2c688e2 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/apps/statistics/repository/ApiStatisticsRepository.java @@ -0,0 +1,81 @@ +package com.eactive.apim.portal.apps.statistics.repository; + +import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto; +import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto; +import com.eactive.apim.portal.obp.entity.ObpGwMetric; +import com.eactive.eai.data.jpa.BaseRepository; +import com.eactive.eai.rms.data.EMSDataSource; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** + * API 통계 Repository + * ObpGwMetric 테이블에서 통계 데이터를 조회 + */ +@EMSDataSource +public interface ApiStatisticsRepository extends BaseRepository { + + /** + * 전체 통계 조회 (특정 기간, 특정 조직의 모든 앱) + */ + @Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" + + "COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " + + "FROM ObpGwMetric m " + + "WHERE m.orgId = :orgId " + + "AND m.timeslice BETWEEN :startTime AND :endTime") + ApiStatisticsSummaryDto findSummaryByOrgAndPeriod( + @Param("orgId") String orgId, + @Param("startTime") LocalDateTime startTime, + @Param("endTime") LocalDateTime endTime); + + /** + * 전체 통계 조회 (특정 기간, 특정 앱) + */ + @Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" + + "COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " + + "FROM ObpGwMetric m " + + "WHERE m.orgId = :orgId " + + "AND m.clientId = :clientId " + + "AND m.timeslice BETWEEN :startTime AND :endTime") + ApiStatisticsSummaryDto findSummaryByOrgAndApp( + @Param("orgId") String orgId, + @Param("clientId") String clientId, + @Param("startTime") LocalDateTime startTime, + @Param("endTime") LocalDateTime endTime); + + /** + * 개별 API 통계 조회 (특정 기간, 특정 조직의 모든 앱) + */ + @Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" + + "m.apiId, m.apiName, " + + "COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " + + "FROM ObpGwMetric m " + + "WHERE m.orgId = :orgId " + + "AND m.timeslice BETWEEN :startTime AND :endTime " + + "GROUP BY m.apiId, m.apiName " + + "ORDER BY SUM(m.attemptedCount) DESC") + List findDetailByOrgAndPeriod( + @Param("orgId") String orgId, + @Param("startTime") LocalDateTime startTime, + @Param("endTime") LocalDateTime endTime); + + /** + * 개별 API 통계 조회 (특정 기간, 특정 앱) + */ + @Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" + + "m.apiId, m.apiName, " + + "COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " + + "FROM ObpGwMetric m " + + "WHERE m.orgId = :orgId " + + "AND m.clientId = :clientId " + + "AND m.timeslice BETWEEN :startTime AND :endTime " + + "GROUP BY m.apiId, m.apiName " + + "ORDER BY SUM(m.attemptedCount) DESC") + List findDetailByOrgAndApp( + @Param("orgId") String orgId, + @Param("clientId") String clientId, + @Param("startTime") LocalDateTime startTime, + @Param("endTime") LocalDateTime endTime); +} diff --git a/src/main/java/com/eactive/apim/portal/apps/statistics/service/ApiStatisticsService.java b/src/main/java/com/eactive/apim/portal/apps/statistics/service/ApiStatisticsService.java new file mode 100644 index 0000000..a12b869 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/apps/statistics/service/ApiStatisticsService.java @@ -0,0 +1,172 @@ +package com.eactive.apim.portal.apps.statistics.service; + +import com.eactive.apim.portal.app.entity.Credential; +import com.eactive.apim.portal.app.repository.CredentialRepository; +import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto; +import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsResultDto; +import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSearchDto; +import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto; +import com.eactive.apim.portal.apps.statistics.repository.ApiStatisticsRepository; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Collections; +import java.util.List; +import javax.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * API 통계 Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ApiStatisticsService { + + private final CredentialRepository credentialRepository; + private final ApiStatisticsRepository apiStatisticsRepository; + + /** + * 로그인 사용자 법인의 앱 목록 조회 (전체) + */ + public List getAppListByOrg(String orgId) { + return credentialRepository.findAllByOrgid(orgId); + } + + /** + * 통계 조회 + */ + public ApiStatisticsResultDto getStatistics(String orgId, ApiStatisticsSearchDto searchDto) { + LocalDateTime startTime = searchDto.getStartDate().atStartOfDay(); + LocalDateTime endTime = searchDto.getEndDate().atTime(LocalTime.MAX); + + ApiStatisticsSummaryDto summary; + List details; + + if (searchDto.hasSelectedApp()) { + // 특정 앱 통계 + String clientId = searchDto.getClientId(); + + // 해당 앱이 조직 소속인지 검증 + if (!isAppBelongsToOrg(clientId, orgId)) { + return ApiStatisticsResultDto.empty(); + } + + summary = apiStatisticsRepository.findSummaryByOrgAndApp( + orgId, clientId, startTime, endTime); + details = apiStatisticsRepository.findDetailByOrgAndApp( + orgId, clientId, startTime, endTime); + } else { + // 전체 앱 통계 + summary = apiStatisticsRepository.findSummaryByOrgAndPeriod( + orgId, startTime, endTime); + details = apiStatisticsRepository.findDetailByOrgAndPeriod( + orgId, startTime, endTime); + } + + // null 체크 및 빈 결과 처리 + if (summary == null) { + summary = ApiStatisticsSummaryDto.empty(); + } else { + summary.calculateRates(); + } + + if (details == null) { + details = Collections.emptyList(); + } else { + details.forEach(ApiStatisticsDetailDto::calculateRates); + } + + return new ApiStatisticsResultDto(summary, details); + } + + /** + * 앱이 해당 조직 소속인지 검증 + */ + private boolean isAppBelongsToOrg(String clientId, String orgId) { + return credentialRepository.findByClientidAndOrgid(clientId, orgId).isPresent(); + } + + /** + * CSV 다운로드 + */ + public void downloadCsv(String orgId, ApiStatisticsSearchDto searchDto, HttpServletResponse response) throws IOException { + ApiStatisticsResultDto result = getStatistics(orgId, searchDto); + + String fileName = String.format("API_Statistics_%s_%s.csv", + searchDto.getStartDate().toString(), + searchDto.getEndDate().toString()); + + response.setContentType("text/csv; charset=UTF-8"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()) + "\""); + + // BOM for Excel UTF-8 recognition + response.getOutputStream().write(0xEF); + response.getOutputStream().write(0xBB); + response.getOutputStream().write(0xBF); + + try (PrintWriter writer = new PrintWriter(new OutputStreamWriter(response.getOutputStream(), StandardCharsets.UTF_8), true)) { + // 조회 조건 + writer.println("조회 기간," + searchDto.getStartDate() + " ~ " + searchDto.getEndDate()); + + String appName = "전체 앱"; + if (searchDto.hasSelectedApp()) { + appName = credentialRepository.findById(searchDto.getClientId()) + .map(Credential::getClientname) + .orElse("선택된 앱"); + } + writer.println("앱 구분," + appName); + writer.println(); + + // 전체 통계 + ApiStatisticsSummaryDto summary = result.getSummary(); + writer.println("[전체 통계]"); + writer.println("총 호출 건," + summary.getTotalAttempted()); + writer.println("성공 건," + summary.getTotalCompleted() + "," + summary.getSuccessRate() + "%"); + writer.println("실패 건," + summary.getTotalFailed() + "," + summary.getFailureRate() + "%"); + writer.println(); + + // API별 통계 헤더 + writer.println("[API별 통계]"); + writer.println("API명,호출 건,성공 건,성공율,실패 건,실패율"); + + // API별 통계 데이터 + for (ApiStatisticsDetailDto detail : result.getDetails()) { + writer.println(String.format("%s,%d,%d,%s%%,%d,%s%%", + escapeCsv(detail.getApiName()), + detail.getAttemptedCount(), + detail.getCompletedCount(), + detail.getSuccessRate(), + detail.getFailedCount(), + detail.getFailureRate())); + } + + // 전체 합계 행 + writer.println(String.format("전체,%d,%d,%s%%,%d,%s%%", + summary.getTotalAttempted(), + summary.getTotalCompleted(), + summary.getSuccessRate(), + summary.getTotalFailed(), + summary.getFailureRate())); + } + } + + /** + * CSV 값 이스케이프 (쉼표, 따옴표 처리) + */ + private String escapeCsv(String value) { + if (value == null) return ""; + if (value.contains(",") || value.contains("\"") || value.contains("\n")) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + return value; + } +} diff --git a/src/main/resources/static/css/api-statistics.css b/src/main/resources/static/css/api-statistics.css new file mode 100644 index 0000000..c84f929 --- /dev/null +++ b/src/main/resources/static/css/api-statistics.css @@ -0,0 +1,442 @@ +/* API Statistics Page Styles */ + +.api-statistics-container { + max-width: 1200px; + margin: 0 auto; + padding: 40px 20px; +} + +/* Search Section */ +.statistics-search-section { + display: flex; + justify-content: flex-end; + margin-bottom: 30px; +} + +.search-filter-row { + display: flex; + align-items: center; + gap: 10px; + background: #fff; + padding: 8px 16px; + border-radius: 30px; + border: 1px solid #e0e0e0; +} + +.date-range-picker { + display: flex; + align-items: center; + gap: 8px; +} + +.date-input { + border: none; + padding: 8px 12px; + font-size: 14px; + color: #333; + background: transparent; + outline: none; +} + +.date-separator { + color: #666; +} + +.btn-search { + background: #0049B4; + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + 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; + padding: 40px; + margin-bottom: 30px; +} + +.summary-content { + display: flex; + align-items: center; + justify-content: space-between; + gap: 40px; +} + +.summary-left { + flex: 1; +} + +.app-select-wrapper { + margin-bottom: 24px; +} + +.app-select { + padding: 12px 40px 12px 20px; + border-radius: 25px; + border: 1px solid #0049B4; + background: #fff; + color: #0049B4; + font-size: 14px; + font-weight: 500; + cursor: pointer; + appearance: none; + background-image: 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='%230049B4' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 16px center; + min-width: 150px; + 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); +} + +.summary-info { + padding-left: 10px; +} + +.summary-total-text { + font-size: 18px; + color: #333; + margin-bottom: 16px; +} + +.summary-total-text strong { + font-size: 24px; + color: #0049B4; +} + +.summary-detail-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.summary-detail-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; +} + +.summary-detail-item.success .detail-icon { + color: #4A90D9; +} + +.summary-detail-item.failure .detail-icon { + color: #F5A3B5; +} + +.detail-label { + color: #666; + min-width: 30px; +} + +.detail-value { + font-weight: 600; + color: #333; +} + +.detail-percent { + color: #999; +} + +/* Donut Chart */ +.summary-center { + flex: 0 0 200px; + display: flex; + align-items: center; + justify-content: center; +} + +#donutChart { + max-width: 200px; + max-height: 200px; +} + +.donut-segment { + transition: stroke-dasharray 0.3s ease, stroke-dashoffset 0.3s ease; +} + +/* Rate Display */ +.summary-right { + flex: 0 0 150px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.rate-display { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid #e0e0e0; +} + +.rate-display:last-child { + border-bottom: none; +} + +.rate-indicator { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.rate-indicator.success { + background: #4A90D9; +} + +.rate-indicator.failure { + background: #F5A3B5; +} + +.rate-label { + font-size: 14px; + color: #666; +} + +.rate-value { + font-size: 32px; + font-weight: 700; +} + +.success-rate .rate-value { + color: #4A90D9; +} + +.failure-rate .rate-value { + color: #F5A3B5; +} + +.rate-percent { + font-size: 18px; + 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); +} + +.table-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + background: #0049B4; + color: #fff; +} + +.table-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 18px; + font-weight: 600; + margin: 0; +} + +.table-title svg { + stroke: #fff; +} + +.btn-download { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: rgba(255, 255, 255, 0.2); + border-radius: 20px; + color: #fff; + text-decoration: none; + font-size: 14px; + transition: background 0.2s; +} + +.btn-download:hover { + background: rgba(255, 255, 255, 0.3); + color: #fff; +} + +.table-container { + padding: 0; +} + +.statistics-table { + width: 100%; + border-collapse: collapse; +} + +.statistics-table thead th { + padding: 16px 20px; + text-align: left; + font-weight: 500; + color: #666; + 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; +} + +.api-name { + display: flex; + align-items: center; + gap: 10px; +} + +.api-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + 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; +} + +.progress-bar { + display: flex; + height: 12px; + background: #e0e0e0; + border-radius: 6px; + overflow: hidden; +} + +.progress-success { + background: #4A90D9; + 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; + } +} diff --git a/src/main/resources/templates/views/apps/statistics/apiStatistics.html b/src/main/resources/templates/views/apps/statistics/apiStatistics.html new file mode 100644 index 0000000..7ce80f1 --- /dev/null +++ b/src/main/resources/templates/views/apps/statistics/apiStatistics.html @@ -0,0 +1,431 @@ + + + + +
+
+ +

APP 통계

+
+
+ + + +
+ + +
+
+
+ + - + +
+ +
+
+ + +
+
+ +
+
+ +
+ +
+

+ 총 API 호출 수는 0건입니다. +

+
+
+ + + + + + 성공 + 0 + (0%) +
+
+ + + + + + 실패 + 0 + (0%) +
+
+
+
+ + +
+ + + + + +
+ + +
+
+ + 성공률 + 0 + % +
+
+ + 실패률 + 0 + % +
+
+
+
+ + +
+
+

+ + + + + API별 통계 +

+ + + + + + + 다운로드 + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
API명API호출수성공실패통계
+ + + + + + API 이름 + 000 +
+
+
+
+
데이터가 없습니다.
+ + + + + + + 전체 + 000 +
+
+
+
+
+
+
+ +
+
+ + + + + + +