Add Workflow/Invocations/Metrics/Settings tabs to automations

Split the automation detail screen into four route-based tabs behind a
shared AutomationHeader:

- Workflow: the existing editor canvas.
- Invocations: a paginated, filterable run log with expandable per-node
  detail, a refresh control, and a loading state.
- Metrics: KPI cards, a runs-over-time @unovis chart with locale-aware
  date labels, and a posts-by-platform breakdown over a date range.
- Settings: rename, an activate/pause switch, and a danger-zone delete.

Invocations and Metrics report only real executions via a new
productionRuns scope, so manual test runs (dry or with real data) never
leak into the log or the charts. The now-unused excludingDryRuns scope
is removed.

Generated copy now flows the most-restrictive platform context through
the humanizer too, and the editor guide documents every available
expression grouped by source node.
This commit is contained in:
Paulo Castellano 2026-06-12 19:19:46 -03:00
parent a09b1f45c2
commit 3e43da29e0
25 changed files with 1894 additions and 422 deletions

View file

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Automation;
use App\Models\Automation;
use App\Models\AutomationRun;
use App\Models\AutomationTriggerItem;
use Illuminate\Database\Eloquent\Collection;
class GetAutomationDetails
{
/**
* @return array{
* runs: Collection<int, AutomationRun>,
* triggerItems: Collection<int, AutomationTriggerItem>,
* }
*/
public function __invoke(Automation $automation): array
{
return [
'runs' => $automation->runs()->excludingDryRuns()->latest()->take(50)->get(),
'triggerItems' => $automation->triggerItems()->with('run')->latest()->take(50)->get(),
];
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Automation;
use App\Models\Automation;
use App\Models\AutomationRun;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
class GetAutomationInvocations
{
/**
* Paginated, real (non-dry-run) executions for the Invocations tab, newest
* first, each carrying the count of node runs it produced so the list can
* render a "Workflow completed · N steps" summary without N+1 queries.
*
* @return LengthAwarePaginator<int, AutomationRun>
*/
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);
}
}

View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Automation;
use App\Enums\Automation\Run\Status;
use App\Models\Automation;
use App\Models\AutomationRun;
use App\Models\PostPlatform;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
class GetAutomationMetrics
{
/**
* Aggregate run health and flow output for the Metrics tab over the given
* (inclusive) date range. App timezone is UTC, so day bucketing aligns with
* the stored UTC timestamps without conversion.
*
* @return array{
* totals: array{runs: int, completed: int, failed: int, in_progress: int, success_rate: ?int, avg_duration_ms: ?int, posts_created: int},
* timeseries: array<int, array{date: string, started: int, completed: int, failed: int}>,
* platforms: array<int, array{platform: string, count: int}>,
* }
*/
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<int, AutomationRun> $runs
* @return array<int, array{date: string, started: int, completed: int, failed: int}>
*/
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<int, string> $postIds
* @return array<int, array{platform: string, count: int}>
*/
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();
}
}

View file

@ -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(),
],
]);
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* A single execution row for the Invocations tab: enough to render the list
* (status, timing, step count, error summary) without loading every node run.
*/
class AutomationInvocationResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
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,
];
}
}

View file

@ -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);
}
}

View file

@ -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.',

View file

@ -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.',

View file

@ -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.',

View file

@ -0,0 +1,26 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import AutomationHeader from '@/components/automations/AutomationHeader.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import type { Automation } from '@/types/automation/automation';
defineProps<{
automation: Automation;
current: 'workflow' | 'invocations' | 'metrics' | 'settings';
}>();
</script>
<template>
<Head :title="automation.name" />
<AppLayout full-width>
<div class="flex min-h-0 flex-1 flex-col bg-background">
<AutomationHeader :automation="automation" :current="current" />
<div class="min-h-0 flex-1 overflow-y-auto">
<slot />
</div>
</div>
</AppLayout>
</template>

View file

@ -0,0 +1,51 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { IconArrowLeft, IconCircleCheck, IconCircleDot, IconCircleX } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import AutomationTabsNav from '@/components/automations/AutomationTabsNav.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { index as automationsIndex } from '@/routes/app/automations';
import type { Automation } from '@/types/automation/automation';
defineProps<{
automation: Automation;
current: 'workflow' | 'invocations' | 'metrics' | 'settings';
}>();
const statusConfig = (status: string) => {
const configs: Record<string, { icon: typeof IconCircleDot; label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
draft: { icon: IconCircleDot, label: trans('automations.status.draft'), variant: 'outline' },
active: { icon: IconCircleCheck, label: trans('automations.status.active'), variant: 'default' },
paused: { icon: IconCircleX, label: trans('automations.status.paused'), variant: 'secondary' },
};
return configs[status] ?? configs['draft'];
};
</script>
<template>
<div class="flex-shrink-0">
<header class="flex items-center justify-between gap-4 border-b-2 border-foreground/10 bg-card px-4 py-2">
<div class="flex min-w-0 items-center gap-3">
<Link :href="automationsIndex.url()">
<Button variant="outline" size="icon-sm">
<IconArrowLeft class="size-4" />
</Button>
</Link>
<div class="flex min-w-0 items-center gap-3">
<h1 class="truncate text-lg font-semibold">{{ automation.name }}</h1>
<Badge :variant="statusConfig(automation.status).variant" class="shrink-0">
<component :is="statusConfig(automation.status).icon" class="size-3" />
{{ statusConfig(automation.status).label }}
</Badge>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<slot name="actions" />
</div>
</header>
<AutomationTabsNav :automation-id="automation.id" :current="current" />
</div>
</template>

View file

