Init
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user