feat: [flow-tester] 플로우 실행 기능 완성

- FlowTesterController에 FlowExecutor 연결
- 실행 결과를 AdminApiFlowRun에 저장
- 실행 중 로딩 인디케이터 추가
- 결과 모달로 상세 실행 결과 표시
  - 상태, 소요 시간, 완료 스텝 표시
  - 각 스텝별 성공/실패 로그 표시
  - 상세 보기 링크 제공
This commit is contained in:
2025-11-27 20:25:32 +09:00
parent 3dcad4e00c
commit 28ca71a17e
2 changed files with 162 additions and 13 deletions

View File

@@ -5,6 +5,7 @@
use App\Http\Controllers\Controller;
use App\Models\Admin\AdminApiFlow;
use App\Models\Admin\AdminApiFlowRun;
use App\Services\FlowTester\FlowExecutor;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -216,14 +217,57 @@ public function run(int $id)
'executed_by' => auth()->id(),
]);
// TODO: 실제 플로우 실행 로직은 Service 클래스로 분리
// 현재는 스캐폴딩만 제공
try {
// FlowExecutor로 실제 실행
$executor = new FlowExecutor();
$result = $executor->execute($flow->flow_definition);
return response()->json([
'success' => true,
'run_id' => $run->id,
'message' => '플로우 실행이 시작되었습니다.',
]);
// 실행 결과 저장
$run->update([
'status' => $result['status'],
'completed_at' => now(),
'duration_ms' => $result['duration'],
'completed_steps' => $result['completedSteps'],
'failed_step' => $result['failedStep'],
'execution_log' => $result['executionLog'],
'error_message' => $result['errorMessage'],
]);
return response()->json([
'success' => $result['status'] === 'SUCCESS',
'run_id' => $run->id,
'status' => $result['status'],
'message' => $this->getResultMessage($result),
'result' => $result,
]);
} catch (\Exception $e) {
// 예외 발생 시 실패 처리
$run->update([
'status' => AdminApiFlowRun::STATUS_FAILED,
'completed_at' => now(),
'error_message' => $e->getMessage(),
]);
return response()->json([
'success' => false,
'run_id' => $run->id,
'status' => 'FAILED',
'message' => '실행 오류: '.$e->getMessage(),
], 500);
}
}
/**
* 실행 결과 메시지 생성
*/
private function getResultMessage(array $result): string
{
return match ($result['status']) {
'SUCCESS' => "플로우 실행 완료! ({$result['completedSteps']}/{$result['totalSteps']} 스텝 성공)",
'FAILED' => "플로우 실행 실패: ".($result['errorMessage'] ?? '알 수 없는 오류'),
'PARTIAL' => "부분 성공: {$result['completedSteps']}/{$result['totalSteps']} 스텝 완료",
default => "실행 완료 (상태: {$result['status']})",
};
}
/**

View File

@@ -196,6 +196,15 @@ class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition"
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-4 h-4 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: {
@@ -205,18 +214,114 @@ function runFlow(id) {
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
location.reload();
} else {
alert('실행 실패: ' + (data.message || '알 수 없는 오류'));
}
// 버튼 복원
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;