refactor storage minio for k8s
This commit is contained in:
386
ui/detection-app/src/panels/SourceSelector.vue
Normal file
386
ui/detection-app/src/panels/SourceSelector.vue
Normal file
@@ -0,0 +1,386 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Panel } from 'mpr-ui-framework'
|
||||
import { usePipelineStore } from '../stores/pipeline'
|
||||
|
||||
const pipeline = usePipelineStore()
|
||||
|
||||
interface ChunkInfo {
|
||||
filename: string
|
||||
key: string
|
||||
size_bytes: number
|
||||
}
|
||||
|
||||
interface SourceInfo {
|
||||
job_id: string
|
||||
source_type: string
|
||||
chunk_count: number
|
||||
total_bytes: number
|
||||
}
|
||||
|
||||
const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||
chunk_job: 'CHUNKS',
|
||||
upload: 'UPLOAD',
|
||||
device: 'DEVICE',
|
||||
stream: 'STREAM',
|
||||
}
|
||||
|
||||
const sources = ref<SourceInfo[]>([])
|
||||
const chunks = ref<ChunkInfo[]>([])
|
||||
const selectedSource = ref<string | null>(null)
|
||||
const selectedChunk = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const running = ref(false)
|
||||
const skipVlm = ref(false)
|
||||
const skipCloud = ref(true)
|
||||
const checkpoint = ref(true)
|
||||
const logLevel = ref('INFO')
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function loadSources() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await fetch('/api/detect/sources')
|
||||
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`)
|
||||
sources.value = await resp.json()
|
||||
} catch (e: any) {
|
||||
error.value = `Failed to load sources: ${e.message}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChunks(jobId: string) {
|
||||
selectedSource.value = jobId
|
||||
selectedChunk.value = null
|
||||
chunks.value = []
|
||||
try {
|
||||
const resp = await fetch(`/api/detect/sources/${jobId}/chunks`)
|
||||
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`)
|
||||
chunks.value = await resp.json()
|
||||
} catch (e: any) {
|
||||
error.value = `Failed to load chunks: ${e.message}`
|
||||
}
|
||||
}
|
||||
|
||||
function selectChunk(chunk: ChunkInfo) {
|
||||
selectedChunk.value = chunk.key
|
||||
}
|
||||
|
||||
async function openPreview(chunk: ChunkInfo) {
|
||||
if (!selectedSource.value) return
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/detect/sources/${selectedSource.value}/chunks/${encodeURIComponent(chunk.filename)}/url`
|
||||
)
|
||||
if (!resp.ok) throw new Error(`${resp.status}`)
|
||||
const data = await resp.json()
|
||||
window.open(data.url, '_blank')
|
||||
} catch (e: any) {
|
||||
error.value = `Could not get preview URL: ${e.message}`
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'job-started', jobId: string): void
|
||||
}>()
|
||||
|
||||
async function runPipeline() {
|
||||
if (!selectedChunk.value) return
|
||||
running.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/detect/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
video_path: selectedChunk.value,
|
||||
checkpoint: checkpoint.value,
|
||||
skip_vlm: skipVlm.value,
|
||||
skip_cloud: skipCloud.value,
|
||||
log_level: logLevel.value,
|
||||
}),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.text()
|
||||
throw new Error(`${resp.status}: ${detail}`)
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
emit('job-started', data.job_id)
|
||||
} catch (e: any) {
|
||||
error.value = `Failed to start pipeline: ${e.message}`
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`
|
||||
}
|
||||
|
||||
function sourceTypeLabel(sourceType: string): string {
|
||||
return SOURCE_TYPE_LABELS[sourceType] ?? sourceType.toUpperCase()
|
||||
}
|
||||
|
||||
onMounted(loadSources)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Panel title="Select Source">
|
||||
<div class="source-selector">
|
||||
|
||||
<div v-if="error" class="source-error">{{ error }}</div>
|
||||
|
||||
<!-- Source list -->
|
||||
<div class="source-section">
|
||||
<h3>Chunk Jobs</h3>
|
||||
<div class="source-list" v-if="!loading">
|
||||
<div
|
||||
v-for="src in sources"
|
||||
:key="src.job_id"
|
||||
:class="['source-item', { selected: selectedSource === src.job_id }]"
|
||||
@click="loadChunks(src.job_id)"
|
||||
>
|
||||
<span class="source-id">{{ src.job_id.slice(0, 12) }}</span>
|
||||
<span class="source-meta">
|
||||
<span class="source-type-badge">{{ sourceTypeLabel(src.source_type) }}</span>
|
||||
<span class="source-count">{{ src.chunk_count }} chunks</span>
|
||||
<span class="source-size">{{ formatSize(src.total_bytes) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="sources.length === 0" class="source-empty">No sources found</div>
|
||||
</div>
|
||||
<div v-else class="source-empty">Loading...</div>
|
||||
</div>
|
||||
|
||||
<!-- Chunk list -->
|
||||
<div class="source-section" v-if="chunks.length > 0">
|
||||
<h3>Chunks</h3>
|
||||
<div class="chunk-list">
|
||||
<div
|
||||
v-for="chunk in chunks"
|
||||
:key="chunk.key"
|
||||
:class="['chunk-item', { selected: selectedChunk === chunk.key }]"
|
||||
@click="selectChunk(chunk)"
|
||||
>
|
||||
<span class="chunk-name">{{ chunk.filename }}</span>
|
||||
<span class="chunk-meta">
|
||||
<span class="chunk-size">{{ formatSize(chunk.size_bytes) }}</span>
|
||||
<button
|
||||
class="preview-btn"
|
||||
@click.stop="openPreview(chunk)"
|
||||
title="Open preview"
|
||||
>▶</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Run options -->
|
||||
<div class="run-options" v-if="selectedChunk">
|
||||
<h3>Run Options</h3>
|
||||
<label><input type="checkbox" v-model="checkpoint"> Checkpointing</label>
|
||||
<label><input type="checkbox" v-model="skipVlm"> Skip VLM</label>
|
||||
<label><input type="checkbox" v-model="skipCloud"> Skip Cloud</label>
|
||||
<label>
|
||||
Log level
|
||||
<select v-model="logLevel" class="log-level-select">
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="selected-path">{{ selectedChunk }}</div>
|
||||
|
||||
<button class="run-btn" @click="runPipeline" :disabled="running">
|
||||
{{ running ? 'Starting...' : 'Run Pipeline' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="source-actions">
|
||||
<button class="editor-close" @click="pipeline.closeEditor()">✕ Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.source-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.source-error {
|
||||
color: var(--status-error);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--space-2);
|
||||
background: rgba(224, 82, 82, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.source-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.source-section h3 {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.source-list, .chunk-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--panel-radius);
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
.source-item, .chunk-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.source-item:hover, .chunk-item:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.source-item.selected, .chunk-item.selected {
|
||||
background: var(--surface-3);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.source-id { font-family: var(--font-mono); }
|
||||
|
||||
.source-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.source-type-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--status-live);
|
||||
background: rgba(0, 255, 128, 0.1);
|
||||
border-radius: 2px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.source-count, .chunk-size, .source-size { color: var(--text-dim); font-size: 11px; }
|
||||
|
||||
.log-level-select {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--surface-3);
|
||||
border-radius: 3px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 2px 4px;
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
.source-empty { color: var(--text-dim); text-align: center; padding: var(--space-3); font-size: var(--font-size-sm); }
|
||||
|
||||
.chunk-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.preview-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--surface-3);
|
||||
border-radius: 3px;
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.preview-btn:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.run-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.run-options label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selected-path {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--surface-2);
|
||||
border-radius: 3px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.run-btn {
|
||||
background: var(--status-live);
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.run-btn:hover { opacity: 0.9; }
|
||||
.run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.source-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.editor-close {
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--surface-3);
|
||||
border-radius: 4px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.editor-close:hover {
|
||||
background: var(--status-error);
|
||||
color: #000;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user