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

66
resources/css/app.css Normal file
View File

@@ -0,0 +1,66 @@
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
@source '../**/*.vue';
@theme {
--font-sans: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', monospace;
--font-mono: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', monospace;
--color-deep: #0a0a14;
--color-panel: #12121e;
--color-section: #181826;
--color-input: #0e0e1a;
--color-border: #262638;
--color-border-hover: #363650;
--color-label: #6e6e88;
--color-value: #c0c0d0;
--color-muted: #484860;
--color-accent: #4ec9b0;
--color-accent-dim: #2a7868;
--color-warn: #d4a843;
--color-danger: #c75050;
}
body {
background-color: var(--color-deep);
color: var(--color-value);
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--color-deep); }
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--color-border-hover); }
.folder-content {
max-height: 2000px;
overflow: hidden;
transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.25s ease;
opacity: 1;
}
.folder-collapsed {
max-height: 0 !important;
opacity: 0;
}
@keyframes pulse-glow {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.animate-pulse-dot {
animation: pulse-glow 2s ease-in-out infinite;
}
.progress-glow {
box-shadow: 0 0 6px rgba(78, 201, 176, 0.5),
0 0 2px rgba(78, 201, 176, 0.8);
}
input[type="number"] { -moz-appearance: textfield; }
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }

View File

@@ -0,0 +1,83 @@
<template>
<div class="space-y-3">
<p class="text-[11px] text-muted leading-relaxed">
per-record parallel enrichment · per-API retry policies · multiple external APIs
</p>
<div class="flex items-center gap-3 text-[10px] text-muted/60">
<span class="font-mono">app/Temporal/DataEnrichment/</span>
<a href="https://docs.temporal.io/develop/php/asynchronous-activity-completion" target="_blank" class="text-accent/50 hover:text-accent transition-colors">Workflow::async()</a>
<a href="https://php.temporal.io/classes/Temporal-Common-RetryOptions.html" target="_blank" class="text-accent/50 hover:text-accent transition-colors">per-API RetryOptions</a>
<a href="https://docs.temporal.io/develop/php/message-passing#queries" target="_blank" class="text-accent/50 hover:text-accent transition-colors">queries</a>
</div>
<SimulationControls v-model="simulation" />
<div class="flex items-center gap-2">
<button @click="startEnrichment"
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 Enrichment
</button>
</div>
<div v-if="workflowId" class="bg-deep/50 border border-border rounded-sm p-3 space-y-2">
<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 items-center gap-4 text-[10px] text-muted">
<span>enriched <span class="text-value">{{ status.enrichedRecords || 0 }}</span> / {{ status.totalRecords || 0 }}</span>
<span>failed <span class="text-danger">{{ status.failedRecords || 0 }}</span></span>
</div>
<div v-if="status.apiStats" class="space-y-1 pt-1 border-t border-border/30">
<div class="text-[10px] text-muted uppercase tracking-wider">Per-API Stats</div>
<div v-for="(stats, api) in status.apiStats" :key="api"
class="flex items-center gap-3 text-[10px]">
<span class="text-label w-16">{{ api }}</span>
<span class="text-muted">calls <span class="text-value">{{ stats.calls }}</span></span>
<span class="text-muted">retries <span class="text-warn">{{ stats.retries }}</span></span>
<span class="text-muted">429s <span class="text-warn">{{ stats.rateLimits }}</span></span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import axios from 'axios';
import { usePolling } from '../composables/usePolling.js';
import SimulationControls from './SimulationControls.vue';
const simulation = ref({
failureRate: 0,
latencyMs: 0,
rateLimiting: { enabled: false, hitChance: 25, retryAfterMs: 2000 },
});
const workflowId = ref(null);
const status = ref({});
const isTerminal = computed(() =>
['completed', 'failed'].includes(status.value.status) || !!status.value.error
);
const { start: startPolling } = usePolling(async () => {
const { data } = await axios.get(`/temporal/enrichment/${workflowId.value}/status`);
if (data.error && !data.status) {
status.value = { ...status.value, status: 'completed' };
return true;
}
status.value = data;
return isTerminal.value;
});
async function startEnrichment() {
const { data } = await axios.post('/temporal/enrichment/start', { simulation: simulation.value });
workflowId.value = data.workflow_id;
status.value = { status: 'starting' };
startPolling();
}
</script>

View File

