164 lines
6.1 KiB
Vue
164 lines
6.1 KiB
Vue
<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>
|