This commit is contained in:
2026-03-23 09:58:40 -03:00
parent 9c9c7dff09
commit 8186bb5fe6
40 changed files with 3996 additions and 17 deletions

23
ui/framework/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "mpr-ui-framework",
"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": "^3.5",
"pinia": "^2.2"
},
"devDependencies": {
"typescript": "^5.6",
"vitest": "^2",
"vue-tsc": "^2",
"vite": "^6",
"@vitejs/plugin-vue": "^5"
}
}

1558
ui/framework/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View 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>,
}
}

View 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))
}
}

View File

@@ -0,0 +1,94 @@
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
}
})
}
// Also listen to the generic 'done' terminal event
this.es.addEventListener('done', () => {
this.status.value = 'idle'
this.disconnect()
})
}
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()
}
}
}

View 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'
}
}

View 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()
})
})

View File

@@ -0,0 +1,5 @@
// 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'

View 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"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
},
})