@@ -0,0 +1,109 @@
<template>
<div class="space-y-3">
<p class="text-[11px] text-muted leading-relaxed">
aggregations · eager loading · chunked batch · transactions · cross-model joins
</p>
<div class="flex items-center gap-3 text-[10px] text-muted/60">
<span class="font-mono">app/Temporal/EloquentQuery/</span>
<a href="https://laravel.com/docs/eloquent" target="_blank" class="text-accent/50 hover:text-accent transition-colors">Eloquent ORM</a>
<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">heartbeat</a>
</div>
<SimulationControls v-model="simulation" />
<div class="flex items-center gap-2">
<button @click="startPipeline"
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">
Run Pipeline
</button>
</div>
<div v-if="workflowId" class="bg-deep/50 border border-border rounded-sm p-3 space-y-3">
<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>
<span v-if="status.currentStep" class="text-[10px] text-muted">· {{ status.currentStep }}</span>
</div>
<!-- Step progress -->
<div class="flex items-center gap-1">
<div v-for="i in (status.totalSteps || 6)" :key="i"
class="h-1 flex-1 rounded-full transition-all duration-300"
:class="i <= (status.completedSteps || 0) ? 'bg-accent' : i === (status.completedSteps || 0) + 1 && !isTerminal ? 'bg-accent/40 animate-pulse' : 'bg-border'">
</div>
</div>
<div class="text-[10px] text-muted">
step {{ status.completedSteps || 0 }} / {{ status.totalSteps || 6 }}
</div>
<!-- Totals bar -->
<div class="flex items-center gap-4 text-[10px] text-muted">
<span>queries <span class="text-value">{{ status.totalQueriesRun || 0 }}</span></span>
<span>rows affected <span class="text-value">{{ status.totalRowsAffected || 0 }}</span></span>
<span>time <span class="text-value">{{ (status.totalExecutionTimeMs || 0).toFixed(1) }}ms</span></span>
</div>
<!-- Per-step results -->
<div v-if="Object.keys(status.stepResults || {}).length" class="space-y-1 pt-1 border-t border-border/30">
<div v-for="(result, key) in status.stepResults" :key="key"
class="border border-border/20 rounded-sm">
<button @click="toggleStep(key)"
class="w-full flex items-center gap-2 px-2 py-1 text-left cursor-pointer hover:bg-deep/30 transition-colors">
<span class="text-[10px] text-accent">{{ expandedSteps[key] ? '▾' : '▸' }}</span>
<span class="text-[10px] text-label flex-1">{{ result.label }}</span>
<span class="text-[9px] text-muted">{{ result.queriesRun }}q · {{ result.rowsAffected }}r · {{ result.executionTimeMs?.toFixed(1) }}ms</span>
<span v-if="result.attempt > 1" class="text-[9px] text-warn">attempt {{ result.attempt }}</span>
</button>
<div v-if="expandedSteps[key]" class="px-2 pb-2">
<pre class="text-[9px] text-muted/80 overflow-x-auto max-h-48 whitespace-pre-wrap">{{ JSON.stringify(result.data, null, 2) }}</pre>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue';
import axios from 'axios';
import { usePolling } from '../composables/usePolling.js';
import SimulationControls from './SimulationControls.vue';
const simulation = ref({
failureRate: 0,
latencyMs: 0,
rateLimiting: { enabled: false, hitChance: 25, retryAfterMs: 2000 },
});
const workflowId = ref(null);
const status = ref({});
const expandedSteps = reactive({});
const isTerminal = computed(() =>
['completed', 'failed'].includes(status.value.status) || !!status.value.error
);
function toggleStep(key) {
expandedSteps[key] = !expandedSteps[key];
}
const { start: startPolling } = usePolling(async () => {
const { data } = await axios.get(`/temporal/eloquent-query/${workflowId.value}/status`);
if (data.error && !data.status) {
status.value = { ...status.value, status: 'completed' };
return true;
}
status.value = data;
return isTerminal.value;
});
async function startPipeline() {
const { data } = await axios.post('/temporal/eloquent-query/start', { simulation: simulation.value });
workflowId.value = data.workflow_id;
status.value = { status: 'starting' };
Object.keys(expandedSteps).forEach(k => delete expandedSteps[k]);
startPolling();
}
</script>

View File

