diff --git a/app/Http/Controllers/DevTools/FlowTesterController.php b/app/Http/Controllers/DevTools/FlowTesterController.php new file mode 100644 index 00000000..459d9b24 --- /dev/null +++ b/app/Http/Controllers/DevTools/FlowTesterController.php @@ -0,0 +1,239 @@ + 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')); + } +} diff --git a/app/Models/Admin/AdminApiFlow.php b/app/Models/Admin/AdminApiFlow.php new file mode 100644 index 00000000..6aaf801d --- /dev/null +++ b/app/Models/Admin/AdminApiFlow.php @@ -0,0 +1,100 @@ + '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'] ?? []); + } +} diff --git a/app/Models/Admin/AdminApiFlowRun.php b/app/Models/Admin/AdminApiFlowRun.php new file mode 100644 index 00000000..04732104 --- /dev/null +++ b/app/Models/Admin/AdminApiFlowRun.php @@ -0,0 +1,173 @@ + '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', + }; + } +} diff --git a/resources/views/dev-tools/flow-tester/create.blade.php b/resources/views/dev-tools/flow-tester/create.blade.php new file mode 100644 index 00000000..2adb287b --- /dev/null +++ b/resources/views/dev-tools/flow-tester/create.blade.php @@ -0,0 +1,192 @@ +@extends('layouts.app') + +@section('title', '새 플로우 생성') + +@section('content') + +
+
+ + + + + +

새 플로우 생성

+
+
+ +
+ @csrf + +
+ +
+
+

기본 정보

+ +
+
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + +
+ +
+ + +
+
+
+ + + +
+ + +
+
+
+

플로우 정의 (JSON)

+
+ + +
+
+ + + @error('flow_definition') +

{{ $message }}

+ @enderror +
+ + +
+ + 취소 + + +
+
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/dev-tools/flow-tester/edit.blade.php b/resources/views/dev-tools/flow-tester/edit.blade.php new file mode 100644 index 00000000..6ab5260a --- /dev/null +++ b/resources/views/dev-tools/flow-tester/edit.blade.php @@ -0,0 +1,243 @@ +@extends('layouts.app') + +@section('title', '플로우 편집') + +@section('content') + +
+
+ + + + + +

플로우 편집

+
+
+ +
+
+ + @if(session('success')) +
+ {{ session('success') }} +
+ @endif + +
+ @csrf + @method('PUT') + +
+ +
+
+

기본 정보

+ +
+
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + +
+ +
+ + +
+ +
+ is_active) ? 'checked' : '' }} + class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"> + +
+
+
+ + + + + +
+

정보

+
+
+
생성일
+
{{ $flow->created_at->format('Y-m-d H:i') }}
+
+
+
수정일
+
{{ $flow->updated_at->format('Y-m-d H:i') }}
+
+
+
스텝 수
+
{{ $flow->step_count }}개
+
+
+
+
+ + +
+
+
+

플로우 정의 (JSON)

+
+ + +
+
+ + + @error('flow_definition') +

{{ $message }}

+ @enderror +
+ + +
+ + 취소 + + +
+
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/dev-tools/flow-tester/history.blade.php b/resources/views/dev-tools/flow-tester/history.blade.php new file mode 100644 index 00000000..eadc68c8 --- /dev/null +++ b/resources/views/dev-tools/flow-tester/history.blade.php @@ -0,0 +1,85 @@ +@extends('layouts.app') + +@section('title', '실행 이력 - ' . $flow->name) + +@section('content') + +
+
+ + + + + +
+

실행 이력

+

{{ $flow->name }}

+
+
+
+ + +
+ @if($runs->isEmpty()) +
+ + + +

실행 이력이 없습니다

+

플로우를 실행하면 이력이 기록됩니다.

+
+ @else + + + + + + + + + + + + + @foreach($runs as $run) + + + + + + + + + @endforeach + +
ID상태진행소요시간실행일시액션
#{{ $run->id }} + + {{ $run->status_label }} + + + {{ $run->completed_steps }}/{{ $run->total_steps ?? '-' }} + + @if($run->duration_ms) + {{ number_format($run->duration_ms / 1000, 2) }}s + @else + - + @endif + + {{ $run->created_at->format('Y-m-d H:i:s') }} + + + 상세 보기 + +
+ + + @if($runs->hasPages()) +
+ {{ $runs->links() }} +
+ @endif + @endif +
+@endsection diff --git a/resources/views/dev-tools/flow-tester/index.blade.php b/resources/views/dev-tools/flow-tester/index.blade.php new file mode 100644 index 00000000..43daca7f --- /dev/null +++ b/resources/views/dev-tools/flow-tester/index.blade.php @@ -0,0 +1,218 @@ +@extends('layouts.app') + +@section('title', 'API 플로우 테스터') + +@section('content') + +
+

