Files
2026-05-09 01:18:51 +02:00

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">&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>