@@ -0,0 +1,93 @@
<template>
<div class="space-y-3">
<p class="text-[11px] text-muted leading-relaxed">
cursor-based pagination · rate limiting · token refresh · pause/resume
</p>
<div class="flex items-center gap-3 text-[10px] text-muted/60">
<span class="font-mono">app/Temporal/ExternalApiSync/</span>
<a href="https://docs.temporal.io/develop/php/failure-detection#activity-next-retry-delay" target="_blank" class="text-accent/50 hover:text-accent transition-colors">nextRetryDelay</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-2">
<button @click="startSync"
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 Sync
</button>
<template v-if="workflowId">
<button v-if="!status.isPaused" @click="pauseSync"
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="resumeSync"
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>
</template>
</div>
<div v-if="workflowId" class="bg-deep/50 border border-border rounded-sm p-3 space-y-2">
<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 items-center gap-4 text-[10px] text-muted">
<span>pages <span class="text-value">{{ status.pagesFetched || 0 }}</span></span>
<span>records <span class="text-value">{{ status.recordsSynced || 0 }}</span></span>
<span>cursor <span class="text-value">{{ status.currentCursor || '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>
</template>
<script setup>
import { ref, computed } from 'vue';
import axios from 'axios';
import { usePolling } from '../composables/usePolling.js';
import SimulationControls from './SimulationControls.vue';
const simulation = ref({
failureRate: 0,
latencyMs: 0,
rateLimiting: { enabled: false, hitChance: 25, retryAfterMs: 2000 },
});
const workflowId = ref(null);
const status = ref({});
const isTerminal = computed(() =>
['completed', 'failed'].includes(status.value.status) || !!status.value.error
);
const { start: startPolling } = usePolling(async () => {
const { data } = await axios.get(`/temporal/api-sync/${workflowId.value}/status`);
if (data.error && !data.status) {
status.value = { ...status.value, status: 'completed' };
return true;
}
status.value = data;
return isTerminal.value;
});
async function startSync() {
const { data } = await axios.post('/temporal/api-sync/start', { simulation: simulation.value });
workflowId.value = data.workflow_id;
status.value = { status: 'starting' };
startPolling();
}
async function pauseSync() {
await axios.post(`/temporal/api-sync/${workflowId.value}/pause`);
}
async function resumeSync() {
await axios.post(`/temporal/api-sync/${workflowId.value}/resume`);
}
</script>

View File

@@ -0,0 +1,163 @@
<template>
<Teleport to="body">
<transition name="modal">
<div v-if="visible" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-deep/85 backdrop-blur-sm" @click="closeIfDone"></div>
<div class="relative w-full max-w-[640px] mx-4 bg-panel border border-border rounded overflow-hidden shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between px-3 py-2 bg-section border-b border-border">
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full"
:class="running ? 'bg-accent animate-pulse-dot' : (hasError ? 'bg-danger' : 'bg-accent')"></span>
<span class="text-[11px] font-semibold uppercase tracking-[0.1em] text-label">{{ title }}</span>
</div>
<button v-if="!running" @click="close"
class="text-muted hover:text-value text-sm leading-none cursor-pointer px-1">&times;</button>
</div>
<!-- Log output -->
<div ref="logArea" class="bg-deep p-3 max-h-[420px] overflow-y-auto scroll-smooth">
<div v-for="(line, i) in lines" :key="i"
class="flex gap-2.5 text-[11px] leading-[1.7]">
<span class="text-muted/60 shrink-0 select-none">{{ line.time }}</span>
<span :class="lineClass(line.type)">
<span v-if="line.type === 'success' || line.type === 'done'" class="text-accent">&#10003; </span>
<span v-else-if="line.type === 'error'" class="text-danger">&#10007; </span>
{{ line.message }}
</span>
</div>
<div v-if="running" class="text-accent/50 text-[11px] leading-[1.7] animate-pulse">&#9613;</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-3 py-2 border-t border-border bg-section">
<span class="text-[10px] text-muted">
{{ running ? 'Running...' : (hasError ? 'Completed with errors' : 'Completed') }}
</span>
<button @click="close" :disabled="running"
class="px-3 py-1 text-[10px] uppercase tracking-wider rounded-sm transition-all cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
:class="running ? 'text-muted border border-border' : 'text-accent border border-accent/30 hover:bg-accent/10'">
Close
</button>
</div>
</div>
</div>
</transition>
</Teleport>
</template>
<script setup>
import { ref, nextTick, watch } from 'vue';
const visible = ref(false);
const title = ref('');
const lines = ref([]);
const running = ref(false);
const hasError = ref(false);
const logArea = ref(null);
const emit = defineEmits(['close']);
function getXsrfToken() {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : '';
}
async function start(url, modalTitle) {
visible.value = true;
title.value = modalTitle;
lines.value = [];
running.value = true;
hasError.value = false;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'X-XSRF-TOKEN': getXsrfToken(),
'Accept': 'text/plain',
},
credentials: 'same-origin',
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n');
buffer = parts.pop();
for (const part of parts) {
if (!part.trim()) continue;
try {
const line = JSON.parse(part);
lines.value.push(line);
if (line.type === 'error') hasError.value = true;
} catch {
lines.value.push({ type: 'info', message: part, time: '' });
}
}
}
// Process remaining buffer
if (buffer.trim()) {
try {
const line = JSON.parse(buffer);
lines.value.push(line);
if (line.type === 'error') hasError.value = true;
} catch {
lines.value.push({ type: 'info', message: buffer, time: '' });
}
}
} catch (e) {
lines.value.push({ type: 'error', message: `Connection failed: ${e.message}`, time: new Date().toLocaleTimeString('en-US', { hour12: false }) });
hasError.value = true;
} finally {
running.value = false;
}
}
function close() {
if (running.value) return;
visible.value = false;
emit('close');
}
function closeIfDone() {
if (!running.value) close();
}
// Auto-scroll to bottom on new lines
watch(lines, () => {
nextTick(() => {
if (logArea.value) {
logArea.value.scrollTop = logArea.value.scrollHeight;
}
});
}, { deep: true });
function lineClass(type) {
switch (type) {
case 'step': return 'text-value font-medium';
case 'success': return 'text-accent';
case 'done': return 'text-accent font-semibold';
case 'warn': return 'text-warn';
case 'error': return 'text-danger';
default: return 'text-label';
}
}
defineExpose({ start });
</script>
<style scoped>
.modal-enter-active { transition: opacity 0.15s ease; }
.modal-leave-active { transition: opacity 0.1s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div class="space-y-3">
<p class="text-[11px] text-muted leading-relaxed">
saga compensations · external signals · long-running waits · retry policies
</p>
<div class="flex items-center gap-3 text-[10px] text-muted/60">
<span class="font-mono">app/Temporal/OrderFulfillment/</span>
<a href="https://docs.temporal.io/develop/php/message-passing#signals" target="_blank" class="text-accent/50 hover:text-accent transition-colors">signals</a>
<a href="https://github.com/temporalio/samples-php" target="_blank" class="text-accent/50 hover:text-accent transition-colors">saga pattern</a>
<a href="https://docs.temporal.io/develop/php/failure-detection" target="_blank" class="text-accent/50 hover:text-accent transition-colors">failure detection</a>
</div>
<SimulationControls v-model="simulation" />
<template v-if="localOrders.length > 0">
<table class="w-full text-[11px]">
<thead>
<tr class="text-left text-muted border-b border-border">
<th class="py-1 font-normal">Order</th>
<th class="font-normal">Customer</th>
<th class="font-normal text-right pr-4">Amount</th>
<th class="font-normal pl-4">Status</th>
<th class="font-normal text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="order in localOrders" :key="order.id"
class="border-t border-border/30 text-value hover:bg-section/30 transition-colors">
<td class="py-1.5 font-medium">{{ order.order_number }}</td>
<td class="text-muted">{{ order.customer_name }}</td>
<td class="text-right pr-4">${{ Number(order.total_amount).toFixed(2) }}</td>
<td class="pl-4">
<span class="inline-flex items-center gap-1">
<span class="w-1 h-1 rounded-full" :class="orderStatusDot(order.displayStatus || order.status)"></span>
{{ order.displayStatus || order.status }}
</span>
</td>
<td class="text-right">
<div class="inline-flex gap-1">
<button @click="processOrder(order)"
class="px-1.5 py-0.5 text-[10px] text-accent border border-accent/20 hover:bg-accent/10 transition-all rounded-sm cursor-pointer">
process
</button>
<button @click="shipOrder(order)"
class="px-1.5 py-0.5 text-[10px] text-value border border-border hover:bg-border/30 transition-all rounded-sm cursor-pointer">
ship
</button>
<button @click="checkStatus(order)"
class="px-1.5 py-0.5 text-[10px] text-muted border border-border/50 hover:bg-border/20 transition-all rounded-sm cursor-pointer">
status
</button>
</div>
</td>
</tr>
</tbody>
</table>
</template>
<p v-else class="text-[11px] text-muted">
No orders found. Run <code class="bg-deep px-1 py-0.5 rounded-sm text-label">TemporalDemoSeeder</code> first.
</p>
<div v-if="resultJson" class="bg-deep border border-border rounded-sm overflow-hidden">
<div class="px-2 py-1 border-b border-border/50 text-[10px] text-muted uppercase tracking-wider">Response</div>
<pre class="p-2 text-[10px] text-label leading-relaxed overflow-x-auto">{{ resultJson }}</pre>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue';
import axios from 'axios';
import { usePolling } from '../composables/usePolling.js';
import SimulationControls from './SimulationControls.vue';
const props = defineProps({
orders: { type: Array, default: () => [] },
});
const simulation = ref({
failureRate: 0,
latencyMs: 0,
rateLimiting: { enabled: false, hitChance: 25, retryAfterMs: 2000 },
});
const localOrders = reactive([...props.orders]);
const resultJson = ref('');
let currentPollingOrder = null;
const { start: startPolling, stop: stopPolling } = usePolling(async () => {
if (!currentPollingOrder) return true;
const { data } = await axios.get(`/temporal/order/${currentPollingOrder.id}/status`);
if (data.error && !data.status) {
currentPollingOrder.displayStatus = 'completed';
resultJson.value = JSON.stringify(data, null, 2);
return true;
}
currentPollingOrder.displayStatus = data.status || currentPollingOrder.displayStatus;
resultJson.value = JSON.stringify(data, null, 2);
return ['completed', 'failed'].includes(data.status);
});
async function processOrder(order) {
const { data } = await axios.post(`/temporal/order/${order.id}/process`, { simulation: simulation.value });
resultJson.value = JSON.stringify(data, null, 2);
stopPolling();
currentPollingOrder = order;
startPolling();
}
async function shipOrder(order) {
const { data } = await axios.post(`/temporal/order/${order.id}/ship`);
resultJson.value = JSON.stringify(data, null, 2);
}
async function checkStatus(order) {
const { data } = await axios.get(`/temporal/order/${order.id}/status`);
order.displayStatus = data.status || order.displayStatus;
resultJson.value = JSON.stringify(data, null, 2);
}
function orderStatusDot(status) {
if (status === 'completed' || status === 'delivered') return 'bg-accent';
if (status === 'failed' || status === 'cancelled') return 'bg-danger';
if (status === 'processing' || status === 'shipping') return 'bg-warn';
return 'bg-muted';
}
</script>

View File

@@ -0,0 +1,32 @@
<template>
<div class="border-b border-border last:border-b-0">
<button
@click="open = !open"
class="w-full flex items-center gap-2 px-3 py-2 bg-section hover:bg-section/80 transition-colors cursor-pointer select-none"
>
<span
class="inline-block w-0 h-0 border-l-[4px] border-l-transparent border-r-[4px] border-r-transparent border-t-[5px] border-t-label transition-transform duration-200"
:class="{ '-rotate-90': !open }"
></span>
<span class="text-[11px] font-semibold uppercase tracking-[0.1em] text-label">{{ title }}</span>
<span v-if="badge !== undefined" class="ml-auto text-[10px] text-muted bg-deep px-1.5 py-0.5 rounded-sm">{{ badge }}</span>
</button>
<div class="folder-content" :class="{ 'folder-collapsed': !open }">
<div class="px-3 py-3">
<slot />
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
title: { type: String, required: true },
badge: { type: [String, Number], default: undefined },
defaultOpen: { type: Boolean, default: true },
});
const open = ref(props.defaultOpen);
</script>

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