API 플로우 테스터

+ + + + + 새 플로우 + +
+ + +
+
+ +
+ +
+ + +
+ +
+ + + +
+
+ + +
+ @if($flows->isEmpty()) +
+ + + +

등록된 플로우가 없습니다

+

새 플로우를 생성하여 API 테스트를 시작하세요.

+
+ @else + + + + + + + + + + + + + @foreach($flows as $flow) + @php + $latestRun = $flow->runs->first(); + @endphp + + + + + + + + + @endforeach + +
이름카테고리스텝최근 실행상태액션
+
{{ $flow->name }}
+ @if($flow->description) +
{{ $flow->description }}
+ @endif +
+ @if($flow->category) + {{ $flow->category }} + @else + - + @endif + + {{ $flow->step_count }}개 + + @if($latestRun) + {{ $latestRun->created_at->diffForHumans() }} + @else + - + @endif + + @if($latestRun) + + {{ $latestRun->status_label }} + + @else + 대기 + @endif + +
+ + + + + + + + + + + + + + + +
+ @csrf + +
+ + +
+
+ + + @if($flows->hasPages()) +
+ {{ $flows->links() }} +
+ @endif + @endif +
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/dev-tools/flow-tester/run-detail.blade.php b/resources/views/dev-tools/flow-tester/run-detail.blade.php new file mode 100644 index 00000000..694e4396 --- /dev/null +++ b/resources/views/dev-tools/flow-tester/run-detail.blade.php @@ -0,0 +1,145 @@ +@extends('layouts.app') + +@section('title', '실행 상세 - #' . $run->id) + +@section('content') + +
+
+ + + + + +
+

실행 상세 #{{ $run->id }}

+

{{ $run->flow->name }}

+
+
+ + {{ $run->status_label }} + +
+ +
+ +
+
+

실행 정보

+ +
+
+
상태
+
+ + {{ $run->status_label }} + +
+
+
+
진행률
+
{{ $run->progress }}%
+
+
+
완료 스텝
+
{{ $run->completed_steps }}/{{ $run->total_steps ?? '-' }}
+
+ @if($run->failed_step) +
+
실패 스텝
+
Step {{ $run->failed_step }}
+
+ @endif +
+
소요시간
+
+ @if($run->duration_ms) + {{ number_format($run->duration_ms / 1000, 2) }}s + @else + - + @endif +
+
+
+
시작시간
+
{{ $run->started_at?->format('H:i:s') ?? '-' }}
+
+
+
완료시간
+
{{ $run->completed_at?->format('H:i:s') ?? '-' }}
+
+
+
실행일
+
{{ $run->created_at->format('Y-m-d') }}
+
+
+
+ + @if($run->error_message) +
+

에러 메시지

+

{{ $run->error_message }}

+
+ @endif +
+ + +
+
+

실행 로그

+ + @if($run->execution_log) +
+ @foreach($run->execution_log as $index => $log) +
+
+
+ @if($log['success'] ?? false) + + + + @else + + + + @endif + {{ $log['step_id'] ?? 'Step '.($index + 1) }} + {{ $log['name'] ?? '' }} +
+ {{ $log['duration'] ?? '' }}ms +
+ + @if(isset($log['request'])) +
+ Request: + {{ $log['request']['method'] ?? '' }} {{ $log['request']['endpoint'] ?? '' }} +
+ @endif + + @if(isset($log['response'])) +
+ Response: + + {{ $log['response']['status'] ?? '-' }} + +
+ @endif + + @if(isset($log['error'])) +
+ {{ $log['error'] }} +
+ @endif +
+ @endforeach +
+ @else +
+

실행 로그가 없습니다.

+
+ @endif +
+
+
+@endsection diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index aedad6ae..2b8b65f0 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -238,6 +238,28 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover: + + +
  • + + +
  • @@ -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); diff --git a/routes/web.php b/routes/web.php index d661a1a4..b9d8d399 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); + }); + }); });