2026-06-12 22:19:46 +00:00
|
|
|
<script setup lang="ts">
|
2026-06-13 20:41:48 +00:00
|
|
|
import { InfiniteScroll, router, useHttp } from '@inertiajs/vue3';
|
2026-06-12 22:19:46 +00:00
|
|
|
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';
|
2026-06-13 20:50:23 +00:00
|
|
|
import type { NodeRunStatusValue } from '@/types/automation/node-run-status';
|
|
|
|
|
import type { NodeTypeValue } from '@/types/automation/node-type';
|
|
|
|
|
import { RunStatus, type RunStatusValue } from '@/types/automation/run-status';
|
2026-06-12 22:19:46 +00:00
|
|
|
|
|
|
|
|
type Invocation = {
|
|
|
|
|
id: string;
|
2026-06-13 20:50:23 +00:00
|
|
|
status: RunStatusValue;
|
2026-06-12 22:19:46 +00:00
|
|
|
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;
|
2026-06-13 20:50:23 +00:00
|
|
|
node_type: NodeTypeValue;
|
|
|
|
|
status: NodeRunStatusValue;
|
2026-06-12 22:19:46 +00:00
|
|
|
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 = (
|
2026-06-13 20:50:23 +00:00
|
|
|
status: RunStatusValue | NodeRunStatusValue,
|
2026-06-12 22:19:46 +00:00
|
|
|
): 'default' | 'secondary' | 'destructive' | 'outline' => {
|
2026-06-13 20:50:23 +00:00
|
|
|
if (status === RunStatus.Completed) return 'default';
|
|
|
|
|
if (status === RunStatus.Failed || status === RunStatus.Cancelled) return 'destructive';
|
|
|
|
|
if (status === RunStatus.Running || status === RunStatus.Waiting) return 'secondary';
|
2026-06-12 22:19:46 +00:00
|
|
|
return 'outline';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const summary = (invocation: Invocation): string => {
|
2026-06-13 20:50:23 +00:00
|
|
|
if (invocation.status === RunStatus.Failed)
|
2026-06-12 22:19:46 +00:00
|
|
|
return (
|
|
|
|
|
invocation.error_message ??
|
|
|
|
|
trans('automations.invocations.summary.failed')
|
|
|
|
|
);
|
2026-06-13 20:50:23 +00:00
|
|
|
if (invocation.status === RunStatus.Completed)
|
2026-06-12 22:19:46 +00:00
|
|
|
return trans('automations.invocations.summary.completed');
|
2026-06-13 20:50:23 +00:00
|
|
|
if (invocation.status === RunStatus.Running || invocation.status === RunStatus.Waiting)
|
2026-06-12 22:19:46 +00:00
|
|
|
return trans('automations.invocations.summary.running');
|
2026-06-13 20:50:23 +00:00
|
|
|
if (invocation.status === RunStatus.Cancelled)
|
2026-06-12 22:19:46 +00:00
|
|
|
return trans('automations.invocations.summary.cancelled');
|
|
|
|
|
return trans('automations.invocations.summary.pending');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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>>({});
|
|
|
|
|
|
2026-06-13 20:41:48 +00:00
|
|
|
const runHttp = useHttp<Record<string, never>, { node_runs: NodeRun[] }>({});
|
|
|
|
|
|
2026-06-12 22:19:46 +00:00
|
|
|
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 {
|
2026-06-13 20:41:48 +00:00
|
|
|
const payload = await runHttp.get(showRunRoute.url({ automation: props.automation.id, run: invocation.id }));
|
2026-06-12 22:19:46 +00:00
|
|
|
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">
|
2026-07-18 13:58:07 +00:00
|
|
|
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
|
|
|
|
<div class="flex items-center gap-2 sm:contents">
|
|
|
|
|
<Select v-model="statusFilter">
|
|
|
|
|
<SelectTrigger
|
|
|
|
|
class="w-full sm: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>
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger as-child>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="icon"
|
|
|
|
|
class="shrink-0 sm:order-last"
|
|
|
|
|
:aria-label="
|
|
|
|
|
$t('automations.invocations.refresh')
|
|
|
|
|
"
|
|
|
|
|
:disabled="isRefreshing"
|
|
|
|
|
@click="reload"
|
|
|
|
|
>
|
|
|
|
|
<IconRefresh
|
|
|
|
|
class="size-4"
|
|
|
|
|
:class="{ 'animate-spin': isRefreshing }"
|
|
|
|
|
/>
|
|
|
|
|
</Button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent>{{
|
|
|
|
|
$t('automations.invocations.refresh')
|
|
|
|
|
}}</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
</div>
|
2026-06-12 22:19:46 +00:00
|
|
|
<Input
|
|
|
|
|
v-model="search"
|
|
|
|
|
:placeholder="
|
|
|
|
|
$t('automations.invocations.search_placeholder')
|
|
|
|
|
"
|
2026-07-18 13:58:07 +00:00
|
|
|
class="sm:max-w-xs"
|
2026-06-12 22:19:46 +00:00
|
|
|
/>
|
|
|
|
|
</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"
|
|
|
|
|
@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"
|
|
|
|
|
>{{
|
2026-06-25 21:42:05 +00:00
|
|
|
date.formatDurationMs(
|
2026-06-12 22:19:46 +00:00
|
|
|
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>
|