ui
This commit is contained in:
20
ui/.gitignore
vendored
Normal file
20
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
|
||||||
|
# Vite cache
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Editor / local
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
12
ui/app/index.html
Normal file
12
ui/app/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Meetus — Review</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1618
ui/app/package-lock.json
generated
Normal file
1618
ui/app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
ui/app/package.json
Normal file
23
ui/app/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "meetus-review-ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"jszip": "^3.10",
|
||||||
|
"vue": "^3.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@vitejs/plugin-vue": "^5",
|
||||||
|
"typescript": "^5.6",
|
||||||
|
"vite": "^6",
|
||||||
|
"vue-tsc": "^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
340
ui/app/server/meetusApi.ts
Normal file
340
ui/app/server/meetusApi.ts
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* Vite dev middleware exposing the local meetus output tree to the app.
|
||||||
|
*
|
||||||
|
* Runs in the dev server process (Node), so it can reach the source video by
|
||||||
|
* the absolute path stored in each run's manifest.json — something a browser
|
||||||
|
* cannot do. All reads are local; nothing leaves the machine.
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* GET /api/runs list runs
|
||||||
|
* GET /api/runs/:id manifest + segments + frames + review
|
||||||
|
* GET /api/runs/:id/frames/:file stream a frame JPEG
|
||||||
|
* GET /api/runs/:id/video stream the source video (Range support)
|
||||||
|
* PUT /api/runs/:id/review write sidecar review.json (atomic)
|
||||||
|
*/
|
||||||
|
import type { Plugin } from 'vite'
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import fsp from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
outputDir: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Manifest {
|
||||||
|
video?: { name?: string; path?: string }
|
||||||
|
processed_at?: string
|
||||||
|
outputs?: {
|
||||||
|
frames?: string
|
||||||
|
enhanced_transcript?: string
|
||||||
|
whisper_transcript?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIDEO_MIME: Record<string, string> = {
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.m4v': 'video/mp4',
|
||||||
|
'.webm': 'video/webm',
|
||||||
|
'.mov': 'video/quicktime',
|
||||||
|
'.mkv': 'video/x-matroska',
|
||||||
|
'.avi': 'video/x-msvideo',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function meetusApi(opts: Options): Plugin {
|
||||||
|
const outputDir = path.resolve(opts.outputDir)
|
||||||
|
|
||||||
|
/** Resolve a run id to its directory, rejecting traversal. */
|
||||||
|
function runDir(id: string): string | null {
|
||||||
|
const safe = path.basename(id)
|
||||||
|
if (safe !== id || !safe || safe.startsWith('.')) return null
|
||||||
|
const dir = path.join(outputDir, safe)
|
||||||
|
if (!dir.startsWith(outputDir + path.sep)) return null
|
||||||
|
try {
|
||||||
|
if (fs.statSync(dir).isDirectory()) return dir
|
||||||
|
} catch { /* missing */ }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readManifest(dir: string): Manifest | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf-8'))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'meetus-api',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use('/api', (req, res, next) => {
|
||||||
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
||||||
|
const parts = url.pathname.split('/').filter(Boolean) // after /api strip
|
||||||
|
// parts: ['runs', ...] (the '/api' prefix is stripped by `use('/api', ...)`)
|
||||||
|
handle(req, res, parts, server.config.logger).catch((err) => {
|
||||||
|
server.config.logger.error(`[meetus-api] ${String(err)}`)
|
||||||
|
sendJson(res, 500, { error: String(err) })
|
||||||
|
}).then((handled) => {
|
||||||
|
if (!handled) next()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
parts: string[],
|
||||||
|
logger: { warn: (m: string) => void },
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (parts[0] !== 'runs') return false
|
||||||
|
|
||||||
|
// GET /api/runs
|
||||||
|
if (parts.length === 1 && req.method === 'GET') {
|
||||||
|
await listRuns(res)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = parts[1] ? decodeURIComponent(parts[1]) : ''
|
||||||
|
const dir = id ? runDir(id) : null
|
||||||
|
if (!dir) { sendJson(res, 404, { error: 'run not found' }); return true }
|
||||||
|
|
||||||
|
// GET /api/runs/:id
|
||||||
|
if (parts.length === 2 && req.method === 'GET') {
|
||||||
|
await getRun(res, id, dir)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/runs/:id/frames/:file
|
||||||
|
if (parts.length === 4 && parts[2] === 'frames' && req.method === 'GET') {
|
||||||
|
serveFrame(req, res, dir, decodeURIComponent(parts[3]))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/runs/:id/video
|
||||||
|
if (parts.length === 3 && parts[2] === 'video' && req.method === 'GET') {
|
||||||
|
serveVideo(req, res, dir, logger)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/runs/:id/review
|
||||||
|
if (parts.length === 3 && parts[2] === 'review' && req.method === 'PUT') {
|
||||||
|
await saveReview(req, res, dir)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(res, 404, { error: 'not found' })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRuns(res: ServerResponse): Promise<void> {
|
||||||
|
let entries: string[] = []
|
||||||
|
try {
|
||||||
|
entries = (await fsp.readdir(outputDir, { withFileTypes: true }))
|
||||||
|
.filter((e) => e.isDirectory())
|
||||||
|
.map((e) => e.name)
|
||||||
|
} catch {
|
||||||
|
sendJson(res, 200, { runs: [], outputDir })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const runs = []
|
||||||
|
for (const name of entries.sort().reverse()) {
|
||||||
|
const dir = path.join(outputDir, name)
|
||||||
|
const manifest = readManifest(dir)
|
||||||
|
if (!manifest) continue
|
||||||
|
const framesRel = manifest.outputs?.frames ?? 'frames'
|
||||||
|
let frameCount = 0
|
||||||
|
try {
|
||||||
|
frameCount = (await fsp.readdir(path.join(dir, framesRel)))
|
||||||
|
.filter((f) => f.toLowerCase().endsWith('.jpg')).length
|
||||||
|
} catch { /* no frames */ }
|
||||||
|
runs.push({
|
||||||
|
id: name,
|
||||||
|
name: manifest.video?.name ?? name,
|
||||||
|
processedAt: manifest.processed_at ?? null,
|
||||||
|
frameCount,
|
||||||
|
hasVideo: !!manifest.video?.path && fs.existsSync(manifest.video.path),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sendJson(res, 200, { runs, outputDir })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRun(res: ServerResponse, id: string, dir: string): Promise<void> {
|
||||||
|
const manifest = readManifest(dir)
|
||||||
|
if (!manifest) { sendJson(res, 404, { error: 'manifest missing' }); return }
|
||||||
|
|
||||||
|
// Segments from the Whisper JSON. The manifest sometimes records
|
||||||
|
// `whisper_transcript: null` even though `{stem}.json` exists on disk
|
||||||
|
// (whisper run out-of-band), so fall back to probing for it by stem.
|
||||||
|
const segments: Array<{ start: number; end: number; text: string; speaker: string | null }> = []
|
||||||
|
let whisperName = manifest.outputs?.whisper_transcript ?? null
|
||||||
|
if (!whisperName) {
|
||||||
|
const stem = manifest.video?.name ? path.parse(manifest.video.name).name : null
|
||||||
|
if (stem && fs.existsSync(path.join(dir, `${stem}.json`))) whisperName = `${stem}.json`
|
||||||
|
}
|
||||||
|
if (whisperName && fs.existsSync(path.join(dir, whisperName))) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await fsp.readFile(path.join(dir, whisperName), 'utf-8'))
|
||||||
|
const segs = Array.isArray(data) ? data : data.segments ?? []
|
||||||
|
for (const s of segs) {
|
||||||
|
segments.push({
|
||||||
|
start: Number(s.start ?? s.timestamp ?? 0),
|
||||||
|
end: Number(s.end ?? s.start ?? 0),
|
||||||
|
text: String(s.text ?? '').trim(),
|
||||||
|
speaker: s.speaker ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch { /* no/invalid transcript */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frame → time map from enhanced.txt (universal, MM:SS granularity).
|
||||||
|
const framesRel = manifest.outputs?.frames ?? 'frames'
|
||||||
|
const enhancedName = manifest.outputs?.enhanced_transcript
|
||||||
|
const enhancedTimes = new Map<string, number>()
|
||||||
|
let enhancedAvailable = false
|
||||||
|
if (enhancedName) {
|
||||||
|
try {
|
||||||
|
const text = await fsp.readFile(path.join(dir, enhancedName), 'utf-8')
|
||||||
|
enhancedAvailable = true
|
||||||
|
let lastTs: number | null = null
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
const ts = line.match(/^\[(\d+):(\d+)\]/)
|
||||||
|
if (ts) lastTs = Number(ts[1]) * 60 + Number(ts[2])
|
||||||
|
const fm = line.match(/Frame:\s*\S*?([^/\\\s]+\.jpg)/i)
|
||||||
|
if (fm && lastTs != null) enhancedTimes.set(fm[1], lastTs)
|
||||||
|
}
|
||||||
|
} catch { /* none */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frame list with resolved times and byte sizes.
|
||||||
|
const frames: Array<{ file: string; url: string; time: number | null; size: number }> = []
|
||||||
|
try {
|
||||||
|
const framesAbs = path.join(dir, framesRel)
|
||||||
|
const files = (await fsp.readdir(framesAbs))
|
||||||
|
.filter((f) => f.toLowerCase().endsWith('.jpg'))
|
||||||
|
.sort()
|
||||||
|
for (const file of files) {
|
||||||
|
// Interval-mode filenames encode an exact float: `..._123.45s.jpg`.
|
||||||
|
const fny = file.match(/_(\d+(?:\.\d+)?)s\.jpg$/i)
|
||||||
|
const time = fny ? Number(fny[1])
|
||||||
|
: enhancedTimes.has(file) ? enhancedTimes.get(file)!
|
||||||
|
: null
|
||||||
|
let size = 0
|
||||||
|
try { size = (await fsp.stat(path.join(framesAbs, file))).size } catch { /* ignore */ }
|
||||||
|
frames.push({
|
||||||
|
file,
|
||||||
|
url: `/api/runs/${encodeURIComponent(id)}/frames/${encodeURIComponent(file)}`,
|
||||||
|
time,
|
||||||
|
size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch { /* no frames dir */ }
|
||||||
|
|
||||||
|
// Existing review sidecar, if any.
|
||||||
|
let review: unknown = null
|
||||||
|
try {
|
||||||
|
review = JSON.parse(await fsp.readFile(path.join(dir, 'review.json'), 'utf-8'))
|
||||||
|
} catch { /* none */ }
|
||||||
|
|
||||||
|
sendJson(res, 200, {
|
||||||
|
id,
|
||||||
|
manifest,
|
||||||
|
segments,
|
||||||
|
frames,
|
||||||
|
enhancedAvailable,
|
||||||
|
hasVideo: !!manifest.video?.path && fs.existsSync(manifest.video.path),
|
||||||
|
videoUrl: `/api/runs/${encodeURIComponent(id)}/video`,
|
||||||
|
review,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveFrame(req: IncomingMessage, res: ServerResponse, dir: string, file: string): void {
|
||||||
|
const manifest = readManifest(dir)
|
||||||
|
const framesRel = manifest?.outputs?.frames ?? 'frames'
|
||||||
|
const safe = path.basename(file)
|
||||||
|
const full = path.join(dir, framesRel, safe)
|
||||||
|
if (safe !== file || !fs.existsSync(full)) { sendJson(res, 404, { error: 'frame not found' }); return }
|
||||||
|
res.setHeader('Content-Type', 'image/jpeg')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
|
fs.createReadStream(full).pipe(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveVideo(
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
dir: string,
|
||||||
|
logger: { warn: (m: string) => void },
|
||||||
|
): void {
|
||||||
|
const manifest = readManifest(dir)
|
||||||
|
const videoPath = manifest?.video?.path
|
||||||
|
if (!videoPath || !fs.existsSync(videoPath)) {
|
||||||
|
logger.warn(`[meetus-api] source video missing for run: ${dir}`)
|
||||||
|
sendJson(res, 404, { error: 'source video not available at manifest path' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const stat = fs.statSync(videoPath)
|
||||||
|
const mime = VIDEO_MIME[path.extname(videoPath).toLowerCase()] ?? 'application/octet-stream'
|
||||||
|
res.setHeader('Accept-Ranges', 'bytes')
|
||||||
|
res.setHeader('Content-Type', mime)
|
||||||
|
|
||||||
|
const range = req.headers.range
|
||||||
|
if (range) {
|
||||||
|
const m = range.match(/bytes=(\d*)-(\d*)/)
|
||||||
|
if (m) {
|
||||||
|
const start = m[1] ? parseInt(m[1], 10) : 0
|
||||||
|
const end = m[2] ? parseInt(m[2], 10) : stat.size - 1
|
||||||
|
if (start >= stat.size || end >= stat.size || start > end) {
|
||||||
|
res.statusCode = 416
|
||||||
|
res.setHeader('Content-Range', `bytes */${stat.size}`)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.statusCode = 206
|
||||||
|
res.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`)
|
||||||
|
res.setHeader('Content-Length', String(end - start + 1))
|
||||||
|
fs.createReadStream(videoPath, { start, end }).pipe(res)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.statusCode = 200
|
||||||
|
res.setHeader('Content-Length', String(stat.size))
|
||||||
|
fs.createReadStream(videoPath).pipe(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveReview(req: IncomingMessage, res: ServerResponse, dir: string): Promise<void> {
|
||||||
|
const body = await readBody(req)
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(body || '{}')
|
||||||
|
} catch {
|
||||||
|
sendJson(res, 400, { error: 'invalid JSON' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) {
|
||||||
|
sendJson(res, 400, { error: 'review must be an object' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const target = path.join(dir, 'review.json')
|
||||||
|
const tmp = path.join(dir, `.review.${process.pid}.tmp`)
|
||||||
|
await fsp.writeFile(tmp, JSON.stringify(parsed, null, 2), 'utf-8')
|
||||||
|
await fsp.rename(tmp, target) // atomic; never touches source artifacts
|
||||||
|
sendJson(res, 200, { ok: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res: ServerResponse, status: number, obj: unknown): void {
|
||||||
|
const body = JSON.stringify(obj)
|
||||||
|
res.statusCode = status
|
||||||
|
res.setHeader('Content-Type', 'application/json')
|
||||||
|
res.setHeader('Content-Length', Buffer.byteLength(body))
|
||||||
|
res.end(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req: IncomingMessage): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let data = ''
|
||||||
|
req.on('data', (c) => { data += c; if (data.length > 5_000_000) reject(new Error('body too large')) })
|
||||||
|
req.on('end', () => resolve(data))
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
89
ui/app/src/App.vue
Normal file
89
ui/app/src/App.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SplitPane from '@framework/components/SplitPane.vue'
|
||||||
|
import Panel from '@framework/components/Panel.vue'
|
||||||
|
import VideoPlayer from '@framework/components/VideoPlayer.vue'
|
||||||
|
import RunPicker from '@/components/RunPicker.vue'
|
||||||
|
import TranscriptEditor from '@/components/TranscriptEditor.vue'
|
||||||
|
import FrameSelector from '@/components/FrameSelector.vue'
|
||||||
|
import ExportBar from '@/components/ExportBar.vue'
|
||||||
|
import { useStore } from '@/store'
|
||||||
|
|
||||||
|
const { state } = useStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app">
|
||||||
|
<header class="topbar">
|
||||||
|
<RunPicker />
|
||||||
|
<div class="spacer" />
|
||||||
|
<ExportBar />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<SplitPane direction="horizontal" :initialSize="1.4" sizeMode="ratio" :min="0.4" :max="4">
|
||||||
|
<template #first>
|
||||||
|
<SplitPane direction="vertical" :initialSize="1.3" sizeMode="ratio" :min="0.3" :max="4">
|
||||||
|
<template #first>
|
||||||
|
<Panel title="Video" :status="state.playing ? 'live' : 'idle'">
|
||||||
|
<VideoPlayer
|
||||||
|
v-if="state.videoUrl"
|
||||||
|
:src="state.videoUrl"
|
||||||
|
v-model:currentTime="state.currentTime"
|
||||||
|
v-model:playing="state.playing"
|
||||||
|
@duration="state.duration = $event"
|
||||||
|
/>
|
||||||
|
<div v-else class="placeholder">
|
||||||
|
{{ state.currentRunId
|
||||||
|
? 'Source video not available at the path recorded in manifest.json.'
|
||||||
|
: 'Select a meeting to begin.' }}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
<template #second>
|
||||||
|
<FrameSelector />
|
||||||
|
</template>
|
||||||
|
</SplitPane>
|
||||||
|
</template>
|
||||||
|
<template #second>
|
||||||
|
<TranscriptEditor />
|
||||||
|
</template>
|
||||||
|
</SplitPane>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
height: 52px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
background: var(--surface-1);
|
||||||
|
border-bottom: var(--panel-border);
|
||||||
|
}
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
.placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
88
ui/app/src/components/ExportBar.vue
Normal file
88
ui/app/src/components/ExportBar.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useStore } from '@/store'
|
||||||
|
|
||||||
|
const { state, packageEstimate, selectedFrames, buildTranscript } = useStore()
|
||||||
|
|
||||||
|
const busy = ref(false)
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
const saveLabel = computed(() => {
|
||||||
|
switch (state.saveStatus) {
|
||||||
|
case 'saving': return 'saving…'
|
||||||
|
case 'saved': return 'saved'
|
||||||
|
case 'error': return 'save failed'
|
||||||
|
default: return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatBytes(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||||
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTranscript() {
|
||||||
|
await navigator.clipboard.writeText(buildTranscript())
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => { copied.value = false }, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadPackage() {
|
||||||
|
if (!state.currentRunId) return
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const { default: JSZip } = await import('jszip')
|
||||||
|
const zip = new JSZip()
|
||||||
|
zip.file('transcript.txt', buildTranscript())
|
||||||
|
const framesFolder = zip.folder('frames')!
|
||||||
|
for (const f of selectedFrames.value) {
|
||||||
|
const res = await fetch(f.url)
|
||||||
|
framesFolder.file(f.file, await res.blob())
|
||||||
|
}
|
||||||
|
const blob = await zip.generateAsync({ type: 'blob' })
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = URL.createObjectURL(blob)
|
||||||
|
a.download = `${state.currentRunId}-package.zip`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(a.href)
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="export-bar">
|
||||||
|
<span v-if="saveLabel" class="save" :class="state.saveStatus">{{ saveLabel }}</span>
|
||||||
|
<span class="estimate" v-if="state.currentRunId">
|
||||||
|
{{ packageEstimate.frames }}/{{ packageEstimate.totalFrames }} frames ·
|
||||||
|
{{ formatBytes(packageEstimate.bytes) }}
|
||||||
|
</span>
|
||||||
|
<button :disabled="!state.currentRunId" @click="copyTranscript">
|
||||||
|
{{ copied ? 'copied!' : 'Copy transcript' }}
|
||||||
|
</button>
|
||||||
|
<button :disabled="!state.currentRunId || busy" @click="downloadPackage">
|
||||||
|
{{ busy ? 'building…' : 'Download package' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.export-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.estimate {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.save {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
.save.saved { color: var(--status-live); }
|
||||||
|
.save.saving { color: var(--status-processing); }
|
||||||
|
.save.error { color: var(--status-error); }
|
||||||
|
</style>
|
||||||
121
ui/app/src/components/FrameSelector.vue
Normal file
121
ui/app/src/components/FrameSelector.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
import Panel from '@framework/components/Panel.vue'
|
||||||
|
import { useStore } from '@/store'
|
||||||
|
|
||||||
|
const { state, activeFrameIndex, selectedFrames, seek, setSelectAll } = useStore()
|
||||||
|
|
||||||
|
const cardEls = ref<Record<number, HTMLElement>>({})
|
||||||
|
|
||||||
|
function setCard(i: number, el: Element | null) {
|
||||||
|
if (el) cardEls.value[i] = el as HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(seconds: number | null): string {
|
||||||
|
if (seconds == null) return '—'
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(activeFrameIndex, async (i) => {
|
||||||
|
if (i < 0) return
|
||||||
|
await nextTick()
|
||||||
|
cardEls.value[i]?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Panel title="Frames">
|
||||||
|
<template #actions>
|
||||||
|
<span class="count">{{ selectedFrames.length }}/{{ state.frames.length }} selected</span>
|
||||||
|
<button @click="setSelectAll(true)">All</button>
|
||||||
|
<button @click="setSelectAll(false)">None</button>
|
||||||
|
</template>
|
||||||
|
<div class="strip">
|
||||||
|
<p v-if="!state.frames.length" class="empty">No frames for this run.</p>
|
||||||
|
<div
|
||||||
|
v-for="(f, i) in state.frames"
|
||||||
|
:key="f.file"
|
||||||
|
:ref="(el) => setCard(i, el as Element | null)"
|
||||||
|
class="card"
|
||||||
|
:class="{ active: i === activeFrameIndex, off: !f.selected }"
|
||||||
|
>
|
||||||
|
<div class="thumb" @click="f.time != null && seek(f.time)" :title="f.time != null ? 'Seek here' : 'No timestamp'">
|
||||||
|
<img :src="f.url" loading="lazy" :alt="f.file" />
|
||||||
|
<span class="time">{{ fmt(f.time) }}</span>
|
||||||
|
</div>
|
||||||
|
<label class="pick">
|
||||||
|
<input type="checkbox" v-model="f.selected" />
|
||||||
|
include
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.strip {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-2);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-content: flex-start;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 150px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--panel-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-1);
|
||||||
|
}
|
||||||
|
.card.active {
|
||||||
|
border-color: var(--status-processing);
|
||||||
|
box-shadow: 0 0 0 1px var(--status-processing);
|
||||||
|
}
|
||||||
|
.card.off {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.thumb {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 84px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.time {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
right: 2px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.pick {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.count {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
52
ui/app/src/components/RunPicker.vue
Normal file
52
ui/app/src/components/RunPicker.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useStore } from '@/store'
|
||||||
|
|
||||||
|
const { state, loadRuns, openRun } = useStore()
|
||||||
|
|
||||||
|
onMounted(loadRuns)
|
||||||
|
|
||||||
|
function onChange(e: Event) {
|
||||||
|
const id = (e.target as HTMLSelectElement).value
|
||||||
|
if (id) openRun(id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="run-picker">
|
||||||
|
<label class="lbl">Meeting</label>
|
||||||
|
<select :value="state.currentRunId ?? ''" @change="onChange">
|
||||||
|
<option value="" disabled>Select a run…</option>
|
||||||
|
<option v-for="r in state.runs" :key="r.id" :value="r.id">
|
||||||
|
{{ r.name }} · {{ r.frameCount }} frames{{ r.hasVideo ? '' : ' · no video' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span v-if="state.loading" class="hint">loading…</span>
|
||||||
|
<span v-else-if="state.error" class="hint err">{{ state.error }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.run-picker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.lbl {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
.hint.err {
|
||||||
|
color: var(--status-error);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
128
ui/app/src/components/TranscriptEditor.vue
Normal file
128
ui/app/src/components/TranscriptEditor.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
import Panel from '@framework/components/Panel.vue'
|
||||||
|
import { useStore } from '@/store'
|
||||||
|
|
||||||
|
const { state, activeSegmentIndex, seek } = useStore()
|
||||||
|
|
||||||
|
const listEl = ref<HTMLElement | null>(null)
|
||||||
|
const rowEls = ref<Record<number, HTMLElement>>({})
|
||||||
|
|
||||||
|
function fmt(seconds: number): string {
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRow(i: number, el: Element | null) {
|
||||||
|
if (el) rowEls.value[i] = el as HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoResize(e: Event) {
|
||||||
|
const el = e.target as HTMLTextAreaElement
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = `${el.scrollHeight}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAreaMounted(el: HTMLTextAreaElement | null) {
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = `${el.scrollHeight}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the active segment in view while the video plays (not while editing).
|
||||||
|
watch(activeSegmentIndex, async (i) => {
|
||||||
|
if (i < 0) return
|
||||||
|
if (document.activeElement?.tagName === 'TEXTAREA' || document.activeElement?.tagName === 'INPUT') return
|
||||||
|
await nextTick()
|
||||||
|
rowEls.value[i]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Panel title="Transcript">
|
||||||
|
<template #actions>
|
||||||
|
<span class="count">{{ state.segments.length }} segments</span>
|
||||||
|
</template>
|
||||||
|
<div ref="listEl" class="seg-list">
|
||||||
|
<p v-if="!state.segments.length" class="empty">
|
||||||
|
No transcript for this run.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="(seg, i) in state.segments"
|
||||||
|
:key="i"
|
||||||
|
:ref="(el) => setRow(i, el as Element | null)"
|
||||||
|
class="seg"
|
||||||
|
:class="{ active: i === activeSegmentIndex }"
|
||||||
|
>
|
||||||
|
<div class="seg-head">
|
||||||
|
<button class="ts" title="Seek here" @click="seek(seg.start)">{{ fmt(seg.start) }}</button>
|
||||||
|
<input v-model="seg.speaker" class="speaker" spellcheck="false" placeholder="SPEAKER" />
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="seg.text"
|
||||||
|
class="text"
|
||||||
|
rows="1"
|
||||||
|
spellcheck="false"
|
||||||
|
:ref="(el) => onAreaMounted(el as HTMLTextAreaElement | null)"
|
||||||
|
@input="autoResize"
|
||||||
|
@focus="seek(seg.start)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.seg-list {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
.seg {
|
||||||
|
padding: var(--space-2);
|
||||||
|
border-radius: var(--panel-radius);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
.seg.active {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-color: var(--status-processing);
|
||||||
|
}
|
||||||
|
.seg-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
.ts {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--status-processing);
|
||||||
|
background: var(--surface-0);
|
||||||
|
padding: 1px var(--space-2);
|
||||||
|
}
|
||||||
|
.speaker {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 1px var(--space-2);
|
||||||
|
width: 130px;
|
||||||
|
}
|
||||||
|
.text {
|
||||||
|
width: 100%;
|
||||||
|
resize: none;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.45;
|
||||||
|
padding: var(--space-2);
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
.count {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
7
ui/app/src/env.d.ts
vendored
Normal file
7
ui/app/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
6
ui/app/src/main.ts
Normal file
6
ui/app/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import '@framework/tokens.css'
|
||||||
|
import './styles.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
BIN
ui/app/src/store.ts
Normal file
BIN
ui/app/src/store.ts
Normal file
Binary file not shown.
61
ui/app/src/styles.css
Normal file
61
ui/app/src/styles.css
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--surface-0);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: var(--panel-border);
|
||||||
|
border-radius: var(--panel-radius);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background: var(--surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--surface-0);
|
||||||
|
border: var(--panel-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--surface-3);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
22
ui/app/tsconfig.json
Normal file
22
ui/app/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client", "node"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@framework/*": ["../framework/src/*"],
|
||||||
|
"@/*": ["src/*"],
|
||||||
|
"vue": ["node_modules/vue"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue", "server/**/*.ts", "vite.config.ts"]
|
||||||
|
}
|
||||||
28
ui/app/vite.config.ts
Normal file
28
ui/app/vite.config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { meetusApi } from './server/meetusApi'
|
||||||
|
|
||||||
|
// Output directory produced by the meetus pipeline. Override with MEETUS_OUTPUT.
|
||||||
|
const outputDir = process.env.MEETUS_OUTPUT
|
||||||
|
?? fileURLToPath(new URL('../../output', import.meta.url))
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), meetusApi({ outputDir })],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@framework': fileURLToPath(new URL('../framework/src', import.meta.url)),
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
fs: {
|
||||||
|
// Allow the dev server to read the framework source and the output tree.
|
||||||
|
allow: [
|
||||||
|
fileURLToPath(new URL('.', import.meta.url)),
|
||||||
|
fileURLToPath(new URL('../framework', import.meta.url)),
|
||||||
|
outputDir,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
25
ui/framework/package.json
Normal file
25
ui/framework/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "soleprint-ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vue-flow/core": "^1.48.2",
|
||||||
|
"pinia": "^2.2",
|
||||||
|
"uplot": "^1.6",
|
||||||
|
"vue": "^3.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5",
|
||||||
|
"typescript": "^5.6",
|
||||||
|
"vite": "^6",
|
||||||
|
"vitest": "^2",
|
||||||
|
"vue-tsc": "^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
1692
ui/framework/pnpm-lock.yaml
generated
Normal file
1692
ui/framework/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
ui/framework/src/components/LayoutGrid.vue
Normal file
32
ui/framework/src/components/LayoutGrid.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
columns?: number
|
||||||
|
rows?: number
|
||||||
|
gap?: string
|
||||||
|
}>(), {
|
||||||
|
columns: 2,
|
||||||
|
rows: 2,
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="layout-grid"
|
||||||
|
:style="{
|
||||||
|
gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
|
||||||
|
gridTemplateRows: `repeat(${props.rows}, 1fr)`,
|
||||||
|
gap: props.gap,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout-grid {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
87
ui/framework/src/components/Panel.vue
Normal file
87
ui/framework/src/components/Panel.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
status?: 'idle' | 'live' | 'processing' | 'error'
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">{{ title }}</span>
|
||||||
|
<span class="panel-actions"><slot name="actions" /></span>
|
||||||
|
<span class="panel-status" :class="status ?? 'idle'" />
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div class="panel-overlay">
|
||||||
|
<slot name="overlay" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel {
|
||||||
|
position: relative;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: var(--panel-border);
|
||||||
|
border-radius: var(--panel-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
height: var(--panel-header-height);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-bottom: var(--panel-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-status {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.panel-status.idle { background: var(--status-idle); }
|
||||||
|
.panel-status.live { background: var(--status-live); }
|
||||||
|
.panel-status.processing { background: var(--status-processing); }
|
||||||
|
.panel-status.error { background: var(--status-error); }
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: var(--space-2);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: var(--panel-header-height) 0 0 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.panel-overlay > :deep(*) {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
145
ui/framework/src/components/ParameterEditor.vue
Normal file
145
ui/framework/src/components/ParameterEditor.vue
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
export interface ConfigField {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
default: unknown
|
||||||
|
description: string
|
||||||
|
min: number | null
|
||||||
|
max: number | null
|
||||||
|
options: string[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
fields: ConfigField[]
|
||||||
|
values: Record<string, unknown>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update': [name: string, value: unknown]
|
||||||
|
'reset': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const numericFields = computed(() => props.fields.filter(f => f.type === 'int' || f.type === 'float'))
|
||||||
|
const boolFields = computed(() => props.fields.filter(f => f.type === 'bool'))
|
||||||
|
|
||||||
|
function onInput(name: string, value: unknown) {
|
||||||
|
emit('update', name, value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="param-editor">
|
||||||
|
<!-- Boolean fields -->
|
||||||
|
<label v-for="f in boolFields" :key="f.name" class="param-field bool-field">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="!!values[f.name]"
|
||||||
|
@change="(e) => onInput(f.name, (e.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
<span class="field-label" :title="f.description">{{ f.name.replace(/_/g, ' ') }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Numeric fields (range sliders) -->
|
||||||
|
<div v-for="f in numericFields" :key="f.name" class="param-field">
|
||||||
|
<div class="field-header">
|
||||||
|
<span class="field-label" :title="f.description">{{ f.name.replace(/^edge_/, '').replace(/_/g, ' ') }}</span>
|
||||||
|
<span class="field-value">{{ values[f.name] }}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:min="f.min ?? 0"
|
||||||
|
:max="f.max ?? 500"
|
||||||
|
:step="f.type === 'float' ? 0.01 : 1"
|
||||||
|
:value="values[f.name] as number"
|
||||||
|
@input="(e) => onInput(f.name, Number((e.target as HTMLInputElement).value))"
|
||||||
|
/>
|
||||||
|
<div class="field-range">
|
||||||
|
<span>{{ f.min ?? 0 }}</span>
|
||||||
|
<span>{{ f.max ?? 500 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.param-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bool-field {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-width: 30px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-range {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
accent-color: #00bcd4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
70
ui/framework/src/components/ResizeHandle.vue
Normal file
70
ui/framework/src/components/ResizeHandle.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
direction: 'horizontal' | 'vertical'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
resize: [delta: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dragging = ref(false)
|
||||||
|
let startPos = 0
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
dragging.value = true
|
||||||
|
startPos = props.direction === 'horizontal' ? e.clientX : e.clientY
|
||||||
|
const el = e.target as HTMLElement
|
||||||
|
el.setPointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!dragging.value) return
|
||||||
|
const currentPos = props.direction === 'horizontal' ? e.clientX : e.clientY
|
||||||
|
const delta = currentPos - startPos
|
||||||
|
startPos = currentPos
|
||||||
|
emit('resize', delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
dragging.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="resize-handle"
|
||||||
|
:class="[direction, { dragging }]"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
@pointermove="onPointerMove"
|
||||||
|
@pointerup="onPointerUp"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.resize-handle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle:hover,
|
||||||
|
.resize-handle.dragging {
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.horizontal {
|
||||||
|
width: 4px;
|
||||||
|
cursor: col-resize;
|
||||||
|
margin: 0 -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.vertical {
|
||||||
|
height: 4px;
|
||||||
|
cursor: row-resize;
|
||||||
|
margin: -2px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
157
ui/framework/src/components/SplitPane.vue
Normal file
157
ui/framework/src/components/SplitPane.vue
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
/** Split direction */
|
||||||
|
direction?: 'horizontal' | 'vertical'
|
||||||
|
/** Initial size of the sized pane (px or flex ratio) */
|
||||||
|
initialSize?: number
|
||||||
|
/** Size mode: 'px' = sized pane fixed in pixels, 'ratio' = flex ratio */
|
||||||
|
sizeMode?: 'px' | 'ratio'
|
||||||
|
/** Which pane is sized: 'first' or 'second'. Default: 'first'. */
|
||||||
|
anchor?: 'first' | 'second'
|
||||||
|
/** Min size (px in px-mode, ratio in ratio-mode) */
|
||||||
|
min?: number
|
||||||
|
/** Max size (px in px-mode, ratio in ratio-mode) */
|
||||||
|
max?: number
|
||||||
|
/** Whether the divider is draggable */
|
||||||
|
resizable?: boolean
|
||||||
|
}>(), {
|
||||||
|
direction: 'horizontal',
|
||||||
|
initialSize: 1,
|
||||||
|
sizeMode: 'ratio',
|
||||||
|
anchor: 'first',
|
||||||
|
min: 0.1,
|
||||||
|
max: 10,
|
||||||
|
resizable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const size = ref(props.initialSize)
|
||||||
|
const dragging = ref(false)
|
||||||
|
let startPos = 0
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (!props.resizable) return
|
||||||
|
dragging.value = true
|
||||||
|
startPos = props.direction === 'horizontal' ? e.clientX : e.clientY
|
||||||
|
const el = e.target as HTMLElement
|
||||||
|
el.setPointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!dragging.value) return
|
||||||
|
const currentPos = props.direction === 'horizontal' ? e.clientX : e.clientY
|
||||||
|
let delta = currentPos - startPos
|
||||||
|
startPos = currentPos
|
||||||
|
|
||||||
|
// Dragging right/down grows first pane, shrinks second.
|
||||||
|
// If anchor is 'second', invert so dragging grows the second pane.
|
||||||
|
if (props.anchor === 'second') delta = -delta
|
||||||
|
|
||||||
|
if (props.sizeMode === 'px') {
|
||||||
|
size.value = Math.max(props.min, Math.min(props.max, size.value + delta))
|
||||||
|
} else {
|
||||||
|
const scale = props.direction === 'horizontal' ? 0.01 : 0.02
|
||||||
|
size.value = Math.max(props.min, Math.min(props.max, size.value + delta * scale))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
dragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHorizontal = computed(() => props.direction === 'horizontal')
|
||||||
|
|
||||||
|
const sizedStyle = computed(() => {
|
||||||
|
if (props.sizeMode === 'px') {
|
||||||
|
const sizeStr = size.value + 'px'
|
||||||
|
const minStr = props.min + 'px'
|
||||||
|
return isHorizontal.value
|
||||||
|
? { width: sizeStr, minWidth: minStr, flexShrink: '0' }
|
||||||
|
: { height: sizeStr, minHeight: minStr, flexShrink: '0' }
|
||||||
|
}
|
||||||
|
return { flex: String(size.value) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const flexStyle = computed(() => ({ flex: '1' }))
|
||||||
|
|
||||||
|
const firstStyle = computed(() => props.anchor === 'first' ? sizedStyle.value : flexStyle.value)
|
||||||
|
const secondStyle = computed(() => props.anchor === 'second' ? sizedStyle.value : flexStyle.value)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="split-pane" :class="[direction]">
|
||||||
|
<div class="split-first" :style="firstStyle">
|
||||||
|
<slot name="first" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="resizable"
|
||||||
|
class="split-divider"
|
||||||
|
:class="[direction, { dragging }]"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
@pointermove="onPointerMove"
|
||||||
|
@pointerup="onPointerUp"
|
||||||
|
/>
|
||||||
|
<div class="split-second" :style="secondStyle">
|
||||||
|
<slot name="second" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.split-pane {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane.horizontal {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane.vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-first,
|
||||||
|
.split-second {
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Children must fill their pane */
|
||||||
|
.split-first > :deep(*),
|
||||||
|
.split-second > :deep(*) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-divider {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-divider:hover,
|
||||||
|
.split-divider.dragging {
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-divider.horizontal {
|
||||||
|
width: 4px;
|
||||||
|
cursor: col-resize;
|
||||||
|
margin: 0 -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-divider.vertical {
|
||||||
|
height: 4px;
|
||||||
|
cursor: row-resize;
|
||||||
|
margin: -2px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
143
ui/framework/src/components/VideoPlayer.vue
Normal file
143
ui/framework/src/components/VideoPlayer.vue
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onBeforeUnmount } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agnostic seekable media player.
|
||||||
|
*
|
||||||
|
* Two-way bindable playback position and play state:
|
||||||
|
* <VideoPlayer :src="url" v-model:currentTime="t" v-model:playing="p" />
|
||||||
|
*
|
||||||
|
* External `currentTime` changes seek the element (guarded against the
|
||||||
|
* feedback loop with the element's own `timeupdate` events).
|
||||||
|
*/
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
/** Media source URL */
|
||||||
|
src: string
|
||||||
|
/** Playback position in seconds (v-model:currentTime) */
|
||||||
|
currentTime?: number
|
||||||
|
/** Whether the media is playing (v-model:playing) */
|
||||||
|
playing?: boolean
|
||||||
|
}>(), {
|
||||||
|
currentTime: 0,
|
||||||
|
playing: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:currentTime': [value: number]
|
||||||
|
'update:playing': [value: boolean]
|
||||||
|
/** Media duration once metadata has loaded */
|
||||||
|
duration: [value: number]
|
||||||
|
/** Playback reached the end */
|
||||||
|
ended: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const video = ref<HTMLVideoElement | null>(null)
|
||||||
|
|
||||||
|
/** True while we are applying an external seek, so the resulting
|
||||||
|
* `timeupdate` does not echo back out as an update. */
|
||||||
|
let seekingFromProp = false
|
||||||
|
/** Tolerance (s) below which we treat positions as already in sync. */
|
||||||
|
const EPSILON = 0.25
|
||||||
|
|
||||||
|
watch(() => props.currentTime, (t) => {
|
||||||
|
const el = video.value
|
||||||
|
if (!el) return
|
||||||
|
if (Math.abs(el.currentTime - t) < EPSILON) return
|
||||||
|
seekingFromProp = true
|
||||||
|
el.currentTime = t
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.playing, (shouldPlay) => {
|
||||||
|
const el = video.value
|
||||||
|
if (!el) return
|
||||||
|
if (shouldPlay && el.paused) {
|
||||||
|
void el.play().catch(() => { /* autoplay may be blocked; ignore */ })
|
||||||
|
} else if (!shouldPlay && !el.paused) {
|
||||||
|
el.pause()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onTimeUpdate() {
|
||||||
|
const el = video.value
|
||||||
|
if (!el) return
|
||||||
|
if (seekingFromProp) {
|
||||||
|
seekingFromProp = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:currentTime', el.currentTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSeeked() {
|
||||||
|
// Clear the guard once the browser settles the requested seek.
|
||||||
|
seekingFromProp = false
|
||||||
|
const el = video.value
|
||||||
|
if (el) emit('update:currentTime', el.currentTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLoadedMetadata() {
|
||||||
|
const el = video.value
|
||||||
|
if (!el) return
|
||||||
|
emit('duration', el.duration)
|
||||||
|
// Honour an initial position requested before metadata was ready.
|
||||||
|
if (props.currentTime && Math.abs(el.currentTime - props.currentTime) >= EPSILON) {
|
||||||
|
seekingFromProp = true
|
||||||
|
el.currentTime = props.currentTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPlay() { emit('update:playing', true) }
|
||||||
|
function onPause() { emit('update:playing', false) }
|
||||||
|
function onEnded() {
|
||||||
|
emit('update:playing', false)
|
||||||
|
emit('ended')
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
// Release the media resource promptly.
|
||||||
|
const el = video.value
|
||||||
|
if (el) {
|
||||||
|
el.pause()
|
||||||
|
el.removeAttribute('src')
|
||||||
|
el.load()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="video-player">
|
||||||
|
<video
|
||||||
|
ref="video"
|
||||||
|
class="video-el"
|
||||||
|
:src="src"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
@timeupdate="onTimeUpdate"
|
||||||
|
@seeked="onSeeked"
|
||||||
|
@loadedmetadata="onLoadedMetadata"
|
||||||
|
@play="onPlay"
|
||||||
|
@pause="onPause"
|
||||||
|
@ended="onEnded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.video-player {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--surface-0);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-el {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
ui/framework/src/composables/useDataSource.ts
Normal file
23
ui/framework/src/composables/useDataSource.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { onMounted, onUnmounted, type Ref } from 'vue'
|
||||||
|
import { DataSource, type DataSourceStatus } from '../datasources/DataSource'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable that connects a component to a DataSource.
|
||||||
|
*
|
||||||
|
* Connects on mount, disconnects on unmount.
|
||||||
|
* Returns reactive refs for data, status, and error.
|
||||||
|
*/
|
||||||
|
export function useDataSource<T = unknown>(source: DataSource<T>): {
|
||||||
|
data: Ref<T | null>
|
||||||
|
status: Ref<DataSourceStatus>
|
||||||
|
error: Ref<string | null>
|
||||||
|
} {
|
||||||
|
onMounted(() => source.connect())
|
||||||
|
onUnmounted(() => source.disconnect())
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: source.data as Ref<T | null>,
|
||||||
|
status: source.status,
|
||||||
|
error: source.error as Ref<string | null>,
|
||||||
|
}
|
||||||
|
}
|
||||||
57
ui/framework/src/composables/useEditorExecution.ts
Normal file
57
ui/framework/src/composables/useEditorExecution.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export interface EditorExecutionOptions {
|
||||||
|
/** Debounce delay in ms for auto-apply. Default: 150 */
|
||||||
|
debounceMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic editor execution pattern — debounced apply with auto-apply toggle,
|
||||||
|
* loading/error/timing state tracking.
|
||||||
|
*
|
||||||
|
* The caller provides the actual execution function. This composable handles
|
||||||
|
* the orchestration: debounce, auto-apply, loading state, timing.
|
||||||
|
*/
|
||||||
|
export function useEditorExecution(
|
||||||
|
executeFn: () => Promise<void>,
|
||||||
|
options: EditorExecutionOptions = {},
|
||||||
|
) {
|
||||||
|
const debounceMs = options.debounceMs ?? 150
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const autoApply = ref(true)
|
||||||
|
const execTimeMs = ref<number | null>(null)
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
execTimeMs.value = null
|
||||||
|
const t0 = performance.now()
|
||||||
|
try {
|
||||||
|
await executeFn()
|
||||||
|
execTimeMs.value = Math.round(performance.now() - t0)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onParameterChange() {
|
||||||
|
if (!autoApply.value) return
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => apply(), debounceMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
autoApply,
|
||||||
|
execTimeMs,
|
||||||
|
apply,
|
||||||
|
onParameterChange,
|
||||||
|
}
|
||||||
|
}
|
||||||
77
ui/framework/src/composables/useRegistry.ts
Normal file
77
ui/framework/src/composables/useRegistry.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { ref, type Ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic registry composable — fetches typed data from a URL, caches it,
|
||||||
|
* exposes it reactively.
|
||||||
|
*
|
||||||
|
* Use for any data that is loaded once at app init and rarely changes:
|
||||||
|
* stage definitions, config schemas, available models, etc.
|
||||||
|
*
|
||||||
|
* The registry is shared across all consumers (singleton per URL).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const cache = new Map<string, { data: Ref<any>; loading: Ref<boolean>; error: Ref<string | null>; promise: Promise<void> | null }>()
|
||||||
|
|
||||||
|
export function useRegistry<T>(url: string): {
|
||||||
|
data: Ref<T[]>
|
||||||
|
loading: Ref<boolean>
|
||||||
|
error: Ref<string | null>
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
} {
|
||||||
|
if (!cache.has(url)) {
|
||||||
|
const data = ref<T[]>([]) as Ref<T[]>
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const entry = { data, loading, error, promise: null as Promise<void> | null }
|
||||||
|
cache.set(url, entry)
|
||||||
|
|
||||||
|
async function doFetch() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url)
|
||||||
|
if (!resp.ok) {
|
||||||
|
error.value = `Failed to fetch registry: ${resp.status}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.value = await resp.json()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.promise = doFetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = cache.get(url)!
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const data = entry.data
|
||||||
|
const loading = entry.loading
|
||||||
|
const error = entry.error
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url)
|
||||||
|
if (!resp.ok) {
|
||||||
|
error.value = `Failed to fetch registry: ${resp.status}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.value = await resp.json()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: entry.data as Ref<T[]>,
|
||||||
|
loading: entry.loading,
|
||||||
|
error: entry.error,
|
||||||
|
refresh,
|
||||||
|
}
|
||||||
|
}
|
||||||
40
ui/framework/src/datasources/DataSource.ts
Normal file
40
ui/framework/src/datasources/DataSource.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { type Ref, ref } from 'vue'
|
||||||
|
|
||||||
|
export type DataSourceStatus = 'idle' | 'connecting' | 'live' | 'error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for all data sources.
|
||||||
|
*
|
||||||
|
* A DataSource connects to some event stream, exposes reactive state,
|
||||||
|
* and lets consumers subscribe to typed events. Panels read from these
|
||||||
|
* reactively — they never touch the transport layer directly.
|
||||||
|
*/
|
||||||
|
export abstract class DataSource<T = unknown> {
|
||||||
|
readonly id: string
|
||||||
|
readonly data: Ref<T | null> = ref(null) as Ref<T | null>
|
||||||
|
readonly status: Ref<DataSourceStatus> = ref('idle')
|
||||||
|
readonly error: Ref<string | null> = ref(null) as Ref<string | null>
|
||||||
|
|
||||||
|
private listeners = new Map<string, Set<(payload: any) => void>>()
|
||||||
|
|
||||||
|
constructor(id: string) {
|
||||||
|
this.id = id
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract connect(): void
|
||||||
|
abstract disconnect(): void
|
||||||
|
|
||||||
|
/** Subscribe to a specific event type */
|
||||||
|
on<P = unknown>(eventType: string, handler: (payload: P) => void): () => void {
|
||||||
|
if (!this.listeners.has(eventType)) {
|
||||||
|
this.listeners.set(eventType, new Set())
|
||||||
|
}
|
||||||
|
this.listeners.get(eventType)!.add(handler)
|
||||||
|
return () => this.listeners.get(eventType)?.delete(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit an event to subscribers (called by subclasses) */
|
||||||
|
protected emit(eventType: string, payload: unknown): void {
|
||||||
|
this.listeners.get(eventType)?.forEach((fn) => fn(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
93
ui/framework/src/datasources/SSEDataSource.ts
Normal file
93
ui/framework/src/datasources/SSEDataSource.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { DataSource } from './DataSource'
|
||||||
|
|
||||||
|
export interface SSEDataSourceOptions {
|
||||||
|
/** Unique identifier for this source */
|
||||||
|
id: string
|
||||||
|
/** SSE endpoint URL (e.g. '/api/detect/stream/job-123') */
|
||||||
|
url: string
|
||||||
|
/** Event types to listen for. Each is dispatched to subscribers via on(). */
|
||||||
|
eventTypes: string[]
|
||||||
|
/** Max reconnection attempts before giving up. Default: 10 */
|
||||||
|
maxRetries?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataSource backed by native EventSource (Server-Sent Events).
|
||||||
|
*
|
||||||
|
* Connects to a single SSE endpoint and demultiplexes events by type.
|
||||||
|
* Multiple panels can subscribe to different event types from the same source.
|
||||||
|
*/
|
||||||
|
export class SSEDataSource extends DataSource {
|
||||||
|
private es: EventSource | null = null
|
||||||
|
private url: string
|
||||||
|
private eventTypes: string[]
|
||||||
|
private maxRetries: number
|
||||||
|
private retryCount = 0
|
||||||
|
|
||||||
|
constructor(opts: SSEDataSourceOptions) {
|
||||||
|
super(opts.id)
|
||||||
|
this.url = opts.url
|
||||||
|
this.eventTypes = opts.eventTypes
|
||||||
|
this.maxRetries = opts.maxRetries ?? 10
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
if (this.es) return
|
||||||
|
this.status.value = 'connecting'
|
||||||
|
this.error.value = null
|
||||||
|
|
||||||
|
this.es = new EventSource(this.url)
|
||||||
|
|
||||||
|
this.es.onopen = () => {
|
||||||
|
this.status.value = 'live'
|
||||||
|
this.retryCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
this.es.onerror = () => {
|
||||||
|
if (this.es?.readyState === EventSource.CLOSED) {
|
||||||
|
this.retryCount++
|
||||||
|
if (this.retryCount >= this.maxRetries) {
|
||||||
|
this.status.value = 'error'
|
||||||
|
this.error.value = `Connection lost after ${this.maxRetries} retries`
|
||||||
|
this.disconnect()
|
||||||
|
} else {
|
||||||
|
this.status.value = 'connecting'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a listener for each event type
|
||||||
|
for (const eventType of this.eventTypes) {
|
||||||
|
this.es.addEventListener(eventType, (e: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(e.data)
|
||||||
|
this.data.value = parsed
|
||||||
|
this.emit(eventType, parsed)
|
||||||
|
} catch {
|
||||||
|
// ignore malformed events
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal event — pipeline finished (success, failure, or cancel)
|
||||||
|
this.es.addEventListener('done', () => {
|
||||||
|
this.status.value = 'idle'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
if (this.es) {
|
||||||
|
this.es.close()
|
||||||
|
this.es = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the URL (e.g. when job ID changes) and reconnect */
|
||||||
|
setUrl(url: string): void {
|
||||||
|
this.url = url
|
||||||
|
if (this.status.value === 'live' || this.status.value === 'connecting') {
|
||||||
|
this.disconnect()
|
||||||
|
this.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
ui/framework/src/datasources/StaticDataSource.ts
Normal file
45
ui/framework/src/datasources/StaticDataSource.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { DataSource } from './DataSource'
|
||||||
|
|
||||||
|
export interface StaticEvent {
|
||||||
|
type: string
|
||||||
|
data: unknown
|
||||||
|
/** Delay in ms before emitting this event (relative to previous). Default: 0 */
|
||||||
|
delay?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataSource that replays a fixture array of events.
|
||||||
|
*
|
||||||
|
* Used for development and testing without a running backend.
|
||||||
|
* Events are emitted in sequence with optional delays.
|
||||||
|
*/
|
||||||
|
export class StaticDataSource extends DataSource {
|
||||||
|
private events: StaticEvent[]
|
||||||
|
private timeouts: ReturnType<typeof setTimeout>[] = []
|
||||||
|
|
||||||
|
constructor(id: string, events: StaticEvent[]) {
|
||||||
|
super(id)
|
||||||
|
this.events = events
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
this.status.value = 'live'
|
||||||
|
this.error.value = null
|
||||||
|
|
||||||
|
let cumDelay = 0
|
||||||
|
for (const event of this.events) {
|
||||||
|
cumDelay += event.delay ?? 0
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.data.value = event.data
|
||||||
|
this.emit(event.type, event.data)
|
||||||
|
}, cumDelay)
|
||||||
|
this.timeouts.push(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
for (const t of this.timeouts) clearTimeout(t)
|
||||||
|
this.timeouts = []
|
||||||
|
this.status.value = 'idle'
|
||||||
|
}
|
||||||
|
}
|
||||||
103
ui/framework/src/datasources/__tests__/StaticDataSource.test.ts
Normal file
103
ui/framework/src/datasources/__tests__/StaticDataSource.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { StaticDataSource } from '../StaticDataSource'
|
||||||
|
|
||||||
|
describe('StaticDataSource', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits events in order', async () => {
|
||||||
|
const source = new StaticDataSource('test', [
|
||||||
|
{ type: 'log', data: { msg: 'first' } },
|
||||||
|
{ type: 'log', data: { msg: 'second' } },
|
||||||
|
{ type: 'stats', data: { count: 42 } },
|
||||||
|
])
|
||||||
|
|
||||||
|
const received: { type: string; data: unknown }[] = []
|
||||||
|
source.on('log', (d) => received.push({ type: 'log', data: d }))
|
||||||
|
source.on('stats', (d) => received.push({ type: 'stats', data: d }))
|
||||||
|
|
||||||
|
source.connect()
|
||||||
|
|
||||||
|
// Events with delay=0 fire on next microtask via setTimeout(0)
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
|
||||||
|
expect(source.status.value).toBe('live')
|
||||||
|
expect(received).toHaveLength(3)
|
||||||
|
expect(received[0]).toEqual({ type: 'log', data: { msg: 'first' } })
|
||||||
|
expect(received[1]).toEqual({ type: 'log', data: { msg: 'second' } })
|
||||||
|
expect(received[2]).toEqual({ type: 'stats', data: { count: 42 } })
|
||||||
|
|
||||||
|
source.disconnect()
|
||||||
|
expect(source.status.value).toBe('idle')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects delays between events', async () => {
|
||||||
|
const source = new StaticDataSource('test-delay', [
|
||||||
|
{ type: 'a', data: 1 },
|
||||||
|
{ type: 'b', data: 2, delay: 50 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const received: unknown[] = []
|
||||||
|
source.on('a', (d) => received.push(d))
|
||||||
|
source.on('b', (d) => received.push(d))
|
||||||
|
|
||||||
|
source.connect()
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
expect(received).toHaveLength(1) // only 'a' so far
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 60))
|
||||||
|
expect(received).toHaveLength(2) // 'b' arrived after delay
|
||||||
|
|
||||||
|
source.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates data ref with latest event payload', async () => {
|
||||||
|
const source = new StaticDataSource('test-data', [
|
||||||
|
{ type: 'x', data: { v: 1 } },
|
||||||
|
{ type: 'x', data: { v: 2 } },
|
||||||
|
])
|
||||||
|
|
||||||
|
source.connect()
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
|
||||||
|
expect(source.data.value).toEqual({ v: 2 })
|
||||||
|
|
||||||
|
source.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cleans up on disconnect', async () => {
|
||||||
|
const source = new StaticDataSource('test-cleanup', [
|
||||||
|
{ type: 'a', data: 1 },
|
||||||
|
{ type: 'b', data: 2, delay: 100 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const received: unknown[] = []
|
||||||
|
source.on('b', (d) => received.push(d))
|
||||||
|
|
||||||
|
source.connect()
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
source.disconnect()
|
||||||
|
|
||||||
|
// 'b' should never fire since we disconnected before its delay
|
||||||
|
await new Promise((r) => setTimeout(r, 150))
|
||||||
|
expect(received).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unsubscribe removes listener', async () => {
|
||||||
|
const source = new StaticDataSource('test-unsub', [
|
||||||
|
{ type: 'x', data: 1 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const received: unknown[] = []
|
||||||
|
const unsub = source.on('x', (d) => received.push(d))
|
||||||
|
unsub()
|
||||||
|
|
||||||
|
source.connect()
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
|
||||||
|
expect(received).toHaveLength(0)
|
||||||
|
source.disconnect()
|
||||||
|
})
|
||||||
|
})
|
||||||
39
ui/framework/src/index.ts
Normal file
39
ui/framework/src/index.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Framework public API
|
||||||
|
export { DataSource, type DataSourceStatus } from './datasources/DataSource'
|
||||||
|
export { SSEDataSource } from './datasources/SSEDataSource'
|
||||||
|
export { StaticDataSource } from './datasources/StaticDataSource'
|
||||||
|
export { useDataSource } from './composables/useDataSource'
|
||||||
|
export { useRegistry } from './composables/useRegistry'
|
||||||
|
export { useEditorExecution } from './composables/useEditorExecution'
|
||||||
|
export type { EditorExecutionOptions } from './composables/useEditorExecution'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export { default as Panel } from './components/Panel.vue'
|
||||||
|
export { default as LayoutGrid } from './components/LayoutGrid.vue'
|
||||||
|
export { default as ResizeHandle } from './components/ResizeHandle.vue'
|
||||||
|
export { default as SplitPane } from './components/SplitPane.vue'
|
||||||
|
export { default as ParameterEditor } from './components/ParameterEditor.vue'
|
||||||
|
export type { ConfigField } from './components/ParameterEditor.vue'
|
||||||
|
export { default as VideoPlayer } from './components/VideoPlayer.vue'
|
||||||
|
|
||||||
|
// Renderers
|
||||||
|
export { default as LogRenderer } from './renderers/LogRenderer.vue'
|
||||||
|
export { default as TimeSeriesRenderer } from './renderers/TimeSeriesRenderer.vue'
|
||||||
|
export { default as GraphRenderer } from './renderers/GraphRenderer.vue'
|
||||||
|
export { default as FrameRenderer } from './renderers/FrameRenderer.vue'
|
||||||
|
export { default as TableRenderer } from './renderers/TableRenderer.vue'
|
||||||
|
|
||||||
|
// Renderer types
|
||||||
|
export type { FrameBBox, FrameOverlay } from './renderers/FrameRenderer.vue'
|
||||||
|
export type { LogEntry } from './renderers/LogRenderer.vue'
|
||||||
|
export type { GraphNode, GraphMode } from './renderers/GraphRenderer.vue'
|
||||||
|
export type { TableColumn } from './renderers/TableRenderer.vue'
|
||||||
|
export type { TimeSeriesSeries } from './renderers/TimeSeriesRenderer.vue'
|
||||||
|
|
||||||
|
// Interaction plugins
|
||||||
|
export type { InteractionPlugin, PluginContext } from './plugins/InteractionPlugin'
|
||||||
|
export { BBoxDrawPlugin } from './plugins/BBoxDrawPlugin'
|
||||||
|
export type { BBoxResult, BBoxCallback } from './plugins/BBoxDrawPlugin'
|
||||||
|
export { CrosshairPlugin } from './plugins/CrosshairPlugin'
|
||||||
|
export type { CrosshairCallback } from './plugins/CrosshairPlugin'
|
||||||
|
|
||||||
88
ui/framework/src/plugins/BBoxDrawPlugin.ts
Normal file
88
ui/framework/src/plugins/BBoxDrawPlugin.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* BBoxDrawPlugin — draw bounding boxes on the frame viewer.
|
||||||
|
*
|
||||||
|
* User drags on the canvas to draw a rectangle.
|
||||||
|
* On pointer up, emits the bbox coordinates via the callback.
|
||||||
|
* The frame viewer panel feeds this into the selection store.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { InteractionPlugin, PluginContext } from './InteractionPlugin'
|
||||||
|
|
||||||
|
export interface BBoxResult {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BBoxCallback = (bbox: BBoxResult) => void
|
||||||
|
|
||||||
|
export class BBoxDrawPlugin implements InteractionPlugin {
|
||||||
|
name = 'bbox-draw'
|
||||||
|
|
||||||
|
private ctx: CanvasRenderingContext2D | null = null
|
||||||
|
private drawing = false
|
||||||
|
private startX = 0
|
||||||
|
private startY = 0
|
||||||
|
private currentBox: BBoxResult | null = null
|
||||||
|
private callback: BBoxCallback
|
||||||
|
|
||||||
|
constructor(callback: BBoxCallback) {
|
||||||
|
this.callback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(context: PluginContext): void {
|
||||||
|
this.ctx = context.ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmount(): void {
|
||||||
|
this.ctx = null
|
||||||
|
this.drawing = false
|
||||||
|
this.currentBox = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerDown(e: PointerEvent): void {
|
||||||
|
this.drawing = true
|
||||||
|
this.startX = e.offsetX
|
||||||
|
this.startY = e.offsetY
|
||||||
|
this.currentBox = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerMove(e: PointerEvent): void {
|
||||||
|
if (!this.drawing) return
|
||||||
|
|
||||||
|
const x = Math.min(this.startX, e.offsetX)
|
||||||
|
const y = Math.min(this.startY, e.offsetY)
|
||||||
|
const w = Math.abs(e.offsetX - this.startX)
|
||||||
|
const h = Math.abs(e.offsetY - this.startY)
|
||||||
|
|
||||||
|
this.currentBox = { x, y, w, h }
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerUp(_e: PointerEvent): void {
|
||||||
|
if (!this.drawing) return
|
||||||
|
this.drawing = false
|
||||||
|
|
||||||
|
if (this.currentBox && this.currentBox.w > 5 && this.currentBox.h > 5) {
|
||||||
|
this.callback(this.currentBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentBox = null
|
||||||
|
}
|
||||||
|
|
||||||
|
render(ctx: CanvasRenderingContext2D): void {
|
||||||
|
if (!this.currentBox) return
|
||||||
|
|
||||||
|
const box = this.currentBox
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#4f9cf9'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.setLineDash([6, 3])
|
||||||
|
ctx.strokeRect(box.x, box.y, box.w, box.h)
|
||||||
|
ctx.setLineDash([])
|
||||||
|
|
||||||
|
// Semi-transparent fill
|
||||||
|
ctx.fillStyle = 'rgba(79, 156, 249, 0.1)'
|
||||||
|
ctx.fillRect(box.x, box.y, box.w, box.h)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
ui/framework/src/plugins/CrosshairPlugin.ts
Normal file
60
ui/framework/src/plugins/CrosshairPlugin.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* CrosshairPlugin — synchronized vertical crosshair across time-series panels.
|
||||||
|
*
|
||||||
|
* When the user hovers on any panel with this plugin, the crosshair
|
||||||
|
* position (as a timestamp) is written to the selection store.
|
||||||
|
* All panels with this plugin render a vertical line at that timestamp.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { InteractionPlugin, PluginContext } from './InteractionPlugin'
|
||||||
|
|
||||||
|
export type CrosshairCallback = (timestamp: number | null) => void
|
||||||
|
|
||||||
|
export class CrosshairPlugin implements InteractionPlugin {
|
||||||
|
name = 'crosshair'
|
||||||
|
|
||||||
|
private width = 0
|
||||||
|
private callback: CrosshairCallback
|
||||||
|
|
||||||
|
/** Current crosshair X position (pixels), set externally from store */
|
||||||
|
public crosshairX: number | null = null
|
||||||
|
|
||||||
|
constructor(callback: CrosshairCallback) {
|
||||||
|
this.callback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(context: PluginContext): void {
|
||||||
|
this.width = context.width
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmount(): void {
|
||||||
|
this.crosshairX = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerMove(e: PointerEvent): void {
|
||||||
|
// Convert pixel X to normalized position (0-1)
|
||||||
|
const normalized = e.offsetX / this.width
|
||||||
|
this.callback(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerDown(_e: PointerEvent): void {
|
||||||
|
// no-op for crosshair
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerUp(_e: PointerEvent): void {
|
||||||
|
this.callback(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
render(ctx: CanvasRenderingContext2D): void {
|
||||||
|
if (this.crosshairX === null) return
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#a78bfa'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.setLineDash([4, 4])
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(this.crosshairX, 0)
|
||||||
|
ctx.lineTo(this.crosshairX, ctx.canvas.height)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.setLineDash([])
|
||||||
|
}
|
||||||
|
}
|
||||||
36
ui/framework/src/plugins/InteractionPlugin.ts
Normal file
36
ui/framework/src/plugins/InteractionPlugin.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Interaction plugin interface.
|
||||||
|
*
|
||||||
|
* Plugins attach to a Panel's overlay canvas. They receive pointer events
|
||||||
|
* and emit typed results via the callback. The panel handles rendering
|
||||||
|
* the overlay and routing events to the active plugin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PluginContext {
|
||||||
|
/** Canvas element for drawing overlays */
|
||||||
|
canvas: HTMLCanvasElement
|
||||||
|
/** 2D rendering context */
|
||||||
|
ctx: CanvasRenderingContext2D
|
||||||
|
/** Canvas dimensions (may differ from display size) */
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InteractionPlugin {
|
||||||
|
/** Unique plugin name */
|
||||||
|
name: string
|
||||||
|
|
||||||
|
/** Called when the plugin is mounted on a panel */
|
||||||
|
onMount(context: PluginContext): void
|
||||||
|
|
||||||
|
/** Called when the plugin is unmounted */
|
||||||
|
onUnmount(): void
|
||||||
|
|
||||||
|
/** Pointer event handlers (optional) */
|
||||||
|
onPointerDown?(e: PointerEvent): void
|
||||||
|
onPointerMove?(e: PointerEvent): void
|
||||||
|
onPointerUp?(e: PointerEvent): void
|
||||||
|
|
||||||
|
/** Called each animation frame to render the overlay */
|
||||||
|
render(ctx: CanvasRenderingContext2D): void
|
||||||
|
}
|
||||||
178
ui/framework/src/renderers/FrameRenderer.vue
Normal file
178
ui/framework/src/renderers/FrameRenderer.vue
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
|
||||||
|
export interface FrameBBox {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
confidence: number
|
||||||
|
label: string
|
||||||
|
resolved_brand?: string | null
|
||||||
|
source?: string | null
|
||||||
|
stage?: string | null
|
||||||
|
ocr_text?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrameOverlay {
|
||||||
|
/** Base64 encoded image (same dimensions as main image) */
|
||||||
|
src: string
|
||||||
|
label: string
|
||||||
|
visible: boolean
|
||||||
|
/** Opacity 0-1, default 0.5 */
|
||||||
|
opacity?: number
|
||||||
|
/** Image format — 'jpeg' (default) or 'png' (supports transparency) */
|
||||||
|
srcFormat?: 'jpeg' | 'png'
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Base64 JPEG image */
|
||||||
|
imageSrc: string
|
||||||
|
/** Bounding boxes to overlay */
|
||||||
|
boxes: FrameBBox[]
|
||||||
|
/** Debug overlay layers (edge images, line visualizations, etc.) */
|
||||||
|
overlays?: FrameOverlay[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const container = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const cvs = canvas.value
|
||||||
|
const ctr = container.value
|
||||||
|
if (!cvs || !ctr || !props.imageSrc) return
|
||||||
|
|
||||||
|
const ctx = cvs.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const img = new window.Image()
|
||||||
|
img.onload = () => {
|
||||||
|
cvs.width = ctr.clientWidth
|
||||||
|
cvs.height = ctr.clientHeight
|
||||||
|
|
||||||
|
const scale = Math.min(cvs.width / img.width, cvs.height / img.height)
|
||||||
|
const dx = (cvs.width - img.width * scale) / 2
|
||||||
|
const dy = (cvs.height - img.height * scale) / 2
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, cvs.width, cvs.height)
|
||||||
|
ctx.drawImage(img, dx, dy, img.width * scale, img.height * scale)
|
||||||
|
|
||||||
|
// Draw debug overlays (edge images, line visualizations)
|
||||||
|
drawOverlays(ctx, dx, dy, img.width * scale, img.height * scale)
|
||||||
|
|
||||||
|
// Draw bounding boxes on top
|
||||||
|
for (const box of props.boxes) {
|
||||||
|
const bx = dx + box.x * scale
|
||||||
|
const by = dy + box.y * scale
|
||||||
|
const bw = box.w * scale
|
||||||
|
const bh = box.h * scale
|
||||||
|
|
||||||
|
const color = sourceColor(box)
|
||||||
|
const resolved = box.resolved_brand || box.ocr_text
|
||||||
|
|
||||||
|
ctx.strokeStyle = color
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
if (!resolved) {
|
||||||
|
ctx.setLineDash([4, 3])
|
||||||
|
}
|
||||||
|
ctx.strokeRect(bx, by, bw, bh)
|
||||||
|
ctx.setLineDash([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.src = `data:image/jpeg;base64,${props.imageSrc}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pending overlay images that need async loading */
|
||||||
|
const overlayCache = new Map<string, HTMLImageElement>()
|
||||||
|
|
||||||
|
function drawOverlays(ctx: CanvasRenderingContext2D, dx: number, dy: number, dw: number, dh: number) {
|
||||||
|
const layers = props.overlays ?? []
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (!layer.visible || !layer.src) continue
|
||||||
|
|
||||||
|
const cached = overlayCache.get(layer.src)
|
||||||
|
if (cached && cached.complete) {
|
||||||
|
ctx.globalAlpha = layer.opacity ?? 0.5
|
||||||
|
ctx.drawImage(cached, dx, dy, dw, dh)
|
||||||
|
ctx.globalAlpha = 1.0
|
||||||
|
} else if (!cached) {
|
||||||
|
// Load async, redraw when ready
|
||||||
|
const overlay = new window.Image()
|
||||||
|
overlay.onload = () => draw()
|
||||||
|
overlay.src = `data:image/${layer.srcFormat ?? 'jpeg'};base64,${layer.src}`
|
||||||
|
overlayCache.set(layer.src, overlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_COLORS: Record<string, string> = {
|
||||||
|
yolo: '#f5a623', // yellow — raw detection
|
||||||
|
ocr: '#ff8c42', // orange — text extracted
|
||||||
|
ocr_matched: '#3ecf8e', // green — brand resolved
|
||||||
|
local_vlm: '#4f9cf9', // blue — VLM resolved
|
||||||
|
cloud_llm: '#a78bfa', // purple — cloud resolved
|
||||||
|
unresolved: '#e05252', // red — nothing matched
|
||||||
|
}
|
||||||
|
|
||||||
|
// CV region labels — distinct from source-based colors
|
||||||
|
const REGION_COLORS: Record<string, string> = {
|
||||||
|
edge_region: '#00bcd4', // cyan
|
||||||
|
contour_region: '#ffd54f', // yellow
|
||||||
|
color_region: '#e040fb', // magenta
|
||||||
|
candidate: '#4caf50', // green — passed readability
|
||||||
|
rejected: '#e05252', // red — failed readability
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceColor(box: FrameBBox): string {
|
||||||
|
if (REGION_COLORS[box.label]) return REGION_COLORS[box.label]
|
||||||
|
if (box.resolved_brand) return SOURCE_COLORS.ocr_matched
|
||||||
|
if (box.source && SOURCE_COLORS[box.source]) return SOURCE_COLORS[box.source]
|
||||||
|
return confidenceColor(box.confidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
function confidenceColor(conf: number): string {
|
||||||
|
if (conf >= 0.7) return 'var(--conf-high)'
|
||||||
|
if (conf >= 0.4) return 'var(--conf-mid)'
|
||||||
|
return 'var(--conf-low)'
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.imageSrc, props.boxes, props.overlays], () => nextTick(draw), { deep: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(draw)
|
||||||
|
const observer = new ResizeObserver(() => draw())
|
||||||
|
if (container.value) observer.observe(container.value)
|
||||||
|
onUnmounted(() => observer.disconnect())
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="container" class="frame-renderer">
|
||||||
|
<canvas ref="canvas" />
|
||||||
|
<div v-if="!imageSrc" class="frame-empty">No frame</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.frame-renderer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-renderer canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-empty {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
317
ui/framework/src/renderers/GraphRenderer.vue
Normal file
317
ui/framework/src/renderers/GraphRenderer.vue
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { VueFlow } from '@vue-flow/core'
|
||||||
|
import '@vue-flow/core/dist/style.css'
|
||||||
|
import '@vue-flow/core/dist/theme-default.css'
|
||||||
|
|
||||||
|
export interface GraphNode {
|
||||||
|
id: string
|
||||||
|
status: 'pending' | 'running' | 'done' | 'error' | 'skipped' | 'placeholder'
|
||||||
|
/** Whether a checkpoint exists at this stage */
|
||||||
|
hasCheckpoint?: boolean
|
||||||
|
/** Stage category (e.g. 'cv', 'ai', 'preprocessing') */
|
||||||
|
category?: string
|
||||||
|
/** Which editors are available for this stage */
|
||||||
|
availableEditors?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GraphMode = 'observe' | 'edit-in-pipeline' | 'edit-isolated'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
nodes: GraphNode[]
|
||||||
|
/** Interaction mode — changes visual treatment and click behavior */
|
||||||
|
mode?: GraphMode
|
||||||
|
/** Currently edited stage (highlighted in edit modes) */
|
||||||
|
activeStage?: string | null
|
||||||
|
/** Stages that have a region editor (bbox/polygon) */
|
||||||
|
regionStages?: string[]
|
||||||
|
}>(), {
|
||||||
|
mode: 'observe',
|
||||||
|
activeStage: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'open-region-editor': [stage: string]
|
||||||
|
'open-stage-editor': [stage: string]
|
||||||
|
'node-click': [stage: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const regionStageSet = computed(() => new Set(props.regionStages ?? []))
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
pending: 'var(--status-idle)',
|
||||||
|
running: 'var(--status-processing)',
|
||||||
|
done: 'var(--status-live)',
|
||||||
|
error: 'var(--status-error)',
|
||||||
|
skipped: '#4a6fa5',
|
||||||
|
placeholder: 'transparent',
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeAppearance(node: GraphNode) {
|
||||||
|
const isActive = node.id === props.activeStage
|
||||||
|
const mode = props.mode
|
||||||
|
|
||||||
|
// Edit-isolated: only the active node is fully visible
|
||||||
|
if (mode === 'edit-isolated' && !isActive) {
|
||||||
|
return {
|
||||||
|
color: 'var(--surface-3)',
|
||||||
|
textColor: 'var(--text-dim)',
|
||||||
|
opacity: 0.5,
|
||||||
|
outline: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit-in-pipeline: active node highlighted, upstream dimmed, downstream normal
|
||||||
|
if (mode === 'edit-in-pipeline' && props.activeStage) {
|
||||||
|
const activeIdx = props.nodes.findIndex(n => n.id === props.activeStage)
|
||||||
|
const nodeIdx = props.nodes.findIndex(n => n.id === node.id)
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
return {
|
||||||
|
color: 'var(--status-processing)',
|
||||||
|
textColor: '#fff',
|
||||||
|
opacity: 1,
|
||||||
|
outline: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nodeIdx < activeIdx) {
|
||||||
|
// Upstream: frozen from checkpoint
|
||||||
|
return {
|
||||||
|
color: 'var(--surface-3)',
|
||||||
|
textColor: 'var(--text-secondary)',
|
||||||
|
opacity: 0.7,
|
||||||
|
outline: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder: hollow, no text
|
||||||
|
if (node.status === 'placeholder') {
|
||||||
|
return {
|
||||||
|
color: 'transparent',
|
||||||
|
textColor: 'transparent',
|
||||||
|
opacity: 0.6,
|
||||||
|
outline: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: observe mode or downstream in edit-in-pipeline
|
||||||
|
return {
|
||||||
|
color: STATUS_COLORS[node.status] ?? STATUS_COLORS.pending,
|
||||||
|
textColor: '#fff',
|
||||||
|
opacity: 1,
|
||||||
|
outline: isActive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowNodes = computed(() =>
|
||||||
|
props.nodes.map((n, i) => {
|
||||||
|
const appearance = nodeAppearance(n)
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
type: 'stage',
|
||||||
|
position: { x: 20, y: i * 80 },
|
||||||
|
data: {
|
||||||
|
label: n.id.replace(/_/g, ' '),
|
||||||
|
status: n.status,
|
||||||
|
...appearance,
|
||||||
|
hasCheckpoint: n.hasCheckpoint ?? false,
|
||||||
|
hasStageEditor: regionStageSet.value.has(n.id),
|
||||||
|
isRunning: n.status === 'running',
|
||||||
|
isActive: n.id === props.activeStage,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const flowEdges = computed(() => {
|
||||||
|
const edges = []
|
||||||
|
for (let i = 0; i < props.nodes.length - 1; i++) {
|
||||||
|
const isActiveEdge = props.mode !== 'observe' && props.activeStage
|
||||||
|
&& props.nodes.findIndex(n => n.id === props.activeStage) > i
|
||||||
|
|
||||||
|
edges.push({
|
||||||
|
id: `${props.nodes[i].id}->${props.nodes[i + 1].id}`,
|
||||||
|
source: props.nodes[i].id,
|
||||||
|
target: props.nodes[i + 1].id,
|
||||||
|
animated: props.nodes[i].status === 'running',
|
||||||
|
style: {
|
||||||
|
stroke: isActiveEdge ? 'var(--text-dim)' : '#555568',
|
||||||
|
strokeDasharray: isActiveEdge ? '4 4' : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return edges
|
||||||
|
})
|
||||||
|
|
||||||
|
function onNodeClick(id: string) {
|
||||||
|
emit('node-click', id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="graph-renderer">
|
||||||
|
<VueFlow
|
||||||
|
:nodes="flowNodes"
|
||||||
|
:edges="flowEdges"
|
||||||
|
:fit-view-on-init="true"
|
||||||
|
:nodes-draggable="false"
|
||||||
|
:nodes-connectable="false"
|
||||||
|
:zoom-on-scroll="false"
|
||||||
|
:pan-on-scroll="false"
|
||||||
|
>
|
||||||
|
<template #node-stage="{ data, id }">
|
||||||
|
<div
|
||||||
|
class="stage-node"
|
||||||
|
:class="{
|
||||||
|
running: data.isRunning,
|
||||||
|
active: data.isActive,
|
||||||
|
outline: data.outline,
|
||||||
|
dimmed: data.opacity < 1,
|
||||||
|
placeholder: data.status === 'placeholder',
|
||||||
|
}"
|
||||||
|
:style="{
|
||||||
|
background: data.color,
|
||||||
|
color: data.textColor,
|
||||||
|
opacity: data.opacity,
|
||||||
|
}"
|
||||||
|
@click="onNodeClick(id)"
|
||||||
|
>
|
||||||
|
<span class="stage-label">{{ data.label }}</span>
|
||||||
|
|
||||||
|
<!-- Checkpoint indicator -->
|
||||||
|
<span v-if="data.hasCheckpoint" class="checkpoint-badge" title="Checkpoint available">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
|
||||||
|
<circle cx="5" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<circle cx="5" cy="5" r="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="stage-actions">
|
||||||
|
<button
|
||||||
|
v-if="data.hasStageEditor"
|
||||||
|
class="stage-btn editor-btn"
|
||||||
|
title="Stage editor"
|
||||||
|
@click.stop="emit('open-region-editor', id)"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<circle cx="5" cy="5" r="3.5"/><line x1="7.5" y1="7.5" x2="11" y2="11"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="stage-btn config-btn"
|
||||||
|
title="Stage config"
|
||||||
|
@click.stop="emit('open-stage-editor', id)"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<circle cx="6" cy="6" r="2"/><path d="M6 1v2M6 9v2M1 6h2M9 6h2M2.5 2.5l1.4 1.4M8.1 8.1l1.4 1.4M2.5 9.5l1.4-1.4M8.1 3.9l1.4-1.4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VueFlow>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.graph-renderer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-renderer :deep(.vue-flow__background) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide default node styling — we use custom template */
|
||||||
|
.graph-renderer :deep(.vue-flow__node-stage) {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--panel-radius);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 180px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-node.running {
|
||||||
|
animation: node-pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-node.outline {
|
||||||
|
box-shadow: 0 0 0 2px var(--status-processing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-node.dimmed {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-node.placeholder {
|
||||||
|
border: 1px dashed var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
color: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-node.placeholder .stage-actions,
|
||||||
|
.stage-node.placeholder .checkpoint-badge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkpoint-badge {
|
||||||
|
opacity: 0.7;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-node:hover .stage-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-btn {
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes node-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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>
|
||||||
122
ui/framework/src/renderers/TableRenderer.vue
Normal file
122
ui/framework/src/renderers/TableRenderer.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
export interface TableColumn {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
width?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
columns: TableColumn[]
|
||||||
|
rows: Record<string, unknown>[]
|
||||||
|
sortKey?: string
|
||||||
|
sortDir?: 'asc' | 'desc'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
sort: [key: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sorted = computed(() => {
|
||||||
|
if (!props.sortKey) return props.rows
|
||||||
|
const key = props.sortKey
|
||||||
|
const dir = props.sortDir === 'desc' ? -1 : 1
|
||||||
|
return [...props.rows].sort((a, b) => {
|
||||||
|
const av = a[key] as number | string
|
||||||
|
const bv = b[key] as number | string
|
||||||
|
if (av < bv) return -1 * dir
|
||||||
|
if (av > bv) return 1 * dir
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="table-renderer">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
v-for="col in columns"
|
||||||
|
:key="col.key"
|
||||||
|
:style="{ width: col.width }"
|
||||||
|
@click="emits('sort', col.key)"
|
||||||
|
class="sortable"
|
||||||
|
>
|
||||||
|
{{ col.label }}
|
||||||
|
<span v-if="sortKey === col.key" class="sort-indicator">
|
||||||
|
{{ sortDir === 'desc' ? '▼' : '▲' }}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, i) in sorted" :key="i">
|
||||||
|
<td v-for="col in columns" :key="col.key">
|
||||||
|
{{ row[col.key] }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="rows.length === 0">
|
||||||
|
<td :colspan="columns.length" class="empty">No detections yet</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.table-renderer {
|
||||||
|
overflow: auto;
|
||||||
|
height: 100%;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-bottom: var(--panel-border);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-indicator {
|
||||||
|
font-size: 9px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--surface-3);
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background: var(--surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
198
ui/framework/src/renderers/TimeSeriesRenderer.vue
Normal file
198
ui/framework/src/renderers/TimeSeriesRenderer.vue
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
|
import uPlot from 'uplot'
|
||||||
|
import 'uplot/dist/uPlot.min.css'
|
||||||
|
|
||||||
|
export interface TimeSeriesSeries {
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
/** Array of series configs (label + color) */
|
||||||
|
series: TimeSeriesSeries[]
|
||||||
|
/** Data: [timestamps[], series1[], series2[], ...] */
|
||||||
|
data: uPlot.AlignedData
|
||||||
|
/** Chart title (optional) */
|
||||||
|
title?: string
|
||||||
|
/** Stacked area mode */
|
||||||
|
stacked?: boolean
|
||||||
|
}>(), {
|
||||||
|
stacked: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const container = ref<HTMLElement | null>(null)
|
||||||
|
const zoomed = ref(false)
|
||||||
|
let chart: uPlot | null = null
|
||||||
|
|
||||||
|
function buildOpts(): uPlot.Options {
|
||||||
|
const seriesOpts: uPlot.Series[] = [
|
||||||
|
{ label: 'Time' },
|
||||||
|
...props.series.map((s) => ({
|
||||||
|
label: s.label,
|
||||||
|
stroke: s.color,
|
||||||
|
fill: props.stacked ? s.color + '40' : undefined,
|
||||||
|
width: 2,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: container.value?.clientWidth ?? 400,
|
||||||
|
height: container.value?.clientHeight ?? 200,
|
||||||
|
series: seriesOpts,
|
||||||
|
axes: [
|
||||||
|
{
|
||||||
|
stroke: '#555568',
|
||||||
|
grid: { stroke: '#2e2e3822' },
|
||||||
|
size: 40,
|
||||||
|
font: '10px monospace',
|
||||||
|
ticks: { size: 3 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stroke: '#555568',
|
||||||
|
grid: { stroke: '#2e2e3822' },
|
||||||
|
size: 35,
|
||||||
|
font: '10px monospace',
|
||||||
|
ticks: { size: 3 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cursor: { show: true },
|
||||||
|
legend: { show: true, live: false },
|
||||||
|
padding: [8, 8, 0, 0],
|
||||||
|
hooks: {
|
||||||
|
setScale: [(_self: uPlot, scaleKey: string) => {
|
||||||
|
if (scaleKey === 'x') zoomed.value = true
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetZoom() {
|
||||||
|
if (!chart) return
|
||||||
|
const data = chart.data
|
||||||
|
if (data && data[0] && data[0].length > 0) {
|
||||||
|
const min = data[0][0]
|
||||||
|
const max = data[0][data[0].length - 1]
|
||||||
|
chart.setScale('x', { min, max })
|
||||||
|
}
|
||||||
|
zoomed.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLegendHeight(): number {
|
||||||
|
if (!container.value) return 0
|
||||||
|
const legend = container.value.querySelector('.u-legend') as HTMLElement | null
|
||||||
|
return legend ? legend.offsetHeight : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChart() {
|
||||||
|
if (!container.value) return
|
||||||
|
if (chart) chart.destroy()
|
||||||
|
chart = new uPlot(buildOpts(), props.data, container.value)
|
||||||
|
// Refit after legend renders
|
||||||
|
nextTick(() => resize())
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
if (!chart || !container.value) return
|
||||||
|
const legendH = getLegendHeight()
|
||||||
|
const availableH = container.value.clientHeight
|
||||||
|
// uPlot height = canvas height (chart sets total = canvas + legend)
|
||||||
|
const chartH = Math.max(60, availableH - legendH)
|
||||||
|
chart.setSize({
|
||||||
|
width: container.value.clientWidth,
|
||||||
|
height: chartH,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.data, (newData) => {
|
||||||
|
if (chart) {
|
||||||
|
chart.setData(newData)
|
||||||
|
} else {
|
||||||
|
nextTick(createChart)
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(createChart)
|
||||||
|
const observer = new ResizeObserver(resize)
|
||||||
|
if (container.value) observer.observe(container.value)
|
||||||
|
onUnmounted(() => {
|
||||||
|
observer.disconnect()
|
||||||
|
chart?.destroy()
|
||||||
|
chart = null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="timeseries-wrapper">
|
||||||
|
<button v-if="zoomed" class="reset-zoom" @click="resetZoom" title="Reset zoom">⟲</button>
|
||||||
|
<div ref="container" class="timeseries-renderer" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.timeseries-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-zoom {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--surface-3);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-zoom:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeseries-renderer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* uPlot creates a .u-wrap for canvas + a .u-legend below it */
|
||||||
|
.timeseries-renderer :deep(.u-wrap) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeseries-renderer :deep(.u-legend) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeseries-renderer :deep(.u-legend) {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 2px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeseries-renderer :deep(.u-legend .u-series) {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
59
ui/framework/src/tokens.css
Normal file
59
ui/framework/src/tokens.css
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/* Framework design tokens — retheme by replacing this file */
|
||||||
|
:root {
|
||||||
|
/* spacing scale (4px base) */
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 32px;
|
||||||
|
|
||||||
|
/* color — dark theme (observability UIs are always dark) */
|
||||||
|
--surface-0: #0d0d0f;
|
||||||
|
--surface-1: #16161a;
|
||||||
|
--surface-2: #1e1e24;
|
||||||
|
--surface-3: #26262f;
|
||||||
|
--border: #2e2e38;
|
||||||
|
|
||||||
|
--text-primary: #e8e8f0;
|
||||||
|
--text-secondary: #8888a0;
|
||||||
|
--text-dim: #555568;
|
||||||
|
|
||||||
|
/* status colors */
|
||||||
|
--status-idle: #555568;
|
||||||
|
--status-live: #3ecf8e;
|
||||||
|
--status-processing: #4f9cf9;
|
||||||
|
--status-escalating: #f5a623;
|
||||||
|
--status-error: #f06565;
|
||||||
|
|
||||||
|
/* confidence color scale (low → high) */
|
||||||
|
--conf-low: #f06565;
|
||||||
|
--conf-mid: #f5a623;
|
||||||
|
--conf-high: #3ecf8e;
|
||||||
|
|
||||||
|
/* typography */
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
--font-ui: 'Inter', system-ui, sans-serif;
|
||||||
|
--font-size-sm: 11px;
|
||||||
|
--font-size-base: 13px;
|
||||||
|
--font-size-lg: 15px;
|
||||||
|
|
||||||
|
/* panel chrome */
|
||||||
|
--panel-radius: 6px;
|
||||||
|
--panel-border: 1px solid var(--border);
|
||||||
|
--panel-header-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated gradient outline for buttons in a waiting state.
|
||||||
|
Usage: add class="waiting" to any button/element. */
|
||||||
|
@keyframes waiting-glow {
|
||||||
|
0% { box-shadow: 0 0 3px 1px var(--status-processing); }
|
||||||
|
33% { box-shadow: 0 0 3px 1px var(--status-live); }
|
||||||
|
66% { box-shadow: 0 0 3px 1px var(--status-escalating); }
|
||||||
|
100% { box-shadow: 0 0 3px 1px var(--status-processing); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.waiting {
|
||||||
|
animation: waiting-glow 2s linear infinite;
|
||||||
|
outline: 1px solid transparent;
|
||||||
|
}
|
||||||
18
ui/framework/tsconfig.json
Normal file
18
ui/framework/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
7
ui/framework/vitest.config.ts
Normal file
7
ui/framework/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user