phase 3
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
WORKDIR /ui
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
COPY package.json ./
|
||||
RUN pnpm install
|
||||
# Copy both framework and detection-app (preserves relative link structure)
|
||||
COPY framework/ ./framework/
|
||||
COPY detection-app/ ./detection-app/
|
||||
|
||||
COPY . .
|
||||
WORKDIR /ui/detection-app
|
||||
RUN pnpm install
|
||||
|
||||
EXPOSE 5175
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import { ref } from 'vue'
|
||||
import { SSEDataSource, Panel, LayoutGrid } from 'mpr-ui-framework'
|
||||
import 'mpr-ui-framework/src/tokens.css'
|
||||
import type { LogEvent, StatsUpdate } from './types/sse-contract'
|
||||
import LogPanel from './panels/LogPanel.vue'
|
||||
import type { StatsUpdate } from './types/sse-contract'
|
||||
|
||||
const jobId = ref(new URLSearchParams(window.location.search).get('job') || 'test-job')
|
||||
const logs = ref<LogEvent[]>([])
|
||||
const stats = ref<StatsUpdate | null>(null)
|
||||
const status = ref<'idle' | 'live' | 'processing' | 'error'>('idle')
|
||||
|
||||
@@ -15,11 +15,6 @@ const source = new SSEDataSource({
|
||||
eventTypes: ['graph_update', 'stats_update', 'frame_update', 'detection', 'log', 'job_complete', 'waiting'],
|
||||
})
|
||||
|
||||
source.on<LogEvent>('log', (e) => {
|
||||
logs.value.push(e)
|
||||
if (logs.value.length > 200) logs.value.shift()
|
||||
})
|
||||
|
||||
source.on<StatsUpdate>('stats_update', (e) => {
|
||||
stats.value = e
|
||||
})
|
||||
@@ -62,17 +57,7 @@ source.connect()
|
||||
<div v-else class="empty">Waiting for stats...</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Log" :status="status">
|
||||
<div class="log-scroll">
|
||||
<div v-for="(log, i) in logs" :key="i" class="log-line" :class="log.level.toLowerCase()">
|
||||
<span class="ts">{{ log.ts }}</span>
|
||||
<span class="level">{{ log.level }}</span>
|
||||
<span class="stage">{{ log.stage }}</span>
|
||||
<span class="msg">{{ log.msg }}</span>
|
||||
</div>
|
||||
<div v-if="logs.length === 0" class="empty">Waiting for events...</div>
|
||||
</div>
|
||||
</Panel>
|
||||
<LogPanel :source="source" :status="status" />
|
||||
</LayoutGrid>
|
||||
</div>
|
||||
</template>
|
||||
@@ -133,25 +118,5 @@ header h1 { font-size: var(--font-size-lg); font-weight: 600; }
|
||||
.stat .label { display: block; color: var(--text-dim); font-size: var(--font-size-sm); margin-bottom: var(--space-1); }
|
||||
.stat .value { font-size: 20px; font-weight: 600; }
|
||||
|
||||
.log-scroll {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.log-line .ts { color: var(--text-dim); min-width: 80px; }
|
||||
.log-line .level { min-width: 56px; font-weight: 600; }
|
||||
.log-line .stage { color: var(--status-processing); min-width: 120px; }
|
||||
.log-line.info .level { color: var(--status-live); }
|
||||
.log-line.warning .level { color: var(--status-escalating); }
|
||||
.log-line.error .level { color: var(--status-error); }
|
||||
.log-line.debug .level { color: var(--text-dim); }
|
||||
|
||||
.empty { color: var(--text-dim); padding: var(--space-6); text-align: center; }
|
||||
</style>
|
||||
|
||||
30
ui/detection-app/src/panels/LogPanel.vue
Normal file
30
ui/detection-app/src/panels/LogPanel.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Panel } from 'mpr-ui-framework'
|
||||
import LogRenderer from 'mpr-ui-framework/src/renderers/LogRenderer.vue'
|
||||
import type { LogEntry } from 'mpr-ui-framework/src/renderers/LogRenderer.vue'
|
||||
import type { DataSource } from 'mpr-ui-framework'
|
||||
import type { LogEvent } from '../types/sse-contract'
|
||||
|
||||
const props = defineProps<{
|
||||
source: DataSource
|
||||
status?: 'idle' | 'live' | 'processing' | 'error'
|
||||
}>()
|
||||
|
||||
const entries = ref<LogEntry[]>([])
|
||||
|
||||
props.source.on<LogEvent>('log', (e) => {
|
||||
entries.value.push({
|
||||
level: e.level,
|
||||
stage: e.stage,
|
||||
msg: e.msg,
|
||||
ts: e.ts,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Panel title="Log" :status="status">
|
||||
<LogRenderer :entries="entries" />
|
||||
</Panel>
|
||||
</template>
|
||||
@@ -12,7 +12,11 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 5175,
|
||||
allowedHosts: ['mpr.local.ar'],
|
||||
allowedHosts: ['mpr.local.ar', 'k8s.mpr.local.ar'],
|
||||
hmr: {
|
||||
// When behind a reverse proxy, connect WebSocket to the same host the page was loaded from
|
||||
clientPort: 80,
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8702',
|
||||
|
||||
@@ -7,3 +7,6 @@ export { useDataSource } from './composables/useDataSource'
|
||||
// Components
|
||||
export { default as Panel } from './components/Panel.vue'
|
||||
export { default as LayoutGrid } from './components/LayoutGrid.vue'
|
||||
|
||||
// Renderers
|
||||
export { default as LogRenderer } from './renderers/LogRenderer.vue'
|
||||
|
||||
143
ui/framework/src/renderers/LogRenderer.vue
Normal file
143
ui/framework/src/renderers/LogRenderer.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user