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') + +
{{ $flow->name }}
+실행 이력이 없습니다
+플로우를 실행하면 이력이 기록됩니다.
+| 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') }} + | ++ + 상세 보기 + + | +
등록된 플로우가 없습니다
+새 플로우를 생성하여 API 테스트를 시작하세요.
+| 이름 | +카테고리 | +스텝 | +최근 실행 | +상태 | +액션 | +
|---|---|---|---|---|---|
|
+ {{ $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 + | ++ + | +
{{ $run->flow->name }}
+{{ $run->error_message }}
+{{ $log['request']['method'] ?? '' }} {{ $log['request']['endpoint'] ?? '' }}
+ 실행 로그가 없습니다.
+