@ -0,0 +1,74 @@
<script setup lang="ts">
import { CurveType } from '@unovis/ts';
import { VisArea, VisAxis, VisCrosshair, VisLine, VisTooltip, VisXYContainer } from '@unovis/vue';
import { getActiveLanguage } from 'laravel-vue-i18n';
import dayjs from '@/dayjs';
type Point = { date: string; started: number; completed: number; failed: number };
const props = defineProps<{ data: Point[] }>();
const seriesColors = {
started: '#6366f1',
completed: '#22c55e',
failed: '#ef4444',
};
// English puts the month first (MM/DD); pt-BR and es read day first (DD/MM).
const dayMonthFormat = getActiveLanguage().toLowerCase().startsWith('en') ? 'MM/DD' : 'DD/MM';
const x = (_d: Point, i: number) => i;
const yStarted = (d: Point) => d.started;
const yCompleted = (d: Point) => d.completed;
const yFailed = (d: Point) => d.failed;
const xTickFormat = (value: number): string => {
const point = props.data[Math.round(value)];
return point ? dayjs(point.date).format(dayMonthFormat) : '';
};
const tooltipTemplate = (d: Point): string =>
`<div style="font-size:12px;line-height:1.5">
<div style="font-weight:600;margin-bottom:2px">${dayjs(d.date).format(`${dayMonthFormat}/YYYY`)}</div>
<div style="color:${seriesColors.started}"> started: ${d.started}</div>
<div style="color:${seriesColors.completed}"> completed: ${d.completed}</div>
<div style="color:${seriesColors.failed}"> failed: ${d.failed}</div>
</div>`;
</script>
<template>
<VisXYContainer :data="data" :height="260" :margin="{ top: 12, right: 8, bottom: 4, left: 8 }">
<VisArea :x="x" :y="yStarted" :color="seriesColors.started" :opacity="0.1" :curve-type="CurveType.MonotoneX" />
<VisLine :x="x" :y="yStarted" :color="seriesColors.started" :line-width="2.5" :curve-type="CurveType.MonotoneX" />
<VisLine :x="x" :y="yCompleted" :color="seriesColors.completed" :line-width="2.5" :curve-type="CurveType.MonotoneX" />
<VisLine :x="x" :y="yFailed" :color="seriesColors.failed" :line-width="2.5" :curve-type="CurveType.MonotoneX" />
<VisAxis
type="x"
:tick-format="xTickFormat"
:num-ticks="6"
:grid-line="false"
:domain-line="false"
:tick-line="false"
color="var(--color-foreground)"
/>
<VisAxis
type="y"
:num-ticks="3"
:grid-line="false"
:domain-line="false"
:tick-line="false"
color="var(--color-foreground)"
/>
<VisCrosshair :template="tooltipTemplate" :color="seriesColors.started" />
<VisTooltip />
</VisXYContainer>
</template>
<style scoped>
:deep(.unovis-xy-container) {
--vis-axis-tick-label-color: color-mix(in oklab, var(--color-foreground) 45%, transparent);
--vis-axis-tick-label-font-size: 11px;
--vis-crosshair-line-stroke-color: color-mix(in oklab, var(--color-foreground) 20%, transparent);
}
</style>

View file

@ -0,0 +1,37 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import { invocations, metrics, settings, workflow } from '@/routes/app/automations';
const props = defineProps<{
automationId: string;
current: 'workflow' | 'invocations' | 'metrics' | 'settings';
}>();
const tabs = computed(() => [
{ key: 'workflow' as const, label: 'automations.nav.workflow', href: workflow.url(props.automationId) },
{ key: 'invocations' as const, label: 'automations.nav.invocations', href: invocations.url(props.automationId) },
{ key: 'metrics' as const, label: 'automations.nav.metrics', href: metrics.url(props.automationId) },
{ key: 'settings' as const, label: 'automations.nav.settings', href: settings.url(props.automationId) },
]);
</script>
<template>
<nav class="flex items-center gap-6 border-b-2 border-foreground/10 px-4">
<Link
v-for="tab in tabs"
:key="tab.key"
:href="tab.href"
:dusk="`automation-tab-${tab.key}`"
class="-mb-0.5 border-b-2 py-2.5 text-sm font-medium transition-colors"
:class="
tab.key === current
? 'border-primary text-foreground'
: 'border-transparent text-foreground/55 hover:text-foreground'
"
>
{{ $t(tab.label) }}
</Link>
</nav>
</template>

View file