View File

@@ -0,0 +1,119 @@
<template>
<div class="border border-border/50 rounded-sm overflow-hidden">
<button @click="open = !open"
class="w-full flex items-center gap-2 px-2 py-1.5 bg-deep/50 hover:bg-deep/80 transition-colors cursor-pointer select-none">
<span class="inline-block w-0 h-0 border-l-[3px] border-l-transparent border-r-[3px] border-r-transparent border-t-[4px] border-t-muted transition-transform duration-200"
:class="{ '-rotate-90': !open }"></span>
<span class="text-[10px] uppercase tracking-wider text-muted">Fault Injection</span>
<span v-if="isActive" class="ml-auto w-1.5 h-1.5 rounded-full bg-warn animate-pulse-dot"></span>
</button>
<div v-show="open" class="px-3 py-2 space-y-2 bg-deep/30">
<p class="text-[10px] text-muted/80 leading-relaxed pb-1 border-b border-border/30">
Inject faults into activities to test Temporal's retry and error handling.
Temporal automatically retries failed activities per the configured retry policy.
</p>
<!-- Failure Rate -->
<div class="space-y-0.5">
<div class="flex items-center gap-2">
<label class="text-[10px] text-muted w-20 shrink-0">Failure %</label>
<input type="range" min="0" max="100" step="5"
:value="modelValue.failureRate"
@input="update('failureRate', +$event.target.value)"
class="flex-1 h-1 accent-danger cursor-pointer">
<span class="text-[10px] text-value w-8 text-right">{{ modelValue.failureRate }}%</span>
</div>
<p class="text-[9px] text-muted/50 pl-[88px]">Chance each activity throws a RuntimeException (retried automatically)</p>
</div>
<!-- Latency -->
<div class="space-y-0.5">
<div class="flex items-center gap-2">
<label class="text-[10px] text-muted w-20 shrink-0">Latency ms</label>
<input type="range" min="0" max="2000" step="100"
:value="modelValue.latencyMs"
@input="update('latencyMs', +$event.target.value)"
class="flex-1 h-1 accent-warn cursor-pointer">
<span class="text-[10px] text-value w-8 text-right">{{ modelValue.latencyMs }}</span>
</div>
<p class="text-[9px] text-muted/50 pl-[88px]">Artificial delay added to every activity execution</p>
</div>
<!-- Rate Limiting -->
<div class="space-y-0.5">
<div class="flex items-center gap-2">
<label class="text-[10px] text-muted w-20 shrink-0">Rate limit</label>
<button @click="toggleRateLimit"
class="px-1.5 py-0.5 text-[9px] uppercase tracking-wider rounded-sm border transition-all cursor-pointer"
:class="modelValue.rateLimiting.enabled
? 'text-warn border-warn/40 bg-warn/10'
: 'text-muted border-border/50'">
{{ modelValue.rateLimiting.enabled ? 'ON' : 'OFF' }}
</button>
</div>
<p class="text-[9px] text-muted/50 pl-[88px]">Simulates HTTP 429 responses with nextRetryDelay backoff</p>
</div>
<template v-if="modelValue.rateLimiting.enabled">
<div class="flex items-center gap-2 pl-4">
<label class="text-[10px] text-muted w-16 shrink-0">Hit %</label>
<input type="range" min="0" max="100" step="5"
:value="modelValue.rateLimiting.hitChance"
@input="updateRL('hitChance', +$event.target.value)"
class="flex-1 h-1 accent-warn cursor-pointer">
<span class="text-[10px] text-value w-8 text-right">{{ modelValue.rateLimiting.hitChance }}%</span>
</div>
<div class="flex items-center gap-2 pl-4">
<label class="text-[10px] text-muted w-16 shrink-0">Retry ms</label>
<input type="range" min="500" max="5000" step="500"
:value="modelValue.rateLimiting.retryAfterMs"
@input="updateRL('retryAfterMs', +$event.target.value)"
class="flex-1 h-1 accent-warn cursor-pointer">
<span class="text-[10px] text-value w-8 text-right">{{ modelValue.rateLimiting.retryAfterMs }}</span>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
modelValue: {
type: Object,
default: () => ({
failureRate: 0,
latencyMs: 0,
rateLimiting: { enabled: false, hitChance: 25, retryAfterMs: 2000 },
}),
},
});
const emit = defineEmits(['update:modelValue']);
const open = ref(false);
const isActive = computed(() =>
props.modelValue.failureRate > 0 ||
props.modelValue.latencyMs > 0 ||
props.modelValue.rateLimiting.enabled
);
function update(key, value) {
emit('update:modelValue', { ...props.modelValue, [key]: value });
}
function toggleRateLimit() {
emit('update:modelValue', {
...props.modelValue,
rateLimiting: { ...props.modelValue.rateLimiting, enabled: !props.modelValue.rateLimiting.enabled },
});
}
function updateRL(key, value) {
emit('update:modelValue', {
...props.modelValue,
rateLimiting: { ...props.modelValue.rateLimiting, [key]: value },
});
}
</script>

