Init
This commit is contained in:
66
resources/css/app.css
Normal file
66
resources/css/app.css
Normal 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; }
|
||||
83
resources/js/Components/DataEnrichment.vue
Normal file
83
resources/js/Components/DataEnrichment.vue
Normal 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>
|
||||
109
resources/js/Components/EloquentQuery.vue
Normal file
109
resources/js/Components/EloquentQuery.vue
Normal 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>
|
||||
93
resources/js/Components/ExternalApiSync.vue
Normal file
93
resources/js/Components/ExternalApiSync.vue
Normal 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>
|
||||
163
resources/js/Components/LogModal.vue
Normal file
163
resources/js/Components/LogModal.vue
Normal 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">×</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">✓ </span>
|
||||
<span v-else-if="line.type === 'error'" class="text-danger">✗ </span>
|
||||
{{ line.message }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="running" class="text-accent/50 text-[11px] leading-[1.7] animate-pulse">▍</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>
|
||||
127
resources/js/Components/OrderFulfillment.vue
Normal file
127
resources/js/Components/OrderFulfillment.vue
Normal 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>
|
||||
32
resources/js/Components/PanelFolder.vue
Normal file
32
resources/js/Components/PanelFolder.vue
Normal 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>
|
||||
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>
|
||||
119
resources/js/Components/SimulationControls.vue
Normal file
119
resources/js/Components/SimulationControls.vue
Normal 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>
|
||||
151
resources/js/Components/SystemMonitor.vue
Normal file
151
resources/js/Components/SystemMonitor.vue
Normal 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>
|
||||
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>
|
||||
88
resources/js/Components/WebhookDelivery.vue
Normal file
88
resources/js/Components/WebhookDelivery.vue
Normal 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>
|
||||
54
resources/js/Layouts/AppLayout.vue
Normal file
54
resources/js/Layouts/AppLayout.vue
Normal 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 →
|
||||
</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>
|
||||
57
resources/js/Pages/Dashboard.vue
Normal file
57
resources/js/Pages/Dashboard.vue
Normal 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
16
resources/js/app.js
Normal 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
4
resources/js/bootstrap.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
35
resources/js/composables/usePolling.js
Normal file
35
resources/js/composables/usePolling.js
Normal 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 };
|
||||
}
|
||||
15
resources/views/app.blade.php
Normal file
15
resources/views/app.blade.php
Normal 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>
|
||||
277
resources/views/welcome.blade.php
Normal file
277
resources/views/welcome.blade.php
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user