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:
parent
a09b1f45c2
commit
3e43da29e0
25 changed files with 1894 additions and 422 deletions
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
123
app/Actions/Automation/Automation/GetAutomationMetrics.php
Normal file
123
app/Actions/Automation/Automation/GetAutomationMetrics.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
37
app/Http/Resources/AutomationInvocationResource.php
Normal file
37
app/Http/Resources/AutomationInvocationResource.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
51
resources/js/components/automations/AutomationHeader.vue
Normal file
51
resources/js/components/automations/AutomationHeader.vue
Normal 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>
|
||||
74
resources/js/components/automations/AutomationRunsChart.vue
Normal file
74
resources/js/components/automations/AutomationRunsChart.vue
Normal 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>
|
||||
37
resources/js/components/automations/AutomationTabsNav.vue
Normal file
37
resources/js/components/automations/AutomationTabsNav.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
479
resources/js/pages/automations/Invocations.vue
Normal file
479
resources/js/pages/automations/Invocations.vue
Normal 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>
|
||||
125
resources/js/pages/automations/Metrics.vue
Normal file
125
resources/js/pages/automations/Metrics.vue
Normal 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>
|
||||
213
resources/js/pages/automations/Settings.vue
Normal file
213
resources/js/pages/automations/Settings.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
95
tests/Feature/Automation/DetailTabsTest.php
Normal file
95
tests/Feature/Automation/DetailTabsTest.php
Normal 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')
|
||||
);
|
||||
});
|
||||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue