feat: ASRX hybrid pipeline, identity history, worker fixes, checkpoint system
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
const mockReplace = vi.fn()
|
||||
const mockUseRoute = vi.fn(() => ({
|
||||
query: { username: '', password: '' },
|
||||
path: '/login',
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush, replace: mockReplace }),
|
||||
useRoute: mockUseRoute,
|
||||
}))
|
||||
|
||||
const mockHttpFetch = vi.fn()
|
||||
vi.mock('@/api/client', () => ({
|
||||
httpFetch: mockHttpFetch,
|
||||
getCurrentConfig: () => ({
|
||||
api_base_url: 'http://localhost:3003',
|
||||
api_key: '',
|
||||
timeout_secs: 30,
|
||||
}),
|
||||
saveConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mockUseRoute.mockReturnValue({
|
||||
query: { username: '', password: '' },
|
||||
path: '/login',
|
||||
})
|
||||
})
|
||||
|
||||
async function createWrapper() {
|
||||
const { default: LoginView } = await import('./LoginView.vue')
|
||||
return mount(LoginView, {
|
||||
attachTo: document.body,
|
||||
})
|
||||
}
|
||||
|
||||
describe('LoginView', () => {
|
||||
it('renders login form', async () => {
|
||||
const wrapper = await createWrapper()
|
||||
expect(wrapper.find('h1').text()).toBe('Momentry')
|
||||
expect(wrapper.find('input[type="text"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Login')
|
||||
})
|
||||
|
||||
it('updates username and password on input', async () => {
|
||||
const wrapper = await createWrapper()
|
||||
const usernameInput = wrapper.find('input[type="text"]')
|
||||
const passwordInput = wrapper.find('input[type="password"]')
|
||||
|
||||
await usernameInput.setValue('demo')
|
||||
await passwordInput.setValue('secret')
|
||||
|
||||
expect((usernameInput.element as HTMLInputElement).value).toBe('demo')
|
||||
expect((passwordInput.element as HTMLInputElement).value).toBe('secret')
|
||||
})
|
||||
|
||||
it('toggles password visibility', async () => {
|
||||
const wrapper = await createWrapper()
|
||||
const toggleBtn = wrapper.find('button[type="button"]')
|
||||
const passwordInput = wrapper.find('input[type="password"]')
|
||||
|
||||
expect(passwordInput.attributes('type')).toBe('password')
|
||||
await toggleBtn.trigger('click')
|
||||
expect(passwordInput.attributes('type')).toBe('text')
|
||||
await toggleBtn.trigger('click')
|
||||
expect(passwordInput.attributes('type')).toBe('password')
|
||||
})
|
||||
|
||||
it('shows loading state on submit', async () => {
|
||||
mockHttpFetch.mockImplementation(() => new Promise(() => {}))
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('demo')
|
||||
await wrapper.find('input[type="password"]').setValue('demo')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Logging in...')
|
||||
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows error on login failure with message', async () => {
|
||||
mockHttpFetch.mockResolvedValue({ success: false, message: 'Account disabled' })
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('demo')
|
||||
await wrapper.find('input[type="password"]').setValue('demo')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Account disabled')
|
||||
})
|
||||
|
||||
it('shows generic error for 401', async () => {
|
||||
mockHttpFetch.mockRejectedValue(new Error('401 Unauthorized'))
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('bad')
|
||||
await wrapper.find('input[type="password"]').setValue('creds')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Invalid username or password')
|
||||
})
|
||||
|
||||
it('shows connection error on network failure', async () => {
|
||||
mockHttpFetch.mockRejectedValue(new Error('NetworkError'))
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('demo')
|
||||
await wrapper.find('input[type="password"]').setValue('demo')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Connection error')
|
||||
})
|
||||
|
||||
it('redirects on successful login', async () => {
|
||||
mockHttpFetch.mockResolvedValue({
|
||||
success: true,
|
||||
api_key: 'test_key_123',
|
||||
user: { name: 'demo', role: 'admin' },
|
||||
})
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('admin')
|
||||
await wrapper.find('input[type="password"]').setValue('admin')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await nextTick()
|
||||
|
||||
expect(localStorage.getItem('momentry_user')).toBe(
|
||||
JSON.stringify({ name: 'demo', role: 'admin' }),
|
||||
)
|
||||
expect(localStorage.getItem('momentry_api_key')).toBe('test_key_123')
|
||||
expect(mockPush).toHaveBeenCalledWith('/home')
|
||||
})
|
||||
|
||||
it('redirects to original redirect path after login', async () => {
|
||||
mockUseRoute.mockReturnValue({
|
||||
query: { redirect: '/search?q=test' },
|
||||
path: '/login',
|
||||
})
|
||||
mockHttpFetch.mockResolvedValue({
|
||||
success: true,
|
||||
api_key: 'key',
|
||||
user: { name: 'demo' },
|
||||
})
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('demo')
|
||||
await wrapper.find('input[type="password"]').setValue('demo')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await nextTick()
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/search?q=test')
|
||||
})
|
||||
|
||||
it('auto-submits when query params are present', async () => {
|
||||
mockUseRoute.mockReturnValue({
|
||||
query: { username: 'auto', password: 'login' },
|
||||
path: '/login',
|
||||
})
|
||||
mockHttpFetch.mockResolvedValue({
|
||||
success: true,
|
||||
api_key: 'auto_key',
|
||||
user: { name: 'auto_user' },
|
||||
})
|
||||
|
||||
await createWrapper()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(mockHttpFetch).toHaveBeenCalled()
|
||||
expect(localStorage.getItem('momentry_api_key')).toBe('auto_key')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
|
||||
<div class="text-8xl font-bold text-gray-600 mb-4">404</div>
|
||||
<h2 class="text-2xl font-semibold text-gray-300 mb-2">頁面不存在</h2>
|
||||
<p class="text-gray-500 mb-8">您要尋找的頁面不存在或已被移除</p>
|
||||
<router-link to="/home"
|
||||
class="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded-lg transition">
|
||||
回到首頁
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,370 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-900 text-gray-100 p-6">
|
||||
|
||||
<div v-if="loading" class="text-center py-12"><p class="text-gray-400">載入中...</p></div>
|
||||
<div v-else-if="error" class="bg-red-900/50 border border-red-700 rounded p-4 mb-4">
|
||||
<p class="text-red-300">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- ═══ 頂部:標題 + 篩選 + 搜尋 ═══ -->
|
||||
<div class="flex flex-wrap items-center justify-between mb-4 gap-3">
|
||||
<h1 class="text-2xl font-bold">📋 檔案歷程</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 狀態篩選 -->
|
||||
<button v-for="f in filterOptions" :key="f.key"
|
||||
@click="activeFilter = f.key"
|
||||
class="px-3 py-1 rounded text-sm transition"
|
||||
:class="activeFilter === f.key ? 'bg-blue-700 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'">
|
||||
{{ f.label }}
|
||||
</button>
|
||||
<!-- 搜尋 -->
|
||||
<input v-model="searchQuery" placeholder="搜尋 UUID 或檔名..."
|
||||
class="bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-sm w-48 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Job 清單(摺疊) ═══ -->
|
||||
<div class="bg-gray-800 rounded-lg mb-4 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-gray-400 border-b border-gray-700 text-xs">
|
||||
<th class="text-left py-2 px-3 w-12">#</th>
|
||||
<th class="text-left py-2">檔案名稱</th>
|
||||
<th class="text-left py-2 w-16">狀態</th>
|
||||
<th class="text-left py-2 w-20">時間</th>
|
||||
<th class="text-left py-2 w-16">進度</th>
|
||||
<th class="text-left py-2 w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="job in filteredJobs" :key="job.id"
|
||||
@click="selectedId = job.id"
|
||||
class="border-b border-gray-700/30 cursor-pointer transition"
|
||||
:class="selectedId === job.id ? 'bg-blue-900/30' : 'hover:bg-gray-700/30'">
|
||||
<td class="py-2 px-3 font-mono text-xs text-gray-500">{{ job.id }}</td>
|
||||
<td class="py-2 truncate max-w-64">{{ job.file_name || '未知' }}</td>
|
||||
<td class="py-2"><span :class="statusBadge(job.status)" class="px-2 py-0.5 rounded text-xs">{{ job.status }}</span></td>
|
||||
<td class="py-2 font-mono text-xs text-gray-400">{{ job.createdAt || '-' }}</td>
|
||||
<td class="py-2">{{ completedCount(job) }}/{{ job.processorList?.length || 0 }}</td>
|
||||
<td class="py-2 text-xs text-gray-500">{{ selectedId === job.id ? '◀' : '▶' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 選中的 Job 詳細資料 ═══ -->
|
||||
<div v-if="selectedJob">
|
||||
<!-- ① 檔案基本資料 -->
|
||||
<div class="bg-gray-800 rounded-lg p-5 mb-4">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold flex items-center gap-2">
|
||||
{{ selectedJob.file_name || '未知檔案' }}
|
||||
<span :class="statusBadge(selectedJob.status)" class="px-2 py-0.5 rounded text-xs">{{ selectedJob.status }}</span>
|
||||
</h2>
|
||||
<p class="text-gray-400 text-xs mt-1 font-mono">UUID: {{ selectedJob.uuid || '-' }}</p>
|
||||
</div>
|
||||
<div class="text-right text-xs text-gray-500">
|
||||
<div>Job #{{ selectedJob.id }}</div>
|
||||
<div v-if="selectedJob.metadata && selectedJob.metadata['duration']">{{ Math.round(selectedJob.metadata['duration']/60) }}min</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedJob.metadata" class="grid grid-cols-4 gap-3 text-sm bg-gray-900/50 rounded p-3 mb-3">
|
||||
<div><span class="text-gray-500">長度</span><br>{{ selectedJob.metadata['duration'] ? Math.round(selectedJob.metadata['duration']) + 's' : '-' }}</div>
|
||||
<div><span class="text-gray-500">解析度</span><br>{{ selectedJob.metadata['width'] || '?' }}x{{ selectedJob.metadata['height'] || '?' }}</div>
|
||||
<div><span class="text-gray-500">FPS</span><br>{{ selectedJob.metadata['fps'] || '?' }}</div>
|
||||
<div><span class="text-gray-500">總幀數</span><br>{{ selectedJob.metadata['total_frames'] || '?' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 flex-wrap mb-3" v-if="selectedJob.uuid">
|
||||
<a :href="baseURL + '/api/v1/file/' + selectedJob.uuid + '/video'" target="_blank" class="px-3 py-1 bg-blue-700 hover:bg-blue-600 rounded text-xs">🎬 串流</a>
|
||||
<a :href="baseURL + '/api/v1/file/' + selectedJob.uuid + '/thumbnail?frame=0'" target="_blank" class="px-3 py-1 bg-green-700 hover:bg-green-600 rounded text-xs">🖼️ 縮圖</a>
|
||||
<router-link :to="'/search?uuid=' + selectedJob.uuid" class="px-3 py-1 bg-purple-700 hover:bg-purple-600 rounded text-xs">🔍 搜尋</router-link>
|
||||
</div>
|
||||
|
||||
<!-- ② 時間軸 -->
|
||||
<div v-if="selectedJob.timeline && selectedJob.timeline.length" class="mb-4">
|
||||
<h3 class="text-sm font-semibold text-gray-300 mb-2">⏱️ 處理時間軸</h3>
|
||||
<div class="relative h-8 bg-gray-900 rounded overflow-hidden">
|
||||
<div v-for="(seg, i) in selectedJob.timeline" :key="i"
|
||||
:title="seg.label + ': ' + seg.duration"
|
||||
class="absolute h-full flex items-center justify-center text-xs font-bold text-white truncate"
|
||||
:style="{ left: seg.left + '%', width: seg.width + '%', background: seg.color }">
|
||||
{{ seg.width > 8 ? seg.label : '' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-1 text-xs text-gray-500 flex-wrap">
|
||||
<span v-for="(seg, i) in selectedJob.timeline" :key="'l'+i"><span :style="{ color: seg.color }">●</span> {{ seg.label }} ({{ seg.duration }})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ③ Processors -->
|
||||
<table class="w-full text-sm mb-3">
|
||||
<thead>
|
||||
<tr class="text-gray-400 border-b border-gray-700">
|
||||
<th class="text-left py-2 w-20">Proc</th>
|
||||
<th class="text-left py-2 w-10">St</th>
|
||||
<th class="text-left py-2 w-14">Start</th>
|
||||
<th class="text-left py-2 w-14">End</th>
|
||||
<th class="text-left py-2 w-16">耗時</th>
|
||||
<th class="text-right py-2">已產出</th>
|
||||
<th class="text-right py-2">已處理</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in selectedJob.processorList" :key="p.name" class="border-b border-gray-700/50 hover:bg-gray-700/30">
|
||||
<td class="py-1.5 font-mono text-sm">{{ p.name }}</td>
|
||||
<td class="py-1.5">{{ statusIcon(p.status) }}</td>
|
||||
<td class="py-1.5 font-mono text-xs text-gray-400">{{ p.start }}</td>
|
||||
<td class="py-1.5 font-mono text-xs text-gray-400">{{ p.end }}</td>
|
||||
<td class="py-1.5 font-mono text-xs text-gray-400">{{ p.duration || '-' }}</td>
|
||||
<td class="py-1.5 text-right font-mono text-sm">{{ p.chunks ?? '-' }}</td>
|
||||
<td class="py-1.5 text-right font-mono text-sm">{{ p.frames ?? '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="text-xs text-gray-500 mb-3">已處理 {{ completedCount(selectedJob) }}/{{ selectedJob.processorList?.length || 0 }}</div>
|
||||
|
||||
<!-- ④ Post-Processing -->
|
||||
<div v-if="selectedJob.postProcessing" class="mb-4">
|
||||
<h3 class="text-sm font-semibold text-gray-300 mb-2">⚙️ Post-Processing</h3>
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-gray-400 border-b border-gray-700">
|
||||
<th class="text-left py-2">Stage</th>
|
||||
<th class="text-left py-2 w-10">St</th>
|
||||
<th class="text-right py-2 w-16">已產出</th>
|
||||
<th class="text-left py-2 pl-4">依賴進度狀態</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pp in selectedJob.postProcessing" :key="pp.stage" class="border-b border-gray-700/50 hover:bg-gray-700/30">
|
||||
<td class="py-1.5 text-sm">{{ pp.stage }}</td>
|
||||
<td class="py-1.5">{{ statusIcon(pp.status) }}</td>
|
||||
<td class="py-1.5 text-right font-mono text-xs text-gray-400">{{ pp.output || '-' }}</td>
|
||||
<td class="py-1.5 pl-4 font-mono text-xs text-gray-400">{{ pp.deps }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ⑤ Resources -->
|
||||
<div v-if="selectedJob.processorList.some(p => p.version)" class="mb-2">
|
||||
<h3 class="text-sm font-semibold text-gray-300 mb-2">🔧 Resources</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
<div v-for="p in selectedJob.processorList.filter(p => p.version)" :key="p.name" class="bg-gray-900/50 rounded p-2 text-xs">
|
||||
<div class="text-gray-400">{{ p.name }}</div>
|
||||
<div class="font-mono text-gray-300 truncate">{{ p.version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 無匹配 -->
|
||||
<div v-if="filteredJobs.length === 0" class="text-center py-12 text-gray-500">無符合條件的檔案記錄</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { httpFetch } from '@/api/client'
|
||||
|
||||
interface ProcessorInfo {
|
||||
name: string; status: string; start: string; end: string; duration: string
|
||||
chunks: number; frames: number; version: string
|
||||
}
|
||||
interface PostProcessInfo { stage: string; status: string; output: string; deps: string }
|
||||
interface TimelineSeg { label: string; left: number; width: number; color: string; duration: string }
|
||||
interface JobInfo {
|
||||
id: number; uuid: string; status: string; file_name: string; createdAt: string
|
||||
metadata: any
|
||||
timeline: TimelineSeg[]
|
||||
processorList: ProcessorInfo[]
|
||||
postProcessing: PostProcessInfo[]
|
||||
}
|
||||
|
||||
const baseURL = JSON.parse(localStorage.getItem('portal_config') || '{}').api_base_url || 'http://127.0.0.1:3003'
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const jobs = ref<JobInfo[]>([])
|
||||
const activeFilter = ref('all')
|
||||
const searchQuery = ref('')
|
||||
const selectedId = ref<number | null>(null)
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const filterOptions = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'running', label: '⏳ Running' },
|
||||
{ key: 'completed', label: '✅ Completed' },
|
||||
{ key: 'failed', label: '❌ Failed' },
|
||||
]
|
||||
|
||||
const filteredJobs = computed(() => {
|
||||
let list = jobs.value
|
||||
if (activeFilter.value !== 'all') {
|
||||
list = list.filter(j => j.status === activeFilter.value)
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
list = list.filter(j =>
|
||||
(j.file_name && j.file_name.toLowerCase().includes(q)) ||
|
||||
(j.uuid && j.uuid.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const selectedJob = computed(() => {
|
||||
return jobs.value.find(j => j.id === selectedId.value) || null
|
||||
})
|
||||
|
||||
const procColors: Record<string, string> = {
|
||||
cut: '#3b82f6', face: '#10b981', ocr: '#f59e0b',
|
||||
pose: '#8b5cf6', yolo: '#ef4444', asr: '#06b6d4', asrx: '#ec4899'
|
||||
}
|
||||
|
||||
function statusIcon(st: string): string {
|
||||
return ({ completed: '✅', running: '⏳', pending: '⬜', failed: '❌', skipped: '⏭️' })[st] || '⬜'
|
||||
}
|
||||
function statusBadge(st: string): string {
|
||||
return ({
|
||||
completed: 'bg-green-700 text-green-200', running: 'bg-blue-700 text-blue-200',
|
||||
failed: 'bg-red-700 text-red-200'
|
||||
})[st] || 'bg-gray-600 text-gray-300'
|
||||
}
|
||||
function completedCount(job: JobInfo): number {
|
||||
return job.processorList?.filter(p => p.status === 'completed').length || 0
|
||||
}
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso) return '-'
|
||||
try { return new Date(iso).toTimeString().substring(0, 5) }
|
||||
catch { return iso.substring(11, 16) }
|
||||
}
|
||||
function formatDuration(secs: number): string {
|
||||
if (!secs || secs <= 0) return '-'
|
||||
if (secs < 60) return Math.round(secs) + 's'
|
||||
return Math.floor(secs / 60) + 'm ' + Math.round(secs % 60) + 's'
|
||||
}
|
||||
function formatDateTime(iso: string): string {
|
||||
if (!iso) return '-'
|
||||
try { return new Date(iso).toLocaleString('zh-TW', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }
|
||||
catch { return iso.substring(5, 16) }
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
try {
|
||||
const resp: any = await httpFetch(`${baseURL}/api/v1/jobs`)
|
||||
const rawJobs = resp?.jobs || []
|
||||
const result: JobInfo[] = []
|
||||
|
||||
for (const j of (Array.isArray(rawJobs) ? rawJobs : []).slice(-10)) {
|
||||
const jobId = j.id
|
||||
const uuid = j.uuid || ''
|
||||
let processors: ProcessorInfo[] = []
|
||||
let postProcessing: PostProcessInfo[] = []
|
||||
let fileName = ''
|
||||
let fileMeta: Record<string, any> | null = null
|
||||
let timeline: TimelineSeg[] = []
|
||||
|
||||
if (uuid) {
|
||||
try {
|
||||
// Fetch file probe
|
||||
const probe: any = await httpFetch(`${baseURL}/api/v1/file/${uuid}/probe`)
|
||||
fileMeta = probe || null
|
||||
fileName = probe?.file_name || fileName
|
||||
|
||||
// Fetch progress
|
||||
const prog: any = await httpFetch(`${baseURL}/api/v1/progress/${uuid}`)
|
||||
fileName = prog?.file_name || fileName
|
||||
|
||||
const procMap: Record<string, any> = {}
|
||||
for (const p of (prog?.processors || [])) procMap[p.name] = p
|
||||
|
||||
const procOrder = ['cut', 'face', 'ocr', 'pose', 'yolo', 'asr', 'asrx']
|
||||
const parsed: { name: string; start: number; end: number; status: string }[] = []
|
||||
|
||||
for (const name of procOrder) {
|
||||
const p = procMap[name] || { status: 'pending' }
|
||||
const startStr = p.started_at || ''
|
||||
const endStr = p.completed_at || ''
|
||||
const startMs = startStr ? new Date(startStr).getTime() : 0
|
||||
const endMs = endStr ? new Date(endStr).getTime() : (startMs || 0)
|
||||
const dur = (endMs && endMs >= startMs) ? (endMs - startMs) / 1000 : 0
|
||||
|
||||
processors.push({
|
||||
name, status: p.status,
|
||||
start: formatTime(startStr),
|
||||
end: formatTime(endStr),
|
||||
duration: formatDuration(dur),
|
||||
chunks: p.chunks_produced ?? 0,
|
||||
frames: p.frames_processed ?? 0,
|
||||
version: p.version || ''
|
||||
})
|
||||
if (startMs && startStr) {
|
||||
parsed.push({ name, start: startMs, end: endMs || Date.now(), status: p.status })
|
||||
}
|
||||
}
|
||||
|
||||
// Build timeline
|
||||
if (parsed.length > 0) {
|
||||
const minT = Math.min(...parsed.map(p => p.start))
|
||||
const maxT = Math.max(...parsed.map(p => p.end === Date.now() ? Date.now() : p.end))
|
||||
const range = maxT - minT || 1
|
||||
for (const p of parsed) {
|
||||
timeline.push({
|
||||
label: p.name,
|
||||
left: ((p.start - minT) / range) * 100,
|
||||
width: Math.max(((p.end - p.start) / range) * 100, 3),
|
||||
color: procColors[p.name] || '#6b7280',
|
||||
duration: formatDuration((p.end - p.start) / 1000)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Post-processing deps
|
||||
const allDone = processors.every(p => p.status === 'completed')
|
||||
const S = (n: string) => statusIcon(procMap[n]?.status || 'pending')
|
||||
postProcessing = [
|
||||
{ stage: 'Rule 1 chunks', status: allDone ? 'running' : 'pending', output: '-', deps: `ASR${S('asr')} + ASRX${S('asrx')}` },
|
||||
{ stage: 'face_trace', status: allDone ? 'running' : 'pending', output: '-', deps: `cut${S('cut')} face${S('face')} ocr${S('ocr')} pose${S('pose')} yolo${S('yolo')} asr${S('asr')} asrx${S('asrx')}` },
|
||||
{ stage: 'Qdrant face sync', status: 'pending', output: '-', deps: 'face_trace⬜' },
|
||||
{ stage: 'Qdrant voice', status: 'pending', output: '-', deps: `ASRX${S('asrx')} (inline)` },
|
||||
{ stage: 'ANE vectorize', status: 'pending', output: '-', deps: 'Rule 1 chunks⬜' },
|
||||
{ stage: '5W1H Agent', status: 'pending', output: '-', deps: 'Rule 1⬜ + Rule 3⬜' },
|
||||
]
|
||||
} catch (e) { console.warn(`skip ${uuid}:`, e) }
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: jobId, uuid, status: j.status || 'unknown', file_name: fileName,
|
||||
createdAt: j.created_at ? formatDateTime(j.created_at) : '',
|
||||
metadata: fileMeta, timeline, processorList: processors, postProcessing
|
||||
})
|
||||
}
|
||||
jobs.value = result.reverse()
|
||||
if (result.length > 0 && selectedId.value === null) {
|
||||
selectedId.value = result[result.length - 1].id
|
||||
}
|
||||
|
||||
// Auto refresh if any job is running
|
||||
const hasRunning = result.some(j => j.status === 'running')
|
||||
if (hasRunning && !refreshTimer) {
|
||||
refreshTimer = setInterval(loadJobs, 15000)
|
||||
} else if (!hasRunning && refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || '載入失敗'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadJobs)
|
||||
onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer) })
|
||||
</script>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<button @click="$router.back()" class="text-gray-400 hover:text-white text-lg">← 返回</button>
|
||||
<h2 class="text-2xl font-bold">Trace #{{ traceId }}</h2>
|
||||
<span class="text-gray-400 text-sm">{{ fileUuid?.substring(0, 12) }}...</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-12"><div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-500 mx-auto"></div></div>
|
||||
|
||||
<div v-else-if="trace" class="grid gap-6">
|
||||
<!-- Summary -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div><span class="text-xs text-gray-500">DETECTIONS</span><p class="text-white text-lg font-semibold">{{ trace.face_count }}</p></div>
|
||||
<div><span class="text-xs text-gray-500">DURATION</span><p class="text-white text-lg font-semibold">{{ trace.duration_sec?.toFixed(1) }}s</p></div>
|
||||
<div><span class="text-xs text-gray-500">CONFIDENCE</span><p class="text-white text-lg font-semibold">{{ (trace.avg_confidence * 100).toFixed(0) }}%</p></div>
|
||||
<div><span class="text-xs text-gray-500">TIME</span><p class="text-white text-lg font-semibold">{{ trace.first_sec?.toFixed(0) }}s - {{ trace.last_sec?.toFixed(0) }}s</p></div>
|
||||
</div>
|
||||
|
||||
<!-- Video -->
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
||||
<video controls autoplay class="w-full" @error="videoError = true">
|
||||
<source :src="videoUrl" type="video/mp4" />
|
||||
</video>
|
||||
<div v-if="videoError" class="p-4 text-center text-gray-500">Video unavailable</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Faces -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-lg font-semibold mb-4 text-blue-400">Recent Detections</h3>
|
||||
<div class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
||||
<div v-for="f in recentFaces" :key="f.id" class="bg-gray-900 rounded overflow-hidden">
|
||||
<img :src="thumbUrl(f)" class="w-full aspect-square object-cover" loading="lazy" @error="e => (e.target as HTMLElement).style.display='none'" />
|
||||
<div class="p-1 text-[9px] text-gray-400 truncate">#{{ f.start_frame }}<br/>{{ (f.confidence * 100).toFixed(0) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getCurrentConfig, httpFetch } from '@/api/client'
|
||||
|
||||
const route = useRoute()
|
||||
const fileUuid = route.params.file_uuid as string
|
||||
const traceId = route.params.trace_id as string
|
||||
const config = getCurrentConfig()
|
||||
|
||||
const trace = ref<any>(null)
|
||||
const faces = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const videoError = ref(false)
|
||||
|
||||
const videoUrl = computed(() =>
|
||||
`${config.api_base_url}/api/v1/file/${fileUuid}/trace/${traceId}/video?padding=1`
|
||||
)
|
||||
|
||||
const recentFaces = computed(() => faces.value.slice(0, 40))
|
||||
|
||||
function thumbUrl(f: any): string {
|
||||
return `${config.api_base_url}/api/v1/file/${fileUuid}/thumbnail?frame=${f.start_frame}&x=${f.x}&y=${f.y}&w=${f.width}&h=${f.height}`
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const traces = await httpFetch<any>(`${config.api_base_url}/api/v1/file/${fileUuid}/face_trace/sortby`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ limit: 500 })
|
||||
})
|
||||
trace.value = (traces.traces || []).find((t: any) => String(t.trace_id) === traceId)
|
||||
|
||||
const faceData = await httpFetch<any>(`${config.api_base_url}/api/v1/file/${fileUuid}/trace/${traceId}/faces?limit=50`)
|
||||
faces.value = faceData.faces || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load trace:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-900 text-white p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-blue-400">V5: 3D Space-Time Cube</h2>
|
||||
<button @click="goBack" class="text-sm bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded">← 返回</button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mb-3 flex gap-4">
|
||||
<span>X = 畫面水平位置(紅軸)</span>
|
||||
<span>Y = 畫面垂直位置(綠軸)</span>
|
||||
<span>Z = 深度 - bbox 面積(藍軸)</span>
|
||||
<span>T = 時間 - 顏色漸層藍→紅</span>
|
||||
</div>
|
||||
<div class="h-[calc(100vh-140px)]">
|
||||
<SpaceTimeCube
|
||||
:file-uuid="fileUuid"
|
||||
:traces="allTraces"
|
||||
:frame-width="1920"
|
||||
:frame-height="1080"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getCurrentConfig } from '@/api/client'
|
||||
import SpaceTimeCube from '@/components/SpaceTimeCube.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const fileUuid = route.params.file_uuid as string
|
||||
const allTraces = ref<any[]>([])
|
||||
|
||||
// Auto-configure from query params (for demo)
|
||||
const keyParam = route.query.key as string
|
||||
const baseParam = route.query.base as string
|
||||
if (keyParam && baseParam) {
|
||||
const existing = JSON.parse(localStorage.getItem('portal_config') || '{}')
|
||||
existing.api_key = keyParam
|
||||
existing.api_base_url = baseParam
|
||||
localStorage.setItem('portal_config', JSON.stringify(existing))
|
||||
}
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
onMounted(async () => {
|
||||
const config = getCurrentConfig()
|
||||
try {
|
||||
const resp = await fetch(`${config.api_base_url}/api/v1/file/${fileUuid}/face_trace/sortby`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(config.api_key ? { 'X-API-Key': config.api_key } : {})
|
||||
},
|
||||
body: JSON.stringify({ sort_by: 'face_count', limit: 200, min_faces: 1 })
|
||||
})
|
||||
const data = await resp.json()
|
||||
allTraces.value = data.traces || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load traces:', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user