feat: frame-based positioning and mark system foundation

Core Changes:
- Fix SearchView to use start_frame/end_frame directly (no time*fps conversion)
- Add hard_delete support to delete_trace API
- VideoPlayer: Main timeline + Mark system foundation
- Proxy: Add local routes for auth, media, identity-matches, cluster-results
- Add .gitignore to exclude build artifacts and dependencies

Design Documents:
- Multi-track Mark system design (.opencode/plans/)
- Video editing positioning standards research

Files Modified:
- src/views/SearchView.vue: Frame positioning, ensureMinDuration (240 frames)
- src/views/PeopleView.vue: batchDeleteGroups with hard_delete
- src/api/index.ts: delete_trace with hard_delete body
- src/components/VideoPlayer.vue: Timeline + Mark UI
- src-tauri/src/proxy.rs: New local routes
- AGENTS.md: Update documentation
This commit is contained in:
2026-07-24 20:19:47 +08:00
parent 299f58e883
commit 5951aca086
38 changed files with 12107 additions and 1666 deletions

View File

@@ -6,6 +6,7 @@
<button class="admin-tab" :class="{ active: activeTab === 'dashboard' }" @click="activeTab = 'dashboard'">Dashboard</button>
<button class="admin-tab" :class="{ active: activeTab === 'users' }" @click="activeTab = 'users'">Users</button>
<button class="admin-tab" :class="{ active: activeTab === 'shares' }" @click="activeTab = 'shares'">Shares</button>
<button class="admin-tab" :class="{ active: activeTab === 'settings' }" @click="activeTab = 'settings'">Settings</button>
</div>
</div>
@@ -41,6 +42,108 @@
</tbody>
</table>
</div>
<div v-if="activeTab === 'settings'" class="settings-panel">
<div v-if="settingsError" class="settings-error">{{ settingsError }}</div>
<div class="settings-group">
<h3>Upload Path</h3>
<p class="settings-desc">Base directory where uploaded files are stored (appended with /&lt;username&gt;).</p>
<div class="settings-row">
<input class="settings-input" v-model="uploadPath" placeholder="/Users/accusys/momentry/var/sftpgo/data" />
<button class="settings-save-btn" @click="saveUploadPath" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
<div v-if="saveMessage" :class="['settings-msg', saveError ? 'error' : 'ok']">{{ saveMessage }}</div>
</div>
<div class="settings-group">
<h3>Service URLs</h3>
<p class="settings-desc">Configure backend service endpoints.</p>
<div class="service-card">
<div class="service-header">
<span class="service-name">Core API</span>
<span :class="['service-status', coreStatus]">{{ coreStatus }}</span>
</div>
<div class="service-row">
<input class="settings-input" v-model="coreApiUrl" placeholder="http://localhost:3002" />
<button class="settings-test-btn" @click="testCoreApi" :disabled="testingCore">{{ testingCore ? 'Testing...' : 'Test' }}</button>
</div>
<div class="service-actions">
<button class="service-stop-btn" @click="stopService('core')" :disabled="coreActionLoading">Stop</button>
<button class="service-start-btn" @click="startService('core')" :disabled="coreActionLoading">Start</button>
<span v-if="coreActionLoading" class="action-loading">Processing...</span>
<span v-if="coreActionResult" :class="['action-result', coreActionResultOk ? 'ok' : 'error']">{{ coreActionResult }}</span>
</div>
<div v-if="coreInfo" class="service-info">
<span>Version: {{ coreInfo.version || 'N/A' }}</span>
<span>Uptime: {{ formatUptime(coreInfo.uptime) }}</span>
</div>
</div>
<div class="service-card">
<div class="service-header">
<span class="service-name">MarkBase</span>
<span :class="['service-status', mbStatus]">{{ mbStatus }}</span>
</div>
<div class="service-row">
<input class="settings-input" v-model="markbaseUrl" placeholder="http://localhost:11438" />
<button class="settings-test-btn" @click="testMarkbase" :disabled="testingMb">{{ testingMb ? 'Testing...' : 'Test' }}</button>
</div>
<div class="service-actions">
<button class="service-stop-btn" @click="stopService('markbase')" :disabled="mbActionLoading">Stop</button>
<button class="service-start-btn" @click="startService('markbase')" :disabled="mbActionLoading">Start</button>
<span v-if="mbActionLoading" class="action-loading">Processing...</span>
<span v-if="mbActionResult" :class="['action-result', mbActionResultOk ? 'ok' : 'error']">{{ mbActionResult }}</span>
</div>
<div v-if="mbInfo" class="service-info">
<span>Version: {{ mbInfo.version || 'N/A' }}</span>
<span>Uptime: {{ formatUptime(mbInfo.uptime) }}</span>
</div>
</div>
<div class="service-card">
<div class="service-header">
<span class="service-name">MarkBaseEngine</span>
<span :class="['service-status', mbeStatus]">{{ mbeStatus }}</span>
</div>
<div class="service-row">
<input class="settings-input" v-model="mbeUrl" placeholder="http://localhost:8080" />
<button class="settings-test-btn" @click="testMbe" :disabled="testingMbe">{{ testingMbe ? 'Testing...' : 'Test' }}</button>
</div>
<div class="service-actions">
<button class="service-stop-btn" @click="stopService('mbe')" :disabled="mbeActionLoading">Stop</button>
<button class="service-start-btn" @click="startService('mbe')" :disabled="mbeActionLoading">Start</button>
<span v-if="mbeActionLoading" class="action-loading">Processing...</span>
<span v-if="mbeActionResult" :class="['action-result', mbeActionResultOk ? 'ok' : 'error']">{{ mbeActionResult }}</span>
</div>
<div v-if="mbeInfo" class="service-info">
<span>Version: {{ mbeInfo.version || 'N/A' }}</span>
<span>Uptime: {{ formatUptime(mbeInfo.uptime) }}</span>
</div>
</div>
<div class="service-card">
<div class="service-header">
<span class="service-name">Proxy</span>
<span :class="['service-status', proxyStatus]">{{ proxyStatus }}</span>
</div>
<div class="service-row">
<input class="settings-input" v-model="proxyUrl" placeholder="http://localhost:8888" />
<button class="settings-test-btn" @click="testProxy" :disabled="testingProxy">{{ testingProxy ? 'Testing...' : 'Test' }}</button>
</div>
<div class="service-actions">
<button class="service-stop-btn" @click="stopService('proxy')" :disabled="proxyActionLoading">Stop</button>
<button class="service-start-btn" @click="startService('proxy')" :disabled="proxyActionLoading">Start</button>
<span v-if="proxyActionLoading" class="action-loading">Processing...</span>
<span v-if="proxyActionResult" :class="['action-result', proxyActionResultOk ? 'ok' : 'error']">{{ proxyActionResult }}</span>
</div>
</div>
<button class="settings-save-btn" @click="saveServiceUrls" style="margin-top: 12px;">Save URLs</button>
<div v-if="urlSaveMsg" :class="['settings-msg', urlSaveError ? 'error' : 'ok']">{{ urlSaveMsg }}</div>
</div>
</div>
</div>
</div>
</template>
@@ -54,6 +157,226 @@ const stats = ref({ cpu: 0, memory: 0, disk: 0 })
const users = ref<any[]>([])
const shares = ref<any[]>([])
// Settings
const uploadPath = ref('')
const saving = ref(false)
const saveMessage = ref('')
const saveError = ref(false)
const settingsError = ref('')
const coreApiUrl = ref('http://localhost:3002')
const markbaseUrl = ref('http://localhost:11438')
const mbeUrl = ref('http://localhost:8080')
const proxyUrl = ref('http://localhost:8888')
const coreStatus = ref('unknown')
const mbStatus = ref('unknown')
const mbeStatus = ref('unknown')
const proxyStatus = ref('unknown')
const coreInfo = ref<any>(null)
const mbInfo = ref<any>(null)
const mbeInfo = ref<any>(null)
const testingCore = ref(false)
const testingMb = ref(false)
const testingMbe = ref(false)
const testingProxy = ref(false)
const urlSaveMsg = ref('')
const urlSaveError = ref(false)
// Service stop/start state
const coreActionLoading = ref(false)
const coreActionResult = ref('')
const coreActionResultOk = ref(false)
const mbActionLoading = ref(false)
const mbActionResult = ref('')
const mbActionResultOk = ref(false)
const mbeActionLoading = ref(false)
const mbeActionResult = ref('')
const mbeActionResultOk = ref(false)
const proxyActionLoading = ref(false)
const proxyActionResult = ref('')
const proxyActionResultOk = ref(false)
async function stopService(name: string) {
const s = serviceState(name)
s.loading.value = true
s.result.value = ''
try {
const result = await apiCall('stop_service', { name })
s.result.value = result === 'stopped' ? 'Service stopped' : result
s.ok.value = true
setTimeout(() => s.test(), 1000)
} catch (e: any) {
s.result.value = 'Error: ' + (e.message || e)
s.ok.value = false
} finally {
s.loading.value = false
}
}
async function startService(name: string) {
const s = serviceState(name)
s.loading.value = true
s.result.value = ''
try {
const result = await apiCall('start_service', { name })
const started = typeof result === 'string' ? result === 'started' : result?.result === 'started'
s.result.value = started ? (name === 'mbe' ? 'Loading model...' : 'Service started') : JSON.stringify(result)
s.ok.value = started
// Poll up to 30s for the service to pass health check
for (let i = 0; i < 15; i++) {
await new Promise(r => setTimeout(r, 2000))
await s.test()
if (s.status.value === 'online') {
s.result.value = 'Service started'
break
}
}
} catch (e: any) {
s.result.value = 'Error: ' + (e.message || e)
s.ok.value = false
} finally {
s.loading.value = false
}
}
function serviceState(name: string) {
switch (name) {
case 'core': return { loading: coreActionLoading, result: coreActionResult, ok: coreActionResultOk, status: coreStatus, test: testCoreApi }
case 'markbase': return { loading: mbActionLoading, result: mbActionResult, ok: mbActionResultOk, status: mbStatus, test: testMarkbase }
case 'proxy': return { loading: proxyActionLoading, result: proxyActionResult, ok: proxyActionResultOk, status: proxyStatus, test: testProxy }
default: return { loading: mbeActionLoading, result: mbeActionResult, ok: mbeActionResultOk, status: mbeStatus, test: testMbe }
}
}
function loadServiceUrls() {
const stored = localStorage.getItem('service_urls')
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const currentOrigin = window.location.origin
if (stored) {
try {
const urls = JSON.parse(stored)
coreApiUrl.value = urls.core || (isLocalhost ? 'http://localhost:3002' : currentOrigin)
markbaseUrl.value = urls.markbase || (isLocalhost ? 'http://localhost:11438' : currentOrigin)
mbeUrl.value = urls.mbe || (isLocalhost ? 'http://localhost:8080' : currentOrigin)
proxyUrl.value = urls.proxy || (isLocalhost ? 'http://localhost:8888' : currentOrigin)
} catch (e) {
coreApiUrl.value = isLocalhost ? 'http://localhost:3002' : currentOrigin
markbaseUrl.value = isLocalhost ? 'http://localhost:11438' : currentOrigin
mbeUrl.value = isLocalhost ? 'http://localhost:8080' : currentOrigin
proxyUrl.value = isLocalhost ? 'http://localhost:8888' : currentOrigin
}
} else {
coreApiUrl.value = isLocalhost ? 'http://localhost:3002' : currentOrigin
markbaseUrl.value = isLocalhost ? 'http://localhost:11438' : currentOrigin
mbeUrl.value = isLocalhost ? 'http://localhost:8080' : currentOrigin
proxyUrl.value = isLocalhost ? 'http://localhost:8888' : currentOrigin
}
}
function saveServiceUrls() {
urlSaveMsg.value = ''
urlSaveError.value = false
try {
localStorage.setItem('service_urls', JSON.stringify({
core: coreApiUrl.value,
markbase: markbaseUrl.value,
mbe: mbeUrl.value,
proxy: proxyUrl.value,
}))
;(window as any).__CORE_API_URL__ = coreApiUrl.value
;(window as any).__MARKBASE_URL__ = markbaseUrl.value
;(window as any).__MBE_URL__ = mbeUrl.value
urlSaveMsg.value = 'Service URLs saved'
} catch (e: any) {
urlSaveError.value = true
urlSaveMsg.value = 'Failed to save: ' + (e.message || e)
}
}
async function testCoreApi() {
testingCore.value = true
coreInfo.value = null
try {
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const healthUrl = isLocalhost ? `${coreApiUrl.value}/health` : `${coreApiUrl.value}/api/v1/health`
const resp = await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
coreStatus.value = 'online'
coreInfo.value = { version: data.version, uptime: data.uptime_ms }
} catch (e: any) {
coreStatus.value = 'offline'
coreInfo.value = null
} finally {
testingCore.value = false
}
}
async function testMarkbase() {
testingMb.value = true
mbInfo.value = null
try {
const resp = await fetch(`${markbaseUrl.value}/api/v2/health`, { signal: AbortSignal.timeout(5000) })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
mbStatus.value = 'online'
mbInfo.value = { version: data.version, uptime: data.uptime }
} catch (e: any) {
mbStatus.value = 'offline'
mbInfo.value = null
} finally {
testingMb.value = false
}
}
async function testMbe() {
testingMbe.value = true
mbeInfo.value = null
try {
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const healthUrl = isLocalhost ? `${mbeUrl.value}/health` : `${mbeUrl.value}/v1/health`
const resp = await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const text = await resp.text()
if (text === 'OK') {
mbeStatus.value = 'online'
mbeInfo.value = { version: 'MarkBaseEngine', uptime: 0 }
} else {
throw new Error('Unexpected response: ' + text)
}
} catch (e: any) {
mbeStatus.value = 'offline'
mbeInfo.value = null
} finally {
testingMbe.value = false
}
}
async function testProxy() {
testingProxy.value = true
try {
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const healthUrl = isLocalhost ? `${proxyUrl.value}/health` : `${proxyUrl.value}/api/v1/health`
const resp = await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })
proxyStatus.value = resp.ok ? 'online' : 'offline'
} catch {
proxyStatus.value = 'offline'
} finally {
testingProxy.value = false
}
}
function formatUptime(seconds: number | undefined): string {
if (!seconds) return 'N/A'
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (d > 0) return `${d}d ${h}h ${m}m`
if (h > 0) return `${h}h ${m}m`
return `${m}m`
}
async function loadDashboard() {
try { stats.value = await apiCall('get_system_stats', {}) || { cpu: 0, memory: 0, disk: 0 } } catch (e) { console.error(e) }
}
@@ -66,20 +389,90 @@ async function loadShares() {
try { shares.value = await apiCall('list_shares', {}) || [] } catch (e) { console.error(e) }
}
onMounted(async () => { await loadDashboard(); await loadUsers(); await loadShares() })
async function loadSettings() {
settingsError.value = ''
saveMessage.value = ''
try {
const resp = await fetch(`${markbaseUrl.value}/api/v2/config`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const cfg = await resp.json()
uploadPath.value = cfg.server?.upload_path || ''
} catch (e: any) {
settingsError.value = 'Failed to load settings: ' + (e.message || e)
}
}
async function saveUploadPath() {
saving.value = true
saveMessage.value = ''
saveError.value = false
try {
const resp = await fetch(`${markbaseUrl.value}/api/v2/config/edit?key=server.upload_path&value=${encodeURIComponent(uploadPath.value)}`, { method: 'POST' })
const data = await resp.json()
if (data.ok) {
saveMessage.value = 'Upload path saved successfully'
} else {
saveError.value = true
saveMessage.value = data.error || 'Save failed'
}
} catch (e: any) {
saveError.value = true
saveMessage.value = 'Error: ' + (e.message || e)
} finally {
saving.value = false
}
}
onMounted(async () => {
loadServiceUrls()
await Promise.all([loadDashboard(), loadUsers(), loadShares(), loadSettings()])
await Promise.all([testCoreApi(), testMarkbase(), testMbe(), testProxy()])
})
</script>
<style scoped>
#ms-view-admin { padding: 20px; }
#ms-view-admin { padding: 20px; color: var(--text-primary); }
.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.admin-tabs { display: flex; gap: 10px; }
.admin-tab { padding: 8px 16px; border: none; background: #f0f0f0; cursor: pointer; border-radius: 4px; }
.admin-tab.active { background: #2563eb; color: white; }
.admin-content { background: white; border-radius: 8px; padding: 20px; }
.admin-tab { padding: 8px 16px; border: none; background: var(--hover-background); color: var(--text-primary); cursor: pointer; border-radius: 4px; }
.admin-tab.active { background: var(--primary-color); color: white; }
.admin-content { background: var(--card-background); border-radius: 8px; padding: 20px; }
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 15px; }
.stat-card { background: #f8f9fa; padding: 15px; border-radius: 8px; text-align: center; }
.stat-label { font-size: 12px; color: #666; }
.stat-value { font-size: 24px; font-weight: bold; }
.stat-card { background: var(--hover-background); padding: 15px; border-radius: 8px; text-align: center; }
.stat-label { font-size: 12px; color: var(--text-secondary); }
.stat-value { font-size: 24px; font-weight: bold; color: var(--text-primary); }
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th, .admin-table td { padding: 10px; border: 1px solid #ddd; text-align: left; }
.admin-table th, .admin-table td { padding: 10px; border: 1px solid var(--border-color); text-align: left; color: var(--text-primary); }
.settings-panel { max-width: 600px; }
.settings-group { margin-bottom: 24px; }
.settings-group h3 { margin: 0 0 4px; font-size: 16px; color: var(--text-primary); }
.settings-desc { margin: 0 0 10px; font-size: 13px; color: var(--text-secondary); }
.settings-row { display: flex; gap: 8px; align-items: center; }
.settings-input { flex: 1; padding: 8px 12px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 14px; background: var(--card-background); color: var(--text-primary); }
.settings-save-btn { padding: 8px 16px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; }
.settings-save-btn:disabled { opacity: 0.6; cursor: default; }
.settings-msg { margin-top: 8px; font-size: 13px; }
.settings-msg.ok { color: var(--success-color); }
.settings-msg.error { color: var(--danger-color); }
.settings-error { background: var(--danger-background); color: var(--danger-color); padding: 10px; border-radius: 4px; margin-bottom: 16px; }
.service-card { background: var(--hover-background); border-radius: 6px; padding: 12px; margin-top: 12px; }
.service-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.service-name { font-weight: 600; font-size: 14px; color: var(--text-primary); }
.service-status { font-size: 12px; padding: 2px 8px; border-radius: 4px; text-transform: uppercase; }
.service-status.online { background: rgba(var(--success-color), 0.2); color: var(--success-color); }
.service-status.offline { background: var(--danger-background); color: var(--danger-color); }
.service-status.unknown { background: var(--hover-background); color: var(--text-secondary); }
.service-row { display: flex; gap: 8px; align-items: center; }
.service-test-btn { padding: 6px 12px; background: var(--text-secondary); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; }
.service-test-btn:disabled { opacity: 0.6; cursor: default; }
.service-info { margin-top: 8px; font-size: 12px; color: var(--text-secondary); display: flex; gap: 16px; }
.service-actions { display: flex; gap: 6px; align-items: center; margin-top: 8px; }
.service-stop-btn { padding: 4px 12px; background: var(--danger-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; }
.service-stop-btn:disabled { opacity: 0.5; cursor: default; }
.service-start-btn { padding: 4px 12px; background: var(--success-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; }
.service-start-btn:disabled { opacity: 0.5; cursor: default; }
.action-loading { font-size: 12px; color: var(--text-secondary); }
.action-result { font-size: 12px; }
.action-result.ok { color: var(--success-color); }
.action-result.error { color: var(--danger-color); }
</style>