511 lines
20 KiB
Vue
511 lines
20 KiB
Vue
<template>
|
||
<div class="space-y-8">
|
||
<!-- Hero Section -->
|
||
<div class="bg-gradient-to-r from-blue-900 to-purple-900 rounded-lg p-8">
|
||
<h2 class="text-3xl font-bold mb-4">歡迎使用 Momentry Portal</h2>
|
||
<p class="text-gray-300 mb-6">
|
||
影片內容搜尋與人物管理平台
|
||
</p>
|
||
<div class="flex space-x-4">
|
||
<router-link
|
||
to="/search"
|
||
class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-semibold transition"
|
||
>
|
||
開始搜尋
|
||
</router-link>
|
||
<router-link
|
||
to="/persons"
|
||
class="bg-gray-700 hover:bg-gray-600 px-6 py-3 rounded-lg font-semibold transition"
|
||
>
|
||
人物管理
|
||
</router-link>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Ingest Stats Section -->
|
||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||
<h3 class="text-xl font-semibold text-green-400 mb-4">入庫統計</h3>
|
||
|
||
<div v-if="ingestStats" class="space-y-4">
|
||
<!-- Row 1: Videos + Total Chunks -->
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<!-- Total Videos -->
|
||
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
|
||
<div class="text-3xl font-bold text-blue-400">{{ ingestStats.total_videos }}</div>
|
||
<div class="text-sm text-gray-400 mt-1">影片總數</div>
|
||
</div>
|
||
|
||
<!-- Total Chunks -->
|
||
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
|
||
<div class="text-3xl font-bold text-purple-400">{{ ingestStats.total_chunks }}</div>
|
||
<div class="text-sm text-gray-400 mt-1">片段總數</div>
|
||
</div>
|
||
|
||
<!-- Searchable Chunks -->
|
||
<div class="bg-gray-900 p-4 rounded border border-green-700 text-center">
|
||
<div class="text-3xl font-bold text-green-400">{{ ingestStats.searchable_chunks }}</div>
|
||
<div class="text-sm text-gray-400 mt-1">可搜尋</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Row 2: Chunk Types Breakdown -->
|
||
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||
<div class="text-sm text-gray-500 mb-3">片段類型分類</div>
|
||
<div class="grid grid-cols-3 gap-4 text-center">
|
||
<div>
|
||
<div class="text-2xl font-bold text-pink-400">{{ ingestStats.sentence_chunks }}</div>
|
||
<div class="text-xs text-gray-400">Sentence (句子)</div>
|
||
</div>
|
||
<div>
|
||
<div class="text-2xl font-bold text-orange-400">{{ ingestStats.cut_chunks }}</div>
|
||
<div class="text-xs text-gray-400">Cut (剪輯點)</div>
|
||
</div>
|
||
<div>
|
||
<div class="text-2xl font-bold text-indigo-400">{{ ingestStats.time_chunks }}</div>
|
||
<div class="text-xs text-gray-400">Time (時間段)</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Chunk Type Definitions -->
|
||
<div class="mt-4 pt-3 border-t border-gray-700 space-y-2 text-xs text-gray-500">
|
||
<div class="flex items-start space-x-2">
|
||
<span class="text-pink-400 font-semibold">Sentence</span>
|
||
<span>基於語音辨識的自然語句分割,每個片段代表一句完整的對話或敘述,適合語意搜尋與內容理解。</span>
|
||
</div>
|
||
<div class="flex items-start space-x-2">
|
||
<span class="text-orange-400 font-semibold">Cut</span>
|
||
<span>基於影片場景切換的分割點,偵測畫面變化(如鏡頭切換、場景轉換)作為片段邊界,適合視覺內容分析。</span>
|
||
</div>
|
||
<div class="flex items-start space-x-2">
|
||
<span class="text-indigo-400 font-semibold">Time</span>
|
||
<span>基於固定時間間隔的分割(如每 60 秒),確保片段長度一致,適合時間序列分析與段落瀏覽。</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Row 3: Processing Status -->
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<!-- Chunks with Summary -->
|
||
<div class="bg-gray-900 p-4 rounded border border-emerald-700 text-center">
|
||
<div class="text-3xl font-bold text-emerald-400">{{ ingestStats.chunks_with_summary }}</div>
|
||
<div class="text-sm text-gray-400 mt-1">已生成摘要</div>
|
||
</div>
|
||
|
||
<!-- Chunks with Visual -->
|
||
<div class="bg-gray-900 p-4 rounded border border-cyan-700 text-center">
|
||
<div class="text-3xl font-bold text-cyan-400">{{ ingestStats.chunks_with_visual }}</div>
|
||
<div class="text-sm text-gray-400 mt-1">有視覺分析</div>
|
||
</div>
|
||
|
||
<!-- Pending Videos -->
|
||
<div class="bg-gray-900 p-4 rounded border border-yellow-700 text-center">
|
||
<div class="text-3xl font-bold text-yellow-400">{{ ingestStats.pending_videos }}</div>
|
||
<div class="text-sm text-gray-400 mt-1">待處理</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="text-gray-400">載入中...</div>
|
||
</div>
|
||
|
||
<!-- SFTPGo Status Section -->
|
||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||
<h3 class="text-xl font-semibold text-orange-400 mb-4">SFTPGo 狀態</h3>
|
||
|
||
<div v-if="sftpgoStatus" class="space-y-4">
|
||
<!-- Status message -->
|
||
<div v-if="statusMsg" class="text-sm px-3 py-2 rounded"
|
||
:class="statusMsg.type === 'ok' ? 'bg-green-900/50 text-green-300' : 'bg-red-900/50 text-red-300'">
|
||
{{ statusMsg.text }}
|
||
<button @click="statusMsg = null" class="ml-2 text-gray-500 hover:text-white">×</button>
|
||
</div>
|
||
<!-- Basic Info -->
|
||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||
<span class="text-xs text-gray-500 uppercase tracking-wider">Username</span>
|
||
<p class="text-white mt-1 text-lg font-semibold">{{ sftpgoStatus.username }}</p>
|
||
</div>
|
||
<div class="bg-gray-900 p-4 rounded border border-gray-600 lg:col-span-2">
|
||
<span class="text-xs text-gray-500 uppercase tracking-wider">Home Path</span>
|
||
<p class="text-gray-300 mt-1 text-sm font-mono break-all">{{ sftpgoStatus.home_dir }}</p>
|
||
</div>
|
||
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||
<span class="text-xs text-gray-500 uppercase tracking-wider">Files Count</span>
|
||
<button
|
||
@click="openSftpgoFiles"
|
||
class="text-orange-400 mt-1 text-lg font-semibold hover:text-orange-300 cursor-pointer flex items-center space-x-1"
|
||
>
|
||
<span>{{ sftpgoStatus.files_count }}</span>
|
||
<span>🔗</span>
|
||
</button>
|
||
</div>
|
||
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||
<span class="text-xs text-gray-500 uppercase tracking-wider">Registered Videos</span>
|
||
<p class="text-green-400 mt-1 text-lg font-semibold">{{ sftpgoStatus.registered_videos.length }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SFTPGo URL -->
|
||
<div class="mt-4 bg-gray-900 p-4 rounded border border-gray-600">
|
||
<span class="text-xs text-gray-500 uppercase tracking-wider">SFTPGo 檔案管理</span>
|
||
<div class="mt-2 space-y-2">
|
||
<div class="flex items-center space-x-2">
|
||
<button
|
||
@click="openSftpgoFiles"
|
||
class="flex-1 px-4 py-3 bg-orange-700 hover:bg-orange-600 text-white text-center rounded font-medium no-underline"
|
||
>
|
||
📂 點擊開啟 SFTPGo 檔案管理
|
||
</button>
|
||
</div>
|
||
<div class="flex items-center space-x-2 text-sm">
|
||
<span class="text-gray-500">或複製網址:</span>
|
||
<input
|
||
:value="sftpgoUrl"
|
||
readonly
|
||
class="flex-1 px-3 py-1 bg-gray-800 border border-gray-600 rounded text-gray-300 text-xs font-mono"
|
||
/>
|
||
<button
|
||
@click="copySftpgoUrl"
|
||
class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-white text-xs rounded"
|
||
>
|
||
複製
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Registered Videos List -->
|
||
<div v-if="sftpgoStatus.registered_videos.length > 0" class="bg-gray-900 rounded border border-gray-600">
|
||
<div class="p-3 border-b border-gray-700">
|
||
<span class="text-sm font-semibold text-gray-300">已註冊影片</span>
|
||
</div>
|
||
<div class="divide-y divide-gray-700">
|
||
<div v-for="video in sftpgoStatus.registered_videos" :key="video.uuid" class="p-3 flex justify-between items-center">
|
||
<div>
|
||
<p class="text-white text-sm">{{ video.file_name }}</p>
|
||
<p class="text-gray-500 text-xs">{{ video.uuid }}</p>
|
||
</div>
|
||
<span :class="video.status === 'completed' ? 'bg-green-900 text-green-300' : 'bg-yellow-900 text-yellow-300'" class="px-2 py-1 rounded text-xs">
|
||
{{ video.status }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="bg-gray-900 p-4 rounded border border-gray-600 text-center text-gray-500 text-sm">
|
||
尚未註冊任何影片
|
||
</div>
|
||
</div>
|
||
<div v-else class="text-gray-400">載入中...</div>
|
||
</div>
|
||
|
||
<!-- Inference Engines Section -->
|
||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||
<h3 class="text-xl font-semibold text-pink-400 mb-4">推理引擎狀態</h3>
|
||
|
||
<div v-if="inferenceHealth" class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<!-- Ollama (Embedding) -->
|
||
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<div class="flex items-center space-x-2">
|
||
<span class="text-pink-400">🧠</span>
|
||
<span class="font-semibold">{{ inferenceHealth.ollama.engine }}</span>
|
||
</div>
|
||
<span :class="inferenceHealth.ollama.status === 'ok' ? 'text-green-400' : 'text-red-400'">
|
||
{{ inferenceHealth.ollama.status === 'ok' ? '●' : '○' }}
|
||
</span>
|
||
</div>
|
||
<div class="space-y-1 text-sm">
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-500">模型</span>
|
||
<span class="text-white">{{ inferenceHealth.ollama.model }}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-500">用途</span>
|
||
<span class="text-purple-400">Embedding</span>
|
||
</div>
|
||
<div v-if="inferenceHealth.ollama.latency_ms" class="flex justify-between">
|
||
<span class="text-gray-500">延遲</span>
|
||
<span class="text-white">{{ inferenceHealth.ollama.latency_ms }}ms</span>
|
||
</div>
|
||
<div v-if="inferenceHealth.ollama.error" class="text-red-400 text-xs mt-2">
|
||
{{ inferenceHealth.ollama.error }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- llama-server (LLM) -->
|
||
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<div class="flex items-center space-x-2">
|
||
<span class="text-cyan-400">💬</span>
|
||
<span class="font-semibold">{{ inferenceHealth.llama_server.engine }}</span>
|
||
</div>
|
||
<span :class="inferenceHealth.llama_server.status === 'ok' ? 'text-green-400' : 'text-red-400'">
|
||
{{ inferenceHealth.llama_server.status === 'ok' ? '●' : '○' }}
|
||
</span>
|
||
</div>
|
||
<div class="space-y-1 text-sm">
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-500">模型</span>
|
||
<span class="text-white">{{ inferenceHealth.llama_server.model }}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-500">用途</span>
|
||
<span class="text-cyan-400">LLM (5W1H, Summary)</span>
|
||
</div>
|
||
<div v-if="inferenceHealth.llama_server.latency_ms" class="flex justify-between">
|
||
<span class="text-gray-500">延遲</span>
|
||
<span class="text-white">{{ inferenceHealth.llama_server.latency_ms }}ms</span>
|
||
</div>
|
||
<div v-if="inferenceHealth.llama_server.error" class="text-red-400 text-xs mt-2">
|
||
{{ inferenceHealth.llama_server.error }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="text-gray-400">載入中...</div>
|
||
</div>
|
||
|
||
<!-- Health Check Section -->
|
||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h3 class="text-xl font-semibold text-blue-400">服務狀態</h3>
|
||
<div class="flex items-center space-x-2">
|
||
<span class="text-sm text-gray-400">API: {{ apiBaseUrl }}</span>
|
||
<button
|
||
@click="refreshHealth"
|
||
class="text-blue-400 hover:text-blue-300 text-sm"
|
||
:disabled="loading"
|
||
>
|
||
{{ loading ? '檢查中...' : '重新檢查' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Overall Status -->
|
||
<div v-if="healthError" class="bg-red-900/30 rounded-lg p-4 border border-red-700">
|
||
<div class="flex items-center space-x-2">
|
||
<span class="text-red-400">⚠</span>
|
||
<span class="text-red-300">{{ healthError }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="health" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||
<!-- PostgreSQL -->
|
||
<ServiceStatusCard
|
||
name="PostgreSQL"
|
||
:status="health.services.postgres.status"
|
||
:latency="health.services.postgres.latency_ms"
|
||
:error="health.services.postgres.error"
|
||
/>
|
||
<!-- Redis -->
|
||
<ServiceStatusCard
|
||
name="Redis"
|
||
:status="health.services.redis.status"
|
||
:latency="health.services.redis.latency_ms"
|
||
:error="health.services.redis.error"
|
||
/>
|
||
<!-- Qdrant -->
|
||
<ServiceStatusCard
|
||
name="Qdrant"
|
||
:status="health.services.qdrant.status"
|
||
:latency="health.services.qdrant.latency_ms"
|
||
:error="health.services.qdrant.error"
|
||
/>
|
||
<!-- MongoDB -->
|
||
<ServiceStatusCard
|
||
name="MongoDB"
|
||
:status="health.services.mongodb.status"
|
||
:latency="health.services.mongodb.latency_ms"
|
||
:error="health.services.mongodb.error"
|
||
/>
|
||
</div>
|
||
|
||
<div v-else class="text-gray-400 text-sm">載入中...</div>
|
||
|
||
<!-- Version Info -->
|
||
<div v-if="health" class="mt-4 pt-4 border-t border-gray-700 flex justify-between text-sm text-gray-400">
|
||
<span>版本: {{ health.version }}</span>
|
||
<span>運行時間: {{ formatUptime(health.uptime_ms) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quick Stats -->
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||
<h3 class="text-xl font-semibold text-blue-400 mb-2">搜尋功能</h3>
|
||
<p class="text-gray-400">智能搜尋影片內容,支援語意向量與關鍵字檢索</p>
|
||
</div>
|
||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||
<h3 class="text-xl font-semibold text-green-400 mb-2">人物管理</h3>
|
||
<p class="text-gray-400">管理全域身份、區域人物與臉部特徵</p>
|
||
</div>
|
||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||
<h3 class="text-xl font-semibold text-purple-400 mb-2">臉部擷取</h3>
|
||
<p class="text-gray-400">擷取並管理人物臉部截圖</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue'
|
||
import { getHealth, getIngestStats, getSftpgoStatus, getInferenceHealth, getCurrentConfig, isTauri } from '@/api/client'
|
||
|
||
interface ServiceStatus {
|
||
status: string
|
||
latency_ms: number | null
|
||
error: string | null
|
||
}
|
||
|
||
interface ServiceHealth {
|
||
postgres: ServiceStatus
|
||
redis: ServiceStatus
|
||
qdrant: ServiceStatus
|
||
mongodb: ServiceStatus
|
||
}
|
||
|
||
interface DetailedHealthResponse {
|
||
status: string
|
||
version: string
|
||
uptime_ms: number
|
||
services: ServiceHealth
|
||
}
|
||
|
||
interface IngestStats {
|
||
total_videos: number
|
||
total_chunks: number
|
||
sentence_chunks: number
|
||
cut_chunks: number
|
||
time_chunks: number
|
||
searchable_chunks: number
|
||
chunks_with_visual: number
|
||
chunks_with_summary: number
|
||
pending_videos: number
|
||
}
|
||
|
||
interface RegisteredVideo {
|
||
uuid: string
|
||
file_name: string
|
||
status: string
|
||
}
|
||
|
||
interface SftpgoStatus {
|
||
username: string
|
||
home_dir: string
|
||
files_count: number
|
||
registered_videos: RegisteredVideo[]
|
||
last_login: string | null
|
||
}
|
||
|
||
interface InferenceEngineStatus {
|
||
engine: string
|
||
model: string
|
||
status: string
|
||
latency_ms: number | null
|
||
error: string | null
|
||
}
|
||
|
||
interface InferenceHealthResponse {
|
||
ollama: InferenceEngineStatus
|
||
llama_server: InferenceEngineStatus
|
||
}
|
||
|
||
const health = ref<DetailedHealthResponse | null>(null)
|
||
const healthError = ref<string | null>(null)
|
||
const ingestStats = ref<IngestStats | null>(null)
|
||
const sftpgoStatus = ref<SftpgoStatus | null>(null)
|
||
const inferenceHealth = ref<InferenceHealthResponse | null>(null)
|
||
const loading = ref(false)
|
||
const apiBaseUrl = ref(getCurrentConfig().api_base_url)
|
||
const sftpgoUrl = ref('https://sftpgo.momentry.ddns.net/web/client')
|
||
const statusMsg = ref<{ text: string; type: string } | null>(null)
|
||
|
||
async function fetchHealth() {
|
||
loading.value = true
|
||
healthError.value = null
|
||
try {
|
||
health.value = await getHealth()
|
||
} catch (e) {
|
||
healthError.value = String(e)
|
||
}
|
||
loading.value = false
|
||
}
|
||
|
||
async function fetchIngestStats() {
|
||
try {
|
||
ingestStats.value = await getIngestStats()
|
||
} catch (e) {
|
||
console.error('Failed to fetch ingest stats:', e)
|
||
}
|
||
}
|
||
|
||
async function fetchSftpgoStatus() {
|
||
try {
|
||
sftpgoStatus.value = await getSftpgoStatus()
|
||
} catch (e) {
|
||
console.error('Failed to fetch sftpgo status:', e)
|
||
}
|
||
}
|
||
|
||
async function fetchInferenceHealth() {
|
||
try {
|
||
inferenceHealth.value = await getInferenceHealth()
|
||
} catch (e) {
|
||
console.error('Failed to fetch inference health:', e)
|
||
}
|
||
}
|
||
|
||
function openSftpgoFiles() {
|
||
const url = sftpgoUrl.value
|
||
console.log('Momentry: Opening URL:', url, 'isTauri:', isTauri())
|
||
statusMsg.value = { text: '即將開啟:' + url, type: 'ok' }
|
||
|
||
if (isTauri()) {
|
||
try {
|
||
import('@tauri-apps/api/core').then(({ invoke }) => {
|
||
invoke('plugin:shell|open', { path: url }).then(() => {
|
||
console.log('Momentry: Opened with shell')
|
||
statusMsg.value = { text: '已開啟', type: 'ok' }
|
||
}).catch((e) => {
|
||
console.error('Momentry: Shell error:', e)
|
||
statusMsg.value = { text: '開啟失敗:' + e, type: 'err' }
|
||
})
|
||
})
|
||
} catch (e) {
|
||
console.error('Momentry: Import error:', e)
|
||
statusMsg.value = { text: '導入失敗:' + e, type: 'err' }
|
||
}
|
||
return
|
||
}
|
||
window.open(url, '_blank')?.focus()
|
||
}
|
||
|
||
function copySftpgoUrl() {
|
||
navigator.clipboard.writeText(sftpgoUrl.value)
|
||
statusMsg.value = { text: '已複製網址:' + sftpgoUrl.value, type: 'ok' }
|
||
}
|
||
|
||
async function refreshHealth() {
|
||
await fetchHealth()
|
||
}
|
||
|
||
function formatUptime(ms: number): string {
|
||
const seconds = Math.floor(ms / 1000)
|
||
const minutes = Math.floor(seconds / 60)
|
||
const hours = Math.floor(minutes / 60)
|
||
const days = Math.floor(hours / 24)
|
||
|
||
if (days > 0) return `${days}d ${hours % 24}h`
|
||
if (hours > 0) return `${hours}h ${minutes % 60}m`
|
||
if (minutes > 0) return `${minutes}m ${seconds % 60}s`
|
||
return `${seconds}s`
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchHealth()
|
||
fetchIngestStats()
|
||
fetchSftpgoStatus()
|
||
fetchInferenceHealth()
|
||
})
|
||
</script>
|