144 lines
3.7 KiB
Vue
144 lines
3.7 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
|
|
export interface LogEntry {
|
|
level: string
|
|
stage: string
|
|
msg: string
|
|
ts: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<{
|
|
entries: LogEntry[]
|
|
rowHeight?: number
|
|
autoScroll?: boolean
|
|
}>(), {
|
|
rowHeight: 24,
|
|
autoScroll: true,
|
|
})
|
|
|
|
const container = ref<HTMLElement | null>(null)
|
|
const scrollTop = ref(0)
|
|
const containerHeight = ref(0)
|
|
const userScrolled = ref(false)
|
|
|
|
const visibleRange = computed(() => {
|
|
const start = Math.floor(scrollTop.value / props.rowHeight)
|
|
const visible = Math.ceil(containerHeight.value / props.rowHeight) + 2
|
|
return {
|
|
start: Math.max(0, start - 1),
|
|
end: Math.min(props.entries.length, start + visible),
|
|
}
|
|
})
|
|
|
|
const totalHeight = computed(() => props.entries.length * props.rowHeight)
|
|
|
|
const visibleEntries = computed(() =>
|
|
props.entries.slice(visibleRange.value.start, visibleRange.value.end).map((entry, i) => ({
|
|
...entry,
|
|
index: visibleRange.value.start + i,
|
|
}))
|
|
)
|
|
|
|
function onScroll(e: Event) {
|
|
const el = e.target as HTMLElement
|
|
scrollTop.value = el.scrollTop
|
|
// If user scrolled away from bottom, pause auto-scroll
|
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < props.rowHeight * 2
|
|
userScrolled.value = !atBottom
|
|
}
|
|
|
|
function scrollToBottom() {
|
|
if (container.value && props.autoScroll && !userScrolled.value) {
|
|
container.value.scrollTop = container.value.scrollHeight
|
|
}
|
|
}
|
|
|
|
watch(() => props.entries.length, () => {
|
|
nextTick(scrollToBottom)
|
|
})
|
|
|
|
onMounted(() => {
|
|
if (container.value) {
|
|
containerHeight.value = container.value.clientHeight
|
|
const observer = new ResizeObserver(([entry]) => {
|
|
containerHeight.value = entry.contentRect.height
|
|
})
|
|
observer.observe(container.value)
|
|
onUnmounted(() => observer.disconnect())
|
|
}
|
|
})
|
|
|
|
const levelClass = (level: string) => level.toLowerCase()
|
|
</script>
|
|
|
|
<template>
|
|
<div class="log-renderer" ref="container" @scroll="onScroll">
|
|
<div class="log-spacer" :style="{ height: totalHeight + 'px' }">
|
|
<div
|
|
class="log-viewport"
|
|
:style="{ transform: `translateY(${visibleRange.start * rowHeight}px)` }"
|
|
>
|
|
<div
|
|
v-for="entry in visibleEntries"
|
|
:key="entry.index"
|
|
class="log-row"
|
|
:class="levelClass(entry.level)"
|
|
:style="{ height: rowHeight + 'px' }"
|
|
>
|
|
<span class="log-ts">{{ entry.ts }}</span>
|
|
<span class="log-level">{{ entry.level }}</span>
|
|
<span class="log-stage">{{ entry.stage }}</span>
|
|
<span class="log-msg">{{ entry.msg }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="entries.length === 0" class="log-empty">
|
|
Waiting for log events...
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.log-renderer {
|
|
overflow-y: auto;
|
|
height: 100%;
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.log-spacer {
|
|
position: relative;
|
|
}
|
|
|
|
.log-viewport {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
}
|
|
|
|
.log-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
padding: 0 var(--space-2);
|
|
line-height: 1;
|
|
}
|
|
|
|
.log-ts { color: var(--text-dim); min-width: 80px; flex-shrink: 0; }
|
|
.log-level { min-width: 56px; font-weight: 600; flex-shrink: 0; }
|
|
.log-stage { color: var(--status-processing); min-width: 120px; flex-shrink: 0; }
|
|
.log-msg { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
|
|
.log-row.info .log-level { color: var(--status-live); }
|
|
.log-row.warning .log-level { color: var(--status-escalating); }
|
|
.log-row.error .log-level { color: var(--status-error); }
|
|
.log-row.debug .log-level { color: var(--text-dim); }
|
|
|
|
.log-empty {
|
|
color: var(--text-dim);
|
|
padding: var(--space-6);
|
|
text-align: center;
|
|
}
|
|
</style>
|