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,66 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface Toast {
id: string
type: 'success' | 'error' | 'warning' | 'info'
message: string
duration?: number
}
export const useToastStore = defineStore('toast', () => {
const toasts = ref<Toast[]>([])
function addToast(toast: Omit<Toast, 'id'>) {
const id = crypto.randomUUID()
const duration = toast.duration ?? (toast.type === 'error' ? 8000 : 5000)
const newToast: Toast = {
...toast,
id,
duration,
}
toasts.value.push(newToast)
// Auto-remove after duration
if (duration > 0) {
setTimeout(() => {
removeToast(id)
}, duration)
}
}
function removeToast(id: string) {
const index = toasts.value.findIndex(t => t.id === id)
if (index !== -1) {
toasts.value.splice(index, 1)
}
}
function success(message: string, duration?: number) {
addToast({ type: 'success', message, duration })
}
function error(message: string, duration?: number) {
addToast({ type: 'error', message, duration })
}
function warning(message: string, duration?: number) {
addToast({ type: 'warning', message, duration })
}
function info(message: string, duration?: number) {
addToast({ type: 'info', message, duration })
}
return {
toasts,
addToast,
removeToast,
success,
error,
warning,
info,
}
})

View File

@@ -1,31 +1,286 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import type { WipeProfile, WipeSchedule, WipeHistory } from '@/types'
import { useApi } from '@/composables/useApi'
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
export const useWipeStore = defineStore('wipe', () => {
const api = useApi()
const authStore = useAuthStore()
const websocket = useWebSocket()
const toast = useToastStore()
const profiles = ref<WipeProfile[]>([])
const schedules = ref<WipeSchedule[]>([])
const history = ref<WipeHistory[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
// WebSocket event handler
function handleWebSocketMessage(message: WebSocketMessage) {
if (message.type !== 'event') return
const { event, data } = message
switch (event) {
case 'wipe_started':
// Update wipe history with new status
if (data?.wipe_history_id) {
updateWipeHistoryItem(data)
}
toast.info(`Wipe started: ${data?.wipe_type || 'unknown'} wipe in progress`)
break
case 'wipe_status':
// Update wipe history with status progress
if (data?.wipe_history_id) {
updateWipeHistoryItem(data)
}
break
case 'wipe_completed':
toast.success(`Wipe completed successfully in ${data?.duration || '?'} seconds`)
// Refresh history to get final state
fetchHistory().catch(console.error)
break
case 'wipe_failed':
toast.error(`Wipe failed: ${data?.error_message || 'Unknown error'}`, 10000)
// Refresh history to get final state
fetchHistory().catch(console.error)
break
default:
// Ignore other events
break
}
}
// Update a single wipe history item from WebSocket data
function updateWipeHistoryItem(data: any) {
const id = data.wipe_history_id || data.id
if (!id) return
const index = history.value.findIndex(h => h.id === id)
if (index !== -1 && history.value[index]) {
// Update existing item (merge partial data)
const current = history.value[index]
// Only update fields that are present in data
if (data.status) current.status = data.status
if (data.started_at) current.started_at = data.started_at
if (data.completed_at) current.completed_at = data.completed_at
if (data.error_message !== undefined) current.error_message = data.error_message
if (data.map_used) current.map_used = data.map_used
if (data.plugins_wiped) current.plugins_wiped = data.plugins_wiped
if (data.plugins_preserved) current.plugins_preserved = data.plugins_preserved
} else {
// Add new item if it doesn't exist (shouldn't happen, but defensive)
fetchHistory().catch(console.error)
}
}
// Subscribe to WebSocket events when store is initialized
onMounted(() => {
websocket.subscribe(handleWebSocketMessage)
})
async function fetchProfiles() {
// TODO: GET /api/profiles
isLoading.value = true
error.value = null
try {
const data = await api.get<WipeProfile[]>('/profiles')
profiles.value = data
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch profiles'
console.error('Failed to fetch wipe profiles:', err)
throw err
} finally {
isLoading.value = false
}
}
async function fetchSchedules() {
// TODO: GET /api/schedules
isLoading.value = true
error.value = null
try {
const data = await api.get<WipeSchedule[]>('/schedules')
schedules.value = data
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch schedules'
console.error('Failed to fetch wipe schedules:', err)
throw err
} finally {
isLoading.value = false
}
}
async function fetchHistory() {
// TODO: GET /api/wipes/history
async function fetchHistory(limit = 50) {
isLoading.value = true
error.value = null
try {
const licenseId = authStore.license?.id
if (!licenseId) {
throw new Error('No license ID available')
}
const data = await api.get<WipeHistory[]>(`/wipes/history?limit=${limit}`)
history.value = data
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch wipe history'
console.error('Failed to fetch wipe history:', err)
throw err
} finally {
isLoading.value = false
}
}
async function triggerWipe(_wipeType: string, _profileId: string) {
// TODO: POST /api/wipes/:server_id/trigger
async function triggerWipe(
wipeType: 'map' | 'blueprint' | 'full',
profileId: string
): Promise<{ wipe_history_id: string }> {
isLoading.value = true
error.value = null
try {
const licenseId = authStore.license?.id
if (!licenseId) {
throw new Error('No license ID available')
}
const result = await api.post<{ wipe_history_id: string }>(
`/wipes/trigger`,
{
license_id: licenseId,
wipe_type: wipeType,
profile_id: profileId,
trigger_type: 'manual',
}
)
// Optimistically add pending wipe to history
const newWipe: WipeHistory = {
id: result.wipe_history_id,
wipe_type: wipeType,
trigger_type: 'manual',
status: 'pending',
started_at: null,
completed_at: null,
map_used: null,
plugins_wiped: [],
plugins_preserved: [],
error_message: null,
}
history.value.unshift(newWipe)
toast.success(`${wipeType.charAt(0).toUpperCase() + wipeType.slice(1)} wipe triggered successfully`)
return result
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to trigger wipe'
console.error('Failed to trigger wipe:', err)
throw err
} finally {
isLoading.value = false
}
}
async function triggerDryRun(_wipeType: string, _profileId: string) {
// TODO: POST /api/wipes/:server_id/dry-run
async function triggerDryRun(
wipeType: 'map' | 'blueprint' | 'full',
profileId: string
): Promise<{
would_delete: string[]
would_preserve: string[]
estimated_duration_seconds: number
}> {
isLoading.value = true
error.value = null
try {
const licenseId = authStore.license?.id
if (!licenseId) {
throw new Error('No license ID available')
}
const result = await api.post<{
would_delete: string[]
would_preserve: string[]
estimated_duration_seconds: number
}>(
`/wipes/dry-run`,
{
license_id: licenseId,
wipe_type: wipeType,
profile_id: profileId,
}
)
return result
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to run dry-run'
console.error('Failed to run dry-run:', err)
throw err
} finally {
isLoading.value = false
}
}
async function createProfile(profile: Omit<WipeProfile, 'id' | 'license_id'>): Promise<WipeProfile> {
isLoading.value = true
error.value = null
try {
const licenseId = authStore.license?.id
if (!licenseId) {
throw new Error('No license ID available')
}
const newProfile = await api.post<WipeProfile>('/profiles', {
...profile,
license_id: licenseId,
})
profiles.value.push(newProfile)
return newProfile
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to create profile'
console.error('Failed to create profile:', err)
throw err
} finally {
isLoading.value = false
}
}
async function updateProfile(id: string, updates: Partial<WipeProfile>): Promise<void> {
isLoading.value = true
error.value = null
try {
const updated = await api.put<WipeProfile>(`/profiles/${id}`, updates)
const index = profiles.value.findIndex(p => p.id === id)
if (index !== -1) {
profiles.value[index] = updated
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to update profile'
console.error('Failed to update profile:', err)
throw err
} finally {
isLoading.value = false
}
}
async function deleteProfile(id: string): Promise<void> {
isLoading.value = true
error.value = null
try {
await api.del(`/profiles/${id}`)
profiles.value = profiles.value.filter(p => p.id !== id)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to delete profile'
console.error('Failed to delete profile:', err)
throw err
} finally {
isLoading.value = false
}
}
return {
@@ -33,10 +288,14 @@ export const useWipeStore = defineStore('wipe', () => {
schedules,
history,
isLoading,
error,
fetchProfiles,
fetchSchedules,
fetchHistory,
triggerWipe,
triggerDryRun,
createProfile,
updateProfile,
deleteProfile,
}
})