feat: Complete Phase 1 frontend — WebSocket + Wipe feature end-to-end

Implements full-stack vertical slice for wipe management with real-time updates.

WebSocket Integration:
- useWebSocket composable with auto-reconnect (exponential backoff up to 30s)
- JWT authentication via query parameter
- Automatic connection on auth state change
- Bi-directional messaging support
- Message handler subscription pattern
- Vite dev proxy configured for WebSocket (ws: true)

Toast Notification System:
- Pinia store with convenience methods (success/error/warning/info)
- Vue component with Lucide icons and Tailwind styling
- Auto-dismiss with configurable duration (5s default, 8s for errors)
- Manual dismiss with X button
- Smooth slide-in transitions from bottom-right
- Stack multiple toasts with proper spacing

Wipe Store Implementation:
- All API methods: fetchProfiles, fetchSchedules, fetchHistory
- Trigger wipe with optimistic UI update
- Dry-run simulation endpoint
- Profile CRUD operations (create, update, delete)
- WebSocket event listeners for real-time status updates
- Toast notifications on wipe_started, wipe_completed, wipe_failed
- Automatic history refresh on completion events
- Error handling with user-facing messages

Real-time Event Flow:
1. User triggers wipe → POST /api/wipes/trigger
2. Backend publishes NATS event: corrosion.{license_id}.wipe_started
3. WebSocket forwards event to frontend
4. Wipe store updates history array
5. Toast notification shows "Wipe started"
6. Progress events update status in real-time
7. Completion event triggers success toast + history refresh

Files Created:
- frontend/src/composables/useWebSocket.ts (208 LOC)
- frontend/src/stores/toast.ts (63 LOC)
- frontend/src/components/ToastNotification.vue (47 LOC)

Files Modified:
- frontend/src/stores/wipe.ts (273 LOC, was 42 LOC — 5 TODO methods → fully implemented)
- frontend/src/App.vue (added ToastNotification component)
- frontend/vite.config.ts (enabled WebSocket proxy)

TypeScript: Strict mode, zero build errors
Frontend builds:  929ms, 45.86 kB gzip

Phase 1 Status: ~80% complete
-  WebSocket/NATS real-time layer
-  Wipe feature production-ready
- ⏸️ Remaining stores (plugins, chat, players) still stubbed

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-15 12:17:31 -05:00
parent 8320591cf4
commit c5d057146a
6 changed files with 600 additions and 9 deletions

View File

@@ -0,0 +1,213 @@
import { ref, computed, watch, onUnmounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
export interface WebSocketMessage {
type: 'connected' | 'event' | 'error'
license_id?: string
subscribed_to?: string
event?: string
subject?: string
data?: any
raw?: string
message?: string
}
type MessageHandler = (message: WebSocketMessage) => void
export function useWebSocket() {
const authStore = useAuthStore()
const ws = ref<WebSocket | null>(null)
const isConnected = ref(false)
const isConnecting = ref(false)
const error = ref<string | null>(null)
const reconnectAttempts = ref(0)
const maxReconnectAttempts = 10
const baseReconnectDelay = 1000 // 1 second
const messageHandlers = new Set<MessageHandler>()
let reconnectTimeout: number | null = null
const status = computed(() => {
if (isConnected.value) return 'connected'
if (isConnecting.value) return 'connecting'
if (error.value) return 'error'
return 'disconnected'
})
function getWebSocketUrl(): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = import.meta.env.PROD
? window.location.host
: 'localhost:3000'
const token = authStore.accessToken
if (!token) {
throw new Error('No access token available for WebSocket authentication')
}
return `${protocol}//${host}/api/ws?token=${encodeURIComponent(token)}`
}
function connect() {
if (!authStore.isAuthenticated) {
console.log('[WebSocket] Not authenticated, skipping connection')
return
}
if (isConnecting.value || isConnected.value) {
console.log('[WebSocket] Already connecting or connected')
return
}
try {
isConnecting.value = true
error.value = null
const url = getWebSocketUrl()
console.log('[WebSocket] Connecting to', url.replace(/token=[^&]+/, 'token=***'))
ws.value = new WebSocket(url)
ws.value.onopen = () => {
console.log('[WebSocket] Connected')
isConnected.value = true
isConnecting.value = false
reconnectAttempts.value = 0
error.value = null
}
ws.value.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data)
console.log('[WebSocket] Message received:', message)
// Broadcast to all handlers
messageHandlers.forEach(handler => {
try {
handler(message)
} catch (err) {
console.error('[WebSocket] Handler error:', err)
}
})
} catch (err) {
console.error('[WebSocket] Failed to parse message:', err)
}
}
ws.value.onerror = (event) => {
console.error('[WebSocket] Error:', event)
error.value = 'WebSocket connection error'
isConnecting.value = false
}
ws.value.onclose = (event) => {
console.log('[WebSocket] Closed:', event.code, event.reason)
isConnected.value = false
isConnecting.value = false
// Attempt reconnect if not a clean close or max attempts not reached
if (!event.wasClean && reconnectAttempts.value < maxReconnectAttempts) {
scheduleReconnect()
} else if (reconnectAttempts.value >= maxReconnectAttempts) {
error.value = 'Max reconnection attempts reached'
}
}
} catch (err) {
console.error('[WebSocket] Connection failed:', err)
error.value = err instanceof Error ? err.message : 'Unknown connection error'
isConnecting.value = false
scheduleReconnect()
}
}
function scheduleReconnect() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
}
reconnectAttempts.value++
const delay = Math.min(
baseReconnectDelay * Math.pow(2, reconnectAttempts.value - 1),
30000 // Max 30 seconds
)
console.log(
`[WebSocket] Reconnecting in ${delay}ms (attempt ${reconnectAttempts.value}/${maxReconnectAttempts})`
)
reconnectTimeout = window.setTimeout(() => {
connect()
}, delay)
}
function disconnect() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
if (ws.value) {
console.log('[WebSocket] Disconnecting')
ws.value.close(1000, 'Client disconnect')
ws.value = null
}
isConnected.value = false
isConnecting.value = false
reconnectAttempts.value = 0
}
function send(data: any) {
if (!ws.value || !isConnected.value) {
console.warn('[WebSocket] Cannot send, not connected')
return false
}
try {
ws.value.send(JSON.stringify(data))
return true
} catch (err) {
console.error('[WebSocket] Send failed:', err)
return false
}
}
function subscribe(handler: MessageHandler) {
messageHandlers.add(handler)
// Return unsubscribe function
return () => {
messageHandlers.delete(handler)
}
}
// Auto-connect when authenticated
watch(
() => authStore.isAuthenticated,
(authenticated) => {
if (authenticated) {
connect()
} else {
disconnect()
}
},
{ immediate: true }
)
// Cleanup on unmount
onUnmounted(() => {
disconnect()
})
return {
isConnected,
isConnecting,
status,
error,
connect,
disconnect,
send,
subscribe,
}
}