feat: API Flow Tester 기능 기반 구조 추가

- 모델: AdminApiFlow, AdminApiFlowRun
- 컨트롤러: FlowTesterController
- 뷰: index, create, edit, history, run-detail
- 사이드바 메뉴에 "개발 도구" 그룹 추가
- 라우트 설정
This commit is contained in:
2025-11-27 19:02:18 +09:00
parent 604aa256f6
commit ff943ab728
10 changed files with 1442 additions and 1 deletions

View File

@@ -0,0 +1,239 @@
<?php
namespace App\Http\Controllers\DevTools;
use App\Http\Controllers\Controller;
use App\Models\Admin\AdminApiFlow;
use App\Models\Admin\AdminApiFlowRun;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* API Flow Tester Controller
*
* API 플로우 테스트 도구 컨트롤러
*/
class FlowTesterController extends Controller
{
/**
* 플로우 목록
*/
public function index(): View
{
$flows = AdminApiFlow::with(['runs' => fn ($q) => $q->latest()->limit(1)])
->orderByDesc('updated_at')
->paginate(20);
return view('dev-tools.flow-tester.index', compact('flows'));
}
/**
* 플로우 생성 폼
*/
public function create(): View
{
return view('dev-tools.flow-tester.create');
}
/**
* 플로우 저장
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string',
'category' => 'nullable|string|max:50',
'flow_definition' => 'required|json',
]);
$validated['flow_definition'] = json_decode($validated['flow_definition'], true);
$validated['created_by'] = auth()->id();
$flow = AdminApiFlow::create($validated);
return redirect()
->route('dev-tools.flow-tester.edit', $flow->id)
->with('success', '플로우가 생성되었습니다.');
}
/**
* 플로우 편집 폼
*/
public function edit(int $id): View
{
$flow = AdminApiFlow::findOrFail($id);
return view('dev-tools.flow-tester.edit', compact('flow'));
}
/**
* 플로우 수정
*/
public function update(Request $request, int $id)
{
$flow = AdminApiFlow::findOrFail($id);
$validated = $request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string',
'category' => 'nullable|string|max:50',
'flow_definition' => 'required|json',
'is_active' => 'boolean',
]);
$validated['flow_definition'] = json_decode($validated['flow_definition'], true);
$validated['updated_by'] = auth()->id();
$flow->update($validated);
return redirect()
->route('dev-tools.flow-tester.edit', $flow->id)
->with('success', '플로우가 수정되었습니다.');
}
/**
* 플로우 삭제
*/
public function destroy(int $id)
{
$flow = AdminApiFlow::findOrFail($id);
$flow->delete();
return redirect()
->route('dev-tools.flow-tester.index')
->with('success', '플로우가 삭제되었습니다.');
}
/**
* 플로우 복제
*/
public function clone(int $id)
{
$original = AdminApiFlow::findOrFail($id);
$clone = $original->replicate();
$clone->name = $original->name.' (복사본)';
$clone->created_by = auth()->id();
$clone->updated_by = null;
$clone->save();
return redirect()
->route('dev-tools.flow-tester.edit', $clone->id)
->with('success', '플로우가 복제되었습니다.');
}
/**
* JSON 유효성 검사 (HTMX)
*/
public function validateJson(Request $request)
{
$json = $request->input('flow_definition', '');
try {
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
// 기본 구조 검증
$errors = [];
if (! isset($data['steps']) || ! is_array($data['steps'])) {
$errors[] = 'steps 배열이 필요합니다.';
} else {
foreach ($data['steps'] as $i => $step) {
if (empty($step['id'])) {
$errors[] = "steps[{$i}]: id가 필요합니다.";
}
if (empty($step['method'])) {
$errors[] = "steps[{$i}]: method가 필요합니다.";
}
if (empty($step['endpoint'])) {
$errors[] = "steps[{$i}]: endpoint가 필요합니다.";
}
}
}
if (! empty($errors)) {
return response()->json([
'valid' => false,
'errors' => $errors,
]);
}
return response()->json([
'valid' => true,
'stepCount' => count($data['steps'] ?? []),
]);
} catch (\JsonException $e) {
return response()->json([
'valid' => false,
'errors' => ['JSON 파싱 오류: '.$e->getMessage()],
]);
}
}
/**
* 플로우 실행
*/
public function run(int $id)
{
$flow = AdminApiFlow::findOrFail($id);
// 실행 기록 생성
$run = AdminApiFlowRun::create([
'flow_id' => $flow->id,
'status' => AdminApiFlowRun::STATUS_RUNNING,
'started_at' => now(),
'total_steps' => $flow->step_count,
'executed_by' => auth()->id(),
]);
// TODO: 실제 플로우 실행 로직은 Service 클래스로 분리
// 현재는 스캐폴딩만 제공
return response()->json([
'success' => true,
'run_id' => $run->id,
'message' => '플로우 실행이 시작되었습니다.',
]);
}
/**
* 실행 상태 조회 (Polling)
*/
public function runStatus(int $runId)
{
$run = AdminApiFlowRun::findOrFail($runId);
return response()->json([
'status' => $run->status,
'progress' => $run->progress,
'completed_steps' => $run->completed_steps,
'total_steps' => $run->total_steps,
'is_completed' => $run->isCompleted(),
'execution_log' => $run->execution_log,
]);
}
/**
* 실행 이력 목록
*/
public function history(int $id): View
{
$flow = AdminApiFlow::findOrFail($id);
$runs = $flow->runs()
->orderByDesc('created_at')
->paginate(20);
return view('dev-tools.flow-tester.history', compact('flow', 'runs'));
}
/**
* 실행 상세 보기
*/
public function runDetail(int $runId): View
{
$run = AdminApiFlowRun::with('flow')->findOrFail($runId);
return view('dev-tools.flow-tester.run-detail', compact('run'));
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Models\Admin;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* API Flow Tester - 플로우 정의 모델
*
* @property int $id
* @property string $name
* @property string|null $description
* @property string|null $category
* @property array $flow_definition
* @property bool $is_active
* @property int|null $created_by
* @property int|null $updated_by
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class AdminApiFlow extends Model
{
protected $table = 'admin_api_flows';
protected $fillable = [
'name',
'description',
'category',
'flow_definition',
'is_active',
'created_by',
'updated_by',
];
protected $casts = [
'flow_definition' => 'array',
'is_active' => 'boolean',
'created_by' => 'integer',
'updated_by' => 'integer',
];
/**
* 활성화된 플로우만 조회
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* 카테고리로 필터링
*/
public function scopeCategory($query, string $category)
{
return $query->where('category', $category);
}
/**
* 관계: 실행 이력
*/
public function runs(): HasMany
{
return $this->hasMany(AdminApiFlowRun::class, 'flow_id');
}
/**
* 관계: 생성자
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 관계: 수정자
*/
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
/**
* 최근 실행 결과 조회
*/
public function latestRun(): ?AdminApiFlowRun
{
return $this->runs()->latest('created_at')->first();
}
/**
* 플로우 정의에서 스텝 수 반환
*/
public function getStepCountAttribute(): int
{
return count($this->flow_definition['steps'] ?? []);
}
}

View File

@@ -0,0 +1,173 @@
<?php
namespace App\Models\Admin;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* API Flow Tester - 실행 이력 모델
*
* @property int $id
* @property int $flow_id
* @property string $status
* @property \Carbon\Carbon|null $started_at
* @property \Carbon\Carbon|null $completed_at
* @property int|null $duration_ms
* @property int|null $total_steps
* @property int $completed_steps
* @property int|null $failed_step
* @property array|null $execution_log
* @property array|null $input_variables
* @property string|null $error_message
* @property int|null $executed_by
* @property \Carbon\Carbon $created_at
*/
class AdminApiFlowRun extends Model
{
// 상태 상수
public const STATUS_PENDING = 'PENDING';
public const STATUS_RUNNING = 'RUNNING';
public const STATUS_SUCCESS = 'SUCCESS';
public const STATUS_FAILED = 'FAILED';
public const STATUS_PARTIAL = 'PARTIAL';
protected $table = 'admin_api_flow_runs';
public $timestamps = false; // created_at만 사용
protected $fillable = [
'flow_id',
'status',
'started_at',
'completed_at',
'duration_ms',
'total_steps',
'completed_steps',
'failed_step',
'execution_log',
'input_variables',
'error_message',
'executed_by',
];
protected $casts = [
'flow_id' => 'integer',
'started_at' => 'datetime',
'completed_at' => 'datetime',
'duration_ms' => 'integer',
'total_steps' => 'integer',
'completed_steps' => 'integer',
'failed_step' => 'integer',
'execution_log' => 'array',
'input_variables' => 'array',
'executed_by' => 'integer',
];
/**
* 상태별 필터링
*/
public function scopeStatus($query, string $status)
{
return $query->where('status', $status);
}
/**
* 성공한 실행만 조회
*/
public function scopeSuccessful($query)
{
return $query->where('status', self::STATUS_SUCCESS);
}
/**
* 실패한 실행만 조회
*/
public function scopeFailed($query)
{
return $query->where('status', self::STATUS_FAILED);
}
/**
* 관계: 플로우
*/
public function flow(): BelongsTo
{
return $this->belongsTo(AdminApiFlow::class, 'flow_id');
}
/**
* 관계: 실행자
*/
public function executor(): BelongsTo
{
return $this->belongsTo(User::class, 'executed_by');
}
/**
* 실행 중인지 확인
*/
public function isRunning(): bool
{
return $this->status === self::STATUS_RUNNING;
}
/**
* 완료되었는지 확인 (성공/실패/부분 포함)
*/
public function isCompleted(): bool
{
return in_array($this->status, [
self::STATUS_SUCCESS,
self::STATUS_FAILED,
self::STATUS_PARTIAL,
]);
}
/**
* 진행률 반환 (0-100)
*/
public function getProgressAttribute(): int
{
if (! $this->total_steps || $this->total_steps === 0) {
return 0;
}
return (int) round(($this->completed_steps / $this->total_steps) * 100);
}
/**
* 상태 라벨 반환 (한글)
*/
public function getStatusLabelAttribute(): string
{
return match ($this->status) {
self::STATUS_PENDING => '대기 중',
self::STATUS_RUNNING => '실행 중',
self::STATUS_SUCCESS => '성공',
self::STATUS_FAILED => '실패',
self::STATUS_PARTIAL => '부분 성공',
default => $this->status,
};
}
/**
* 상태 색상 반환 (Tailwind CSS class)
*/
public function getStatusColorAttribute(): string
{
return match ($this->status) {
self::STATUS_PENDING => 'badge-ghost',
self::STATUS_RUNNING => 'badge-info',
self::STATUS_SUCCESS => 'badge-success',
self::STATUS_FAILED => 'badge-error',
self::STATUS_PARTIAL => 'badge-warning',
default => 'badge-ghost',
};
}
}

View File

@@ -0,0 +1,192 @@
@extends('layouts.app')
@section('title', '새 플로우 생성')
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<div class="flex items-center gap-4">
<a href="{{ route('dev-tools.flow-tester.index') }}"
class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition">
<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="M15 19l-7-7 7-7" />
</svg>
</a>
<h1 class="text-2xl font-bold text-gray-800"> 플로우 생성</h1>
</div>
</div>
<form action="{{ route('dev-tools.flow-tester.store') }}" method="POST">
@csrf
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 기본 정보 -->
<div class="lg:col-span-1">
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">기본 정보</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">이름 *</label>
<input type="text"
name="name"
value="{{ old('name') }}"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 @error('name') border-red-500 @enderror"
placeholder="플로우 이름">
@error('name')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">카테고리</label>
<input type="text"
name="category"
value="{{ old('category') }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="item-master, auth, bom 등">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">설명</label>
<textarea name="description"
rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="플로우에 대한 설명">{{ old('description') }}</textarea>
</div>
</div>
</div>
<!-- 검증 결과 -->
<div id="validation-result" class="mt-4 bg-white rounded-lg shadow-sm p-4 hidden">
<div id="validation-success" class="hidden text-green-600 flex items-center gap-2">
<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="M5 13l4 4L19 7" />
</svg>
<span>JSON 유효 - <span id="step-count">0</span> 스텝</span>
</div>
<div id="validation-error" class="hidden text-red-600">
<div class="flex items-center gap-2 mb-2">
<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>
<span>검증 실패</span>
</div>
<ul id="error-list" class="text-sm list-disc list-inside"></ul>
</div>
</div>
</div>
<!-- JSON 에디터 -->
<div class="lg:col-span-2">
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800">플로우 정의 (JSON)</h2>
<div class="flex gap-2">
<button type="button"
onclick="validateJson()"
class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition">
검증
</button>
<button type="button"
onclick="formatJson()"
class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition">
포맷
</button>
</div>
</div>
<textarea name="flow_definition"
id="flow-definition"
rows="30"
required
class="w-full px-4 py-3 font-mono text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 @error('flow_definition') border-red-500 @enderror"
placeholder='{"version": "1.0", "steps": [...]}'>{{ old('flow_definition', '{
"version": "1.0",
"config": {
"baseUrl": "https://sam.kr/api/v1",
"timeout": 30000
},
"variables": {},
"steps": [
{
"id": "step1",
"name": "첫 번째 단계",
"method": "GET",
"endpoint": "/health",
"expect": {
"status": [200]
}
}
]
}') }}</textarea>
@error('flow_definition')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<!-- 제출 버튼 -->
<div class="mt-6 flex justify-end gap-4">
<a href="{{ route('dev-tools.flow-tester.index') }}"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition">
취소
</a>
<button type="submit"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
저장
</button>
</div>
</div>
</div>
</form>
@endsection
@push('scripts')
<script>
function formatJson() {
const textarea = document.getElementById('flow-definition');
try {
const json = JSON.parse(textarea.value);
textarea.value = JSON.stringify(json, null, 2);
} catch (e) {
alert('JSON 파싱 오류: ' + e.message);
}
}
function validateJson() {
const textarea = document.getElementById('flow-definition');
const resultDiv = document.getElementById('validation-result');
const successDiv = document.getElementById('validation-success');
const errorDiv = document.getElementById('validation-error');
const errorList = document.getElementById('error-list');
const stepCount = document.getElementById('step-count');
fetch('{{ route("dev-tools.flow-tester.validate-json") }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Content-Type': 'application/json',
},
body: JSON.stringify({ flow_definition: textarea.value }),
})
.then(response => response.json())
.then(data => {
resultDiv.classList.remove('hidden');
if (data.valid) {
successDiv.classList.remove('hidden');
errorDiv.classList.add('hidden');
stepCount.textContent = data.stepCount;
} else {
successDiv.classList.add('hidden');
errorDiv.classList.remove('hidden');
errorList.innerHTML = data.errors.map(e => `<li>${e}</li>`).join('');
}
})
.catch(error => {
alert('검증 오류: ' + error.message);
});
}
</script>
@endpush

View File

@@ -0,0 +1,243 @@
@extends('layouts.app')
@section('title', '플로우 편집')
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<div class="flex items-center gap-4">
<a href="{{ route('dev-tools.flow-tester.index') }}"
class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition">
<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="M15 19l-7-7 7-7" />
</svg>
</a>
<h1 class="text-2xl font-bold text-gray-800">플로우 편집</h1>
</div>
<div class="flex gap-2">
<button onclick="runFlow({{ $flow->id }})"
class="bg-green-600 hover:bg-green-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="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>
</div>
</div>
@if(session('success'))
<div class="mb-6 px-4 py-3 bg-green-100 border border-green-400 text-green-700 rounded-lg">
{{ session('success') }}
</div>
@endif
<form action="{{ route('dev-tools.flow-tester.update', $flow->id) }}" method="POST">
@csrf
@method('PUT')
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 기본 정보 -->
<div class="lg:col-span-1">
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">기본 정보</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">이름 *</label>
<input type="text"
name="name"
value="{{ old('name', $flow->name) }}"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 @error('name') border-red-500 @enderror"
placeholder="플로우 이름">
@error('name')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">카테고리</label>
<input type="text"
name="category"
value="{{ old('category', $flow->category) }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="item-master, auth, bom 등">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">설명</label>
<textarea name="description"
rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="플로우에 대한 설명">{{ old('description', $flow->description) }}</textarea>
</div>
<div class="flex items-center gap-2">
<input type="checkbox"
name="is_active"
value="1"
id="is_active"
{{ old('is_active', $flow->is_active) ? 'checked' : '' }}
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
<label for="is_active" class="text-sm text-gray-700">활성화</label>
</div>
</div>
</div>
<!-- 검증 결과 -->
<div id="validation-result" class="mt-4 bg-white rounded-lg shadow-sm p-4 hidden">
<div id="validation-success" class="hidden text-green-600 flex items-center gap-2">
<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="M5 13l4 4L19 7" />
</svg>
<span>JSON 유효 - <span id="step-count">0</span> 스텝</span>
</div>
<div id="validation-error" class="hidden text-red-600">
<div class="flex items-center gap-2 mb-2">
<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>
<span>검증 실패</span>
</div>
<ul id="error-list" class="text-sm list-disc list-inside"></ul>
</div>
</div>
<!-- 메타 정보 -->
<div class="mt-4 bg-white rounded-lg shadow-sm p-4">
<h3 class="text-sm font-medium text-gray-500 mb-2">정보</h3>
<dl class="text-sm space-y-1">
<div class="flex justify-between">
<dt class="text-gray-500">생성일</dt>
<dd class="text-gray-900">{{ $flow->created_at->format('Y-m-d H:i') }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">수정일</dt>
<dd class="text-gray-900">{{ $flow->updated_at->format('Y-m-d H:i') }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">스텝 </dt>
<dd class="text-gray-900">{{ $flow->step_count }}</dd>
</div>
</dl>
</div>
</div>
<!-- JSON 에디터 -->
<div class="lg:col-span-2">
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800">플로우 정의 (JSON)</h2>
<div class="flex gap-2">
<button type="button"
onclick="validateJson()"
class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition">
검증
</button>
<button type="button"
onclick="formatJson()"
class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition">
포맷
</button>
</div>
</div>
<textarea name="flow_definition"
id="flow-definition"
rows="30"
required
class="w-full px-4 py-3 font-mono text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 @error('flow_definition') border-red-500 @enderror">{{ old('flow_definition', json_encode($flow->flow_definition, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) }}</textarea>
@error('flow_definition')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<!-- 제출 버튼 -->
<div class="mt-6 flex justify-end gap-4">
<a href="{{ route('dev-tools.flow-tester.index') }}"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition">
취소
</a>
<button type="submit"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
저장
</button>
</div>
</div>
</div>
</form>
@endsection
@push('scripts')
<script>
function formatJson() {
const textarea = document.getElementById('flow-definition');
try {
const json = JSON.parse(textarea.value);
textarea.value = JSON.stringify(json, null, 2);
} catch (e) {
alert('JSON 파싱 오류: ' + e.message);
}
}
function validateJson() {
const textarea = document.getElementById('flow-definition');
const resultDiv = document.getElementById('validation-result');
const successDiv = document.getElementById('validation-success');
const errorDiv = document.getElementById('validation-error');
const errorList = document.getElementById('error-list');
const stepCount = document.getElementById('step-count');
fetch('{{ route("dev-tools.flow-tester.validate-json") }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Content-Type': 'application/json',
},
body: JSON.stringify({ flow_definition: textarea.value }),
})
.then(response => response.json())
.then(data => {
resultDiv.classList.remove('hidden');
if (data.valid) {
successDiv.classList.remove('hidden');
errorDiv.classList.add('hidden');
stepCount.textContent = data.stepCount;
} else {
successDiv.classList.add('hidden');
errorDiv.classList.remove('hidden');
errorList.innerHTML = data.errors.map(e => `<li>${e}</li>`).join('');
}
})
.catch(error => {
alert('검증 오류: ' + error.message);
});
}
function runFlow(id) {
if (!confirm('이 플로우를 실행하시겠습니까?')) return;
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 => {
if (data.success) {
alert(data.message);
location.reload();
} else {
alert('실행 실패: ' + (data.message || '알 수 없는 오류'));
}
})
.catch(error => {
alert('오류 발생: ' + error.message);
});
}
</script>
@endpush

View File

@@ -0,0 +1,85 @@
@extends('layouts.app')
@section('title', '실행 이력 - ' . $flow->name)
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<div class="flex items-center gap-4">
<a href="{{ route('dev-tools.flow-tester.edit', $flow->id) }}"
class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition">
<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="M15 19l-7-7 7-7" />
</svg>
</a>
<div>
<h1 class="text-2xl font-bold text-gray-800">실행 이력</h1>
<p class="text-sm text-gray-500">{{ $flow->name }}</p>
</div>
</div>
</div>
<!-- 이력 목록 -->
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
@if($runs->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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-lg font-medium">실행 이력이 없습니다</p>
<p class="text-sm mt-1">플로우를 실행하면 이력이 기록됩니다.</p>
</div>
@else
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">상태</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">진행</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">소요시간</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">실행일시</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">액션</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($runs as $run)
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 text-sm text-gray-900">#{{ $run->id }}</td>
<td class="px-6 py-4">
<span class="px-2 py-1 text-xs font-medium rounded {{ $run->status_color }}">
{{ $run->status_label }}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
{{ $run->completed_steps }}/{{ $run->total_steps ?? '-' }}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
@if($run->duration_ms)
{{ number_format($run->duration_ms / 1000, 2) }}s
@else
-
@endif
</td>
<td class="px-6 py-4 text-sm text-gray-500">
{{ $run->created_at->format('Y-m-d H:i:s') }}
</td>
<td class="px-6 py-4 text-right">
<a href="{{ route('dev-tools.flow-tester.run-detail', $run->id) }}"
class="text-blue-600 hover:text-blue-800 text-sm">
상세 보기
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
<!-- 페이지네이션 -->
@if($runs->hasPages())
<div class="px-6 py-4 border-t border-gray-200">
{{ $runs->links() }}
</div>
@endif
@endif
</div>
@endsection

View File

@@ -0,0 +1,218 @@
@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>
<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 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-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">이름</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">카테고리</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">스텝</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">최근 실행</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">상태</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">액션</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($flows as $flow)
@php
$latestRun = $flow->runs->first();
@endphp
<tr class="hover:bg-gray-50">
<td class="px-6 py-4">
<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
</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">
<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-4 h-4" 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-4 h-4" 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-4 h-4" 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-4 h-4" 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-4 h-4" 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>
@endforeach
</tbody>
</table>
<!-- 페이지네이션 -->
@if($flows->hasPages())
<div class="px-6 py-4 border-t border-gray-200">
{{ $flows->links() }}
</div>
@endif
@endif
</div>
@endsection
@push('scripts')
<script>
function runFlow(id) {
if (!confirm('이 플로우를 실행하시겠습니까?')) return;
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 => {
if (data.success) {
alert(data.message);
location.reload();
} else {
alert('실행 실패: ' + (data.message || '알 수 없는 오류'));
}
})
.catch(error => {
alert('오류 발생: ' + error.message);
});
}
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

View File

@@ -0,0 +1,145 @@
@extends('layouts.app')
@section('title', '실행 상세 - #' . $run->id)
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<div class="flex items-center gap-4">
<a href="{{ route('dev-tools.flow-tester.history', $run->flow_id) }}"
class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition">
<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="M15 19l-7-7 7-7" />
</svg>
</a>
<div>
<h1 class="text-2xl font-bold text-gray-800">실행 상세 #{{ $run->id }}</h1>
<p class="text-sm text-gray-500">{{ $run->flow->name }}</p>
</div>
</div>
<span class="px-3 py-1 text-sm font-medium rounded {{ $run->status_color }}">
{{ $run->status_label }}
</span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 요약 정보 -->
<div class="lg:col-span-1">
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">실행 정보</h2>
<dl class="space-y-3">
<div class="flex justify-between">
<dt class="text-gray-500">상태</dt>
<dd>
<span class="px-2 py-1 text-xs font-medium rounded {{ $run->status_color }}">
{{ $run->status_label }}
</span>
</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">진행률</dt>
<dd class="text-gray-900">{{ $run->progress }}%</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">완료 스텝</dt>
<dd class="text-gray-900">{{ $run->completed_steps }}/{{ $run->total_steps ?? '-' }}</dd>
</div>
@if($run->failed_step)
<div class="flex justify-between">
<dt class="text-gray-500">실패 스텝</dt>
<dd class="text-red-600">Step {{ $run->failed_step }}</dd>
</div>
@endif
<div class="flex justify-between">
<dt class="text-gray-500">소요시간</dt>
<dd class="text-gray-900">
@if($run->duration_ms)
{{ number_format($run->duration_ms / 1000, 2) }}s
@else
-
@endif
</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">시작시간</dt>
<dd class="text-gray-900">{{ $run->started_at?->format('H:i:s') ?? '-' }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">완료시간</dt>
<dd class="text-gray-900">{{ $run->completed_at?->format('H:i:s') ?? '-' }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">실행일</dt>
<dd class="text-gray-900">{{ $run->created_at->format('Y-m-d') }}</dd>
</div>
</dl>
</div>
@if($run->error_message)
<div class="mt-4 bg-red-50 border border-red-200 rounded-lg p-4">
<h3 class="text-sm font-medium text-red-800 mb-2">에러 메시지</h3>
<p class="text-sm text-red-700">{{ $run->error_message }}</p>
</div>
@endif
</div>
<!-- 실행 로그 -->
<div class="lg:col-span-2">
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">실행 로그</h2>
@if($run->execution_log)
<div class="space-y-4">
@foreach($run->execution_log as $index => $log)
<div class="border rounded-lg p-4 {{ ($log['success'] ?? false) ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50' }}">
<div class="flex justify-between items-start mb-2">
<div class="flex items-center gap-2">
@if($log['success'] ?? false)
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
@else
<svg class="w-5 h-5 text-red-600" 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>
@endif
<span class="font-medium">{{ $log['step_id'] ?? 'Step '.($index + 1) }}</span>
<span class="text-gray-500">{{ $log['name'] ?? '' }}</span>
</div>
<span class="text-sm text-gray-500">{{ $log['duration'] ?? '' }}ms</span>
</div>
@if(isset($log['request']))
<div class="text-sm mb-2">
<span class="font-medium text-gray-600">Request:</span>
<code class="ml-2 text-gray-800">{{ $log['request']['method'] ?? '' }} {{ $log['request']['endpoint'] ?? '' }}</code>
</div>
@endif
@if(isset($log['response']))
<div class="text-sm">
<span class="font-medium text-gray-600">Response:</span>
<span class="ml-2 {{ ($log['response']['status'] ?? 0) < 400 ? 'text-green-600' : 'text-red-600' }}">
{{ $log['response']['status'] ?? '-' }}
</span>
</div>
@endif
@if(isset($log['error']))
<div class="mt-2 text-sm text-red-600">
{{ $log['error'] }}
</div>
@endif
</div>
@endforeach
</div>
@else
<div class="text-center py-8 text-gray-500">
<p>실행 로그가 없습니다.</p>
</div>
@endif
</div>
</div>
</div>
@endsection

View File

@@ -238,6 +238,28 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:
</li>
</ul>
</li>
<!-- 개발 도구 그룹 -->
<li class="pt-4 pb-1 border-t border-gray-200 mt-2">
<button onclick="toggleGroup('dev-tools-group')" class="w-full flex items-center justify-between px-3 py-2 text-xs font-bold text-gray-600 uppercase tracking-wider hover:bg-gray-50 rounded">
<span>개발 도구</span>
<svg id="dev-tools-group-icon" class="w-3 h-3 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<ul id="dev-tools-group" class="space-y-1 mt-1">
<li>
<a href="{{ route('dev-tools.flow-tester.index') }}"
class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100 {{ request()->routeIs('dev-tools.flow-tester.*') ? 'bg-primary text-white hover:bg-primary' : '' }}"
style="padding-left: 2rem;">
<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 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="font-medium">API 플로우 테스터</span>
</a>
</li>
</ul>
</li>
</ul>
</nav>
@@ -278,7 +300,7 @@ function toggleGroup(groupId) {
// 페이지 로드 시 저장된 상태 복원
document.addEventListener('DOMContentLoaded', function() {
['system-group', 'permission-group', 'production-group', 'system-settings-group'].forEach(function(groupId) {
['system-group', 'permission-group', 'production-group', 'system-settings-group', 'dev-tools-group'].forEach(function(groupId) {
const state = localStorage.getItem('sidebar-' + groupId);
if (state === 'closed') {
const group = document.getElementById(groupId);

View File

@@ -3,6 +3,7 @@
use App\Http\Controllers\ArchivedRecordController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\DepartmentController;
use App\Http\Controllers\DevTools\FlowTesterController;
use App\Http\Controllers\MenuController;
use App\Http\Controllers\PermissionController;
use App\Http\Controllers\RoleController;
@@ -103,4 +104,27 @@
Route::get('/', function () {
return redirect()->route('dashboard');
});
/*
|--------------------------------------------------------------------------
| 개발 도구 Routes
|--------------------------------------------------------------------------
*/
Route::prefix('dev-tools')->name('dev-tools.')->group(function () {
// API 플로우 테스터
Route::prefix('flow-tester')->name('flow-tester.')->group(function () {
Route::get('/', [FlowTesterController::class, 'index'])->name('index');
Route::get('/create', [FlowTesterController::class, 'create'])->name('create');
Route::post('/', [FlowTesterController::class, 'store'])->name('store');
Route::get('/{id}', [FlowTesterController::class, 'edit'])->name('edit');
Route::put('/{id}', [FlowTesterController::class, 'update'])->name('update');
Route::delete('/{id}', [FlowTesterController::class, 'destroy'])->name('destroy');
Route::post('/{id}/clone', [FlowTesterController::class, 'clone'])->name('clone');
Route::post('/validate-json', [FlowTesterController::class, 'validateJson'])->name('validate-json');
Route::post('/{id}/run', [FlowTesterController::class, 'run'])->name('run');
Route::get('/runs/{runId}/status', [FlowTesterController::class, 'runStatus'])->name('run-status');
Route::get('/{id}/history', [FlowTesterController::class, 'history'])->name('history');
Route::get('/runs/{runId}', [FlowTesterController::class, 'runDetail'])->name('run-detail');
});
});
});