phase 1
This commit is contained in:
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>,
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
94
ui/framework/src/datasources/SSEDataSource.ts
Normal file
94
ui/framework/src/datasources/SSEDataSource.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
})
|
||||
})
|
||||
5
ui/framework/src/index.ts
Normal file
5
ui/framework/src/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user