Init
This commit is contained in:
195
resources/js/Components/ProductImport.vue
Normal file
195
resources/js/Components/ProductImport.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<p class="text-[11px] text-muted leading-relaxed">
|
||||
child workflows · heartbeating · retry policies · signals · queries · batch processing
|
||||
</p>
|
||||
<div class="flex items-center gap-3 text-[10px] text-muted/60">
|
||||
<span class="font-mono">app/Temporal/ProductImport/</span>
|
||||
<a href="https://docs.temporal.io/develop/php/child-workflows" target="_blank" class="text-accent/50 hover:text-accent transition-colors">child workflows</a>
|
||||
<a href="https://docs.temporal.io/develop/php/failure-detection#activity-heartbeats" target="_blank" class="text-accent/50 hover:text-accent transition-colors">heartbeats</a>
|
||||
<a href="https://docs.temporal.io/develop/php/failure-detection#activity-timeouts" target="_blank" class="text-accent/50 hover:text-accent transition-colors">retries</a>
|
||||
</div>
|
||||
|
||||
<SimulationControls v-model="simulation" />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="startImport"
|
||||
class="px-3 py-1 text-[11px] font-medium uppercase tracking-wider bg-accent/15 text-accent border border-accent/30 hover:bg-accent/25 hover:border-accent/50 transition-all rounded-sm cursor-pointer">
|
||||
Start Import
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="jobId" class="bg-deep/50 border border-border rounded-sm p-3 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
:class="isTerminal ? 'bg-accent' : 'bg-accent animate-pulse-dot'"></span>
|
||||
<span class="text-[11px] text-value">{{ status.status || 'starting' }}</span>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button v-if="!status.isPaused" @click="pause"
|
||||
class="px-2 py-0.5 text-[10px] uppercase tracking-wider text-warn border border-warn/30 hover:bg-warn/10 transition-all rounded-sm cursor-pointer">
|
||||
Pause
|
||||
</button>
|
||||
<button v-else @click="resume"
|
||||
class="px-2 py-0.5 text-[10px] uppercase tracking-wider text-accent border border-accent/30 hover:bg-accent/10 transition-all rounded-sm cursor-pointer">
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-[3px] bg-border rounded-full overflow-hidden">
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-500"
|
||||
:class="{ 'progress-glow': !isTerminal }"
|
||||
:style="{ width: progressPct + '%' }"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 text-[10px] text-muted">
|
||||
<span>processed <span class="text-value">{{ status.processedRecords || 0 }}</span> / {{ status.totalRecords || 0 }}</span>
|
||||
<span>failed <span class="text-danger">{{ status.failedRecords || 0 }}</span></span>
|
||||
<span v-if="status.retryCount">retries <span class="text-warn">{{ status.retryCount }}</span></span>
|
||||
<span v-if="status.rateLimitHits">429s <span class="text-warn">{{ status.rateLimitHits }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="productImportJobs.length > 0" class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wider text-muted pb-1 border-b border-border/50">Recent Jobs</div>
|
||||
<table class="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr class="text-left text-muted">
|
||||
<th class="py-1 font-normal">ID</th>
|
||||
<th class="font-normal">Workflow</th>
|
||||
<th class="font-normal">Status</th>
|
||||
<th class="font-normal text-right">Records</th>
|
||||
<th class="font-normal text-right">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="job in productImportJobs" :key="job.id" class="border-t border-border/30 text-value">
|
||||
<td class="py-1">{{ job.id }}</td>
|
||||
<td class="text-muted text-[10px]">{{ truncateId(job.workflow_id) }}</td>
|
||||
<td>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="w-1 h-1 rounded-full" :class="statusDotClass(job.status)"></span>
|
||||
{{ job.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-right">{{ job.processed_records }}/{{ job.total_records }}</td>
|
||||
<td class="text-right text-muted">{{ formatDate(job.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import axios from 'axios';
|
||||
import { usePolling } from '../composables/usePolling.js';
|
||||
import SimulationControls from './SimulationControls.vue';
|
||||
|
||||
const props = defineProps({
|
||||
importJobs: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const productImportJobs = computed(() =>
|
||||
props.importJobs.filter(j => j.type === 'product_import')
|
||||
);
|
||||
|
||||
const simulation = ref({
|
||||
failureRate: 0,
|
||||
latencyMs: 0,
|
||||
rateLimiting: { enabled: false, hitChance: 25, retryAfterMs: 2000 },
|
||||
});
|
||||
|
||||
const jobId = ref(null);
|
||||
const status = ref({});
|
||||
|
||||
const progressPct = computed(() => {
|
||||
if (!status.value.totalRecords) return 0;
|
||||
return Math.round((status.value.processedRecords / status.value.totalRecords) * 100);
|
||||
});
|
||||
|
||||
const isTerminal = computed(() =>
|
||||
['completed', 'cancelled', 'failed'].includes(status.value.status)
|
||||
);
|
||||
|
||||
const { start: startPolling } = usePolling(async () => {
|
||||
const { data } = await axios.get(`/temporal/import/${jobId.value}/status`);
|
||||
status.value = data;
|
||||
if (isTerminal.value) {
|
||||
router.reload({ only: ['importJobs'] });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
async function startImport() {
|
||||
const { data } = await axios.post('/temporal/import/start', { simulation: simulation.value });
|
||||
if (data.error) { alert(data.error); return; }
|
||||
jobId.value = data.import_job_id;
|
||||
status.value = { status: 'starting' };
|
||||
startPolling();
|
||||
}
|
||||
|
||||
async function pause() {
|
||||
await axios.post(`/temporal/import/${jobId.value}/pause`);
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
await axios.post(`/temporal/import/${jobId.value}/resume`);
|
||||
}
|
||||
|
||||
function truncateId(id) {
|
||||
if (!id) return '';
|
||||
return id.length > 24 ? id.slice(0, 24) + '\u2026' : id;
|
||||
}
|
||||
|
||||
function statusDotClass(s) {
|
||||
if (s === 'completed') return 'bg-accent';
|
||||
if (s === 'failed') return 'bg-danger';
|
||||
return 'bg-warn';
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Poll stale in-progress jobs from previous sessions
|
||||
const terminalStatuses = ['completed', 'cancelled', 'failed'];
|
||||
let staleTimer = null;
|
||||
|
||||
onMounted(() => {
|
||||
const staleJobs = productImportJobs.value.filter(j => !terminalStatuses.includes(j.status));
|
||||
if (staleJobs.length === 0) return;
|
||||
|
||||
const pending = new Set(staleJobs.map(j => j.id));
|
||||
|
||||
staleTimer = setInterval(async () => {
|
||||
for (const id of [...pending]) {
|
||||
try {
|
||||
const { data } = await axios.get(`/temporal/import/${id}/status`);
|
||||
if (terminalStatuses.includes(data.status)) pending.delete(id);
|
||||
} catch {
|
||||
pending.delete(id);
|
||||
}
|
||||
}
|
||||
router.reload({ only: ['importJobs'] });
|
||||
if (pending.size === 0) {
|
||||
clearInterval(staleTimer);
|
||||
staleTimer = null;
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (staleTimer) {
|
||||
clearInterval(staleTimer);
|
||||
staleTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user