Files
sam-manage/resources/views/dev-tools/api-explorer/usage.blade.php
hskwon 15a66a345e feat: API 사용 현황 및 폐기 후보 관리 기능 추가
- API 사용 통계 조회 및 미사용 API 식별 기능
- 폐기 후보 등록/상태변경/삭제 기능
- API Explorer에서 사용 현황 페이지 링크 추가
- 북마크 토글 버그 수정 (라우트-컨트롤러 메서드명 일치)
2025-12-18 20:26:17 +09:00

654 lines
27 KiB
PHP

@extends('layouts.app')
@section('title', 'API 사용 현황')
@push('styles')
<style>
/* 통계 카드 */
.stat-card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.stat-value {
font-size: 2rem;
font-weight: 700;
line-height: 1.2;
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
margin-top: 0.25rem;
}
/* HTTP 메서드 배지 */
.method-badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
font-size: 0.7rem;
font-weight: 600;
border-radius: 0.25rem;
text-transform: uppercase;
}
.method-get { background: #dcfce7; color: #166534; }
.method-post { background: #dbeafe; color: #1e40af; }
.method-put { background: #fef3c7; color: #92400e; }
.method-patch { background: #ffedd5; color: #9a3412; }
.method-delete { background: #fee2e2; color: #991b1b; }
/* 상태 배지 */
.status-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 9999px;
}
.status-candidate { background: #fef3c7; color: #92400e; }
.status-scheduled { background: #ffedd5; color: #9a3412; }
.status-deprecated { background: #fee2e2; color: #991b1b; }
.status-removed { background: #f3f4f6; color: #6b7280; }
/* 테이블 스타일 */
.api-table {
width: 100%;
border-collapse: collapse;
}
.api-table th {
background: #f9fafb;
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
border-bottom: 1px solid #e5e7eb;
}
.api-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5e7eb;
font-size: 0.875rem;
}
.api-table tbody tr:hover {
background: #f9fafb;
}
.endpoint-path {
font-family: ui-monospace, monospace;
font-size: 0.8rem;
color: #374151;
}
/* 탭 네비게이션 */
.tab-nav {
display: flex;
gap: 0;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 1.5rem;
}
.tab-item {
padding: 0.75rem 1.5rem;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.15s;
}
.tab-item:hover {
color: #374151;
}
.tab-item.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* 차트 컨테이너 */
.chart-container {
height: 200px;
background: #f9fafb;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
color: #9ca3af;
}
</style>
@endpush
@section('content')
<div class="container-fluid py-4 px-6">
<!-- 헤더 -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">API 사용 현황</h1>
<p class="text-sm text-gray-500 mt-1">Swagger 기반 API 사용 통계 폐기 관리</p>
</div>
<div class="flex gap-2">
<a href="{{ route('dev-tools.api-explorer.index') }}" class="btn btn-ghost btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
API Explorer
</a>
<button onclick="addAllUnused()" class="btn btn-warning btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
미사용 API 전체 등록
</button>
</div>
</div>
<!-- 통계 요약 -->
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="stat-card">
<div class="stat-value text-gray-800">{{ $comparison['summary']['total'] }}</div>
<div class="stat-label">전체 API</div>
</div>
<div class="stat-card">
<div class="stat-value text-green-600">{{ $comparison['summary']['used_count'] }}</div>
<div class="stat-label">사용중 API</div>
</div>
<div class="stat-card">
<div class="stat-value text-orange-500">{{ $comparison['summary']['unused_count'] }}</div>
<div class="stat-label">미사용 API</div>
</div>
<div class="stat-card">
<div class="stat-value text-red-500">{{ $comparison['summary']['deprecation_count'] }}</div>
<div class="stat-label">폐기 후보</div>
</div>
</div>
<!-- 네비게이션 -->
<div class="bg-white rounded-lg shadow">
<div class="tab-nav px-4">
<div class="tab-item active" data-tab="used">
사용중 API ({{ count($comparison['used']) }})
</div>
<div class="tab-item" data-tab="unused">
미사용 API ({{ count($comparison['unused']) }})
</div>
<div class="tab-item" data-tab="deprecations">
폐기 후보 ({{ count($deprecations) }})
</div>
<div class="tab-item" data-tab="trend">
호출 추이
</div>
</div>
<!-- 사용중 API -->
<div class="tab-content active" id="tab-used">
<div class="overflow-x-auto">
<table class="api-table">
<thead>
<tr>
<th style="width: 80px;">메서드</th>
<th>엔드포인트</th>
<th style="width: 120px;">호출 </th>
<th style="width: 100px;">성공</th>
<th style="width: 100px;">실패</th>
<th style="width: 100px;">평균 응답</th>
<th style="width: 150px;">마지막 호출</th>
<th style="width: 80px;">작업</th>
</tr>
</thead>
<tbody>
@forelse($comparison['used'] as $api)
<tr>
<td>
<span class="method-badge method-{{ strtolower($api['method']) }}">
{{ $api['method'] }}
</span>
</td>
<td>
<div class="endpoint-path">{{ $api['endpoint'] }}</div>
@if($api['summary'])
<div class="text-xs text-gray-400 mt-0.5">{{ $api['summary'] }}</div>
@endif
</td>
<td class="font-semibold">{{ number_format($api['call_count']) }}</td>
<td class="text-green-600">{{ number_format($api['success_count']) }}</td>
<td class="text-red-500">{{ number_format($api['error_count']) }}</td>
<td>
@if($api['avg_duration_ms'])
{{ number_format($api['avg_duration_ms'], 0) }}ms
@else
-
@endif
</td>
<td class="text-gray-500 text-xs">
@if($api['last_called_at'])
{{ \Carbon\Carbon::parse($api['last_called_at'])->diffForHumans() }}
@else
-
@endif
</td>
<td>
@if(!$api['deprecation'])
<button onclick="addDeprecation('{{ $api['endpoint'] }}', '{{ $api['method'] }}')"
class="btn btn-ghost btn-xs text-orange-500" title="폐기 후보 등록">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</button>
@else
<span class="status-badge status-{{ $api['deprecation']->status }}">
{{ $api['deprecation']->status_label }}
</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="8" class="text-center py-8 text-gray-400">
사용된 API가 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<!-- 미사용 API -->
<div class="tab-content" id="tab-unused">
<div class="overflow-x-auto">
<table class="api-table">
<thead>
<tr>
<th style="width: 80px;">메서드</th>
<th>엔드포인트</th>
<th>태그</th>
<th>상태</th>
<th style="width: 80px;">작업</th>
</tr>
</thead>
<tbody>
@forelse($comparison['unused'] as $api)
<tr>
<td>
<span class="method-badge method-{{ strtolower($api['method']) }}">
{{ $api['method'] }}
</span>
</td>
<td>
<div class="endpoint-path">{{ $api['endpoint'] }}</div>
@if($api['summary'])
<div class="text-xs text-gray-400 mt-0.5">{{ $api['summary'] }}</div>
@endif
</td>
<td>
@foreach($api['tags'] ?? [] as $tag)
<span class="badge badge-ghost badge-sm">{{ $tag }}</span>
@endforeach
</td>
<td>
@if($api['deprecation'])
<span class="status-badge status-{{ $api['deprecation']->status }}">
{{ $api['deprecation']->status_label }}
</span>
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td>
@if(!$api['deprecation'])
<button onclick="addDeprecation('{{ $api['endpoint'] }}', '{{ $api['method'] }}')"
class="btn btn-ghost btn-xs text-orange-500" title="폐기 후보 등록">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</button>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center py-8 text-gray-400">
미사용 API가 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<!-- 폐기 후보 -->
<div class="tab-content" id="tab-deprecations">
<div class="overflow-x-auto">
<table class="api-table">
<thead>
<tr>
<th style="width: 80px;">메서드</th>
<th>엔드포인트</th>
<th>상태</th>
<th>사유</th>
<th>예정일</th>
<th>등록자</th>
<th>등록일</th>
<th style="width: 120px;">작업</th>
</tr>
</thead>
<tbody>
@forelse($deprecations as $dep)
<tr>
<td>
<span class="method-badge method-{{ strtolower($dep->method) }}">
{{ $dep->method }}
</span>
</td>
<td>
<div class="endpoint-path">{{ $dep->endpoint }}</div>
</td>
<td>
<select onchange="updateDeprecationStatus({{ $dep->id }}, this.value)"
class="select select-bordered select-xs">
@foreach(\App\Models\DevTools\ApiDeprecation::statusLabels() as $value => $label)
<option value="{{ $value }}" {{ $dep->status === $value ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
</td>
<td class="text-gray-600 text-sm">{{ $dep->reason ?? '-' }}</td>
<td class="text-gray-500 text-xs">
{{ $dep->scheduled_date?->format('Y-m-d') ?? '-' }}
</td>
<td class="text-gray-500 text-xs">
{{ $dep->creator?->name ?? '-' }}
</td>
<td class="text-gray-500 text-xs">
{{ $dep->created_at->format('Y-m-d') }}
</td>
<td>
<div class="flex gap-1">
<button onclick="editDeprecation({{ $dep->id }})"
class="btn btn-ghost btn-xs" title="수정">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button onclick="removeDeprecation({{ $dep->id }})"
class="btn btn-ghost btn-xs text-red-500" title="삭제">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="8" class="text-center py-8 text-gray-400">
폐기 후보가 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<!-- 호출 추이 -->
<div class="tab-content p-6" id="tab-trend">
<div class="chart-container" id="trend-chart">
<div class="text-center">
<p class="mb-2">최근 30 API 호출 추이</p>
<div class="overflow-x-auto">
<table class="api-table mx-auto" style="max-width: 600px;">
<thead>
<tr>
<th>날짜</th>
<th>호출 </th>
<th>고유 엔드포인트</th>
</tr>
</thead>
<tbody>
@forelse($dailyTrend as $day)
<tr>
<td>{{ $day->date }}</td>
<td class="font-semibold">{{ number_format($day->call_count) }}</td>
<td>{{ $day->unique_endpoints }}</td>
</tr>
@empty
<tr>
<td colspan="3" class="text-center py-4 text-gray-400">
데이터가 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 폐기 후보 등록 모달 -->
<div id="deprecation-modal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="closeDeprecationModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full p-6 relative">
<!-- 헤더 -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">폐기 후보 등록</h3>
<button onclick="closeDeprecationModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form id="deprecation-form">
<input type="hidden" name="endpoint" id="dep-endpoint">
<input type="hidden" name="method" id="dep-method">
<!-- API 정보 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">API 정보</label>
<div id="dep-api-info" class="bg-gray-100 p-3 rounded-lg font-mono text-sm"></div>
</div>
<!-- 폐기 사유 -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-1">폐기 사유</label>
<textarea name="reason" id="dep-reason" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="폐기 사유를 입력하세요 (선택)"></textarea>
</div>
<!-- 버튼 -->
<div class="flex justify-end gap-2">
<button type="button" onclick="closeDeprecationModal()"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
취소
</button>
<button type="submit"
class="px-4 py-2 text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition">
등록
</button>
</div>
</form>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
// 탭 전환
document.querySelectorAll('.tab-item').forEach(tab => {
tab.addEventListener('click', function() {
const tabId = this.dataset.tab;
// 탭 버튼 활성화
document.querySelectorAll('.tab-item').forEach(t => t.classList.remove('active'));
this.classList.add('active');
// 탭 컨텐츠 전환
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.getElementById('tab-' + tabId).classList.add('active');
});
});
// 폐기 후보 등록 폼
document.getElementById('deprecation-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = {
endpoint: document.getElementById('dep-endpoint').value,
method: document.getElementById('dep-method').value,
reason: document.getElementById('dep-reason').value
};
try {
const response = await fetch('{{ route("dev-tools.api-explorer.deprecations.store") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.success) {
closeDeprecationModal();
location.reload();
} else {
alert('등록 실패: ' + (result.message || '알 수 없는 오류'));
}
} catch (error) {
console.error('Error:', error);
alert('등록 중 오류가 발생했습니다.');
}
});
});
// 폐기 후보 등록 모달 열기
function addDeprecation(endpoint, method) {
document.getElementById('dep-endpoint').value = endpoint;
document.getElementById('dep-method').value = method;
document.getElementById('dep-api-info').innerHTML = `<span class="method-badge method-${method.toLowerCase()}">${method}</span> ${endpoint}`;
document.getElementById('dep-reason').value = '';
document.getElementById('deprecation-modal').classList.remove('hidden');
}
// 폐기 후보 등록 모달 닫기
function closeDeprecationModal() {
document.getElementById('deprecation-modal').classList.add('hidden');
}
// 미사용 API 전체 등록
async function addAllUnused() {
if (!confirm('미사용 API를 모두 폐기 후보로 등록하시겠습니까?')) {
return;
}
try {
const response = await fetch('{{ route("dev-tools.api-explorer.deprecations.bulk-unused") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
});
const result = await response.json();
if (result.success) {
alert(`${result.added_count}개의 API가 폐기 후보로 등록되었습니다.`);
location.reload();
} else {
alert('등록 실패: ' + (result.message || '알 수 없는 오류'));
}
} catch (error) {
console.error('Error:', error);
alert('등록 중 오류가 발생했습니다.');
}
}
// 폐기 상태 변경
async function updateDeprecationStatus(id, status) {
try {
const response = await fetch(`{{ url('dev-tools/api-explorer/deprecations') }}/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ status })
});
const result = await response.json();
if (!result.success) {
alert('상태 변경 실패');
location.reload();
}
} catch (error) {
console.error('Error:', error);
alert('상태 변경 중 오류가 발생했습니다.');
}
}
// 폐기 후보 삭제
async function removeDeprecation(id) {
if (!confirm('이 폐기 후보를 삭제하시겠습니까?')) {
return;
}
try {
const response = await fetch(`{{ url('dev-tools/api-explorer/deprecations') }}/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
});
const result = await response.json();
if (result.success) {
location.reload();
} else {
alert('삭제 실패');
}
} catch (error) {
console.error('Error:', error);
alert('삭제 중 오류가 발생했습니다.');
}
}
// 폐기 후보 수정 (TODO: 모달로 구현)
function editDeprecation(id) {
alert('수정 기능은 추후 구현 예정입니다.');
}
</script>
@endpush