Init
This commit is contained in:
206
resources/js/Components/UserMigration.vue
Normal file
206
resources/js/Components/UserMigration.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user