- 테이블 헤더 스타일 통일 (menus, roles, permissions, boards 등) - 권한 매트릭스 체크박스/버튼 크기 20x20으로 표준화 - 스크럼 항목 추가/수정 모달 통합 (코드 중복 제거) - daily-logs API URL 경로 수정 (/pm/ 제거) - 타임존 Asia/Seoul로 변경 - flow-tester 액션 아이콘 크기 조정
596 lines
42 KiB
PHP
596 lines
42 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', 'API 플로우 테스터')
|
|
|
|
@section('content')
|
|
<!-- 페이지 헤더 -->
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h1 class="text-2xl font-bold text-gray-800">API 플로우 테스터</h1>
|
|
<div class="flex gap-2">
|
|
<!-- JSON 작성 가이드 버튼 -->
|
|
<button onclick="GuideModal.open()"
|
|
class="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg transition flex items-center gap-2">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
JSON 가이드
|
|
</button>
|
|
<!-- 예제 플로우 버튼 -->
|
|
<button onclick="ExamplesModal.open()"
|
|
class="bg-purple-100 hover:bg-purple-200 text-purple-700 px-4 py-2 rounded-lg transition flex items-center gap-2">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
|
</svg>
|
|
예제 플로우
|
|
</button>
|
|
<!-- 새 플로우 버튼 -->
|
|
<a href="{{ route('dev-tools.flow-tester.create') }}"
|
|
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
새 플로우
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 필터 영역 -->
|
|
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
|
<form method="GET" class="flex gap-4">
|
|
<!-- 카테고리 선택 -->
|
|
<div class="w-40">
|
|
<select name="category"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
<option value="">전체 카테고리</option>
|
|
<option value="item-master" {{ request('category') === 'item-master' ? 'selected' : '' }}>item-master</option>
|
|
<option value="auth" {{ request('category') === 'auth' ? 'selected' : '' }}>auth</option>
|
|
<option value="bom" {{ request('category') === 'bom' ? 'selected' : '' }}>bom</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- 검색 -->
|
|
<div class="flex-1">
|
|
<input type="text"
|
|
name="search"
|
|
value="{{ request('search') }}"
|
|
placeholder="플로우 이름으로 검색..."
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
</div>
|
|
|
|
<!-- 검색 버튼 -->
|
|
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
|
|
검색
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- 플로우 목록 -->
|
|
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
@if($flows->isEmpty())
|
|
<div class="text-center py-12 text-gray-500">
|
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
</svg>
|
|
<p class="text-lg font-medium">등록된 플로우가 없습니다</p>
|
|
<p class="text-sm mt-1">새 플로우를 생성하여 API 테스트를 시작하세요.</p>
|
|
</div>
|
|
@else
|
|
<table class="w-full">
|
|
<thead class="bg-gray-50">
|
|
<tr>
|
|
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">이름</th>
|
|
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">카테고리</th>
|
|
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">스텝</th>
|
|
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">최근 실행</th>
|
|
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">상태</th>
|
|
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">액션</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200">
|
|
@foreach($flows as $flow)
|
|
@php
|
|
$latestRun = $flow->runs->first();
|
|
$steps = $flow->flow_definition['steps'] ?? [];
|
|
@endphp
|
|
{{-- 메인 row --}}
|
|
<tr class="hover:bg-gray-50 cursor-pointer flow-row" data-flow-id="{{ $flow->id }}" onclick="toggleFlowDetail({{ $flow->id }}, event)">
|
|
<td class="px-6 py-4">
|
|
<div class="flex items-center gap-2">
|
|
<svg class="w-4 h-4 text-gray-400 transition-transform flow-chevron" id="chevron-{{ $flow->id }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
<div>
|
|
<div class="font-medium text-gray-900">{{ $flow->name }}</div>
|
|
@if($flow->description)
|
|
<div class="text-sm text-gray-500 truncate max-w-xs">{{ $flow->description }}</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4">
|
|
@if($flow->category)
|
|
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-700 rounded">{{ $flow->category }}</span>
|
|
@else
|
|
<span class="text-gray-400">-</span>
|
|
@endif
|
|
</td>
|
|
<td class="px-6 py-4 text-sm text-gray-500">
|
|
{{ $flow->step_count }}개
|
|
</td>
|
|
<td class="px-6 py-4 text-sm text-gray-500">
|
|
@if($latestRun)
|
|
{{ $latestRun->created_at->diffForHumans() }}
|
|
@else
|
|
<span class="text-gray-400">-</span>
|
|
@endif
|
|
</td>
|
|
<td class="px-6 py-4">
|
|
@if($latestRun)
|
|
<span class="px-2 py-1 text-xs font-medium rounded {{ $latestRun->status_color }}">
|
|
{{ $latestRun->status_label }}
|
|
</span>
|
|
@else
|
|
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-500 rounded">대기</span>
|
|
@endif
|
|
</td>
|
|
<td class="px-6 py-4 text-right" onclick="event.stopPropagation()">
|
|
<div class="flex justify-end gap-2">
|
|
<!-- 실행 버튼 -->
|
|
<button onclick="runFlow({{ $flow->id }})"
|
|
class="p-2 text-green-600 hover:bg-green-50 rounded-lg transition"
|
|
title="실행">
|
|
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</button>
|
|
<!-- 편집 버튼 -->
|
|
<a href="{{ route('dev-tools.flow-tester.edit', $flow->id) }}"
|
|
class="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition"
|
|
title="편집">
|
|
<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="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>
|
|
</a>
|
|
<!-- 이력 버튼 -->
|
|
<a href="{{ route('dev-tools.flow-tester.history', $flow->id) }}"
|
|
class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition"
|
|
title="실행 이력">
|
|
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</a>
|
|
<!-- 복제 버튼 -->
|
|
<form action="{{ route('dev-tools.flow-tester.clone', $flow->id) }}" method="POST" class="inline">
|
|
@csrf
|
|
<button type="submit"
|
|
class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition"
|
|
title="복제">
|
|
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
</svg>
|
|
</button>
|
|
</form>
|
|
<!-- 삭제 버튼 -->
|
|
<button onclick="confirmDelete({{ $flow->id }}, '{{ $flow->name }}')"
|
|
class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition"
|
|
title="삭제">
|
|
<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="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>
|
|
{{-- 스텝 상세 row (확장 영역) --}}
|
|
@php
|
|
// 최근 실행 로그에서 스텝별 상태 추출
|
|
$stepResults = [];
|
|
if ($latestRun && $latestRun->execution_log) {
|
|
foreach ($latestRun->execution_log as $log) {
|
|
$stepResults[$log['stepId'] ?? ''] = $log;
|
|
}
|
|
}
|
|
@endphp
|
|
<tr id="flow-detail-{{ $flow->id }}" class="hidden">
|
|
<td colspan="6" class="px-6 py-4 bg-gray-50">
|
|
<div class="mb-3 flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<h4 class="text-sm font-semibold text-gray-700">플로우 스텝 ({{ count($steps) }}개)</h4>
|
|
@if($latestRun)
|
|
<span class="text-xs text-gray-500">
|
|
최근 실행: {{ $latestRun->created_at->format('m/d H:i') }}
|
|
@if($latestRun->duration_ms)
|
|
({{ number_format($latestRun->duration_ms / 1000, 2) }}초)
|
|
@endif
|
|
</span>
|
|
@endif
|
|
</div>
|
|
@if($flow->flow_definition['config']['baseUrl'] ?? null)
|
|
<span class="text-xs text-gray-500">Base URL: {{ $flow->flow_definition['config']['baseUrl'] }}</span>
|
|
@endif
|
|
</div>
|
|
{{-- 스텝 파이프라인 --}}
|
|
<div style="background: white; border-radius: 12px; border: 1px solid #e5e7eb; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
|
<div style="display: flex; flex-wrap: wrap; align-items: stretch; gap: 12px;">
|
|
@foreach($steps as $idx => $step)
|
|
@php
|
|
// 메서드 배지 색상만 정의
|
|
$badgeColors = [
|
|
'GET' => '#3b82f6',
|
|
'POST' => '#10b981',
|
|
'PUT' => '#f59e0b',
|
|
'PATCH' => '#f97316',
|
|
'DELETE' => '#ef4444',
|
|
];
|
|
$method = strtoupper($step['method'] ?? 'GET');
|
|
$badgeColor = $badgeColors[$method] ?? '#6b7280';
|
|
$hasDeps = !empty($step['dependsOn']);
|
|
|
|
// 실행 결과 확인
|
|
$stepId = $step['id'] ?? '';
|
|
$result = $stepResults[$stepId] ?? null;
|
|
$isSkipped = $result && ($result['skipped'] ?? false);
|
|
$isSuccess = $result && !$isSkipped && ($result['success'] ?? false);
|
|
$isFailed = $result && !$isSkipped && !($result['success'] ?? true);
|
|
$isPending = !$result;
|
|
|
|
// 상태 기준 카드 스타일
|
|
if ($isPending) {
|
|
$cardBg = '#f9fafb';
|
|
$cardBorder = '#e5e7eb';
|
|
$numberBg = '#9ca3af';
|
|
$statusIcon = null;
|
|
} elseif ($isSkipped) {
|
|
$cardBg = '#fefce8'; // 연노랑
|
|
$cardBorder = '#facc15'; // 노랑
|
|
$numberBg = '#eab308';
|
|
$statusIcon = 'skip';
|
|
} elseif ($isSuccess) {
|
|
$cardBg = '#ecfdf5';
|
|
$cardBorder = '#10b981';
|
|
$numberBg = '#10b981';
|
|
$statusIcon = 'success';
|
|
} else {
|
|
$cardBg = '#fef2f2';
|
|
$cardBorder = '#ef4444';
|
|
$numberBg = '#ef4444';
|
|
$statusIcon = 'fail';
|
|
}
|
|
@endphp
|
|
{{-- 연결 화살표 --}}
|
|
@if($idx > 0)
|
|
<div style="display: flex; align-items: center; align-self: center; margin: 0 -4px;">
|
|
<div style="width: 24px; height: 2px; background: linear-gradient(to right, #d1d5db, #9ca3af);"></div>
|
|
<div style="width: 0; height: 0; border-top: 6px solid transparent; border-bottom: 6px solid transparent; border-left: 10px solid #9ca3af;"></div>
|
|
</div>
|
|
@endif
|
|
{{-- 스텝 카드 --}}
|
|
<div class="group" style="position: relative; display: flex; flex-direction: column; min-width: 170px; max-width: 210px;">
|
|
{{-- 스텝 번호 --}}
|
|
<div style="position: absolute; top: -10px; left: -10px; width: 28px; height: 28px; border-radius: 50%; background: {{ $numberBg }}; color: white; font-size: 12px; font-weight: bold; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 4px rgba(0,0,0,0.2); z-index: 10;">
|
|
{{ $idx + 1 }}
|
|
</div>
|
|
{{-- 상태 인디케이터 (성공/실패/스킵 시) --}}
|
|
@if($statusIcon)
|
|
@php
|
|
$iconBgColor = match($statusIcon) {
|
|
'success' => '#10b981',
|
|
'fail' => '#ef4444',
|
|
'skip' => '#eab308',
|
|
default => '#9ca3af'
|
|
};
|
|
@endphp
|
|
<div style="position: absolute; top: -10px; right: -10px; width: 28px; height: 28px; border-radius: 50%; background: {{ $iconBgColor }}; color: white; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 4px rgba(0,0,0,0.2); z-index: 10;">
|
|
@if($statusIcon === 'success')
|
|
<svg style="width: 16px; height: 16px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/>
|
|
</svg>
|
|
@elseif($statusIcon === 'skip')
|
|
{{-- Skip 아이콘: 화살표 우회 --}}
|
|
<svg style="width: 16px; height: 16px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 5l7 7-7 7M5 5l7 7-7 7"/>
|
|
</svg>
|
|
@else
|
|
<svg style="width: 16px; height: 16px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
{{-- 카드 본체 (상태 기준 색상) --}}
|
|
<div style="flex: 1; border-radius: 10px; border: 2px solid {{ $cardBorder }}; background: {{ $cardBg }}; padding: 14px; transition: box-shadow 0.2s;" class="hover:shadow-lg">
|
|
{{-- 메서드 배지 --}}
|
|
<div style="margin-bottom: 10px;">
|
|
<span style="display: inline-block; padding: 3px 10px; font-size: 11px; font-weight: bold; border-radius: 4px; background: {{ $badgeColor }}; color: white; box-shadow: 0 1px 2px rgba(0,0,0,0.1);">
|
|
{{ $method }}
|
|
</span>
|
|
</div>
|
|
{{-- 스텝 이름 --}}
|
|
<div style="font-size: 13px; font-weight: 600; color: #1f2937; margin-bottom: 6px; line-height: 1.3;">
|
|
{{ $step['name'] ?? $step['id'] }}
|
|
</div>
|
|
{{-- 엔드포인트 --}}
|
|
<div style="font-size: 11px; color: #6b7280; font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{ $step['endpoint'] ?? '' }}">
|
|
{{ $step['endpoint'] ?? '-' }}
|
|
</div>
|
|
{{-- 실행 결과 또는 의존성 --}}
|
|
@if($result)
|
|
<div style="display: flex; align-items: center; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid {{ $cardBorder }};">
|
|
@if($isSkipped)
|
|
<span style="display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 500; color: #ca8a04;">
|
|
<svg style="width: 14px; height: 14px;" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-11.25a.75.75 0 00-1.5 0v2.5h-2.5a.75.75 0 000 1.5h2.5v2.5a.75.75 0 001.5 0v-2.5h2.5a.75.75 0 000-1.5h-2.5v-2.5z" clip-rule="evenodd" transform="rotate(45 10 10)"/></svg>
|
|
스킵
|
|
</span>
|
|
@elseif($isSuccess)
|
|
<span style="display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 500; color: #059669;">
|
|
<svg style="width: 14px; height: 14px;" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
|
성공
|
|
<span style="color: #6b7280; margin-left: 4px;">{{ $result['duration'] ?? 0 }}ms</span>
|
|
</span>
|
|
@else
|
|
<span style="display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 500; color: #dc2626;">
|
|
<svg style="width: 14px; height: 14px;" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>
|
|
실패
|
|
<span style="color: #6b7280; margin-left: 4px;">{{ $result['duration'] ?? 0 }}ms</span>
|
|
</span>
|
|
@endif
|
|
</div>
|
|
@elseif($hasDeps)
|
|
<div style="display: flex; align-items: center; gap: 4px; margin-top: 10px; padding-top: 10px; border-top: 1px solid {{ $cardBorder }};">
|
|
<svg style="width: 12px; height: 12px; color: #9ca3af;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"/>
|
|
</svg>
|
|
<span style="font-size: 10px; color: #6b7280;">{{ implode(', ', $step['dependsOn']) }}</span>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
{{-- 툴팁 --}}
|
|
<div class="absolute left-1/2 -translate-x-1/2 top-full mt-2 z-30 hidden group-hover:block" style="width: 320px; padding: 16px; background: #111827; color: white; font-size: 12px; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.3);">
|
|
<div style="position: absolute; top: -8px; left: 50%; transform: translateX(-50%); width: 0; height: 0; border-left: 8px solid transparent; border-right: 8px solid transparent; border-bottom: 8px solid #111827;"></div>
|
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
|
<span style="padding: 2px 8px; font-size: 10px; font-weight: bold; border-radius: 4px; background: {{ $badgeColor }}; color: white;">{{ $method }}</span>
|
|
<span style="font-weight: 600;">{{ $step['name'] ?? $step['id'] }}</span>
|
|
</div>
|
|
<div style="color: #9ca3af; font-family: monospace; font-size: 11px; margin-bottom: 8px; word-break: break-all;">{{ $step['endpoint'] ?? '' }}</div>
|
|
@if(!empty($step['description']))
|
|
<div style="color: #d1d5db; margin-bottom: 8px;">{{ $step['description'] }}</div>
|
|
@endif
|
|
@if($result)
|
|
<div style="border-top: 1px solid #374151; padding-top: 8px; margin-top: 8px;">
|
|
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 4px;">
|
|
@if($isSkipped)
|
|
<span style="font-weight: 600; color: #facc15;">⏭ 스킵됨</span>
|
|
@else
|
|
<span style="font-weight: 600; color: {{ $isSuccess ? '#4ade80' : '#f87171' }};">
|
|
{{ $isSuccess ? '✓ 성공' : '✗ 실패' }}
|
|
</span>
|
|
<span style="color: #9ca3af;">{{ $result['duration'] ?? 0 }}ms</span>
|
|
@if(!empty($result['response']['status']))
|
|
<span style="padding: 2px 6px; background: #374151; border-radius: 4px; color: #d1d5db;">HTTP {{ $result['response']['status'] }}</span>
|
|
@endif
|
|
@endif
|
|
</div>
|
|
@if($isSkipped && !empty($result['skipReason']))
|
|
<div style="color: #fde047; font-size: 11px; margin-top: 4px;">{{ $result['skipReason'] }}</div>
|
|
@elseif(!$isSuccess && !$isSkipped && !empty($result['error']))
|
|
<div style="color: #fca5a5; font-size: 11px; margin-top: 4px;">{{ $result['error'] }}</div>
|
|
@endif
|
|
</div>
|
|
@else
|
|
@if(!empty($step['expect']))
|
|
<div style="color: #9ca3af; margin-top: 4px;">
|
|
<span style="color: #6b7280;">expect:</span> status {{ json_encode($step['expect']['status'] ?? []) }}
|
|
</div>
|
|
@endif
|
|
@if(!empty($step['extract']))
|
|
<div style="color: #34d399; margin-top: 4px;">
|
|
<span style="color: #6b7280;">extract:</span> {{ implode(', ', array_keys($step['extract'])) }}
|
|
</div>
|
|
@endif
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@if(empty($steps))
|
|
<div class="text-sm text-gray-400 italic">스텝이 없습니다.</div>
|
|
@endif
|
|
</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- 페이지네이션 -->
|
|
@if($flows->hasPages())
|
|
<div class="px-6 py-4 border-t border-gray-200">
|
|
{{ $flows->links() }}
|
|
</div>
|
|
@endif
|
|
@endif
|
|
</div>
|
|
|
|
{{-- 모달 포함 --}}
|
|
@include('dev-tools.flow-tester.partials.guide-modal')
|
|
@include('dev-tools.flow-tester.partials.example-flows')
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
// 플로우 스텝 상세 토글
|
|
function toggleFlowDetail(id, event) {
|
|
// 버튼/링크 클릭 시 무시 (액션 영역)
|
|
if (event.target.closest('button, a, form')) {
|
|
return;
|
|
}
|
|
|
|
const detailRow = document.getElementById(`flow-detail-${id}`);
|
|
const chevron = document.getElementById(`chevron-${id}`);
|
|
|
|
if (!detailRow) return;
|
|
|
|
const isHidden = detailRow.classList.contains('hidden');
|
|
|
|
if (isHidden) {
|
|
detailRow.classList.remove('hidden');
|
|
chevron?.classList.add('rotate-90');
|
|
} else {
|
|
detailRow.classList.add('hidden');
|
|
chevron?.classList.remove('rotate-90');
|
|
}
|
|
}
|
|
|
|
function runFlow(id) {
|
|
if (!confirm('이 플로우를 실행하시겠습니까?')) return;
|
|
|
|
// 실행 버튼을 로딩 상태로 변경
|
|
const btn = document.querySelector(`button[onclick="runFlow(${id})"]`);
|
|
const originalHtml = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = `<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>`;
|
|
|
|
fetch(`/dev-tools/flow-tester/${id}/run`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
// 버튼 복원
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalHtml;
|
|
|
|
// 결과 모달 표시
|
|
showResultModal(data);
|
|
})
|
|
.catch(error => {
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalHtml;
|
|
alert('오류 발생: ' + error.message);
|
|
});
|
|
}
|
|
|
|
function showResultModal(data) {
|
|
const isSuccess = data.status === 'SUCCESS';
|
|
const isFailed = data.status === 'FAILED';
|
|
const isPartial = data.status === 'PARTIAL';
|
|
|
|
const statusColor = isSuccess ? 'green' : (isFailed ? 'red' : 'yellow');
|
|
const statusIcon = isSuccess
|
|
? `<svg class="w-12 h-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>`
|
|
: (isFailed
|
|
? `<svg class="w-12 h-12 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>`
|
|
: `<svg class="w-12 h-12 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<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>`);
|
|
|
|
const result = data.result || {};
|
|
const duration = result.duration ? `${(result.duration / 1000).toFixed(2)}초` : '-';
|
|
|
|
// 실행 로그 요약
|
|
let logSummary = '';
|
|
if (result.executionLog && result.executionLog.length > 0) {
|
|
logSummary = result.executionLog.map((step, idx) => {
|
|
const stepIcon = step.success
|
|
? '<span class="text-green-500">✓</span>'
|
|
: '<span class="text-red-500">✗</span>';
|
|
return `<div class="flex items-center gap-2 py-1 text-sm ${!step.success ? 'text-red-600' : ''}">
|
|
${stepIcon}
|
|
<span class="font-medium">${step.stepName || step.stepId}</span>
|
|
<span class="text-gray-400">${step.duration}ms</span>
|
|
${step.error ? `<span class="text-red-500 text-xs ml-2">${step.error}</span>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
const modal = document.createElement('div');
|
|
modal.className = 'fixed inset-0 z-50 overflow-y-auto';
|
|
modal.innerHTML = `
|
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75" onclick="this.parentElement.remove()"></div>
|
|
<div class="flex min-h-full items-center justify-center p-4">
|
|
<div class="relative bg-white rounded-lg shadow-xl max-w-lg w-full p-6">
|
|
<div class="text-center mb-4">
|
|
${statusIcon}
|
|
<h3 class="mt-4 text-lg font-semibold text-gray-900">${data.message}</h3>
|
|
</div>
|
|
|
|
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<span class="text-gray-500">상태:</span>
|
|
<span class="ml-2 font-medium text-${statusColor}-600">${data.status}</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-500">소요 시간:</span>
|
|
<span class="ml-2 font-medium">${duration}</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-500">완료 스텝:</span>
|
|
<span class="ml-2 font-medium">${result.completedSteps || 0}/${result.totalSteps || 0}</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-500">실행 ID:</span>
|
|
<span class="ml-2 font-medium">#${data.run_id}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
${logSummary ? `
|
|
<div class="mb-4">
|
|
<h4 class="text-sm font-medium text-gray-700 mb-2">실행 로그</h4>
|
|
<div class="bg-gray-50 rounded-lg p-3 max-h-48 overflow-y-auto">
|
|
${logSummary}
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="flex gap-3">
|
|
<button onclick="this.closest('.fixed').remove(); location.reload();"
|
|
class="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition">
|
|
닫기
|
|
</button>
|
|
<a href="/dev-tools/flow-tester/runs/${data.run_id}"
|
|
class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-center rounded-lg transition">
|
|
상세 보기
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
}
|
|
|
|
function confirmDelete(id, name) {
|
|
if (!confirm(`"${name}" 플로우를 삭제하시겠습니까?`)) return;
|
|
|
|
fetch(`/dev-tools/flow-tester/${id}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
},
|
|
})
|
|
.then(response => {
|
|
if (response.ok) {
|
|
location.reload();
|
|
} else {
|
|
alert('삭제 실패');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
alert('오류 발생: ' + error.message);
|
|
});
|
|
}
|
|
</script>
|
|
@endpush
|