View File

@@ -0,0 +1,151 @@
<template>
<div class="space-y-3">
<p class="text-[11px] text-muted leading-relaxed">
durable timers · Continue-As-New · heartbeat · signal-driven stop · ~30 min long-running
</p>
<div class="flex items-center gap-3 text-[10px] text-muted/60">
<span class="font-mono">app/Temporal/SystemMonitor/</span>
<a href="https://docs.temporal.io/develop/php/timers#timers" target="_blank" class="text-accent/50 hover:text-accent transition-colors">Workflow::timer()</a>
<a href="https://docs.temporal.io/develop/php/continue-as-new" target="_blank" class="text-accent/50 hover:text-accent transition-colors">Continue-As-New</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" />
<!-- Config sliders -->
<div class="flex items-center gap-4 text-[10px] text-muted">
<label class="flex items-center gap-1.5">
<span class="text-label">Interval</span>
<input type="range" v-model.number="intervalSeconds" min="5" max="120" step="5" class="w-20 accent-accent">
<span class="text-value w-8 text-right">{{ intervalSeconds }}s</span>
</label>
<label class="flex items-center gap-1.5">
<span class="text-label">Iterations</span>
<input type="range" v-model.number="maxIterations" min="3" max="60" step="1" class="w-20 accent-accent">
<span class="text-value w-6 text-right">{{ maxIterations }}</span>
</label>
<span class="text-muted/50">~{{ Math.round(intervalSeconds * maxIterations / 60) }} min</span>
</div>
<div class="flex items-center gap-2">
<button @click="startMonitor"
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 Monitor
</button>
<button v-if="workflowId && !isTerminal" @click="stopMonitor"
class="px-2 py-0.5 text-[10px] uppercase tracking-wider text-danger border border-danger/30 hover:bg-danger/10 transition-all rounded-sm cursor-pointer">
Stop
</button>
</div>
<div v-if="workflowId" class="bg-deep/50 border border-border rounded-sm p-3 space-y-3">
<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>
<!-- Iteration + health score -->
<div class="flex items-center gap-4 text-[10px] text-muted">
<span>iteration <span class="text-value">{{ status.iteration || 0 }}</span> / {{ status.totalIterations || maxIterations }}</span>
<span>health <span class="font-medium" :class="scoreColor">{{ status.healthScore ?? '—' }}</span></span>
<span v-if="status.startedAt">started <span class="text-value">{{ formatTime(status.startedAt) }}</span></span>
<span v-if="status.lastCheckAt">last check <span class="text-value">{{ formatTime(status.lastCheckAt) }}</span></span>
</div>
<!-- Health score bar -->
<div class="h-1.5 bg-border/30 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500"
:class="scoreBarColor"
:style="{ width: (status.healthScore ?? 100) + '%' }">
</div>
</div>
<!-- Check history -->
<div v-if="(status.checkHistory || []).length" class="space-y-1 pt-1 border-t border-border/30">
<div class="text-[10px] text-muted uppercase tracking-wider">Check History (last {{ status.checkHistory.length }})</div>
<div v-for="(check, i) in [...(status.checkHistory || [])].reverse()" :key="i"
class="flex items-center gap-3 text-[10px] border-b border-border/10 py-0.5">
<span class="text-muted w-28">{{ formatTime(check.timestamp) }}</span>
<span class="font-medium w-6 text-right" :class="check.healthScore >= 80 ? 'text-accent' : check.healthScore >= 50 ? 'text-warn' : 'text-danger'">{{ check.healthScore }}</span>
<span v-if="check.checks" class="text-muted/60">
{{ check.checks.product_count || 0 }} products ·
{{ check.checks.order_count || 0 }} orders ·
{{ check.checks.out_of_stock_products || 0 }} OOS
</span>
<span v-if="check.attempt > 1" class="text-warn text-[9px]">attempt {{ check.attempt }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import axios from 'axios';
import { usePolling } from '../composables/usePolling.js';
import SimulationControls from './SimulationControls.vue';
const simulation = ref({
failureRate: 0,
latencyMs: 0,
rateLimiting: { enabled: false, hitChance: 25, retryAfterMs: 2000 },
});
const intervalSeconds = ref(60);
const maxIterations = ref(30);
const workflowId = ref(null);
const status = ref({});
const isTerminal = computed(() =>
['completed', 'stopped', 'failed'].includes(status.value.status) || !!status.value.error
);
const scoreColor = computed(() => {
const s = status.value.healthScore ?? 100;
if (s >= 80) return 'text-accent';
if (s >= 50) return 'text-warn';
return 'text-danger';
});
const scoreBarColor = computed(() => {
const s = status.value.healthScore ?? 100;
if (s >= 80) return 'bg-accent';
if (s >= 50) return 'bg-warn';
return 'bg-danger';
});
function formatTime(iso) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleTimeString();
} catch {
return iso;
}
}
const { start: startPolling } = usePolling(async () => {
const { data } = await axios.get(`/temporal/system-monitor/${workflowId.value}/status`);
if (data.error && !data.status) {
status.value = { ...status.value, status: 'completed' };
return true;
}
status.value = data;
return isTerminal.value;
}, 3000);
async function startMonitor() {
const { data } = await axios.post('/temporal/system-monitor/start', {
interval_seconds: intervalSeconds.value,
max_iterations: maxIterations.value,
simulation: simulation.value,
});
workflowId.value = data.workflow_id;
status.value = { status: 'starting' };
startPolling();
}
async function stopMonitor() {
await axios.post(`/temporal/system-monitor/${workflowId.value}/stop`);
}
</script>

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>

