feat: API Flow Tester 기능 기반 구조 추가
- 모델: AdminApiFlow, AdminApiFlowRun - 컨트롤러: FlowTesterController - 뷰: index, create, edit, history, run-detail - 사이드바 메뉴에 "개발 도구" 그룹 추가 - 라우트 설정
This commit is contained in:
239
app/Http/Controllers/DevTools/FlowTesterController.php
Normal file
239
app/Http/Controllers/DevTools/FlowTesterController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
100
app/Models/Admin/AdminApiFlow.php
Normal file
100
app/Models/Admin/AdminApiFlow.php
Normal 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'] ?? []);
|
||||
}
|
||||
}
|
||||
173
app/Models/Admin/AdminApiFlowRun.php
Normal file
173
app/Models/Admin/AdminApiFlowRun.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
192
resources/views/dev-tools/flow-tester/create.blade.php
Normal file
192
resources/views/dev-tools/flow-tester/create.blade.php
Normal 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
|
||||
243
resources/views/dev-tools/flow-tester/edit.blade.php
Normal file
243
resources/views/dev-tools/flow-tester/edit.blade.php
Normal 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
|
||||
85
resources/views/dev-tools/flow-tester/history.blade.php
Normal file
85
resources/views/dev-tools/flow-tester/history.blade.php
Normal 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
|
||||
218
resources/views/dev-tools/flow-tester/index.blade.php
Normal file
218
resources/views/dev-tools/flow-tester/index.blade.php
Normal 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
|
||||
145
resources/views/dev-tools/flow-tester/run-detail.blade.php
Normal file
145
resources/views/dev-tools/flow-tester/run-detail.blade.php
Normal 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
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user