feat: [flow-tester] 의존성 검사, Docker 지원, 인증 자동 주입 기능 추가
FlowExecutor 개선:
- 의존성 스텝 실패 시 후속 스텝 자동 스킵 로직 추가
- Docker 환경 자동 감지 및 내부 URL 변환 (api.sam.kr → nginx)
- SSL 검증 비활성화 및 Host 헤더 설정 지원
- .env에서 API Key/Bearer Token 자동 주입
VariableBinder 개선:
- 임의 stepId 패턴 지원 (page_create_1.tempPageId 등)
- {{$env.VAR_NAME}} 환경변수 플레이스홀더 추가
- {{$auth.token}}, {{$auth.apiKey}} 인증 플레이스홀더 추가
UI 개선:
- SKIPPED 상태 스타일링 (노란색 배경/테두리)
- 행 클릭 시 스텝 상세 확장 기능
- 실행 결과 실시간 표시 개선
This commit is contained in:
@@ -43,6 +43,11 @@ class FlowExecutor
|
||||
|
||||
private int $totalSteps = 0;
|
||||
|
||||
/**
|
||||
* 스텝별 성공/실패 추적
|
||||
*/
|
||||
private array $stepSuccessMap = [];
|
||||
|
||||
public function __construct(
|
||||
?VariableBinder $binder = null,
|
||||
?DependencyResolver $resolver = null,
|
||||
@@ -101,9 +106,26 @@ public function execute(array $flowDefinition, array $inputVariables = []): arra
|
||||
|
||||
foreach ($orderedStepIds as $stepId) {
|
||||
$step = $stepMap[$stepId];
|
||||
|
||||
// 의존성 스텝 성공 여부 확인
|
||||
$dependencyCheck = $this->checkDependencies($step);
|
||||
if (! $dependencyCheck['canRun']) {
|
||||
// 의존성 실패로 스킵
|
||||
$skipResult = $this->buildStepResult($stepId, $step['name'] ?? $stepId, microtime(true), false, [
|
||||
'skipped' => true,
|
||||
'skipReason' => $dependencyCheck['reason'],
|
||||
'failedDependencies' => $dependencyCheck['failedDeps'],
|
||||
]);
|
||||
$this->executionLog[] = $skipResult;
|
||||
$this->stepSuccessMap[$stepId] = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$stepResult = $this->executeStep($step);
|
||||
|
||||
$this->executionLog[] = $stepResult;
|
||||
$this->stepSuccessMap[$stepId] = $stepResult['success'];
|
||||
|
||||
if ($stepResult['success']) {
|
||||
$this->completedSteps++;
|
||||
@@ -133,6 +155,42 @@ public function execute(array $flowDefinition, array $inputVariables = []): arra
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 의존성 스텝 성공 여부 확인
|
||||
*
|
||||
* @param array $step 실행할 스텝
|
||||
* @return array ['canRun' => bool, 'reason' => string|null, 'failedDeps' => array]
|
||||
*/
|
||||
private function checkDependencies(array $step): array
|
||||
{
|
||||
$dependencies = $step['dependsOn'] ?? [];
|
||||
|
||||
if (empty($dependencies)) {
|
||||
return ['canRun' => true, 'reason' => null, 'failedDeps' => []];
|
||||
}
|
||||
|
||||
$failedDeps = [];
|
||||
|
||||
foreach ($dependencies as $depId) {
|
||||
// 의존성 스텝이 실행되지 않았거나 실패한 경우
|
||||
if (! isset($this->stepSuccessMap[$depId])) {
|
||||
$failedDeps[] = $depId.' (not executed)';
|
||||
} elseif (! $this->stepSuccessMap[$depId]) {
|
||||
$failedDeps[] = $depId.' (failed)';
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($failedDeps)) {
|
||||
return [
|
||||
'canRun' => false,
|
||||
'reason' => 'Dependency failed: '.implode(', ', $failedDeps),
|
||||
'failedDeps' => $failedDeps,
|
||||
];
|
||||
}
|
||||
|
||||
return ['canRun' => true, 'reason' => null, 'failedDeps' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 스텝 실행
|
||||
*/
|
||||
@@ -246,9 +304,31 @@ private function validateFlowDefinition(array $definition): void
|
||||
*/
|
||||
private function applyConfig(array $config): void
|
||||
{
|
||||
// Base URL
|
||||
// Base URL - Docker 환경 자동 변환
|
||||
if (isset($config['baseUrl'])) {
|
||||
$this->httpClient->setBaseUrl($config['baseUrl']);
|
||||
$baseUrl = $config['baseUrl'];
|
||||
|
||||
// Docker 환경에서 외부 URL을 내부 URL로 변환
|
||||
if ($this->isDockerEnvironment()) {
|
||||
$parsedUrl = parse_url($baseUrl);
|
||||
$host = $parsedUrl['host'] ?? '';
|
||||
|
||||
// *.sam.kr 도메인을 nginx 컨테이너로 라우팅
|
||||
if (str_ends_with($host, '.sam.kr') || $host === 'sam.kr') {
|
||||
$this->httpClient->setHostHeader($host);
|
||||
$this->httpClient->withoutVerifying();
|
||||
|
||||
// URL을 https://nginx/... 로 변환
|
||||
$internalUrl = preg_replace(
|
||||
'#https?://[^/]+#',
|
||||
'https://nginx',
|
||||
$baseUrl
|
||||
);
|
||||
$baseUrl = $internalUrl;
|
||||
}
|
||||
}
|
||||
|
||||
$this->httpClient->setBaseUrl($baseUrl);
|
||||
}
|
||||
|
||||
// Timeout
|
||||
@@ -262,13 +342,53 @@ private function applyConfig(array $config): void
|
||||
$this->httpClient->setDefaultHeaders($config['headers']);
|
||||
}
|
||||
|
||||
// 인증
|
||||
if (isset($config['apiKey'])) {
|
||||
$this->httpClient->setApiKey($config['apiKey']);
|
||||
// 인증 - JSON에 없으면 .env에서 자동 로드
|
||||
$apiKey = $config['apiKey'] ?? null;
|
||||
if (empty($apiKey)) {
|
||||
$apiKey = env('FLOW_TESTER_API_KEY');
|
||||
} else {
|
||||
// 플레이스홀더 치환 ({{$auth.apiKey}} 등)
|
||||
$apiKey = $this->binder->bind($apiKey);
|
||||
}
|
||||
if (isset($config['bearerToken'])) {
|
||||
$this->httpClient->setBearerToken($config['bearerToken']);
|
||||
if (! empty($apiKey)) {
|
||||
$this->httpClient->setApiKey($apiKey);
|
||||
}
|
||||
|
||||
$bearerToken = $config['bearerToken'] ?? null;
|
||||
if (empty($bearerToken)) {
|
||||
// .env 또는 로그인 사용자의 토큰 사용
|
||||
$bearerToken = $this->getDefaultBearerToken();
|
||||
} else {
|
||||
// 플레이스홀더 치환 ({{$auth.token}} 등)
|
||||
$bearerToken = $this->binder->bind($bearerToken);
|
||||
}
|
||||
if (! empty($bearerToken)) {
|
||||
$this->httpClient->setBearerToken($bearerToken);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 Bearer 토큰 조회
|
||||
* 우선순위: 사용자 api_token → .env FLOW_TESTER_API_TOKEN
|
||||
*/
|
||||
private function getDefaultBearerToken(): ?string
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user && ! empty($user->api_token)) {
|
||||
return $user->api_token;
|
||||
}
|
||||
|
||||
return env('FLOW_TESTER_API_TOKEN');
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker 환경인지 확인
|
||||
*/
|
||||
private function isDockerEnvironment(): bool
|
||||
{
|
||||
// Docker 컨테이너 내부에서 실행 중인지 확인
|
||||
return file_exists('/.dockerenv') || (getenv('DOCKER_CONTAINER') === 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -338,6 +458,7 @@ private function reset(): void
|
||||
$this->status = 'PENDING';
|
||||
$this->completedSteps = 0;
|
||||
$this->totalSteps = 0;
|
||||
$this->stepSuccessMap = [];
|
||||
$this->binder->reset();
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,30 @@ class HttpClient
|
||||
|
||||
private ?string $bearerToken = null;
|
||||
|
||||
private bool $verifySsl = true;
|
||||
|
||||
private ?string $hostHeader = null;
|
||||
|
||||
/**
|
||||
* SSL 검증 비활성화 (Docker 내부 통신용)
|
||||
*/
|
||||
public function withoutVerifying(): self
|
||||
{
|
||||
$this->verifySsl = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host 헤더 설정 (Docker 내부 통신용)
|
||||
*/
|
||||
public function setHostHeader(string $host): self
|
||||
{
|
||||
$this->hostHeader = $host;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 URL 설정
|
||||
*/
|
||||
@@ -91,6 +115,16 @@ public function request(string $method, string $endpoint, array $options = []):
|
||||
$request = Http::timeout($this->timeout)
|
||||
->withHeaders($headers);
|
||||
|
||||
// SSL 검증 비활성화 (Docker 내부 통신용)
|
||||
if (! $this->verifySsl) {
|
||||
$request = $request->withoutVerifying();
|
||||
}
|
||||
|
||||
// Host 헤더 추가 (Docker 내부 통신용)
|
||||
if ($this->hostHeader) {
|
||||
$request = $request->withHeaders(['Host' => $this->hostHeader]);
|
||||
}
|
||||
|
||||
// API 키 추가
|
||||
if ($this->apiKey) {
|
||||
$request = $request->withHeaders(['X-API-KEY' => $this->apiKey]);
|
||||
|
||||
@@ -93,7 +93,7 @@ private function validateValue(mixed $actual, mixed $expected, string $path): ?s
|
||||
if (! is_string($expected) || ! str_starts_with($expected, '@')) {
|
||||
if ($actual !== $expected) {
|
||||
return sprintf(
|
||||
"Path %s: expected %s, got %s",
|
||||
'Path %s: expected %s, got %s',
|
||||
$path,
|
||||
json_encode($expected, JSON_UNESCAPED_UNICODE),
|
||||
json_encode($actual, JSON_UNESCAPED_UNICODE)
|
||||
|
||||
@@ -92,7 +92,7 @@ private function bindString(string $input): string
|
||||
}
|
||||
|
||||
/**
|
||||
* 내장 변수 처리 ($timestamp, $uuid, $random:N)
|
||||
* 내장 변수 처리 ($timestamp, $uuid, $random:N, $env.XXX, $auth.token)
|
||||
*/
|
||||
private function resolveBuiltins(string $input): string
|
||||
{
|
||||
@@ -120,11 +120,47 @@ function ($m) {
|
||||
// {{$datetime}} → 현재 날짜시간 (Y-m-d H:i:s)
|
||||
$input = str_replace('{{$datetime}}', date('Y-m-d H:i:s'), $input);
|
||||
|
||||
// {{$env.VAR_NAME}} → 환경변수에서 읽기
|
||||
$input = preg_replace_callback(
|
||||
'/\{\{\$env\.([A-Z_][A-Z0-9_]*)\}\}/i',
|
||||
fn ($m) => env($m[1], ''),
|
||||
$input
|
||||
);
|
||||
|
||||
// {{$auth.token}} → 현재 로그인 사용자의 API 토큰
|
||||
if (str_contains($input, '{{$auth.token}}')) {
|
||||
$token = $this->getAuthToken();
|
||||
$input = str_replace('{{$auth.token}}', $token, $input);
|
||||
}
|
||||
|
||||
// {{$auth.apiKey}} → .env의 API Key
|
||||
$input = str_replace('{{$auth.apiKey}}', env('FLOW_TESTER_API_KEY', ''), $input);
|
||||
|
||||
return $input;
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 경로 해석 (step1.pageId → 실제 값)
|
||||
* 현재 로그인 사용자의 API 토큰 조회
|
||||
*/
|
||||
private function getAuthToken(): string
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user) {
|
||||
return env('FLOW_TESTER_API_TOKEN', '');
|
||||
}
|
||||
|
||||
// 사용자에게 저장된 API 토큰이 있으면 사용
|
||||
if (! empty($user->api_token)) {
|
||||
return $user->api_token;
|
||||
}
|
||||
|
||||
// 없으면 .env의 기본 토큰 사용
|
||||
return env('FLOW_TESTER_API_TOKEN', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 경로 해석 (step1.pageId 또는 page_create_1.pageId → 실제 값)
|
||||
*/
|
||||
private function resolveReference(string $path): string
|
||||
{
|
||||
@@ -137,23 +173,28 @@ private function resolveReference(string $path): string
|
||||
return $this->valueToString($value);
|
||||
}
|
||||
|
||||
// stepN.xxx → $this->context['steps']['stepN']['xxx']
|
||||
if (preg_match('/^(step\w+)\.(.+)$/', $path, $m)) {
|
||||
$stepId = $m[1];
|
||||
$subPath = $m[2];
|
||||
// stepId.xxx 패턴 감지 (stepId는 등록된 step의 ID)
|
||||
// 첫 번째 점(.) 기준으로 분리
|
||||
$dotPos = strpos($path, '.');
|
||||
if ($dotPos !== false) {
|
||||
$potentialStepId = substr($path, 0, $dotPos);
|
||||
$subPath = substr($path, $dotPos + 1);
|
||||
|
||||
// stepN.response.xxx → 전체 응답에서 추출
|
||||
if (str_starts_with($subPath, 'response.')) {
|
||||
$responsePath = substr($subPath, 9); // "response." 제거
|
||||
$value = data_get($this->context['steps'][$stepId]['response'] ?? [], $responsePath, '');
|
||||
// 등록된 step인지 확인
|
||||
if (isset($this->context['steps'][$potentialStepId])) {
|
||||
// stepId.response.xxx → 전체 응답에서 추출
|
||||
if (str_starts_with($subPath, 'response.')) {
|
||||
$responsePath = substr($subPath, 9); // "response." 제거
|
||||
$value = data_get($this->context['steps'][$potentialStepId]['response'] ?? [], $responsePath, '');
|
||||
|
||||
return $this->valueToString($value);
|
||||
}
|
||||
|
||||
// stepId.xxx → extracted 또는 직접 접근
|
||||
$value = data_get($this->context['steps'][$potentialStepId] ?? [], $subPath, '');
|
||||
|
||||
return $this->valueToString($value);
|
||||
}
|
||||
|
||||
// stepN.xxx → extracted 또는 직접 접근
|
||||
$value = data_get($this->context['steps'][$stepId] ?? [], $subPath, '');
|
||||
|
||||
return $this->valueToString($value);
|
||||
}
|
||||
|
||||
// 기타 경로는 context에서 직접 조회
|
||||
|
||||
@@ -90,13 +90,22 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
@foreach($flows as $flow)
|
||||
@php
|
||||
$latestRun = $flow->runs->first();
|
||||
$steps = $flow->flow_definition['steps'] ?? [];
|
||||
@endphp
|
||||
<tr class="hover:bg-gray-50">
|
||||
{{-- 메인 row --}}
|
||||
<tr class="hover:bg-gray-50 cursor-pointer flow-row" data-flow-id="{{ $flow->id }}" onclick="toggleFlowDetail({{ $flow->id }}, event)">
|
||||
<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
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform flow-chevron" id="chevron-{{ $flow->id }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<div>
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
@if($flow->category)
|
||||
@@ -124,7 +133,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
<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">
|
||||
<td class="px-6 py-4 text-right" onclick="event.stopPropagation()">
|
||||
<div class="flex justify-end gap-2">
|
||||
<!-- 실행 버튼 -->
|
||||
<button onclick="runFlow({{ $flow->id }})"
|
||||
@@ -173,6 +182,223 @@ class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition"
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{-- 스텝 상세 row (확장 영역) --}}
|
||||
@php
|
||||
// 최근 실행 로그에서 스텝별 상태 추출
|
||||
$stepResults = [];
|
||||
if ($latestRun && $latestRun->execution_log) {
|
||||
foreach ($latestRun->execution_log as $log) {
|
||||
$stepResults[$log['stepId'] ?? ''] = $log;
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
<tr id="flow-detail-{{ $flow->id }}" class="hidden">
|
||||
<td colspan="6" class="px-6 py-4 bg-gray-50">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<h4 class="text-sm font-semibold text-gray-700">플로우 스텝 ({{ count($steps) }}개)</h4>
|
||||
@if($latestRun)
|
||||
<span class="text-xs text-gray-500">
|
||||
최근 실행: {{ $latestRun->created_at->format('m/d H:i') }}
|
||||
@if($latestRun->duration_ms)
|
||||
({{ number_format($latestRun->duration_ms / 1000, 2) }}초)
|
||||
@endif
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@if($flow->flow_definition['config']['baseUrl'] ?? null)
|
||||
<span class="text-xs text-gray-500">Base URL: {{ $flow->flow_definition['config']['baseUrl'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
{{-- 스텝 파이프라인 --}}
|
||||
<div style="background: white; border-radius: 12px; border: 1px solid #e5e7eb; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: stretch; gap: 12px;">
|
||||
@foreach($steps as $idx => $step)
|
||||
@php
|
||||
// 메서드 배지 색상만 정의
|
||||
$badgeColors = [
|
||||
'GET' => '#3b82f6',
|
||||
'POST' => '#10b981',
|
||||
'PUT' => '#f59e0b',
|
||||
'PATCH' => '#f97316',
|
||||
'DELETE' => '#ef4444',
|
||||
];
|
||||
$method = strtoupper($step['method'] ?? 'GET');
|
||||
$badgeColor = $badgeColors[$method] ?? '#6b7280';
|
||||
$hasDeps = !empty($step['dependsOn']);
|
||||
|
||||
// 실행 결과 확인
|
||||
$stepId = $step['id'] ?? '';
|
||||
$result = $stepResults[$stepId] ?? null;
|
||||
$isSkipped = $result && ($result['skipped'] ?? false);
|
||||
$isSuccess = $result && !$isSkipped && ($result['success'] ?? false);
|
||||
$isFailed = $result && !$isSkipped && !($result['success'] ?? true);
|
||||
$isPending = !$result;
|
||||
|
||||
// 상태 기준 카드 스타일
|
||||
if ($isPending) {
|
||||
$cardBg = '#f9fafb';
|
||||
$cardBorder = '#e5e7eb';
|
||||
$numberBg = '#9ca3af';
|
||||
$statusIcon = null;
|
||||
} elseif ($isSkipped) {
|
||||
$cardBg = '#fefce8'; // 연노랑
|
||||
$cardBorder = '#facc15'; // 노랑
|
||||
$numberBg = '#eab308';
|
||||
$statusIcon = 'skip';
|
||||
} elseif ($isSuccess) {
|
||||
$cardBg = '#ecfdf5';
|
||||
$cardBorder = '#10b981';
|
||||
$numberBg = '#10b981';
|
||||
$statusIcon = 'success';
|
||||
} else {
|
||||
$cardBg = '#fef2f2';
|
||||
$cardBorder = '#ef4444';
|
||||
$numberBg = '#ef4444';
|
||||
$statusIcon = 'fail';
|
||||
}
|
||||
@endphp
|
||||
{{-- 연결 화살표 --}}
|
||||
@if($idx > 0)
|
||||
<div style="display: flex; align-items: center; align-self: center; margin: 0 -4px;">
|
||||
<div style="width: 24px; height: 2px; background: linear-gradient(to right, #d1d5db, #9ca3af);"></div>
|
||||
<div style="width: 0; height: 0; border-top: 6px solid transparent; border-bottom: 6px solid transparent; border-left: 10px solid #9ca3af;"></div>
|
||||
</div>
|
||||
@endif
|
||||
{{-- 스텝 카드 --}}
|
||||
<div class="group" style="position: relative; display: flex; flex-direction: column; min-width: 170px; max-width: 210px;">
|
||||
{{-- 스텝 번호 --}}
|
||||
<div style="position: absolute; top: -10px; left: -10px; width: 28px; height: 28px; border-radius: 50%; background: {{ $numberBg }}; color: white; font-size: 12px; font-weight: bold; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 4px rgba(0,0,0,0.2); z-index: 10;">
|
||||
{{ $idx + 1 }}
|
||||
</div>
|
||||
{{-- 상태 인디케이터 (성공/실패/스킵 시) --}}
|
||||
@if($statusIcon)
|
||||
@php
|
||||
$iconBgColor = match($statusIcon) {
|
||||
'success' => '#10b981',
|
||||
'fail' => '#ef4444',
|
||||
'skip' => '#eab308',
|
||||
default => '#9ca3af'
|
||||
};
|
||||
@endphp
|
||||
<div style="position: absolute; top: -10px; right: -10px; width: 28px; height: 28px; border-radius: 50%; background: {{ $iconBgColor }}; color: white; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 4px rgba(0,0,0,0.2); z-index: 10;">
|
||||
@if($statusIcon === 'success')
|
||||
<svg style="width: 16px; height: 16px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
@elseif($statusIcon === 'skip')
|
||||
{{-- Skip 아이콘: 화살표 우회 --}}
|
||||
<svg style="width: 16px; height: 16px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 5l7 7-7 7M5 5l7 7-7 7"/>
|
||||
</svg>
|
||||
@else
|
||||
<svg style="width: 16px; height: 16px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
{{-- 카드 본체 (상태 기준 색상) --}}
|
||||
<div style="flex: 1; border-radius: 10px; border: 2px solid {{ $cardBorder }}; background: {{ $cardBg }}; padding: 14px; transition: box-shadow 0.2s;" class="hover:shadow-lg">
|
||||
{{-- 메서드 배지 --}}
|
||||
<div style="margin-bottom: 10px;">
|
||||
<span style="display: inline-block; padding: 3px 10px; font-size: 11px; font-weight: bold; border-radius: 4px; background: {{ $badgeColor }}; color: white; box-shadow: 0 1px 2px rgba(0,0,0,0.1);">
|
||||
{{ $method }}
|
||||
</span>
|
||||
</div>
|
||||
{{-- 스텝 이름 --}}
|
||||
<div style="font-size: 13px; font-weight: 600; color: #1f2937; margin-bottom: 6px; line-height: 1.3;">
|
||||
{{ $step['name'] ?? $step['id'] }}
|
||||
</div>
|
||||
{{-- 엔드포인트 --}}
|
||||
<div style="font-size: 11px; color: #6b7280; font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{ $step['endpoint'] ?? '' }}">
|
||||
{{ $step['endpoint'] ?? '-' }}
|
||||
</div>
|
||||
{{-- 실행 결과 또는 의존성 --}}
|
||||
@if($result)
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid {{ $cardBorder }};">
|
||||
@if($isSkipped)
|
||||
<span style="display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 500; color: #ca8a04;">
|
||||
<svg style="width: 14px; height: 14px;" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-11.25a.75.75 0 00-1.5 0v2.5h-2.5a.75.75 0 000 1.5h2.5v2.5a.75.75 0 001.5 0v-2.5h2.5a.75.75 0 000-1.5h-2.5v-2.5z" clip-rule="evenodd" transform="rotate(45 10 10)"/></svg>
|
||||
스킵
|
||||
</span>
|
||||
@elseif($isSuccess)
|
||||
<span style="display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 500; color: #059669;">
|
||||
<svg style="width: 14px; height: 14px;" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
||||
성공
|
||||
<span style="color: #6b7280; margin-left: 4px;">{{ $result['duration'] ?? 0 }}ms</span>
|
||||
</span>
|
||||
@else
|
||||
<span style="display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 500; color: #dc2626;">
|
||||
<svg style="width: 14px; height: 14px;" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>
|
||||
실패
|
||||
<span style="color: #6b7280; margin-left: 4px;">{{ $result['duration'] ?? 0 }}ms</span>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@elseif($hasDeps)
|
||||
<div style="display: flex; align-items: center; gap: 4px; margin-top: 10px; padding-top: 10px; border-top: 1px solid {{ $cardBorder }};">
|
||||
<svg style="width: 12px; height: 12px; color: #9ca3af;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"/>
|
||||
</svg>
|
||||
<span style="font-size: 10px; color: #6b7280;">{{ implode(', ', $step['dependsOn']) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
{{-- 툴팁 --}}
|
||||
<div class="absolute left-1/2 -translate-x-1/2 top-full mt-2 z-30 hidden group-hover:block" style="width: 320px; padding: 16px; background: #111827; color: white; font-size: 12px; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.3);">
|
||||
<div style="position: absolute; top: -8px; left: 50%; transform: translateX(-50%); width: 0; height: 0; border-left: 8px solid transparent; border-right: 8px solid transparent; border-bottom: 8px solid #111827;"></div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<span style="padding: 2px 8px; font-size: 10px; font-weight: bold; border-radius: 4px; background: {{ $badgeColor }}; color: white;">{{ $method }}</span>
|
||||
<span style="font-weight: 600;">{{ $step['name'] ?? $step['id'] }}</span>
|
||||
</div>
|
||||
<div style="color: #9ca3af; font-family: monospace; font-size: 11px; margin-bottom: 8px; word-break: break-all;">{{ $step['endpoint'] ?? '' }}</div>
|
||||
@if(!empty($step['description']))
|
||||
<div style="color: #d1d5db; margin-bottom: 8px;">{{ $step['description'] }}</div>
|
||||
@endif
|
||||
@if($result)
|
||||
<div style="border-top: 1px solid #374151; padding-top: 8px; margin-top: 8px;">
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 4px;">
|
||||
@if($isSkipped)
|
||||
<span style="font-weight: 600; color: #facc15;">⏭ 스킵됨</span>
|
||||
@else
|
||||
<span style="font-weight: 600; color: {{ $isSuccess ? '#4ade80' : '#f87171' }};">
|
||||
{{ $isSuccess ? '✓ 성공' : '✗ 실패' }}
|
||||
</span>
|
||||
<span style="color: #9ca3af;">{{ $result['duration'] ?? 0 }}ms</span>
|
||||
@if(!empty($result['response']['status']))
|
||||
<span style="padding: 2px 6px; background: #374151; border-radius: 4px; color: #d1d5db;">HTTP {{ $result['response']['status'] }}</span>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@if($isSkipped && !empty($result['skipReason']))
|
||||
<div style="color: #fde047; font-size: 11px; margin-top: 4px;">{{ $result['skipReason'] }}</div>
|
||||
@elseif(!$isSuccess && !$isSkipped && !empty($result['error']))
|
||||
<div style="color: #fca5a5; font-size: 11px; margin-top: 4px;">{{ $result['error'] }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@if(!empty($step['expect']))
|
||||
<div style="color: #9ca3af; margin-top: 4px;">
|
||||
<span style="color: #6b7280;">expect:</span> status {{ json_encode($step['expect']['status'] ?? []) }}
|
||||
</div>
|
||||
@endif
|
||||
@if(!empty($step['extract']))
|
||||
<div style="color: #34d399; margin-top: 4px;">
|
||||
<span style="color: #6b7280;">extract:</span> {{ implode(', ', array_keys($step['extract'])) }}
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@if(empty($steps))
|
||||
<div class="text-sm text-gray-400 italic">스텝이 없습니다.</div>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -193,6 +419,29 @@ class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition"
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// 플로우 스텝 상세 토글
|
||||
function toggleFlowDetail(id, event) {
|
||||
// 버튼/링크 클릭 시 무시 (액션 영역)
|
||||
if (event.target.closest('button, a, form')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detailRow = document.getElementById(`flow-detail-${id}`);
|
||||
const chevron = document.getElementById(`chevron-${id}`);
|
||||
|
||||
if (!detailRow) return;
|
||||
|
||||
const isHidden = detailRow.classList.contains('hidden');
|
||||
|
||||
if (isHidden) {
|
||||
detailRow.classList.remove('hidden');
|
||||
chevron?.classList.add('rotate-90');
|
||||
} else {
|
||||
detailRow.classList.add('hidden');
|
||||
chevron?.classList.remove('rotate-90');
|
||||
}
|
||||
}
|
||||
|
||||
function runFlow(id) {
|
||||
if (!confirm('이 플로우를 실행하시겠습니까?')) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user