View File

@@ -0,0 +1,88 @@
<template>
<div class="space-y-3">
<p class="text-[11px] text-muted leading-relaxed">
fan-out parallel activities · per-endpoint retries · dead-letter queue
</p>
<div class="flex items-center gap-3 text-[10px] text-muted/60">
<span class="font-mono">app/Temporal/WebhookDelivery/</span>
<a href="https://docs.temporal.io/develop/php/asynchronous-activity-completion" target="_blank" class="text-accent/50 hover:text-accent transition-colors">Workflow::async()</a>
<a href="https://php.temporal.io/classes/Temporal-Common-RetryOptions.html" target="_blank" class="text-accent/50 hover:text-accent transition-colors">RetryOptions</a>
<a href="https://docs.temporal.io/develop/php/failure-detection#activity-timeouts" target="_blank" class="text-accent/50 hover:text-accent transition-colors">failure detection</a>
</div>
<SimulationControls v-model="simulation" />
<div class="flex items-center gap-2">
<button @click="deliver"
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">
Deliver Webhooks
</button>
</div>
<div v-if="workflowId" class="bg-deep/50 border border-border rounded-sm p-3 space-y-2">
<div class="flex items-center gap-4 text-[10px] text-muted">
<span>delivered <span class="text-accent">{{ status.totalDelivered || 0 }}</span></span>
<span>failed <span class="text-danger">{{ status.totalFailed || 0 }}</span></span>
<span>dead-lettered <span class="text-warn">{{ status.totalDeadLettered || 0 }}</span></span>
<span v-if="status.retryCount">retries <span class="text-warn">{{ status.retryCount }}</span></span>
</div>
<div v-if="status.endpoints" class="space-y-1">
<div v-for="(info, endpoint) in status.endpoints" :key="endpoint"
class="flex items-center gap-2 text-[10px]">
<span class="w-2 h-2 rounded-full shrink-0"
:class="{
'bg-accent': info.status === 'delivered',
'bg-danger': info.status === 'failed',
'bg-warn': info.status === 'dead_lettered',
'bg-muted animate-pulse-dot': info.status === 'delivering',
'bg-border': info.status === 'pending',
}"></span>
<span class="text-muted truncate flex-1">{{ endpoint }}</span>
<span class="text-value shrink-0">{{ info.status }}</span>
<span v-if="info.responseTime" class="text-muted shrink-0">{{ info.responseTime }}ms</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import axios from 'axios';
import { usePolling } from '../composables/usePolling.js';
import SimulationControls from './SimulationControls.vue';
const simulation = ref({
failureRate: 0,
latencyMs: 0,
rateLimiting: { enabled: false, hitChance: 25, retryAfterMs: 2000 },
});
const workflowId = ref(null);
const status = ref({});
const isTerminal = computed(() => {
if (status.value.error) return true;
if (!status.value.endpoints) return false;
const statuses = Object.values(status.value.endpoints).map(e => e.status);
return statuses.length > 0 && statuses.every(s => ['delivered', 'failed', 'dead_lettered'].includes(s));
});
const { start: startPolling } = usePolling(async () => {
const { data } = await axios.get(`/temporal/webhooks/${workflowId.value}/status`);
if (data.error && !data.endpoints) {
status.value = { ...status.value, error: data.error };
return true;
}
status.value = data;
return isTerminal.value;
});
async function deliver() {
const { data } = await axios.post('/temporal/webhooks/deliver', { simulation: simulation.value });
workflowId.value = data.workflow_id;
status.value = {};
startPolling();
}
</script>

