207 lines
8.8 KiB
Vue
207 lines
8.8 KiB
Vue
<template>
|
|
<div class="space-y-3">
|
|
<p class="text-[11px] text-muted leading-relaxed">
|
|
batch processing · external API simulation · pause/resume · progress queries
|
|
</p>
|
|
<div class="flex items-center gap-3 text-[10px] text-muted/60">
|
|
<span class="font-mono">app/Temporal/UserMigration/</span>
|
|
<a href="https://docs.temporal.io/develop/php/message-passing#queries" target="_blank" class="text-accent/50 hover:text-accent transition-colors">queries</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/message-passing#signals" target="_blank" class="text-accent/50 hover:text-accent transition-colors">signals</a>
|
|
</div>
|
|
|
|
<SimulationControls v-model="simulation" />
|
|
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex items-center gap-2">
|
|
<label class="text-[10px] text-label uppercase tracking-wider">Users</label>
|
|
<input v-model.number="totalUsers" type="number"
|
|
class="w-16 bg-input border border-border text-value text-[11px] px-2 py-1 rounded-sm focus:border-accent/50 focus:outline-none transition-colors text-right">
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<label class="text-[10px] text-label uppercase tracking-wider">Batch</label>
|
|
<input v-model.number="batchSize" type="number"
|
|
class="w-16 bg-input border border-border text-value text-[11px] px-2 py-1 rounded-sm focus:border-accent/50 focus:outline-none transition-colors text-right">
|
|
</div>
|
|
<button @click="startMigration"
|
|
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 Migration
|
|
</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: (status.percentComplete || 0) + '%' }"></div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3 text-[10px] text-muted">
|
|
<span>processed <span class="text-value">{{ status.processedUsers || 0 }}</span> / {{ status.totalUsers || 0 }}</span>
|
|
<span>failed <span class="text-danger">{{ status.failedUsers || 0 }}</span></span>
|
|
<span>batch <span class="text-value">{{ status.currentBatch || 0 }}</span> / {{ status.totalBatches || 0 }}</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="migrationJobs.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 migrationJobs" :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 migrationJobs = computed(() =>
|
|
props.importJobs.filter(j => j.type === 'user_migration')
|
|
);
|
|
|
|
const simulation = ref({
|
|
failureRate: 0,
|
|
latencyMs: 0,
|
|
rateLimiting: { enabled: false, hitChance: 25, retryAfterMs: 2000 },
|
|
});
|
|
|
|
const totalUsers = ref(100);
|
|
const batchSize = ref(20);
|
|
const jobId = ref(null);
|
|
const status = ref({});
|
|
|
|
const isTerminal = computed(() =>
|
|
['completed', 'failed'].includes(status.value.status)
|
|
);
|
|
|
|
const { start: startPolling } = usePolling(async () => {
|
|
const { data } = await axios.get(`/temporal/migration/${jobId.value}/status`);
|
|
status.value = data;
|
|
if (isTerminal.value) {
|
|
router.reload({ only: ['importJobs'] });
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
async function startMigration() {
|
|
const { data } = await axios.post('/temporal/migration/start', {
|
|
total_users: totalUsers.value,
|
|
batch_size: batchSize.value,
|
|
simulation: simulation.value,
|
|
});
|
|
jobId.value = data.import_job_id;
|
|
status.value = { status: 'starting' };
|
|
startPolling();
|
|
}
|
|
|
|
async function pause() {
|
|
await axios.post(`/temporal/migration/${jobId.value}/pause`);
|
|
}
|
|
|
|
async function resume() {
|
|
await axios.post(`/temporal/migration/${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 = migrationJobs.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/migration/${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>
|