diff --git a/app/Actions/Automation/Automation/GetAutomationDetails.php b/app/Actions/Automation/Automation/GetAutomationDetails.php deleted file mode 100644 index af4424a2..00000000 --- a/app/Actions/Automation/Automation/GetAutomationDetails.php +++ /dev/null @@ -1,27 +0,0 @@ -, - * triggerItems: Collection, - * } - */ - public function __invoke(Automation $automation): array - { - return [ - 'runs' => $automation->runs()->excludingDryRuns()->latest()->take(50)->get(), - 'triggerItems' => $automation->triggerItems()->with('run')->latest()->take(50)->get(), - ]; - } -} diff --git a/app/Actions/Automation/Automation/GetAutomationInvocations.php b/app/Actions/Automation/Automation/GetAutomationInvocations.php new file mode 100644 index 00000000..02d0a38d --- /dev/null +++ b/app/Actions/Automation/Automation/GetAutomationInvocations.php @@ -0,0 +1,31 @@ + + */ + public function __invoke(Automation $automation, ?string $status = null, ?string $search = null): LengthAwarePaginator|CursorPaginator + { + return $automation->runs() + ->productionRuns() + ->withCount('nodeRuns') + ->when($status !== null, fn ($query) => $query->where('status', $status)) + ->when($search !== null && $search !== '', fn ($query) => $query->whereLike('id', "%{$search}%")) + ->latest() + ->paginate(25); + } +} diff --git a/app/Actions/Automation/Automation/GetAutomationMetrics.php b/app/Actions/Automation/Automation/GetAutomationMetrics.php new file mode 100644 index 00000000..229c5aa4 --- /dev/null +++ b/app/Actions/Automation/Automation/GetAutomationMetrics.php @@ -0,0 +1,123 @@ +, + * platforms: array, + * } + */ + public function __invoke(Automation $automation, CarbonInterface $start, CarbonInterface $end): array + { + $start = $start->copy()->startOfDay(); + $end = $end->copy()->endOfDay(); + $days = (int) $start->diffInDays($end) + 1; + + $runs = $automation->runs() + ->productionRuns() + ->whereBetween('created_at', [$start, $end]) + ->get(['id', 'status', 'generated_post_id', 'created_at', 'started_at', 'finished_at']); + + $completed = $runs->where('status', Status::Completed); + $failed = $runs->where('status', Status::Failed); + $inProgress = $runs->whereIn('status', [Status::Pending, Status::Running, Status::Waiting]); + + $finished = $completed->count() + $failed->count(); + $successRate = $finished > 0 ? (int) round($completed->count() / $finished * 100) : null; + + $durations = $completed + ->filter(fn ($run) => $run->started_at !== null && $run->finished_at !== null) + ->map(fn ($run) => $run->started_at->diffInMilliseconds($run->finished_at)); + $avgDurationMs = $durations->isNotEmpty() ? (int) round($durations->avg()) : null; + + return [ + 'totals' => [ + 'runs' => $runs->count(), + 'completed' => $completed->count(), + 'failed' => $failed->count(), + 'in_progress' => $inProgress->count(), + 'success_rate' => $successRate, + 'avg_duration_ms' => $avgDurationMs, + 'posts_created' => $runs->whereNotNull('generated_post_id')->count(), + ], + 'timeseries' => $this->buildTimeseries($runs, $start, $days), + 'platforms' => $this->buildPlatformBreakdown($runs->pluck('generated_post_id')->filter()->all()), + ]; + } + + /** + * Zero-filled daily buckets so the chart line stays continuous across days + * with no runs. + * + * @param Collection $runs + * @return array + */ + private function buildTimeseries(Collection $runs, CarbonInterface $since, int $days): array + { + $series = []; + for ($i = 0; $i < $days; $i++) { + $date = $since->copy()->addDays($i)->format('Y-m-d'); + $series[$date] = ['date' => $date, 'started' => 0, 'completed' => 0, 'failed' => 0]; + } + + foreach ($runs as $run) { + $date = $run->created_at->format('Y-m-d'); + + if (! isset($series[$date])) { + continue; + } + + $series[$date]['started']++; + + if ($run->status === Status::Completed) { + $series[$date]['completed']++; + } + + if ($run->status === Status::Failed) { + $series[$date]['failed']++; + } + } + + return array_values($series); + } + + /** + * Count published platform targets across the posts this automation + * generated, so the chart shows where its output actually went. + * + * @param array $postIds + * @return array + */ + private function buildPlatformBreakdown(array $postIds): array + { + if ($postIds === []) { + return []; + } + + return PostPlatform::query() + ->whereIn('post_id', $postIds) + ->selectRaw('platform, count(*) as total') + ->groupBy('platform') + ->orderByDesc('total') + ->get() + ->map(fn ($row) => ['platform' => $row->platform->value, 'count' => (int) $row->total]) + ->all(); + } +} diff --git a/app/Http/Controllers/App/AutomationController.php b/app/Http/Controllers/App/AutomationController.php index 06ef742b..d4721fb0 100644 --- a/app/Http/Controllers/App/AutomationController.php +++ b/app/Http/Controllers/App/AutomationController.php @@ -7,8 +7,9 @@ use App\Actions\Automation\Automation\ActivateAutomation; use App\Actions\Automation\Automation\CreateAutomation; use App\Actions\Automation\Automation\DeleteAutomation; -use App\Actions\Automation\Automation\GetAutomationDetails; use App\Actions\Automation\Automation\GetAutomationEditorData; +use App\Actions\Automation\Automation\GetAutomationInvocations; +use App\Actions\Automation\Automation\GetAutomationMetrics; use App\Actions\Automation\Automation\ListAutomations; use App\Actions\Automation\Automation\PauseAutomation; use App\Actions\Automation\Automation\UpdateAutomation; @@ -23,10 +24,10 @@ use App\Http\Requests\App\Automations\UpdateAutomationRequest; use App\Http\Resources\App\PlatformConfigResource; use App\Http\Resources\App\SocialAccountResource; +use App\Http\Resources\AutomationInvocationResource; use App\Http\Resources\AutomationNodeRunResource; use App\Http\Resources\AutomationResource; use App\Http\Resources\AutomationRunResource; -use App\Http\Resources\AutomationTriggerItemResource; use App\Models\Automation; use App\Models\AutomationNodeRun; use App\Models\AutomationRun; @@ -57,10 +58,15 @@ public function store(StoreAutomationRequest $request, CreateAutomation $create) $request->user(), ); - return redirect()->route('app.automations.edit', $automation->id); + return redirect()->route('app.automations.workflow', $automation->id); } - public function edit(Automation $automation, GetAutomationEditorData $editorData): Response + public function show(Automation $automation): RedirectResponse + { + return redirect()->route('app.automations.workflow', $automation->id); + } + + public function workflow(Automation $automation, GetAutomationEditorData $editorData): Response { $this->authorize('update', $automation); @@ -79,16 +85,58 @@ public function edit(Automation $automation, GetAutomationEditorData $editorData ]); } - public function show(Automation $automation, GetAutomationDetails $details): Response + public function invocations(Automation $automation, GetAutomationInvocations $invocations): Response { $this->authorize('view', $automation); - ['runs' => $runs, 'triggerItems' => $triggerItems] = $details($automation); + $status = request()->string('status')->toString() ?: null; + $search = request()->string('search')->toString() ?: null; - return Inertia::render('automations/Show', [ + return Inertia::render('automations/Invocations', [ 'automation' => AutomationResource::make($automation), - 'runs' => AutomationRunResource::collection($runs), - 'triggerItems' => AutomationTriggerItemResource::collection($triggerItems), + 'invocations' => Inertia::scroll(fn () => AutomationInvocationResource::collection( + $invocations($automation, $status, $search) + )), + 'filters' => [ + 'status' => $status, + 'search' => $search, + ], + ]); + } + + public function settings(Automation $automation): Response + { + $this->authorize('view', $automation); + + return Inertia::render('automations/Settings', [ + 'automation' => AutomationResource::make($automation), + ]); + } + + public function metrics(Automation $automation, GetAutomationMetrics $metrics): Response + { + $this->authorize('view', $automation); + + $end = (request()->date('end') ?? now())->startOfDay(); + $start = (request()->date('start') ?? now()->subDays(6))->startOfDay(); + + if ($start->greaterThan($end)) { + [$start, $end] = [$end, $start]; + } + + // Cap the window so a hand-edited URL can't request a multi-year, daily + // bucketed series. + if ($start->diffInDays($end) > 366) { + $start = $end->copy()->subDays(366); + } + + return Inertia::render('automations/Metrics', [ + 'automation' => AutomationResource::make($automation), + 'metrics' => $metrics($automation, $start, $end), + 'filters' => [ + 'start' => $start->toDateString(), + 'end' => $end->toDateString(), + ], ]); } diff --git a/app/Http/Resources/AutomationInvocationResource.php b/app/Http/Resources/AutomationInvocationResource.php new file mode 100644 index 00000000..0c70a6ed --- /dev/null +++ b/app/Http/Resources/AutomationInvocationResource.php @@ -0,0 +1,37 @@ + + */ + public function toArray(Request $request): array + { + $durationMs = $this->started_at !== null && $this->finished_at !== null + ? $this->started_at->diffInMilliseconds($this->finished_at) + : null; + + return [ + 'id' => $this->id, + 'status' => $this->status->value, + 'is_manual' => (bool) $this->is_manual, + 'node_run_count' => (int) ($this->node_runs_count ?? 0), + 'duration_ms' => $durationMs, + 'error_message' => is_array($this->error) ? ($this->error['message'] ?? null) : $this->error, + 'created_at' => $this->created_at, + 'started_at' => $this->started_at, + 'finished_at' => $this->finished_at, + ]; + } +} diff --git a/app/Models/AutomationRun.php b/app/Models/AutomationRun.php index ee51f6c4..876630a3 100644 --- a/app/Models/AutomationRun.php +++ b/app/Models/AutomationRun.php @@ -81,11 +81,13 @@ public function nodeRuns(): HasMany } /** - * Hides dry-run rows from user-facing history queries. Internal/analytics - * queries can ignore the scope to see every row. + * Real, production executions only — excludes manual test runs (both dry + * runs and "with real data" tests are flagged is_manual). The Invocations + * and Metrics tabs report on these, not on runs the user triggered to test + * the editor. */ - public function scopeExcludingDryRuns(Builder $query): Builder + public function scopeProductionRuns(Builder $query): Builder { - return $query->where('is_dry_run', false); + return $query->where('is_manual', false)->where('is_dry_run', false); } } diff --git a/lang/en/automations.php b/lang/en/automations.php index 7ef2f79d..52882f96 100644 --- a/lang/en/automations.php +++ b/lang/en/automations.php @@ -23,6 +23,99 @@ 'test' => 'Test', ], + 'nav' => [ + 'workflow' => 'Workflow', + 'invocations' => 'Invocations', + 'metrics' => 'Metrics', + 'settings' => 'Settings', + ], + + 'settings' => [ + 'general' => 'General', + 'general_description' => 'Rename this automation.', + 'name_label' => 'Name', + 'name_saved' => 'Automation renamed.', + 'status_title' => 'Status', + 'status_description' => 'Activate to start running it, or pause to stop.', + 'activated_at' => 'Activated :date', + 'paused_at' => 'Paused :date', + 'created_at' => 'Created :date', + 'danger_title' => 'Danger zone', + 'danger_description' => 'Irreversible actions.', + 'delete_title' => 'Delete this automation', + 'delete_description' => 'Permanently removes the automation and its run history.', + ], + + 'status_run' => [ + 'pending' => 'Pending', + 'running' => 'Running', + 'waiting' => 'Waiting', + 'completed' => 'Completed', + 'failed' => 'Failed', + 'cancelled' => 'Cancelled', + ], + + 'node_type' => [ + 'trigger' => 'Trigger', + 'generate' => 'Generate content', + 'delay' => 'Delay', + 'condition' => 'Condition', + 'publish' => 'Publish', + 'webhook' => 'Webhook', + 'end' => 'End', + 'fetch_rss' => 'Fetch RSS', + 'http_request' => 'HTTP request', + ], + + 'invocations' => [ + 'empty' => 'No invocations yet.', + 'refresh' => 'Refresh', + 'search_placeholder' => 'Search by run ID…', + 'copied' => 'Run ID copied.', + 'loading' => 'Loading steps…', + 'no_steps' => 'No steps recorded.', + 'load_error' => 'Could not load steps.', + 'steps' => '{0}No steps|{1}:count step|[2,*]:count steps', + 'filter' => [ + 'all' => 'All statuses', + ], + 'columns' => [ + 'timestamp' => 'Timestamp', + 'run' => 'Run', + 'status' => 'Status', + 'message' => 'Last message', + 'duration' => 'Duration', + ], + 'summary' => [ + 'completed' => 'Workflow completed', + 'failed' => 'Workflow failed', + 'running' => 'Workflow running', + 'cancelled' => 'Workflow cancelled', + 'pending' => 'Workflow pending', + ], + ], + + 'metrics' => [ + 'overview' => 'Overview', + 'runs_over_time' => 'Runs over time', + 'posts_by_platform' => 'Posts by platform', + 'no_posts' => 'No posts published in this period.', + 'cards' => [ + 'runs' => 'Total runs', + 'completed' => 'Completed', + 'failed' => 'Failed', + 'in_progress' => 'In progress', + 'success_rate' => 'Success rate', + 'avg_duration' => 'Avg duration', + 'posts_created' => 'Posts created', + ], + 'legend' => [ + 'started' => 'Started', + 'completed' => 'Completed', + 'failed' => 'Failed', + ], + ], + 'categories' => [ 'sources' => 'Sources', 'content' => 'Content', @@ -42,19 +135,26 @@ ], 'guide' => [ - 'title' => 'How to use automations', - 'subtitle' => 'A quick reference for building your flow.', - 'flow_title' => 'How it works', - 'flow_text' => 'A trigger (a schedule, or a published/scheduled post) starts the flow. Actions in between fetch data, generate content, or branch on conditions. Output nodes publish posts or call webhooks.', - 'data_title' => 'Passing data between nodes', - 'data_text' => 'Reference data from earlier nodes in any field. Type the name in double braces, for example:', - 'refs' => [ - 'trigger_post' => 'The post that fired a post trigger', - 'fetched_title' => 'Current RSS item / HTTP response', - 'fetched_link' => 'Its link', - 'generated' => 'The AI-generated post', - 'now' => 'Current date & time', + 'title' => 'How automations work', + 'subtitle' => 'Build a flow, then wire data between nodes.', + 'tabs' => [ + 'overview' => 'Overview', + 'expressions' => 'Expressions', ], + 'flow_title' => 'The flow', + 'flow_text' => 'A trigger (a schedule, or a published/scheduled post) starts the flow. Actions in between fetch data, generate content, or branch on conditions. Output nodes publish posts or call webhooks.', + 'scope_title' => 'What data is in scope', + 'scope_text' => 'A node can only use data from nodes connected before it (upstream). In any text field, type {{ to autocomplete what is available — unknown references are flagged.', + 'data_text' => 'Wrap a path in double braces. Which paths exist depends on the nodes running upstream:', + 'groups' => [ + 'trigger_schedule' => 'Schedule trigger', + 'trigger_post' => 'Post trigger', + 'fetch_rss' => 'Fetch RSS', + 'http' => 'HTTP request', + 'generate' => 'Generate content', + 'always' => 'Always available', + ], + 'http_note' => 'Append any field from the JSON response, e.g. {{ fetched.data.0.title }}.', 'vars_title' => 'Variables', 'vars_text' => 'Define reusable values (API keys, base URLs) in the Variables tab, then reference them by key. Secrets are stored encrypted.', 'tip_text' => 'Run a Test to see exactly what data each node outputs.', @@ -116,20 +216,6 @@ ], ], - 'show' => [ - 'activated' => 'Activated', - 'tabs' => [ - 'overview' => 'Overview', - 'runs' => 'Runs', - 'trigger_items' => 'Trigger items', - ], - 'canvas_placeholder' => 'Canvas preview (read-only)', - 'empty_runs' => 'No runs yet.', - 'empty_trigger_items' => 'No trigger items yet.', - 'started' => 'Started', - 'run_label' => 'Run', - ], - 'form' => [ 'activate_error_fallback' => 'Could not activate automation.', 'pause_error_fallback' => 'Could not pause automation.', diff --git a/lang/es/automations.php b/lang/es/automations.php index 66e5d712..eea7c2e6 100644 --- a/lang/es/automations.php +++ b/lang/es/automations.php @@ -23,6 +23,99 @@ 'test' => 'Probar', ], + 'nav' => [ + 'workflow' => 'Workflow', + 'invocations' => 'Invocaciones', + 'metrics' => 'Métricas', + 'settings' => 'Configuración', + ], + + 'settings' => [ + 'general' => 'General', + 'general_description' => 'Renombra esta automatización.', + 'name_label' => 'Nombre', + 'name_saved' => 'Automatización renombrada.', + 'status_title' => 'Estado', + 'status_description' => 'Actívala para que empiece a ejecutarse, o pausa para detenerla.', + 'activated_at' => 'Activada el :date', + 'paused_at' => 'Pausada el :date', + 'created_at' => 'Creada el :date', + 'danger_title' => 'Zona de peligro', + 'danger_description' => 'Acciones irreversibles.', + 'delete_title' => 'Eliminar esta automatización', + 'delete_description' => 'Elimina permanentemente la automatización y su historial de ejecuciones.', + ], + + 'status_run' => [ + 'pending' => 'Pendiente', + 'running' => 'Ejecutando', + 'waiting' => 'Esperando', + 'completed' => 'Completado', + 'failed' => 'Fallido', + 'cancelled' => 'Cancelado', + ], + + 'node_type' => [ + 'trigger' => 'Disparador', + 'generate' => 'Generar contenido', + 'delay' => 'Espera', + 'condition' => 'Condición', + 'publish' => 'Publicar', + 'webhook' => 'Webhook', + 'end' => 'Fin', + 'fetch_rss' => 'Obtener RSS', + 'http_request' => 'Petición HTTP', + ], + + 'invocations' => [ + 'empty' => 'Aún no hay invocaciones.', + 'refresh' => 'Actualizar', + 'search_placeholder' => 'Buscar por ID de ejecución…', + 'copied' => 'ID de ejecución copiado.', + 'loading' => 'Cargando pasos…', + 'no_steps' => 'Sin pasos registrados.', + 'load_error' => 'No se pudieron cargar los pasos.', + 'steps' => '{0}Sin pasos|{1}:count paso|[2,*]:count pasos', + 'filter' => [ + 'all' => 'Todos los estados', + ], + 'columns' => [ + 'timestamp' => 'Fecha', + 'run' => 'Ejecución', + 'status' => 'Estado', + 'message' => 'Último mensaje', + 'duration' => 'Duración', + ], + 'summary' => [ + 'completed' => 'Workflow completado', + 'failed' => 'Workflow fallido', + 'running' => 'Workflow en ejecución', + 'cancelled' => 'Workflow cancelado', + 'pending' => 'Workflow pendiente', + ], + ], + + 'metrics' => [ + 'overview' => 'Resumen', + 'runs_over_time' => 'Ejecuciones a lo largo del tiempo', + 'posts_by_platform' => 'Posts por plataforma', + 'no_posts' => 'No se publicaron posts en este período.', + 'cards' => [ + 'runs' => 'Total de ejecuciones', + 'completed' => 'Completadas', + 'failed' => 'Fallidas', + 'in_progress' => 'En progreso', + 'success_rate' => 'Tasa de éxito', + 'avg_duration' => 'Duración media', + 'posts_created' => 'Posts creados', + ], + 'legend' => [ + 'started' => 'Iniciadas', + 'completed' => 'Completadas', + 'failed' => 'Fallidas', + ], + ], + 'categories' => [ 'sources' => 'Fuentes', 'content' => 'Contenido', @@ -42,19 +135,26 @@ ], 'guide' => [ - 'title' => 'Cómo usar automatizaciones', - 'subtitle' => 'Una referencia rápida para construir tu flujo.', - 'flow_title' => 'Cómo funciona', - 'flow_text' => 'Un disparador (una programación, o un post publicado/programado) inicia el flujo. Las acciones intermedias obtienen datos, generan contenido o se ramifican por condiciones. Los nodos de salida publican posts o llaman webhooks.', - 'data_title' => 'Pasar datos entre nodos', - 'data_text' => 'Referencia datos de nodos anteriores en cualquier campo. Escribe el nombre entre llaves dobles, por ejemplo:', - 'refs' => [ - 'trigger_post' => 'El post que disparó un disparador de post', - 'fetched_title' => 'Elemento actual del RSS / respuesta HTTP', - 'fetched_link' => 'Su enlace', - 'generated' => 'El post generado por IA', - 'now' => 'Fecha y hora actuales', + 'title' => 'Cómo funcionan las automatizaciones', + 'subtitle' => 'Construye un flujo y conecta los datos entre nodos.', + 'tabs' => [ + 'overview' => 'Resumen', + 'expressions' => 'Expresiones', ], + 'flow_title' => 'El flujo', + 'flow_text' => 'Un disparador (una programación, o un post publicado/programado) inicia el flujo. Las acciones intermedias obtienen datos, generan contenido o se ramifican por condiciones. Los nodos de salida publican posts o llaman webhooks.', + 'scope_title' => 'Qué datos están disponibles', + 'scope_text' => 'Un nodo solo puede usar datos de nodos conectados antes de él (upstream). En cualquier campo de texto, escribe {{ para autocompletar lo disponible — las referencias desconocidas se señalan.', + 'data_text' => 'Envuelve la ruta en llaves dobles. Qué rutas existen depende de los nodos que se ejecutan antes:', + 'groups' => [ + 'trigger_schedule' => 'Disparador programado', + 'trigger_post' => 'Disparador de post', + 'fetch_rss' => 'Obtener RSS', + 'http' => 'Petición HTTP', + 'generate' => 'Generar contenido', + 'always' => 'Siempre disponible', + ], + 'http_note' => 'Añade cualquier campo de la respuesta JSON, ej: {{ fetched.data.0.title }}.', 'vars_title' => 'Variables', 'vars_text' => 'Define valores reutilizables (API keys, URLs base) en la pestaña Variables y referéncialos por clave. Los secretos se almacenan cifrados.', 'tip_text' => 'Ejecuta una Prueba para ver exactamente qué produce cada nodo.', @@ -116,20 +216,6 @@ ], ], - 'show' => [ - 'activated' => 'Activada', - 'tabs' => [ - 'overview' => 'Resumen', - 'runs' => 'Ejecuciones', - 'trigger_items' => 'Elementos del disparador', - ], - 'canvas_placeholder' => 'Vista previa del canvas (solo lectura)', - 'empty_runs' => 'Aún no hay ejecuciones.', - 'empty_trigger_items' => 'Aún no hay elementos del disparador.', - 'started' => 'Iniciada', - 'run_label' => 'Ejecución', - ], - 'form' => [ 'activate_error_fallback' => 'No se pudo activar la automatización.', 'pause_error_fallback' => 'No se pudo pausar la automatización.', diff --git a/lang/pt-BR/automations.php b/lang/pt-BR/automations.php index f44b07ce..839c39dd 100644 --- a/lang/pt-BR/automations.php +++ b/lang/pt-BR/automations.php @@ -23,6 +23,99 @@ 'test' => 'Testar', ], + 'nav' => [ + 'workflow' => 'Workflow', + 'invocations' => 'Invocações', + 'metrics' => 'Métricas', + 'settings' => 'Configurações', + ], + + 'settings' => [ + 'general' => 'Geral', + 'general_description' => 'Renomeie esta automação.', + 'name_label' => 'Nome', + 'name_saved' => 'Automação renomeada.', + 'status_title' => 'Status', + 'status_description' => 'Ative para começar a rodar, ou pause para parar.', + 'activated_at' => 'Ativada em :date', + 'paused_at' => 'Pausada em :date', + 'created_at' => 'Criada em :date', + 'danger_title' => 'Zona de perigo', + 'danger_description' => 'Ações irreversíveis.', + 'delete_title' => 'Excluir esta automação', + 'delete_description' => 'Remove permanentemente a automação e o histórico de execuções.', + ], + + 'status_run' => [ + 'pending' => 'Pendente', + 'running' => 'Executando', + 'waiting' => 'Aguardando', + 'completed' => 'Concluído', + 'failed' => 'Falhou', + 'cancelled' => 'Cancelado', + ], + + 'node_type' => [ + 'trigger' => 'Gatilho', + 'generate' => 'Gerar conteúdo', + 'delay' => 'Espera', + 'condition' => 'Condição', + 'publish' => 'Publicar', + 'webhook' => 'Webhook', + 'end' => 'Fim', + 'fetch_rss' => 'Buscar RSS', + 'http_request' => 'Requisição HTTP', + ], + + 'invocations' => [ + 'empty' => 'Nenhuma invocação ainda.', + 'refresh' => 'Atualizar', + 'search_placeholder' => 'Buscar por ID do run…', + 'copied' => 'ID do run copiado.', + 'loading' => 'Carregando passos…', + 'no_steps' => 'Nenhum passo registrado.', + 'load_error' => 'Não foi possível carregar os passos.', + 'steps' => '{0}Nenhum passo|{1}:count passo|[2,*]:count passos', + 'filter' => [ + 'all' => 'Todos os status', + ], + 'columns' => [ + 'timestamp' => 'Data', + 'run' => 'Run', + 'status' => 'Status', + 'message' => 'Última mensagem', + 'duration' => 'Duração', + ], + 'summary' => [ + 'completed' => 'Workflow concluído', + 'failed' => 'Workflow falhou', + 'running' => 'Workflow em execução', + 'cancelled' => 'Workflow cancelado', + 'pending' => 'Workflow pendente', + ], + ], + + 'metrics' => [ + 'overview' => 'Visão geral', + 'runs_over_time' => 'Execuções ao longo do tempo', + 'posts_by_platform' => 'Posts por plataforma', + 'no_posts' => 'Nenhum post publicado neste período.', + 'cards' => [ + 'runs' => 'Total de execuções', + 'completed' => 'Concluídas', + 'failed' => 'Falhas', + 'in_progress' => 'Em progresso', + 'success_rate' => 'Taxa de sucesso', + 'avg_duration' => 'Duração média', + 'posts_created' => 'Posts criados', + ], + 'legend' => [ + 'started' => 'Iniciadas', + 'completed' => 'Concluídas', + 'failed' => 'Falhas', + ], + ], + 'categories' => [ 'sources' => 'Fontes', 'content' => 'Conteúdo', @@ -42,19 +135,26 @@ ], 'guide' => [ - 'title' => 'Como usar automações', - 'subtitle' => 'Uma referência rápida pra montar seu fluxo.', - 'flow_title' => 'Como funciona', - 'flow_text' => 'Um trigger (um agendamento, ou um post publicado/agendado) inicia o fluxo. As ações no meio buscam dados, geram conteúdo ou ramificam por condições. Os nós de saída publicam posts ou chamam webhooks.', - 'data_title' => 'Passando dados entre nós', - 'data_text' => 'Referencie dados de nós anteriores em qualquer campo. Escreva o nome entre chaves duplas, por exemplo:', - 'refs' => [ - 'trigger_post' => 'O post que disparou um trigger de post', - 'fetched_title' => 'Item atual do RSS / resposta HTTP', - 'fetched_link' => 'O link dele', - 'generated' => 'O post gerado pela IA', - 'now' => 'Data e hora atuais', + 'title' => 'Como as automações funcionam', + 'subtitle' => 'Monte um fluxo e conecte os dados entre os nós.', + 'tabs' => [ + 'overview' => 'Visão geral', + 'expressions' => 'Expressões', ], + 'flow_title' => 'O fluxo', + 'flow_text' => 'Um trigger (um agendamento, ou um post publicado/agendado) inicia o fluxo. As ações no meio buscam dados, geram conteúdo ou ramificam por condições. Os nós de saída publicam posts ou chamam webhooks.', + 'scope_title' => 'Quais dados estão disponíveis', + 'scope_text' => 'Um nó só pode usar dados de nós conectados antes dele (upstream). Em qualquer campo de texto, digite {{ pra autocompletar o que está disponível — referências desconhecidas são sinalizadas.', + 'data_text' => 'Envolva o caminho em chaves duplas. Quais caminhos existem depende dos nós que rodam antes:', + 'groups' => [ + 'trigger_schedule' => 'Trigger agendado', + 'trigger_post' => 'Trigger de post', + 'fetch_rss' => 'Buscar RSS', + 'http' => 'Requisição HTTP', + 'generate' => 'Gerar conteúdo', + 'always' => 'Sempre disponível', + ], + 'http_note' => 'Adicione qualquer campo da resposta JSON, ex: {{ fetched.data.0.title }}.', 'vars_title' => 'Variáveis', 'vars_text' => 'Defina valores reutilizáveis (API keys, base URLs) na aba Variáveis e referencie pela chave. Segredos são armazenados encriptados.', 'tip_text' => 'Rode um Teste pra ver exatamente o que cada nó produz.', @@ -116,20 +216,6 @@ ], ], - 'show' => [ - 'activated' => 'Ativada', - 'tabs' => [ - 'overview' => 'Visão geral', - 'runs' => 'Execuções', - 'trigger_items' => 'Itens do trigger', - ], - 'canvas_placeholder' => 'Pré-visualização do canvas (somente leitura)', - 'empty_runs' => 'Nenhuma execução ainda.', - 'empty_trigger_items' => 'Nenhum item de trigger ainda.', - 'started' => 'Iniciada', - 'run_label' => 'Execução', - ], - 'form' => [ 'activate_error_fallback' => 'Não foi possível ativar a automação.', 'pause_error_fallback' => 'Não foi possível pausar a automação.', diff --git a/resources/js/components/automations/AutomationDetailLayout.vue b/resources/js/components/automations/AutomationDetailLayout.vue new file mode 100644 index 00000000..8b9ff418 --- /dev/null +++ b/resources/js/components/automations/AutomationDetailLayout.vue @@ -0,0 +1,26 @@ + + + diff --git a/resources/js/components/automations/AutomationHeader.vue b/resources/js/components/automations/AutomationHeader.vue new file mode 100644 index 00000000..51b2cd29 --- /dev/null +++ b/resources/js/components/automations/AutomationHeader.vue @@ -0,0 +1,51 @@ + + + diff --git a/resources/js/components/automations/AutomationRunsChart.vue b/resources/js/components/automations/AutomationRunsChart.vue new file mode 100644 index 00000000..c1cf4daa --- /dev/null +++ b/resources/js/components/automations/AutomationRunsChart.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/resources/js/components/automations/AutomationTabsNav.vue b/resources/js/components/automations/AutomationTabsNav.vue new file mode 100644 index 00000000..095a0bde --- /dev/null +++ b/resources/js/components/automations/AutomationTabsNav.vue @@ -0,0 +1,37 @@ + + + diff --git a/resources/js/components/automations/EditorGuide.vue b/resources/js/components/automations/EditorGuide.vue index 37cb28a0..2e64fb01 100644 --- a/resources/js/components/automations/EditorGuide.vue +++ b/resources/js/components/automations/EditorGuide.vue @@ -1,72 +1,119 @@ diff --git a/resources/js/pages/automations/Form.vue b/resources/js/pages/automations/Form.vue index 80136b4b..e6f3962d 100644 --- a/resources/js/pages/automations/Form.vue +++ b/resources/js/pages/automations/Form.vue @@ -1,5 +1,5 @@ + + diff --git a/resources/js/pages/automations/Metrics.vue b/resources/js/pages/automations/Metrics.vue new file mode 100644 index 00000000..321c1e8c --- /dev/null +++ b/resources/js/pages/automations/Metrics.vue @@ -0,0 +1,125 @@ + + + diff --git a/resources/js/pages/automations/Settings.vue b/resources/js/pages/automations/Settings.vue new file mode 100644 index 00000000..3aa7d822 --- /dev/null +++ b/resources/js/pages/automations/Settings.vue @@ -0,0 +1,213 @@ + + + diff --git a/resources/js/pages/automations/Show.vue b/resources/js/pages/automations/Show.vue deleted file mode 100644 index af3c4ba5..00000000 --- a/resources/js/pages/automations/Show.vue +++ /dev/null @@ -1,205 +0,0 @@ - - - diff --git a/routes/app.php b/routes/app.php index 05333493..fe17fe82 100644 --- a/routes/app.php +++ b/routes/app.php @@ -211,7 +211,10 @@ Route::get('automations', [AutomationController::class, 'index'])->name('app.automations.index'); Route::post('automations', [AutomationController::class, 'store'])->name('app.automations.store'); Route::get('automations/{automation}', [AutomationController::class, 'show'])->name('app.automations.show'); - Route::get('automations/{automation}/edit', [AutomationController::class, 'edit'])->name('app.automations.edit'); + Route::get('automations/{automation}/workflow', [AutomationController::class, 'workflow'])->name('app.automations.workflow'); + Route::get('automations/{automation}/invocations', [AutomationController::class, 'invocations'])->name('app.automations.invocations'); + Route::get('automations/{automation}/metrics', [AutomationController::class, 'metrics'])->name('app.automations.metrics'); + Route::get('automations/{automation}/settings', [AutomationController::class, 'settings'])->name('app.automations.settings'); Route::put('automations/{automation}', [AutomationController::class, 'update'])->name('app.automations.update'); Route::delete('automations/{automation}', [AutomationController::class, 'destroy'])->name('app.automations.destroy'); Route::post('automations/{automation}/activate', [AutomationController::class, 'activate'])->name('app.automations.activate'); diff --git a/tests/Feature/Automation/Automation/ReadActionsTest.php b/tests/Feature/Automation/Automation/ReadActionsTest.php index 731739d8..dd967505 100644 --- a/tests/Feature/Automation/Automation/ReadActionsTest.php +++ b/tests/Feature/Automation/Automation/ReadActionsTest.php @@ -3,13 +3,16 @@ declare(strict_types=1); use App\Actions\Automation\Automation\DeleteAutomation; -use App\Actions\Automation\Automation\GetAutomationDetails; use App\Actions\Automation\Automation\GetAutomationEditorData; +use App\Actions\Automation\Automation\GetAutomationInvocations; +use App\Actions\Automation\Automation\GetAutomationMetrics; use App\Actions\Automation\Automation\ListAutomations; +use App\Enums\Automation\Run\Status as RunStatus; use App\Enums\SocialAccount\Platform; use App\Models\Automation; use App\Models\AutomationRun; -use App\Models\AutomationTriggerItem; +use App\Models\Post; +use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Models\Workspace; use App\Services\Social\PinterestPublisher; @@ -38,18 +41,80 @@ expect(Automation::find($automation->id))->toBeNull(); }); -it('returns non-dry runs and trigger items, newest first', function () { +it('shows only production runs — never dry runs or manual test runs', function () { $automation = Automation::factory()->create(); $real = AutomationRun::factory()->for($automation)->create(); AutomationRun::factory()->for($automation)->create(['is_dry_run' => true]); - $item = AutomationTriggerItem::factory()->for($automation)->create(); + // A "test with real data" run: not a dry run, but still manual — must be hidden. + AutomationRun::factory()->for($automation)->create(['is_manual' => true, 'is_dry_run' => false]); - $result = app(GetAutomationDetails::class)($automation); + $result = app(GetAutomationInvocations::class)($automation); - expect($result['runs'])->toHaveCount(1); - expect($result['runs']->first()->id)->toBe($real->id); - expect($result['triggerItems'])->toHaveCount(1); - expect($result['triggerItems']->first()->id)->toBe($item->id); + expect($result->total())->toBe(1); + expect($result->items()[0]->id)->toBe($real->id); + expect($result->items()[0]->node_runs_count)->toBe(0); +}); + +it('filters invocations by status', function () { + $automation = Automation::factory()->create(); + $failed = AutomationRun::factory()->for($automation)->create(['status' => RunStatus::Failed->value]); + AutomationRun::factory()->for($automation)->create(['status' => RunStatus::Completed->value]); + + $result = app(GetAutomationInvocations::class)($automation, RunStatus::Failed->value); + + expect($result->total())->toBe(1); + expect($result->items()[0]->id)->toBe($failed->id); +}); + +it('searches invocations by run id', function () { + $automation = Automation::factory()->create(); + $match = AutomationRun::factory()->for($automation)->create(); + AutomationRun::factory()->for($automation)->create(); + + // UUIDv7 ids share a timestamp prefix, so match on the random suffix to + // assert the LIKE actually narrows the result set. + $result = app(GetAutomationInvocations::class)($automation, null, substr($match->id, -8)); + + expect($result->total())->toBe(1); + expect($result->items()[0]->id)->toBe($match->id); +}); + +it('aggregates run metrics over the period, excluding dry runs', function () { + $automation = Automation::factory()->create(); + AutomationRun::factory()->for($automation)->create([ + 'status' => RunStatus::Completed->value, + 'started_at' => now()->subSeconds(2), + 'finished_at' => now(), + ]); + AutomationRun::factory()->for($automation)->create(['status' => RunStatus::Failed->value]); + AutomationRun::factory()->for($automation)->create(['status' => RunStatus::Completed->value, 'is_dry_run' => true]); + // A manual "test with real data" run must not inflate the metrics either. + AutomationRun::factory()->for($automation)->create(['status' => RunStatus::Completed->value, 'is_manual' => true]); + + $metrics = app(GetAutomationMetrics::class)($automation, now()->subDays(6), now()); + + expect($metrics['totals']['runs'])->toBe(2); + expect($metrics['totals']['completed'])->toBe(1); + expect($metrics['totals']['failed'])->toBe(1); + expect($metrics['totals']['success_rate'])->toBe(50); + expect($metrics['timeseries'])->toHaveCount(7); + expect($metrics['timeseries'][6]['started'])->toBe(2); + expect($metrics['timeseries'][6]['completed'])->toBe(1); + expect($metrics['timeseries'][6]['failed'])->toBe(1); +}); + +it('breaks down generated posts by platform', function () { + $automation = Automation::factory()->create(); + $post = Post::factory()->create(); + PostPlatform::factory()->for($post)->create(['platform' => Platform::LinkedIn->value]); + PostPlatform::factory()->for($post)->create(['platform' => Platform::X->value]); + AutomationRun::factory()->for($automation)->create(['generated_post_id' => $post->id]); + + $metrics = app(GetAutomationMetrics::class)($automation, now()->subDays(6), now()); + + expect($metrics['platforms'])->toHaveCount(2); + expect(collect($metrics['platforms'])->pluck('platform')->all()) + ->toContain(Platform::LinkedIn->value, Platform::X->value); }); it('returns only active social accounts for the automation workspace', function () { diff --git a/tests/Feature/Automation/AutomationCrudTest.php b/tests/Feature/Automation/AutomationCrudTest.php index 6ca75980..43d24ea1 100644 --- a/tests/Feature/Automation/AutomationCrudTest.php +++ b/tests/Feature/Automation/AutomationCrudTest.php @@ -188,11 +188,19 @@ $otherAutomation = Automation::factory()->for($otherWorkspace)->create(); $this->actingAs($this->user) - ->get(route('app.automations.show', $otherAutomation->id)) + ->get(route('app.automations.workflow', $otherAutomation->id)) ->assertForbidden(); $this->actingAs($this->user) - ->get(route('app.automations.edit', $otherAutomation->id)) + ->get(route('app.automations.invocations', $otherAutomation->id)) + ->assertForbidden(); + + $this->actingAs($this->user) + ->get(route('app.automations.metrics', $otherAutomation->id)) + ->assertForbidden(); + + $this->actingAs($this->user) + ->get(route('app.automations.settings', $otherAutomation->id)) ->assertForbidden(); $this->actingAs($this->user) diff --git a/tests/Feature/Automation/DetailTabsTest.php b/tests/Feature/Automation/DetailTabsTest.php new file mode 100644 index 00000000..8d9e8244 --- /dev/null +++ b/tests/Feature/Automation/DetailTabsTest.php @@ -0,0 +1,95 @@ +workspace = Workspace::factory()->create(); + $this->user = User::factory()->create(['current_workspace_id' => $this->workspace->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]); + $this->user->refresh(); + $this->automation = Automation::factory()->for($this->workspace)->create(); +}); + +it('redirects the bare automation URL to the workflow tab', function () { + $this->actingAs($this->user) + ->get(route('app.automations.show', $this->automation->id)) + ->assertRedirect(route('app.automations.workflow', $this->automation->id)); +}); + +it('renders the workflow editor on the workflow tab', function () { + $this->actingAs($this->user) + ->get(route('app.automations.workflow', $this->automation->id)) + ->assertOk() + ->assertInertia(fn ($page) => $page->component('automations/Form')); +}); + +it('renders the settings tab', function () { + $this->actingAs($this->user) + ->get(route('app.automations.settings', $this->automation->id)) + ->assertOk() + ->assertInertia(fn ($page) => $page->component('automations/Settings')); +}); + +it('renames the automation from settings without wiping the graph', function () { + $automation = Automation::factory()->for($this->workspace)->withScheduleTrigger()->create(); + $originalNodes = $automation->nodes; + + $this->actingAs($this->user) + ->put(route('app.automations.update', $automation->id), ['name' => 'Renamed flow']) + ->assertRedirect(); + + $automation->refresh(); + expect($automation->name)->toBe('Renamed flow'); + expect($automation->nodes)->toBe($originalNodes); +}); + +it('renders the invocations tab with a scroll-paginated list', function () { + AutomationRun::factory()->for($this->automation)->create(); + + $this->actingAs($this->user) + ->get(route('app.automations.invocations', $this->automation->id)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('automations/Invocations') + ->has('invocations.data', 1) + ); +}); + +it('renders the metrics tab with aggregated totals over a default 7-day window', function () { + $this->actingAs($this->user) + ->get(route('app.automations.metrics', $this->automation->id)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('automations/Metrics') + ->has('filters.start') + ->has('filters.end') + ->has('metrics.totals') + ->has('metrics.timeseries', 7) + ); +}); + +it('honours an explicit date range and orders a reversed range', function () { + $this->actingAs($this->user) + ->get(route('app.automations.metrics', $this->automation->id).'?start=2026-06-01&end=2026-06-10') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('filters.start', '2026-06-01') + ->where('filters.end', '2026-06-10') + ->has('metrics.timeseries', 10) + ); + + // start after end → the controller swaps them rather than erroring. + $this->actingAs($this->user) + ->get(route('app.automations.metrics', $this->automation->id).'?start=2026-06-10&end=2026-06-01') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('filters.start', '2026-06-01') + ->where('filters.end', '2026-06-10') + ); +}); diff --git a/tests/Feature/Automation/DryRunTest.php b/tests/Feature/Automation/DryRunTest.php index 356a3761..27347633 100644 --- a/tests/Feature/Automation/DryRunTest.php +++ b/tests/Feature/Automation/DryRunTest.php @@ -218,26 +218,27 @@ Bus::assertNotDispatched(ProcessAutomationNode::class); }); -it('excludingDryRuns scope filters out dry rows', function () { +it('productionRuns scope filters out dry runs and manual test runs', function () { $automation = Automation::factory()->for($this->workspace)->create(); - AutomationRun::factory()->for($automation)->count(2)->create(['is_dry_run' => false]); + AutomationRun::factory()->for($automation)->count(2)->create(['is_manual' => false, 'is_dry_run' => false]); AutomationRun::factory()->for($automation)->count(3)->create(['is_dry_run' => true]); + AutomationRun::factory()->for($automation)->create(['is_manual' => true, 'is_dry_run' => false]); - expect(AutomationRun::query()->count())->toBe(5); - expect(AutomationRun::query()->excludingDryRuns()->count())->toBe(2); + expect(AutomationRun::query()->count())->toBe(6); + expect(AutomationRun::query()->productionRuns()->count())->toBe(2); }); -it('does not show dry runs in the Show controller history', function () { +it('does not show dry runs in the invocations history', function () { $automation = Automation::factory()->for($this->workspace)->create(); $real = AutomationRun::factory()->for($automation)->create(['is_dry_run' => false]); AutomationRun::factory()->for($automation)->create(['is_dry_run' => true]); $this->actingAs($this->user) - ->get(route('app.automations.show', $automation->id)) + ->get(route('app.automations.invocations', $automation->id)) ->assertOk() ->assertInertia(fn ($page) => $page - ->has('runs', 1) - ->where('runs.0.id', $real->id) + ->has('invocations.data', 1) + ->where('invocations.data.0.id', $real->id) ); });