View File

@@ -0,0 +1,54 @@
<template>
<div class="min-h-screen bg-deep flex items-start justify-center p-4 pt-8 pb-16">
<div class="w-full max-w-[940px]">
<div class="bg-panel border border-border rounded-t flex items-center justify-between px-3 py-2">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-accent"></div>
<span class="text-[11px] font-semibold uppercase tracking-[0.15em] text-value">Temporal Control</span>
</div>
<div class="flex items-center gap-2">
<button @click="terminateAll"
class="text-[10px] text-warn/70 hover:text-warn border border-warn/20 hover:border-warn/40 px-2 py-0.5 rounded-sm transition-all uppercase tracking-wider cursor-pointer">
Terminate All
</button>
<button @click="resetAll"
class="text-[10px] text-danger/70 hover:text-danger border border-danger/20 hover:border-danger/40 px-2 py-0.5 rounded-sm transition-all uppercase tracking-wider cursor-pointer">
Reset
</button>
<span class="text-border">|</span>
<a href="http://localhost:8080" target="_blank"
class="text-[10px] text-muted hover:text-accent transition-colors uppercase tracking-wider">
Open UI &rarr;
</a>
</div>
</div>
<div class="bg-panel border-x border-b border-border rounded-b overflow-hidden">
<slot />
</div>
</div>
<LogModal ref="logModal" @close="onModalClose" />
</div>
</template>
<script setup>
import { ref } from 'vue';
import { router } from '@inertiajs/vue3';
import LogModal from '../Components/LogModal.vue';
const logModal = ref(null);
function terminateAll() {
logModal.value.start('/temporal/terminate-all', 'Terminate All');
}
function resetAll() {
if (!confirm('This will wipe the database, terminate all workflows, and restart Temporal. Continue?')) return;
logModal.value.start('/temporal/reset', 'Full Reset');
}
function onModalClose() {
router.reload();
}
</script>