@ -1,72 +1,119 @@
<script setup lang="ts">
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
const open = defineModel<boolean>('open', { default: false });
// The `{{ ... }}` snippets stay literal (they're code); only the short
// descriptions are translated.
const dataRefs = [
{ code: '{{ trigger.post.content }}', descKey: 'automations.guide.refs.trigger_post' },
{ code: '{{ fetched.title }}', descKey: 'automations.guide.refs.fetched_title' },
{ code: '{{ fetched.link }}', descKey: 'automations.guide.refs.fetched_link' },
{ code: '{{ generated.content }}', descKey: 'automations.guide.refs.generated' },
{ code: '{{ now }}', descKey: 'automations.guide.refs.now' },
// The `{{ ... }}` snippets stay literal (they're code). Descriptions reuse the
// same `automations.expr.*` keys the `{{` autocomplete shows, so the guide and
// the editor hints never drift apart.
const expressionGroups = [
{
titleKey: 'automations.guide.groups.trigger_schedule',
items: [
{ code: '{{ trigger.event }}', descKey: 'automations.expr.trigger_event' },
{ code: '{{ trigger.fired_at }}', descKey: 'automations.expr.trigger_fired_at' },
],
},
{
titleKey: 'automations.guide.groups.trigger_post',
items: [
{ code: '{{ trigger.post.id }}', descKey: 'automations.expr.trigger_post_id' },
{ code: '{{ trigger.post.content }}', descKey: 'automations.expr.trigger_post_content' },
{ code: '{{ trigger.post.status }}', descKey: 'automations.expr.trigger_post_status' },
{ code: '{{ trigger.post.scheduled_at }}', descKey: 'automations.expr.trigger_post_scheduled_at' },
{ code: '{{ trigger.post.published_at }}', descKey: 'automations.expr.trigger_post_published_at' },
],
},
{
titleKey: 'automations.guide.groups.fetch_rss',
items: [
{ code: '{{ fetched.title }}', descKey: 'automations.expr.fetched_title' },
{ code: '{{ fetched.link }}', descKey: 'automations.expr.fetched_link' },
{ code: '{{ fetched.description }}', descKey: 'automations.expr.fetched_description' },
{ code: '{{ fetched.pubDate }}', descKey: 'automations.expr.fetched_pubdate' },
],
},
{
titleKey: 'automations.guide.groups.http',
items: [{ code: '{{ fetched.<field> }}', descKey: 'automations.expr.fetched_http' }],
noteKey: 'automations.guide.http_note',
},
{
titleKey: 'automations.guide.groups.generate',
items: [
{ code: '{{ generated.content }}', descKey: 'automations.expr.generated_content' },
{ code: '{{ generated.post_url }}', descKey: 'automations.expr.generated_post_url' },
],
},
{
titleKey: 'automations.guide.groups.always',
items: [
{ code: '{{ variables.API_KEY }}', descKey: 'automations.expr.variable' },
{ code: '{{ now }}', descKey: 'automations.expr.now' },
],
},
];
const variableExample = '{{ variables.API_KEY }}';
</script>
<template>
<Sheet v-model:open="open">
<SheetContent side="right" class="w-full overflow-y-auto sm:max-w-md">
<SheetContent side="right" class="flex w-full flex-col overflow-hidden sm:max-w-md">
<SheetHeader>
<SheetTitle>{{ $t('automations.guide.title') }}</SheetTitle>
<SheetDescription>{{ $t('automations.guide.subtitle') }}</SheetDescription>
</SheetHeader>
<div class="space-y-6 px-4 pb-8">
<section class="space-y-1.5">
<h3 class="text-[11px] font-black uppercase tracking-widest text-foreground/50">
{{ $t('automations.guide.flow_title') }}
</h3>
<p class="text-sm text-foreground/70">{{ $t('automations.guide.flow_text') }}</p>
</section>
<Tabs default-value="overview" class="flex min-h-0 flex-1 flex-col px-4 pb-6">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="overview">{{ $t('automations.guide.tabs.overview') }}</TabsTrigger>
<TabsTrigger value="expressions">{{ $t('automations.guide.tabs.expressions') }}</TabsTrigger>
</TabsList>
<section class="space-y-2">
<h3 class="text-[11px] font-black uppercase tracking-widest text-foreground/50">
{{ $t('automations.guide.data_title') }}
</h3>
<TabsContent value="overview" class="mt-4 min-h-0 flex-1 space-y-6 overflow-y-auto">
<section class="space-y-1.5">
<h3 class="text-[11px] font-black uppercase tracking-widest text-foreground/50">
{{ $t('automations.guide.flow_title') }}
</h3>
<p class="text-sm text-foreground/70">{{ $t('automations.guide.flow_text') }}</p>
</section>
<section class="space-y-1.5">
<h3 class="text-[11px] font-black uppercase tracking-widest text-foreground/50">
{{ $t('automations.guide.scope_title') }}
</h3>
<p class="text-sm text-foreground/70">{{ $t('automations.guide.scope_text') }}</p>
</section>
<section class="space-y-2">
<h3 class="text-[11px] font-black uppercase tracking-widest text-foreground/50">
{{ $t('automations.guide.vars_title') }}
</h3>
<p class="text-sm text-foreground/70">{{ $t('automations.guide.vars_text') }}</p>
</section>
<section class="rounded-xl border-2 border-foreground bg-amber-50 p-3 shadow-[3px_3px_0_var(--foreground)]">
<p class="text-sm font-medium text-foreground">💡 {{ $t('automations.guide.tip_text') }}</p>
</section>
</TabsContent>
<TabsContent value="expressions" class="mt-4 min-h-0 flex-1 space-y-3 overflow-y-auto">
<p class="text-sm text-foreground/70">{{ $t('automations.guide.data_text') }}</p>
<div class="space-y-1.5">
<div v-for="group in expressionGroups" :key="group.titleKey" class="space-y-1.5">
<h4 class="text-xs font-bold text-foreground/70">{{ $t(group.titleKey) }}</h4>
<div
v-for="ref in dataRefs"
:key="ref.code"
v-for="item in group.items"
:key="item.code"
class="flex flex-col gap-0.5 rounded-lg border-2 border-foreground/15 bg-card/50 p-2.5"
>
<code class="font-mono text-xs font-bold text-foreground">{{ ref.code }}</code>
<span class="text-xs text-foreground/55">{{ $t(ref.descKey) }}</span>
<code class="font-mono text-xs font-bold text-foreground">{{ item.code }}</code>
<span class="text-xs text-foreground/55">{{ $t(item.descKey) }}</span>
</div>
<p v-if="group.noteKey" class="text-xs text-foreground/55">{{ $t(group.noteKey) }}</p>
</div>
</section>
<section class="space-y-2">
<h3 class="text-[11px] font-black uppercase tracking-widest text-foreground/50">
{{ $t('automations.guide.vars_title') }}
</h3>
<p class="text-sm text-foreground/70">{{ $t('automations.guide.vars_text') }}</p>
<code class="inline-block rounded-lg border-2 border-foreground/15 bg-card/50 px-2.5 py-1.5 font-mono text-xs font-bold text-foreground">{{ variableExample }}</code>
</section>
<section class="rounded-xl border-2 border-foreground bg-amber-50 p-3 shadow-[3px_3px_0_var(--foreground)]">
<p class="text-sm font-medium text-foreground">💡 {{ $t('automations.guide.tip_text') }}</p>
</section>
</div>
</TabsContent>
</Tabs>
</SheetContent>
</Sheet>
</template>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { Head, router } from '@inertiajs/vue3';
import { IconBolt, IconHelp } from '@tabler/icons-vue';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
@ -23,6 +23,7 @@ import '@vue-flow/controls/dist/style.css';
import AutomationConnectionLine from '@/components/automations/AutomationConnectionLine.vue';
import AutomationHeader from '@/components/automations/AutomationHeader.vue';
import ConditionNodeConfig from '@/components/automations/config/ConditionNodeConfig.vue';
import DelayNodeConfig from '@/components/automations/config/DelayNodeConfig.vue';
import EndNodeConfig from '@/components/automations/config/EndNodeConfig.vue';
@ -51,14 +52,11 @@ import { RemoveEdgeCommand } from '@/composables/history/commands/RemoveEdgeComm
import { RemoveNodeCommand } from '@/composables/history/commands/RemoveNodeCommand';
import { UpdateNodeDataCommand } from '@/composables/history/commands/UpdateNodeDataCommand';
import { useHistory } from '@/composables/history/useHistory';
import { buildExpressionCatalog } from '@/composables/useExpressionCompletions';
import { usePageErrors } from '@/composables/usePageErrors';
import { useShortcut } from '@/composables/useShortcut';
import { buildExpressionCatalog } from '@/composables/useExpressionCompletions';
import AppLayout from '@/layouts/AppLayout.vue';
import {
show as showAutomation,
update as updateAutomation,
} from '@/routes/app/automations';
import { update as updateAutomation } from '@/routes/app/automations';
import type { Automation, AutomationVariable } from '@/types/automation/automation';
import { NodeType } from '@/types/automation/node-type';
import type { RawConnection } from '@/types/automation/raw-connection';
@ -104,7 +102,6 @@ const nodes = ref<Node[]>(props.automation.nodes ?? []);
const edges = ref<Edge[]>(hydrateEdges(props.automation.connections ?? []));
const selectedNodeId = ref<string | null>(null);
const selectedEdgeId = ref<string | null>(null);
const name = ref(props.automation.name);
const variables = ref<AutomationVariable[]>(props.automation.variables ?? []);
watch(
@ -119,10 +116,6 @@ watch(
},
);
watch(() => props.automation.name, (newName) => {
name.value = newName;
});
watch(() => props.automation.variables, (newVariables) => {
variables.value = newVariables ?? [];
});
@ -373,7 +366,6 @@ const save = (): Promise<boolean> =>
router.put(
updateAutomation.url(props.automation.id),
{
name: name.value.trim() || props.automation.name,
nodes: sanitizeNodes(nodes.value),
connections: sanitizeEdges(edges.value),
variables: variables.value.filter((variable) => variable.key.trim() !== ''),
@ -445,26 +437,15 @@ const defaultEdgeOptions = {
<AppLayout full-width>
<div class="flex min-h-0 flex-1 flex-col bg-background">
<header class="grid flex-shrink-0 grid-cols-[1fr_auto_1fr] items-center gap-3 border-b-2 border-foreground/10 bg-card px-4 py-2">
<div class="flex items-center">
<Link :href="showAutomation.url(automation.id)">
<Button variant="outline" size="sm"> {{ $t('common.back') }}</Button>
</Link>
</div>
<input
v-model="name"
type="text"
:placeholder="$t('automations.form.name_placeholder')"
class="w-72 rounded-md border-2 border-transparent bg-transparent px-3 py-1 text-center text-sm font-semibold text-foreground transition-colors hover:border-foreground/15 focus:border-foreground focus:bg-background focus:outline-none"
/>
<div class="flex items-center justify-end gap-2">
<AutomationHeader :automation="automation" current="workflow">
<template #actions>
<Button variant="outline" size="sm" @click="isGuideOpen = true">
<IconHelp class="size-4" />
{{ $t('automations.actions.guide') }}
</Button>
<Button size="sm" @click="save" :disabled="isSaving">{{ $t('automations.actions.save') }}</Button>
</div>
</header>
</template>
</AutomationHeader>
<div class="flex flex-1 overflow-hidden">
<main

View file

@ -4,10 +4,10 @@ import { IconBolt, IconCircleCheck, IconCircleDot, IconCircleX, IconPlus } from
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue';
import PageHeader from '@/components/PageHeader.vue';
import EmptyState from '@/components/EmptyState.vue';
import { Button } from '@/components/ui/button';
import PageHeader from '@/components/PageHeader.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
@ -20,8 +20,8 @@ import {
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import {
show as showAutomation,
store as storeAutomation,
workflow as workflowAutomation,
} from '@/routes/app/automations';
import type { Automation } from '@/types/automation/automation';
@ -101,7 +101,7 @@ const handleCreate = () => {
v-for="automation in automations.data"
:key="automation.id"
class="cursor-pointer"
@click="router.visit(showAutomation.url(automation.id))"
@click="router.visit(workflowAutomation.url(automation.id))"
>
<TableCell class="font-medium">{{ automation.name }}</TableCell>
<TableCell>

View file

@ -0,0 +1,479 @@
<script setup lang="ts">
import { InfiniteScroll, router } from '@inertiajs/vue3';
import { IconChevronRight, IconCopy, IconRefresh } from '@tabler/icons-vue';
import { trans, transChoice } from 'laravel-vue-i18n';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { showRun as showRunRoute } from '@/actions/App/Http/Controllers/App/AutomationController';
import AutomationDetailLayout from '@/components/automations/AutomationDetailLayout.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Spinner } from '@/components/ui/spinner';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import TableLoadMore from '@/components/ui/table/TableLoadMore.vue';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import date from '@/date';
import type { Automation } from '@/types/automation/automation';
type Invocation = {
id: string;
status: string;
is_manual: boolean;
node_run_count: number;
duration_ms: number | null;
error_message: string | null;
created_at: string;
started_at: string | null;
finished_at: string | null;
};
type NodeRun = {
id: string;
node_id: string;
node_type: string;
status: string;
error: { message?: string } | null;
started_at: string | null;
finished_at: string | null;
};
const props = defineProps<{
automation: Automation;
invocations: { data: Invocation[] };
filters: { status: string | null; search: string | null };
}>();
const statusFilter = ref(props.filters.status ?? 'all');
const search = ref(props.filters.search ?? '');
const statusLabel = computed(() =>
statusFilter.value === 'all'
? trans('automations.invocations.filter.all')
: trans(`automations.status_run.${statusFilter.value}`),
);
const statusVariant = (
status: string,
): 'default' | 'secondary' | 'destructive' | 'outline' => {
if (status === 'completed') return 'default';
if (status === 'failed' || status === 'cancelled') return 'destructive';
if (status === 'running' || status === 'waiting') return 'secondary';
return 'outline';
};
const summary = (invocation: Invocation): string => {
if (invocation.status === 'failed')
return (
invocation.error_message ??
trans('automations.invocations.summary.failed')
);
if (invocation.status === 'completed')
return trans('automations.invocations.summary.completed');
if (invocation.status === 'running' || invocation.status === 'waiting')
return trans('automations.invocations.summary.running');
if (invocation.status === 'cancelled')
return trans('automations.invocations.summary.cancelled');
return trans('automations.invocations.summary.pending');
};
const formatDuration = (ms: number | null): string => {
if (ms === null) return '—';
if (ms < 1000) return `${ms}ms`;
const seconds = ms / 1000;
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ${Math.round(seconds % 60)}s`;
};
const stepsLabel = (count: number): string =>
transChoice('automations.invocations.steps', count, {
count: String(count),
});
const copyId = (id: string) => {
navigator.clipboard.writeText(id);
toast.success(trans('automations.invocations.copied'));
};
let searchTimer: ReturnType<typeof setTimeout> | undefined;
const isRefreshing = ref(false);
const reload = () => {
router.reload({
data: {
status:
statusFilter.value === 'all' ? undefined : statusFilter.value,
search: search.value || undefined,
},
only: ['invocations', 'filters'],
// `invocations` is an Inertia scroll/merge prop, so a plain reload would
// APPEND the filtered page onto the existing rows instead of replacing
// them. Resetting the prop clears the merged list before the new results.
reset: ['invocations'],
onStart: () => {
isRefreshing.value = true;
},
onFinish: () => {
isRefreshing.value = false;
},
});
};
watch(statusFilter, reload);
watch(search, () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(reload, 350);
});
const expanded = ref<Record<string, boolean>>({});
const nodeRuns = ref<Record<string, NodeRun[]>>({});
const loadingRuns = ref<Record<string, boolean>>({});
const toggleExpand = async (invocation: Invocation) => {
expanded.value[invocation.id] = !expanded.value[invocation.id];
if (expanded.value[invocation.id] && !nodeRuns.value[invocation.id]) {
loadingRuns.value[invocation.id] = true;
try {
const response = await fetch(
showRunRoute.url({
automation: props.automation.id,
run: invocation.id,
}),
{
headers: { Accept: 'application/json' },
},
);
const payload = await response.json();
nodeRuns.value[invocation.id] = payload.node_runs ?? [];
} catch {
toast.error(trans('automations.invocations.load_error'));
} finally {
loadingRuns.value[invocation.id] = false;
}
}
};
</script>
<template>
<AutomationDetailLayout :automation="automation" current="invocations">
<div class="space-y-4 p-4">
<div class="flex flex-wrap items-center gap-2">
<Select v-model="statusFilter">
<SelectTrigger
dusk="invocations-status-filter"
class="w-44"
>
<SelectValue>{{ statusLabel }}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{
$t('automations.invocations.filter.all')
}}</SelectItem>
<SelectItem value="completed">{{
$t('automations.status_run.completed')
}}</SelectItem>
<SelectItem value="failed">{{
$t('automations.status_run.failed')
}}</SelectItem>
<SelectItem value="running">{{
$t('automations.status_run.running')
}}</SelectItem>
<SelectItem value="waiting">{{
$t('automations.status_run.waiting')
}}</SelectItem>
</SelectContent>
</Select>
<Input
v-model="search"
:placeholder="
$t('automations.invocations.search_placeholder')
"
class="max-w-xs"
dusk="invocations-search"
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="outline"
size="icon"
:aria-label="
$t('automations.invocations.refresh')
"
:disabled="isRefreshing"
dusk="invocations-refresh"
@click="reload"
>
<IconRefresh
class="size-4"
:class="{ 'animate-spin': isRefreshing }"
/>
</Button>
</TooltipTrigger>
<TooltipContent>{{
$t('automations.invocations.refresh')
}}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div class="relative">
<div
:class="{
'pointer-events-none opacity-50 transition-opacity':
isRefreshing,
}"
>
<div
v-if="invocations.data.length === 0"
class="rounded-xl border-2 border-dashed border-foreground/25 bg-card p-12 text-center"
>
<p class="text-foreground/60">
{{ $t('automations.invocations.empty') }}
</p>
</div>
<InfiniteScroll
v-else
data="invocations"
items-element="#invocations-body"
preserve-url
>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-8"></TableHead>
<TableHead>{{
$t(
'automations.invocations.columns.timestamp',
)
}}</TableHead>
<TableHead>{{
$t(
'automations.invocations.columns.run',
)
}}</TableHead>
<TableHead>{{
$t(
'automations.invocations.columns.status',
)
}}</TableHead>
<TableHead>{{
$t(
'automations.invocations.columns.message',
)
}}</TableHead>
<TableHead class="text-right">{{
$t(
'automations.invocations.columns.duration',
)
}}</TableHead>
</TableRow>
</TableHeader>
<TableBody id="invocations-body">
<template
v-for="invocation in invocations.data"
:key="invocation.id"
>
<TableRow
class="cursor-pointer"
:dusk="`invocation-row-${invocation.id}`"
@click="toggleExpand(invocation)"
>
<TableCell>
<IconChevronRight
class="size-4 text-foreground/40 transition-transform"
:class="{
'rotate-90':
expanded[invocation.id],
}"
/>
</TableCell>
<TableCell
class="text-sm whitespace-nowrap text-foreground/70"
>{{
date.diffForHumans(
invocation.created_at,
)
}}</TableCell
>
<TableCell>
<button
type="button"
class="flex items-center gap-1.5 font-mono text-xs text-foreground/60 hover:text-foreground"
@click.stop="
copyId(invocation.id)
"
>
{{ invocation.id.slice(0, 8) }}
<IconCopy class="size-3" />
</button>
</TableCell>
<TableCell>
<Badge
:variant="
statusVariant(
invocation.status,
)
"
>{{
$t(
`automations.status_run.${invocation.status}`,
)
}}</Badge
>
</TableCell>
<TableCell class="max-w-md">
<p
class="truncate text-sm font-medium"
>
{{ summary(invocation) }}
</p>
<p
class="text-xs text-foreground/50"
>
{{
stepsLabel(
invocation.node_run_count,
)
}}
</p>
</TableCell>
<TableCell
class="text-right text-sm text-foreground/70 tabular-nums"
>{{
formatDuration(
invocation.duration_ms,
)
}}</TableCell
>
</TableRow>
<TableRow
v-if="expanded[invocation.id]"
:key="`${invocation.id}-detail`"
>
<TableCell
colspan="6"
class="bg-muted/30"
>
<div
v-if="
loadingRuns[invocation.id]
"
class="py-3 text-center text-sm text-foreground/50"
>
{{
$t(
'automations.invocations.loading',
)
}}
</div>
<ul
v-else-if="
(
nodeRuns[
invocation.id
] ?? []
).length > 0
"
class="divide-y divide-foreground/5"
>
<li
v-for="nodeRun in nodeRuns[
invocation.id
]"
:key="nodeRun.id"
class="flex items-center justify-between gap-4 py-2"
>
<div
class="flex items-center gap-2"
>
<Badge
:variant="
statusVariant(
nodeRun.status,
)
"
class="font-mono text-[10px]"
>{{
nodeRun.status
}}</Badge
>
<span
class="text-sm font-medium"
>{{
$t(
`automations.node_type.${nodeRun.node_type}`,
)
}}</span
>
</div>
<span
v-if="
nodeRun.error
?.message
"
class="truncate text-xs text-destructive"
>{{
nodeRun.error
.message
}}</span
>
</li>
</ul>
<p
v-else
class="py-3 text-center text-sm text-foreground/50"
>
{{
$t(
'automations.invocations.no_steps',
)
}}
</p>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
<template #next="{ loading }">
<TableLoadMore v-if="loading" />
</template>
</InfiniteScroll>
</div>
<div
v-if="isRefreshing"
class="absolute inset-x-0 top-16 flex justify-center"
>
<Spinner class="size-6" />
</div>
</div>
</div>
</AutomationDetailLayout>
</template>

View file

@ -0,0 +1,125 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import AutomationDetailLayout from '@/components/automations/AutomationDetailLayout.vue';
import AutomationRunsChart from '@/components/automations/AutomationRunsChart.vue';
import { DateRangePicker } from '@/components/ui/date-range-picker';
import dayjs from '@/dayjs';
import type { Automation } from '@/types/automation/automation';
type Metrics = {
totals: {
runs: number;
completed: number;
failed: number;
in_progress: number;
success_rate: number | null;
avg_duration_ms: number | null;
posts_created: number;
};
timeseries: { date: string; started: number; completed: number; failed: number }[];
platforms: { platform: string; count: number }[];
};
const props = defineProps<{
automation: Automation;
metrics: Metrics;
filters: { start: string; end: string };
}>();
const dateRange = ref({
start: dayjs(props.filters.start).toDate(),
end: dayjs(props.filters.end).toDate(),
});
watch(
dateRange,
(range) => {
router.reload({
data: {
start: dayjs(range.start).format('YYYY-MM-DD'),
end: dayjs(range.end).format('YYYY-MM-DD'),
},
only: ['metrics', 'filters'],
});
},
{ deep: true },
);
const formatDuration = (ms: number | null): string => {
if (ms === null) return '—';
if (ms < 1000) return `${ms}ms`;
const seconds = ms / 1000;
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ${Math.round(seconds % 60)}s`;
};
const cards = computed(() => [
{ key: 'runs', label: 'automations.metrics.cards.runs', value: String(props.metrics.totals.runs) },
{ key: 'completed', label: 'automations.metrics.cards.completed', value: String(props.metrics.totals.completed) },
{ key: 'failed', label: 'automations.metrics.cards.failed', value: String(props.metrics.totals.failed) },
{ key: 'in_progress', label: 'automations.metrics.cards.in_progress', value: String(props.metrics.totals.in_progress) },
{ key: 'success_rate', label: 'automations.metrics.cards.success_rate', value: props.metrics.totals.success_rate === null ? '—' : `${props.metrics.totals.success_rate}%` },
{ key: 'avg_duration', label: 'automations.metrics.cards.avg_duration', value: formatDuration(props.metrics.totals.avg_duration_ms) },
{ key: 'posts_created', label: 'automations.metrics.cards.posts_created', value: String(props.metrics.totals.posts_created) },
]);
const legend = [
{ key: 'started', color: '#6366f1', label: 'automations.metrics.legend.started' },
{ key: 'completed', color: '#22c55e', label: 'automations.metrics.legend.completed' },
{ key: 'failed', color: '#ef4444', label: 'automations.metrics.legend.failed' },
];
const maxPlatform = computed(() => Math.max(1, ...props.metrics.platforms.map((p) => p.count)));
const platformLabel = (platform: string): string => platform.charAt(0).toUpperCase() + platform.slice(1);
</script>
<template>
<AutomationDetailLayout :automation="automation" current="metrics">
<div class="space-y-6 p-4">
<div class="flex items-center justify-between gap-2">
<h2 class="text-sm font-semibold text-foreground/70">{{ $t('automations.metrics.overview') }}</h2>
<DateRangePicker v-model="dateRange" dusk="metrics-range" />
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-7">
<div v-for="card in cards" :key="card.key" class="rounded-xl border-2 border-foreground/10 bg-card p-4">
<p class="text-xs text-foreground/55">{{ $t(card.label) }}</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ card.value }}</p>
</div>
</div>
<div class="rounded-xl border-2 border-foreground/10 bg-card p-4">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-semibold">{{ $t('automations.metrics.runs_over_time') }}</h3>
<div class="flex items-center gap-4">
<div v-for="item in legend" :key="item.key" class="flex items-center gap-1.5">
<span class="size-2.5 rounded-full" :style="{ backgroundColor: item.color }"></span>
<span class="text-xs text-foreground/60">{{ $t(item.label) }}</span>
</div>
</div>
</div>
<AutomationRunsChart :data="metrics.timeseries" />
</div>
<div class="rounded-xl border-2 border-foreground/10 bg-card p-4">
<h3 class="mb-3 text-sm font-semibold">{{ $t('automations.metrics.posts_by_platform') }}</h3>
<div v-if="metrics.platforms.length === 0" class="py-6 text-center text-sm text-foreground/50">
{{ $t('automations.metrics.no_posts') }}
</div>
<ul v-else class="space-y-2">
<li v-for="item in metrics.platforms" :key="item.platform" class="flex items-center gap-3">
<span class="w-24 shrink-0 text-sm text-foreground/70">{{ platformLabel(item.platform) }}</span>
<div class="h-5 flex-1 overflow-hidden rounded bg-muted">
<div class="h-full rounded bg-primary/70" :style="{ width: `${(item.count / maxPlatform) * 100}%` }"></div>
</div>
<span class="w-8 shrink-0 text-right text-sm tabular-nums text-foreground/70">{{ item.count }}</span>
</li>
</ul>
</div>
</div>
</AutomationDetailLayout>
</template>

View file

@ -0,0 +1,213 @@
<script setup lang="ts">
import { Form, router } from '@inertiajs/vue3';
import { IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import AutomationDetailLayout from '@/components/automations/AutomationDetailLayout.vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import date from '@/date';
import {
activate as activateAutomation,
destroy as destroyAutomation,
pause as pauseAutomation,
update as updateAutomation,
} from '@/routes/app/automations';
import type { Automation } from '@/types/automation/automation';
const props = defineProps<{ automation: Automation }>();
const statusLabel = computed(() =>
trans(`automations.status.${props.automation.status}`),
);
const statusDot = computed(
() =>
({
draft: 'bg-foreground/30',
active: 'bg-emerald-500',
paused: 'bg-amber-500',
})[props.automation.status] ?? 'bg-foreground/30',
);
const isActive = computed(() => props.automation.status === 'active');
const isToggling = ref(false);
const statusDetail = computed(() => {
const automation = props.automation;
if (automation.status === 'active' && automation.activated_at) {
return trans('automations.settings.activated_at', { date: date.formatDate(automation.activated_at) });
}
if (automation.status === 'paused' && automation.paused_at) {
return trans('automations.settings.paused_at', { date: date.formatDate(automation.paused_at) });
}
return trans('automations.settings.created_at', { date: date.formatDate(automation.created_at) });
});
const toggleActive = () => {
if (isToggling.value) return;
isToggling.value = true;
const url = isActive.value
? pauseAutomation.url(props.automation.id)
: activateAutomation.url(props.automation.id);
router.post(
url,
{},
{
preserveScroll: true,
onFinish: () => {
isToggling.value = false;
},
onError: (errors: Record<string, string>) => {
const fallback = isActive.value
? trans('automations.form.pause_error_fallback')
: trans('automations.form.activate_error_fallback');
toast.error(errors.message ?? fallback);
},
},
);
};
const onNameSaved = () =>
toast.success(trans('automations.settings.name_saved'));
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const openDeleteModal = () => {
deleteModal.value?.open({
url: destroyAutomation.url(props.automation.id),
confirmText: props.automation.name,
});
};
</script>
<template>
<AutomationDetailLayout :automation="automation" current="settings">
<div class="mx-auto max-w-2xl space-y-8 p-6">
<section class="space-y-4">
<HeadingSmall
:title="$t('automations.settings.general')"
:description="
$t('automations.settings.general_description')
"
/>
<Form
v-bind="updateAutomation.form(automation.id)"
class="grid gap-2"
:options="{ preserveScroll: true }"
@success="onNameSaved"
v-slot="{ errors, processing }"
>
<Label for="name">{{
$t('automations.settings.name_label')
}}</Label>
<div class="flex items-start gap-2">
<div class="flex-1">
<Input
id="name"
name="name"
:default-value="automation.name"
dusk="automation-name-input"
/>
<InputError :message="errors.name" class="mt-1" />
</div>
<Button
:disabled="processing"
dusk="automation-name-save"
>{{ $t('automations.actions.save') }}</Button
>
</div>
</Form>
</section>
<Separator />
<section class="space-y-4">
<HeadingSmall
:title="$t('automations.settings.status_title')"
:description="$t('automations.settings.status_description')"
/>
<div
class="flex items-center justify-between gap-4 rounded-xl border-2 border-foreground/10 bg-card px-4 py-3.5"
>
<div class="flex items-start gap-3">
<span
class="mt-1.5 size-2.5 shrink-0 rounded-full"
:class="statusDot"
/>
<div class="space-y-0.5">
<p class="text-sm font-semibold">
{{ statusLabel }}
</p>
<p class="text-xs text-foreground/50">
{{ statusDetail }}
</p>
</div>
</div>
<Switch
:model-value="isActive"
:disabled="isToggling"
:aria-label="
isActive
? $t('automations.actions.pause')
: $t('automations.actions.activate')
"
dusk="automation-toggle-active"
@update:model-value="toggleActive"
/>
</div>
</section>
<Separator />
<section class="space-y-4">
<HeadingSmall
:title="$t('automations.settings.danger_title')"
:description="$t('automations.settings.danger_description')"
/>
<div
class="flex items-center justify-between gap-4 rounded-xl border-2 border-destructive/30 bg-destructive/5 p-4"
>
<div class="text-sm">
<p class="font-medium">
{{ $t('automations.settings.delete_title') }}
</p>
<p class="text-foreground/60">
{{ $t('automations.settings.delete_description') }}
</p>
</div>
<Button
variant="destructive"
dusk="automation-delete"
@click="openDeleteModal"
>
<IconTrash class="size-4" />
{{ $t('automations.actions.delete') }}
</Button>
</div>
</section>
</div>
<ConfirmDeleteModal
ref="deleteModal"
:title="$t('automations.delete.title')"
:description="$t('automations.delete.description')"
:action="$t('automations.delete.confirm')"
:cancel="$t('automations.delete.cancel')"
/>
</AutomationDetailLayout>
</template>

View file

@ -1,205 +0,0 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { IconArrowLeft, IconCircleCheck, IconCircleDot, IconCircleX, IconPlayerPause, IconPlayerPlay, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import JsonViewer from '@/components/JsonViewer.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import {
activate as activateAutomation,
destroy as destroyAutomation,
edit as editAutomation,
index as automationsIndex,
pause as pauseAutomation,
} from '@/routes/app/automations';
import { retryRun as retryRunRoute } from '@/actions/App/Http/Controllers/App/AutomationController';
import type { Automation } from '@/types/automation/automation';
import type { Run } from '@/types/automation/run';
import type { TriggerItem } from '@/types/automation/trigger-item';
const props = defineProps<{
automation: Automation;
runs: Run[];
triggerItems: TriggerItem[];
}>();
const retry = (run: Run) => {
router.post(retryRunRoute.url({ automation: props.automation.id, run: run.id }));
};
const statusConfig = (status: string) => {
const configs: Record<string, { icon: typeof IconCircleDot; label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
draft: { icon: IconCircleDot, label: trans('automations.status.draft'), variant: 'outline' },
active: { icon: IconCircleCheck, label: trans('automations.status.active'), variant: 'default' },
paused: { icon: IconCircleX, label: trans('automations.status.paused'), variant: 'secondary' },
};
return configs[status] ?? configs['draft'];
};
const formatDateTime = (date: string | null) => {
if (!date) return '—';
return dayjs.utc(date).local().format('D MMM YYYY, HH:mm');
};
const runStatusVariant = (status: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
if (status === 'completed') return 'default';
if (status === 'failed') return 'destructive';
if (status === 'running') return 'secondary';
return 'outline';
};
const activeTab = ref('overview');
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const openDeleteModal = () => {
deleteModal.value?.open({
url: destroyAutomation.url(props.automation.id),
confirmText: props.automation.name,
});
};
const isActive = computed(() => props.automation.status === 'active');
const isToggling = ref(false);
const toggleActive = () => {
if (isToggling.value) return;
isToggling.value = true;
const url = isActive.value
? pauseAutomation.url(props.automation.id)
: activateAutomation.url(props.automation.id);
router.post(url, {}, {
preserveScroll: true,
onFinish: () => { isToggling.value = false; },
onError: (errors: Record<string, string>) => {
const fallback = isActive.value
? trans('automations.form.pause_error_fallback')
: trans('automations.form.activate_error_fallback');
const msg = (errors as any).message ?? fallback;
toast.error(msg);
},
});
};
</script>
<template>
<Head :title="automation.name" />
<AppLayout>
<div class="flex h-full flex-1 flex-col gap-6 px-6 py-8">
<div class="flex items-start justify-between gap-4">
<div class="flex min-w-0 items-center gap-3">
<Link :href="automationsIndex.url()">
<Button variant="outline" size="icon-sm">
<IconArrowLeft class="size-4" />
</Button>
</Link>
<div class="min-w-0">
<div class="flex items-center gap-3">
<h1 class="truncate text-4xl font-semibold" style="font-family: var(--font-display)">
{{ automation.name }}
</h1>
<Badge :variant="statusConfig(automation.status).variant" class="shrink-0">
<component :is="statusConfig(automation.status).icon" class="size-3" />
{{ statusConfig(automation.status).label }}
</Badge>
</div>
<p v-if="automation.activated_at" class="mt-1 text-sm text-foreground/60">
{{ $t('automations.show.activated') }} {{ formatDateTime(automation.activated_at) }}
</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<Link :href="editAutomation.url(automation.id)">
<Button variant="outline">{{ $t('automations.actions.edit') }}</Button>
</Link>
<Button @click="toggleActive" :disabled="isToggling">
<IconPlayerPause v-if="isActive" class="size-4" />
<IconPlayerPlay v-else class="size-4" />
{{ isActive ? $t('automations.actions.pause') : $t('automations.actions.activate') }}
</Button>
<Button
variant="outline"
size="icon"
class="bg-rose-100 hover:bg-rose-200"
:aria-label="$t('automations.actions.delete')"
@click="openDeleteModal"
>
<IconTrash class="size-4 text-rose-700" />
</Button>
</div>
</div>
<Tabs v-model="activeTab">
<TabsList>
<TabsTrigger value="overview">{{ $t('automations.show.tabs.overview') }}</TabsTrigger>
<TabsTrigger value="runs">{{ $t('automations.show.tabs.runs') }} ({{ runs.length }})</TabsTrigger>
<TabsTrigger value="items">{{ $t('automations.show.tabs.trigger_items') }} ({{ triggerItems.length }})</TabsTrigger>
</TabsList>
<TabsContent value="overview" class="mt-4">
<div class="flex items-center justify-center rounded-xl border-2 border-dashed border-foreground/25 bg-card p-12 text-center">
<p class="text-foreground/60">{{ $t('automations.show.canvas_placeholder') }}</p>
</div>
</TabsContent>
<TabsContent value="runs" class="mt-4">
<div v-if="runs.length === 0" class="rounded-xl border-2 border-dashed border-foreground/25 bg-card p-8 text-center">
<p class="text-foreground/60">{{ $t('automations.show.empty_runs') }}</p>
</div>
<ul v-else class="divide-y rounded-xl border-2 border-foreground/10 bg-card">
<li v-for="run in runs" :key="run.id" class="flex items-start justify-between p-4 gap-4">
<div class="space-y-1 min-w-0">
<p class="font-mono text-xs text-foreground/50 truncate">{{ run.id }}</p>
<div class="flex items-center gap-2">
<Badge :variant="runStatusVariant(run.status)">{{ run.status }}</Badge>
<span class="text-sm text-foreground/60">{{ $t('automations.show.started') }} {{ formatDateTime(run.started_at) }}</span>
</div>
<p v-if="run.error" class="text-sm text-destructive">{{ run.error.message }}</p>
</div>
<div class="flex shrink-0 items-center gap-3">
<Button v-if="run.status === 'failed'" variant="ghost" size="sm" @click="retry(run)">{{ $t('automations.actions.retry') }}</Button>
<span class="text-sm text-foreground/60">{{ formatDateTime(run.finished_at) }}</span>
</div>
</li>
</ul>
</TabsContent>
<TabsContent value="items" class="mt-4">
<div v-if="triggerItems.length === 0" class="rounded-xl border-2 border-dashed border-foreground/25 bg-card p-8 text-center">
<p class="text-foreground/60">{{ $t('automations.show.empty_trigger_items') }}</p>
</div>
<ul v-else class="divide-y rounded-xl border-2 border-foreground/10 bg-card">
<li v-for="item in triggerItems" :key="item.id" class="p-4 space-y-2">
<div class="flex items-center justify-between gap-4">
<p class="font-mono text-xs text-foreground/50 truncate">{{ item.item_key }}</p>
<span class="shrink-0 text-xs text-foreground/50">{{ formatDateTime(item.first_seen_at) }}</span>
</div>
<JsonViewer :value="item.payload" />
<div v-if="item.run" class="flex items-center gap-2">
<span class="text-xs text-foreground/60">{{ $t('automations.show.run_label') }}:</span>
<Badge :variant="runStatusVariant(item.run.status)">{{ item.run.status }}</Badge>
<span class="font-mono text-xs text-foreground/50">{{ item.run.id }}</span>
</div>
</li>
</ul>
</TabsContent>
</Tabs>
</div>
<ConfirmDeleteModal
ref="deleteModal"
:title="$t('automations.delete.title')"
:description="$t('automations.delete.description')"
:action="$t('automations.delete.confirm')"
:cancel="$t('automations.delete.cancel')"
/>
</AppLayout>
</template>

View file

@ -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');

View file

@ -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 () {

View file

@ -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)

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\Automation;
use App\Models\AutomationRun;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->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')
);
});

View file

@ -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)
);
});