Full Vue 3 + Vite + TypeScript + Tailwind SPA replacing the vanilla JS static frontend. Includes router, Pinia stores (auth/tasks/calendar/devops), axios API client with all endpoints, UI components (Button/Card/Dialog/Badge/Input/etc), calendar grid with lane-packing algorithm and DnD support, SSE live feed, and all 11 views. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
105 lines
2.6 KiB
TypeScript
105 lines
2.6 KiB
TypeScript
import { ref, onUnmounted } from 'vue'
|
|
|
|
export interface SSEEvent {
|
|
type: string
|
|
data: unknown
|
|
}
|
|
|
|
export function useSSE(url: string) {
|
|
const events = ref<SSEEvent[]>([])
|
|
const connected = ref(false)
|
|
const error = ref<string | null>(null)
|
|
let es: EventSource | null = null
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
let closed = false
|
|
|
|
function connect() {
|
|
if (closed) return
|
|
try {
|
|
es = new EventSource(url)
|
|
|
|
es.onopen = () => {
|
|
connected.value = true
|
|
error.value = null
|
|
}
|
|
|
|
es.onmessage = (e) => {
|
|
try {
|
|
const parsed = JSON.parse(e.data)
|
|
events.value.push({ type: 'message', data: parsed })
|
|
// Keep last 200 events
|
|
if (events.value.length > 200) events.value.shift()
|
|
} catch {
|
|
events.value.push({ type: 'message', data: e.data })
|
|
}
|
|
}
|
|
|
|
es.addEventListener('session_start', (e: MessageEvent) => {
|
|
try {
|
|
events.value.push({ type: 'session_start', data: JSON.parse(e.data) })
|
|
} catch {
|
|
events.value.push({ type: 'session_start', data: e.data })
|
|
}
|
|
if (events.value.length > 200) events.value.shift()
|
|
})
|
|
|
|
es.addEventListener('session_end', (e: MessageEvent) => {
|
|
try {
|
|
events.value.push({ type: 'session_end', data: JSON.parse(e.data) })
|
|
} catch {
|
|
events.value.push({ type: 'session_end', data: e.data })
|
|
}
|
|
if (events.value.length > 200) events.value.shift()
|
|
})
|
|
|
|
es.addEventListener('activity', (e: MessageEvent) => {
|
|
try {
|
|
events.value.push({ type: 'activity', data: JSON.parse(e.data) })
|
|
} catch {
|
|
events.value.push({ type: 'activity', data: e.data })
|
|
}
|
|
if (events.value.length > 200) events.value.shift()
|
|
})
|
|
|
|
es.onerror = () => {
|
|
connected.value = false
|
|
error.value = 'Connection lost, reconnecting...'
|
|
es?.close()
|
|
es = null
|
|
if (!closed) {
|
|
reconnectTimer = setTimeout(() => connect(), 5000)
|
|
}
|
|
}
|
|
} catch (err) {
|
|
error.value = 'Failed to connect to event stream'
|
|
if (!closed) {
|
|
reconnectTimer = setTimeout(() => connect(), 5000)
|
|
}
|
|
}
|
|
}
|
|
|
|
function disconnect() {
|
|
closed = true
|
|
if (reconnectTimer) clearTimeout(reconnectTimer)
|
|
es?.close()
|
|
es = null
|
|
connected.value = false
|
|
}
|
|
|
|
function clearEvents() {
|
|
events.value = []
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
disconnect()
|
|
})
|
|
|
|
return {
|
|
events,
|
|
connected,
|
|
error,
|
|
connect,
|
|
disconnect,
|
|
clearEvents,
|
|
}
|
|
}
|