View File

@@ -0,0 +1,57 @@
<template>
<AppLayout>
<PanelFolder title="CSV Product Import" :badge="productImportCount">
<ProductImport :import-jobs="importJobs" />
</PanelFolder>
<PanelFolder title="Order Fulfillment · Saga" :badge="orders.length || undefined">
<OrderFulfillment :orders="orders" />
</PanelFolder>
<PanelFolder title="User Data Migration" :badge="migrationCount">
<UserMigration :import-jobs="importJobs" />
</PanelFolder>
<PanelFolder title="External API Sync" :default-open="false">
<ExternalApiSync />
</PanelFolder>
<PanelFolder title="Webhook Delivery" :default-open="false">
<WebhookDelivery />
</PanelFolder>
<PanelFolder title="Data Enrichment Pipeline" :default-open="false">
<DataEnrichment />
</PanelFolder>
<PanelFolder title="Eloquent Query Pipeline" :default-open="false">
<EloquentQuery />
</PanelFolder>
<PanelFolder title="System Health Monitor" :default-open="false">
<SystemMonitor />
</PanelFolder>
</AppLayout>
</template>
<script setup>
import { computed } from 'vue';
import AppLayout from '../Layouts/AppLayout.vue';
import PanelFolder from '../Components/PanelFolder.vue';
import ProductImport from '../Components/ProductImport.vue';
import OrderFulfillment from '../Components/OrderFulfillment.vue';
import UserMigration from '../Components/UserMigration.vue';
import ExternalApiSync from '../Components/ExternalApiSync.vue';
import WebhookDelivery from '../Components/WebhookDelivery.vue';
import DataEnrichment from '../Components/DataEnrichment.vue';
import EloquentQuery from '../Components/EloquentQuery.vue';
import SystemMonitor from '../Components/SystemMonitor.vue';
const props = defineProps({
importJobs: { type: Array, default: () => [] },
orders: { type: Array, default: () => [] },
});
const productImportCount = computed(() => {
const count = props.importJobs.filter(j => j.type === 'product_import').length;
return count || undefined;
});
const migrationCount = computed(() => {
const count = props.importJobs.filter(j => j.type === 'user_migration').length;
return count || undefined;
});
</script>

16
resources/js/app.js Normal file
View File

@@ -0,0 +1,16 @@
import './bootstrap';
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
createInertiaApp({
resolve: name => {
const pages = import.meta.glob('./Pages/**/*.vue', { eager: true });
return pages[`./Pages/${name}.vue`];
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el);
},
});

4
resources/js/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,4 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

View File

@@ -0,0 +1,35 @@
import { onUnmounted, ref } from 'vue';
export function usePolling(callback, intervalMs = 2000) {
const isPolling = ref(false);
let timer = null;
let consecutiveErrors = 0;
function start() {
if (timer) stop();
isPolling.value = true;
consecutiveErrors = 0;
timer = setInterval(async () => {
try {
const shouldStop = await callback();
consecutiveErrors = 0;
if (shouldStop) stop();
} catch {
consecutiveErrors++;
if (consecutiveErrors >= 3) stop();
}
}, intervalMs);
}
function stop() {
if (timer) {
clearInterval(timer);
timer = null;
}
isPolling.value = false;
}
onUnmounted(stop);
return { isPolling, start, stop };
}

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&display=swap" rel="stylesheet">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@inertiaHead
</head>
<body>
@inertia
</body>
</html>

File diff suppressed because one or more lines are too long