This commit is contained in:
2026-05-09 01:18:51 +02:00
parent 7116ee4619
commit 959970c150
132 changed files with 21310 additions and 0 deletions

View 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>